diff options
| author | Joshua Liu <joshua.liu@sourceobby.com> | 2026-07-18 11:15:41 -0400 |
|---|---|---|
| committer | Joshua Liu <joshua.liu@sourceobby.com> | 2026-07-18 11:15:41 -0400 |
| commit | 692428b52339c1d4e0fccd312bf467789d2f1b15 (patch) | |
| tree | ec09dda906a52ff3468d36765e221a4319115cea | |
| parent | 6e21f45dfc0d76259b77b3c21d42fcde16027867 (diff) | |
| -rw-r--r-- | lua/latex-wordcount.lua (renamed from autoload/latex-wordcount.vim) | 279 |
1 files changed, 249 insertions, 30 deletions
diff --git a/autoload/latex-wordcount.vim b/lua/latex-wordcount.lua index a3dca18..01af4ff 100644 --- a/autoload/latex-wordcount.vim +++ b/lua/latex-wordcount.lua @@ -8,8 +8,6 @@ labels, citations, includes, and similar non-prose content. This is a pattern/heuristic-based tool, not a full LaTeX parser, so it will not be 100% perfect on every document (LaTeX is not a regular language). -It mirrors a Python implementation of the same approach and aims to be a -practical approximation that handles the vast majority of real documents. USAGE AS A NEOVIM PLUGIN MODULE -------------------------------- @@ -30,10 +28,29 @@ USAGE AS A NEOVIM PLUGIN MODULE If loaded inside Neovim, this module also registers a `:LatexWordCount` user command (optionally takes a path to a JSON config file as its arg). -USAGE AS A STANDALONE SCRIPT +RECURSIVE \input AND \include ------------------------------ - lua count_latex_words.lua paper.tex - lua count_latex_words.lua paper.tex mycmds.json +count_file() and count_buffer() follow \input{file} and \include{file} +(including the old brace-less `\input file` form) by default, reading +each referenced file, recursing into it (so files can nest arbitrarily +deep), and splicing its content in before counting -- so a main.tex that +pulls in chapter1.tex, chapter2.tex, etc. gets one combined word count. + + - Paths are resolved relative to the ROOT document's directory (exactly + how real LaTeX/pdflatex resolves \input and \include -- always + relative to wherever the compiler is invoked, not relative to + whichever file happens to contain the \input), and ".tex" is appended + automatically if the bare filename doesn't exist. + - Already-visited files are skipped (cycle guard), and recursion stops + after 20 levels deep by default. + - Missing/unreadable files are left alone rather than aborting the + whole count; the count_file/count_buffer functions return a 4th + value, a list of warning strings, so callers can surface problems. + - Pass { follow_includes = false } as the third argument to disable + this and count only the file/buffer's own literal content: + local n = wc.count_file("paper.tex", config, { follow_includes = false }) + - You can also call wc.expand_includes(text, opts) directly if you + need to splice includes into an arbitrary string yourself. CUSTOM COMMANDS (JSON CONFIG) ------------------------------ @@ -134,6 +151,7 @@ function M.default_config() keep_arg_commands = to_set(DEFAULT_KEEP_ARG_COMMANDS), zero_arg_commands = to_set(DEFAULT_ZERO_ARG_COMMANDS), custom_arg_commands = custom, + do_not_expand = false, } end @@ -624,6 +642,149 @@ function M.count_words(text) end -- --------------------------------------------------------------------- +-- Recursive \input / \include expansion +-- --------------------------------------------------------------------- + +--- Try to locate a file referenced by \input{name} or \include{name}, +-- resolved relative to base_dir, trying the name as given and then with +-- a ".tex" extension appended. +-- @return resolved path string, or nil if no candidate could be opened +function M.resolve_tex_path(filename, base_dir) + filename = filename:match("^%s*(.-)%s*$") -- trim whitespace + if filename == "" then + return nil + end + local is_absolute = filename:sub(1, 1) == "/" or filename:match("^%a:[/\\]") + local candidates + if is_absolute then + candidates = { filename, filename .. ".tex" } + else + base_dir = base_dir or "." + candidates = { base_dir .. "/" .. filename, base_dir .. "/" .. filename .. ".tex" } + end + for _, c in ipairs(candidates) do + local f = io.open(c, "r") + if f then + f:close() + return c + end + end + return nil +end + +--- Recursively expand \input{file}, \input file (brace-less form), and +-- \include{file} directives by splicing in the referenced file's content. +-- Comments are stripped from each chunk as it's read, so a commented-out +-- \input is correctly left unexpanded. +-- +-- @param text string LaTeX source to expand +-- @param opts table|nil: +-- base_dir directory \input/\include paths are resolved against +-- (default ".") +-- seen table used as a set of already-visited resolved paths, +-- to guard against include cycles (default {}) +-- depth current recursion depth, internal use (default 0) +-- max_depth recursion limit (default 20) +-- warnings table that unresolved/cyclic includes get appended to +-- as human-readable strings (default {}) +-- @return expanded text +function M.expand_includes(text, opts) + opts = opts or {} + local base_dir = opts.base_dir or "." + local seen = opts.seen or {} + local depth = opts.depth or 0 + local max_depth = opts.max_depth or 20 + local warnings = opts.warnings or {} + + text = M.strip_comments(text) + + if depth > max_depth then + table.insert(warnings, "max include depth (" .. max_depth .. ") exceeded near " .. base_dir) + return text + end + + local out = {} + local i, n = 1, #text + + while i <= n do + local next_bs = text:find("\\", i, true) + if not next_bs then + table.insert(out, text:sub(i)) + break + end + if next_bs > i then + table.insert(out, text:sub(i, next_bs - 1)) + end + i = next_bs + + local matched_name = nil + for _, name in ipairs({ "input", "include" }) do + if text:sub(i + 1, i + #name) == name and not text:sub(i + 1 + #name, i + 1 + #name):match("%a") then + matched_name = name + break + end + end + + if matched_name then + local j = i + 1 + #matched_name + j = M.strip_optional_arg(text, j) + local filename + + if text:sub(j, j) == "{" then + filename, j = M.read_braced_group(text, j) + else + -- Old-style brace-less form: \input filename, terminated by + -- whitespace, a backslash, or a brace. + while text:sub(j, j):match("%s") do + j = j + 1 + end + local start = j + while j <= n and not text:sub(j, j):match("[%s\\{}]") do + j = j + 1 + end + filename = text:sub(start, j - 1) + end + + local resolved = M.resolve_tex_path(filename, base_dir) + if not resolved then + table.insert(warnings, "could not find included file '" .. filename .. "' (referenced from " .. base_dir .. ")") + elseif seen[resolved] then + table.insert(warnings, "skipped already-included file (cycle guard): " .. resolved) + else + local f = io.open(resolved, "r") + if f then + local included_raw = f:read("*a") + f:close() + seen[resolved] = true + -- Note: real LaTeX resolves \input/\include paths relative to + -- the ROOT document's directory (i.e. wherever latex/pdflatex + -- is invoked), not relative to the file doing the including. + -- So base_dir is deliberately kept the same at every recursion + -- level, rather than switched to the included file's own dir. + local expanded = M.expand_includes(included_raw, { + base_dir = base_dir, + seen = seen, + depth = depth + 1, + max_depth = max_depth, + warnings = warnings, + }) + table.insert(out, "\n" .. expanded .. "\n") + else + table.insert(warnings, "could not open included file: " .. resolved) + end + end + + i = j + else + table.insert(out, "\\") + i = i + 1 + end + end + + return table.concat(out) +end + +-- --------------------------------------------------------------------- -- Top-level entry points -- --------------------------------------------------------------------- @@ -643,27 +804,83 @@ function M.count_text(text, config) return #words, words, t end ---- Run the full pipeline over a file on disk. -function M.count_file(path, config) +--- Run the full pipeline over a file on disk, following \input/\include +-- by default (see module docstring). Returns a 4th value: a list of +-- warning strings for any includes that couldn't be resolved. +-- @param opts table|nil { follow_includes = true, max_depth = 20 } +function M.count_file(path, config, opts) + opts = opts or {} + local follow = opts.follow_includes + if follow == nil then + follow = true + end + local f, err = io.open(path, "r") if not f then error("count_latex_words: could not open file: " .. tostring(err)) end local content = f:read("*a") f:close() - return M.count_text(content, config) + + local warnings = {} + local text = content + if follow then + local base_dir = path:match("^(.*)/[^/]+$") or "." + local basename = path:match("([^/]+)$") or path + local root_key = M.resolve_tex_path(basename, base_dir) or path + local abs_seen = {} + abs_seen[root_key] = true + text = M.expand_includes(content, { + base_dir = base_dir, + seen = abs_seen, + max_depth = opts.max_depth, + warnings = warnings, + }) + end + + local count, words, cleaned = M.count_text(text, config) + return count, words, cleaned, warnings end ---- Run the full pipeline over a Neovim buffer's contents. +--- Run the full pipeline over a Neovim buffer's contents, following +-- \input/\include relative to the buffer's own file by default. Returns +-- a 4th value: a list of warning strings for unresolved includes. -- @param bufnr number|nil buffer handle (0 or nil = current buffer) -function M.count_buffer(bufnr, config) +-- @param opts table|nil { follow_includes = true, max_depth = 20 } +function M.count_buffer(bufnr, config, opts) if vim == nil then error("count_latex_words: count_buffer() requires Neovim's 'vim' API") end + opts = opts or {} + local follow = opts.follow_includes + if follow == nil then + follow = true + end + bufnr = bufnr or 0 local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local text = table.concat(lines, "\n") - return M.count_text(text, config) + + local warnings = {} + if follow then + local bufname = vim.api.nvim_buf_get_name(bufnr) + local base_dir = (bufname ~= "" and bufname:match("^(.*)/[^/]+$")) or vim.fn.getcwd() + local seen = {} + if bufname ~= "" then + local basename = bufname:match("([^/]+)$") or bufname + local root_key = M.resolve_tex_path(basename, base_dir) or bufname + seen[root_key] = true + end + text = M.expand_includes(text, { + base_dir = base_dir, + seen = seen, + max_depth = opts.max_depth, + warnings = warnings, + }) + end + + local count, words, cleaned = M.count_text(text, config) + return count, words, cleaned, warnings end -- --------------------------------------------------------------------- @@ -671,7 +888,7 @@ end -- --------------------------------------------------------------------- if vim ~= nil and vim.api ~= nil and vim.api.nvim_create_user_command ~= nil then - vim.api.nvim_create_user_command("LatexWordCount", function(opts) + vim.api.nvim_create_user_command("MultiLatexWordCount", function(opts) local config = M.default_config() if opts.args and opts.args ~= "" then local ok, err = pcall(M.load_config, config, opts.args) @@ -680,25 +897,27 @@ if vim ~= nil and vim.api ~= nil and vim.api.nvim_create_user_command ~= nil the return end end - local count = M.count_buffer(0, config) + local count, _, _, warnings = M.count_buffer(0, config) vim.notify(string.format("Real words: %d", count)) - end, { nargs = "?", complete = "file", desc = "Count real (visible) words in the current LaTeX buffer" }) -end - --- --------------------------------------------------------------------- --- Standalone CLI usage: `lua count_latex_words.lua paper.tex [config.json]` --- --------------------------------------------------------------------- + if warnings and #warnings > 0 then + vim.notify("count_latex_words include warnings:\n" .. table.concat(warnings, "\n"), vim.log.levels.WARN) + end + end, { nargs = "?", complete = "file", desc = "Count real (visible) words in the current LaTeX buffer (Including other files)" }) -if vim == nil and arg ~= nil and arg[0] and arg[1] then - local config = M.default_config() - if arg[2] then - M.load_config(config, arg[2]) - end - local count, _, cleaned = M.count_file(arg[1], config) - if os.getenv("LATEX_WORDS_VERBOSE") then - io.stderr:write("--- Cleaned text ---\n" .. cleaned .. "\n--- End cleaned text ---\n\n") - end - print(string.format("%s: %d words", arg[1], count)) + vim.api.nvim_create_user_command("SingleLatexWordCount", function(opts) + local config = M.default_config() + if opts.args and opts.args ~= "" then + local ok, err = pcall(M.load_config, config, opts.args) + if not ok then + vim.notify(tostring(err), vim.log.levels.ERROR) + return + end + end + local count, _, _, warnings = M.count_buffer(0, config, { follow_includes = false }) + vim.notify(string.format("Real words: %d", count)) + if warnings and #warnings > 0 then + vim.notify("count_latex_words include warnings:\n" .. table.concat(warnings, "\n"), vim.log.levels.WARN) + end + end, { nargs = "?", complete = "file", desc = "Count real (visible) words in the current LaTeX buffer" }) end - return M |
