Эх сурвалжийг харах

feat(packages): add projectile and company-mode packages

Add two new Lua packages:

projectile.lua - Project management and navigation:
- Project root detection via markers (.git, Makefile, etc.)
- Project file listing with caching
- Recent projects tracking
- Commands: projectile-find-file (C-c p f), projectile-switch-project (C-c p p),
  projectile-project-info (C-c p i), projectile-run-command, etc.

company.lua - Text completion framework:
- Multiple backends: buffer (words in buffer), words (common keywords), path
- Echo area popup display with selection
- Auto-completion option (disabled by default)
- Commands: company-complete (M-Tab), company-mode toggle
- Context-aware keybindings (C-n/C-p select candidates when active)

Also updates:
- LuaApi::call_idle_hooks() now calls company idle check
- init.lua loads both new packages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Bernardo Magri 1 сар өмнө
parent
commit
7982163864

+ 6 - 0
init.lua

@@ -55,6 +55,12 @@ dofile("lua/packages/rainbow-delimiters.lua")
 -- ido: Enhanced fuzzy completion (optional, provides utilities)
 dofile("lua/packages/ido.lua")
 
+-- projectile: Project management and navigation
+dofile("lua/packages/projectile.lua")
+
+-- company: Text completion framework
+dofile("lua/packages/company.lua")
+
 -- ============================================================================
 -- MINOR MODES (User-defined extensions)
 -- ============================================================================

+ 502 - 0
lua/packages/company.lua

