summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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