init.lua 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  1. -- projectile.lua
  2. -- ============================================================================
  3. -- Project management and navigation package.
  4. -- Similar to Emacs projectile package.
  5. --
  6. -- Features:
  7. -- - Project root detection (via .git, .projectile, etc.)
  8. -- - Project-scoped file finding
  9. -- - Recent projects list
  10. -- - Project-local commands
  11. -- ============================================================================
  12. local projectile = {}
  13. -- Configuration
  14. projectile.config = {
  15. -- Markers that indicate a project root
  16. project_markers = {
  17. ".git",
  18. ".projectile",
  19. ".project",
  20. "Makefile",
  21. "CMakeLists.txt",
  22. "package.json",
  23. "Cargo.toml",
  24. "go.mod",
  25. "pyproject.toml",
  26. "setup.py",
  27. ".hg",
  28. ".svn",
  29. },
  30. -- File patterns to ignore when listing project files
  31. ignore_patterns = {
  32. "^%.git/",
  33. "^%.hg/",
  34. "^%.svn/",
  35. "^node_modules/",
  36. "^build/",
  37. "^dist/",
  38. "^target/",
  39. "^%.cache/",
  40. "^__pycache__/",
  41. "%.pyc$",
  42. "%.o$",
  43. "%.a$",
  44. "%.so$",
  45. "%.dylib$",
  46. },
  47. -- Max files to scan in a project
  48. max_files = 5000,
  49. -- Max depth to search for project root
  50. max_depth = 10,
  51. -- Cache project file lists
  52. cache_enabled = true,
  53. }
  54. -- State
  55. projectile.current_project = nil
  56. projectile.recent_projects = {}
  57. projectile.project_cache = {} -- project_root -> {files, timestamp}
  58. -- Helper: Check if a path exists
  59. local function path_exists(path)
  60. local f = io.open(path, "r")
  61. if f then
  62. f:close()
  63. return true
  64. end
  65. return false
  66. end
  67. -- Helper: Check if path is a directory (heuristic)
  68. local function is_directory(path)
  69. -- Try to open as directory by appending /. and checking
  70. local f = io.open(path .. "/.", "r")
  71. if f then
  72. f:close()
  73. return true
  74. end
  75. return false
  76. end
  77. -- Helper: Get parent directory
  78. local function parent_dir(path)
  79. return path:match("(.+)/[^/]+$") or "/"
  80. end
  81. -- Helper: Get basename
  82. local function basename(path)
  83. return path:match("[^/]+$") or path
  84. end
  85. -- Helper: Join paths
  86. local function join_path(...)
  87. local parts = {...}
  88. local result = table.concat(parts, "/")
  89. -- Normalize double slashes
  90. result = result:gsub("//+", "/")
  91. return result
  92. end
  93. -- Find project root from a given path
  94. function projectile.find_project_root(start_path)
  95. local path = start_path or os.getenv("PWD") or "."
  96. local depth = 0
  97. while path and path ~= "/" and depth < projectile.config.max_depth do
  98. for _, marker in ipairs(projectile.config.project_markers) do
  99. local marker_path = join_path(path, marker)
  100. if path_exists(marker_path) or is_directory(marker_path) then
  101. return path
  102. end
  103. end
  104. path = parent_dir(path)
  105. depth = depth + 1
  106. end
  107. return nil
  108. end
  109. -- Get current project root (from current buffer's file path)
  110. function projectile.get_current_project()
  111. local buf = editor.buffer
  112. local filepath = buf:filepath()
  113. if filepath and filepath ~= "" then
  114. local dir = parent_dir(filepath)
  115. return projectile.find_project_root(dir)
  116. end
  117. -- Fallback to working directory
  118. return projectile.find_project_root()
  119. end
  120. -- Check if a file matches ignore patterns
  121. local function should_ignore(relative_path)
  122. for _, pattern in ipairs(projectile.config.ignore_patterns) do
  123. if relative_path:match(pattern) then
  124. return true
  125. end
  126. end
  127. return false
  128. end
  129. -- List files in project using shell command
  130. function projectile.list_project_files(project_root)
  131. if not project_root then
  132. return {}
  133. end
  134. -- Check cache
  135. if projectile.config.cache_enabled then
  136. local cached = projectile.project_cache[project_root]
  137. if cached then
  138. -- Use cache if less than 30 seconds old
  139. local age = os.time() - cached.timestamp
  140. if age < 30 then
  141. return cached.files
  142. end
  143. end
  144. end
  145. -- Use find command to list files
  146. local cmd = string.format(
  147. "find '%s' -type f -not -path '*/.git/*' -not -path '*/node_modules/*' -not -path '*/build/*' 2>/dev/null | head -%d",
  148. project_root,
  149. projectile.config.max_files
  150. )
  151. local handle = io.popen(cmd)
  152. if not handle then
  153. return {}
  154. end
  155. local files = {}
  156. local prefix_len = #project_root + 2 -- +2 for trailing / and 1-based index
  157. for line in handle:lines() do
  158. local relative = line:sub(prefix_len)
  159. if relative and relative ~= "" and not should_ignore(relative) then
  160. table.insert(files, relative)
  161. end
  162. end
  163. handle:close()
  164. -- Sort files
  165. table.sort(files)
  166. -- Cache results
  167. if projectile.config.cache_enabled then
  168. projectile.project_cache[project_root] = {
  169. files = files,
  170. timestamp = os.time()
  171. }
  172. end
  173. return files
  174. end
  175. -- Add project to recent list
  176. function projectile.add_recent_project(project_root)
  177. if not project_root then return end
  178. -- Remove if already in list
  179. for i, p in ipairs(projectile.recent_projects) do
  180. if p == project_root then
  181. table.remove(projectile.recent_projects, i)
  182. break
  183. end
  184. end
  185. -- Add to front
  186. table.insert(projectile.recent_projects, 1, project_root)
  187. -- Keep only last 20
  188. while #projectile.recent_projects > 20 do
  189. table.remove(projectile.recent_projects)
  190. end
  191. end
  192. -- Switch to project
  193. function projectile.switch_project(project_root)
  194. projectile.current_project = project_root
  195. projectile.add_recent_project(project_root)
  196. editor:message("Switched to project: " .. project_root)
  197. end
  198. -- Find file in project
  199. function projectile.find_file_in_project(project_root, pattern)
  200. local files = projectile.list_project_files(project_root)
  201. local matches = {}
  202. pattern = pattern:lower()
  203. for _, file in ipairs(files) do
  204. if file:lower():find(pattern, 1, true) then
  205. table.insert(matches, file)
  206. end
  207. end
  208. return matches
  209. end
  210. -- Clear project cache
  211. function projectile.invalidate_cache(project_root)
  212. if project_root then
  213. projectile.project_cache[project_root] = nil
  214. else
  215. projectile.project_cache = {}
  216. end
  217. end
  218. -- Get project name from root
  219. function projectile.get_project_name(project_root)
  220. return basename(project_root)
  221. end
  222. -- ============================================================================
  223. -- Commands
  224. -- ============================================================================
  225. -- Find file in current project
  226. editor:register_command("projectile-find-file", "Find file in current project", function(args)
  227. local project_root = projectile.get_current_project()
  228. if not project_root then
  229. return {success = false, message = "Not in a project"}
  230. end
  231. projectile.switch_project(project_root)
  232. local files = projectile.list_project_files(project_root)
  233. if #files == 0 then
  234. return {success = false, message = "No files found in project"}
  235. end
  236. -- If pattern provided, filter immediately
  237. if #args > 0 then
  238. local matches = projectile.find_file_in_project(project_root, args[1])
  239. if #matches == 0 then
  240. return {success = false, message = "No matches for: " .. args[1]}
  241. elseif #matches == 1 then
  242. local filepath = join_path(project_root, matches[1])
  243. editor:load_file(filepath)
  244. return {success = true, message = "Opened: " .. matches[1]}
  245. else
  246. -- Show matches
  247. local display = {}
  248. for i, m in ipairs(matches) do
  249. if i > 10 then
  250. table.insert(display, "... and " .. (#matches - 10) .. " more")
  251. break
  252. end
  253. table.insert(display, m)
  254. end
  255. return {success = true, message = "Matches:\n" .. table.concat(display, "\n")}
  256. end
  257. end
  258. -- Show file count
  259. return {success = true, message = "Project: " .. projectile.get_project_name(project_root) .. " (" .. #files .. " files)"}
  260. end, {"pf"}, true, "s")
  261. -- Switch to known project
  262. editor:register_command("projectile-switch-project", "Switch to a known project", function(args)
  263. if #projectile.recent_projects == 0 then
  264. return {success = false, message = "No recent projects"}
  265. end
  266. if #args > 0 then
  267. -- Find project matching pattern
  268. local pattern = args[1]:lower()
  269. for _, project in ipairs(projectile.recent_projects) do
  270. if project:lower():find(pattern, 1, true) then
  271. projectile.switch_project(project)
  272. return {success = true, message = "Switched to: " .. projectile.get_project_name(project)}
  273. end
  274. end
  275. return {success = false, message = "No project matching: " .. args[1]}
  276. end
  277. -- Show recent projects
  278. local display = {"Recent projects:"}
  279. for i, project in ipairs(projectile.recent_projects) do
  280. table.insert(display, i .. ". " .. projectile.get_project_name(project))
  281. end
  282. return {success = true, message = table.concat(display, "\n")}
  283. end, {"pp"}, true, "s")
  284. -- Show current project info
  285. editor:register_command("projectile-project-info", "Show current project information", function(args)
  286. local project_root = projectile.get_current_project()
  287. if not project_root then
  288. return {success = false, message = "Not in a project"}
  289. end
  290. local files = projectile.list_project_files(project_root)
  291. local info = {
  292. "Project: " .. projectile.get_project_name(project_root),
  293. "Root: " .. project_root,
  294. "Files: " .. #files,
  295. }
  296. return {success = true, message = table.concat(info, "\n")}
  297. end, {"pi"})
  298. -- Invalidate project cache
  299. editor:register_command("projectile-invalidate-cache", "Clear project file cache", function(args)
  300. local project_root = projectile.get_current_project()
  301. projectile.invalidate_cache(project_root)
  302. return {success = true, message = "Project cache cleared"}
  303. end, {"pic"})
  304. -- Add current directory as project
  305. editor:register_command("projectile-add-known-project", "Add current directory as a known project", function(args)
  306. local project_root
  307. if #args > 0 then
  308. project_root = args[1]
  309. else
  310. project_root = projectile.get_current_project() or os.getenv("PWD")
  311. end
  312. if project_root then
  313. projectile.add_recent_project(project_root)
  314. return {success = true, message = "Added project: " .. project_root}
  315. else
  316. return {success = false, message = "Could not determine project root"}
  317. end
  318. end, {"pak"}, true, "s")
  319. -- Run command in project root
  320. editor:register_command("projectile-run-command", "Run shell command in project root", function(args)
  321. local project_root = projectile.get_current_project()
  322. if not project_root then
  323. return {success = false, message = "Not in a project"}
  324. end
  325. if #args == 0 then
  326. return {success = false, message = "Usage: projectile-run-command <command>"}
  327. end
  328. local cmd = table.concat(args, " ")
  329. local full_cmd = string.format("cd '%s' && %s 2>&1", project_root, cmd)
  330. local handle = io.popen(full_cmd)
  331. if not handle then
  332. return {success = false, message = "Failed to run command"}
  333. end
  334. local output = handle:read("*a")
  335. handle:close()
  336. -- Truncate long output
  337. if #output > 500 then
  338. output = output:sub(1, 500) .. "\n... (truncated)"
  339. end
  340. return {success = true, message = output}
  341. end, {"prc"}, true, "s")
  342. -- Keybindings
  343. editor:bind_key("C-c p f", "projectile-find-file", "Find file in project")
  344. editor:bind_key("C-c p p", "projectile-switch-project", "Switch project")
  345. editor:bind_key("C-c p i", "projectile-project-info", "Project info")
  346. -- Store module
  347. lumacs.projectile = projectile
  348. print("[projectile] Package loaded")
  349. return projectile