diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..54e0686 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,23 @@ +name: Lint +on: [push, pull_request] +jobs: + stylua: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check formatting + uses: JohnnyMorganz/stylua-action@v5 + with: + token: ${{ secrets.GITHUB_TOKEN }} + # Keep in sync with the rev in .pre-commit-config.yaml. + version: 2.5.2 + args: --check . + + luacheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Lint + uses: lunarmodules/luacheck@v1 + with: + args: . diff --git a/.luacheckrc b/.luacheckrc new file mode 100644 index 0000000..bf470d4 --- /dev/null +++ b/.luacheckrc @@ -0,0 +1,44 @@ +-- Luacheck configuration for md-render.nvim +-- Docs: https://luacheck.readthedocs.io/en/stable/config.html + +-- Neovim embeds LuaJIT. +std = "luajit" +cache = true + +-- Globals injected at runtime by Neovim and optional integrations. +read_globals = { + "vim", + "Snacks", -- folke/snacks.nvim (optional image backend) +} + +-- `vim..foo = ...` assignments are legitimate. Declaring the mutable +-- subtables as writable globals stops luacheck reporting them as writes to a +-- read-only field of the `vim` global. +globals = { + "vim.g", + "vim.b", + "vim.w", + "vim.o", + "vim.bo", + "vim.wo", + "vim.go", + "vim.env", + "vim.opt", +} + +-- Line width is owned by StyLua (column_width in .stylua.toml). A few string +-- literals legitimately exceed it and cannot be wrapped. +ignore = { "631" } + +exclude_files = { "tests/fixtures/" } + +-- Tests intentionally destructure unused return values, shadow module upvalues, +-- and monkeypatch vim.* functions when mocking. +files["tests/"] = { + ignore = { + "211", -- unused local variable + "212", -- unused argument + "431", -- shadowing an upvalue + "122", -- setting a read-only field of vim (mocking vim.notify, vim.api.*) + }, +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ca3ce19 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +# Pre-commit hooks for md-render.nvim. +# Setup: pipx install pre-commit (or: brew install pre-commit) +# pre-commit install +# Run against everything: pre-commit run --all-files +# +# Only StyLua runs here. It downloads its own release binary, so the hook needs no +# local toolchain and formats staged Lua files in place (honoring .stylua.toml). +# +# Luacheck is intentionally CI-only (.github/workflows/lint.yml): its distribution +# can't run cleanly on every local setup (e.g. the official language:lua hook fails +# to build under Lua 5.5, and the Docker image is amd64-only). CI runs it on every +# push / PR, which is enough to guard the lint gate. +repos: + - repo: https://github.com/JohnnyMorganz/StyLua + rev: v2.5.2 + hooks: + - id: stylua-github diff --git a/Makefile b/Makefile index 4ec36e4..dda2497 100644 --- a/Makefile +++ b/Makefile @@ -2,9 +2,19 @@ NVIM := nvim --headless -u NONE --noplugin TEST_FILES := $(sort $(wildcard tests/*_test.lua)) -.PHONY: test $(TEST_FILES) +.PHONY: test $(TEST_FILES) lint format check test: $(TEST_FILES) $(TEST_FILES): $(NVIM) -l $@ + +# Format Lua sources in place (honors .stylua.toml). +format: + stylua . + +# Local gate: formatting check. Luacheck runs in CI (.github/workflows/lint.yml). +lint: + stylua --check . + +check: lint test diff --git a/lua/md-render/content_builder.lua b/lua/md-render/content_builder.lua index 538dae2..b00104f 100644 --- a/lua/md-render/content_builder.lua +++ b/lua/md-render/content_builder.lua @@ -185,7 +185,6 @@ end local wrap_mod = require "md-render.wrap" local icons = require "md-render.icons" -local split_segments = wrap_mod.split_segments local wrap_words = wrap_mod.wrap_words --- Distribute markdown highlights across wrapped lines @@ -1057,8 +1056,6 @@ function ContentBuilder:render_document(lines, opts) local callout_code_block_id = nil local callout_code_has_truncation = false local in_math_block = false - local math_block_start = nil - local math_block_id = nil local in_indented_code = false local in_comment_block = false local in_html_comment = false @@ -1970,12 +1967,8 @@ function ContentBuilder:render_document(lines, opts) if not in_code_block and line:match "^%$%$$" then if not in_math_block then in_math_block = true - math_block_start = #self.lines - math_block_id = src_idx else in_math_block = false - math_block_start = nil - math_block_id = nil end elseif in_math_block then local indented = indent .. line diff --git a/lua/md-render/display_utils.lua b/lua/md-render/display_utils.lua index bb4f3b2..85e987a 100644 --- a/lua/md-render/display_utils.lua +++ b/lua/md-render/display_utils.lua @@ -1,4 +1,3 @@ -local FloatWin = require "md-render.float_win" local UrlHover = require "md-render.url_hover" local M = {} @@ -480,8 +479,6 @@ function M.setup_images(win, content, ns, opts) -- across Neovim restarts but cleanup commands may not have been processed). image.clear_all() - local uv = vim.uv or vim.loop - ---@type MdRender.ImageState local state = { placements = content.image_placements, diff --git a/lua/md-render/image.lua b/lua/md-render/image.lua index 00c61f6..fc67d4e 100644 --- a/lua/md-render/image.lua +++ b/lua/md-render/image.lua @@ -450,7 +450,7 @@ function M.render_mermaid(source) f:close() local cmd = build_mmdc_cmd(cmd_prefix, tmp_input, cache_path) - local result = vim.system(cmd, { text = true, timeout = 30000 }):wait() + vim.system(cmd, { text = true, timeout = 30000 }):wait() os.remove(tmp_input) if vim.fn.filereadable(cache_path) == 1 then return cache_path end @@ -484,7 +484,7 @@ function M.render_mermaid_async(source, callback) local cmd = build_mmdc_cmd(cmd_prefix, tmp_input, cache_path) - vim.system(cmd, { text = true, timeout = 30000 }, function(result) + vim.system(cmd, { text = true, timeout = 30000 }, function(_) vim.schedule(function() os.remove(tmp_input) if vim.fn.filereadable(cache_path) == 1 then @@ -514,6 +514,16 @@ end local _kitty_supported = nil local _is_ghostty = nil +-- Mutable module state hoisted here so M.reset_cache() (defined below, before the +-- sections that use these) resolves to the same upvalues. Declaring them only in +-- the later sections left reset_cache() writing to accidental globals instead. +local _convert_cmd = nil +local _convert_checked = false +local _anim_cmd = nil +local _anim_checked = false +local _image_id = 100 +local _session_cleared = false + --- Check if running inside Ghostty terminal. --- Ghostty does not support t=t (temporary file transfer mode) in Kitty --- graphics protocol, so we must use t=f and clean up temp files ourselves. @@ -915,9 +925,6 @@ end -- Image conversion tool detection -- ============================================================================ -local _convert_cmd = nil -local _convert_checked = false - --- Detect the best available tool for static image conversion (JPEG/WebP → PNG). --- Priority: sips (macOS) → ffmpeg → magick ---@return string? tool "sips", "ffmpeg", or "magick" @@ -934,9 +941,6 @@ local function find_convert_tool() return _convert_cmd end -local _anim_cmd = nil -local _anim_checked = false - --- Detect the best available tool for animated GIF frame extraction. --- Priority: ffmpeg → magick ---@return string? tool "ffmpeg" or "magick" @@ -1156,10 +1160,8 @@ end -- Two-phase image display: transmit + put -- ============================================================================ -local _image_id = 100 local _image_paths = {} -- image_id → file path (for Ghostty a=T workaround) local _temp_image_paths = {} -- image_id → true for temp files that need cleanup -local _session_cleared = false --- Transmit image data to terminal (store without displaying). --- The image can then be displayed cheaply with put_image(). @@ -1263,6 +1265,12 @@ local function build_frame_extract_cmd(tool, path, cache_dir, total_frames) end end +-- Forward declarations: these helpers are defined further down but are already +-- referenced by M.transmit_animated below. Without this, the calls would resolve +-- to (nil) globals instead of the locals. +local get_frames_cache_dir +local get_cached_frames + --- Extract frames from an animated GIF and transmit each as a separate image. --- Large GIFs are resized and frames are sampled to stay under MAX_ANIM_FRAMES. --- Extracted frames are cached on disk for fast subsequent loads. @@ -1365,7 +1373,7 @@ end --- Uses a hash of the source path to create a stable directory name. ---@param gif_path string ---@return string -local function get_frames_cache_dir(gif_path) +function get_frames_cache_dir(gif_path) local hash = vim.fn.sha256(gif_path):sub(1, 16) local dir = get_cache_dir() .. "/frames_" .. hash return dir @@ -1375,7 +1383,7 @@ end ---@param gif_path string ---@param cache_dir string ---@return string[]? sorted list of frame PNG paths, or nil if cache miss -local function get_cached_frames(gif_path, cache_dir) +function get_cached_frames(gif_path, cache_dir) local frames = vim.fn.glob(cache_dir .. "/frame_*.png", false, true) if #frames == 0 then return nil end table.sort(frames) diff --git a/lua/md-render/markdown.lua b/lua/md-render/markdown.lua index c3903e9..424a65e 100644 --- a/lua/md-render/markdown.lua +++ b/lua/md-render/markdown.lua @@ -513,42 +513,6 @@ local function process_underscore_emphasis(text, hl_group, highlights, links) return processed end ---- Process code markers (backticks) by removing them and adding highlights ----@param text string The input text ----@param hl_group string The highlight group to apply ----@param highlights MdRender.Markdown.Highlight[] Existing highlights to adjust ----@param links MdRender.Markdown.Link[] Existing links to adjust ----@return string processed The text with backticks removed -local function process_code_markers(text, hl_group, highlights, links) - local pre_hl_count = #highlights - local pre_link_count = #links - local removals = {} - local processed = "" - local i = 1 - while i <= #text do - if text:sub(i, i) == "`" then - local e = text:find("`", i + 1) - if e then - table.insert(removals, { start = i - 1, count = 1 }) - table.insert(removals, { start = e - 1, count = 1 }) - local content = text:sub(i + 1, e - 1) - local start_col = #processed - processed = processed .. content - table.insert(highlights, { col = start_col, end_col = start_col + #content, hl = hl_group }) - i = e + 1 - else - processed = processed .. text:sub(i, i) - i = i + 1 - end - else - processed = processed .. text:sub(i, i) - i = i + 1 - end - end - adjust_positions(highlights, links, removals, pre_hl_count, pre_link_count) - return processed -end - --- Process [[wikilinks]]: display as link text with highlight ---@param text string ---@param highlights MdRender.Markdown.Highlight[] diff --git a/lua/md-render/markdown_table.lua b/lua/md-render/markdown_table.lua index b8a740b..883547e 100644 --- a/lua/md-render/markdown_table.lua +++ b/lua/md-render/markdown_table.lua @@ -492,7 +492,6 @@ function MarkdownTable.render(parsed_table, indent, max_width, expanded, buf_dir for _, w in ipairs(col_widths) do content_sum = content_sum + w end - local total = overhead + content_sum if has_image_cells then -- Ensure columns are wide enough for actual image display sizes @@ -517,7 +516,7 @@ function MarkdownTable.render(parsed_table, indent, max_width, expanded, buf_dir for _, w in ipairs(col_widths) do content_sum = content_sum + w end - total = overhead + content_sum + local total = overhead + content_sum if total > max_width then local budget = max_width - overhead if budget < num_cols then budget = num_cols end @@ -583,7 +582,7 @@ function MarkdownTable.render(parsed_table, indent, max_width, expanded, buf_dir local display_text = cell.text local cell_hls = cell.highlights local cell_links = cell.links - local truncated_byte_len = #display_text + local truncated_byte_len -- Truncate cell text if it exceeds the (possibly shrunk) column width if vim.api.nvim_strwidth(display_text) > col_widths[col] then diff --git a/lua/md-render/preview.lua b/lua/md-render/preview.lua index fbc6508..17a0089 100644 --- a/lua/md-render/preview.lua +++ b/lua/md-render/preview.lua @@ -1030,7 +1030,6 @@ local function install_scroll_sync(session) -- small margin so a one-line move on the source side doesn't -- immediately push it off-screen on the render side. if target_cursor then - local cursor = math.max(1, math.floor(target_cursor + 0.5)) local margin = math.min(3, math.floor(dest_height / 4)) if cursor < topline + margin then topline = cursor - margin diff --git a/lua/md-render/tty.lua b/lua/md-render/tty.lua index 97a446f..4608f74 100644 --- a/lua/md-render/tty.lua +++ b/lua/md-render/tty.lua @@ -4,7 +4,6 @@ --- module's public API returns nil without invoking any FFI calls. local ffi = require "ffi" -local uv = vim.uv or vim.loop local M = {} diff --git a/lua/md-render/wrap.lua b/lua/md-render/wrap.lua index 1f407c8..85ace1b 100644 --- a/lua/md-render/wrap.lua +++ b/lua/md-render/wrap.lua @@ -717,7 +717,6 @@ function M.wrap_words(text, max_width) -- For kinsoku 追い出し: track state before the last segment was appended local prev_current = "" - local prev_width = 0 local last_seg_text = "" local last_seg_pos = 0 @@ -786,7 +785,6 @@ function M.wrap_words(text, max_width) current_width = seg_width end prev_current = "" - prev_width = 0 last_seg_text = "" last_seg_pos = 0 else @@ -804,7 +802,6 @@ function M.wrap_words(text, max_width) current_start = seg.byte_pos current_width = seg_width prev_current = "" - prev_width = 0 last_seg_text = "" last_seg_pos = 0 goto continue @@ -813,7 +810,6 @@ function M.wrap_words(text, max_width) -- Save state before appending (for potential 追い出し on the next segment) prev_current = current - prev_width = current_width last_seg_text = seg.text last_seg_pos = seg.byte_pos