diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4fa0bc7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,254 @@ +# Contributing to Marksman.nvim + +Thank you for your interest in contributing to Marksman.nvim! This document provides guidelines for contributing to this project. + +## Getting Started + +1. **Fork the repository** and clone your fork locally +2. **Create a new branch** for your feature or bug fix +3. **Test your changes** thoroughly +4. **Submit a pull request** with a clear description + +## Development Setup + +### Prerequisites +- Neovim >= 0.8.0 +- Git +- Lua 5.1+ (for testing outside Neovim) + +### Code Quality Tools +```bash +# Install luacheck for linting +luarocks install luacheck + +# Install stylua for formatting +cargo install stylua + +# Run linting +luacheck lua/ + +# Run formatting +stylua lua/ +``` + +## Code Style + +### Lua Style Guidelines +- Use **snake_case** for functions and variables +- Use **PascalCase** for classes/modules +- **Indent with tabs** (width: 4) +- **Line length**: 100 characters max +- **Use meaningful variable names** + +### Documentation +- Add **JSDoc-style comments** for public functions +- Include **@param** and **@return** annotations +- Document **complex logic** with inline comments + +Example: +```lua +---Add a mark at the current cursor position +---@param name string|nil Optional mark name (auto-generated if nil) +---@param description string|nil Optional mark description +---@return table result Result with success, message, and mark_name +function M.add_mark(name, description) + -- Implementation here +end +``` + +## Project Structure + +``` +lua/marksman/ +├── init.lua -- Main plugin entry point +├── storage.lua -- Mark persistence and project management +├── ui.lua -- Floating window and interface +└── utils.lua -- Utilities and validation +``` + +## Contribution Types + +### Bug Fixes +1. **Create an issue** describing the bug +2. **Include reproduction steps** and expected behavior +3. **Write a test case** if applicable +4. **Fix the bug** and ensure tests pass + +### New Features +1. **Discuss the feature** in an issue first +2. **Ensure it aligns** with the plugin's focused scope +3. **Write comprehensive documentation** +4. **Add configuration options** if needed + +### Documentation +- **Fix typos** and improve clarity +- **Add examples** for complex features +- **Update README** with new functionality + +## Testing Guidelines + +### Manual Testing +- Test with **multiple projects** +- Verify **mark persistence** across sessions +- Test **edge cases** (empty files, special characters) +- Check **memory usage** with large mark sets + +### Test Scenarios +1. **Basic Operations**: + - Add/delete/rename marks + - Jump to marks + - Search functionality + +2. **Project Switching**: + - Marks isolated per project + - Correct project detection + +3. **Error Handling**: + - Invalid file paths + - Corrupted storage files + - Memory limits + +4. **UI Testing**: + - Window sizing and positioning + - Keyboard navigation + - Search and filtering + +## Performance Considerations + +### Memory Efficiency +- **Lazy load modules** when possible +- **Clean up resources** on plugin disable +- **Cache strategically** with expiration +- **Debounce operations** to reduce I/O + +### Code Patterns +```lua +-- Good: Lazy loading +local function get_storage() + if not storage then + storage = require("marksman.storage") + end + return storage +end + +-- Good: Error handling +local ok, result = pcall(function() + -- Potentially failing operation +end) +if not ok then + notify("Operation failed: " .. tostring(result), vim.log.levels.ERROR) + return false +end +``` + +## Error Handling + +### Guidelines +- **Always handle errors** gracefully +- **Provide meaningful messages** to users +- **Log technical details** for debugging +- **Fallback to safe defaults** when possible + +### Error Message Format +```lua +-- User-facing: Clear and actionable +notify("Cannot add mark: file is not readable", vim.log.levels.WARN) + +-- Debug: Technical details +notify("Failed to save marks: " .. tostring(err), vim.log.levels.ERROR) +``` + +## Configuration Design + +### Principles +- **Sensible defaults** that work out of the box +- **Granular options** for customization +- **Backward compatibility** when possible +- **Validation** for user inputs + +### Example Configuration +```lua +require("marksman").setup({ + -- Core functionality + max_marks = 100, + auto_save = true, + + -- UI preferences + minimal = false, + silent = false, + + -- Performance tuning + debounce_ms = 500, + + -- Customization + keymaps = { ... }, + highlights = { ... }, +}) +``` + +## Pull Request Process + +### Before Submitting +1. **Test thoroughly** on your system +2. **Run linting** and fix issues +3. **Update documentation** if needed +4. **Rebase on main** branch + +### PR Description Template +```markdown +## Description +Brief description of changes + +## Type of Change +- [ ] Bug fix +- [ ] New feature +- [ ] Documentation update +- [ ] Refactoring + +## Testing +Describe how you tested the changes + +## Breaking Changes +List any breaking changes (if applicable) + +## Checklist +- [ ] Code follows style guidelines +- [ ] Self-review completed +- [ ] Documentation updated +- [ ] Tests pass +``` + +## Review Process + +### What We Look For +- **Code quality** and maintainability +- **Performance impact** +- **User experience** improvements +- **Documentation completeness** +- **Test coverage** + +### Response Time +- Initial review: 2-3 business days +- Follow-up reviews: 1-2 business days +- Simple fixes: Same day + +## Getting Help + +### Communication Channels +- **GitHub Issues**: Bug reports and feature requests +- **GitHub Discussions**: Questions and general discussion +- **Pull Request Comments**: Code-specific discussions + +### Response Guidelines +- Be **respectful** and constructive +- **Ask questions** if requirements are unclear +- **Provide context** for your suggestions + +## Recognition + +Contributors are recognized in: +- **README.md** contributors section +- **Release notes** for significant contributions +- **GitHub repository** contributor graphs + +Thank you for helping make Marksman.nvim better! 🚀 diff --git a/README.md b/README.md index 230ecfc..43240aa 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,9 @@ Vim's built-in marks are great, but they're global and get messy fast. Marksman - **Interactive UI** - Browse and manage marks in an enhanced floating window - **Reordering** - Move marks up and down to organize them as needed - **Multiple integrations** - Works with Telescope, Snacks.nvim, and more +- **Robust error handling** - Graceful fallbacks and comprehensive validation +- **Memory efficient** - Lazy loading and cleanup for optimal performance +- **Debounced operations** - Reduced I/O with intelligent batching ## Requirements @@ -58,6 +61,7 @@ Vim's built-in marks are great, but they're global and get messy fast. Marksman silent = false, minimal = false, disable_default_keymaps = false, + debounce_ms = 500, }, } ``` @@ -97,6 +101,16 @@ require("marksman").setup({ }) ``` +### Performance Tuning + +```lua +require("marksman").setup({ + max_marks = 50, -- Lower limit for better performance + debounce_ms = 1000, -- Longer debounce for fewer saves + auto_save = true, -- Keep auto-save enabled +}) +``` + ### Disable Default Keymaps ```lua @@ -150,6 +164,14 @@ Search through all mark data: require("marksman").search_marks("api controller") ``` +#### Mark Statistics +Get detailed statistics about your marks: +```lua +local stats = require("marksman").get_memory_usage() +print("Total marks: " .. stats.marks_count) +print("File size: " .. stats.file_size .. " bytes") +``` + ## Commands ``` @@ -162,6 +184,7 @@ require("marksman").search_marks("api controller") :MarkSearch [query] - Search marks :MarkExport - Export marks to JSON :MarkImport - Import marks from JSON +:MarkStats - Show mark statistics ``` ## Enhanced UI Features @@ -173,6 +196,8 @@ The floating window includes: - **File path display** - View relative file paths for better context - **Mark reordering** - Press `J`/`K` to move marks up/down - **Clear all marks** - Press `C` to clear all marks with confirmation +- **Dynamic sizing** - Window adapts to content size +- **Error feedback** - Clear messages for failed operations ## Telescope Integration @@ -277,22 +302,34 @@ end ```lua local marksman = require("marksman") --- Basic operations -marksman.add_mark("my_mark") -marksman.goto_mark("my_mark") -marksman.goto_mark(1) -- Jump to first mark by index -marksman.delete_mark("my_mark") -marksman.rename_mark("old_name", "new_name") +-- Basic operations (now return result tables) +local result = marksman.add_mark("my_mark") +if result.success then + print("Mark added: " .. result.mark_name) +else + print("Error: " .. result.message) +end + +local result = marksman.goto_mark("my_mark") +local result = marksman.goto_mark(1) -- Jump to first mark by index +local result = marksman.delete_mark("my_mark") +local result = marksman.rename_mark("old_name", "new_name") -- Enhanced features -marksman.search_marks("search query") -marksman.show_marks() +local filtered = marksman.search_marks("search query") +marksman.show_marks("optional_search_query") -- Utility functions -marksman.get_marks() -marksman.get_marks_count() -marksman.export_marks() -marksman.import_marks() +local marks = marksman.get_marks() +local count = marksman.get_marks_count() +local stats = marksman.get_memory_usage() + +-- Import/Export +local result = marksman.export_marks() +local result = marksman.import_marks() + +-- Cleanup +marksman.cleanup() -- Free memory and resources ``` ### Storage Operations @@ -301,6 +338,30 @@ marksman.import_marks() local storage = require("marksman.storage") storage.get_project_name() -- Get current project name +storage.save_marks() -- Manual save +storage.cleanup() -- Cleanup resources +``` + +### Utils Functions + +```lua +local utils = require("marksman.utils") + +-- Validation +local valid, err = utils.validate_mark_name("my_mark") +local valid, err = utils.validate_mark_data(mark_data) + +-- Smart naming +local name = utils.suggest_mark_name(bufname, line, existing_marks) +local sanitized = utils.sanitize_mark_name("raw name") + +-- Statistics +local stats = utils.get_marks_statistics(marks) +local is_stale, reason = utils.is_mark_stale(mark) + +-- File handling +local rel_path = utils.get_relative_path(filepath) +local formatted = utils.format_file_path(filepath, max_length) ``` ## Configuration Options @@ -309,49 +370,99 @@ storage.get_project_name() -- Get current project name |--------|------|---------|-------------| | `keymaps` | table | `{...}` | Key mappings for mark operations | | `auto_save` | boolean | `true` | Automatically save marks | -| `max_marks` | number | `100` | Maximum marks per project | +| `max_marks` | number | `100` | Maximum marks per project (1-1000) | | `search_in_ui` | boolean | `true` | Enable search in UI | -| `minimal` | boolean | `false` | Set to true for clean UI (number, name, and filepath only)| -| `silent` | boolean | `false` | Set to true to supress notifications| -| `disable_default_keymaps` | boolean | `false` | Set to true to disable all default keymaps | +| `minimal` | boolean | `false` | Clean UI (number, name, and filepath only)| +| `silent` | boolean | `false` | Suppress notifications| +| `disable_default_keymaps` | boolean | `false` | Disable all default keymaps | +| `debounce_ms` | number | `500` | Debounce delay for save operations (100-5000ms) | | `highlights` | table | `{...}` | Custom highlight groups | ## How it works ### Storage -Marks are stored in `~/.local/share/nvim/marksman_[hash].json` where the hash is derived from your project path. Each project gets its own file with automatic backup support. +Marks are stored in `~/.local/share/nvim/marksman_[hash].json` where the hash is derived from your project path. Each project gets its own file with automatic backup support and atomic writes for data safety. ### Smart Naming -When you add a mark without a name, Marksman analyzes the code context to generate meaningful names: +When you add a mark without a name, Marksman analyzes the code context to generate meaningful names with expanded language support: -- **Functions**: `fn:calculate_total` +- **Functions**: `fn:calculate_total`, `async_function:fetch_data` - **Classes**: `class:UserModel` - **Structs**: `struct:Config` +- **Methods**: `method:validate` +- **Variables**: `var:api_key` - **Fallback**: `filename:line` ### Project Detection -Marksman uses multiple methods to find your project root: +Marksman uses multiple methods to find your project root with caching: 1. Git repository root 2. Common project files (.git, package.json, Cargo.toml, etc.) 3. Current working directory as fallback ### Search Algorithm -The search function looks through: +The enhanced search function looks through: - Mark names - File names and paths - Code context (the line content) +- Mark descriptions +- Parent directory names ### File Path Display The UI shows relative file paths instead of just filenames, making it easier to distinguish between files with the same name in different directories. +### Error Handling +Comprehensive error handling with: +- Graceful fallbacks for corrupted data +- Automatic backup restoration +- Input validation with clear error messages +- Safe file operations with atomic writes + +### Memory Management +- **Lazy loading**: Modules loaded only when needed +- **Resource cleanup**: Automatic cleanup on exit +- **Debounced operations**: Reduced I/O operations +- **Cache management**: Smart caching with expiration + ## Performance - **Lazy loading**: Modules are only loaded when needed -- **Efficient storage**: JSON format with minimal file I/O -- **Smart caching**: Marks are cached in memory after first load -- **Fast search**: Optimized filtering algorithms +- **Efficient storage**: JSON format with minimal file I/O and atomic writes +- **Smart caching**: Project roots and marks cached in memory with expiration +- **Fast search**: Optimized filtering algorithms with multi-field search +- **Debounced saves**: Intelligent batching of save operations +- **Memory monitoring**: Built-in memory usage tracking + +## Development + +### Contributing +See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed contribution guidelines. + +### Code Quality +```bash +# Lint code +luacheck lua/ + +# Format code +stylua lua/ + +# Check for common issues +grep -r "TODO\|FIXME\|XXX" lua/ +``` + +### Performance Testing +```lua +-- Monitor memory usage +local stats = require("marksman").get_memory_usage() +print(vim.inspect(stats)) + +-- Test with large datasets +for i = 1, 1000 do + require("marksman").add_mark("test_" .. i) +end +``` ## License MIT + diff --git a/lua/marksman/init.lua b/lua/marksman/init.lua index c243e8d..3b19de9 100644 --- a/lua/marksman/init.lua +++ b/lua/marksman/init.lua @@ -1,4 +1,9 @@ -- luacheck: globals vim +---@class Marksman +---@field config table Plugin configuration +---@field storage table Storage module instance +---@field ui table UI module instance +---@field utils table Utils module instance local M = {} -- Lazy load modules @@ -6,7 +11,7 @@ local storage = nil local ui = nil local utils = nil --- Configuration +-- Default configuration with validation schema local default_config = { keymaps = { add = "", @@ -33,62 +38,175 @@ local default_config = { silent = false, minimal = false, disable_default_keymaps = false, + debounce_ms = 500, -- Debounce save operations +} + +-- Configuration validation schema +local config_schema = { + auto_save = { type = "boolean" }, + max_marks = { type = "number", min = 1, max = 1000 }, + search_in_ui = { type = "boolean" }, + silent = { type = "boolean" }, + minimal = { type = "boolean" }, + disable_default_keymaps = { type = "boolean" }, + debounce_ms = { type = "number", min = 100, max = 5000 }, } local config = {} --- Helper function for conditional notifications +-- Debounced save timer +local save_timer = nil + +---Helper function for conditional notifications +---@param message string The notification message +---@param level number The log level (vim.log.levels.*) local function notify(message, level) if not config.silent then vim.notify(message, level) end end --- Lazy module loading +---Validate configuration against schema +---@param user_config table User provided configuration +---@param schema table Validation schema +---@return table validated_config Validated configuration +local function validate_config(user_config, schema) + local validated = {} + + for key, value in pairs(user_config or {}) do + local rule = schema[key] + if rule then + if rule.type and type(value) ~= rule.type then + notify( + string.format("Invalid config type for %s: expected %s, got %s", key, rule.type, type(value)), + vim.log.levels.WARN + ) + elseif rule.min and value < rule.min then + notify( + string.format("Config value %s below minimum: %s < %s", key, value, rule.min), + vim.log.levels.WARN + ) + elseif rule.max and value > rule.max then + notify( + string.format("Config value %s above maximum: %s > %s", key, value, rule.max), + vim.log.levels.WARN + ) + else + validated[key] = value + end + else + validated[key] = value -- Allow unknown keys for forward compatibility + end + end + + return validated +end + +---Lazy module loading with error handling +---@return table storage Storage module local function get_storage() if not storage then - storage = require("marksman.storage") + local ok, module = pcall(require, "marksman.storage") + if not ok then + notify("Failed to load storage module: " .. tostring(module), vim.log.levels.ERROR) + return nil + end + storage = module storage.setup(config) end return storage end +---Lazy module loading with error handling +---@return table ui UI module local function get_ui() if not ui then - ui = require("marksman.ui") + local ok, module = pcall(require, "marksman.ui") + if not ok then + notify("Failed to load UI module: " .. tostring(module), vim.log.levels.ERROR) + return nil + end + ui = module ui.setup(config) end return ui end +---Lazy module loading with error handling +---@return table utils Utils module local function get_utils() if not utils then - utils = require("marksman.utils") + local ok, module = pcall(require, "marksman.utils") + if not ok then + notify("Failed to load utils module: " .. tostring(module), vim.log.levels.ERROR) + return nil + end + utils = module end return utils end --- Core API +---Debounced save operation +local function debounced_save() + if save_timer then + save_timer:stop() + end + + save_timer = vim.defer_fn(function() + local storage_module = get_storage() + if storage_module then + storage_module.save_marks() + end + save_timer = nil + end, config.debounce_ms or 500) +end + +---Add a mark at the current cursor position +---@param name string|nil Optional mark name (auto-generated if nil) +---@param description string|nil Optional mark description +---@return table result Result with success, message, and mark_name function M.add_mark(name, description) local storage_module = get_storage() local utils_module = get_utils() + if not storage_module or not utils_module then + return { success = false, message = "Failed to load required modules" } + end + local bufname = vim.fn.expand("%:p") - if bufname == "" then - notify("Cannot add mark: no file", vim.log.levels.WARN) - return false + if bufname == "" or bufname == "[No Name]" then + return { success = false, message = "Cannot add mark: no file or unnamed buffer" } + end + + -- Check if file exists and is readable + if vim.fn.filereadable(bufname) == 0 then + return { success = false, message = "Cannot add mark: file is not readable" } end if storage_module.get_marks_count() >= config.max_marks then - notify("Maximum marks limit reached (" .. config.max_marks .. ")", vim.log.levels.WARN) - return false + return { + success = false, + message = string.format("Maximum marks limit reached (%d)", config.max_marks), + } end local line = vim.fn.line(".") local col = vim.fn.col(".") - if not name or name == "" then - name = utils_module.generate_mark_name(bufname, line) + -- Validate and generate name if needed + if name then + local valid, err = utils_module.validate_mark_name(name) + if not valid then + return { success = false, message = "Invalid mark name: " .. err } + end + else + name = utils_module.suggest_mark_name(bufname, line, storage_module.get_marks()) + end + + -- Check for existing mark with same name + local existing_marks = storage_module.get_marks() + if existing_marks[name] then + return { success = false, message = "Mark already exists: " .. name } end local mark = { @@ -96,21 +214,33 @@ function M.add_mark(name, description) line = line, col = col, text = vim.fn.getline("."):sub(1, 80), + created_at = os.time(), + description = description, } local success = storage_module.add_mark(name, mark) if success then + debounced_save() notify("󰃀 Mark added: " .. name, vim.log.levels.INFO) - return true + return { success = true, message = "Mark added successfully", mark_name = name } else - notify("Failed to add mark: " .. name, vim.log.levels.ERROR) - return false + return { success = false, message = "Failed to add mark: " .. name } end end +---Jump to a mark by name or index +---@param name_or_index string|number Mark name or numeric index +---@return table result Result with success and message function M.goto_mark(name_or_index) local storage_module = get_storage() + if not storage_module then + return { success = false, message = "Failed to load storage module" } + end + local marks = storage_module.get_marks() + if vim.tbl_isempty(marks) then + return { success = false, message = "No marks available" } + end local mark = nil local mark_name = name_or_index @@ -120,84 +250,156 @@ function M.goto_mark(name_or_index) if name_or_index > 0 and name_or_index <= #mark_names then mark_name = mark_names[name_or_index] mark = marks[mark_name] + else + return { success = false, message = "Invalid mark index: " .. name_or_index } end else mark = marks[name_or_index] + if not mark then + return { success = false, message = "Mark not found: " .. tostring(name_or_index) } + end end - if mark then - if vim.fn.filereadable(mark.file) == 0 then - notify("Mark file no longer exists: " .. mark.file, vim.log.levels.WARN) - return false + -- Validate mark data before jumping + local utils_module = get_utils() + if utils_module then + local valid, err = utils_module.validate_mark_data(mark) + if not valid then + return { success = false, message = "Invalid mark data: " .. err } end + end + -- Check if file still exists + if vim.fn.filereadable(mark.file) == 0 then + return { success = false, message = "Mark file no longer exists: " .. mark.file } + end + + -- Safely jump to mark + local ok, err = pcall(function() vim.cmd("edit " .. vim.fn.fnameescape(mark.file)) vim.fn.cursor(mark.line, mark.col) vim.cmd("normal! zz") -- Center the line + end) + + if ok then notify("󰃀 Jumped to: " .. mark_name, vim.log.levels.INFO) - return true + return { success = true, message = "Jumped to mark successfully", mark_name = mark_name } else - notify("Mark not found: " .. tostring(name_or_index), vim.log.levels.WARN) - return false + return { success = false, message = "Failed to jump to mark: " .. tostring(err) } end end +---Delete a mark by name +---@param name string Mark name to delete +---@return table result Result with success and message function M.delete_mark(name) local storage_module = get_storage() - local success = storage_module.delete_mark(name) + if not storage_module then + return { success = false, message = "Failed to load storage module" } + end + + if not name or name == "" then + return { success = false, message = "Mark name cannot be empty" } + end + local success = storage_module.delete_mark(name) if success then + debounced_save() notify("󰃀 Mark deleted: " .. name, vim.log.levels.INFO) - return true + return { success = true, message = "Mark deleted successfully", mark_name = name } else - notify("Mark not found: " .. name, vim.log.levels.WARN) - return false + return { success = false, message = "Mark not found: " .. name } end end +---Rename a mark +---@param old_name string Current mark name +---@param new_name string New mark name +---@return table result Result with success and message function M.rename_mark(old_name, new_name) local storage_module = get_storage() - local success = storage_module.rename_mark(old_name, new_name) + local utils_module = get_utils() + + if not storage_module or not utils_module then + return { success = false, message = "Failed to load required modules" } + end + -- Validate new name + local valid, err = utils_module.validate_mark_name(new_name) + if not valid then + return { success = false, message = "Invalid new mark name: " .. err } + end + + local success = storage_module.rename_mark(old_name, new_name) if success then + debounced_save() notify("󰃀 Mark renamed: " .. old_name .. " → " .. new_name, vim.log.levels.INFO) - return true + return { success = true, message = "Mark renamed successfully", old_name = old_name, new_name = new_name } else - notify("Failed to rename mark", vim.log.levels.WARN) - return false + return { success = false, message = "Failed to rename mark" } end end +---Move a mark up or down in the list +---@param name string Mark name +---@param direction string "up" or "down" +---@return table result Result with success and message function M.move_mark(name, direction) local storage_module = get_storage() - local success = storage_module.move_mark(name, direction) + if not storage_module then + return { success = false, message = "Failed to load storage module" } + end + + if direction ~= "up" and direction ~= "down" then + return { success = false, message = "Invalid direction: must be 'up' or 'down'" } + end + local success = storage_module.move_mark(name, direction) if success then + debounced_save() notify("󰃀 Mark moved " .. direction, vim.log.levels.INFO) - return true + return { success = true, message = "Mark moved successfully", direction = direction } else - notify("Cannot move mark " .. direction, vim.log.levels.WARN) - return false + return { success = false, message = "Cannot move mark " .. direction } end end -function M.show_marks() +---Show marks in floating window +---@param search_query string|nil Optional search query to filter marks +function M.show_marks(search_query) local storage_module = get_storage() local ui_module = get_ui() + if not storage_module or not ui_module then + notify("Failed to load required modules", vim.log.levels.ERROR) + return + end + local marks = storage_module.get_marks() if vim.tbl_isempty(marks) then notify("No marks in current project", vim.log.levels.INFO) return end - ui_module.show_marks_window(marks, storage_module.get_project_name()) + ui_module.show_marks_window(marks, storage_module.get_project_name(), search_query) end +---Search marks by query +---@param query string Search query +---@return table filtered_marks Filtered marks matching the query function M.search_marks(query) local storage_module = get_storage() local utils_module = get_utils() + if not storage_module or not utils_module then + notify("Failed to load required modules", vim.log.levels.ERROR) + return {} + end + + if not query or query == "" then + return storage_module.get_marks() + end + local marks = storage_module.get_marks() local filtered = utils_module.filter_marks(marks, query) @@ -209,49 +411,130 @@ function M.search_marks(query) return filtered end +---Get total number of marks +---@return number count Number of marks in current project function M.get_marks_count() local storage_module = get_storage() + if not storage_module then + return 0 + end return storage_module.get_marks_count() end +---Get all marks +---@return table marks All marks in current project function M.get_marks() local storage_module = get_storage() + if not storage_module then + return {} + end return storage_module.get_marks() end +---Clear all marks with confirmation function M.clear_all_marks() vim.ui.select({ "Yes", "No" }, { prompt = "Clear all marks in this project?", }, function(choice) if choice == "Yes" then local storage_module = get_storage() - storage_module.clear_all_marks() - notify("󰃀 All marks cleared", vim.log.levels.INFO) + if storage_module then + storage_module.clear_all_marks() + debounced_save() + notify("󰃀 All marks cleared", vim.log.levels.INFO) + end end end) end +---Export marks to JSON file +---@return table result Result with success and message function M.export_marks() local storage_module = get_storage() + if not storage_module then + return { success = false, message = "Failed to load storage module" } + end return storage_module.export_marks() end +---Import marks from JSON file +---@return table result Result with success and message function M.import_marks() local storage_module = get_storage() + if not storage_module then + return { success = false, message = "Failed to load storage module" } + end return storage_module.import_marks() end +---Get memory usage statistics +---@return table stats Memory usage statistics +function M.get_memory_usage() + local storage_module = get_storage() + if not storage_module then + return { marks_count = 0, file_size = 0 } + end + + local marks_count = storage_module.get_marks_count() + local file_size = storage_module.get_storage_file_size() + + return { + marks_count = marks_count, + file_size = file_size, + modules_loaded = { + storage = storage ~= nil, + ui = ui ~= nil, + utils = utils ~= nil, + }, + } +end + +---Cleanup function to free memory and resources +function M.cleanup() + -- Stop any pending save operations + if save_timer then + save_timer:stop() + save_timer = nil + end + + -- Cleanup modules + if ui then + if ui.cleanup then + ui.cleanup() + end + ui = nil + end + if storage then + if storage.cleanup then + storage.cleanup() + end + storage = nil + end + if utils then + utils = nil + end + + config = {} +end + +---Setup function to initialize the plugin +---@param opts table|nil User configuration options function M.setup(opts) - config = vim.tbl_deep_extend("force", default_config, opts or {}) + -- Validate and merge configuration + local validated_opts = validate_config(opts, config_schema) + config = vim.tbl_deep_extend("force", default_config, validated_opts) - -- Create user commands + -- Create user commands with better error handling local commands = { { "MarkAdd", function(args) - M.add_mark(args.args ~= "" and args.args or nil) + local result = M.add_mark(args.args ~= "" and args.args or nil) + if not result.success then + notify(result.message, vim.log.levels.WARN) + end end, - { nargs = "?" }, + { nargs = "?", desc = "Add a mark at current position" }, }, { "MarkGoto", @@ -259,10 +542,13 @@ function M.setup(opts) if args.args == "" then M.show_marks() else - M.goto_mark(args.args) + local result = M.goto_mark(args.args) + if not result.success then + notify(result.message, vim.log.levels.WARN) + end end end, - { nargs = "?" }, + { nargs = "?", desc = "Jump to mark or show marks list" }, }, { "MarkDelete", @@ -270,10 +556,13 @@ function M.setup(opts) if args.args == "" then M.clear_all_marks() else - M.delete_mark(args.args) + local result = M.delete_mark(args.args) + if not result.success then + notify(result.message, vim.log.levels.WARN) + end end end, - { nargs = "?" }, + { nargs = "?", desc = "Delete a mark or clear all marks" }, }, { "MarkRename", @@ -282,21 +571,38 @@ function M.setup(opts) if #parts >= 2 then local old_name = parts[1] local new_name = table.concat(parts, " ", 2) - M.rename_mark(old_name, new_name) + local result = M.rename_mark(old_name, new_name) + if not result.success then + notify(result.message, vim.log.levels.WARN) + end + else + notify("Usage: MarkRename ", vim.log.levels.WARN) end end, - { nargs = "+" }, + { nargs = "+", desc = "Rename a mark" }, }, - { "MarkList", M.show_marks, {} }, - { "MarkClear", M.clear_all_marks, {} }, - { "MarkExport", M.export_marks, {} }, - { "MarkImport", M.import_marks, {} }, + { "MarkList", M.show_marks, { desc = "Show all marks" } }, + { "MarkClear", M.clear_all_marks, { desc = "Clear all marks" } }, + { "MarkExport", M.export_marks, { desc = "Export marks to JSON" } }, + { "MarkImport", M.import_marks, { desc = "Import marks from JSON" } }, { "MarkSearch", function(args) - M.search_marks(args.args) + local filtered = M.search_marks(args.args) + if not vim.tbl_isempty(filtered) then + M.show_marks(args.args) + end end, - { nargs = 1 }, + { nargs = 1, desc = "Search marks" }, + }, + { + "MarkStats", + function() + local stats = M.get_memory_usage() + local msg = string.format("Marks: %d, File size: %d bytes", stats.marks_count, stats.file_size) + notify(msg, vim.log.levels.INFO) + end, + { desc = "Show mark statistics" }, }, } @@ -319,11 +625,21 @@ function M.setup(opts) local key = keymaps["goto_" .. i] if key then vim.keymap.set("n", key, function() - M.goto_mark(i) + local result = M.goto_mark(i) + if not result.success then + notify(result.message, vim.log.levels.WARN) + end end, { desc = "Go to mark " .. i }) end end end + + -- Setup cleanup on VimLeavePre + vim.api.nvim_create_autocmd("VimLeavePre", { + pattern = "*", + callback = M.cleanup, + desc = "Cleanup marksman resources", + }) end return M diff --git a/lua/marksman/storage.lua b/lua/marksman/storage.lua index d04ac8f..bc910ba 100644 --- a/lua/marksman/storage.lua +++ b/lua/marksman/storage.lua @@ -1,4 +1,9 @@ -- luacheck: globals vim +---@class Storage +---@field marks table Current marks data +---@field mark_order table Order of marks +---@field current_project string Current project path +---@field config table Plugin configuration local M = {} -- State @@ -7,22 +12,43 @@ local mark_order = {} local current_project = nil local config = {} --- Helper function for conditional notifications +-- Cache for project root detection +local project_root_cache = {} +local cache_expiry = {} + +---Helper function for conditional notifications +---@param message string The notification message +---@param level number The log level local function notify(message, level) if not config.silent then vim.notify(message, level) end end +---Detect project root using multiple methods with caching +---@return string project_root The project root directory local function get_project_root() + local current_dir = vim.fn.expand("%:p:h") + + -- Check cache first (expires after 30 seconds) + local cache_key = current_dir + local now = os.time() + if project_root_cache[cache_key] and cache_expiry[cache_key] and cache_expiry[cache_key] > now then + return project_root_cache[cache_key] + end + -- Try multiple methods to find project root local methods = { + -- Git repository root function() - local git_root = vim.fn.system("git rev-parse --show-toplevel 2>/dev/null"):gsub("\n", "") - return vim.v.shell_error == 0 and git_root or nil + local ok, git_root = pcall(function() + local result = vim.fn.system("git rev-parse --show-toplevel 2>/dev/null"):gsub("\n", "") + return vim.v.shell_error == 0 and result or nil + end) + return ok and git_root or nil end, + -- Look for common project files function() - -- Look for common project files local project_files = { ".git", "package.json", @@ -30,44 +56,65 @@ local function get_project_root() "go.mod", "pyproject.toml", "composer.json", + "Makefile", + ".editorconfig", + "tsconfig.json", } - local current_dir = vim.fn.expand("%:p:h") + local search_dir = current_dir - while current_dir ~= "/" and current_dir ~= "" do + while search_dir ~= "/" and search_dir ~= "" do for _, file in ipairs(project_files) do - if - vim.fn.filereadable(current_dir .. "/" .. file) == 1 - or vim.fn.isdirectory(current_dir .. "/" .. file) == 1 - then - return current_dir + local path = search_dir .. "/" .. file + if vim.fn.filereadable(path) == 1 or vim.fn.isdirectory(path) == 1 then + return search_dir end end - current_dir = vim.fn.fnamemodify(current_dir, ":h") + search_dir = vim.fn.fnamemodify(search_dir, ":h") end return nil end, + -- Fallback to current working directory function() return vim.fn.getcwd() end, } + local result = nil for _, method in ipairs(methods) do - local result = method() - if result then - return result + local ok, root = pcall(method) + if ok and root and root ~= "" then + result = root + break end end - return vim.fn.getcwd() + -- Cache the result + result = result or vim.fn.getcwd() + project_root_cache[cache_key] = result + cache_expiry[cache_key] = now + 30 -- Cache for 30 seconds + + return result end +---Get the marks storage file path +---@return string file_path Path to the marks storage file local function get_marks_file() local project = get_project_root() + if not project then + error("Could not determine project root") + end + local hash = vim.fn.sha256(project):sub(1, 8) local data_path = vim.fn.stdpath("data") + if not data_path then + error("Could not get Neovim data directory") + end + return data_path .. "/marksman_" .. hash .. ".json" end +---Create backup of marks file +---@return boolean success Whether backup was created successfully local function backup_marks_file() local file = get_marks_file() local backup_file = file .. ".backup" @@ -80,75 +127,210 @@ local function backup_marks_file() if not ok then notify("Failed to create backup: " .. tostring(err), vim.log.levels.WARN) + return false + end + return true + end + return false +end + +---Restore marks from backup file +---@return boolean success Whether restoration was successful +local function restore_from_backup() + local file = get_marks_file() + local backup_file = file .. ".backup" + + if vim.fn.filereadable(backup_file) == 1 then + local ok, err = pcall(function() + local content = vim.fn.readfile(backup_file) + vim.fn.writefile(content, file) + end) + + if ok then + notify("Restored marks from backup", vim.log.levels.INFO) + return true + else + notify("Failed to restore from backup: " .. tostring(err), vim.log.levels.ERROR) end end + return false end +---Validate marks file data structure +---@param data table The data to validate +---@return boolean valid Whether the data is valid +---@return string|nil error Error message if invalid +local function validate_marks_data(data) + if type(data) ~= "table" then + return false, "Data must be a table" + end + + -- Handle legacy format (just marks) + if data.marks then + data = data.marks + end + + for name, mark in pairs(data) do + if type(name) ~= "string" or name == "" then + return false, "Mark names must be non-empty strings" + end + + if type(mark) ~= "table" then + return false, "Mark data must be a table" + end + + local required_fields = { "file", "line", "col" } + for _, field in ipairs(required_fields) do + if not mark[field] then + return false, "Missing required field: " .. field + end + end + + if type(mark.line) ~= "number" or mark.line < 1 then + return false, "Line must be a positive number" + end + + if type(mark.col) ~= "number" or mark.col < 1 then + return false, "Column must be a positive number" + end + + if type(mark.file) ~= "string" or mark.file == "" then + return false, "File must be a non-empty string" + end + end + + return true +end + +---Load marks from storage file +---@return table marks The loaded marks local function load_marks() current_project = get_project_root() local file = get_marks_file() + -- Initialize empty state + marks = {} + mark_order = {} + if vim.fn.filereadable(file) == 1 then local ok, err = pcall(function() local content = vim.fn.readfile(file) if #content > 0 then - local decoded = vim.json.decode(table.concat(content, "\n")) - if decoded and type(decoded) == "table" then - marks = decoded.marks or decoded - mark_order = decoded.mark_order or {} - - -- If mark_order is missing or incomplete, rebuild it - if #mark_order == 0 then - mark_order = {} - for name in pairs(marks) do - table.insert(mark_order, name) + local raw_data = table.concat(content, "\n") + if raw_data and raw_data ~= "" then + local decoded = vim.json.decode(raw_data) + if decoded then + -- Validate the loaded data + local valid, validation_error = validate_marks_data(decoded.marks or decoded) + if not valid then + error("Invalid marks data: " .. validation_error) end + + marks = decoded.marks or decoded + mark_order = decoded.mark_order or {} + + -- Rebuild mark_order if missing or incomplete + local mark_names = vim.tbl_keys(marks) + if #mark_order == 0 or #mark_order ~= #mark_names then + mark_order = {} + for name in pairs(marks) do + table.insert(mark_order, name) + end + end + + -- Remove invalid entries from mark_order + local valid_order = {} + for _, name in ipairs(mark_order) do + if marks[name] then + table.insert(valid_order, name) + end + end + mark_order = valid_order end - else - marks = {} - mark_order = {} end - else - marks = {} - mark_order = {} end end) if not ok then notify("Error loading marks: " .. tostring(err), vim.log.levels.ERROR) + -- Try to restore from backup + if restore_from_backup() then + -- Recursive call to load from restored backup + return load_marks() + end marks = {} mark_order = {} end - else - marks = {} - mark_order = {} end return marks end -local function save_marks() +---Save marks to storage file with comprehensive error handling +---@return boolean success Whether save was successful +function M.save_marks() if not config.auto_save then return false end + -- Validate data before saving + if not marks or type(marks) ~= "table" then + notify("Invalid marks data - cannot save", vim.log.levels.ERROR) + return false + end + + local valid, err = validate_marks_data(marks) + if not valid then + notify("Invalid marks data: " .. err, vim.log.levels.ERROR) + return false + end + local file = get_marks_file() -- Create backup before saving backup_marks_file() - local ok, err = pcall(function() + local ok, save_err = pcall(function() local data = { marks = marks, mark_order = mark_order, + version = "2.1", + saved_at = os.date("%Y-%m-%d %H:%M:%S"), + project = current_project, } + local json = vim.json.encode(data) - vim.fn.mkdir(vim.fn.fnamemodify(file, ":h"), "p") - vim.fn.writefile({ json }, file) + if not json then + error("Failed to encode marks data") + end + + -- Ensure directory exists + local dir = vim.fn.fnamemodify(file, ":h") + if vim.fn.isdirectory(dir) == 0 then + local mkdir_result = vim.fn.mkdir(dir, "p") + if mkdir_result == 0 then + error("Failed to create directory: " .. dir) + end + end + + -- Write file atomically (write to temp file first) + local temp_file = file .. ".tmp" + local write_result = vim.fn.writefile({ json }, temp_file) + if write_result ~= 0 then + error("Failed to write temporary file") + end + + -- Move temp file to final location + local rename_ok = os.rename(temp_file, file) + if not rename_ok then + error("Failed to move temporary file to final location") + end end) if not ok then - notify("Failed to save marks: " .. tostring(err), vim.log.levels.ERROR) + notify("Failed to save marks: " .. tostring(save_err), vim.log.levels.ERROR) + -- Try to restore from backup + restore_from_backup() return false end @@ -156,11 +338,16 @@ local function save_marks() end -- Public API + +---Setup the storage module +---@param user_config table Plugin configuration function M.setup(user_config) config = user_config or {} load_marks() end +---Get all marks +---@return table marks Current marks function M.get_marks() if vim.tbl_isempty(marks) then load_marks() @@ -168,16 +355,22 @@ function M.get_marks() return marks end +---Get number of marks +---@return number count Number of marks function M.get_marks_count() return vim.tbl_count(M.get_marks()) end +---Get current project name +---@return string project_name Project name function M.get_project_name() - return vim.fn.fnamemodify(current_project or get_project_root(), ":t") + local project = current_project or get_project_root() + return vim.fn.fnamemodify(project, ":t") end +---Get ordered list of mark names +---@return table mark_names Ordered mark names function M.get_mark_names() - -- Return marks in the order they were added/arranged local valid_names = {} for _, name in ipairs(mark_order) do if marks[name] then @@ -187,22 +380,41 @@ function M.get_mark_names() return valid_names end +---Add a new mark +---@param name string Mark name +---@param mark table Mark data +---@return boolean success Whether mark was added function M.add_mark(name, mark) if not name or name == "" then return false end + if type(mark) ~= "table" then + return false + end + local marks_data = M.get_marks() + -- Validate mark data + local utils = require("marksman.utils") + local valid, err = utils.validate_mark_data(mark) + if not valid then + notify("Invalid mark data: " .. err, vim.log.levels.ERROR) + return false + end + -- If mark doesn't exist, add it to the order if not marks_data[name] then table.insert(mark_order, name) end marks_data[name] = mark - return save_marks() + return true -- Save is handled by debounced save in main module end +---Delete a mark +---@param name string Mark name to delete +---@return boolean success Whether mark was deleted function M.delete_mark(name) local marks_data = M.get_marks() @@ -217,12 +429,16 @@ function M.delete_mark(name) end end - return save_marks() + return true end return false end +---Rename a mark +---@param old_name string Current mark name +---@param new_name string New mark name +---@return boolean success Whether mark was renamed function M.rename_mark(old_name, new_name) if not old_name or not new_name or old_name == new_name then return false @@ -238,6 +454,7 @@ function M.rename_mark(old_name, new_name) return false -- Name already exists end + -- Transfer mark data marks_data[new_name] = marks_data[old_name] marks_data[old_name] = nil @@ -249,9 +466,13 @@ function M.rename_mark(old_name, new_name) end end - return save_marks() + return true end +---Move a mark up or down in the order +---@param name string Mark name to move +---@param direction string "up" or "down" +---@return boolean success Whether mark was moved function M.move_mark(name, direction) local current_index = nil @@ -284,58 +505,69 @@ function M.move_mark(name, direction) -- Swap positions mark_order[current_index], mark_order[new_index] = mark_order[new_index], mark_order[current_index] - return save_marks() + return true end +---Clear all marks +---@return boolean success Whether marks were cleared function M.clear_all_marks() marks = {} mark_order = {} - return save_marks() + return true end +---Export marks to JSON file +---@return table result Export result with success status function M.export_marks() local marks_data = M.get_marks() if vim.tbl_isempty(marks_data) then notify("No marks to export", vim.log.levels.INFO) - return false + return { success = false, message = "No marks to export" } end local export_data = { project = current_project, exported_at = os.date("%Y-%m-%d %H:%M:%S"), - version = "2.0", + version = "2.1", marks = marks_data, mark_order = mark_order, + metadata = { + total_marks = vim.tbl_count(marks_data), + project_name = M.get_project_name(), + }, } local ok, json = pcall(vim.json.encode, export_data) - if ok then - vim.ui.input({ - prompt = "Export to: ", - default = "marks_export_" .. os.date("%Y%m%d") .. ".json", - completion = "file", - }, function(filename) - if filename and filename ~= "" then - local success, err = pcall(function() - vim.fn.writefile({ json }, filename) - end) - - if success then - notify("󰃀 Marks exported to " .. filename, vim.log.levels.INFO) - return true - else - notify("Export failed: " .. tostring(err), vim.log.levels.ERROR) - return false - end - end - end) - else - notify("Failed to encode marks for export", vim.log.levels.ERROR) - return false + if not ok then + return { success = false, message = "Failed to encode marks for export" } end + + vim.ui.input({ + prompt = "Export to: ", + default = "marks_export_" .. os.date("%Y%m%d") .. ".json", + completion = "file", + }, function(filename) + if filename and filename ~= "" then + local success, err = pcall(function() + vim.fn.writefile({ json }, filename) + end) + + if success then + notify("󰃀 Marks exported to " .. filename, vim.log.levels.INFO) + return { success = true, message = "Marks exported successfully", filename = filename } + else + notify("Export failed: " .. tostring(err), vim.log.levels.ERROR) + return { success = false, message = "Export failed: " .. tostring(err) } + end + end + end) + + return { success = true, message = "Export initiated" } end +---Import marks from JSON file +---@return table result Import result with success status function M.import_marks() vim.ui.input({ prompt = "Import from: ", @@ -343,12 +575,12 @@ function M.import_marks() completion = "file", }, function(filename) if not filename or filename == "" then - return + return { success = false, message = "No file specified" } end if vim.fn.filereadable(filename) == 0 then notify("File not found: " .. filename, vim.log.levels.WARN) - return + return { success = false, message = "File not found: " .. filename } end local ok, err = pcall(function() @@ -356,6 +588,12 @@ function M.import_marks() if #content > 0 then local data = vim.json.decode(table.concat(content, "\n")) if data and data.marks then + -- Validate imported marks + local valid, validation_err = validate_marks_data(data.marks) + if not valid then + error("Invalid marks file: " .. validation_err) + end + local marks_data = M.get_marks() -- Ask about merge strategy @@ -366,7 +604,11 @@ function M.import_marks() marks = data.marks mark_order = data.mark_order or {} elseif choice == "Merge" then - marks = vim.tbl_deep_extend("force", marks_data, data.marks) + -- Merge marks + for name, mark in pairs(data.marks) do + marks_data[name] = mark + end + -- Merge order arrays, avoiding duplicates if data.mark_order then for _, name in ipairs(data.mark_order) do @@ -383,25 +625,52 @@ function M.import_marks() end end else - return + return { success = false, message = "Import cancelled" } end - if save_marks() then + if M.save_marks() then notify("󰃀 Marks imported successfully", vim.log.levels.INFO) + return { success = true, message = "Marks imported successfully" } else notify("Failed to save imported marks", vim.log.levels.ERROR) + return { success = false, message = "Failed to save imported marks" } end end) else - notify("Invalid marks file format", vim.log.levels.ERROR) + error("Invalid marks file format") end + else + error("Empty file") end end) if not ok then notify("Import failed: " .. tostring(err), vim.log.levels.ERROR) + return { success = false, message = "Import failed: " .. tostring(err) } end end) + + return { success = true, message = "Import initiated" } +end + +---Get storage file size in bytes +---@return number size File size in bytes +function M.get_storage_file_size() + local file = get_marks_file() + return vim.fn.getfsize(file) +end + +---Cleanup storage module +function M.cleanup() + -- Clear caches + project_root_cache = {} + cache_expiry = {} + + -- Reset state + marks = {} + mark_order = {} + current_project = nil + config = {} end return M diff --git a/lua/marksman/ui.lua b/lua/marksman/ui.lua index e93a4d4..b6dcd92 100644 --- a/lua/marksman/ui.lua +++ b/lua/marksman/ui.lua @@ -1,75 +1,91 @@ -- luacheck: globals vim +---@class UI +---@field config table Plugin configuration +---@field current_window number|nil Current window handle +---@field current_buffer number|nil Current buffer handle local M = {} local config = {} local current_window = nil local current_buffer = nil --- Helper function for conditional notifications +-- File icon mapping for better visual display +local file_icons = { + lua = "", + py = "", + js = "", + ts = "", + jsx = "", + tsx = "", + vue = "﵂", + go = "", + rs = "", + c = "", + cpp = "", + h = "", + hpp = "", + java = "", + kt = "󱈙", + cs = "󰌛", + rb = "", + php = "", + html = "", + css = "", + scss = "", + json = "", + yaml = "", + yml = "", + toml = "", + xml = "󰗀", + md = "", + txt = "", + vim = "", + sh = "", + fish = "󰈺", + zsh = "", + bash = "", +} + +---Helper function for conditional notifications +---@param message string The notification message +---@param level number The log level local function notify(message, level) if not config.silent then vim.notify(message, level) end end +---Setup highlight groups local function setup_highlights() - for name, attrs in pairs(config.highlights) do + for name, attrs in pairs(config.highlights or {}) do vim.api.nvim_set_hl(0, name, attrs) end end +---Get appropriate icon for file extension +---@param filename string File path +---@return string icon File icon local function get_icon_for_file(filename) - local extension = vim.fn.fnamemodify(filename, ":e") - local icons = { - lua = "", - py = "", - js = "", - ts = "", - jsx = "", - tsx = "", - vue = "﵂", - go = "", - rs = "", - c = "", - cpp = "", - h = "", - hpp = "", - java = "", - kt = "󱈙", - cs = "󰌛", - rb = "", - php = "", - html = "", - css = "", - scss = "", - json = "", - yaml = "", - yml = "", - toml = "", - xml = "󰗀", - md = "", - txt = "", - vim = "", - sh = "", - fish = "󰈺", - zsh = "", - bash = "", - } - return icons[extension] or "" + local extension = vim.fn.fnamemodify(filename, ":e"):lower() + return file_icons[extension] or "" end +---Close the current marks window local function close_window() if current_window and vim.api.nvim_win_is_valid(current_window) then - vim.api.nvim_win_close(current_window, true) + pcall(vim.api.nvim_win_close, current_window, true) current_window = nil current_buffer = nil end end +---Get relative path display for better readability +---@param filepath string Full file path +---@return string relative_path Formatted relative path local function get_relative_path_display(filepath) - -- Show relative path from current working directory local rel_path = vim.fn.fnamemodify(filepath, ":~:.") - -- If the path is still too long, show parent directory + filename + + -- If path is too long, show parent directory + filename if #rel_path > 50 then local parent = vim.fn.fnamemodify(filepath, ":h:t") local filename = vim.fn.fnamemodify(filepath, ":t") @@ -78,10 +94,158 @@ local function get_relative_path_display(filepath) return rel_path end +---Create header content for marks window +---@param total_marks number Total number of marks +---@param shown_marks number Number of marks shown +---@param search_query string|nil Search query if any +---@return table lines Array of header lines +---@return table highlights Array of highlight definitions +local function create_header_content(total_marks, shown_marks, search_query) + local lines = {} + local highlights = {} + + -- Title + local title = search_query and search_query ~= "" + and string.format(" 󰃀 Project Marks (filtered: %s) ", search_query) + or " 󰃀 Project Marks " + table.insert(lines, title) + table.insert(highlights, { line = 0, col = 0, end_col = -1, hl_group = "ProjectMarksTitle" }) + table.insert(lines, "") + + -- Stats + local stats_line = string.format(" Showing %d of %d marks", shown_marks, total_marks) + table.insert(lines, stats_line) + table.insert(highlights, { line = 2, col = 0, end_col = -1, hl_group = "ProjectMarksHelp" }) + table.insert(lines, "") + + -- Help text + local help_lines = { + " /1-9: Jump d: Delete r: Rename /: Search", + " J/K: Move up/down C: Clear all q: Close", + } + for _, help_line in ipairs(help_lines) do + table.insert(lines, help_line) + table.insert(highlights, { line = #lines - 1, col = 0, end_col = -1, hl_group = "ProjectMarksHelp" }) + end + table.insert(lines, "") + + return lines, highlights +end + +---Create minimal mark display line +---@param name string Mark name +---@param mark table Mark data +---@param index number Mark index +---@return string line Formatted line +---@return table highlights Array of highlight definitions for this line +local function create_minimal_mark_line(name, mark, index, line_idx) + local filepath = get_relative_path_display(mark.file) + local line = string.format("[%d] %s %s", index, name, filepath) + + local highlights = {} + local number_part = string.format("[%d]", index) + local name_start = #number_part + 1 + local name_end = name_start + #name + + -- Number highlight + table.insert(highlights, { + line = line_idx, + col = 0, + end_col = #number_part, + hl_group = "ProjectMarksNumber", + }) + + -- Name highlight + table.insert(highlights, { + line = line_idx, + col = name_start, + end_col = name_end, + hl_group = "ProjectMarksName", + }) + + -- File path highlight + table.insert(highlights, { + line = line_idx, + col = name_end + 1, + end_col = -1, + hl_group = "ProjectMarksFile", + }) + + return line, highlights +end + +---Create detailed mark display lines +---@param name string Mark name +---@param mark table Mark data +---@param index number Mark index +---@param line_idx number Starting line index +---@return table lines Array of lines for this mark +---@return table highlights Array of highlight definitions +local function create_detailed_mark_lines(name, mark, index, line_idx) + local lines = {} + local highlights = {} + local icon = get_icon_for_file(mark.file) + local rel_path = get_relative_path_display(mark.file) + + local number = string.format("[%d]", index) + local name_part = icon .. " " .. name + local file_part = rel_path .. ":" .. mark.line + + -- Main line with mark name + local main_line = string.format("%s %s", number, name_part) + table.insert(lines, main_line) + + table.insert(highlights, { + line = line_idx, + col = 0, + end_col = #number, + hl_group = "ProjectMarksNumber", + }) + table.insert(highlights, { + line = line_idx, + col = #number + 1, + end_col = #number + 1 + #name_part, + hl_group = "ProjectMarksName", + }) + + -- Preview text + local preview = " │ " .. (mark.text or ""):gsub("^%s+", ""):gsub("%s+$", "") + if #preview > 80 then + preview = preview:sub(1, 77) .. "..." + end + table.insert(lines, preview) + table.insert(highlights, { + line = line_idx + 1, + col = 0, + end_col = -1, + hl_group = "ProjectMarksText", + }) + + -- File info + local info = string.format(" └─ %s", file_part) + table.insert(lines, info) + table.insert(highlights, { + line = line_idx + 2, + col = 6, + end_col = 6 + #file_part, + hl_group = "ProjectMarksFile", + }) + + table.insert(lines, "") + + return lines, highlights +end + +---Create complete marks window content +---@param marks table All marks data +---@param search_query string|nil Optional search query +---@return table lines Array of content lines +---@return table highlights Array of highlight definitions +---@return table mark_info Mapping of line numbers to mark info local function create_marks_content(marks, search_query) local lines = {} local highlights = {} - local mark_info = {} -- Store mark data for each line + local mark_info = {} -- Get ordered mark names from storage local storage = require("marksman.storage") @@ -90,163 +254,86 @@ local function create_marks_content(marks, search_query) -- Filter marks if search query provided local filtered_names = {} if search_query and search_query ~= "" then - search_query = search_query:lower() + local utils = require("marksman.utils") + local filtered_marks = utils.filter_marks(marks, search_query) for _, name in ipairs(mark_names) do - local mark = marks[name] - if mark then - local searchable = (name .. " " .. vim.fn.fnamemodify(mark.file, ":t") .. " " .. (mark.text or "")):lower() - if searchable:find(search_query, 1, true) then - table.insert(filtered_names, name) - end + if filtered_marks[name] then + table.insert(filtered_names, name) end end else filtered_names = mark_names end + local total_marks = vim.tbl_count(marks) + local shown_marks = #filtered_names + + -- Handle minimal mode if config.minimal then - if #filtered_names == 0 then + if shown_marks == 0 then table.insert(lines, " No marks") return lines, highlights, {} end for i, name in ipairs(filtered_names) do local mark = marks[name] - local filepath = get_relative_path_display(mark.file) - local line = string.format("[%d] %s %s", i, name, filepath) + local line_idx = #lines + local line, line_highlights = create_minimal_mark_line(name, mark, i, line_idx) + table.insert(lines, line) - - local line_idx = #lines - 1 mark_info[line_idx] = { name = name, mark = mark, index = i } - - local number_part = string.format("[%d]", i) - local name_start = string.len(number_part) + 1 - local name_end = name_start + string.len(name) - - -- Highlight the number - table.insert(highlights, { - line = line_idx, - col = 0, - end_col = string.len(number_part), - hl_group = "ProjectMarksNumber", - }) - -- Highlight the name - table.insert(highlights, { - line = line_idx, - col = name_start, - end_col = name_end, - hl_group = "ProjectMarksName", - }) - -- Highlight the filepath - table.insert(highlights, { - line = line_idx, - col = name_end + 1, - end_col = -1, - hl_group = "ProjectMarksFile", - }) + + for _, hl in ipairs(line_highlights) do + table.insert(highlights, hl) + end end return lines, highlights, mark_info end - -- Header - local title = search_query - and search_query ~= "" - and string.format(" 󰃀 Project Marks (filtered: %s) ", search_query) - or " 󰃀 Project Marks " - table.insert(lines, title) - table.insert(highlights, { line = 0, col = 0, end_col = -1, hl_group = "ProjectMarksTitle" }) - table.insert(lines, "") - - -- Stats line - local total_marks = vim.tbl_count(marks) - local shown_marks = #filtered_names - local stats_line = string.format(" Showing %d of %d marks", shown_marks, total_marks) - table.insert(lines, stats_line) - table.insert(highlights, { line = 2, col = 0, end_col = -1, hl_group = "ProjectMarksHelp" }) - table.insert(lines, "") - - -- Help text - local help_lines = { - " /1-9: Jump d: Delete r: Rename /: Search", - " J/K: Move up/down C: Clear all q: Close", - } - for _, help_line in ipairs(help_lines) do - table.insert(lines, help_line) - table.insert(highlights, { line = #lines - 1, col = 0, end_col = -1, hl_group = "ProjectMarksHelp" }) + -- Create header + local header_lines, header_highlights = create_header_content(total_marks, shown_marks, search_query) + for _, line in ipairs(header_lines) do + table.insert(lines, line) + end + for _, hl in ipairs(header_highlights) do + table.insert(highlights, hl) end - table.insert(lines, "") - if #filtered_names == 0 then - local no_marks_line = search_query and search_query ~= "" and " No marks found matching search" + -- Handle no marks case + if shown_marks == 0 then + local no_marks_line = search_query and search_query ~= "" + and " No marks found matching search" or " No marks in this project" table.insert(lines, no_marks_line) table.insert(highlights, { line = #lines - 1, col = 0, end_col = -1, hl_group = "ProjectMarksText" }) return lines, highlights, {} end + -- Create detailed mark entries for i, name in ipairs(filtered_names) do local mark = marks[name] - local icon = get_icon_for_file(mark.file) - local rel_path = get_relative_path_display(mark.file) - - local number = string.format("[%d]", i) - local name_part = icon .. " " .. name - local file_part = rel_path .. ":" .. mark.line - - -- Main line with mark name - local line = string.format("%s %s", number, name_part) - table.insert(lines, line) - - local line_idx = #lines - 1 - mark_info[line_idx] = { name = name, mark = mark, index = i } - - table.insert(highlights, { - line = line_idx, - col = 0, - end_col = #number, - hl_group = "ProjectMarksNumber", - }) - table.insert(highlights, { - line = line_idx, - col = #number + 1, - end_col = #number + 1 + #name_part, - hl_group = "ProjectMarksName", - }) - - -- Preview text - local preview = " │ " .. (mark.text or ""):gsub("^%s+", ""):gsub("%s+$", "") - if #preview > 80 then - preview = preview:sub(1, 77) .. "..." + local start_line_idx = #lines + local mark_lines, mark_highlights = create_detailed_mark_lines(name, mark, i, start_line_idx) + + mark_info[start_line_idx] = { name = name, mark = mark, index = i } + + for _, line in ipairs(mark_lines) do + table.insert(lines, line) + end + for _, hl in ipairs(mark_highlights) do + table.insert(highlights, hl) end - table.insert(lines, preview) - table.insert(highlights, { - line = #lines - 1, - col = 0, - end_col = -1, - hl_group = "ProjectMarksText", - }) - - -- File info - local info = string.format(" └─ %s", file_part) - table.insert(lines, info) - table.insert(highlights, { - line = #lines - 1, - col = 6, - end_col = 6 + #file_part, - hl_group = "ProjectMarksFile", - }) - - table.insert(lines, "") end return lines, highlights, mark_info end +---Find mark information for current cursor position +---@param mark_info table Mapping of line numbers to mark info +---@return table|nil mark_info Mark info for cursor position local function get_mark_under_cursor(mark_info) local line = vim.fn.line(".") - - -- Find the closest mark info local closest_mark = nil local closest_distance = math.huge @@ -261,6 +348,12 @@ local function get_mark_under_cursor(mark_info) return closest_mark end +---Setup keymaps for marks window +---@param buf number Buffer handle +---@param marks table Marks data +---@param project_name string Project name +---@param mark_info table Mark info mapping +---@param search_query string|nil Search query local function setup_window_keymaps(buf, marks, project_name, mark_info, search_query) local function refresh_window(new_search) local storage = require("marksman.storage") @@ -273,7 +366,10 @@ local function setup_window_keymaps(buf, marks, project_name, mark_info, search_ if mark_info_item then close_window() local marksman = require("marksman") - marksman.goto_mark(mark_info_item.name) + local result = marksman.goto_mark(mark_info_item.name) + if not result.success then + notify(result.message, vim.log.levels.WARN) + end end end @@ -281,11 +377,14 @@ local function setup_window_keymaps(buf, marks, project_name, mark_info, search_ local mark_info_item = get_mark_under_cursor(mark_info) if mark_info_item then local marksman = require("marksman") - marksman.delete_mark(mark_info_item.name) - -- Force immediate refresh - vim.schedule(function() - refresh_window(search_query) - end) + local result = marksman.delete_mark(mark_info_item.name) + if result.success then + vim.schedule(function() + refresh_window(search_query) + end) + else + notify(result.message, vim.log.levels.WARN) + end end end @@ -298,8 +397,12 @@ local function setup_window_keymaps(buf, marks, project_name, mark_info, search_ }, function(new_name) if new_name and new_name ~= "" and new_name ~= mark_info_item.name then local marksman = require("marksman") - marksman.rename_mark(mark_info_item.name, new_name) - refresh_window(search_query) + local result = marksman.rename_mark(mark_info_item.name, new_name) + if result.success then + refresh_window(search_query) + else + notify(result.message, vim.log.levels.WARN) + end end end) end @@ -309,8 +412,12 @@ local function setup_window_keymaps(buf, marks, project_name, mark_info, search_ local mark_info_item = get_mark_under_cursor(mark_info) if mark_info_item then local marksman = require("marksman") - marksman.move_mark(mark_info_item.name, direction) - refresh_window(search_query) + local result = marksman.move_mark(mark_info_item.name, direction) + if result.success then + refresh_window(search_query) + else + notify(result.message, vim.log.levels.WARN) + end end end @@ -352,22 +459,20 @@ local function setup_window_keymaps(buf, marks, project_name, mark_info, search_ vim.keymap.set("n", "C", clear_all_marks, keymap_opts) -- Reordering - vim.keymap.set("n", "J", function() - move_selected("down") - end, keymap_opts) - vim.keymap.set("n", "K", function() - move_selected("up") - end, keymap_opts) + vim.keymap.set("n", "J", function() move_selected("down") end, keymap_opts) + vim.keymap.set("n", "K", function() move_selected("up") end, keymap_opts) -- Number key navigation for i = 1, 9 do vim.keymap.set("n", tostring(i), function() - -- Find mark with index i for _, info in pairs(mark_info) do if info.index == i then close_window() local marksman = require("marksman") - marksman.goto_mark(info.name) + local result = marksman.goto_mark(info.name) + if not result.success then + notify(result.message, vim.log.levels.WARN) + end return end end @@ -375,47 +480,93 @@ local function setup_window_keymaps(buf, marks, project_name, mark_info, search_ end end +---Calculate optimal window dimensions +---@param content_lines table Array of content lines +---@return table dimensions Window dimensions and position +local function calculate_window_dimensions(content_lines) + local max_width = 120 + local max_height = vim.o.lines - 6 + + -- Calculate content width + local content_width = 0 + for _, line in ipairs(content_lines) do + content_width = math.max(content_width, vim.fn.strdisplaywidth(line)) + end + + local width = math.min(math.max(content_width + 4, 60), max_width) + local height = math.min(#content_lines + 2, max_height) + + return { + width = width, + height = height, + row = (vim.o.lines - height) / 2, + col = (vim.o.columns - width) / 2, + } +end + +-- Public API + +---Setup the UI module +---@param user_config table Plugin configuration function M.setup(user_config) config = user_config or {} setup_highlights() end +---Show marks in floating window +---@param marks table Marks data +---@param project_name string Project name +---@param search_query string|nil Optional search query function M.show_marks_window(marks, project_name, search_query) -- Close existing window close_window() + -- Refresh highlights setup_highlights() + -- Create content local lines, highlights, mark_info = create_marks_content(marks, search_query) -- Create buffer local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) - vim.bo[buf].modifiable = false - vim.bo[buf].buftype = "nofile" - vim.bo[buf].filetype = "marksman" + if not buf or buf == 0 then + notify("Failed to create buffer", vim.log.levels.ERROR) + return + end + + local ok, err = pcall(function() + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + vim.bo[buf].modifiable = false + vim.bo[buf].buftype = "nofile" + vim.bo[buf].filetype = "marksman" + end) + + if not ok then + notify("Failed to setup buffer: " .. tostring(err), vim.log.levels.ERROR) + return + end -- Apply highlights for _, hl in ipairs(highlights) do - vim.api.nvim_buf_add_highlight(buf, -1, hl.hl_group, hl.line, hl.col, hl.end_col) + pcall(vim.api.nvim_buf_add_highlight, buf, -1, hl.hl_group, hl.line, hl.col, hl.end_col) end - -- Calculate window size - local width = math.min(120, vim.o.columns - 10) - local height = math.min(#lines + 2, vim.o.lines - 6) + -- Calculate window dimensions + local dimensions = calculate_window_dimensions(lines) - -- Window options + -- Window title local title = " " .. (project_name or "Project") .. " " if search_query and search_query ~= "" then title = title .. "(filtered) " end + -- Window options local opts = { relative = "editor", - width = width, - height = height, - row = (vim.o.lines - height) / 2, - col = (vim.o.columns - width) / 2, + width = dimensions.width, + height = dimensions.height, + row = dimensions.row, + col = dimensions.col, border = "rounded", style = "minimal", title = title, @@ -423,12 +574,17 @@ function M.show_marks_window(marks, project_name, search_query) } -- Create window - local win = vim.api.nvim_open_win(buf, true, opts) + local win_ok, win = pcall(vim.api.nvim_open_win, buf, true, opts) + if not win_ok then + notify("Failed to create window: " .. tostring(win), vim.log.levels.ERROR) + return + end + current_window = win current_buffer = buf -- Set window highlight - vim.api.nvim_win_set_option(win, "winhighlight", "Normal:Normal,FloatBorder:ProjectMarksBorder") + pcall(vim.api.nvim_win_set_option, win, "winhighlight", "Normal:Normal,FloatBorder:ProjectMarksBorder") -- Setup keymaps setup_window_keymaps(buf, marks, project_name, mark_info, search_query) @@ -442,11 +598,14 @@ function M.show_marks_window(marks, project_name, search_query) end end if first_mark_line ~= math.huge then - vim.fn.cursor(first_mark_line + 1, 1) -- +1 for 1-indexed + pcall(vim.fn.cursor, first_mark_line + 1, 1) -- +1 for 1-indexed end end end +---Show search results in floating window +---@param results table Filtered marks +---@param query string Search query function M.show_search_results(results, query) if vim.tbl_isempty(results) then notify("No marks found matching: " .. query, vim.log.levels.INFO) @@ -456,4 +615,10 @@ function M.show_search_results(results, query) M.show_marks_window(results, "Search Results", query) end +---Cleanup UI resources +function M.cleanup() + close_window() + config = {} +end + return M diff --git a/lua/marksman/utils.lua b/lua/marksman/utils.lua index 0b11fef..3ec653f 100644 --- a/lua/marksman/utils.lua +++ b/lua/marksman/utils.lua @@ -1,9 +1,10 @@ -- luacheck: globals vim +---@class Utils local M = {} --- Pattern matching for smart mark naming +-- Pattern matching for smart mark naming with expanded language support local naming_patterns = { - -- Lua + -- Lua patterns { pattern = "function%s+([%w_.]+)", language = { "lua" }, @@ -19,7 +20,13 @@ local naming_patterns = { language = { "lua" }, type = "table", }, - -- JavaScript/TypeScript + { + pattern = "M%.([%w_]+)%s*=%s*function", + language = { "lua" }, + type = "method", + }, + + -- JavaScript/TypeScript patterns { pattern = "function%s+([%w_]+)", language = { "js", "ts", "jsx", "tsx" }, @@ -30,12 +37,28 @@ local naming_patterns = { language = { "js", "ts", "jsx", "tsx" }, type = "const", }, + { + pattern = "let%s+([%w_]+)%s*=%s*", + language = { "js", "ts", "jsx", "tsx" }, + type = "variable", + }, { pattern = "class%s+([%w_]+)", language = { "js", "ts", "jsx", "tsx" }, type = "class", }, - -- Python + { + pattern = "export%s+function%s+([%w_]+)", + language = { "js", "ts", "jsx", "tsx" }, + type = "export", + }, + { + pattern = "([%w_]+)%s*:%s*function", + language = { "js", "ts", "jsx", "tsx" }, + type = "method", + }, + + -- Python patterns { pattern = "def%s+([%w_]+)", language = { "py" }, @@ -46,18 +69,35 @@ local naming_patterns = { language = { "py" }, type = "class", }, - -- Go + { + pattern = "async%s+def%s+([%w_]+)", + language = { "py" }, + type = "async_function", + }, + + -- Go patterns { pattern = "func%s+([%w_]+)", language = { "go" }, type = "function", }, + { + pattern = "func%s+%([^%)]*%)%s+([%w_]+)", + language = { "go" }, + type = "method", + }, { pattern = "type%s+([%w_]+)%s+struct", language = { "go" }, type = "struct", }, - -- Rust + { + pattern = "type%s+([%w_]+)%s+interface", + language = { "go" }, + type = "interface", + }, + + -- Rust patterns { pattern = "fn%s+([%w_]+)", language = { "rs" }, @@ -68,18 +108,45 @@ local naming_patterns = { language = { "rs" }, type = "struct", }, - -- C/C++ + { + pattern = "enum%s+([%w_]+)", + language = { "rs" }, + type = "enum", + }, + { + pattern = "trait%s+([%w_]+)", + language = { "rs" }, + type = "trait", + }, + { + pattern = "impl%s+([%w_]+)", + language = { "rs" }, + type = "impl", + }, + + -- C/C++ patterns { pattern = "class%s+([%w_]+)", - language = { "cpp", "cc", "cxx" }, + language = { "cpp", "cc", "cxx", "hpp" }, type = "class", }, { pattern = "struct%s+([%w_]+)", - language = { "c", "cpp", "cc", "cxx" }, + language = { "c", "cpp", "cc", "cxx", "h", "hpp" }, type = "struct", }, - -- Java + { + pattern = "enum%s+([%w_]+)", + language = { "c", "cpp", "cc", "cxx", "h", "hpp" }, + type = "enum", + }, + { + pattern = "typedef%s+struct%s+([%w_]+)", + language = { "c", "h" }, + type = "typedef", + }, + + -- Java patterns { pattern = "class%s+([%w_]+)", language = { "java" }, @@ -90,7 +157,18 @@ local naming_patterns = { language = { "java" }, type = "interface", }, - -- Generic patterns for many languages + { + pattern = "enum%s+([%w_]+)", + language = { "java" }, + type = "enum", + }, + { + pattern = "public%s+class%s+([%w_]+)", + language = { "java" }, + type = "class", + }, + + -- Generic patterns { pattern = "#define%s+([%w_]+)", language = { "c", "cpp", "h", "hpp" }, @@ -98,10 +176,18 @@ local naming_patterns = { }, } +---Get file extension from filename +---@param filename string File path +---@return string extension File extension in lowercase local function get_file_extension(filename) return vim.fn.fnamemodify(filename, ":e"):lower() end +---Extract context information from a line of code +---@param line string Code line to analyze +---@param file_ext string File extension +---@return string|nil identifier Extracted identifier +---@return string|nil type Type of identifier (function, class, etc.) local function get_context_from_line(line, file_ext) if not line or line == "" then return nil @@ -125,28 +211,22 @@ local function get_context_from_line(line, file_ext) if matches_language then local identifier = context:match(pattern_info.pattern) - if identifier then + if identifier and identifier ~= "" then return identifier, pattern_info.type end end end - -- Fallback: look for any identifier pattern + -- Fallback patterns local fallback_patterns = { "([%w_]+)%s*[:=]", -- assignment patterns "([%w_]+)%s*{", -- opening brace patterns + "([%w_]+)%s*%(", -- function call patterns } - -- Handle function calls separately to avoid pattern escaping issues - local func_pattern = "([%w_]+)%s*" - local identifier = context:match(func_pattern .. "%(") - if identifier then - return identifier, "identifier" - end - for _, pattern in ipairs(fallback_patterns) do local identifier = context:match(pattern) - if identifier then + if identifier and identifier ~= "" then return identifier, "identifier" end end @@ -154,9 +234,14 @@ local function get_context_from_line(line, file_ext) return nil end +---Get surrounding context from nearby lines +---@param line_num number Current line number +---@param max_lines number Maximum lines to search (default: 3) +---@return table|nil context Context information with identifier, type, and distance local function get_surrounding_context(line_num, max_lines) max_lines = max_lines or 3 local contexts = {} + local file_ext = get_file_extension(vim.fn.expand("%")) -- Look up and down for context for offset = -max_lines, max_lines do @@ -164,9 +249,13 @@ local function get_surrounding_context(line_num, max_lines) local context_line_num = line_num + offset if context_line_num > 0 and context_line_num <= vim.fn.line("$") then local context_line = vim.fn.getline(context_line_num) - local identifier, type = get_context_from_line(context_line, get_file_extension(vim.fn.expand("%"))) + local identifier, type = get_context_from_line(context_line, file_ext) if identifier then - table.insert(contexts, { identifier = identifier, type = type, distance = math.abs(offset) }) + table.insert(contexts, { + identifier = identifier, + type = type, + distance = math.abs(offset), + }) end end end @@ -180,6 +269,99 @@ local function get_surrounding_context(line_num, max_lines) return contexts[1] -- Return closest context end +-- Public API + +---Validate mark name according to rules +---@param name string Mark name to validate +---@return boolean valid Whether the name is valid +---@return string|nil error Error message if invalid +function M.validate_mark_name(name) + if not name or type(name) ~= "string" then + return false, "Mark name must be a string" + end + + if name:match("^%s*$") then + return false, "Mark name cannot be empty or whitespace" + end + + if #name > 50 then + return false, "Mark name too long (max 50 characters)" + end + + if #name < 1 then + return false, "Mark name too short (min 1 character)" + end + + -- Check for invalid characters + if name:match('[<>:"/\\|?*]') then + return false, "Mark name contains invalid characters: < > : \" / \\ | ? *" + end + + -- Check for reserved names + local reserved_names = { "CON", "PRN", "AUX", "NUL" } + local upper_name = name:upper() + for _, reserved in ipairs(reserved_names) do + if upper_name == reserved then + return false, "Mark name cannot be a reserved system name" + end + end + + return true +end + +---Validate mark data structure +---@param mark table Mark data to validate +---@return boolean valid Whether the mark is valid +---@return string|nil error Error message if invalid +function M.validate_mark_data(mark) + if type(mark) ~= "table" then + return false, "Mark must be a table" + end + + local required_fields = { "file", "line", "col" } + for _, field in ipairs(required_fields) do + if not mark[field] then + return false, "Missing required field: " .. field + end + end + + if type(mark.line) ~= "number" or mark.line < 1 then + return false, "Line must be a positive number" + end + + if type(mark.col) ~= "number" or mark.col < 1 then + return false, "Column must be a positive number" + end + + if type(mark.file) ~= "string" or mark.file == "" then + return false, "File must be a non-empty string" + end + + -- Validate file exists and is readable + if vim.fn.filereadable(mark.file) == 0 then + return false, "File does not exist or is not readable: " .. mark.file + end + + -- Validate optional fields + if mark.text and type(mark.text) ~= "string" then + return false, "Text field must be a string" + end + + if mark.description and type(mark.description) ~= "string" then + return false, "Description field must be a string" + end + + if mark.created_at and type(mark.created_at) ~= "number" then + return false, "Created_at field must be a number (timestamp)" + end + + return true +end + +---Generate intelligent mark name based on code context +---@param bufname string Buffer file path +---@param line number Line number +---@return string mark_name Generated mark name function M.generate_mark_name(bufname, line) local filename = vim.fn.fnamemodify(bufname, ":t:r") local file_ext = get_file_extension(bufname) @@ -200,12 +382,22 @@ function M.generate_mark_name(bufname, line) -- Generate name based on what we found if identifier then local prefix = "" - if type == "function" then + if type == "function" or type == "async_function" then prefix = "fn:" elseif type == "class" then prefix = "class:" elseif type == "struct" then prefix = "struct:" + elseif type == "method" then + prefix = "method:" + elseif type == "interface" then + prefix = "interface:" + elseif type == "enum" then + prefix = "enum:" + elseif type == "trait" then + prefix = "trait:" + elseif type == "const" or type == "variable" then + prefix = "var:" end return prefix .. identifier end @@ -214,6 +406,10 @@ function M.generate_mark_name(bufname, line) return filename .. ":" .. line end +---Filter marks based on search query +---@param marks table All marks +---@param query string Search query +---@return table filtered_marks Marks matching the query function M.filter_marks(marks, query) if not query or query == "" then return marks @@ -223,11 +419,18 @@ function M.filter_marks(marks, query) local search_terms = vim.split(query:lower(), "%s+") for name, mark in pairs(marks) do - local searchable_text = (name .. " " .. vim.fn.fnamemodify(mark.file, ":t") .. " " .. vim.fn.fnamemodify( - mark.file, - ":p:h:t" - ) .. " " .. (mark.text or "")):lower() - + -- Create searchable text from multiple sources + local searchable_parts = { + name, + vim.fn.fnamemodify(mark.file, ":t"), -- filename + vim.fn.fnamemodify(mark.file, ":p:h:t"), -- parent directory + mark.text or "", -- line content + mark.description or "", -- description if available + } + + local searchable_text = table.concat(searchable_parts, " "):lower() + + -- Check if all search terms match local matches_all = true for _, term in ipairs(search_terms) do if not searchable_text:find(term, 1, true) then @@ -244,12 +447,15 @@ function M.filter_marks(marks, query) return filtered end +---Sanitize mark name for safe filesystem usage +---@param name string Raw mark name +---@return string|nil sanitized_name Sanitized name or nil if invalid function M.sanitize_mark_name(name) if not name or name == "" then return nil end - -- Remove or replace problematic characters + -- Replace or remove problematic characters local sanitized = name:gsub('[<>:"/\\|?*]', "_") -- Replace filesystem-unsafe chars sanitized = sanitized:gsub("%s+", "_") -- Replace spaces with underscores sanitized = sanitized:gsub("_+", "_") -- Collapse multiple underscores @@ -261,9 +467,18 @@ function M.sanitize_mark_name(name) sanitized = sanitized:sub(1, 50):gsub("_*$", "") end - return sanitized ~= "" and sanitized or nil + -- Ensure minimum length + if #sanitized < 1 then + return nil + end + + return sanitized end +---Get relative path from base directory +---@param filepath string Full file path +---@param base_path string|nil Base directory (default: cwd) +---@return string relative_path Relative path function M.get_relative_path(filepath, base_path) base_path = base_path or vim.fn.getcwd() @@ -271,15 +486,24 @@ function M.get_relative_path(filepath, base_path) local abs_file = vim.fn.fnamemodify(filepath, ":p") local abs_base = vim.fn.fnamemodify(base_path, ":p") + -- Ensure base path ends with separator + if not abs_base:match("/$") then + abs_base = abs_base .. "/" + end + -- Check if file is under base path if abs_file:sub(1, #abs_base) == abs_base then - return abs_file:sub(#abs_base + 2) -- +2 to skip the trailing slash + return abs_file:sub(#abs_base + 1) end -- Fallback to just filename if not under base return vim.fn.fnamemodify(filepath, ":t") end +---Format file path for display with length limit +---@param filepath string Full file path +---@param max_length number|nil Maximum display length (default: 50) +---@return string formatted_path Formatted path function M.format_file_path(filepath, max_length) max_length = max_length or 50 @@ -302,35 +526,13 @@ function M.format_file_path(filepath, max_length) return "..." .. rel_path:sub(-(max_length - 3)) end -function M.validate_mark_data(mark) - if type(mark) ~= "table" then - return false, "Mark must be a table" - end - - local required_fields = { "file", "line", "col" } - for _, field in ipairs(required_fields) do - if not mark[field] then - return false, "Missing required field: " .. field - end - end - - if type(mark.line) ~= "number" or mark.line < 1 then - return false, "Line must be a positive number" - end - - if type(mark.col) ~= "number" or mark.col < 1 then - return false, "Column must be a positive number" - end - - if type(mark.file) ~= "string" or mark.file == "" then - return false, "File must be a non-empty string" - end - - return true -end - +---Merge marks with different strategies +---@param base_marks table Base marks +---@param new_marks table New marks to merge +---@param strategy string|nil Merge strategy: "merge", "replace", "skip_existing" +---@return table merged_marks Merged marks function M.merge_marks(base_marks, new_marks, strategy) - strategy = strategy or "merge" -- "merge", "replace", "skip_existing" + strategy = strategy or "merge" local result = vim.deepcopy(base_marks) @@ -343,6 +545,11 @@ function M.merge_marks(base_marks, new_marks, strategy) result[name] = mark elseif strategy == "merge" then result[name] = vim.tbl_deep_extend("force", result[name], mark) + elseif strategy == "skip_existing" then + -- Don't overwrite existing marks + if not result[name] then + result[name] = mark + end end end end @@ -350,6 +557,11 @@ function M.merge_marks(base_marks, new_marks, strategy) return result end +---Suggest a unique mark name based on existing marks +---@param bufname string Buffer file path +---@param line number Line number +---@param existing_marks table Existing marks to avoid conflicts +---@return string suggested_name Unique suggested name function M.suggest_mark_name(bufname, line, existing_marks) local base_name = M.generate_mark_name(bufname, line) local sanitized = M.sanitize_mark_name(base_name) @@ -376,4 +588,104 @@ function M.suggest_mark_name(bufname, line, existing_marks) return final_name end +---Get statistics about marks +---@param marks table All marks +---@return table stats Statistics about the marks +function M.get_marks_statistics(marks) + local stats = { + total_marks = vim.tbl_count(marks), + file_count = 0, + files = {}, + types = {}, + oldest_mark = nil, + newest_mark = nil, + } + + local files_set = {} + for name, mark in pairs(marks) do + -- Count unique files + if not files_set[mark.file] then + files_set[mark.file] = true + stats.file_count = stats.file_count + 1 + table.insert(stats.files, mark.file) + end + + -- Count mark types based on name prefix + local mark_type = "other" + if name:match("^fn:") then + mark_type = "function" + elseif name:match("^class:") then + mark_type = "class" + elseif name:match("^struct:") then + mark_type = "struct" + elseif name:match("^method:") then + mark_type = "method" + elseif name:match("^var:") then + mark_type = "variable" + end + + stats.types[mark_type] = (stats.types[mark_type] or 0) + 1 + + -- Track oldest/newest marks + if mark.created_at then + if not stats.oldest_mark or mark.created_at < stats.oldest_mark.created_at then + stats.oldest_mark = mark + end + if not stats.newest_mark or mark.created_at > stats.newest_mark.created_at then + stats.newest_mark = mark + end + end + end + + return stats +end + +---Check if mark is stale (file no longer exists or line changed significantly) +---@param mark table Mark data +---@return boolean is_stale Whether the mark appears stale +---@return string|nil reason Reason why it's stale +function M.is_mark_stale(mark) + -- Check if file exists + if vim.fn.filereadable(mark.file) == 0 then + return true, "File no longer exists" + end + + -- Check if line number is valid + local file_lines = vim.fn.readfile(mark.file) + if mark.line > #file_lines then + return true, "Line number exceeds file length" + end + + -- Check if content has changed significantly (if we have original text) + if mark.text then + local current_text = file_lines[mark.line] + if current_text then + -- Simple similarity check - if less than 50% similar, consider stale + local similarity = 0 + local words1 = vim.split(mark.text:lower(), "%s+") + local words2 = vim.split(current_text:lower(), "%s+") + + local common_words = 0 + for _, word1 in ipairs(words1) do + for _, word2 in ipairs(words2) do + if word1 == word2 then + common_words = common_words + 1 + break + end + end + end + + if #words1 > 0 then + similarity = common_words / #words1 + end + + if similarity < 0.5 then + return true, "Line content has changed significantly" + end + end + end + + return false +end + return M