diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml deleted file mode 100644 index 0a0e577..0000000 --- a/.forgejo/workflows/release.yml +++ /dev/null @@ -1,17 +0,0 @@ -on: workflow_dispatch - -jobs: - release: - name: Release - runs-on: ubuntu-latest - permissions: - contents: write # to be able to publish a GitHub release - issues: write # to be able to comment on released issues - pull-requests: write # to be able to comment on released pull requests - id-token: write # to enable use of OIDC for npm provenance - steps: - - name: Create Release - uses: https://git.kjan.de/actions/semantic-release@main - with: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} diff --git a/Makefile b/Makefile deleted file mode 100644 index ed0daf9..0000000 --- a/Makefile +++ /dev/null @@ -1,105 +0,0 @@ -# JQuote Plugin Makefile -# Author: Jan -# License: MIT - -.PHONY: test test-verbose lint format check clean install help - -# Default target -.DEFAULT_GOAL := help - -# Colors for output -BOLD := \033[1m -RED := \033[31m -GREEN := \033[32m -YELLOW := \033[33m -BLUE := \033[34m -RESET := \033[0m - -# Project directories -LUA_DIR := lua -TEST_DIR := tests -PLUGIN_DIR := plugin - -# Lua interpreter -LUA := lua - -## Run all tests -test: - @echo "$(BLUE)$(BOLD)Running JQuote Plugin Tests...$(RESET)" - @cd $(PWD) && $(LUA) tests/test_runner.lua - -## Run tests with verbose output -test-verbose: test - @echo "$(GREEN)Test execution completed$(RESET)" - -## Check code quality (basic syntax check) -check: - @echo "$(BLUE)$(BOLD)Checking Lua syntax...$(RESET)" - @for file in $$(find $(LUA_DIR) -name "*.lua"); do \ - echo "Checking $$file..."; \ - $(LUA) -e "loadfile('$$file')"; \ - if [ $$? -ne 0 ]; then \ - echo "$(RED)Syntax error in $$file$(RESET)"; \ - exit 1; \ - fi; \ - done - @echo "$(GREEN)All Lua files have valid syntax$(RESET)" - -## Format code (placeholder - would use stylua if available) -format: - @echo "$(YELLOW)Code formatting would require stylua or similar tool$(RESET)" - @echo "$(YELLOW)Install with: cargo install stylua$(RESET)" - -## Run linter (placeholder - would use luacheck if available) -lint: - @echo "$(YELLOW)Linting would require luacheck$(RESET)" - @echo "$(YELLOW)Install with: luarocks install luacheck$(RESET)" - -## Clean temporary files -clean: - @echo "$(BLUE)Cleaning temporary files...$(RESET)" - @find . -name "*.tmp" -delete 2>/dev/null || true - @find . -name "*.log" -delete 2>/dev/null || true - @echo "$(GREEN)Cleanup completed$(RESET)" - -## Install plugin (symlink to Neovim config) -install: - @echo "$(BLUE)$(BOLD)Installing JQuote Plugin...$(RESET)" - @if [ -z "$$XDG_CONFIG_HOME" ]; then \ - NVIM_CONFIG="$$HOME/.config/nvim"; \ - else \ - NVIM_CONFIG="$$XDG_CONFIG_HOME/nvim"; \ - fi; \ - if [ ! -d "$$NVIM_CONFIG" ]; then \ - echo "$(RED)Neovim config directory not found: $$NVIM_CONFIG$(RESET)"; \ - exit 1; \ - fi; \ - PLUGIN_TARGET="$$NVIM_CONFIG/lua/jquote"; \ - if [ -L "$$PLUGIN_TARGET" ] || [ -d "$$PLUGIN_TARGET" ]; then \ - echo "$(YELLOW)Plugin already installed, removing old version...$(RESET)"; \ - rm -rf "$$PLUGIN_TARGET"; \ - fi; \ - ln -s "$(PWD)/$(LUA_DIR)/jquote" "$$PLUGIN_TARGET"; \ - echo "$(GREEN)Plugin installed successfully to $$PLUGIN_TARGET$(RESET)" - -## Run development checks (syntax + tests) -dev-check: check test - @echo "$(GREEN)$(BOLD)All development checks passed!$(RESET)" - -## Show help -help: - @echo "$(BOLD)JQuote Plugin Development Commands$(RESET)" - @echo "" - @echo "$(BOLD)Available targets:$(RESET)" - @awk 'BEGIN {FS = ":.*##"} /^[a-zA-Z_-]+:.*##/ { printf " $(BLUE)%-15s$(RESET) %s\n", $$1, $$2 }' $(MAKEFILE_LIST) - @echo "" - @echo "$(BOLD)Usage Examples:$(RESET)" - @echo " make test # Run all tests" - @echo " make check # Check syntax" - @echo " make dev-check # Run all development checks" - @echo " make install # Install plugin to Neovim" - @echo "" - -## Quick development workflow -quick: clean check test - @echo "$(GREEN)$(BOLD)Quick development check completed!$(RESET)" \ No newline at end of file diff --git a/README.md b/README.md deleted file mode 100644 index fc14dbf..0000000 --- a/README.md +++ /dev/null @@ -1,298 +0,0 @@ -# JQuote.nvim - -A powerful Neovim plugin for cycling through quote characters with intelligent auto-jump functionality. - -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Lua](https://img.shields.io/badge/Made%20with-Lua-blueviolet.svg)](https://lua.org) -[![Neovim](https://img.shields.io/badge/NeoVim-%2357A143.svg?&style=for-the-badge&logo=neovim&logoColor=white)](https://neovim.io) - -## โœจ Features - -- **๐Ÿ”„ Quote Cycling**: Seamlessly cycle through different quote characters (`'`, `"`, `` ` ``) -- **๐ŸŽฏ Auto Jump**: Automatically jump to the next quote pair when cursor is not inside quotes -- **โš™๏ธ Configurable**: Customize quote characters, hotkeys, and jump behavior -- **๐Ÿ›ก๏ธ Enterprise-Grade**: Comprehensive error handling and defensive programming -- **๐Ÿ“Š Health Monitoring**: Built-in health check and diagnostics -- **๐Ÿ—๏ธ Version Management**: Dynamic version detection from git tags -- **๐Ÿงช Thoroughly Tested**: Comprehensive unit test suite with 25+ test cases - -## ๐Ÿ“‹ Requirements - -- Neovim 0.5.0 or later -- Lua 5.1+ - -## ๐Ÿ“ฆ Installation - -### Using [lazy.nvim](https://github.com/folke/lazy.nvim) - -```lua -{ - "your-username/jquote.nvim", - config = function() - require("jquote").setup({ - -- Optional configuration - hotkey = "tq", - quote_chars = { "'", '"', "`" }, - jump_behavior = "auto" - }) - end, -} -``` - -### Using [packer.nvim](https://github.com/wbthomason/packer.nvim) - -```lua -use { - "your-username/jquote.nvim", - config = function() - require("jquote").setup() - end -} -``` - -### Using [vim-plug](https://github.com/junegunn/vim-plug) - -```vim -Plug 'your-username/jquote.nvim' - -lua << EOF -require("jquote").setup() -EOF -``` - -### Manual Installation - -```bash -# Clone the repository -git clone https://github.com/your-username/jquote.nvim.git ~/.config/nvim/lua/jquote - -# Or use the Makefile -make install -``` - -## โš™๏ธ Configuration - -### Default Configuration - -```lua -require("jquote").setup({ - hotkey = "tq", -- Key binding for quote toggle - quote_chars = { "'", '"', "`" }, -- Quote characters to cycle through - jump_behavior = "auto" -- "auto" or "manual" jump behavior -}) -``` - -### Configuration Options - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `hotkey` | `string` | `"tq"` | Key mapping for toggling quotes | -| `quote_chars` | `table` | `{ "'", '"', "`" }` | List of quote characters to cycle through | -| `jump_behavior` | `string` | `"auto"` | Jump behavior when cursor is not in quotes (`"auto"` or `"manual"`) | - -### Custom Configuration Examples - -```lua --- Minimal setup with custom hotkey -require("jquote").setup({ - hotkey = "q" -}) - --- Custom quote characters -require("jquote").setup({ - quote_chars = { "'", '"' } -- Only single and double quotes -}) - --- Manual jump behavior -require("jquote").setup({ - jump_behavior = "manual" -- Don't auto-jump, only toggle quotes at cursor -}) - --- Full customization -require("jquote").setup({ - hotkey = "", - quote_chars = { "'", '"', "`", "ยซ", "ยป" }, -- Including Unicode quotes - jump_behavior = "auto" -}) -``` - -## ๐Ÿš€ Usage - -### Basic Usage - -1. **Toggle Quotes**: Place your cursor inside or on a quoted string and press `tq` (or your configured hotkey) -2. **Auto Jump**: When cursor is not inside quotes, the plugin automatically jumps to the next quote pair forward - -### Examples - -```lua --- Before: cursor anywhere in 'hello world' -'hello world' - --- After pressing tq -"hello world" - --- After pressing tq again -`hello world` - --- After pressing tq again (cycles back) -'hello world' -``` - -### Auto Jump Feature - -```lua --- Before: cursor at beginning of line -hello 'world' and "test" -^ - --- After pressing tq (auto-jumps to first quote) -hello 'world' and "test" - ^ --- And toggles the quotes -hello "world" and "test" -``` - -### Multiple Quote Pairs - -```lua --- Works with multiple quote pairs on the same line -print('Hello', "World", `Template`) - --- Handles nested quotes intelligently -outer "inner 'nested' text" end -``` - -## ๐Ÿ”ง Commands and Functions - -### Available Functions - -```lua -local jquote = require("jquote") - --- Setup the plugin -jquote.setup(options) - --- Toggle quotes at cursor position -jquote.toggle_quotes() - --- Get current plugin version -local version = jquote.get_version() - --- Get plugin health information -local health = jquote.health() -``` - -### Health Check - -Check plugin status and configuration: - -```lua -:lua print(vim.inspect(require("jquote").health())) -``` - -Output example: -```lua -{ - hotkey_configured = true, - jump_behavior_valid = true, - options = { ... }, - plugin_version = "1.2.0", - quote_chars_count = 3 -} -``` - -## ๐Ÿงช Development - -### Running Tests - -```bash -# Run all tests -make test - -# Check syntax -make check - -# Run development checks (syntax + tests) -make dev-check - -# Quick development workflow -make quick -``` - -### Test Coverage - -The plugin includes comprehensive unit tests covering: - -- โœ… Plugin initialization and configuration -- โœ… Quote detection and cycling -- โœ… Auto jump functionality -- โœ… Error handling and edge cases -- โœ… Version management -- โœ… Health monitoring -- โœ… Boundary conditions and defensive programming - -### Project Structure - -``` -jquote.nvim/ -โ”œโ”€โ”€ lua/ -โ”‚ โ””โ”€โ”€ jquote/ -โ”‚ โ””โ”€โ”€ init.lua # Main plugin code -โ”œโ”€โ”€ tests/ -โ”‚ โ”œโ”€โ”€ init_spec.lua # Comprehensive test suite -โ”‚ โ”œโ”€โ”€ test_runner.lua # Custom test framework -โ”‚ โ””โ”€โ”€ README.md # Testing documentation -โ”œโ”€โ”€ Makefile # Development workflow -โ””โ”€โ”€ README.md # This file -``` - -## ๐Ÿค Contributing - -Contributions are welcome! Please ensure: - -1. **Code Quality**: Follow the established corporate clean code standards -2. **Testing**: Add tests for new features and ensure all tests pass -3. **Documentation**: Update documentation for any new features or changes - -### Development Workflow - -```bash -# Clone the repository -git clone https://github.com/your-username/jquote.nvim.git -cd jquote.nvim - -# Run tests -make test - -# Check code quality -make check - -# Install for local development -make install -``` - -## ๐Ÿ“„ License - -This project is licensed under the MIT License - -## ๐Ÿ™ Acknowledgments - -- Inspired by the need for efficient quote management in Neovim -- Built with enterprise-grade reliability and comprehensive testing -- Designed for developer productivity and workflow optimization - -## ๐Ÿ“ž Support - -If you encounter any issues or have suggestions: - -1. Check the [Issues](https://git.kjan.de/jank/jquote.nvim/issues) page -2. Run the health check: `:lua print(vim.inspect(require("jquote").health()))` -3. Create a new issue with: - - Your configuration - - Health check output - - Steps to reproduce the problem - ---- - -**Made with โค๏ธ for the Neovim community** diff --git a/lua/jquote/init.lua b/lua/jquote/init.lua index 437785a..6f8ea6e 100644 --- a/lua/jquote/init.lua +++ b/lua/jquote/init.lua @@ -1,28 +1,14 @@ --- JQuote: A Neovim plugin for cycling through quote characters --- @module jquote ---- @author Jan +--- @author Author Name +--- @version 1.0.0 --- @license MIT local M = {} ---- Plugin constants ---- @type table -local CONSTANTS = { - JUMP_BEHAVIORS = { - AUTO = "auto", - MANUAL = "manual", - }, - LOG_LEVELS = vim.log.levels, - PLUGIN_NAME = "JQuote", - MIN_QUOTE_CHARS = 1, - MAX_LINE_LENGTH = 10000, -- Safety limit for line processing - VERSION_FALLBACK = "unknown", -} - --- @class JQuoteOptions --- @field hotkey string The hotkey for toggling quotes (default: "tq") ---- @field quote_chars string[] List of quote characters to cycle through (minimum 1 character required) ---- @field jump_behavior "auto"|"manual" Whether to auto-jump to next pair when not inside quotes (default: "auto") +--- @field quote_chars string[] List of quote characters to cycle through local JQuoteOptions = {} --- Default configuration options @@ -30,7 +16,6 @@ local JQuoteOptions = {} local DEFAULT_OPTIONS = { hotkey = "tq", quote_chars = { "'", '"', "`" }, - jump_behavior = CONSTANTS.JUMP_BEHAVIORS.AUTO, } --- Current plugin options @@ -39,6 +24,7 @@ M.options = vim.deepcopy(DEFAULT_OPTIONS) --- Plugin metadata --- @type table +M._version = "1.0.0" M._name = "jquote" --- Validates if a character is one of the configured quote characters @@ -46,17 +32,10 @@ M._name = "jquote" --- @return boolean true if the character is a configured quote character, false otherwise --- @private local function is_defined_quote_char(char) - -- Input validation with early returns if type(char) ~= "string" or #char ~= 1 then return false end - - -- Defensive programming: ensure quote_chars exists and is valid - if not M.options.quote_chars or type(M.options.quote_chars) ~= "table" then - return false - end - - -- Use explicit loop for better readability and performance + for _, quote_char in ipairs(M.options.quote_chars) do if char == quote_char then return true @@ -65,56 +44,6 @@ local function is_defined_quote_char(char) return false end ---- Finds the next quote pair forwards from the current cursor position ---- @param line string The text line to analyze ---- @param cursor_col number The 0-based cursor column position ---- @return number|nil start_index The 0-based start position of the opening quote ---- @return number|nil end_index The 0-based end position of the closing quote ---- @return string|nil quote_char The quote character used, or nil if not found ---- @private -local function find_next_quote_pair_forward(line, cursor_col) - -- Comprehensive input validation - if type(line) ~= "string" or type(cursor_col) ~= "number" or cursor_col < 0 then - return nil, nil, nil - end - - -- Safety check for excessively long lines - if #line > CONSTANTS.MAX_LINE_LENGTH then - return nil, nil, nil - end - - --- @type table[] Array of quote position records - local quote_positions = {} - - -- Scan line for quote characters starting from cursor position - -- Use math.min to prevent out-of-bounds access - local start_pos = math.min(cursor_col + 2, #line) - for i = start_pos, #line do - local char = line:sub(i, i) - if is_defined_quote_char(char) then - table.insert(quote_positions, { - char = char, - index = i - 1 -- Convert to 0-based indexing - }) - end - end - - -- Find the first matching quote pair using optimized algorithm - for i = 1, #quote_positions do - local start_quote = quote_positions[i] - for j = i + 1, #quote_positions do - local end_quote = quote_positions[j] - - -- Match opening and closing quotes of same type - if start_quote.char == end_quote.char then - return start_quote.index, end_quote.index, start_quote.char - end - end - end - - return nil, nil, nil -end - --- Locates the quoted string boundaries containing the cursor position --- @param line string The text line to analyze --- @param cursor_col number The 0-based cursor column position @@ -123,26 +52,20 @@ end --- @return string|nil quote_char The quote character used, or nil if not found --- @private local function find_quoted_string_at_cursor(line, cursor_col) - -- Comprehensive input validation if type(line) ~= "string" or type(cursor_col) ~= "number" or cursor_col < 0 then return nil, nil, nil end - -- Safety check for excessively long lines - if #line > CONSTANTS.MAX_LINE_LENGTH then - return nil, nil, nil - end - --- @type table[] Array of quote position records local quote_positions = {} - + -- Scan line for quote characters and record their positions for i = 1, #line do local char = line:sub(i, i) if is_defined_quote_char(char) then - table.insert(quote_positions, { - char = char, - index = i - 1 -- Convert to 0-based indexing + table.insert(quote_positions, { + char = char, + index = i - 1 -- Convert to 0-based indexing }) end end @@ -152,14 +75,13 @@ local function find_quoted_string_at_cursor(line, cursor_col) local start_quote = quote_positions[i] for j = i + 1, #quote_positions do local end_quote = quote_positions[j] - + -- Match opening and closing quotes of same type if start_quote.char == end_quote.char then - -- Improved cursor boundary check logic if cursor_col >= start_quote.index and cursor_col <= end_quote.index then return start_quote.index, end_quote.index, start_quote.char end - + -- Skip to next pair to avoid nested matches i = j goto continue_outer_loop @@ -171,38 +93,17 @@ local function find_quoted_string_at_cursor(line, cursor_col) return nil, nil, nil end ---- Utility function for logging with consistent formatting ---- @param message string The message to log ---- @param level number The log level (from vim.log.levels) ---- @private -local function log_message(message, level) - if type(message) ~= "string" then - message = tostring(message) - end - local formatted_message = string.format("%s: %s", CONSTANTS.PLUGIN_NAME, message) - vim.notify(formatted_message, level or CONSTANTS.LOG_LEVELS.INFO) -end - --- Determines the next quote character in the rotation sequence --- @param current_char string The current quote character --- @return string The next quote character in the configured sequence --- @private local function get_next_quote_char(current_char) - -- Input validation with defensive programming if type(current_char) ~= "string" then - log_message("Invalid current_char type, using fallback", CONSTANTS.LOG_LEVELS.WARN) - return M.options.quote_chars[1] or "'" + return M.options.quote_chars[1] end - - -- Ensure quote_chars is valid - if not M.options.quote_chars or type(M.options.quote_chars) ~= "table" or #M.options.quote_chars == 0 then - log_message("Invalid quote_chars configuration, using fallback", CONSTANTS.LOG_LEVELS.ERROR) - return "'" - end - + local quote_count = #M.options.quote_chars - - -- Optimized search with explicit bounds checking + for i = 1, quote_count do if M.options.quote_chars[i] == current_char then -- Cycle to next character (wrapping around to first if at end) @@ -210,67 +111,43 @@ local function get_next_quote_char(current_char) return M.options.quote_chars[next_index] end end - + -- Fallback to first quote character if current not found - log_message("Current quote character not found in configuration, using first available", CONSTANTS.LOG_LEVELS.WARN) return M.options.quote_chars[1] end --- Toggles quote characters around the string at the cursor position --- Changes quotes in a cyclic manner based on the configured quote_chars sequence ---- If cursor is not inside a quote pair, automatically jumps to the next pair forwards ---- @return boolean true if quotes were successfully toggled or cursor moved, false otherwise +--- @return boolean true if quotes were successfully toggled, false otherwise function M.toggle_quotes() - -- Enhanced error handling with detailed logging local success, current_line = pcall(vim.api.nvim_get_current_line) if not success or type(current_line) ~= "string" then - log_message("Failed to get current line", CONSTANTS.LOG_LEVELS.ERROR) + vim.notify("JQuote: Failed to get current line", vim.log.levels.ERROR) return false end - + local success_cursor, cursor_pos = pcall(vim.api.nvim_win_get_cursor, 0) if not success_cursor or type(cursor_pos) ~= "table" or #cursor_pos < 2 then - log_message("Failed to get cursor position", CONSTANTS.LOG_LEVELS.ERROR) + vim.notify("JQuote: Failed to get cursor position", vim.log.levels.ERROR) return false end - + local cursor_row, cursor_col = cursor_pos[1], cursor_pos[2] local start_idx, end_idx, current_char = find_quoted_string_at_cursor(current_line, cursor_col) - -- If cursor is not inside a quote pair, handle based on jump behavior setting if not start_idx or not end_idx or not current_char then - if M.options.jump_behavior == CONSTANTS.JUMP_BEHAVIORS.AUTO then - local next_start_idx, next_end_idx, _ = find_next_quote_pair_forward(current_line, cursor_col) - - if next_start_idx and next_end_idx then - -- Move cursor to the start of the next quote pair - local success_move = pcall(vim.api.nvim_win_set_cursor, 0, { cursor_row, next_start_idx }) - if success_move then - log_message("Jumped to next quote pair", CONSTANTS.LOG_LEVELS.INFO) - return true - else - log_message("Failed to move cursor to next quote pair", CONSTANTS.LOG_LEVELS.ERROR) - return false - end - else - log_message("No quote pairs found forwards on current line", CONSTANTS.LOG_LEVELS.INFO) - return false - end - else - log_message("No quoted string found at cursor position", CONSTANTS.LOG_LEVELS.INFO) - return false - end - end - - -- Toggle quotes for current pair - local next_char = get_next_quote_char(current_char) - if next_char == current_char then - log_message("No alternative quote character available", CONSTANTS.LOG_LEVELS.WARN) + vim.notify("JQuote: No quoted string found at cursor position", vim.log.levels.INFO) return false end - -- Construct new line with replaced quote characters using safe string operations - local success_construct, new_line = pcall(table.concat, { + local next_char = get_next_quote_char(current_char) + if next_char == current_char then + vim.notify("JQuote: No alternative quote character available", vim.log.levels.WARN) + return false + end + + -- Construct new line with replaced quote characters + local new_line = table.concat({ current_line:sub(1, start_idx), next_char, current_line:sub(start_idx + 2, end_idx), @@ -278,24 +155,14 @@ function M.toggle_quotes() current_line:sub(end_idx + 2) }) - if not success_construct or type(new_line) ~= "string" then - log_message("Failed to construct new line with updated quotes", CONSTANTS.LOG_LEVELS.ERROR) - return false - end - local success_set = pcall(vim.api.nvim_set_current_line, new_line) if not success_set then - log_message("Failed to update line", CONSTANTS.LOG_LEVELS.ERROR) + vim.notify("JQuote: Failed to update line", vim.log.levels.ERROR) return false end - - -- Restore cursor position with error handling - local success_restore = pcall(vim.api.nvim_win_set_cursor, 0, { cursor_row, cursor_col }) - if not success_restore then - log_message("Warning: Failed to restore cursor position", CONSTANTS.LOG_LEVELS.WARN) - end - - log_message("Quote characters toggled successfully", CONSTANTS.LOG_LEVELS.INFO) + + -- Restore cursor position + pcall(vim.api.nvim_win_set_cursor, 0, { cursor_row, cursor_col }) return true end @@ -308,37 +175,27 @@ local function validate_options(options) if type(options) ~= "table" then return false, "Options must be a table" end - + if options.hotkey and type(options.hotkey) ~= "string" then return false, "hotkey must be a string" end - + if options.quote_chars then if type(options.quote_chars) ~= "table" then return false, "quote_chars must be a table" end - - if #options.quote_chars < CONSTANTS.MIN_QUOTE_CHARS then - return false, string.format("quote_chars must contain at least %d character(s)", CONSTANTS.MIN_QUOTE_CHARS) + + if #options.quote_chars == 0 then + return false, "quote_chars cannot be empty" end - + for i, char in ipairs(options.quote_chars) do if type(char) ~= "string" or #char ~= 1 then return false, string.format("quote_chars[%d] must be a single character string", i) end end end - - if options.jump_behavior then - if type(options.jump_behavior) ~= "string" then - return false, "jump_behavior must be a string" - end - - if options.jump_behavior ~= CONSTANTS.JUMP_BEHAVIORS.AUTO and options.jump_behavior ~= CONSTANTS.JUMP_BEHAVIORS.MANUAL then - return false, "jump_behavior must be either 'auto' or 'manual'" - end - end - + return true, nil end @@ -347,26 +204,26 @@ end --- @return boolean success Whether setup completed successfully function M.setup(user_options) user_options = user_options or {} - - -- Validate user options with enhanced error reporting + + -- Validate user options local is_valid, error_msg = validate_options(user_options) if not is_valid then - log_message(string.format("Setup error: %s", error_msg), CONSTANTS.LOG_LEVELS.ERROR) + vim.notify(string.format("JQuote setup error: %s", error_msg), vim.log.levels.ERROR) return false end - - -- Merge with defaults using safe deep extend with enhanced error handling + + -- Merge with defaults using safe deep extend local success, merged_options = pcall(vim.tbl_deep_extend, "force", vim.deepcopy(DEFAULT_OPTIONS), user_options) if not success then - log_message("Failed to merge configuration options", CONSTANTS.LOG_LEVELS.ERROR) + vim.notify("JQuote: Failed to merge configuration options", vim.log.levels.ERROR) return false end - + M.options = merged_options - - -- Enhanced defensive check for quote_chars integrity - if not M.options.quote_chars or type(M.options.quote_chars) ~= "table" or #M.options.quote_chars < CONSTANTS.MIN_QUOTE_CHARS then - log_message("quote_chars corrupted or invalid, restoring defaults", CONSTANTS.LOG_LEVELS.WARN) + + -- Defensive check for quote_chars integrity + if not M.options.quote_chars or #M.options.quote_chars == 0 then + vim.notify("JQuote: quote_chars corrupted, restoring defaults", vim.log.levels.WARN) M.options.quote_chars = vim.deepcopy(DEFAULT_OPTIONS.quote_chars) end @@ -379,57 +236,25 @@ function M.setup(user_options) local success_keymap = pcall(vim.keymap.set, "n", M.options.hotkey, M.toggle_quotes, keymap_opts) if not success_keymap then - log_message(string.format("Failed to set keymap for '%s'", M.options.hotkey), CONSTANTS.LOG_LEVELS.ERROR) + vim.notify(string.format("JQuote: Failed to set keymap for '%s'", M.options.hotkey), vim.log.levels.ERROR) return false end - - log_message(string.format("Initialized with hotkey '%s'", M.options.hotkey), CONSTANTS.LOG_LEVELS.INFO) - return true -end - ---- Gets the plugin version from git tags at runtime ---- @return string version The current plugin version or "unknown" if unavailable -function M.get_version() - local success, handle = pcall(io.popen, "git describe --tags --abbrev=0 2>/dev/null") - if not success or not handle then - return CONSTANTS.VERSION_FALLBACK - end - - local version = handle:read("*a") - local close_success = pcall(handle.close, handle) - if not close_success then - log_message("Warning: Failed to close git command handle", CONSTANTS.LOG_LEVELS.WARN) - end - - if version and type(version) == "string" and #version > 0 then - -- Remove trailing whitespace and newlines - version = version:gsub("%s+$", "") - -- Remove 'v' prefix if present (e.g., v1.0.0 -> 1.0.0) - if version:match("^v%d") then - version = version:sub(2) - end - -- Additional validation for semantic versioning format - if version:match("^%d+%.%d+%.%d+") then - return version - end - end - - return CONSTANTS.VERSION_FALLBACK + vim.notify(string.format("JQuote: Initialized with hotkey '%s'", M.options.hotkey), vim.log.levels.INFO) + return true end --- Health check function for plugin diagnostics --- @return table health_info Plugin health information function M.health() local health_info = { + version = M._version, options = M.options, quote_chars_count = M.options.quote_chars and #M.options.quote_chars or 0, hotkey_configured = M.options.hotkey ~= nil, - jump_behavior_valid = M.options.jump_behavior == CONSTANTS.JUMP_BEHAVIORS.AUTO or M.options.jump_behavior == CONSTANTS.JUMP_BEHAVIORS.MANUAL, - plugin_version = M.get_version(), } - + return health_info end -return M +return M \ No newline at end of file diff --git a/release.config.cjs b/release.config.cjs deleted file mode 100644 index ac34c97..0000000 --- a/release.config.cjs +++ /dev/null @@ -1,12 +0,0 @@ -module.exports = { - - branches: ['main'], - plugins: [ - '@semantic-release/commit-analyzer', - '@semantic-release/release-notes-generator', - '@semantic-release/changelog', - ["@saithodev/semantic-release-gitea", { - "giteaUrl": "https://git.kjan.de" - }], - ], -}; diff --git a/tests/README.md b/tests/README.md deleted file mode 100644 index fd65035..0000000 --- a/tests/README.md +++ /dev/null @@ -1,122 +0,0 @@ -# JQuote Plugin Tests - -This directory contains the comprehensive test suite for the JQuote Neovim plugin. - -## Test Structure - -- `init_spec.lua` - Main test file containing all unit tests -- `test_runner.lua` - Custom lightweight test runner -- `README.md` - This file - -## Running Tests - -### Using Make (Recommended) -```bash -# Run all tests -make test - -# Run tests with verbose output -make test-verbose - -# Run development checks (syntax + tests) -make dev-check -``` - -### Direct Execution -```bash -# Run tests directly -lua tests/test_runner.lua - -# Or from project root -cd /path/to/jquote.nvim && lua tests/test_runner.lua -``` - -## Test Categories - -### 1. Plugin Initialization Tests -- Default configuration validation -- User option merging -- Configuration validation -- Key mapping setup - -### 2. Quote Detection Tests -- Finding quoted strings at cursor -- Quote character cycling -- Nested quote handling -- Multiple quote pairs - -### 3. Auto Jump Functionality Tests -- Auto jump to next quote pair -- Manual mode behavior -- Edge case handling - -### 4. Error Handling Tests -- API failure scenarios -- Invalid input handling -- Long line safety limits -- Resource cleanup - -### 5. Version Management Tests -- Git tag version extraction -- Fallback version handling -- Semantic version validation - -### 6. Health Check Tests -- Configuration validation -- Plugin state reporting -- Diagnostic information - -### 7. Edge Cases Tests -- Boundary conditions -- Empty configurations -- Single character strings -- Line boundary quotes - -## Test Output - -The test runner provides colored output: -- ๐ŸŸข Green: Passed tests -- ๐Ÿ”ด Red: Failed tests -- ๐Ÿ”ต Blue: Test categories -- ๐ŸŸก Yellow: Warnings and info - -## Mock System - -Tests use a comprehensive vim API mock that simulates: -- Line content and cursor position -- Notification system -- Key mapping -- Configuration deep merging -- API error scenarios - -## Adding New Tests - -To add new tests, follow this pattern: - -```lua -describe("New Feature", function() - it("should do something specific", function() - -- Setup - mock_vim._set_line_and_cursor("test line", 5) - - -- Execute - local result = jquote.some_function() - - -- Assert - assert.is_true(result) - assert.are.equal("expected", mock_vim._current_line) - end) -end) -``` - -## Best Practices - -1. **Isolation**: Each test should be independent -2. **Clear Names**: Use descriptive test names -3. **Setup/Teardown**: Use `before_each` for test setup -4. **Comprehensive**: Test both success and failure paths -5. **Edge Cases**: Include boundary condition tests - -## Continuous Integration - -These tests are designed to run in CI environments and provide detailed feedback for development workflows. \ No newline at end of file diff --git a/tests/init_spec.lua b/tests/init_spec.lua deleted file mode 100644 index 4f959d0..0000000 --- a/tests/init_spec.lua +++ /dev/null @@ -1,393 +0,0 @@ ---- JQuote Unit Tests ---- @author Jan ---- @license MIT - --- Create global state for mock -local test_state = { - current_line = "", - cursor_row = 1, - cursor_col = 0, - notifications = {}, - keymaps = {} -} - --- Mock vim API for testing -local mock_vim = { - log = { - levels = { - DEBUG = 0, - INFO = 1, - WARN = 2, - ERROR = 3, - } - }, - notify = function(message, level) - table.insert(test_state.notifications, { message = message, level = level }) - end, - api = { - nvim_get_current_line = function() - return test_state.current_line - end, - nvim_win_get_cursor = function() - return { test_state.cursor_row, test_state.cursor_col } - end, - nvim_set_current_line = function(line) - test_state.current_line = line - return true - end, - nvim_win_set_cursor = function(win, pos) - test_state.cursor_row = pos[1] - test_state.cursor_col = pos[2] - return true - end, - }, - keymap = { - set = function(mode, key, func, opts) - test_state.keymaps[key] = { mode = mode, func = func, opts = opts } - return true - end, - }, - tbl_deep_extend = function(behavior, ...) - local result = {} - for _, tbl in ipairs({...}) do - for k, v in pairs(tbl) do - if type(v) == "table" and type(result[k]) == "table" then - result[k] = mock_vim.tbl_deep_extend(behavior, result[k], v) - else - result[k] = v - end - end - end - return result - end, - deepcopy = nil, -- Will be set after mock_vim is defined - -- Test utilities - _reset = function() - test_state.current_line = "" - test_state.cursor_row = 1 - test_state.cursor_col = 0 - test_state.notifications = {} - test_state.keymaps = {} - end, - _get_notifications = function() - return test_state.notifications - end, - _set_line_and_cursor = function(line, col) - test_state.current_line = line - test_state.cursor_col = col or 0 - end, - _keymaps = test_state.keymaps -} - --- Set up deepcopy function now that mock_vim is defined -mock_vim.deepcopy = function(tbl) - if type(tbl) ~= "table" then return tbl end - local copy = {} - for k, v in pairs(tbl) do - if type(v) == "table" then - copy[k] = mock_vim.deepcopy(v) - else - copy[k] = v - end - end - return copy -end - --- Add current directory to Lua path for module loading -package.path = package.path .. ";./?.lua;./?/init.lua" - --- Inject mock vim into global scope BEFORE loading the module -_G.vim = mock_vim - --- Load the module under test -local jquote = require('lua.jquote.init') - --- Test suite -describe("JQuote Plugin", function() - - before_each(function() - mock_vim._reset() - -- Reset plugin to default state - jquote.setup({}) - end) - - describe("Plugin Initialization", function() - - it("should initialize with default options", function() - local success = jquote.setup() - assert.is_true(success) - assert.are.equal("tq", jquote.options.hotkey) - assert.are.same({ "'", '"', "`" }, jquote.options.quote_chars) - assert.are.equal("auto", jquote.options.jump_behavior) - end) - - it("should merge user options with defaults", function() - local user_options = { - hotkey = "q", - quote_chars = { "'", '"' }, - jump_behavior = "manual" - } - local success = jquote.setup(user_options) - assert.is_true(success) - assert.are.equal("q", jquote.options.hotkey) - assert.are.same({ "'", '"' }, jquote.options.quote_chars) - assert.are.equal("manual", jquote.options.jump_behavior) - end) - - it("should validate quote_chars configuration", function() - local success = jquote.setup({ quote_chars = {} }) - assert.is_false(success) - - local notifications = mock_vim._get_notifications() - assert.is_true(#notifications > 0) - assert.matches("quote_chars must contain at least", notifications[1].message) - end) - - it("should validate jump_behavior configuration", function() - local success = jquote.setup({ jump_behavior = "invalid" }) - assert.is_false(success) - - local notifications = mock_vim._get_notifications() - assert.is_true(#notifications > 0) - assert.matches("jump_behavior must be either", notifications[1].message) - end) - - it("should set up key mapping", function() - jquote.setup({ hotkey = "test" }) - assert.is_not_nil(mock_vim._keymaps["test"]) - assert.are.equal("n", mock_vim._keymaps["test"].mode) - end) - end) - - describe("Quote Detection", function() - - it("should find quoted string at cursor position", function() - mock_vim._set_line_and_cursor("hello 'world' test", 8) - local success = jquote.toggle_quotes() - assert.is_true(success) - assert.are.equal('hello "world" test', test_state.current_line) - end) - - it("should cycle through quote characters", function() - jquote.setup({ quote_chars = { "'", '"', "`" } }) - - -- First toggle: ' -> " - mock_vim._set_line_and_cursor("hello 'world'", 8) - jquote.toggle_quotes() - assert.are.equal('hello "world"', test_state.current_line) - - -- Second toggle: " -> ` - mock_vim._set_line_and_cursor('hello "world"', 8) - jquote.toggle_quotes() - assert.are.equal('hello `world`', test_state.current_line) - - -- Third toggle: ` -> ' - mock_vim._set_line_and_cursor('hello `world`', 8) - jquote.toggle_quotes() - assert.are.equal("hello 'world'", test_state.current_line) - end) - - it("should handle nested quotes correctly", function() - mock_vim._set_line_and_cursor('outer "inner \'nested\' text" end', 15) - jquote.toggle_quotes() - assert.are.equal('outer "inner "nested" text" end', test_state.current_line) - end) - - it("should handle multiple quote pairs on same line", function() - mock_vim._set_line_and_cursor("'first' and 'second' pair", 2) - jquote.toggle_quotes() - assert.are.equal('"first" and \'second\' pair', test_state.current_line) - end) - end) - - describe("Auto Jump Functionality", function() - - it("should jump to next quote pair when cursor is not inside quotes", function() - jquote.setup({ jump_behavior = "auto" }) - mock_vim._set_line_and_cursor("hello 'world' and 'test'", 0) - - local success = jquote.toggle_quotes() - assert.is_true(success) - assert.are.equal(6, test_state.cursor_col) -- Should jump to first quote - end) - - it("should not jump when jump_behavior is manual", function() - jquote.setup({ jump_behavior = "manual" }) - mock_vim._set_line_and_cursor("hello 'world' test", 0) - - local success = jquote.toggle_quotes() - assert.is_false(success) - assert.are.equal(0, test_state.cursor_col) -- Cursor should not move - end) - - it("should handle case with no quote pairs found", function() - jquote.setup({ jump_behavior = "auto" }) - mock_vim._set_line_and_cursor("no quotes here", 0) - - local success = jquote.toggle_quotes() - assert.is_false(success) - - local notifications = mock_vim._get_notifications() - assert.is_true(#notifications > 0) - assert.matches("No quote pairs found", notifications[1].message) - end) - end) - - describe("Error Handling", function() - - it("should handle invalid line content gracefully", function() - -- Mock API failure - mock_vim.api.nvim_get_current_line = function() - error("API Error") - end - - local success = jquote.toggle_quotes() - assert.is_false(success) - - local notifications = mock_vim._get_notifications() - assert.is_true(#notifications > 0) - assert.matches("Failed to get current line", notifications[1].message) - end) - - it("should handle cursor position API failure", function() - mock_vim.api.nvim_win_get_cursor = function() - error("Cursor API Error") - end - - local success = jquote.toggle_quotes() - assert.is_false(success) - - local notifications = mock_vim._get_notifications() - assert.is_true(#notifications > 0) - assert.matches("Failed to get cursor position", notifications[1].message) - end) - - it("should handle line update failure", function() - mock_vim._set_line_and_cursor("'test'", 2) - mock_vim.api.nvim_set_current_line = function() - error("Set line error") - end - - local success = jquote.toggle_quotes() - assert.is_false(success) - - local notifications = mock_vim._get_notifications() - assert.is_true(#notifications > 0) - assert.matches("Failed to update line", notifications[1].message) - end) - - it("should handle excessively long lines", function() - local long_line = string.rep("a", 15000) .. "'test'" -- Over MAX_LINE_LENGTH - mock_vim._set_line_and_cursor(long_line, 10000) - - local success = jquote.toggle_quotes() - assert.is_false(success) - end) - end) - - describe("Version Management", function() - - it("should return version from git tag", function() - -- Mock io.popen to return a version - local original_io_popen = io.popen - io.popen = function(cmd) - return { - read = function() return "v1.2.3\n" end, - close = function() return true end - } - end - - local version = jquote.get_version() - assert.are.equal("1.2.3", version) - - -- Restore original function - io.popen = original_io_popen - end) - - it("should return fallback version when git fails", function() - local original_io_popen = io.popen - io.popen = function(cmd) - error("Git not available") - end - - local version = jquote.get_version() - assert.are.equal("unknown", version) - - io.popen = original_io_popen - end) - - it("should validate semantic version format", function() - local original_io_popen = io.popen - io.popen = function(cmd) - return { - read = function() return "invalid-version\n" end, - close = function() return true end - } - end - - local version = jquote.get_version() - assert.are.equal("unknown", version) - - io.popen = original_io_popen - end) - end) - - describe("Health Check", function() - - it("should return comprehensive health information", function() - jquote.setup({ - hotkey = "test", - quote_chars = { "'", '"' }, - jump_behavior = "auto" - }) - - local health = jquote.health() - - assert.is_table(health.options) - assert.are.equal(2, health.quote_chars_count) - assert.is_true(health.hotkey_configured) - assert.is_true(health.jump_behavior_valid) - assert.is_string(health.plugin_version) - end) - - it("should detect invalid configuration in health check", function() - -- Manually corrupt the configuration - jquote.options.jump_behavior = "invalid" - - local health = jquote.health() - assert.is_false(health.jump_behavior_valid) - end) - end) - - describe("Edge Cases", function() - - it("should handle empty quote_chars gracefully", function() - -- Manually set invalid state to test defensive programming - jquote.options.quote_chars = {} - mock_vim._set_line_and_cursor("'test'", 2) - - local success = jquote.toggle_quotes() - assert.is_false(success) - end) - - it("should handle quote at line boundaries", function() - mock_vim._set_line_and_cursor("'start", 0) - local success = jquote.toggle_quotes() - assert.is_false(success) -- No closing quote - end) - - it("should handle cursor at exact quote position", function() - mock_vim._set_line_and_cursor("'test'", 0) -- Cursor on opening quote - local success = jquote.toggle_quotes() - assert.is_true(success) - assert.are.equal('"test"', test_state.current_line) - end) - - it("should handle single character quoted strings", function() - mock_vim._set_line_and_cursor("'a'", 1) - local success = jquote.toggle_quotes() - assert.is_true(success) - assert.are.equal('"a"', test_state.current_line) - end) - end) -end) \ No newline at end of file diff --git a/tests/test_runner.lua b/tests/test_runner.lua deleted file mode 100644 index ca4ecc7..0000000 --- a/tests/test_runner.lua +++ /dev/null @@ -1,217 +0,0 @@ -#!/usr/bin/env lua - ---- Simple Test Runner for JQuote Plugin ---- @author Jan ---- @license MIT - -local test_runner = {} - --- Test result tracking -local stats = { - total = 0, - passed = 0, - failed = 0, - errors = {}, - start_time = os.clock() -} - --- ANSI color codes for terminal output -local colors = { - reset = "\27[0m", - red = "\27[31m", - green = "\27[32m", - yellow = "\27[33m", - blue = "\27[34m", - magenta = "\27[35m", - cyan = "\27[36m", - white = "\27[37m", - bold = "\27[1m" -} - --- Simple assertion library -local assert = { - is_true = function(value, message) - if value ~= true then - error(message or string.format("Expected true, got %s", tostring(value))) - end - end, - - is_false = function(value, message) - if value ~= false then - error(message or string.format("Expected false, got %s", tostring(value))) - end - end, - - are = { - equal = function(expected, actual, message) - if expected ~= actual then - error(message or string.format("Expected %s, got %s", tostring(expected), tostring(actual))) - end - end, - same = function(expected, actual, message) - if not test_runner.deep_equal(expected, actual) then - error(message or string.format("Tables are not the same.\nExpected: %s\nActual: %s", - test_runner.table_tostring(expected), test_runner.table_tostring(actual))) - end - end - }, - - is_not_nil = function(value, message) - if value == nil then - error(message or "Expected non-nil value") - end - end, - - is_string = function(value, message) - if type(value) ~= "string" then - error(message or string.format("Expected string, got %s", type(value))) - end - end, - - is_table = function(value, message) - if type(value) ~= "table" then - error(message or string.format("Expected table, got %s", type(value))) - end - end, - - matches = function(pattern, str, message) - if not string.match(str, pattern) then - error(message or string.format("String '%s' does not match pattern '%s'", str, pattern)) - end - end -} - --- Make assert global for tests -_G.assert = assert - --- Test context management -local current_context = "" -local current_test = "" - --- Utility functions -function test_runner.deep_equal(a, b) - if type(a) ~= type(b) then return false end - if type(a) ~= "table" then return a == b end - - for k, v in pairs(a) do - if not test_runner.deep_equal(v, b[k]) then return false end - end - for k, v in pairs(b) do - if not test_runner.deep_equal(v, a[k]) then return false end - end - return true -end - -function test_runner.table_tostring(t, indent) - if type(t) ~= "table" then return tostring(t) end - indent = indent or 0 - local spaces = string.rep(" ", indent) - local result = "{\n" - for k, v in pairs(t) do - result = result .. spaces .. " " .. tostring(k) .. " = " - if type(v) == "table" then - result = result .. test_runner.table_tostring(v, indent + 1) - else - result = result .. tostring(v) - end - result = result .. ",\n" - end - result = result .. spaces .. "}" - return result -end - --- Test framework functions -function describe(description, func) - current_context = description - print(colors.blue .. colors.bold .. "\n" .. description .. colors.reset) - func() - current_context = "" -end - -function it(description, func) - current_test = description - stats.total = stats.total + 1 - - local success, error_msg = pcall(func) - - if success then - stats.passed = stats.passed + 1 - print(colors.green .. " โœ“ " .. description .. colors.reset) - else - stats.failed = stats.failed + 1 - local full_description = current_context .. " - " .. description - table.insert(stats.errors, { - test = full_description, - error = error_msg - }) - print(colors.red .. " โœ— " .. description .. colors.reset) - print(colors.red .. " " .. error_msg .. colors.reset) - end - - current_test = "" -end - -function before_each(func) - -- Simple implementation - just call the function before each test - -- In a more sophisticated runner, this would be stored and called appropriately - _G._before_each = func -end - --- Override 'it' to call before_each if it exists -local original_it = it -function it(description, func) - if _G._before_each then - _G._before_each() - end - original_it(description, func) -end - --- Test execution -function test_runner.run_tests() - print(colors.cyan .. colors.bold .. "JQuote Plugin Test Suite" .. colors.reset) - print(colors.cyan .. "========================" .. colors.reset) - - -- Load and run tests - local success, error_msg = pcall(dofile, "tests/init_spec.lua") - - if not success then - print(colors.red .. colors.bold .. "Failed to load test file:" .. colors.reset) - print(colors.red .. error_msg .. colors.reset) - return false - end - - -- Print summary - local duration = os.clock() - stats.start_time - print(colors.cyan .. "\n========================" .. colors.reset) - print(colors.white .. colors.bold .. "Test Summary:" .. colors.reset) - print(string.format(" Total tests: %d", stats.total)) - print(colors.green .. string.format(" Passed: %d", stats.passed) .. colors.reset) - - if stats.failed > 0 then - print(colors.red .. string.format(" Failed: %d", stats.failed) .. colors.reset) - print(colors.red .. "\nFailure Details:" .. colors.reset) - for _, error_info in ipairs(stats.errors) do - print(colors.red .. " โ€ข " .. error_info.test .. colors.reset) - print(colors.red .. " " .. error_info.error .. colors.reset) - end - end - - print(string.format(" Duration: %.3fs", duration)) - - local success_rate = stats.total > 0 and (stats.passed / stats.total * 100) or 0 - if success_rate == 100 then - print(colors.green .. colors.bold .. "\n๐ŸŽ‰ All tests passed!" .. colors.reset) - else - print(colors.red .. colors.bold .. string.format("\nโŒ %.1f%% tests passed", success_rate) .. colors.reset) - end - - return stats.failed == 0 -end - --- Command line interface -if arg and arg[0] and arg[0]:match("test_runner%.lua$") then - local success = test_runner.run_tests() - os.exit(success and 0 or 1) -end - -return test_runner \ No newline at end of file