mirror of
https://github.com/ivuorinen/nvim-shellspec.git
synced 2026-01-26 03:24:00 +00:00
feat: add first-class Neovim support with enhanced formatting
- Add modern Lua implementation with modular architecture - Implement HEREDOC preservation and smart comment indentation - Create dual implementation (Neovim Lua + VimScript fallback) - Add comprehensive health check and configuration system - Enhance formatting engine with state machine for context awareness - Update documentation with Lua configuration examples - Add memory files for development workflow and conventions
This commit is contained in:
102
lua/shellspec/autocmds.lua
Normal file
102
lua/shellspec/autocmds.lua
Normal file
@@ -0,0 +1,102 @@
|
||||
-- Neovim-native autocommands for ShellSpec
|
||||
local config = require("shellspec.config")
|
||||
local format = require("shellspec.format")
|
||||
local M = {}
|
||||
|
||||
-- Autocommand group
|
||||
local augroup = vim.api.nvim_create_augroup("ShellSpec", { clear = true })
|
||||
|
||||
-- Setup buffer-local settings
|
||||
local function setup_buffer(bufnr)
|
||||
-- Set buffer options
|
||||
vim.api.nvim_set_option_value("commentstring", "# %s", { buf = bufnr })
|
||||
vim.api.nvim_set_option_value("foldmethod", "indent", { buf = bufnr })
|
||||
vim.api.nvim_set_option_value("shiftwidth", config.get("indent_size"), { buf = bufnr })
|
||||
vim.api.nvim_set_option_value("tabstop", config.get("indent_size"), { buf = bufnr })
|
||||
vim.api.nvim_set_option_value("expandtab", config.get("use_spaces"), { buf = bufnr })
|
||||
|
||||
-- Buffer-local commands
|
||||
vim.api.nvim_buf_create_user_command(bufnr, "ShellSpecFormat", function()
|
||||
format.format_buffer(bufnr)
|
||||
end, { desc = "Format ShellSpec buffer" })
|
||||
|
||||
vim.api.nvim_buf_create_user_command(bufnr, "ShellSpecFormatRange", function(opts)
|
||||
format.format_selection(bufnr, opts.line1, opts.line2)
|
||||
end, {
|
||||
range = true,
|
||||
desc = "Format ShellSpec selection",
|
||||
})
|
||||
|
||||
-- Optional: Set up LSP-style formatting
|
||||
if vim.fn.has("nvim-0.8") == 1 then
|
||||
vim.api.nvim_buf_set_option(bufnr, "formatexpr", 'v:lua.require("shellspec.format").format_buffer()')
|
||||
end
|
||||
end
|
||||
|
||||
-- Create all autocommands
|
||||
function M.setup()
|
||||
-- FileType detection and setup
|
||||
vim.api.nvim_create_autocmd("FileType", {
|
||||
group = augroup,
|
||||
pattern = "shellspec",
|
||||
callback = function(args)
|
||||
setup_buffer(args.buf)
|
||||
end,
|
||||
desc = "Setup ShellSpec buffer",
|
||||
})
|
||||
|
||||
-- Auto-format on save (if enabled)
|
||||
if config.get("auto_format") then
|
||||
vim.api.nvim_create_autocmd("BufWritePre", {
|
||||
group = augroup,
|
||||
pattern = { "*.spec.sh", "*_spec.sh" },
|
||||
callback = function(args)
|
||||
-- Only format if it's a shellspec buffer
|
||||
local filetype = vim.api.nvim_get_option_value("filetype", { buf = args.buf })
|
||||
if filetype == "shellspec" then
|
||||
format.format_buffer(args.buf)
|
||||
end
|
||||
end,
|
||||
desc = "Auto-format ShellSpec files on save",
|
||||
})
|
||||
end
|
||||
|
||||
-- Enhanced filetype detection with better patterns
|
||||
vim.api.nvim_create_autocmd({ "BufRead", "BufNewFile" }, {
|
||||
group = augroup,
|
||||
pattern = {
|
||||
"*_spec.sh",
|
||||
"*.spec.sh",
|
||||
"spec/*.sh",
|
||||
"test/*.sh",
|
||||
},
|
||||
callback = function(args)
|
||||
-- Set filetype to shellspec
|
||||
vim.api.nvim_set_option_value("filetype", "shellspec", { buf = args.buf })
|
||||
end,
|
||||
desc = "Detect ShellSpec files",
|
||||
})
|
||||
|
||||
-- Additional pattern for nested spec directories
|
||||
vim.api.nvim_create_autocmd({ "BufRead", "BufNewFile" }, {
|
||||
group = augroup,
|
||||
pattern = "**/spec/**/*.sh",
|
||||
callback = function(args)
|
||||
vim.api.nvim_set_option_value("filetype", "shellspec", { buf = args.buf })
|
||||
end,
|
||||
desc = "Detect ShellSpec files in nested spec directories",
|
||||
})
|
||||
end
|
||||
|
||||
-- Cleanup function
|
||||
function M.cleanup()
|
||||
vim.api.nvim_clear_autocmds({ group = augroup })
|
||||
end
|
||||
|
||||
-- Update configuration and refresh autocommands
|
||||
function M.refresh()
|
||||
M.cleanup()
|
||||
M.setup()
|
||||
end
|
||||
|
||||
return M
|
||||
51
lua/shellspec/config.lua
Normal file
51
lua/shellspec/config.lua
Normal file
@@ -0,0 +1,51 @@
|
||||
-- ShellSpec configuration management
|
||||
local M = {}
|
||||
|
||||
-- Default configuration
|
||||
M.defaults = {
|
||||
-- Auto-format on save
|
||||
auto_format = false,
|
||||
|
||||
-- Indentation settings
|
||||
indent_size = 2,
|
||||
use_spaces = true,
|
||||
|
||||
-- HEREDOC handling
|
||||
heredoc_patterns = {
|
||||
"<<[A-Z_][A-Z0-9_]*", -- <<EOF, <<DATA, etc.
|
||||
"<<'[^']*'", -- <<'EOF'
|
||||
'<<"[^"]*"', -- <<"EOF"
|
||||
"<<-[A-Z_][A-Z0-9_]*", -- <<-EOF (with leading tab removal)
|
||||
},
|
||||
|
||||
-- Comment indentation
|
||||
indent_comments = true,
|
||||
|
||||
-- Formatting options
|
||||
preserve_empty_lines = true,
|
||||
max_line_length = 160,
|
||||
}
|
||||
|
||||
-- Current configuration
|
||||
M.config = {}
|
||||
|
||||
-- Setup function
|
||||
function M.setup(opts)
|
||||
M.config = vim.tbl_deep_extend("force", M.defaults, opts or {})
|
||||
|
||||
-- Validate configuration
|
||||
if type(M.config.indent_size) ~= "number" or M.config.indent_size < 1 then
|
||||
vim.notify("shellspec: indent_size must be a positive number", vim.log.levels.WARN)
|
||||
M.config.indent_size = M.defaults.indent_size
|
||||
end
|
||||
end
|
||||
|
||||
-- Get configuration value
|
||||
function M.get(key)
|
||||
return M.config[key]
|
||||
end
|
||||
|
||||
-- Initialize with defaults
|
||||
M.setup()
|
||||
|
||||
return M
|
||||
203
lua/shellspec/format.lua
Normal file
203
lua/shellspec/format.lua
Normal file
@@ -0,0 +1,203 @@
|
||||
-- Enhanced ShellSpec DSL formatter with HEREDOC support
|
||||
local config = require("shellspec.config")
|
||||
local M = {}
|
||||
|
||||
-- Formatting state
|
||||
local State = {
|
||||
NORMAL = 1,
|
||||
IN_HEREDOC = 2,
|
||||
IN_DATA_BLOCK = 3,
|
||||
}
|
||||
|
||||
-- HEREDOC detection patterns
|
||||
local function get_heredoc_patterns()
|
||||
return config.get("heredoc_patterns")
|
||||
end
|
||||
|
||||
-- Check if line starts a HEREDOC
|
||||
local function detect_heredoc_start(line)
|
||||
local trimmed = vim.trim(line)
|
||||
for _, pattern in ipairs(get_heredoc_patterns()) do
|
||||
local match = string.match(trimmed, pattern)
|
||||
if match then
|
||||
-- Extract the delimiter
|
||||
local delimiter = string.match(match, "<<-?['\"]?([A-Z_][A-Z0-9_]*)['\"]?")
|
||||
return delimiter
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Check if line ends a HEREDOC
|
||||
local function is_heredoc_end(line, delimiter)
|
||||
if not delimiter then
|
||||
return false
|
||||
end
|
||||
local trimmed = vim.trim(line)
|
||||
return trimmed == delimiter
|
||||
end
|
||||
|
||||
-- Check if line is a ShellSpec block keyword
|
||||
local function is_block_keyword(line)
|
||||
local trimmed = vim.trim(line)
|
||||
|
||||
-- Standard block keywords
|
||||
if string.match(trimmed, "^(Describe|Context|ExampleGroup|It|Specify|Example)") then
|
||||
return true
|
||||
end
|
||||
|
||||
-- Prefixed block keywords (x for skip, f for focus)
|
||||
if string.match(trimmed, "^[xf](Describe|Context|ExampleGroup|It|Specify|Example)") then
|
||||
return true
|
||||
end
|
||||
|
||||
-- Data and Parameters blocks
|
||||
if string.match(trimmed, "^(Data|Parameters)%s*$") then
|
||||
return true
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
-- Check if line is an End keyword
|
||||
local function is_end_keyword(line)
|
||||
local trimmed = vim.trim(line)
|
||||
return string.match(trimmed, "^End%s*$") ~= nil
|
||||
end
|
||||
|
||||
-- Check if line is a comment
|
||||
local function is_comment(line)
|
||||
local trimmed = vim.trim(line)
|
||||
return string.match(trimmed, "^#") ~= nil
|
||||
end
|
||||
|
||||
-- Generate indentation string
|
||||
local function make_indent(level)
|
||||
local indent_size = config.get("indent_size")
|
||||
local use_spaces = config.get("use_spaces")
|
||||
|
||||
if use_spaces then
|
||||
return string.rep(" ", level * indent_size)
|
||||
else
|
||||
return string.rep("\t", level)
|
||||
end
|
||||
end
|
||||
|
||||
-- Main formatting function
|
||||
function M.format_lines(lines)
|
||||
local result = {}
|
||||
local indent_level = 0
|
||||
local state = State.NORMAL
|
||||
local heredoc_delimiter = nil
|
||||
local indent_comments = config.get("indent_comments")
|
||||
|
||||
for _, line in ipairs(lines) do
|
||||
local trimmed = vim.trim(line)
|
||||
|
||||
-- Handle empty lines
|
||||
if trimmed == "" then
|
||||
table.insert(result, line)
|
||||
goto continue
|
||||
end
|
||||
|
||||
-- State machine for HEREDOC handling
|
||||
if state == State.NORMAL then
|
||||
-- Check for HEREDOC start
|
||||
local delimiter = detect_heredoc_start(line)
|
||||
if delimiter then
|
||||
state = State.IN_HEREDOC
|
||||
heredoc_delimiter = delimiter
|
||||
-- Apply current indentation to HEREDOC start line
|
||||
local formatted_line = make_indent(indent_level) .. trimmed
|
||||
table.insert(result, formatted_line)
|
||||
goto continue
|
||||
end
|
||||
|
||||
-- Handle comments with proper indentation
|
||||
if is_comment(line) and indent_comments then
|
||||
local formatted_line = make_indent(indent_level) .. trimmed
|
||||
table.insert(result, formatted_line)
|
||||
goto continue
|
||||
end
|
||||
|
||||
-- Handle End keyword (decrease indent first)
|
||||
if is_end_keyword(line) then
|
||||
indent_level = math.max(0, indent_level - 1)
|
||||
local formatted_line = make_indent(indent_level) .. trimmed
|
||||
table.insert(result, formatted_line)
|
||||
goto continue
|
||||
end
|
||||
|
||||
-- Apply normal indentation for other lines
|
||||
if not is_comment(line) or not indent_comments then
|
||||
local formatted_line = make_indent(indent_level) .. trimmed
|
||||
table.insert(result, formatted_line)
|
||||
|
||||
-- Increase indent after block keywords
|
||||
if is_block_keyword(line) then
|
||||
indent_level = indent_level + 1
|
||||
end
|
||||
else
|
||||
-- Preserve original comment formatting if indent_comments is false
|
||||
table.insert(result, line)
|
||||
end
|
||||
elseif state == State.IN_HEREDOC then
|
||||
-- Check for HEREDOC end
|
||||
if is_heredoc_end(line, heredoc_delimiter) then
|
||||
state = State.NORMAL
|
||||
heredoc_delimiter = nil
|
||||
-- Apply current indentation to HEREDOC end line
|
||||
local formatted_line = make_indent(indent_level) .. trimmed
|
||||
table.insert(result, formatted_line)
|
||||
else
|
||||
-- Preserve original indentation within HEREDOC
|
||||
table.insert(result, line)
|
||||
end
|
||||
end
|
||||
|
||||
::continue::
|
||||
end
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
-- Format entire buffer
|
||||
function M.format_buffer(bufnr)
|
||||
bufnr = bufnr or 0
|
||||
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||
local formatted = M.format_lines(lines)
|
||||
|
||||
-- Store cursor position
|
||||
local cursor_pos = vim.api.nvim_win_get_cursor(0)
|
||||
|
||||
-- Replace buffer content
|
||||
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, formatted)
|
||||
|
||||
-- Restore cursor position
|
||||
pcall(vim.api.nvim_win_set_cursor, 0, cursor_pos)
|
||||
end
|
||||
|
||||
-- Format selection
|
||||
function M.format_selection(bufnr, start_line, end_line)
|
||||
bufnr = bufnr or 0
|
||||
local lines = vim.api.nvim_buf_get_lines(bufnr, start_line - 1, end_line, false)
|
||||
local formatted = M.format_lines(lines)
|
||||
|
||||
-- Replace selection
|
||||
vim.api.nvim_buf_set_lines(bufnr, start_line - 1, end_line, false, formatted)
|
||||
end
|
||||
|
||||
-- Async format function for performance
|
||||
function M.format_buffer_async(bufnr, callback)
|
||||
bufnr = bufnr or 0
|
||||
|
||||
-- Use vim.schedule to avoid blocking
|
||||
vim.schedule(function()
|
||||
M.format_buffer(bufnr)
|
||||
if callback then
|
||||
callback()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
112
lua/shellspec/health.lua
Normal file
112
lua/shellspec/health.lua
Normal file
@@ -0,0 +1,112 @@
|
||||
-- Health check for shellspec.nvim
|
||||
local M = {}
|
||||
|
||||
function M.check()
|
||||
local health = vim.health or require("health")
|
||||
|
||||
health.report_start("ShellSpec.nvim")
|
||||
|
||||
-- Check Neovim version
|
||||
local nvim_version = vim.version()
|
||||
if nvim_version.major > 0 or nvim_version.minor >= 7 then
|
||||
health.report_ok(string.format("Neovim version %d.%d.%d >= 0.7.0", nvim_version.major, nvim_version.minor, nvim_version.patch))
|
||||
else
|
||||
health.report_warn(string.format("Neovim version %d.%d.%d < 0.7.0, some features may not work", nvim_version.major, nvim_version.minor, nvim_version.patch))
|
||||
end
|
||||
|
||||
-- Check if module can be loaded
|
||||
local ok, config = pcall(require, "shellspec.config")
|
||||
if ok then
|
||||
health.report_ok("ShellSpec configuration module loaded successfully")
|
||||
|
||||
-- Report current configuration
|
||||
local current_config = config.config
|
||||
if current_config then
|
||||
health.report_info("Configuration:")
|
||||
health.report_info(" Auto-format: " .. tostring(current_config.auto_format))
|
||||
health.report_info(" Indent size: " .. tostring(current_config.indent_size))
|
||||
health.report_info(" Use spaces: " .. tostring(current_config.use_spaces))
|
||||
health.report_info(" Indent comments: " .. tostring(current_config.indent_comments))
|
||||
end
|
||||
else
|
||||
health.report_error("Failed to load ShellSpec configuration: " .. config)
|
||||
return
|
||||
end
|
||||
|
||||
-- Check formatting module
|
||||
local ok_format, format = pcall(require, "shellspec.format")
|
||||
if ok_format then
|
||||
health.report_ok("ShellSpec formatting module loaded successfully")
|
||||
else
|
||||
health.report_error("Failed to load ShellSpec formatting module: " .. format)
|
||||
end
|
||||
|
||||
-- Check autocommands module
|
||||
local ok_autocmds, autocmds = pcall(require, "shellspec.autocmds")
|
||||
if ok_autocmds then
|
||||
health.report_ok("ShellSpec autocommands module loaded successfully")
|
||||
else
|
||||
health.report_error("Failed to load ShellSpec autocommands module: " .. autocmds)
|
||||
end
|
||||
|
||||
-- Check if we're in a ShellSpec buffer
|
||||
local filetype = vim.bo.filetype
|
||||
if filetype == "shellspec" then
|
||||
health.report_ok("Current buffer is ShellSpec filetype")
|
||||
|
||||
-- Check buffer-local settings
|
||||
local shiftwidth = vim.bo.shiftwidth
|
||||
local expandtab = vim.bo.expandtab
|
||||
local commentstring = vim.bo.commentstring
|
||||
|
||||
health.report_info("Buffer settings:")
|
||||
health.report_info(" shiftwidth: " .. tostring(shiftwidth))
|
||||
health.report_info(" expandtab: " .. tostring(expandtab))
|
||||
health.report_info(" commentstring: " .. tostring(commentstring))
|
||||
else
|
||||
health.report_info("Current buffer filetype: " .. (filetype or "none"))
|
||||
health.report_info("Open a ShellSpec file (*.spec.sh) to test buffer-specific features")
|
||||
end
|
||||
|
||||
-- Check for common ShellSpec files in project
|
||||
local cwd = vim.fn.getcwd()
|
||||
local spec_dirs = { "spec", "test" }
|
||||
local found_specs = false
|
||||
|
||||
for _, dir in ipairs(spec_dirs) do
|
||||
local spec_dir = cwd .. "/" .. dir
|
||||
if vim.fn.isdirectory(spec_dir) == 1 then
|
||||
local files = vim.fn.glob(spec_dir .. "/*.sh", false, true)
|
||||
if #files > 0 then
|
||||
found_specs = true
|
||||
health.report_ok("Found " .. #files .. " ShellSpec files in " .. dir .. "/")
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if not found_specs then
|
||||
local spec_files = vim.fn.glob("**/*_spec.sh", false, true)
|
||||
local spec_files2 = vim.fn.glob("**/*.spec.sh", false, true)
|
||||
local total_specs = #spec_files + #spec_files2
|
||||
|
||||
if total_specs > 0 then
|
||||
health.report_ok("Found " .. total_specs .. " ShellSpec files in project")
|
||||
else
|
||||
health.report_info("No ShellSpec files found in current directory")
|
||||
health.report_info("ShellSpec files typically match: *_spec.sh, *.spec.sh, spec/*.sh")
|
||||
end
|
||||
end
|
||||
|
||||
-- Check commands availability
|
||||
local commands = { "ShellSpecFormat", "ShellSpecFormatRange" }
|
||||
for _, cmd in ipairs(commands) do
|
||||
if vim.fn.exists(":" .. cmd) == 2 then
|
||||
health.report_ok("Command :" .. cmd .. " is available")
|
||||
else
|
||||
health.report_error("Command :" .. cmd .. " is not available")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
90
lua/shellspec/init.lua
Normal file
90
lua/shellspec/init.lua
Normal file
@@ -0,0 +1,90 @@
|
||||
-- Main ShellSpec module for Neovim
|
||||
local M = {}
|
||||
|
||||
-- Lazy-load submodules
|
||||
local config = require("shellspec.config")
|
||||
local format = require("shellspec.format")
|
||||
local autocmds = require("shellspec.autocmds")
|
||||
|
||||
-- Version info
|
||||
M._VERSION = "2.0.0"
|
||||
|
||||
-- Setup function for Lua configuration
|
||||
function M.setup(opts)
|
||||
opts = opts or {}
|
||||
|
||||
-- Setup configuration
|
||||
config.setup(opts)
|
||||
|
||||
-- Setup autocommands
|
||||
autocmds.setup()
|
||||
|
||||
-- Create global commands for compatibility
|
||||
vim.api.nvim_create_user_command("ShellSpecFormat", function()
|
||||
format.format_buffer()
|
||||
end, { desc = "Format current ShellSpec buffer" })
|
||||
|
||||
vim.api.nvim_create_user_command("ShellSpecFormatRange", function(cmd_opts)
|
||||
format.format_selection(0, cmd_opts.line1, cmd_opts.line2)
|
||||
end, {
|
||||
range = true,
|
||||
desc = "Format ShellSpec selection",
|
||||
})
|
||||
|
||||
-- Optional: Enable auto-format if configured
|
||||
if config.get("auto_format") then
|
||||
autocmds.refresh() -- Refresh to pick up auto-format settings
|
||||
end
|
||||
end
|
||||
|
||||
-- Format functions (for external use)
|
||||
M.format_buffer = format.format_buffer
|
||||
M.format_selection = format.format_selection
|
||||
M.format_lines = format.format_lines
|
||||
|
||||
-- Configuration access
|
||||
M.config = config
|
||||
|
||||
-- Health check function for :checkhealth
|
||||
function M.health()
|
||||
local health = vim.health or require("health")
|
||||
|
||||
health.report_start("ShellSpec.nvim")
|
||||
|
||||
-- Check Neovim version
|
||||
if vim.fn.has("nvim-0.7") == 1 then
|
||||
health.report_ok("Neovim version >= 0.7.0")
|
||||
else
|
||||
health.report_warn("Neovim version < 0.7.0, some features may not work")
|
||||
end
|
||||
|
||||
-- Check configuration
|
||||
local current_config = config.config
|
||||
if current_config then
|
||||
health.report_ok("Configuration loaded successfully")
|
||||
health.report_info("Auto-format: " .. tostring(current_config.auto_format))
|
||||
health.report_info("Indent size: " .. tostring(current_config.indent_size))
|
||||
health.report_info("Use spaces: " .. tostring(current_config.use_spaces))
|
||||
else
|
||||
health.report_error("Configuration not loaded")
|
||||
end
|
||||
|
||||
-- Check if in ShellSpec buffer
|
||||
local filetype = vim.bo.filetype
|
||||
if filetype == "shellspec" then
|
||||
health.report_ok("Current buffer is ShellSpec filetype")
|
||||
else
|
||||
health.report_info("Current buffer filetype: " .. (filetype or "none"))
|
||||
end
|
||||
end
|
||||
|
||||
-- Backward compatibility function for VimScript
|
||||
function M.format_buffer_compat()
|
||||
format.format_buffer()
|
||||
end
|
||||
|
||||
function M.format_selection_compat(start_line, end_line)
|
||||
format.format_selection(0, start_line, end_line)
|
||||
end
|
||||
|
||||
return M
|
||||
Reference in New Issue
Block a user