@@ -0,0 +1,502 @@
+-- company.lua
+-- ============================================================================
+-- Text completion framework for lumacs.
+-- Similar to Emacs company-mode.
+--
+-- Features:
+-- - Multiple completion backends (words, buffer, path, etc.)
+-- - Completion popup display in echo area
+-- - Configurable triggers and keybindings
+-- ============================================================================
+
+local company = {}
+
+-- Configuration
+company.config = {
+    enabled = true,
+    idle_delay_ms = 500,       -- Delay before showing completions
+    min_prefix_length = 2,    -- Minimum chars before completing
+    max_candidates = 10,       -- Max candidates to show
+    backends = {"buffer", "words"},  -- Active backends in order
+    auto_complete = false,     -- Auto-show completions while typing
+}
+
+-- State
+company.active = false
+company.candidates = {}
+company.selected_index = 1
+company.prefix = ""
+company.prefix_start_col = 0
+
+-- ============================================================================
+-- Backend implementations
+-- ============================================================================
+
+company.backends = {}
+
+-- Buffer backend: Complete from words in current buffer
+company.backends.buffer = {
+    name = "buffer",
+
+    get_candidates = function(prefix)
+        local candidates = {}
+        local seen = {}
+        local buf = editor.buffer
+        local line_count = buf:line_count()
+
+        prefix = prefix:lower()
+
+        for i = 0, line_count - 1 do
+            local line = buf:line(i)
+            -- Extract words (alphanumeric + underscore)
+            for word in line:gmatch("[%w_]+") do
+                local word_lower = word:lower()
+                if #word >= company.config.min_prefix_length and
+                   word_lower:sub(1, #prefix) == prefix and
+                   not seen[word_lower] then
+                    seen[word_lower] = true
+                    table.insert(candidates, {
+                        text = word,
+                        description = "buffer",
+                    })
+                end
+            end
+        end
+
+        return candidates
+    end
+}
+
+-- Words backend: Complete from a dictionary of common words
+company.backends.words = {
+    name = "words",
+
+    -- Common programming keywords
+    keywords = {
+        "function", "return", "if", "else", "elseif", "then", "end",
+        "for", "while", "do", "repeat", "until", "break", "continue",
+        "local", "true", "false", "nil", "and", "or", "not",
+        "class", "struct", "enum", "interface", "public", "private", "protected",
+        "static", "const", "final", "abstract", "virtual", "override",
+        "import", "export", "require", "module", "package",
+        "try", "catch", "throw", "finally", "except", "raise",
+        "async", "await", "yield", "defer",
+        "self", "this", "super", "new", "delete",
+        "int", "float", "double", "string", "bool", "void", "char",
+        "array", "list", "map", "set", "dict", "table", "vector",
+        "print", "println", "printf", "console", "log", "debug", "error",
+    },
+
+    get_candidates = function(prefix)
+        local candidates = {}
+        prefix = prefix:lower()
+
+        for _, word in ipairs(company.backends.words.keywords) do
+            if word:sub(1, #prefix) == prefix then
+                table.insert(candidates, {
+                    text = word,
+                    description = "keyword",
+                })
+            end
+        end
+
+        return candidates
+    end
+}
+
+-- Path backend: Complete file paths
+company.backends.path = {
+    name = "path",
+
+    get_candidates = function(prefix)
+        local candidates = {}
+
+        -- Only trigger on path-like prefixes
+        if not prefix:match("^[%.~/]") and not prefix:match("/") then
+            return candidates
+        end
+
+        -- Expand ~ to home directory
+        local path = prefix:gsub("^~", os.getenv("HOME") or "~")
+
+        -- Get directory and partial filename
+        local dir, partial
+        if path:match("/$") then
+            dir = path
+            partial = ""
+        else
+            dir = path:match("(.+)/") or "."
+            partial = path:match("[^/]+$") or ""
+        end
+
+        -- List directory contents
+        local cmd = string.format("ls -1 '%s' 2>/dev/null", dir)
+        local handle = io.popen(cmd)
+        if not handle then
+            return candidates
+        end
+
+        partial = partial:lower()
+
+        for entry in handle:lines() do
+            if entry:lower():sub(1, #partial) == partial then
+                local full_path
+                if prefix:match("^~") then
+                    full_path = "~" .. dir:sub(#(os.getenv("HOME") or "") + 1) .. "/" .. entry
+                else
+                    full_path = dir .. "/" .. entry
+                end
+
+                table.insert(candidates, {
+                    text = full_path,
+                    description = "path",
+                })
+            end
+        end
+
+        handle:close()
+        return candidates
+    end
+}
+
+-- ============================================================================
+-- Core completion logic
+-- ============================================================================
+
+-- Get the word prefix at cursor
+function company.get_prefix()
+    local buf = editor.buffer
+    local cursor = editor.cursor
+    local line = buf:line(cursor.line)
+
+    -- Find word start (alphanumeric + underscore, or path chars)
+    local col = cursor.column
+    local start = col
+
+    while start > 0 do
+        local c = line:sub(start, start)
+        if c:match("[%w_%.~/]") then
+            start = start - 1
+        else
+            break
+        end
+    end
+
+    local prefix = line:sub(start + 1, col)
+    return prefix, start
+end
+
+-- Gather candidates from all active backends
+function company.gather_candidates(prefix)
+    local all_candidates = {}
+
+    for _, backend_name in ipairs(company.config.backends) do
+        local backend = company.backends[backend_name]
+        if backend and backend.get_candidates then
+            local candidates = backend.get_candidates(prefix)
+            for _, c in ipairs(candidates) do
+                c.backend = backend_name
+                table.insert(all_candidates, c)
+            end
+        end
+    end
+
+    -- Sort by text
+    table.sort(all_candidates, function(a, b)
+        return a.text < b.text
+    end)
+
+    -- Remove duplicates (keep first occurrence)
+    local seen = {}
+    local unique = {}
+    for _, c in ipairs(all_candidates) do
+        if not seen[c.text] then
+            seen[c.text] = true
+            table.insert(unique, c)
+        end
+    end
+
+    return unique
+end
+
+-- Format candidates for echo area display
+function company.format_display()
+    local lines = {}
+    local start_idx = 1
+    local end_idx = math.min(#company.candidates, company.config.max_candidates)
+
+    -- Adjust window if selected is out of view
+    if company.selected_index > end_idx then
+        start_idx = company.selected_index - company.config.max_candidates + 1
+        end_idx = company.selected_index
+    end
+
+    for i = start_idx, end_idx do
+        local c = company.candidates[i]
+        local marker = (i == company.selected_index) and ">" or " "
+        local line = string.format("%s %s", marker, c.text)
+        if c.description then
+            line = line .. " (" .. c.description .. ")"
+        end
+        table.insert(lines, line)
+    end
+
+    if #company.candidates > company.config.max_candidates then
+        table.insert(lines, string.format("  [%d/%d]", company.selected_index, #company.candidates))
+    end
+
+    return lines
+end
+
+-- Show completion popup
+function company.show()
+    if not company.active or #company.candidates == 0 then
+        return
+    end
+
+    local lines = company.format_display()
+    editor:set_echo_area(lines)
+end
+
+-- Hide completion popup
+function company.hide()
+    if company.active then
+        company.active = false
+        company.candidates = {}
+        company.selected_index = 1
+        editor:clear_echo_area()
+    end
+end
+
+-- Start completion at point
+function company.complete()
+    if not company.config.enabled then
+        return
+    end
+
+    company.prefix, company.prefix_start_col = company.get_prefix()
+
+    if #company.prefix < company.config.min_prefix_length then
+        company.hide()
+        return
+    end
+
+    company.candidates = company.gather_candidates(company.prefix)
+
+    if #company.candidates == 0 then
+        company.hide()
+        return
+    end
+
+    company.active = true
+    company.selected_index = 1
+    company.show()
+end
+
+-- Select next candidate
+function company.select_next()
+    if not company.active or #company.candidates == 0 then
+        return
+    end
+
+    company.selected_index = (company.selected_index % #company.candidates) + 1
+    company.show()
+end
+
+-- Select previous candidate
+function company.select_previous()
+    if not company.active or #company.candidates == 0 then
+        return
+    end
+
+    company.selected_index = ((company.selected_index - 2) % #company.candidates) + 1
+    company.show()
+end
+
+-- Insert selected candidate
+function company.insert_selection()
+    if not company.active or #company.candidates == 0 then
+        return false
+    end
+
+    local candidate = company.candidates[company.selected_index]
+    if not candidate then
+        return false
+    end
+
+    -- Delete the prefix and insert the completion
+    local buf = editor.buffer
+    local cursor = editor.cursor
+    local delete_start = lumacs.Position(cursor.line, company.prefix_start_col)
+    local delete_end = cursor
+
+    if delete_start.column < delete_end.column then
+        buf:erase(lumacs.Range(delete_start, delete_end))
+    end
+
+    -- Insert at the start position
+    buf:insert(delete_start, candidate.text)
+
+    -- Move cursor to end of inserted text
+    editor.cursor = lumacs.Position(cursor.line, company.prefix_start_col + #candidate.text)
+
+    company.hide()
+    return true
+end
+
+-- Abort completion
+function company.abort()
+    company.hide()
+end
+
+-- ============================================================================
+-- Idle hook for auto-completion
+-- ============================================================================
+
+function company.check_idle()
+    if not company.config.enabled or not company.config.auto_complete then
+        return
+    end
+
+    -- Only auto-complete if we've been idle long enough
+    local idle_ms = editor:idle_time_ms()
+    if idle_ms < company.config.idle_delay_ms then
+        return
+    end
+
+    -- Don't auto-complete if minibuffer is active
+    if editor.minibuffer_manager and editor.minibuffer_manager:is_active() then
+        return
+    end
+
+    -- Don't auto-complete if already showing
+    if company.active then
+        return
+    end
+
+    -- Try to complete
+    company.complete()
+end
+
+-- ============================================================================
+-- Commands
+-- ============================================================================
+
+editor:register_command("company-complete", "Start completion at point", function(args)
+    company.complete()
+    return {success = true}
+end, {"complete"})
+
+editor:register_command("company-select-next", "Select next completion candidate", function(args)
+    company.select_next()
+    return {success = true}
+end)
+
+editor:register_command("company-select-previous", "Select previous completion candidate", function(args)
+    company.select_previous()
+    return {success = true}
+end)
+
+editor:register_command("company-complete-selection", "Insert selected completion", function(args)
+    if company.insert_selection() then
+        return {success = true, message = "Completion inserted"}
+    else
+        return {success = false, message = "No completion active"}
+    end
+end)
+
+editor:register_command("company-abort", "Cancel completion", function(args)
+    company.abort()
+    return {success = true}
+end)
+
+editor:register_command("company-mode", "Toggle company-mode", function(args)
+    company.config.enabled = not company.config.enabled
+    if company.config.enabled then
+        editor:message("company-mode enabled")
+    else
+        company.hide()
+        editor:message("company-mode disabled")
+    end
+    return {success = true}
+end)
+
+editor:register_command("company-auto-complete", "Toggle auto-completion", function(args)
+    company.config.auto_complete = not company.config.auto_complete
+    if company.config.auto_complete then
+        editor:message("Auto-completion enabled")
+    else
+        editor:message("Auto-completion disabled")
+    end
+    return {success = true}
+end)
+
+editor:register_command("company-backends", "List or set completion backends", function(args)
+    if #args == 0 then
+        return {success = true, message = "Backends: " .. table.concat(company.config.backends, ", ")}
+    end
+
+    -- Parse comma-separated list
+    local backends = {}
+    for backend in args[1]:gmatch("[^,]+") do
+        backend = backend:match("^%s*(.-)%s*$")  -- trim
+        if company.backends[backend] then
+            table.insert(backends, backend)
+        else
+            return {success = false, message = "Unknown backend: " .. backend}
+        end
+    end
+
+    company.config.backends = backends
+    return {success = true, message = "Backends set to: " .. table.concat(backends, ", ")}
+end, {}, true, "s")
+
+-- ============================================================================
+-- Keybindings
+-- ============================================================================
+
+editor:bind_key("M-Tab", "company-complete", "Complete at point")
+editor:bind_key("C-n", function()
+    if company.active then
+        company.select_next()
+    else
+        editor:move_down()
+    end
+end, "Next line or completion")
+
+editor:bind_key("C-p", function()
+    if company.active then
+        company.select_previous()
+    else
+        editor:move_up()
+    end
+end, "Previous line or completion")
+
+-- Note: These override default behavior when completion is active
+-- Tab to complete selection when active
+editor:bind_key("Tab", function()
+    if company.active then
+        company.insert_selection()
+    else
+        -- Default tab behavior (insert spaces)
+        editor.buffer:insert(editor.cursor, "    ")
+        for i = 1, 4 do editor:move_right() end
+    end
+end, "Tab or complete")
+
+-- Escape to abort
+editor:bind_key("Escape", function()
+    if company.active then
+        company.abort()
+    else
+        editor:execute_command("keyboard-quit", {})
+    end
+end, "Abort or quit")
+
+-- Register idle hook
+lumacs.company_check_idle = company.check_idle
+
+-- Store module
+lumacs.company = company
+
+return company

+ 414 - 0
lua/packages/projectile.lua

@@ -0,0 +1,414 @@
+-- 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
+
+return projectile

+ 8 - 1
src/lua_api.cpp

@@ -1059,12 +1059,19 @@ void LuaApi::register_functions() {
 }
 
 void LuaApi::call_idle_hooks() {
-    // Call the which-key idle check if registered
+    // Call registered idle hooks from Lua packages
     try {
+        // which-key idle check
         sol::optional<sol::function> which_key_check = lua_["lumacs"]["which_key_check_idle"];
         if (which_key_check) {
             (*which_key_check)();
         }
+
+        // company-mode idle check
+        sol::optional<sol::function> company_check = lua_["lumacs"]["company_check_idle"];
+        if (company_check) {
+            (*company_check)();
+        }
     } catch (const sol::error& e) {
         spdlog::error("Error in idle hook: {}", e.what());
     }