| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416 |
- -- projectile.lua
- -- ============================================================================
- -- Project management and navigation package.
- -- Similar to Emacs projectile package.
- --
- -- Features:
- -- - Project root detection (via .git, .projectile, etc.)
- -- - Project-scoped file finding
- -- - Recent projects list
- -- - Project-local commands
- -- ============================================================================
- local projectile = {}
- -- Configuration
- projectile.config = {
- -- Markers that indicate a project root
- project_markers = {
- ".git",
- ".projectile",
- ".project",
- "Makefile",
- "CMakeLists.txt",
- "package.json",
- "Cargo.toml",
- "go.mod",
- "pyproject.toml",
- "setup.py",
- ".hg",
- ".svn",
- },
- -- File patterns to ignore when listing project files
- ignore_patterns = {
- "^%.git/",
- "^%.hg/",
- "^%.svn/",
- "^node_modules/",
- "^build/",
- "^dist/",
- "^target/",
- "^%.cache/",
- "^__pycache__/",
- "%.pyc$",
- "%.o$",
- "%.a$",
- "%.so$",
- "%.dylib$",
- },
- -- Max files to scan in a project
- max_files = 5000,
- -- Max depth to search for project root
- max_depth = 10,
- -- Cache project file lists
- cache_enabled = true,
- }
- -- State
- projectile.current_project = nil
- projectile.recent_projects = {}
- projectile.project_cache = {} -- project_root -> {files, timestamp}
- -- Helper: Check if a path exists
- local function path_exists(path)
- local f = io.open(path, "r")
- if f then
- f:close()
- return true
- end
- return false
- end
- -- Helper: Check if path is a directory (heuristic)
- local function is_directory(path)
- -- Try to open as directory by appending /. and checking
- local f = io.open(path .. "/.", "r")
- if f then
- f:close()
- return true
- end
- return false
- end
- -- Helper: Get parent directory
- local function parent_dir(path)
- return path:match("(.+)/[^/]+$") or "/"
- end
- -- Helper: Get basename
- local function basename(path)
- return path:match("[^/]+$") or path
- end
- -- Helper: Join paths
- local function join_path(...)
- local parts = {...}
- local result = table.concat(parts, "/")
- -- Normalize double slashes
- result = result:gsub("//+", "/")
- return result
- end
- -- Find project root from a given path
- function projectile.find_project_root(start_path)
- local path = start_path or os.getenv("PWD") or "."
- local depth = 0
- while path and path ~= "/" and depth < projectile.config.max_depth do
- for _, marker in ipairs(projectile.config.project_markers) do
- local marker_path = join_path(path, marker)
- if path_exists(marker_path) or is_directory(marker_path) then
- return path
- end
- end
- path = parent_dir(path)
- depth = depth + 1
- end
- return nil
- end
- -- Get current project root (from current buffer's file path)
- function projectile.get_current_project()
- local buf = editor.buffer
- local filepath = buf:filepath()
- if filepath and filepath ~= "" then
- local dir = parent_dir(filepath)
- return projectile.find_project_root(dir)
- end
- -- Fallback to working directory
- return projectile.find_project_root()
- end
- -- Check if a file matches ignore patterns
- local function should_ignore(relative_path)
- for _, pattern in ipairs(projectile.config.ignore_patterns) do
- if relative_path:match(pattern) then
- return true
- end
- end
- return false
- end
- -- List files in project using shell command
- function projectile.list_project_files(project_root)
- if not project_root then
- return {}
- end
- -- Check cache
- if projectile.config.cache_enabled then
- local cached = projectile.project_cache[project_root]
- if cached then
- -- Use cache if less than 30 seconds old
- local age = os.time() - cached.timestamp
- if age < 30 then
- return cached.files
- end
- end
- end
- -- Use find command to list files
- local cmd = string.format(
- "find '%s' -type f -not -path '*/.git/*' -not -path '*/node_modules/*' -not -path '*/build/*' 2>/dev/null | head -%d",
- project_root,
- projectile.config.max_files
- )
- local handle = io.popen(cmd)
- if not handle then
- return {}
- end
- local files = {}
- local prefix_len = #project_root + 2 -- +2 for trailing / and 1-based index
- for line in handle:lines() do
- local relative = line:sub(prefix_len)
- if relative and relative ~= "" and not should_ignore(relative) then
- table.insert(files, relative)
- end
- end
- handle:close()
- -- Sort files
- table.sort(files)
- -- Cache results
- if projectile.config.cache_enabled then
- projectile.project_cache[project_root] = {
- files = files,
- timestamp = os.time()
- }
- end
- return files
- end
- -- Add project to recent list
- function projectile.add_recent_project(project_root)
- if not project_root then return end
- -- Remove if already in list
- for i, p in ipairs(projectile.recent_projects) do
- if p == project_root then
- table.remove(projectile.recent_projects, i)
- break
- end
- end
- -- Add to front
- table.insert(projectile.recent_projects, 1, project_root)
- -- Keep only last 20
- while #projectile.recent_projects > 20 do
- table.remove(projectile.recent_projects)
- end
- end
- -- Switch to project
- function projectile.switch_project(project_root)
- projectile.current_project = project_root
- projectile.add_recent_project(project_root)
- editor:message("Switched to project: " .. project_root)
- end
- -- Find file in project
- function projectile.find_file_in_project(project_root, pattern)
- local files = projectile.list_project_files(project_root)
- local matches = {}
- pattern = pattern:lower()
- for _, file in ipairs(files) do
- if file:lower():find(pattern, 1, true) then
- table.insert(matches, file)
- end
- end
- return matches
- end
- -- Clear project cache
- function projectile.invalidate_cache(project_root)
- if project_root then
- projectile.project_cache[project_root] = nil
- else
- projectile.project_cache = {}
- end
- end
- -- Get project name from root
- function projectile.get_project_name(project_root)
- return basename(project_root)
- end
- -- ============================================================================
- -- Commands
- -- ============================================================================
- -- Find file in current project
- editor:register_command("projectile-find-file", "Find file in current project", function(args)
- local project_root = projectile.get_current_project()
- if not project_root then
- return {success = false, message = "Not in a project"}
- end
- projectile.switch_project(project_root)
- local files = projectile.list_project_files(project_root)
- if #files == 0 then
- return {success = false, message = "No files found in project"}
- end
- -- If pattern provided, filter immediately
- if #args > 0 then
- local matches = projectile.find_file_in_project(project_root, args[1])
- if #matches == 0 then
- return {success = false, message = "No matches for: " .. args[1]}
- elseif #matches == 1 then
- local filepath = join_path(project_root, matches[1])
- editor:load_file(filepath)
- return {success = true, message = "Opened: " .. matches[1]}
- else
- -- Show matches
- local display = {}
- for i, m in ipairs(matches) do
- if i > 10 then
- table.insert(display, "... and " .. (#matches - 10) .. " more")
- break
- end
- table.insert(display, m)
- end
- return {success = true, message = "Matches:\n" .. table.concat(display, "\n")}
- end
- end
- -- Show file count
- return {success = true, message = "Project: " .. projectile.get_project_name(project_root) .. " (" .. #files .. " files)"}
- end, {"pf"}, true, "s")
- -- Switch to known project
- editor:register_command("projectile-switch-project", "Switch to a known project", function(args)
- if #projectile.recent_projects == 0 then
- return {success = false, message = "No recent projects"}
- end
- if #args > 0 then
- -- Find project matching pattern
- local pattern = args[1]:lower()
- for _, project in ipairs(projectile.recent_projects) do
- if project:lower():find(pattern, 1, true) then
- projectile.switch_project(project)
- return {success = true, message = "Switched to: " .. projectile.get_project_name(project)}
- end
- end
- return {success = false, message = "No project matching: " .. args[1]}
- end
- -- Show recent projects
- local display = {"Recent projects:"}
- for i, project in ipairs(projectile.recent_projects) do
- table.insert(display, i .. ". " .. projectile.get_project_name(project))
- end
- return {success = true, message = table.concat(display, "\n")}
- end, {"pp"}, true, "s")
- -- Show current project info
- editor:register_command("projectile-project-info", "Show current project information", function(args)
- local project_root = projectile.get_current_project()
- if not project_root then
- return {success = false, message = "Not in a project"}
- end
- local files = projectile.list_project_files(project_root)
- local info = {
- "Project: " .. projectile.get_project_name(project_root),
- "Root: " .. project_root,
- "Files: " .. #files,
- }
- return {success = true, message = table.concat(info, "\n")}
- end, {"pi"})
- -- Invalidate project cache
- editor:register_command("projectile-invalidate-cache", "Clear project file cache", function(args)
- local project_root = projectile.get_current_project()
- projectile.invalidate_cache(project_root)
- return {success = true, message = "Project cache cleared"}
- end, {"pic"})
- -- Add current directory as project
- editor:register_command("projectile-add-known-project", "Add current directory as a known project", function(args)
- local project_root
- if #args > 0 then
- project_root = args[1]
- else
- project_root = projectile.get_current_project() or os.getenv("PWD")
- end
- if project_root then
- projectile.add_recent_project(project_root)
- return {success = true, message = "Added project: " .. project_root}
- else
- return {success = false, message = "Could not determine project root"}
- end
- end, {"pak"}, true, "s")
- -- Run command in project root
- editor:register_command("projectile-run-command", "Run shell command in project root", function(args)
- local project_root = projectile.get_current_project()
- if not project_root then
- return {success = false, message = "Not in a project"}
- end
- if #args == 0 then
- return {success = false, message = "Usage: projectile-run-command <command>"}
- end
- local cmd = table.concat(args, " ")
- local full_cmd = string.format("cd '%s' && %s 2>&1", project_root, cmd)
- local handle = io.popen(full_cmd)
- if not handle then
- return {success = false, message = "Failed to run command"}
- end
- local output = handle:read("*a")
- handle:close()
- -- Truncate long output
- if #output > 500 then
- output = output:sub(1, 500) .. "\n... (truncated)"
- end
- return {success = true, message = output}
- end, {"prc"}, true, "s")
- -- Keybindings
- editor:bind_key("C-c p f", "projectile-find-file", "Find file in project")
- editor:bind_key("C-c p p", "projectile-switch-project", "Switch project")
- editor:bind_key("C-c p i", "projectile-project-info", "Project info")
- -- Store module
- lumacs.projectile = projectile
- print("[projectile] Package loaded")
- return projectile
|