-- 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 "} 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