From 6f07e61548efc6e8b6c0c426b5567a6b297726d7 Mon Sep 17 00:00:00 2001 From: xvzc Date: Tue, 21 Apr 2026 20:56:20 +0900 Subject: [PATCH 01/13] refactor: redesign cursor API and update all doc annotations - Replace __call metamethod with explicit exec(bufnr) method on cursor - Simplify exec result from {node, range}[] to plain TSNode[] since TSNode already has :range() - Convert all doc example blocks from --- Example: --->lua format to @usage [[ ]] annotation format - Fix all filetype test files to follow actual/expected naming convention - Rename doc/bufsitter.txt to doc/bufsitter.nvim.txt - Remove redundant config.lua module --- .claude/rules/documentation.md | 53 ++ .claude/rules/formatting.md | 7 + .claude/rules/security.md | 20 + .claude/rules/testing.md | 12 + .claude/settings.json | 25 + .commitlintrc.js | 21 + .envrc | 1 + .github/workflows/ci.yml | 58 ++ .gitignore | 12 + .stylua.toml | 6 + Makefile | 55 ++ doc/bufsitter.nvim.txt | 618 ++++++++++++++++ doc/tags | 47 ++ lua/bufsitter/cursor.lua | 543 ++++++++++++++ lua/bufsitter/init.lua | 76 ++ lua/bufsitter/io.lua | 467 ++++++++++++ lua/bufsitter/ref.lua | 91 +++ lua/bufsitter/scratch.lua | 186 +++++ shell.nix | 21 + tests/bufsitter/config_spec.lua | 70 ++ tests/bufsitter/cursor_spec.lua | 790 +++++++++++++++++++++ tests/bufsitter/io_spec.lua | 300 ++++++++ tests/bufsitter/ref_spec.lua | 140 ++++ tests/bufsitter/scratch_spec.lua | 132 ++++ tests/filetypes/go/go_spec.lua | 416 +++++++++++ tests/filetypes/go/sample.go | 46 ++ tests/filetypes/go/sample.go.tree | 146 ++++ tests/filetypes/markdown/markdown_spec.lua | 266 +++++++ tests/filetypes/markdown/sample.md | 35 + tests/filetypes/markdown/sample.md.tree | 108 +++ tests/filetypes/typst/sample.typ | 23 + tests/filetypes/typst/sample.typ.tree | 42 ++ tests/filetypes/typst/typ_spec.lua | 212 ++++++ tests/helpers.lua | 41 ++ tests/minimal_init.lua | 10 + 35 files changed, 5096 insertions(+) create mode 100644 .claude/rules/documentation.md create mode 100644 .claude/rules/formatting.md create mode 100644 .claude/rules/security.md create mode 100644 .claude/rules/testing.md create mode 100644 .claude/settings.json create mode 100644 .commitlintrc.js create mode 100644 .envrc create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .stylua.toml create mode 100644 Makefile create mode 100644 doc/bufsitter.nvim.txt create mode 100644 doc/tags create mode 100644 lua/bufsitter/cursor.lua create mode 100644 lua/bufsitter/init.lua create mode 100644 lua/bufsitter/io.lua create mode 100644 lua/bufsitter/ref.lua create mode 100644 lua/bufsitter/scratch.lua create mode 100644 shell.nix create mode 100644 tests/bufsitter/config_spec.lua create mode 100644 tests/bufsitter/cursor_spec.lua create mode 100644 tests/bufsitter/io_spec.lua create mode 100644 tests/bufsitter/ref_spec.lua create mode 100644 tests/bufsitter/scratch_spec.lua create mode 100644 tests/filetypes/go/go_spec.lua create mode 100644 tests/filetypes/go/sample.go create mode 100644 tests/filetypes/go/sample.go.tree create mode 100644 tests/filetypes/markdown/markdown_spec.lua create mode 100644 tests/filetypes/markdown/sample.md create mode 100644 tests/filetypes/markdown/sample.md.tree create mode 100644 tests/filetypes/typst/sample.typ create mode 100644 tests/filetypes/typst/sample.typ.tree create mode 100644 tests/filetypes/typst/typ_spec.lua create mode 100644 tests/helpers.lua create mode 100644 tests/minimal_init.lua diff --git a/.claude/rules/documentation.md b/.claude/rules/documentation.md new file mode 100644 index 0000000..7774781 --- /dev/null +++ b/.claude/rules/documentation.md @@ -0,0 +1,53 @@ +# Documentation + +Documentation annotations follow the same style as Lua type annotations: no leading space after `---`. + +```lua +-- correct +---@mod bufsitter.io IO +---@brief [[ +---Treesitter-powered buffer manipulation. +---@brief ]] + +-- incorrect +--- @mod bufsitter.io IO +--- @brief [[ +``` + +Nested types use dot notation to separate namespaces, not underscores: + +```lua +-- correct +---@class bufsitter.scratch.win.opts + +-- incorrect +---@class bufsitter.scratch.win_opts +``` + +Example code blocks must declare every variable they use. Never assume `bufnr`, `cursor`, `s`, or any other variable is already in scope: + +```lua +-- correct +--->lua +--- local cursor = require("bufsitter.cursor") +--- local items = cursor.root():children()(bufnr) +---< + +-- incorrect +--->lua +--- local items = cursor.root():children()(bufnr) +---< +``` + +Body text inside annotation blocks (e.g. `---@brief`) may use a leading space for indentation purposes: + +```lua +---@brief [[ +---Top-level description. +--- +--- Indented paragraph or example: +--->lua +--- require("bufsitter").setup() +---< +---@brief ]] +``` diff --git a/.claude/rules/formatting.md b/.claude/rules/formatting.md new file mode 100644 index 0000000..9982f4f --- /dev/null +++ b/.claude/rules/formatting.md @@ -0,0 +1,7 @@ +# Formatting + +Code must be formatted with StyLua. After any code change: +- Check: `stylua . --check` from the project root +- Format: `stylua .` from the project root + +All formatting checks must pass before considering the task complete. diff --git a/.claude/rules/security.md b/.claude/rules/security.md new file mode 100644 index 0000000..fe58017 --- /dev/null +++ b/.claude/rules/security.md @@ -0,0 +1,20 @@ +# Security + +Never hardcode absolute paths containing usernames or system-specific directories. +This applies to all files including source code, configuration, and settings files. + +```lua +-- incorrect +local path = "/Users/username/folder/bufsitter.nvim/doc" + +-- correct +local path = vim.fn.stdpath("data") .. "/bufsitter" +``` + +```json +// incorrect +{ "command": "cd /Users/username/folder/bufsitter.nvim && make test" } + +// correct +{ "command": "make test" } +``` diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md new file mode 100644 index 0000000..0526155 --- /dev/null +++ b/.claude/rules/testing.md @@ -0,0 +1,12 @@ +# Testing + +After any code change, run `make test` from the project root and all tests must pass before considering the task complete. + +Each test should be stateless and self-contained. Avoid sharing state between tests (e.g. global variables, module-level mutable state). Prefer `before_each` / `after_each` for setup and teardown over shared state, and only share state across tests when there is a clear and necessary reason to do so. + +Every function must have tests covering a variety of scenarios (happy path, edge cases, failure cases). Keep each test minimal — only the setup and assertions strictly necessary to verify the scenario. + +Prefer naming local variables before asserting, using one of these conventions: +- Input: `input`, `origin` +- Expected: `expected`, `expected_*` +- Actual: `actual`, `actual_*` diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..3998b63 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,25 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "if": "Bash(git commit*)", + "command": "make test && make docs && git add doc/", + "timeout": 120, + "statusMessage": "Running tests and generating docs..." + }, + { + "type": "command", + "if": "Bash(git push*)", + "command": "FROM=$(git merge-base HEAD @{u} 2>/dev/null) && commitlint --from \"$FROM\" --to HEAD --verbose", + "timeout": 30, + "statusMessage": "Running commitlint..." + } + ] + } + ] + } +} diff --git a/.commitlintrc.js b/.commitlintrc.js new file mode 100644 index 0000000..b03c8e2 --- /dev/null +++ b/.commitlintrc.js @@ -0,0 +1,21 @@ +module.exports = { + extends: ["@commitlint/config-conventional"], + parserPreset: { + name: "conventional-changelog-conventionalcommits", + presetConfig: { + types: [ + { type: "feat", section: "Features" }, + { type: "fix", section: "Bug Fixes" }, + { type: "docs", section: "Documentation", hidden: false }, + { type: "perf", section: "Performance", hidden: false }, + ], + }, + }, + rules: { + 'type-enum': [ + 2, + 'always', + ["build", "chore", "ci", "docs", "feat", "fix", "perf", "refactor", "revert", "style", "test"], + ] + } +}; diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..1d953f4 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use nix diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3e6f3d4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,58 @@ +name: CI + +on: + pull_request: + branches: + - main + +jobs: + commitlint: + name: Commitlint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: lts/* + + - name: Install commitlint + run: npm install --save-dev @commitlint/cli @commitlint/config-conventional conventional-changelog-conventionalcommits + + - name: Lint commits + run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: rhysd/action-setup-vim@v1 + with: + neovim: true + version: v0.12.1 + + - name: Run tests + run: make test + + check-docs: + name: Check Docs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: rhysd/action-setup-vim@v1 + with: + neovim: true + version: stable + + - name: Install lemmy-help + run: | + curl -sL https://github.com/numToStr/lemmy-help/releases/latest/download/lemmy-help-x86_64-unknown-linux-musl.tar.gz \ + | tar -xz -C /usr/local/bin + + - name: Check docs + run: make check-docs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..491587b --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Nix +.direnv/ +result + +# Editor +.DS_Store + +# Local dev +.deps/ +.nvim/ +prompts/ +.cache/ diff --git a/.stylua.toml b/.stylua.toml new file mode 100644 index 0000000..14ff56e --- /dev/null +++ b/.stylua.toml @@ -0,0 +1,6 @@ +column_width = 90 +line_endings = "Unix" +indent_type = "Spaces" +indent_width = 2 +quote_style = "AutoPreferDouble" +call_parentheses = "Always" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3a483c5 --- /dev/null +++ b/Makefile @@ -0,0 +1,55 @@ +DOCS_DIR ?= doc +DEPS_DIR = .deps/start +PARSER_DIR = .deps/parsers +NVIM_PARSER_DIR = $(shell nvim --headless -c "lua io.write(vim.fn.stdpath('data'))" -c "q" 2>/dev/null)/site/parser + +$(DEPS_DIR)/plenary.nvim: + git clone --depth 1 https://github.com/nvim-lua/plenary.nvim $@ + +$(DEPS_DIR)/nvim-treesitter: + git clone --depth 1 https://github.com/nvim-treesitter/nvim-treesitter $@ + +$(PARSER_DIR)/tree-sitter-go: + git clone --depth 1 https://github.com/tree-sitter/tree-sitter-go $@ + +$(PARSER_DIR)/tree-sitter-typst: + git clone --depth 1 https://github.com/uben0/tree-sitter-typst $@ + +_deps: $(DEPS_DIR)/plenary.nvim $(DEPS_DIR)/nvim-treesitter + +_install-parsers: _deps $(PARSER_DIR)/tree-sitter-go $(PARSER_DIR)/tree-sitter-typst + mkdir -p $(NVIM_PARSER_DIR) + gcc -shared -fPIC -o $(NVIM_PARSER_DIR)/go.so -I$(PARSER_DIR)/tree-sitter-go/src \ + $(PARSER_DIR)/tree-sitter-go/src/parser.c + gcc -shared -fPIC -o $(NVIM_PARSER_DIR)/typst.so -I$(PARSER_DIR)/tree-sitter-typst/src \ + $(PARSER_DIR)/tree-sitter-typst/src/parser.c \ + $(PARSER_DIR)/tree-sitter-typst/src/scanner.c + +test: _install-parsers + nvim \ + --headless \ + -u tests/minimal_init.lua \ + -c "PlenaryBustedDirectory tests/ { minimal_init = 'tests/minimal_init.lua' }" + +_gen-docs: + mkdir -p $(DOCS_DIR) + lemmy-help -f -t \ + lua/bufsitter/init.lua \ + lua/bufsitter/cursor.lua \ + lua/bufsitter/io.lua \ + lua/bufsitter/ref.lua \ + lua/bufsitter/scratch.lua \ + > $(DOCS_DIR)/bufsitter.nvim.txt + +docs: + $(MAKE) _gen-docs DOCS_DIR=doc + nvim --headless -c "helptags doc/" -c "q" + +check-docs: + mkdir -p .cache/doc/expected .cache/doc/actual + cp doc/bufsitter.nvim.txt .cache/doc/expected/bufsitter.nvim.txt + $(MAKE) _gen-docs DOCS_DIR=.cache/doc/actual + diff .cache/doc/expected .cache/doc/actual + +clean: + rm -rf .cache .deps diff --git a/doc/bufsitter.nvim.txt b/doc/bufsitter.nvim.txt new file mode 100644 index 0000000..0f9ef9b --- /dev/null +++ b/doc/bufsitter.nvim.txt @@ -0,0 +1,618 @@ +============================================================================== +Table of Contents *bufsitter.contents* + +bufsitter.nvim ····················································· |bufsitter| +Cursor ······················································ |bufsitter.cursor| +IO ······························································ |bufsitter.io| +Ref ···························································· |bufsitter.ref| +Scratch ···················································· |bufsitter.scratch| + +============================================================================== +bufsitter.nvim *bufsitter* + + Treesitter-powered buffer manipulation for Neovim. + + Quick start: +>lua + require("bufsitter").setup() +< + +bufsitter.config.scratch.opts *bufsitter.config.scratch.opts* + + Fields: ~ + {ft?} (string) + {init_contents?} (string[]|fun():string[]) + {on_attach?} (fun(bufnr:integer)) + {win?} (vim.api.keyset.win_config) + + +bufsitter.config.ref.opts *bufsitter.config.ref.opts* + + Fields: ~ + {expand?} (boolean) + + +bufsitter.config.io.opts *bufsitter.config.io.opts* + + Fields: ~ + {on_error?} (fun(err:string)) + + +bufsitter.config.opts *bufsitter.config.opts* + + Fields: ~ + {scratch?} (bufsitter.config.scratch.opts) + {ref?} (bufsitter.config.ref.opts) + {io?} (bufsitter.config.io.opts) + + +Bufsitter *Bufsitter* + + Fields: ~ + {config} (bufsitter.config.opts) + + +M.setup({opts?}) *bufsitter.setup* + Initializes bufsitter with the given options, deep-merged over the defaults. + Must be called once before using any other bufsitter API. + + Parameters: ~ + {opts?} (bufsitter.config.opts) + + Usage: ~ +>lua + require("bufsitter").setup({ + scratch = { ft = "markdown" }, + io = { + on_error = function(err) + vim.notify(err, vim.log.levels.ERROR) + end, + }, + }) +< + + +M.config *bufsitter.config* + + Type: ~ + (bufsitter.config.opts) + + +============================================================================== +Cursor *bufsitter.cursor* + +Lazy treesitter node traversal API. + +A cursor is a reusable, lazy query — it describes a traversal chain but does +not touch any buffer until |bufsitter.Cursor:exec| is called. The same cursor +instance can be passed to multiple |bufsitter.io| functions or evaluated +against different buffers without rebuilding the chain. + +Two cursor types exist: + + - |bufsitter.MultiCursor| — holds zero or more nodes + - |bufsitter.SingleCursor| — holds at most one node + +|bufsitter.Cursor:exec| evaluates the chain and returns a flat list of TSNode +values matched in that buffer at that moment. Each TSNode exposes the standard +treesitter API (`:type()`, `:range()`, `:named_child()`, etc.). + +Entry points are |bufsitter.cursor.root| and |bufsitter.cursor.query|. +Cursors are passed to `io.*` functions via the `cursor` field in opts. + +bufsitter.cursor.opts *bufsitter.cursor.opts* + + Fields: ~ + {names?} (string[]) + {types?} (string[]) + + +bufsitter.cursor.fn *bufsitter.cursor.fn* + + Type: ~ + fun(bufnr:integer,node:TSNode):boolean + + +bufsitter.Cursor *bufsitter.Cursor* + + Fields: ~ + + + +bufsitter.MultiCursor : bufsitter *bufsitter.MultiCursor* + + +bufsitter.SingleCursor : bufsitter *bufsitter.SingleCursor* + + +M.root() *bufsitter.cursor.root* + Returns a cursor seeded with the root node of the buffer's syntax tree. + + Returns: ~ + (bufsitter.MultiCursor) + + Usage: ~ +>lua + local cursor = require("bufsitter.cursor") + local bufnr = vim.api.nvim_get_current_buf() + local nodes = cursor.root():children():exec(bufnr) +< + + +M.query({query_str}) *bufsitter.cursor.query* + Returns a cursor seeded with all nodes captured by the given treesitter + query string, evaluated against the buffer's filetype. + + Parameters: ~ + {query_str} (string) + + Returns: ~ + (bufsitter.MultiCursor) + + Usage: ~ +>lua + local cursor = require("bufsitter.cursor") + local bufnr = vim.api.nvim_get_current_buf() + local nodes = cursor.query("(function_declaration) @fn"):exec(bufnr) +< + + +============================================================================== +IO *bufsitter.io* + +Buffer read/write operations driven by a cursor or explicit row/col range. + +Each function accepts an `opts` table with either a `cursor` field +(a |bufsitter.Cursor|) or explicit `start_row`/`end_row` coordinates. +When `cursor` is given, the operation is applied to every node the cursor +resolves to. An optional `hook` can transform the content before it is +written, and `on_error` can intercept errors thrown by the cursor. + +bufsitter.io.select.opts *bufsitter.io.select.opts* + + Fields: ~ + {cursor?} (bufsitter.Cursor) + {start_row?} (integer) + {start_col?} (integer) + {end_row?} (integer) + {end_col?} (integer) + {on_error?} (fun(err:string)) + {hook?} (fun(bufnr:integer,contents:string[]):string[]) + + +bufsitter.io.insert.opts *bufsitter.io.insert.opts* + + Fields: ~ + {cursor?} (bufsitter.Cursor) + {start_row?} (integer) + {start_col?} (integer) + {end_row?} (integer) + {end_col?} (integer) + {on_error?} (fun(err:string)) + {hook?} (fun(bufnr:integer,contents:string[]):string[]) + + +bufsitter.io.delete.opts *bufsitter.io.delete.opts* + + Fields: ~ + {cursor?} (bufsitter.Cursor) + {start_row?} (integer) + {start_col?} (integer) + {end_row?} (integer) + {end_col?} (integer) + {on_error?} (fun(err:string)) + + +bufsitter.io.replace.opts *bufsitter.io.replace.opts* + + Fields: ~ + {cursor?} (bufsitter.Cursor) + {start_row?} (integer) + {start_col?} (integer) + {end_row?} (integer) + {end_col?} (integer) + {on_error?} (fun(err:string)) + {hook?} (fun(bufnr:integer,contents:string[]):string[]) + + +M.select({bufnr}, {opts}) *bufsitter.io.select* + Reads text from `bufnr`. Returns one `string[]` per matched node when + `cursor` is used, or a single-element wrapper otherwise. + Returns `nil` if the buffer is invalid or the cursor yields nothing. + + Parameters: ~ + {bufnr} (integer) + {opts} (bufsitter.io.select.opts) + + Returns: ~ + (string[][]|nil) + + Usage: ~ +>lua + local io = require("bufsitter.io") + local cursor = require("bufsitter.cursor") + local bufnr = vim.api.nvim_get_current_buf() + local results = io.select(bufnr, { + cursor = cursor.root():children({ types = { "function_declaration" } }), + }) + -- results[1] == { "func foo() {", " ...", "}" } +< + + +M.select_text({bufnr}, {opts?}) *bufsitter.io.select_text* + Like `select`, but joins each node's lines with `\n` and returns a flat + `string[]` — one string per matched node. + + Parameters: ~ + {bufnr} (integer) + {opts?} (bufsitter.io.select.opts) + + Returns: ~ + (string[]|nil) + + Usage: ~ +>lua + local io = require("bufsitter.io") + local cursor = require("bufsitter.cursor") + local bufnr = vim.api.nvim_get_current_buf() + local texts = io.select_text(bufnr, { + cursor = cursor.root():children({ types = { "function_declaration" } }), + }) + -- texts[1] == "func foo() {\n ...\n}" +< + + +M.insert({bufnr}, {contents}, {opts?}) *bufsitter.io.insert* + Inserts `contents` into `bufnr`. When `prepend` is false (default) content + is placed after each target; when true, before. `inline` inserts at the + exact character position without adding a new line. Without a cursor or + range, appends to the end of the buffer. + + Parameters: ~ + {bufnr} (integer) + {contents} (string[]) + {opts?} (bufsitter.io.insert.opts) + + Usage: ~ +>lua + local io = require("bufsitter.io") + local cursor = require("bufsitter.cursor") + local bufnr = vim.api.nvim_get_current_buf() + -- append after the first function + io.insert(bufnr, { "-- generated" }, { + cursor = cursor.root():children({ types = { "function_declaration" } }):first(), + }) + -- prepend before it + io.insert(bufnr, { "-- generated" }, { + prepend = true, + cursor = cursor.root():children({ types = { "function_declaration" } }):first(), + }) +< + + +M.insert_text({bufnr}, {str}, {opts?}) *bufsitter.io.insert_text* + Convenience wrapper around `insert` that splits `str` on newlines first. + + Parameters: ~ + {bufnr} (integer) + {str} (string) + {opts?} (bufsitter.io.insert.opts) + + Usage: ~ +>lua + local io = require("bufsitter.io") + local cursor = require("bufsitter.cursor") + local bufnr = vim.api.nvim_get_current_buf() + io.insert_text(bufnr, "-- line one\n-- line two", { + cursor = cursor.root():children():first(), + }) +< + + +M.delete({bufnr}, {opts?}) *bufsitter.io.delete* + Deletes text from `bufnr`. Nodes are deleted in reverse source order to + preserve row indices for subsequent deletions. + + Parameters: ~ + {bufnr} (integer) + {opts?} (bufsitter.io.delete.opts) + + Usage: ~ +>lua + local io = require("bufsitter.io") + local cursor = require("bufsitter.cursor") + local bufnr = vim.api.nvim_get_current_buf() + io.delete(bufnr, { + cursor = cursor.root():children({ types = { "function_declaration" } }):first(), + }) +< + + +M.replace({bufnr}, {contents}, {opts?}) *bufsitter.io.replace* + Replaces the text of each matched node or range with `contents`. + Multiple matches are replaced in reverse source order to preserve indices. + + Parameters: ~ + {bufnr} (integer) + {contents} (string[]) + {opts?} (bufsitter.io.replace.opts) + + Usage: ~ +>lua + local io = require("bufsitter.io") + local cursor = require("bufsitter.cursor") + local bufnr = vim.api.nvim_get_current_buf() + io.replace(bufnr, { "func foo() {}", "}" }, { + cursor = cursor.root():children({ types = { "function_declaration" } }):first(), + }) +< + + +M.replace_text({bufnr}, {str}, {opts?}) *bufsitter.io.replace_text* + Convenience wrapper around `replace` that splits `str` on newlines first. + + Parameters: ~ + {bufnr} (integer) + {str} (string) + {opts?} (bufsitter.io.replace.opts) + + Usage: ~ +>lua + local io = require("bufsitter.io") + local cursor = require("bufsitter.cursor") + local bufnr = vim.api.nvim_get_current_buf() + io.replace_text(bufnr, "func foo() {}\n}", { + cursor = cursor.root():children():first(), + }) +< + + +M.clear({bufnr}) *bufsitter.io.clear* + Clears all content from `bufnr`, leaving a single empty line. + + Parameters: ~ + {bufnr} (integer) + + Usage: ~ +>lua + local io = require("bufsitter.io") + local bufnr = vim.api.nvim_get_current_buf() + io.clear(bufnr) +< + + +============================================================================== +Ref *bufsitter.ref* + +Generates a human-readable reference string for the current buffer or +visual selection, in the form `path:LN` or `path:LN~LM`. + +Useful for inserting source references into scratch buffers or prompts. +When `expand` is true, the path is expanded to an absolute path; +otherwise it is relative to the home directory (`~`). + +bufsitter.ref.opts *bufsitter.ref.opts* + + Fields: ~ + {expand?} (boolean) + + +M.visual_selection({opts?}) *bufsitter.ref.visual_selection* + Returns a reference string for the most recent visual selection. + Format: `path:LN` for a single line, `path:LN~LM` for a range. + Falls back to the buffer name alone if no selection marks are set. + + Parameters: ~ + {opts?} (bufsitter.ref.opts) + + Returns: ~ + (string) + + Usage: ~ +>lua + -- in a keymap callback, after making a visual selection + local ref = require("bufsitter.ref").visual_selection() + -- "~/project/main.lua:L10~L15" +< + + +M.get({opts?}) *bufsitter.ref.get* + Returns a reference string for the current context: delegates to + `visual_selection` when in a visual mode, otherwise to `buffer`. + + Parameters: ~ + {opts?} (bufsitter.ref.opts) + + Returns: ~ + (string) + + Usage: ~ +>lua + vim.keymap.set({ "n", "v" }, "r", function() + local ref = require("bufsitter.ref").get() + vim.fn.setreg("+", ref) + end) +< + + +M.buffer({opts?}) *bufsitter.ref.buffer* + Returns the name of the current buffer. Returns `"[No Name]"` for unnamed + buffers. With `expand = true`, returns the absolute path. + + Parameters: ~ + {opts?} (bufsitter.ref.opts) + + Returns: ~ + (string) + + Usage: ~ +>lua + local ref = require("bufsitter.ref") + ref.buffer() -- "~/project/main.lua" + ref.buffer({ expand = true }) -- "/Users/user/project/main.lua" +< + + +============================================================================== +Scratch *bufsitter.scratch* + +Floating scratch buffer with show/hide/toggle lifecycle management. + +A `Scratch` is a unlisted, non-file buffer displayed in a floating window. +Window position and size are configured via |bufsitter.scratch.win.opts|. +Initial content can be provided as a string array or a function, and an +`on_attach` callback runs once on buffer creation. + +bufsitter.scratch.win.opts *bufsitter.scratch.win.opts* + + Fields: ~ + {relative?} (string) + {width?} (integer) + {height?} (integer) + {row?} (integer) + {col?} (integer) + {style?} (string) + {border?} (string) + + +bufsitter.scratch.opts *bufsitter.scratch.opts* + + Fields: ~ + {ft?} (string) + {init_contents?} (string[]|fun():string[]) + {on_attach?} (fun(bufnr:integer)) + {win?} (bufsitter.scratch.win.opts) + + +bufsitter.Scratch *bufsitter.Scratch* + + Fields: ~ + + + +Scratch.new({opts?}) *bufsitter.scratch.new* + Creates a new scratch buffer, deep-merging `opts` over the global defaults. + Sets the filetype, writes `init_contents`, and calls `on_attach` if provided. + + Parameters: ~ + {opts?} (bufsitter.scratch.opts) + + Returns: ~ + (bufsitter.Scratch) + + Usage: ~ +>lua + local Scratch = require("bufsitter.scratch") + local s = Scratch.new({ + ft = "markdown", + init_contents = { "# Notes", "" }, + on_attach = function(bufnr) + vim.keymap.set("n", "q", "close", { buffer = bufnr }) + end, + }) +< + + +Scratch:bufnr() *bufsitter.scratch:bufnr* + Returns the buffer number of the scratch buffer. + + Returns: ~ + (integer) + + Usage: ~ +>lua + local Scratch = require("bufsitter.scratch") + local s = Scratch.new() + local bufnr = s:bufnr() +< + + +Scratch:is_valid() *bufsitter.scratch:is_valid* + Returns true if the underlying buffer still exists. + + Returns: ~ + (boolean) + + Usage: ~ +>lua + local Scratch = require("bufsitter.scratch") + local s = Scratch.new() + if s:is_valid() then + s:show() + end +< + + +Scratch:is_visible() *bufsitter.scratch:is_visible* + Returns true if the floating window is currently open. + + Returns: ~ + (boolean) + + Usage: ~ +>lua + local Scratch = require("bufsitter.scratch") + local s = Scratch.new() + if not s:is_visible() then + s:show() + end +< + + +Scratch:show({win_opts?}) *bufsitter.scratch:show* + Opens the floating window. If it is already visible, reattaches the buffer + to the existing window. Returns the window id, or nil if the buffer is invalid. + + Parameters: ~ + {win_opts?} (bufsitter.scratch.win.opts) + + Returns: ~ + (integer|nil) + + Usage: ~ +>lua + local Scratch = require("bufsitter.scratch") + local s = Scratch.new() + s:show() + s:show({ width = 100, height = 30 }) +< + + +Scratch:hide() *bufsitter.scratch:hide* + Closes the floating window without deleting the buffer. + + Usage: ~ +>lua + local Scratch = require("bufsitter.scratch") + local s = Scratch.new() + s:hide() +< + + +Scratch:toggle({win_opts?}) *bufsitter.scratch:toggle* + Hides the window if visible, shows it otherwise. + + Parameters: ~ + {win_opts?} (bufsitter.scratch.win.opts) + + Usage: ~ +>lua + local Scratch = require("bufsitter.scratch") + local s = Scratch.new() + vim.keymap.set("n", "s", function() s:toggle() end) +< + + +Scratch:delete() *bufsitter.scratch:delete* + Closes the floating window and deletes the buffer. The instance should not + be used after calling this. + + Usage: ~ +>lua + local Scratch = require("bufsitter.scratch") + local s = Scratch.new() + s:delete() +< + + +vim:tw=78:ts=8:noet:ft=help:norl: diff --git a/doc/tags b/doc/tags new file mode 100644 index 0000000..db3c88c --- /dev/null +++ b/doc/tags @@ -0,0 +1,47 @@ +Bufsitter bufsitter.nvim.txt /*Bufsitter* +bufsitter bufsitter.nvim.txt /*bufsitter* +bufsitter.Cursor bufsitter.nvim.txt /*bufsitter.Cursor* +bufsitter.MultiCursor bufsitter.nvim.txt /*bufsitter.MultiCursor* +bufsitter.Scratch bufsitter.nvim.txt /*bufsitter.Scratch* +bufsitter.SingleCursor bufsitter.nvim.txt /*bufsitter.SingleCursor* +bufsitter.config bufsitter.nvim.txt /*bufsitter.config* +bufsitter.config.io.opts bufsitter.nvim.txt /*bufsitter.config.io.opts* +bufsitter.config.opts bufsitter.nvim.txt /*bufsitter.config.opts* +bufsitter.config.ref.opts bufsitter.nvim.txt /*bufsitter.config.ref.opts* +bufsitter.config.scratch.opts bufsitter.nvim.txt /*bufsitter.config.scratch.opts* +bufsitter.contents bufsitter.nvim.txt /*bufsitter.contents* +bufsitter.cursor bufsitter.nvim.txt /*bufsitter.cursor* +bufsitter.cursor.fn bufsitter.nvim.txt /*bufsitter.cursor.fn* +bufsitter.cursor.opts bufsitter.nvim.txt /*bufsitter.cursor.opts* +bufsitter.cursor.query bufsitter.nvim.txt /*bufsitter.cursor.query* +bufsitter.cursor.root bufsitter.nvim.txt /*bufsitter.cursor.root* +bufsitter.io bufsitter.nvim.txt /*bufsitter.io* +bufsitter.io.clear bufsitter.nvim.txt /*bufsitter.io.clear* +bufsitter.io.delete bufsitter.nvim.txt /*bufsitter.io.delete* +bufsitter.io.delete.opts bufsitter.nvim.txt /*bufsitter.io.delete.opts* +bufsitter.io.insert bufsitter.nvim.txt /*bufsitter.io.insert* +bufsitter.io.insert.opts bufsitter.nvim.txt /*bufsitter.io.insert.opts* +bufsitter.io.insert_text bufsitter.nvim.txt /*bufsitter.io.insert_text* +bufsitter.io.replace bufsitter.nvim.txt /*bufsitter.io.replace* +bufsitter.io.replace.opts bufsitter.nvim.txt /*bufsitter.io.replace.opts* +bufsitter.io.replace_text bufsitter.nvim.txt /*bufsitter.io.replace_text* +bufsitter.io.select bufsitter.nvim.txt /*bufsitter.io.select* +bufsitter.io.select.opts bufsitter.nvim.txt /*bufsitter.io.select.opts* +bufsitter.io.select_text bufsitter.nvim.txt /*bufsitter.io.select_text* +bufsitter.ref bufsitter.nvim.txt /*bufsitter.ref* +bufsitter.ref.buffer bufsitter.nvim.txt /*bufsitter.ref.buffer* +bufsitter.ref.get bufsitter.nvim.txt /*bufsitter.ref.get* +bufsitter.ref.opts bufsitter.nvim.txt /*bufsitter.ref.opts* +bufsitter.ref.visual_selection bufsitter.nvim.txt /*bufsitter.ref.visual_selection* +bufsitter.scratch bufsitter.nvim.txt /*bufsitter.scratch* +bufsitter.scratch.new bufsitter.nvim.txt /*bufsitter.scratch.new* +bufsitter.scratch.opts bufsitter.nvim.txt /*bufsitter.scratch.opts* +bufsitter.scratch.win.opts bufsitter.nvim.txt /*bufsitter.scratch.win.opts* +bufsitter.scratch:bufnr bufsitter.nvim.txt /*bufsitter.scratch:bufnr* +bufsitter.scratch:delete bufsitter.nvim.txt /*bufsitter.scratch:delete* +bufsitter.scratch:hide bufsitter.nvim.txt /*bufsitter.scratch:hide* +bufsitter.scratch:is_valid bufsitter.nvim.txt /*bufsitter.scratch:is_valid* +bufsitter.scratch:is_visible bufsitter.nvim.txt /*bufsitter.scratch:is_visible* +bufsitter.scratch:show bufsitter.nvim.txt /*bufsitter.scratch:show* +bufsitter.scratch:toggle bufsitter.nvim.txt /*bufsitter.scratch:toggle* +bufsitter.setup bufsitter.nvim.txt /*bufsitter.setup* diff --git a/lua/bufsitter/cursor.lua b/lua/bufsitter/cursor.lua new file mode 100644 index 0000000..d3a04fe --- /dev/null +++ b/lua/bufsitter/cursor.lua @@ -0,0 +1,543 @@ +---@mod bufsitter.cursor Cursor +---@brief [[ +---Lazy treesitter node traversal API. +--- +---A cursor is a reusable, lazy query — it describes a traversal chain but does +---not touch any buffer until |bufsitter.Cursor:exec| is called. The same cursor +---instance can be passed to multiple |bufsitter.io| functions or evaluated +---against different buffers without rebuilding the chain. +--- +---Two cursor types exist: +--- +--- - |bufsitter.MultiCursor| — holds zero or more nodes +--- - |bufsitter.SingleCursor| — holds at most one node +--- +---|bufsitter.Cursor:exec| evaluates the chain and returns a flat list of TSNode +---values matched in that buffer at that moment. Each TSNode exposes the standard +---treesitter API (`:type()`, `:range()`, `:named_child()`, etc.). +--- +---Entry points are |bufsitter.cursor.root| and |bufsitter.cursor.query|. +---Cursors are passed to `io.*` functions via the `cursor` field in opts. +---@brief ]] + +---@class bufsitter.cursor.opts +---@field names? string[] +---@field types? string[] + +---@alias bufsitter.cursor.fn fun(bufnr: integer, node: TSNode): boolean + +---@class bufsitter.Cursor +---@field private _exec fun(bufnr: integer): TSNode[] +---@field private _prev bufsitter.Cursor|nil +local Base = {} +Base.__index = Base + +---@class bufsitter.MultiCursor : bufsitter.Cursor +local Multi = setmetatable({}, { __index = Base }) +Multi.__index = Multi + +---@class bufsitter.SingleCursor : bufsitter.Cursor +local Single = setmetatable({}, { __index = Base }) +Single.__index = Single + +-- Factories + +local function new_multi(exec, prev) + return setmetatable({ _exec = exec, _prev = prev }, Multi) +end + +local function new_single(exec, prev) + return setmetatable({ _exec = exec, _prev = prev }, Single) +end + +-- Shared helpers + +local function get_parser(bufnr) + local ft = vim.bo[bufnr].filetype + local ok, parser = pcall(vim.treesitter.get_parser, bufnr, ft) + if not ok or not parser then + return nil + end + return parser +end + +local function type_matches(node, types) + if not types or #types == 0 then + return true + end + for _, t in ipairs(types) do + if node:type() == t then + return true + end + end + return false +end + +local function node_in_field(node, parent, name) + for _, f in ipairs(parent:field(name)) do + if f == node then + return true + end + end + return false +end + +-- collect named children of `parent`, filtered by opts (names AND types) +local function collect_children(parent, opts) + local candidates = {} + if opts and opts.names and #opts.names > 0 then + local seen = {} + for _, name in ipairs(opts.names) do + for _, child in ipairs(parent:field(name)) do + if not seen[child] then + seen[child] = true + table.insert(candidates, child) + end + end + end + else + for i = 0, parent:named_child_count() - 1 do + table.insert(candidates, parent:named_child(i)) + end + end + if not opts or not opts.types or #opts.types == 0 then + return candidates + end + local result = {} + for _, child in ipairs(candidates) do + if type_matches(child, opts.types) then + table.insert(result, child) + end + end + return result +end + +-- check if `node` matches opts, given its `parent` for field-name checking (names AND types) +local function node_matches(node, parent, opts) + if not opts then + return true + end + if not type_matches(node, opts.types) then + return false + end + if opts.names and #opts.names > 0 then + if not parent then + return false + end + for _, name in ipairs(opts.names) do + if node_in_field(node, parent, name) then + return true + end + end + return false + end + return true +end + +-- Base methods (shared by Multi and Single) + +---Evaluates the cursor chain against `bufnr` and returns the matched TSNodes. +---@param bufnr integer +---@return TSNode[] +---@usage [[ +---local cursor = require("bufsitter.cursor") +---local bufnr = vim.api.nvim_get_current_buf() +---local nodes = cursor.root():children({ types = { "function_declaration" } }):exec(bufnr) +---for _, node in ipairs(nodes) do +--- print(node:type()) +---end +---@usage ]] +function Base:exec(bufnr) + return self._exec(bufnr) +end + +---Returns the named children of every node in the cursor. +---`opts.names` filters by field name; `opts.types` filters by node type. +---Both filters are ANDed when both are specified. +---@param opts? bufsitter.cursor.opts +---@return bufsitter.MultiCursor +---@usage [[ +---local cursor = require("bufsitter.cursor") +---local bufnr = vim.api.nvim_get_current_buf() +---cursor.root():children():exec(bufnr) +---cursor.root():children({ types = { "function_declaration" } }):exec(bufnr) +---cursor.root():children({ names = { "parameters" }, types = { "parameter_list" } }):exec(bufnr) +---@usage ]] +function Base:children(opts) + local prev = self + return new_multi(function(bufnr) + local result = {} + for _, node in ipairs(prev._exec(bufnr)) do + for _, child in ipairs(collect_children(node, opts)) do + table.insert(result, child) + end + end + return result + end) +end + +local function make_or_else(self, constructor) + if not self._prev then + return self + end + local current_exec = self._exec + local prev_exec = self._prev._exec + return constructor(function(bufnr) + local nodes = current_exec(bufnr) + if #nodes > 0 then + return nodes + end + return prev_exec(bufnr) + end) +end + +-- MultiCursor methods + +---Keeps only nodes for which `fn` returns true. +---@param fn bufsitter.cursor.fn +---@return bufsitter.MultiCursor +---@usage [[ +---local cursor = require("bufsitter.cursor") +---local bufnr = vim.api.nvim_get_current_buf() +---cursor.root():children():filter(function(b, node) +--- return vim.treesitter.get_node_text(node, b):find("TODO") ~= nil +---end):exec(bufnr) +---@usage ]] +function Multi:filter(fn) + local prev = self + return new_multi(function(bufnr) + local result = {} + for _, node in ipairs(prev._exec(bufnr)) do + if fn(bufnr, node) then + table.insert(result, node) + end + end + return result + end, prev) +end + +---Returns the parent of every node in the cursor, optionally filtered by +---`opts.types` and `opts.names`. +---@param opts? bufsitter.cursor.opts +---@return bufsitter.MultiCursor +---@usage [[ +---local cursor = require("bufsitter.cursor") +---local bufnr = vim.api.nvim_get_current_buf() +---cursor.root():children():parents():exec(bufnr) +---cursor.root():children():parents({ types = { "source_file" } }):exec(bufnr) +---@usage ]] +function Multi:parents(opts) + local prev = self + return new_multi(function(bufnr) + local result = {} + for _, node in ipairs(prev._exec(bufnr)) do + local p = node:parent() + if p and node_matches(p, p:parent(), opts) then + table.insert(result, p) + end + end + return result + end) +end + +---Falls back to the previous cursor step if the current step yields no nodes. +---@return bufsitter.MultiCursor +---@usage [[ +---local cursor = require("bufsitter.cursor") +---local bufnr = vim.api.nvim_get_current_buf() +---local fn = function(b, node) return node:type() == "identifier" end +----- use all children if filter yields nothing +---cursor.root():children():filter(fn):or_else():exec(bufnr) +---@usage ]] +function Multi:or_else() + return make_or_else(self, new_multi) +end + +---Selects the nth node. Positive indices are 1-based from the front; +---negative indices count from the end (-1 = last). 0 is an error. +---@param n integer 1-based; negative counts from end (-1 = last); 0 is an error +---@return bufsitter.SingleCursor +---@usage [[ +---local cursor = require("bufsitter.cursor") +---local bufnr = vim.api.nvim_get_current_buf() +---cursor.root():children():nth(2):exec(bufnr) -- second child +---cursor.root():children():nth(-2):exec(bufnr) -- second-to-last child +---@usage ]] +function Multi:nth(n) + assert(n ~= 0, "nth: index cannot be 0") + local prev = self + return new_single(function(bufnr) + local nodes = prev._exec(bufnr) + if #nodes == 0 then + return {} + end + local idx = n > 0 and n or (#nodes + n + 1) + local node = nodes[idx] + return node and { node } or {} + end) +end + +---Selects the first node. Equivalent to `nth(1)`. +---@return bufsitter.SingleCursor +---@usage [[ +---local cursor = require("bufsitter.cursor") +---local bufnr = vim.api.nvim_get_current_buf() +---cursor.root():children({ types = { "function_declaration" } }):first():exec(bufnr) +---@usage ]] +function Multi:first() + return self:nth(1) +end + +---Selects the last node. Equivalent to `nth(-1)`. +---@return bufsitter.SingleCursor +---@usage [[ +---local cursor = require("bufsitter.cursor") +---local bufnr = vim.api.nvim_get_current_buf() +---cursor.root():children({ types = { "function_declaration" } }):last():exec(bufnr) +---@usage ]] +function Multi:last() + return self:nth(-1) +end + +---Returns the first node for which `fn` returns true. +---Equivalent to `filter(fn):first()`. +---@param fn bufsitter.cursor.fn +---@return bufsitter.SingleCursor +---@usage [[ +---local cursor = require("bufsitter.cursor") +---local bufnr = vim.api.nvim_get_current_buf() +---cursor.root():children():any(function(b, node) +--- return node:type() == "function_declaration" +---end):exec(bufnr) +---@usage ]] +function Multi:any(fn) + return self:filter(fn):first() +end + +---Errors if the cursor holds no nodes, or if `fn` returns false for any node. +---`msg` overrides the default error message. +---@param msg? string +---@param fn? bufsitter.cursor.fn +---@return bufsitter.MultiCursor +---@usage [[ +---local cursor = require("bufsitter.cursor") +---local bufnr = vim.api.nvim_get_current_buf() +---cursor.root() +--- :children({ types = { "function_declaration" } }) +--- :assert("no functions found"):exec(bufnr) +---@usage ]] +function Multi:assert(msg, fn) + local prev = self + return new_multi(function(bufnr) + local nodes = prev._exec(bufnr) + if #nodes == 0 then + error(msg or "bufsitter: no nodes found", 2) + end + if fn then + for _, node in ipairs(nodes) do + if not fn(bufnr, node) then + error(msg or "bufsitter: assertion failed", 2) + end + end + end + return nodes + end, prev._prev) +end + +-- SingleCursor methods + +---Returns the parent of the node, optionally filtered by `opts`. +---@param opts? bufsitter.cursor.opts +---@return bufsitter.SingleCursor +---@usage [[ +---local cursor = require("bufsitter.cursor") +---local bufnr = vim.api.nvim_get_current_buf() +---cursor.root():children():first():parent():exec(bufnr) +---cursor.root():children():first():parent({ types = { "source_file" } }):exec(bufnr) +---@usage ]] +function Single:parent(opts) + local prev = self + return new_single(function(bufnr) + local result = {} + for _, node in ipairs(prev._exec(bufnr)) do + local p = node:parent() + if p and node_matches(p, p:parent(), opts) then + table.insert(result, p) + end + end + return result + end, prev) +end + +---Returns all siblings of the node (excluding itself), optionally filtered by `opts`. +---@param opts? bufsitter.cursor.opts +---@return bufsitter.MultiCursor +---@usage [[ +---local cursor = require("bufsitter.cursor") +---local bufnr = vim.api.nvim_get_current_buf() +---cursor.root():children():first():siblings():exec(bufnr) +---cursor.root():children():first():siblings({ types = { "function_declaration" } }):exec(bufnr) +---@usage ]] +function Single:siblings(opts) + local prev = self + return new_multi(function(bufnr) + local result = {} + for _, node in ipairs(prev._exec(bufnr)) do + local p = node:parent() + if p then + for _, sib in ipairs(collect_children(p, opts)) do + if sib ~= node then + table.insert(result, sib) + end + end + end + end + return result + end) +end + +---Returns all named siblings that appear after the node in source order, +---optionally filtered by `opts`. +---@param opts? bufsitter.cursor.opts +---@return bufsitter.MultiCursor +---@usage [[ +---local cursor = require("bufsitter.cursor") +---local bufnr = vim.api.nvim_get_current_buf() +---cursor.root():children():first():next_siblings():exec(bufnr) +---cursor.root():children():first():next_siblings({ types = { "comment" } }):exec(bufnr) +---@usage ]] +function Single:next_siblings(opts) + local prev = self + return new_multi(function(bufnr) + local result = {} + for _, node in ipairs(prev._exec(bufnr)) do + local p = node:parent() + local sib = node:next_named_sibling() + while sib ~= nil do + if node_matches(sib, p, opts) then + table.insert(result, sib) + end + sib = sib:next_named_sibling() + end + end + return result + end) +end + +---Returns all named siblings that appear before the node in source order, +---optionally filtered by `opts`. +---@param opts? bufsitter.cursor.opts +---@return bufsitter.MultiCursor +---@usage [[ +---local cursor = require("bufsitter.cursor") +---local bufnr = vim.api.nvim_get_current_buf() +---cursor.root():children():last():prev_siblings():exec(bufnr) +---cursor.root():children():last():prev_siblings({ types = { "comment" } }):exec(bufnr) +---@usage ]] +function Single:prev_siblings(opts) + local prev = self + return new_multi(function(bufnr) + local result = {} + for _, node in ipairs(prev._exec(bufnr)) do + local p = node:parent() + local sib = node:prev_named_sibling() + while sib ~= nil do + if node_matches(sib, p, opts) then + table.insert(result, sib) + end + sib = sib:prev_named_sibling() + end + end + return result + end) +end + +---Falls back to the previous cursor step if the current step yields no node. +---@return bufsitter.SingleCursor +---@usage [[ +---local cursor = require("bufsitter.cursor") +---local bufnr = vim.api.nvim_get_current_buf() +---local fn = function(b, node) return node:type() == "identifier" end +----- fall back to any child if filter finds nothing +---cursor.root():children():filter(fn):first():or_else():exec(bufnr) +---@usage ]] +function Single:or_else() + return make_or_else(self, new_single) +end + +---Errors if the cursor holds no node, or if `fn` returns false for the node. +---`msg` overrides the default error message. +---@param msg? string +---@param fn? bufsitter.cursor.fn +---@return bufsitter.SingleCursor +---@usage [[ +---local cursor = require("bufsitter.cursor") +---local bufnr = vim.api.nvim_get_current_buf() +---cursor.root():children():first():assert("expected a child"):exec(bufnr) +---@usage ]] +function Single:assert(msg, fn) + local prev = self + return new_single(function(bufnr) + local nodes = prev._exec(bufnr) + if fn then + local node = nodes[1] + if not node or not fn(bufnr, node) then + error(msg or "bufsitter: assertion failed", 2) + end + elseif #nodes == 0 then + error(msg or "bufsitter: no nodes found", 2) + end + return nodes + end, prev._prev) +end + +-- Public API + +local M = {} + +---Returns a cursor seeded with the root node of the buffer's syntax tree. +---@return bufsitter.MultiCursor +---@usage [[ +---local cursor = require("bufsitter.cursor") +---local bufnr = vim.api.nvim_get_current_buf() +---local nodes = cursor.root():children():exec(bufnr) +---@usage ]] +function M.root() + return new_multi(function(bufnr) + local parser = get_parser(bufnr) + if not parser then + return {} + end + local root = parser:parse()[1]:root() + return root and { root } or {} + end) +end + +---Returns a cursor seeded with all nodes captured by the given treesitter +---query string, evaluated against the buffer's filetype. +---@param query_str string +---@return bufsitter.MultiCursor +--- +---@usage [[ +---local cursor = require("bufsitter.cursor") +---local bufnr = vim.api.nvim_get_current_buf() +---local nodes = cursor.query("(function_declaration) @fn"):exec(bufnr) +---@usage ]] +function M.query(query_str) + return new_multi(function(bufnr) + local parser = get_parser(bufnr) + if not parser then + return {} + end + local ft = vim.bo[bufnr].filetype + local query = vim.treesitter.query.parse(ft, query_str) + local root = parser:parse()[1]:root() + local result = {} + for _, node in query:iter_captures(root, bufnr) do + table.insert(result, node) + end + return result + end) +end + +return M diff --git a/lua/bufsitter/init.lua b/lua/bufsitter/init.lua new file mode 100644 index 0000000..ddf2819 --- /dev/null +++ b/lua/bufsitter/init.lua @@ -0,0 +1,76 @@ +---@toc bufsitter.contents + +---@mod bufsitter bufsitter.nvim +---@brief [[ +--- Treesitter-powered buffer manipulation for Neovim. +--- +--- Quick start: +--->lua +--- require("bufsitter").setup() +---< +---@brief ]] + +---@class bufsitter.config.scratch.opts +---@field ft? string +---@field init_contents? string[] | fun(): string[] +---@field on_attach? fun(bufnr: integer) +---@field win? vim.api.keyset.win_config + +---@class bufsitter.config.ref.opts +---@field expand? boolean + +---@class bufsitter.config.io.opts +---@field on_error? fun(err: string) + +---@class bufsitter.config.opts +---@field scratch? bufsitter.config.scratch.opts +---@field ref? bufsitter.config.ref.opts +---@field io? bufsitter.config.io.opts + +---@class Bufsitter +---@field config bufsitter.config.opts +local M = {} + +---@type bufsitter.config.opts +local default = { + scratch = { + ft = "markdown", + init_contents = {}, + on_attach = nil, + win = { + relative = "editor", + width = 80, + height = 20, + row = 5, + col = 10, + style = "minimal", + border = "rounded", + }, + }, + io = { + on_error = nil, + }, + ref = { + expand = false, + }, +} + +---Initializes bufsitter with the given options, deep-merged over the defaults. +---Must be called once before using any other bufsitter API. +---@param opts? bufsitter.config.opts +---@usage [[ +---require("bufsitter").setup({ +--- scratch = { ft = "markdown" }, +--- io = { +--- on_error = function(err) +--- vim.notify(err, vim.log.levels.ERROR) +--- end, +--- }, +---}) +---@usage ]] +function M.setup(opts) + ---@type bufsitter.config.opts + M.config = vim.tbl_deep_extend("force", default, opts or {}) +end + +return M diff --git a/lua/bufsitter/io.lua b/lua/bufsitter/io.lua new file mode 100644 index 0000000..0636ada --- /dev/null +++ b/lua/bufsitter/io.lua @@ -0,0 +1,467 @@ +---@mod bufsitter.io IO +---@brief [[ +---Buffer read/write operations driven by a cursor or explicit row/col range. +--- +---Each function accepts an `opts` table with either a `cursor` field +---(a |bufsitter.Cursor|) or explicit `start_row`/`end_row` coordinates. +---When `cursor` is given, the operation is applied to every node the cursor +---resolves to. An optional `hook` can transform the content before it is +---written, and `on_error` can intercept errors thrown by the cursor. +---@brief ]] + +---@class bufsitter.io.select.opts +---@field cursor? bufsitter.Cursor +---@field start_row? integer +---@field start_col? integer +---@field end_row? integer +---@field end_col? integer +---@field on_error? fun(err: string) +---@field hook? fun(bufnr: integer, contents: string[]): string[]? + +---@class bufsitter.io.insert.opts +---@field cursor? bufsitter.Cursor +---@field start_row? integer +---@field start_col? integer +---@field end_row? integer +---@field end_col? integer +---@field on_error? fun(err: string) +---@field hook? fun(bufnr: integer, contents: string[]): string[]? +---@field prepend? boolean +---@field inline? boolean + +---@class bufsitter.io.delete.opts +---@field cursor? bufsitter.Cursor +---@field start_row? integer +---@field start_col? integer +---@field end_row? integer +---@field end_col? integer +---@field on_error? fun(err: string) + +---@class bufsitter.io.replace.opts +---@field cursor? bufsitter.Cursor +---@field start_row? integer +---@field start_col? integer +---@field end_row? integer +---@field end_col? integer +---@field on_error? fun(err: string) +---@field hook? fun(bufnr: integer, contents: string[]): string[]? + +local config = require("bufsitter") + +local M = {} + +local function eval_cursor(cursor_fn, bufnr, on_error) + local handler = on_error + or (config.config and config.config.io and config.config.io.on_error) + if handler then + local ok, result = pcall(function() + return cursor_fn:exec(bufnr) + end) + if not ok then + handler(tostring(result)) + return nil + end + return result + end + return cursor_fn:exec(bufnr) +end + +-- Treesitter node ranges use exclusive end: er=N,ec=0 means "start of row N". +-- nvim_buf_set_text requires valid positions, so clamp to the end of row N-1. +local function clamp_end(bufnr, er, ec) + if ec == 0 and er > 0 then + local prev = vim.api.nvim_buf_get_lines(bufnr, er - 1, er, false)[1] or "" + return er - 1, #prev + end + return er, ec +end + +---Reads text from `bufnr`. Returns one `string[]` per matched node when +---`cursor` is used, or a single-element wrapper otherwise. +---Returns `nil` if the buffer is invalid or the cursor yields nothing. +---@param bufnr integer +---@param opts bufsitter.io.select.opts +---@return string[][]|nil +---@usage [[ +---local io = require("bufsitter.io") +---local cursor = require("bufsitter.cursor") +---local bufnr = vim.api.nvim_get_current_buf() +---local results = io.select(bufnr, { +--- cursor = cursor.root():children({ types = { "function_declaration" } }), +---}) +----- results[1] == { "func foo() {", " ...", "}" } +---@usage ]] +function M.select(bufnr, opts) + if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then + return nil + end + opts = opts or {} + + if opts.cursor then + local items = eval_cursor(opts.cursor, bufnr, opts.on_error) + if not items or #items == 0 then + return nil + end + local results = {} + for _, node in ipairs(items) do + local sr, sc, er, ec = node:range() + er, ec = clamp_end(bufnr, er, ec) + local lines = vim.api.nvim_buf_get_text(bufnr, sr, sc, er, ec, {}) + if type(opts.hook) == "function" then + local res = opts.hook(bufnr, lines) + if res ~= nil then + lines = res + end + end + table.insert(results, lines) + end + return results + end + + local lines + if opts.start_row ~= nil and opts.end_row ~= nil then + local er, ec = clamp_end(bufnr, opts.end_row, opts.end_col or 0) + lines = + vim.api.nvim_buf_get_text(bufnr, opts.start_row, opts.start_col or 0, er, ec, {}) + else + lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + end + + if type(opts.hook) == "function" then + local res = opts.hook(bufnr, lines) + if res ~= nil then + lines = res + end + end + return { lines } +end + +---Like `select`, but joins each node's lines with `\n` and returns a flat +---`string[]` — one string per matched node. +---@param bufnr integer +---@param opts? bufsitter.io.select.opts +---@return string[]|nil +---@usage [[ +---local io = require("bufsitter.io") +---local cursor = require("bufsitter.cursor") +---local bufnr = vim.api.nvim_get_current_buf() +---local texts = io.select_text(bufnr, { +--- cursor = cursor.root():children({ types = { "function_declaration" } }), +---}) +----- texts[1] == "func foo() {\n ...\n}" +---@usage ]] +function M.select_text(bufnr, opts) + local results = M.select(bufnr, opts) + if not results then + return nil + end + local texts = {} + for _, lines in ipairs(results) do + table.insert(texts, table.concat(lines, "\n")) + end + return texts +end + +---Inserts `contents` into `bufnr`. When `prepend` is false (default) content +---is placed after each target; when true, before. `inline` inserts at the +---exact character position without adding a new line. Without a cursor or +---range, appends to the end of the buffer. +---@param bufnr integer +---@param contents string[] +---@param opts? bufsitter.io.insert.opts +---@usage [[ +---local io = require("bufsitter.io") +---local cursor = require("bufsitter.cursor") +---local bufnr = vim.api.nvim_get_current_buf() +----- append after the first function +---io.insert(bufnr, { "-- generated" }, { +--- cursor = cursor.root():children({ types = { "function_declaration" } }):first(), +---}) +----- prepend before it +---io.insert(bufnr, { "-- generated" }, { +--- prepend = true, +--- cursor = cursor.root():children({ types = { "function_declaration" } }):first(), +---}) +---@usage ]] +function M.insert(bufnr, contents, opts) + if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then + return + end + opts = opts or {} + + if type(opts.hook) == "function" then + local res = opts.hook(bufnr, contents) + if res ~= nil then + contents = res + end + end + + if opts.cursor then + local items = eval_cursor(opts.cursor, bufnr, opts.on_error) + if not items or #items == 0 then + return + end + + table.sort(items, function(a, b) + local a_sr, _, a_er = a:range() + local b_sr, _, b_er = b:range() + if opts.prepend then + return a_sr > b_sr + else + return a_er > b_er + end + end) + + local line_count = vim.api.nvim_buf_line_count(bufnr) + for _, node in ipairs(items) do + local sr, sc, er, ec = node:range() + if opts.prepend then + if opts.inline then + -- attach at exact character position, no newline added + vim.api.nvim_buf_set_text(bufnr, sr, sc, sr, sc, contents) + else + vim.api.nvim_buf_set_lines(bufnr, sr, sr, false, contents) + end + else + if opts.inline then + -- attach at exact character position, no newline added + if er >= line_count then + local last = vim.api.nvim_buf_get_lines( + bufnr, + line_count - 1, + line_count, + false + )[1] or "" + vim.api.nvim_buf_set_text( + bufnr, + line_count - 1, + #last, + line_count - 1, + #last, + contents + ) + else + vim.api.nvim_buf_set_text(bufnr, er, ec, er, ec, contents) + end + else + -- ec=0 means exclusive end (before row er), so insert at er; otherwise after er + local row = (ec == 0) and er or (er + 1) + if row > line_count then + row = line_count + end + vim.api.nvim_buf_set_lines(bufnr, row, row, false, contents) + end + end + end + return + end + + if opts.start_row ~= nil and opts.end_row ~= nil then + if opts.prepend then + if opts.inline then + vim.api.nvim_buf_set_text( + bufnr, + opts.start_row, + opts.start_col or 0, + opts.start_row, + opts.start_col or 0, + contents + ) + else + vim.api.nvim_buf_set_lines(bufnr, opts.start_row, opts.start_row, false, contents) + end + else + if opts.inline then + vim.api.nvim_buf_set_text( + bufnr, + opts.end_row, + opts.end_col or 0, + opts.end_row, + opts.end_col or 0, + contents + ) + else + local rep = vim.list_extend(vim.deepcopy(contents), { "" }) + vim.api.nvim_buf_set_text( + bufnr, + opts.end_row, + opts.end_col or 0, + opts.end_row, + opts.end_col or 0, + rep + ) + end + end + return + end + + local last_row = vim.api.nvim_buf_line_count(bufnr) - 1 + local last_line = vim.api.nvim_buf_get_lines(bufnr, last_row, last_row + 1, false)[1] + or "" + vim.api.nvim_buf_set_text( + bufnr, + last_row, + #last_line, + last_row, + #last_line, + { "", unpack(contents) } + ) +end + +---Convenience wrapper around `insert` that splits `str` on newlines first. +---@param bufnr integer +---@param str string +---@param opts? bufsitter.io.insert.opts +---@usage [[ +---local io = require("bufsitter.io") +---local cursor = require("bufsitter.cursor") +---local bufnr = vim.api.nvim_get_current_buf() +---io.insert_text(bufnr, "-- line one\n-- line two", { +--- cursor = cursor.root():children():first(), +---}) +---@usage ]] +function M.insert_text(bufnr, str, opts) + M.insert(bufnr, vim.split(str, "\n"), opts) +end + +---Deletes text from `bufnr`. Nodes are deleted in reverse source order to +---preserve row indices for subsequent deletions. +---@param bufnr integer +---@param opts? bufsitter.io.delete.opts +---@usage [[ +---local io = require("bufsitter.io") +---local cursor = require("bufsitter.cursor") +---local bufnr = vim.api.nvim_get_current_buf() +---io.delete(bufnr, { +--- cursor = cursor.root():children({ types = { "function_declaration" } }):first(), +---}) +---@usage ]] +function M.delete(bufnr, opts) + if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then + return + end + opts = opts or {} + + if opts.cursor then + local items = eval_cursor(opts.cursor, bufnr, opts.on_error) + if not items or #items == 0 then + return + end + + table.sort(items, function(a, b) + local a_sr = a:range() + local b_sr = b:range() + return a_sr > b_sr + end) + for _, node in ipairs(items) do + local sr, sc, er, ec = node:range() + er, ec = clamp_end(bufnr, er, ec) + vim.api.nvim_buf_set_text(bufnr, sr, sc, er, ec, {}) + end + return + end + + if opts.start_row ~= nil and opts.end_row ~= nil then + vim.api.nvim_buf_set_text( + bufnr, + opts.start_row, + opts.start_col or 0, + opts.end_row, + opts.end_col or 0, + {} + ) + end +end + +---Replaces the text of each matched node or range with `contents`. +---Multiple matches are replaced in reverse source order to preserve indices. +---@param bufnr integer +---@param contents string[] +---@param opts? bufsitter.io.replace.opts +---@usage [[ +---local io = require("bufsitter.io") +---local cursor = require("bufsitter.cursor") +---local bufnr = vim.api.nvim_get_current_buf() +---io.replace(bufnr, { "func foo() {}", "}" }, { +--- cursor = cursor.root():children({ types = { "function_declaration" } }):first(), +---}) +---@usage ]] +function M.replace(bufnr, contents, opts) + if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then + return + end + opts = opts or {} + + if type(opts.hook) == "function" then + local res = opts.hook(bufnr, contents) + if res ~= nil then + contents = res + end + end + + if opts.cursor then + local items = eval_cursor(opts.cursor, bufnr, opts.on_error) + if not items or #items == 0 then + return + end + + table.sort(items, function(a, b) + local a_sr = a:range() + local b_sr = b:range() + return a_sr > b_sr + end) + for _, node in ipairs(items) do + local sr, sc, er, ec = node:range() + er, ec = clamp_end(bufnr, er, ec) + vim.api.nvim_buf_set_text(bufnr, sr, sc, er, ec, vim.deepcopy(contents)) + end + return + end + + if opts.start_row ~= nil and opts.end_row ~= nil then + local er, ec = clamp_end(bufnr, opts.end_row, opts.end_col or 0) + vim.api.nvim_buf_set_text( + bufnr, + opts.start_row, + opts.start_col or 0, + er, + ec, + contents + ) + end +end + +---Convenience wrapper around `replace` that splits `str` on newlines first. +---@param bufnr integer +---@param str string +---@param opts? bufsitter.io.replace.opts +---@usage [[ +---local io = require("bufsitter.io") +---local cursor = require("bufsitter.cursor") +---local bufnr = vim.api.nvim_get_current_buf() +---io.replace_text(bufnr, "func foo() {}\n}", { +--- cursor = cursor.root():children():first(), +---}) +---@usage ]] +function M.replace_text(bufnr, str, opts) + M.replace(bufnr, vim.split(str, "\n"), opts) +end + +---Clears all content from `bufnr`, leaving a single empty line. +---@param bufnr integer +---@usage [[ +---local io = require("bufsitter.io") +---local bufnr = vim.api.nvim_get_current_buf() +---io.clear(bufnr) +---@usage ]] +function M.clear(bufnr) + if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then + return + end + local last_row = vim.api.nvim_buf_line_count(bufnr) - 1 + local last_line = vim.api.nvim_buf_get_lines(bufnr, last_row, last_row + 1, false)[1] + or "" + vim.api.nvim_buf_set_text(bufnr, 0, 0, last_row, #last_line, { "" }) +end + +return M diff --git a/lua/bufsitter/ref.lua b/lua/bufsitter/ref.lua new file mode 100644 index 0000000..dda27d9 --- /dev/null +++ b/lua/bufsitter/ref.lua @@ -0,0 +1,91 @@ +---@mod bufsitter.ref Ref +---@brief [[ +---Generates a human-readable reference string for the current buffer or +---visual selection, in the form `path:LN` or `path:LN~LM`. +--- +---Useful for inserting source references into scratch buffers or prompts. +---When `expand` is true, the path is expanded to an absolute path; +---otherwise it is relative to the home directory (`~`). +---@brief ]] + +---@class bufsitter.ref.opts +---@field expand? boolean + +local M = {} + +---Returns a reference string for the most recent visual selection. +---Format: `path:LN` for a single line, `path:LN~LM` for a range. +---Falls back to the buffer name alone if no selection marks are set. +---@param opts? bufsitter.ref.opts +---@return string +---@usage [[ +----- in a keymap callback, after making a visual selection +---local ref = require("bufsitter.ref").visual_selection() +----- "~/project/main.lua:L10~L15" +---@usage ]] +function M.visual_selection(opts) + opts = opts or {} + + -- 1. Exit visual mode FIRST to force update of '< and '> marks + -- Use "xt" to ensure type codes are handled and the call is synchronous enough + vim.cmd([[execute "normal! \"]]) + + local name = vim.api.nvim_buf_get_name(0) + name = (name == "") and "[No Name]" + or vim.fn.fnamemodify(name, opts.expand and ":p" or ":~") + + -- 2. Now these marks are guaranteed to be updated to the recent selection + local s = vim.fn.getpos("'<")[2] + local e = vim.fn.getpos("'>")[2] + + if s == 0 or e == 0 then + return name + end + + return s == e and ("%s:L%d"):format(name, s) or ("%s:L%d~L%d"):format(name, s, e) +end + +---Returns a reference string for the current context: delegates to +---`visual_selection` when in a visual mode, otherwise to `buffer`. +---@param opts? bufsitter.ref.opts +---@return string +---@usage [[ +---vim.keymap.set({ "n", "v" }, "r", function() +--- local ref = require("bufsitter.ref").get() +--- vim.fn.setreg("+", ref) +---end) +---@usage ]] +function M.get(opts) + local mode = vim.api.nvim_get_mode().mode + if mode == "v" or mode == "V" or mode == "\22" then + return M.visual_selection(opts) + end + return M.buffer(opts) +end + +---Returns the name of the current buffer. Returns `"[No Name]"` for unnamed +---buffers. With `expand = true`, returns the absolute path. +---@param opts? bufsitter.ref.opts +---@return string +---@usage [[ +---local ref = require("bufsitter.ref") +---ref.buffer() -- "~/project/main.lua" +---ref.buffer({ expand = true }) -- "/Users/user/project/main.lua" +---@usage ]] +function M.buffer(opts) + opts = opts or {} + local expand = opts.expand or false + local buf_name = vim.api.nvim_buf_get_name(0) + if buf_name == "" then + return "[No Name]" + end + + if expand then + buf_name = vim.fn.fnamemodify(buf_name, ":p") + else + buf_name = vim.fn.fnamemodify(buf_name, ":~") + end + return buf_name +end + +return M diff --git a/lua/bufsitter/scratch.lua b/lua/bufsitter/scratch.lua new file mode 100644 index 0000000..a70adff --- /dev/null +++ b/lua/bufsitter/scratch.lua @@ -0,0 +1,186 @@ +---@mod bufsitter.scratch Scratch +---@brief [[ +---Floating scratch buffer with show/hide/toggle lifecycle management. +--- +---A `Scratch` is a unlisted, non-file buffer displayed in a floating window. +---Window position and size are configured via |bufsitter.scratch.win.opts|. +---Initial content can be provided as a string array or a function, and an +---`on_attach` callback runs once on buffer creation. +---@brief ]] + +---@class bufsitter.scratch.win.opts +---@field relative? string +---@field width? integer +---@field height? integer +---@field row? integer +---@field col? integer +---@field style? string +---@field border? string + +---@class bufsitter.scratch.opts +---@field ft? string +---@field init_contents? string[] | fun(): string[] +---@field on_attach? fun(bufnr: integer) +---@field win? bufsitter.scratch.win.opts + +---@class bufsitter.Scratch +---@field private _bufnr integer +---@field private _winid integer|nil +---@field private _win_opts bufsitter.scratch.win.opts +local Scratch = {} +Scratch.__index = Scratch + +local config = require("bufsitter") + +---Creates a new scratch buffer, deep-merging `opts` over the global defaults. +---Sets the filetype, writes `init_contents`, and calls `on_attach` if provided. +---@param opts? bufsitter.scratch.opts +---@return bufsitter.Scratch +---@usage [[ +---local Scratch = require("bufsitter.scratch") +---local s = Scratch.new({ +--- ft = "markdown", +--- init_contents = { "# Notes", "" }, +--- on_attach = function(bufnr) +--- vim.keymap.set("n", "q", "close", { buffer = bufnr }) +--- end, +---}) +---@usage ]] +function Scratch.new(opts) + opts = vim.tbl_deep_extend("force", config.config.scratch, opts or {}) + + local bufnr = vim.api.nvim_create_buf(false, true) + vim.bo[bufnr].filetype = opts.ft + + local lines = {} + local init_contents = opts.init_contents + if type(init_contents) == "function" then + lines = init_contents() + elseif type(init_contents) == "table" then + lines = init_contents + end + if lines and #lines > 0 then + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + end + + if type(opts.on_attach) == "function" then + opts.on_attach(bufnr) + end + + local self = setmetatable({}, Scratch) + self._bufnr = bufnr + self._winid = nil + self._win_opts = opts.win or {} + return self +end + +---Returns the buffer number of the scratch buffer. +---@return integer +---@usage [[ +---local Scratch = require("bufsitter.scratch") +---local s = Scratch.new() +---local bufnr = s:bufnr() +---@usage ]] +function Scratch:bufnr() + return self._bufnr +end + +---Returns true if the underlying buffer still exists. +---@return boolean +---@usage [[ +---local Scratch = require("bufsitter.scratch") +---local s = Scratch.new() +---if s:is_valid() then +--- s:show() +---end +---@usage ]] +function Scratch:is_valid() + return vim.api.nvim_buf_is_valid(self._bufnr) +end + +---Returns true if the floating window is currently open. +---@return boolean +---@usage [[ +---local Scratch = require("bufsitter.scratch") +---local s = Scratch.new() +---if not s:is_visible() then +--- s:show() +---end +---@usage ]] +function Scratch:is_visible() + return self._winid ~= nil and vim.api.nvim_win_is_valid(self._winid) +end + +---Opens the floating window. If it is already visible, reattaches the buffer +---to the existing window. Returns the window id, or nil if the buffer is invalid. +---@param win_opts? bufsitter.scratch.win.opts +---@return integer|nil +---@usage [[ +---local Scratch = require("bufsitter.scratch") +---local s = Scratch.new() +---s:show() +---s:show({ width = 100, height = 30 }) +---@usage ]] +function Scratch:show(win_opts) + if not self:is_valid() then + return nil + end + + local wopts = vim.tbl_deep_extend("force", self._win_opts, win_opts or {}) + + if self:is_visible() then + vim.api.nvim_win_set_buf(self._winid, self._bufnr) + else + self._winid = vim.api.nvim_open_win(self._bufnr, true, wopts) + end + + return self._winid +end + +---Closes the floating window without deleting the buffer. +---@usage [[ +---local Scratch = require("bufsitter.scratch") +---local s = Scratch.new() +---s:hide() +---@usage ]] +function Scratch:hide() + if not self._winid or not vim.api.nvim_win_is_valid(self._winid) then + return + end + vim.api.nvim_win_close(self._winid, false) + self._winid = nil +end + +---Hides the window if visible, shows it otherwise. +---@param win_opts? bufsitter.scratch.win.opts +---@usage [[ +---local Scratch = require("bufsitter.scratch") +---local s = Scratch.new() +---vim.keymap.set("n", "s", function() s:toggle() end) +---@usage ]] +function Scratch:toggle(win_opts) + if self:is_visible() then + self:hide() + else + self:show(win_opts) + end +end + +---Closes the floating window and deletes the buffer. The instance should not +---be used after calling this. +---@usage [[ +---local Scratch = require("bufsitter.scratch") +---local s = Scratch.new() +---s:delete() +---@usage ]] +function Scratch:delete() + if self._winid and vim.api.nvim_win_is_valid(self._winid) then + vim.api.nvim_win_close(self._winid, false) + end + if vim.api.nvim_buf_is_valid(self._bufnr) then + vim.api.nvim_buf_delete(self._bufnr, { force = true }) + end + self._winid = nil +end + +return Scratch diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..6a12786 --- /dev/null +++ b/shell.nix @@ -0,0 +1,21 @@ +{ + pkgs ? import { }, +}: +let + unstableTarball = builtins.fetchTarball "https://github.com/NixOS/nixpkgs/archive/nixos-unstable.tar.gz"; + unstablePkgs = import unstableTarball { }; +in +pkgs.mkShell { + packages = with pkgs; [ + stylua + lua-language-server + lua + lemmy-help + unstablePkgs.commitlint + ]; + shellHook = # sh + '' + export name="nix:promdown.nvim" + export NVIM_APPNAME="nvim" + ''; +} diff --git a/tests/bufsitter/config_spec.lua b/tests/bufsitter/config_spec.lua new file mode 100644 index 0000000..a72a5a6 --- /dev/null +++ b/tests/bufsitter/config_spec.lua @@ -0,0 +1,70 @@ +local config = require("bufsitter") + +describe("config", function() + before_each(function() + config.setup() + end) + + describe("setup", function() + describe("defaults", function() + it("should set scratch.ft to markdown", function() + assert.are.same("markdown", config.config.scratch.ft) + end) + + it("should set scratch.init_contents as a table", function() + assert.are.same("table", type(config.config.scratch.init_contents)) + end) + + it("should set scratch.on_attach to nil", function() + assert.are.same(nil, config.config.scratch.on_attach) + end) + + it("should set default win options", function() + local win = config.config.scratch.win + assert.are.same("editor", win.relative) + assert.are.same(80, win.width) + assert.are.same(20, win.height) + assert.are.same(5, win.row) + assert.are.same(10, win.col) + assert.are.same("minimal", win.style) + assert.are.same("rounded", win.border) + end) + + it("should set default io options", function() + local io = config.config.io + assert.is_nil(io.on_error) + end) + + it("should set default ref options", function() + assert.is_false(config.config.ref.expand) + end) + end) + + describe("user opts", function() + it("should override scratch.ft", function() + config.setup({ scratch = { ft = "lua" } }) + assert.are.same("lua", config.config.scratch.ft) + end) + + it("should deep merge scratch.win", function() + config.setup({ scratch = { win = { width = 100 } } }) + local win = config.config.scratch.win + assert.are.same(100, win.width) + assert.are.same(20, win.height) + assert.are.same("rounded", win.border) + end) + + it("should override io options", function() + config.setup({ io = { prepend = true, start_line = 3 } }) + assert.is_true(config.config.io.prepend) + assert.are.same(3, config.config.io.start_line) + assert.is_nil(config.config.io.end_line) + end) + + it("should override ref options", function() + config.setup({ ref = { expand = true } }) + assert.is_true(config.config.ref.expand) + end) + end) + end) +end) diff --git a/tests/bufsitter/cursor_spec.lua b/tests/bufsitter/cursor_spec.lua new file mode 100644 index 0000000..cb394e3 --- /dev/null +++ b/tests/bufsitter/cursor_spec.lua @@ -0,0 +1,790 @@ +local cursor = require("bufsitter.cursor") +local io = require("bufsitter.io") +local h = require("tests.helpers") + +describe("cursor", function() + after_each(h.clean_bufs) + + describe("root()", function() + it("should have exec method", function() + assert.are.same(true, type(cursor.root().exec) == "function") + end) + + it("should return empty when parser fails", function() + local bufnr = vim.api.nvim_create_buf(false, true) + local items = cursor.root():exec(bufnr) + assert.are.same(0, #items) + end) + + it("should return root node and full range", function() + local bufnr = h.make_buf({ "# Title", "content" }, "markdown") + local expected_root = h.get_root(bufnr) + local items = cursor.root():exec(bufnr) + assert.are.same(1, #items) + assert.are.same(expected_root, items[1]) + local sr, sc, er, ec = items[1]:range() + assert.are.same(0, sr) + assert.are.same(0, sc) + assert.are.same(2, er) + assert.are.same(0, ec) + end) + end) + + describe("query()", function() + it("should have exec method", function() + assert.are.same(true, type(cursor.query("(section) @node").exec) == "function") + end) + + it("should return all matching nodes", function() + local bufnr = h.make_buf({ "# A", "# B", "# C" }, "markdown") + local items = cursor.query("(section) @node"):exec(bufnr) + assert.are.same(3, #items) + end) + + it("should return empty when query matches nothing", function() + local bufnr = h.make_buf({ "just text" }, "markdown") + local items = cursor.query("(fenced_code_block) @node"):exec(bufnr) + assert.are.same(0, #items) + end) + + it("should return empty when parser fails", function() + local bufnr = vim.api.nvim_create_buf(false, true) + local items = cursor.query("(section) @node"):exec(bufnr) + assert.are.same(0, #items) + end) + + it("should error on invalid query", function() + local bufnr = h.make_buf({ "# Title" }, "markdown") + assert.has_error(function() + cursor.query("this is not valid"):exec(bufnr) + end) + end) + + it("chains into cursor methods", function() + local bufnr = h.make_buf({ "# A", "# B" }, "markdown") + local items = cursor.query("(section) @node"):first():exec(bufnr) + assert.are.same(1, #items) + assert.are.same("section", items[1]:type()) + end) + end) + + describe("children()", function() + it("should have exec method", function() + assert.are.same(true, type(cursor.root():children().exec) == "function") + end) + + it("should return all named children", function() + local bufnr = h.make_buf({ "# A", "# B", "# C" }, "markdown") + local items = cursor.root():children():exec(bufnr) + assert.are.same(3, #items) + end) + + it("should return empty list when no children", function() + local bufnr = vim.api.nvim_create_buf(false, true) + local items = cursor.root():children():exec(bufnr) + assert.are.same(0, #items) + end) + + it("filters by type", function() + local bufnr = h.make_buf({ "# A", "# B" }, "markdown") + local items = cursor.root():children({ types = { "section" } }):exec(bufnr) + assert.are.same(2, #items) + for _, node in ipairs(items) do + assert.are.same("section", node:type()) + end + end) + + it("filters by multiple types (OR)", function() + local bufnr = h.make_buf({ "# A", "# B" }, "markdown") + local items = + cursor.root():children({ types = { "section", "atx_heading" } }):exec(bufnr) + assert.are.same(true, #items >= 2) + end) + + it("returns empty when type filter matches nothing", function() + local bufnr = h.make_buf({ "# Title" }, "markdown") + local items = + cursor.root():children({ types = { "fenced_code_block" } }):exec(bufnr) + assert.are.same(0, #items) + end) + + it("filters by field name", function() + local bufnr = h.make_buf({ "# Title" }, "markdown") + local items = cursor + .root() + :children({ types = { "section" } }) + :first() + :children({ names = { "nonexistent" } }) + :exec(bufnr) + assert.are.same(0, #items) + end) + + it("returns empty when field does not exist", function() + local bufnr = vim.api.nvim_create_buf(false, true) + local items = cursor.root():first():children({ names = { "name" } }):exec(bufnr) + assert.are.same(0, #items) + end) + + it("type and name opts are AND", function() + local bufnr = h.make_buf({ "# Title" }, "markdown") + -- atx_heading exists as a child, but not in a field named "body" + local items = cursor + .root() + :children({ types = { "section" } }) + :first() + :children({ names = { "body" }, types = { "atx_heading" } }) + :exec(bufnr) + assert.are.same(0, #items) + end) + + it("chains: children({types}):first():children({types})", function() + local bufnr = h.make_buf({ "# Title" }, "markdown") + local items = cursor + .root() + :children({ types = { "section" } }) + :first() + :children({ types = { "atx_heading" } }) + :exec(bufnr) + assert.are.same(1, #items) + assert.are.same("atx_heading", items[1]:type()) + end) + + it("stops chain when first() returns empty", function() + local bufnr = h.make_buf({ "# Title" }, "markdown") + local items = cursor + .root() + :children({ types = { "nonexistent" } }) + :first() + :children({ types = { "atx_heading" } }) + :exec(bufnr) + assert.are.same(0, #items) + end) + end) + + describe("parent()", function() + it("should return parent node", function() + local bufnr = h.make_buf({ "# Title", "content" }, "markdown") + local items = cursor + .root() + :children({ types = { "section" } }) + :first() + :children({ types = { "atx_heading" } }) + :first() + :parent() + :exec(bufnr) + assert.are.same(1, #items) + assert.are.same("section", items[1]:type()) + end) + + it("should filter parent by type", function() + local bufnr = h.make_buf({ "# Title" }, "markdown") + local items = cursor + .root() + :children({ types = { "section" } }) + :first() + :children({ types = { "atx_heading" } }) + :first() + :parent({ types = { "section" } }) + :exec(bufnr) + assert.are.same(1, #items) + end) + + it("should return empty when parent type does not match", function() + local bufnr = h.make_buf({ "# Title" }, "markdown") + local items = cursor + .root() + :children({ types = { "section" } }) + :first() + :children({ types = { "atx_heading" } }) + :first() + :parent({ types = { "document" } }) + :exec(bufnr) + assert.are.same(0, #items) + end) + + it("should return empty when node has no parent", function() + local bufnr = h.make_buf({ "# Title" }, "markdown") + local items = cursor.root():first():parent():exec(bufnr) + assert.are.same(0, #items) + end) + + it("or_else() falls back to node when parent is empty", function() + local bufnr = h.make_buf({ "# Title" }, "markdown") + local items = + cursor.root():first():parent({ types = { "nonexistent" } }):or_else():exec(bufnr) + assert.are.same(1, #items) + assert.are.same("document", items[1]:type()) + end) + end) + + describe("parents()", function() + it("collects immediate parents of all nodes", function() + local bufnr = h.make_buf({ "# A", "# B" }, "markdown") + local items = cursor + .root() + :children({ types = { "section" } }) + :children({ types = { "atx_heading" } }) + :parents() + :exec(bufnr) + assert.are.same(2, #items) + for _, node in ipairs(items) do + assert.are.same("section", node:type()) + end + end) + + it("filters parents by type", function() + local bufnr = h.make_buf({ "# A", "# B" }, "markdown") + local items = cursor + .root() + :children({ types = { "section" } }) + :children({ types = { "atx_heading" } }) + :parents({ types = { "document" } }) + :exec(bufnr) + assert.are.same(0, #items) + end) + end) + + describe("siblings()", function() + it("should have exec method", function() + local bufnr = h.make_buf({ "# A", "# B" }, "markdown") + assert.are.same( + true, + type(cursor.root():children({ types = { "section" } }):first():siblings().exec) + == "function" + ) + end) + + it("should return all siblings excluding self", function() + local bufnr = h.make_buf({ "# A", "# B", "# C" }, "markdown") + local items = + cursor.root():children({ types = { "section" } }):first():siblings():exec(bufnr) + assert.are.same(2, #items) + end) + + it("should return empty when no siblings", function() + local bufnr = h.make_buf({ "# Title" }, "markdown") + local items = + cursor.root():children({ types = { "section" } }):first():siblings():exec(bufnr) + assert.are.same(0, #items) + end) + + it("should return empty when node has no parent", function() + local bufnr = h.make_buf({ "# Title" }, "markdown") + local items = cursor.root():first():siblings():exec(bufnr) + assert.are.same(0, #items) + end) + end) + + describe("next_siblings()", function() + it("should have exec method", function() + local bufnr = h.make_buf({ "# A", "# B" }, "markdown") + assert.are.same( + true, + type(cursor.root():children():first():next_siblings().exec) == "function" + ) + end) + + it("should return all next siblings", function() + local bufnr = h.make_buf({ "# A", "# B", "# C" }, "markdown") + local items = cursor.root():children():first():next_siblings():exec(bufnr) + assert.are.same(2, #items) + end) + + it("filters by type", function() + local bufnr = h.make_buf({ "# A", "# B", "# C" }, "markdown") + local items = cursor + .root() + :children() + :first() + :next_siblings({ types = { "section" } }) + :first() + :exec(bufnr) + assert.are.same(1, #items) + end) + + it("should return empty when no next siblings exist", function() + local bufnr = h.make_buf({ "# A", "# B", "# C" }, "markdown") + local items = cursor.root():children():last():next_siblings():exec(bufnr) + assert.are.same(0, #items) + end) + end) + + describe("prev_siblings()", function() + it("should have exec method", function() + local bufnr = h.make_buf({ "# A", "# B" }, "markdown") + assert.are.same( + true, + type(cursor.root():children():last():prev_siblings().exec) == "function" + ) + end) + + it("should return all prev siblings", function() + local bufnr = h.make_buf({ "# A", "# B", "# C" }, "markdown") + local items = cursor.root():children():last():prev_siblings():exec(bufnr) + assert.are.same(2, #items) + end) + + it("filters by type", function() + local bufnr = h.make_buf({ "# A", "# B", "# C" }, "markdown") + local items = cursor + .root() + :children() + :last() + :prev_siblings({ types = { "section" } }) + :first() + :exec(bufnr) + assert.are.same(1, #items) + end) + + it("should return empty when no prev siblings exist", function() + local bufnr = h.make_buf({ "# A", "# B", "# C" }, "markdown") + local items = cursor.root():children():first():prev_siblings():exec(bufnr) + assert.are.same(0, #items) + end) + end) + + describe("or_else()", function() + it("falls back when filter returns empty", function() + local bufnr = h.make_buf({ "# A", "# B", "# C" }, "markdown") + local items = cursor + .root() + :children() + :filter(function() + return false + end) + :or_else() + :exec(bufnr) + assert.are.same(3, #items) + end) + + it("does not fall back when filter has results", function() + local bufnr = h.make_buf({ "# A", "# B", "# C" }, "markdown") + local items = cursor + .root() + :children() + :filter(function(_, n) + return n:type() == "section" + end) + :or_else() + :exec(bufnr) + assert.are.same(3, #items) + end) + + it("double or_else is no-op", function() + local bufnr = h.make_buf({ "# A", "# B" }, "markdown") + local base = cursor.root():children():filter(function() + return false + end) + local once = base:or_else():exec(bufnr) + local twice = base:or_else():or_else():exec(bufnr) + assert.are.same(#once, #twice) + end) + end) + + describe("flatMap traversal", function() + it("children() collects children from all nodes", function() + local bufnr = h.make_buf({ "# A", "content a", "# B", "content b" }, "markdown") + local items = cursor.root():children():children():exec(bufnr) + assert.are.same(true, #items >= 4) + end) + + it("children({types}) filters across all nodes", function() + local bufnr = h.make_buf({ "# A", "para a", "# B", "para b" }, "markdown") + local items = + cursor.root():children():children({ types = { "atx_heading" } }):exec(bufnr) + assert.are.same(2, #items) + for _, node in ipairs(items) do + assert.are.same("atx_heading", node:type()) + end + end) + + it("parents() collects parents of all nodes", function() + local bufnr = h.make_buf({ "# A", "# B" }, "markdown") + local items = cursor + .root() + :children({ types = { "section" } }) + :children({ types = { "atx_heading" } }) + :parents() + :exec(bufnr) + assert.are.same(2, #items) + for _, node in ipairs(items) do + assert.are.same("section", node:type()) + end + end) + + it("parents() with type filter skips non-matching parents", function() + local bufnr = h.make_buf({ "# A", "# B" }, "markdown") + local items = cursor + .root() + :children({ types = { "section" } }) + :children({ types = { "atx_heading" } }) + :parents({ types = { "document" } }) + :exec(bufnr) + assert.are.same(0, #items) + end) + + it("deep chain: children({types}):children({types})", function() + local bufnr = h.make_buf({ "# A", "para a", "# B", "para b" }, "markdown") + local items = cursor + .root() + :children({ types = { "section" } }) + :children({ types = { "paragraph" } }) + :exec(bufnr) + assert.are.same(2, #items) + end) + end) + + describe("filter()", function() + it("should filter items", function() + local bufnr = h.make_buf({ "# A", "# B", "# C" }, "markdown") + local count = 0 + local items = cursor + .root() + :children() + :filter(function(b, n) + count = count + 1 + return count <= 2 + end) + :exec(bufnr) + assert.are.same(2, #items) + end) + + it("should return empty when nothing matches", function() + local bufnr = h.make_buf({ "# Title" }, "markdown") + local items = cursor + .root() + :children() + :filter(function() + return false + end) + :exec(bufnr) + assert.are.same(0, #items) + end) + end) + + describe("nth()", function() + it("should return nth item (1-based)", function() + local bufnr = h.make_buf({ "# A", "# B", "# C" }, "markdown") + local items = cursor.root():children():nth(2):exec(bufnr) + assert.are.same(1, #items) + assert.are.same(h.get_root(bufnr):named_child(1), items[1]) + end) + + it("should support negative index (-1 = last)", function() + local bufnr = h.make_buf({ "# A", "# B", "# C" }, "markdown") + local items = cursor.root():children():nth(-1):exec(bufnr) + local root = h.get_root(bufnr) + assert.are.same(root:named_child(2), items[1]) + end) + + it("should return empty when index out of range", function() + local bufnr = h.make_buf({ "# A" }, "markdown") + local items = cursor.root():children():nth(5):exec(bufnr) + assert.are.same(0, #items) + end) + + it("should error on index 0", function() + assert.has_error(function() + cursor.root():children():nth(0) + end) + end) + end) + + describe("first() / last()", function() + it("first() returns first item", function() + local bufnr = h.make_buf({ "# A", "# B", "# C" }, "markdown") + local items = cursor.root():children():first():exec(bufnr) + assert.are.same(1, #items) + assert.are.same(h.get_root(bufnr):named_child(0), items[1]) + end) + + it("last() returns last item", function() + local bufnr = h.make_buf({ "# A", "# B", "# C" }, "markdown") + local items = cursor.root():children():last():exec(bufnr) + local root = h.get_root(bufnr) + assert.are.same(root:named_child(root:named_child_count() - 1), items[1]) + end) + end) + + describe("assert()", function() + describe("Multi:assert()", function() + it("passes through nodes when non-empty", function() + local bufnr = h.make_buf({ "# A", "# B" }, "markdown") + local items = cursor.root():children():assert():exec(bufnr) + assert.are.same(2, #items) + end) + + it("errors with default message when empty", function() + local bufnr = h.make_buf({ "# Title" }, "markdown") + assert.has_error(function() + cursor.root():children({ types = { "nonexistent" } }):assert():exec(bufnr) + end) + end) + + it("errors with custom message when empty", function() + local bufnr = h.make_buf({ "# Title" }, "markdown") + local ok, err = pcall(function() + cursor + .root() + :children({ types = { "nonexistent" } }) + :assert("missing node") + :exec(bufnr) + end) + assert.are.same(false, ok) + assert.are.same(true, err:find("missing node") ~= nil) + end) + + it("errors when fn returns false for a node", function() + local bufnr = h.make_buf({ "# A", "# B" }, "markdown") + assert.has_error(function() + cursor + .root() + :children() + :assert("wrong type", function(_, node) + return node:type() == "atx_heading" + end) + :exec(bufnr) + end) + end) + + it("passes when fn returns true for all nodes", function() + local bufnr = h.make_buf({ "# A", "# B" }, "markdown") + local items = cursor + .root() + :children() + :assert("must be section", function(_, node) + return node:type() == "section" + end) + :exec(bufnr) + assert.are.same(2, #items) + end) + + it("errors when empty even if fn is provided", function() + local bufnr = h.make_buf({ "# Title" }, "markdown") + assert.has_error(function() + cursor + .root() + :children({ types = { "nonexistent" } }) + :assert("boom", function() + return true + end) + :exec(bufnr) + end) + end) + + it("is chainable", function() + local bufnr = h.make_buf({ "# A", "# B" }, "markdown") + local items = cursor.root():children():assert():first():exec(bufnr) + assert.are.same(1, #items) + end) + end) + + describe("Single:assert()", function() + it("passes through node when present", function() + local bufnr = h.make_buf({ "# Title" }, "markdown") + local items = cursor.root():children():first():assert():exec(bufnr) + assert.are.same(1, #items) + end) + + it("errors with default message when empty", function() + local bufnr = h.make_buf({ "# Title" }, "markdown") + assert.has_error(function() + cursor + .root() + :children({ types = { "nonexistent" } }) + :first() + :assert() + :exec(bufnr) + end) + end) + + it("errors with custom message when empty", function() + local bufnr = h.make_buf({ "# Title" }, "markdown") + local ok, err = pcall(function() + cursor + .root() + :children({ types = { "nonexistent" } }) + :first() + :assert("not found") + :exec(bufnr) + end) + assert.are.same(false, ok) + assert.are.same(true, err:find("not found") ~= nil) + end) + + it("errors when fn returns false", function() + local bufnr = h.make_buf({ "# Title" }, "markdown") + assert.has_error(function() + cursor + .root() + :children() + :first() + :assert("wrong type", function(_, node) + return node:type() == "atx_heading" + end) + :exec(bufnr) + end) + end) + + it("passes when fn returns true", function() + local bufnr = h.make_buf({ "# Title" }, "markdown") + local items = cursor + .root() + :children() + :first() + :assert("must be section", function(_, node) + return node:type() == "section" + end) + :exec(bufnr) + assert.are.same(1, #items) + end) + + it("is chainable", function() + local bufnr = h.make_buf({ "# Title", "content" }, "markdown") + local items = cursor.root():children():first():assert():children():exec(bufnr) + assert.are.same(true, #items > 0) + end) + end) + end) + + describe("io integration", function() + it("io.insert: appends after matched section", function() + local bufnr = h.make_buf({ "# Context", "content", "# Other", "more" }, "markdown") + + local function is_context(b, n) + for i = 0, n:named_child_count() - 1 do + local child = n:named_child(i) + if child:type() == "atx_heading" then + local raw = vim.treesitter.get_node_text(child, b) + if vim.trim(raw:gsub("^#+%s*", "")) == "Context" then + return true + end + end + end + return false + end + + io.insert(bufnr, { "appended" }, { + cursor = cursor.root():children():filter(is_context):first(), + }) + + local actual = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local appended_row, other_row + for i, l in ipairs(actual) do + if l == "appended" then + appended_row = i - 1 + end + if l == "# Other" then + other_row = i - 1 + end + end + assert.are.same(true, appended_row ~= nil) + assert.are.same(true, appended_row <= other_row) + end) + + it("io.insert: inserts at all matched sections", function() + local bufnr = h.make_buf({ "# A", "content a", "# B", "content b" }, "markdown") + + io.insert(bufnr, { "---" }, { + cursor = cursor.root():children(), + }) + + local actual = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local count = 0 + for _, l in ipairs(actual) do + if l == "---" then + count = count + 1 + end + end + assert.are.same(2, count) + end) + + it("io.select: returns string[][] per matched node", function() + local bufnr = h.make_buf({ "# A", "content a", "# B", "content b" }, "markdown") + local results = io.select(bufnr, { + cursor = cursor.root():children(), + }) + assert.are.same(true, results ~= nil and #results == 2) + end) + + it("io.select: returns content of single matched node", function() + local bufnr = h.make_buf({ "# A", "content" }, "markdown") + local results = io.select(bufnr, { + cursor = cursor.root():children({ types = { "section" } }):first(), + }) + assert.are.same(true, results ~= nil and #results == 1) + assert.are.same(true, results[1][1]:find("A") ~= nil) + end) + + it("io.select_text: returns string[] per matched node", function() + local bufnr = h.make_buf({ "# A", "content a", "# B", "content b" }, "markdown") + local results = io.select_text(bufnr, { + cursor = cursor.root():children(), + }) + assert.are.same(true, results ~= nil and #results == 2) + for _, text in ipairs(results) do + assert.are.same("string", type(text)) + end + end) + + it("io.delete: deletes all matched sections", function() + local bufnr = h.make_buf({ "# A", "content a", "# B", "content b" }, "markdown") + io.delete(bufnr, { + cursor = cursor.root():children(), + }) + local actual = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local has_heading = false + for _, l in ipairs(actual) do + if l:find("^#") then + has_heading = true + end + end + assert.are.same(false, has_heading) + end) + + it("io.delete: deletes only first matched section when using first()", function() + local bufnr = h.make_buf({ "# A", "content", "# B" }, "markdown") + io.delete(bufnr, { + cursor = cursor.root():children({ types = { "section" } }):first(), + }) + local actual = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local has_b = false + for _, l in ipairs(actual) do + if l == "# B" then + has_b = true + end + end + assert.are.same(true, has_b) + end) + + it("io.replace: replaces matched node content", function() + local bufnr = h.make_buf({ "# Title", "old content" }, "markdown") + io.replace(bufnr, { "replaced" }, { + cursor = cursor.root():children({ types = { "section" } }):first(), + }) + local actual = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local has_replaced = false + for _, l in ipairs(actual) do + if l == "replaced" then + has_replaced = true + end + end + assert.are.same(true, has_replaced) + end) + + it("io.replace: replaces all matched sections", function() + local bufnr = h.make_buf({ "# A", "content a", "# B", "content b" }, "markdown") + io.replace(bufnr, { "replaced" }, { + cursor = cursor.root():children(), + }) + local actual = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local count = 0 + for _, l in ipairs(actual) do + if l == "replaced" then + count = count + 1 + end + end + assert.are.same(2, count) + end) + end) +end) diff --git a/tests/bufsitter/io_spec.lua b/tests/bufsitter/io_spec.lua new file mode 100644 index 0000000..2af4097 --- /dev/null +++ b/tests/bufsitter/io_spec.lua @@ -0,0 +1,300 @@ +local io = require("bufsitter.io") +local cursor = require("bufsitter.cursor") +local config = require("bufsitter") +local h = require("tests.helpers") + +describe("io", function() + after_each(h.clean_bufs) + + describe("insert", function() + it("should insert contents with prepend=false", function() + local contents = { "line1", "line2", "line3" } + local expected = { "line1", "line2", "added1", "added2", "line3" } + + local bufnr = h.make_buf(contents) + io.insert( + bufnr, + { "added1", "added2" }, + { start_row = 1, end_row = 2, prepend = false } + ) + + local actual = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.same(expected, actual) + end) + + it("should insert contents with prepend=true", function() + local contents = { "line1", "line2", "line3" } + local expected = { "line1", "added1", "added2", "line2", "line3" } + + local bufnr = h.make_buf(contents) + io.insert( + bufnr, + { "added1", "added2" }, + { start_row = 1, end_row = 2, prepend = true } + ) + + local actual = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.same(expected, actual) + end) + + it("should do nothing when cursor returns empty", function() + local contents = { "line1" } + local expected = { "line1" } + + local bufnr = h.make_buf(contents) + io.insert(bufnr, { "added" }, { + cursor = cursor.root():children(), + }) + + local actual = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.same(expected, actual) + end) + + it("inline=true prepend attaches at character position without newline", function() + local contents = { "line1", "line2", "line3" } + local expected = { "line1", "[prefix]line2", "line3" } + + local bufnr = h.make_buf(contents) + io.insert(bufnr, { "[prefix]" }, { + start_row = 1, + end_row = 2, + prepend = true, + inline = true, + }) + + local actual = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.same(expected, actual) + end) + + it("inline=false prepend inserts as new line above", function() + local contents = { "line1", "line2", "line3" } + local expected = { "line1", "added", "line2", "line3" } + + local bufnr = h.make_buf(contents) + io.insert(bufnr, { "added" }, { + start_row = 1, + end_row = 2, + prepend = true, + inline = false, + }) + + local actual = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.same(expected, actual) + end) + + it("inline=true append attaches at character position without newline", function() + local contents = { "line1", "line2", "line3" } + local expected = { "line1", "line2[suffix]", "line3" } + + local bufnr = h.make_buf(contents) + io.insert(bufnr, { "[suffix]" }, { + start_row = 1, + end_row = 1, + end_col = #"line2", + prepend = false, + inline = true, + }) + + local actual = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.same(expected, actual) + end) + end) + + describe("select", function() + it("should select contents in range", function() + local contents = { "line1", "line2", "line3" } + local expected = { { "line2" } } + + local bufnr = h.make_buf(contents) + local actual = io.select(bufnr, { start_row = 1, end_row = 2 }) + + assert.are.same(expected, actual) + end) + + it("should select with hook", function() + local contents = { "line1", "line2", "line3" } + local expected = { { "hooked" } } + + local bufnr = h.make_buf(contents) + local actual = io.select(bufnr, { + start_row = 1, + end_row = 2, + hook = function() + return { "hooked" } + end, + }) + + assert.are.same(expected, actual) + end) + + it("should return nil when cursor returns empty", function() + local contents = { "line1", "line2", "line3" } + local expected = nil + + local bufnr = h.make_buf(contents) + local actual = io.select(bufnr, { + cursor = cursor.root():children(), + }) + + assert.are.same(expected, actual) + end) + end) + + describe("select_text", function() + it("should return joined strings per node", function() + local contents = { "line1", "line2", "line3" } + local expected = { "line2" } + + local bufnr = h.make_buf(contents) + local actual = io.select_text(bufnr, { start_row = 1, end_row = 2 }) + + assert.are.same(expected, actual) + end) + + it("should return nil when cursor returns empty", function() + local contents = { "line1" } + + local bufnr = h.make_buf(contents) + local actual = io.select_text(bufnr, { + cursor = cursor.root():children(), + }) + + assert.are.same(nil, actual) + end) + end) + + describe("delete", function() + it("should delete single line", function() + local contents = { "leave0", "delete1", "leave2", "leave3" } + local expected = { "leave0", "leave2", "leave3" } + + local bufnr = h.make_buf(contents) + io.delete(bufnr, { start_row = 1, end_row = 2 }) + + local actual = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.same(expected, actual) + end) + + it("should delete multiple lines", function() + local contents = { "leave0", "delete1", "delete2", "delete3", "leave4", "leave5" } + local expected = { "leave0", "leave4", "leave5" } + + local bufnr = h.make_buf(contents) + io.delete(bufnr, { start_row = 1, end_row = 4 }) + + local actual = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.same(expected, actual) + end) + + it("should do nothing when cursor returns empty", function() + local contents = { "line1", "line2" } + local expected = { "line1", "line2" } + + local bufnr = h.make_buf(contents) + io.delete(bufnr, { + cursor = cursor.root():children(), + }) + + local actual = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.same(expected, actual) + end) + end) + + describe("on_error", function() + after_each(function() + config.setup({}) + end) + + it("per-call on_error catches assert error from cursor", function() + local bufnr = h.make_buf({ "# Title" }, "markdown") + local captured + io.select(bufnr, { + cursor = cursor.root():children({ types = { "nonexistent" } }):assert("boom"), + on_error = function(err) + captured = err + end, + }) + assert.are.same(true, captured ~= nil) + assert.are.same(true, captured:find("boom") ~= nil) + end) + + it("per-call on_error: no error when cursor succeeds", function() + local bufnr = h.make_buf({ "# Title" }, "markdown") + local captured + local results = io.select(bufnr, { + cursor = cursor.root():children():first():assert(), + on_error = function(err) + captured = err + end, + }) + assert.are.same(nil, captured) + assert.are.same(true, results ~= nil) + end) + + it("global config.io.on_error catches assert error", function() + local bufnr = h.make_buf({ "# Title" }, "markdown") + local captured + config.setup({ + io = { + on_error = function(err) + captured = err + end, + }, + }) + io.delete(bufnr, { + cursor = cursor + .root() + :children({ types = { "nonexistent" } }) + :assert("global boom"), + }) + assert.are.same(true, captured ~= nil) + assert.are.same(true, captured:find("global boom") ~= nil) + end) + + it("per-call on_error overrides global", function() + local bufnr = h.make_buf({ "# Title" }, "markdown") + local global_captured, local_captured + config.setup({ + io = { + on_error = function(err) + global_captured = err + end, + }, + }) + io.insert(bufnr, { "x" }, { + cursor = cursor.root():children({ types = { "nonexistent" } }):assert("local"), + on_error = function(err) + local_captured = err + end, + }) + assert.are.same(nil, global_captured) + assert.are.same(true, local_captured ~= nil) + assert.are.same(true, local_captured:find("local") ~= nil) + end) + + it("without on_error, assert error propagates as lua error", function() + local bufnr = h.make_buf({ "# Title" }, "markdown") + assert.has_error(function() + io.select(bufnr, { + cursor = cursor + .root() + :children({ types = { "nonexistent" } }) + :assert("raw error"), + }) + end) + end) + end) + + describe("clear", function() + it("should clear contents", function() + local contents = { "line1", "line2", "line3" } + local expected = { "" } + + local bufnr = h.make_buf(contents) + io.clear(bufnr) + + local actual = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.same(expected, actual) + end) + end) +end) diff --git a/tests/bufsitter/ref_spec.lua b/tests/bufsitter/ref_spec.lua new file mode 100644 index 0000000..506ca6a --- /dev/null +++ b/tests/bufsitter/ref_spec.lua @@ -0,0 +1,140 @@ +local ref = require("bufsitter.ref") + +describe("ref", function() + local bufnr + + before_each(function() + bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_set_current_buf(bufnr) + vim.fn.setpos("'<", { 0, 0, 0, 0 }) + vim.fn.setpos("'>", { 0, 0, 0, 0 }) + end) + + after_each(function() + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + -- helper: set buffer name and return the resolved name (handles macOS /var -> /private/var symlink) + local function set_buf_name(tmp) + vim.api.nvim_buf_set_name(bufnr, tmp) + return vim.api.nvim_buf_get_name(bufnr) + end + + describe("buffer_ref", function() + it("should return [No Name] when buffer has no name", function() + assert.are.same("[No Name]", ref.buffer()) + end) + + it("should return home-relative path by default", function() + local name = set_buf_name(vim.fn.tempname()) + local expected = vim.fn.fnamemodify(name, ":~") + + assert.are.same(expected, ref.buffer()) + end) + + it("should return home-relative path when expand=false", function() + local name = set_buf_name(vim.fn.tempname()) + local expected = vim.fn.fnamemodify(name, ":~") + + assert.are.same(expected, ref.buffer({ expand = false })) + end) + + it("should return absolute path when expand=true", function() + local name = set_buf_name(vim.fn.tempname()) + local expected = vim.fn.fnamemodify(name, ":p") + + assert.are.same(expected, ref.buffer({ expand = true })) + end) + end) + + describe("get", function() + local original_get_mode + + before_each(function() + original_get_mode = vim.api.nvim_get_mode + end) + + after_each(function() + vim.api.nvim_get_mode = original_get_mode + end) + + it("should call buffer() in normal mode", function() + vim.api.nvim_get_mode = function() + return { mode = "n" } + end + local name = set_buf_name(vim.fn.tempname()) + assert.are.same(vim.fn.fnamemodify(name, ":~"), ref.get()) + end) + + it("should call visual_selection() in v mode", function() + vim.api.nvim_get_mode = function() + return { mode = "v" } + end + local name = set_buf_name(vim.fn.tempname()) + local buf_name = vim.fn.fnamemodify(name, ":~") + vim.fn.setpos("'<", { 0, 3, 1, 0 }) + vim.fn.setpos("'>", { 0, 5, 1, 0 }) + assert.are.same(buf_name .. ":L3~L5", ref.get()) + end) + + it("should call visual_selection() in V mode", function() + vim.api.nvim_get_mode = function() + return { mode = "V" } + end + local name = set_buf_name(vim.fn.tempname()) + local buf_name = vim.fn.fnamemodify(name, ":~") + vim.fn.setpos("'<", { 0, 1, 1, 0 }) + vim.fn.setpos("'>", { 0, 1, 1, 0 }) + assert.are.same(buf_name .. ":L1", ref.get()) + end) + end) + + describe("visual_selection_ref", function() + it("should return just buf_name when no visual selection (unnamed)", function() + assert.are.same("[No Name]", ref.visual_selection()) + end) + + it("should return just buf_name when no visual selection (named)", function() + local name = set_buf_name(vim.fn.tempname()) + local expected = vim.fn.fnamemodify(name, ":~") + + assert.are.same(expected, ref.visual_selection()) + end) + + it("should return single line ref for same-line selection", function() + local name = set_buf_name(vim.fn.tempname()) + local buf_name = vim.fn.fnamemodify(name, ":~") + vim.fn.setpos("'<", { 0, 5, 1, 0 }) + vim.fn.setpos("'>", { 0, 5, 10, 0 }) + + assert.are.same(buf_name .. ":L5", ref.visual_selection()) + end) + + it("should return range ref for multi-line selection", function() + local name = set_buf_name(vim.fn.tempname()) + local buf_name = vim.fn.fnamemodify(name, ":~") + vim.fn.setpos("'<", { 0, 3, 1, 0 }) + vim.fn.setpos("'>", { 0, 7, 1, 0 }) + + assert.are.same(buf_name .. ":L3~L7", ref.visual_selection()) + end) + + it("should use home-relative path by default", function() + local name = set_buf_name(vim.fn.tempname()) + local buf_name = vim.fn.fnamemodify(name, ":~") + vim.fn.setpos("'<", { 0, 1, 1, 0 }) + vim.fn.setpos("'>", { 0, 2, 1, 0 }) + + assert.are.same(buf_name .. ":L1~L2", ref.visual_selection()) + end) + + it("should use absolute path when expand=true", function() + local name = set_buf_name(vim.fn.tempname()) + local buf_name = vim.fn.fnamemodify(name, ":p") + vim.fn.setpos("'<", { 0, 1, 1, 0 }) + vim.fn.setpos("'>", { 0, 3, 1, 0 }) + + assert.are.same(buf_name .. ":L1~L3", ref.visual_selection({ expand = true })) + end) + end) +end) diff --git a/tests/bufsitter/scratch_spec.lua b/tests/bufsitter/scratch_spec.lua new file mode 100644 index 0000000..808dbf1 --- /dev/null +++ b/tests/bufsitter/scratch_spec.lua @@ -0,0 +1,132 @@ +local Scratch = require("bufsitter.scratch") +local config = require("bufsitter") + +describe("scratch", function() + before_each(function() + config.setup() + end) + + describe("new", function() + it("should create a scratch buffer with default ft", function() + local s = Scratch.new() + assert.is_not_nil(s) + assert.is_true(vim.api.nvim_buf_is_valid(s:bufnr())) + assert.are.same("markdown", vim.bo[s:bufnr()].filetype) + s:delete() + end) + + it("should create a scratch buffer with custom ft", function() + local s = Scratch.new({ ft = "lua" }) + assert.are.same("lua", vim.bo[s:bufnr()].filetype) + s:delete() + end) + + it("should call on_attach with bufnr", function() + local called_with + local s = Scratch.new({ + on_attach = function(b) + called_with = b + end, + }) + assert.are.same(s:bufnr(), called_with) + s:delete() + end) + end) + + describe("show", function() + it("should open a window and return winid", function() + local s = Scratch.new() + local winid = s:show() + assert.is_not_nil(winid) + assert.is_true(vim.api.nvim_win_is_valid(winid)) + s:delete() + end) + end) + + describe("hide", function() + it("should close the window", function() + local s = Scratch.new() + local winid = s:show() + s:hide() + assert.is_false(vim.api.nvim_win_is_valid(winid)) + s:delete() + end) + end) + + describe("is_visible", function() + it("should return true when window is open", function() + local s = Scratch.new() + s:show() + assert.is_true(s:is_visible()) + s:delete() + end) + + it("should return false before show", function() + local s = Scratch.new() + assert.is_false(s:is_visible()) + s:delete() + end) + + it("should return false after hide", function() + local s = Scratch.new() + s:show() + s:hide() + assert.is_false(s:is_visible()) + s:delete() + end) + end) + + describe("toggle", function() + it("should hide when visible", function() + local s = Scratch.new() + local winid = s:show() + s:toggle() + assert.is_false(vim.api.nvim_win_is_valid(winid)) + s:delete() + end) + + it("should show when not visible", function() + local s = Scratch.new() + assert.is_false(s:is_visible()) + s:toggle() + assert.is_true(s:is_visible()) + s:delete() + end) + end) + + describe("delete", function() + it("should invalidate the buffer", function() + local s = Scratch.new() + local bufnr = s:bufnr() + s:delete() + assert.is_false(vim.api.nvim_buf_is_valid(bufnr)) + end) + + it("should close the window on delete", function() + local s = Scratch.new() + local winid = s:show() + s:delete() + assert.is_false(vim.api.nvim_win_is_valid(winid)) + end) + end) + + describe("multiple instances", function() + it("should have independent bufnrs", function() + local s1 = Scratch.new() + local s2 = Scratch.new() + assert.are_not.same(s1:bufnr(), s2:bufnr()) + s1:delete() + s2:delete() + end) + + it("should show and hide independently", function() + local s1 = Scratch.new() + local s2 = Scratch.new() + s1:show() + assert.is_true(s1:is_visible()) + assert.is_false(s2:is_visible()) + s1:delete() + s2:delete() + end) + end) +end) diff --git a/tests/filetypes/go/go_spec.lua b/tests/filetypes/go/go_spec.lua new file mode 100644 index 0000000..bb1307b --- /dev/null +++ b/tests/filetypes/go/go_spec.lua @@ -0,0 +1,416 @@ +local cursor = require("bufsitter.cursor") +local io = require("bufsitter.io") +local h = require("tests.helpers") + +local SAMPLE = "tests/filetypes/go/sample.go" + +describe("ft.go", function() + local bufnr + + before_each(function() + bufnr = h.buf_from_file(SAMPLE) + end) + + after_each(h.clean_bufs) + + local function func_named(name) + return function(b, node) + if node:type() ~= "function_declaration" then + return false + end + local n = node:field("name")[1] + return n and vim.treesitter.get_node_text(n, b) == name + end + end + + local function type_named(name) + return function(b, node) + if node:type() ~= "type_declaration" then + return false + end + for i = 0, node:named_child_count() - 1 do + local spec = node:named_child(i) + local n = spec:field("name")[1] + if n and vim.treesitter.get_node_text(n, b) == name then + return true + end + end + return false + end + end + + describe("function_declaration", function() + it("finds NewUserProfile by name", function() + local items = + cursor.root():children():filter(func_named("NewUserProfile")):first():exec(bufnr) + assert.are.same(true, #items > 0) + local actual = items[1]:type() + assert.are.same("function_declaration", actual) + end) + + it("gets name field of NewUserProfile", function() + local items = cursor + .root() + :children() + :filter(func_named("NewUserProfile")) + :first() + :children({ names = { "name" } }) + :exec(bufnr) + local actual = vim.treesitter.get_node_text(items[1], bufnr) + assert.are.same("NewUserProfile", actual) + end) + + it("gets parameters field", function() + local items = cursor + .root() + :children() + :filter(func_named("NewUserProfile")) + :first() + :children({ names = { "parameters" } }) + :exec(bufnr) + local actual = items[1]:type() + assert.are.same("parameter_list", actual) + end) + + it("gets parameter name 'name'", function() + local items = cursor + .root() + :children() + :filter(func_named("NewUserProfile")) + :first() + :children({ names = { "parameters" } }) + :first() + :children() + :first() + :children({ names = { "name" } }) + :exec(bufnr) + local actual = vim.treesitter.get_node_text(items[1], bufnr) + assert.are.same("name", actual) + end) + + it("gets result field (return type)", function() + local items = cursor + .root() + :children() + :filter(func_named("NewUserProfile")) + :first() + :children({ names = { "result" } }) + :exec(bufnr) + assert.are.same(true, #items > 0) + local actual = vim.treesitter.get_node_text(items[1], bufnr) + assert.are.same(true, actual:find("UserProfile") ~= nil) + end) + end) + + describe("type_declaration", function() + it("finds Metadata type by name", function() + local items = + cursor.root():children():filter(type_named("Metadata")):first():exec(bufnr) + assert.are.same(true, #items > 0) + end) + + it("finds UserProfile type by name", function() + local items = + cursor.root():children():filter(type_named("UserProfile")):first():exec(bufnr) + assert.are.same(true, #items > 0) + end) + + it("Metadata struct has 4 fields", function() + local items = cursor + .root() + :children() + :filter(type_named("Metadata")) + :first() + :children({ types = { "type_spec" } }) + :first() + :children({ names = { "type" } }) + :first() + :children({ types = { "field_declaration_list" } }) + :first() + :children() + :filter(function(b, n) + return n:type() == "field_declaration" + end) + :exec(bufnr) + local actual = #items + assert.are.same(4, actual) + end) + + it("finds ID field in Metadata by name", function() + local items = cursor + .root() + :children() + :filter(type_named("Metadata")) + :first() + :children({ types = { "type_spec" } }) + :first() + :children({ names = { "type" } }) + :first() + :children({ types = { "field_declaration_list" } }) + :first() + :children() + :filter(function(b, n) + if n:type() ~= "field_declaration" then + return false + end + local name = n:field("name")[1] + return name and vim.treesitter.get_node_text(name, b) == "ID" + end) + :first() + :children({ names = { "name" } }) + :exec(bufnr) + local actual = vim.treesitter.get_node_text(items[1], bufnr) + assert.are.same("ID", actual) + end) + end) + + describe("io integration", function() + it("io.select returns exact lines of Metadata type_declaration", function() + local results = io.select(bufnr, { + cursor = cursor.root():children():filter(type_named("Metadata")):first(), + }) + local expected = { + "type Metadata struct {", + ' ID int64 `json:"id" check:"required"`', + ' CreatedAt time.Time `json:"created_at"`', + ' IsActive bool `json:"is_active"`', + ' Version string `json:"version"`', + "}", + } + local actual = results[1] + assert.are.same(expected, actual) + end) + + it("io.select returns exact lines of NewUserProfile function_declaration", function() + local results = io.select(bufnr, { + cursor = cursor.root():children():filter(func_named("NewUserProfile")):first(), + }) + local expected = { + "func NewUserProfile(name string) *UserProfile {", + " return &UserProfile{", + " Username: &name,", + ' Roles: []string{"user", "guest"},', + " Settings: make(map[string]string),", + " }", + "}", + } + local actual = results[1] + assert.are.same(expected, actual) + end) + + it("io.delete removes Metadata type_declaration", function() + io.delete(bufnr, { + cursor = cursor.root():children():filter(type_named("Metadata")):first(), + }) + local expected = { + "package main", + "", + 'import "time"', + "", + "// Metadata demonstrates struct tags and basic types", + "", + "", + "// UserProfile includes nested structs, pointers, and collections", + "type UserProfile struct {", + " // 1. Embedded Struct (Named node: field_declaration)", + " Metadata", + "", + " // 2. Basic pointers and strings", + ' Username *string `json:"username"`', + ' Email string `json:"email"`', + " ", + " // 3. Collections: Slices and Maps", + ' Roles []string `json:"roles"`', + ' Settings map[string]string `json:"settings"`', + "", + " // 4. Nested Anonymous Struct", + " Address struct {", + ' City string `json:"city"`', + ' ZipCode int `json:"zip_code"`', + ' } `json:"address"`', + "", + " // 5. Interface member (for polymorphism tests)", + ' Permissions any `json:"permissions"`', + " ", + " // 6. Channel for concurrency tests", + ' StatusChan chan int `json:"-"`', + "}", + "", + "// NewUserProfile is a constructor example to test return types", + "func NewUserProfile(name string) *UserProfile {", + " return &UserProfile{", + " Username: &name,", + ' Roles: []string{"user", "guest"},', + " Settings: make(map[string]string),", + " }", + "}", + } + local actual = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.same(expected, actual) + end) + + it("io.delete removes NewUserProfile function_declaration", function() + io.delete(bufnr, { + cursor = cursor.root():children():filter(func_named("NewUserProfile")):first(), + }) + local expected = { + "package main", + "", + 'import "time"', + "", + "// Metadata demonstrates struct tags and basic types", + "type Metadata struct {", + ' ID int64 `json:"id" check:"required"`', + ' CreatedAt time.Time `json:"created_at"`', + ' IsActive bool `json:"is_active"`', + ' Version string `json:"version"`', + "}", + "", + "// UserProfile includes nested structs, pointers, and collections", + "type UserProfile struct {", + " // 1. Embedded Struct (Named node: field_declaration)", + " Metadata", + "", + " // 2. Basic pointers and strings", + ' Username *string `json:"username"`', + ' Email string `json:"email"`', + " ", + " // 3. Collections: Slices and Maps", + ' Roles []string `json:"roles"`', + ' Settings map[string]string `json:"settings"`', + "", + " // 4. Nested Anonymous Struct", + " Address struct {", + ' City string `json:"city"`', + ' ZipCode int `json:"zip_code"`', + ' } `json:"address"`', + "", + " // 5. Interface member (for polymorphism tests)", + ' Permissions any `json:"permissions"`', + " ", + " // 6. Channel for concurrency tests", + ' StatusChan chan int `json:"-"`', + "}", + "", + "// NewUserProfile is a constructor example to test return types", + "", + } + local actual = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.same(expected, actual) + end) + + it("io.insert prepends before NewUserProfile function_declaration", function() + io.insert(bufnr, { "// generated function" }, { + prepend = true, + cursor = cursor.root():children():filter(func_named("NewUserProfile")):first(), + }) + local expected = { + "package main", + "", + 'import "time"', + "", + "// Metadata demonstrates struct tags and basic types", + "type Metadata struct {", + ' ID int64 `json:"id" check:"required"`', + ' CreatedAt time.Time `json:"created_at"`', + ' IsActive bool `json:"is_active"`', + ' Version string `json:"version"`', + "}", + "", + "// UserProfile includes nested structs, pointers, and collections", + "type UserProfile struct {", + " // 1. Embedded Struct (Named node: field_declaration)", + " Metadata", + "", + " // 2. Basic pointers and strings", + ' Username *string `json:"username"`', + ' Email string `json:"email"`', + " ", + " // 3. Collections: Slices and Maps", + ' Roles []string `json:"roles"`', + ' Settings map[string]string `json:"settings"`', + "", + " // 4. Nested Anonymous Struct", + " Address struct {", + ' City string `json:"city"`', + ' ZipCode int `json:"zip_code"`', + ' } `json:"address"`', + "", + " // 5. Interface member (for polymorphism tests)", + ' Permissions any `json:"permissions"`', + " ", + " // 6. Channel for concurrency tests", + ' StatusChan chan int `json:"-"`', + "}", + "", + "// NewUserProfile is a constructor example to test return types", + "// generated function", + "func NewUserProfile(name string) *UserProfile {", + " return &UserProfile{", + " Username: &name,", + ' Roles: []string{"user", "guest"},', + " Settings: make(map[string]string),", + " }", + "}", + } + local actual = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.same(expected, actual) + end) + + it("io.replace swaps NewUserProfile implementation", function() + io.replace(bufnr, { + "func NewUserProfile() *UserProfile {", + " return nil", + "}", + }, { + cursor = cursor.root():children():filter(func_named("NewUserProfile")):first(), + }) + local expected = { + "package main", + "", + 'import "time"', + "", + "// Metadata demonstrates struct tags and basic types", + "type Metadata struct {", + ' ID int64 `json:"id" check:"required"`', + ' CreatedAt time.Time `json:"created_at"`', + ' IsActive bool `json:"is_active"`', + ' Version string `json:"version"`', + "}", + "", + "// UserProfile includes nested structs, pointers, and collections", + "type UserProfile struct {", + " // 1. Embedded Struct (Named node: field_declaration)", + " Metadata", + "", + " // 2. Basic pointers and strings", + ' Username *string `json:"username"`', + ' Email string `json:"email"`', + " ", + " // 3. Collections: Slices and Maps", + ' Roles []string `json:"roles"`', + ' Settings map[string]string `json:"settings"`', + "", + " // 4. Nested Anonymous Struct", + " Address struct {", + ' City string `json:"city"`', + ' ZipCode int `json:"zip_code"`', + ' } `json:"address"`', + "", + " // 5. Interface member (for polymorphism tests)", + ' Permissions any `json:"permissions"`', + " ", + " // 6. Channel for concurrency tests", + ' StatusChan chan int `json:"-"`', + "}", + "", + "// NewUserProfile is a constructor example to test return types", + "func NewUserProfile() *UserProfile {", + " return nil", + "}", + } + local actual = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.same(expected, actual) + end) + end) +end) diff --git a/tests/filetypes/go/sample.go b/tests/filetypes/go/sample.go new file mode 100644 index 0000000..4c1bd07 --- /dev/null +++ b/tests/filetypes/go/sample.go @@ -0,0 +1,46 @@ +package main + +import "time" + +// Metadata demonstrates struct tags and basic types +type Metadata struct { + ID int64 `json:"id" check:"required"` + CreatedAt time.Time `json:"created_at"` + IsActive bool `json:"is_active"` + Version string `json:"version"` +} + +// UserProfile includes nested structs, pointers, and collections +type UserProfile struct { + // 1. Embedded Struct (Named node: field_declaration) + Metadata + + // 2. Basic pointers and strings + Username *string `json:"username"` + Email string `json:"email"` + + // 3. Collections: Slices and Maps + Roles []string `json:"roles"` + Settings map[string]string `json:"settings"` + + // 4. Nested Anonymous Struct + Address struct { + City string `json:"city"` + ZipCode int `json:"zip_code"` + } `json:"address"` + + // 5. Interface member (for polymorphism tests) + Permissions any `json:"permissions"` + + // 6. Channel for concurrency tests + StatusChan chan int `json:"-"` +} + +// NewUserProfile is a constructor example to test return types +func NewUserProfile(name string) *UserProfile { + return &UserProfile{ + Username: &name, + Roles: []string{"user", "guest"}, + Settings: make(map[string]string), + } +} diff --git a/tests/filetypes/go/sample.go.tree b/tests/filetypes/go/sample.go.tree new file mode 100644 index 0000000..ec3b3a0 --- /dev/null +++ b/tests/filetypes/go/sample.go.tree @@ -0,0 +1,146 @@ +(source_file ; [0, 0] - [46, 0] + (package_clause ; [0, 0] - [0, 12] + (package_identifier)) ; [0, 8] - [0, 12] + (import_declaration ; [2, 0] - [2, 13] + (import_spec ; [2, 7] - [2, 13] + path: (interpreted_string_literal ; [2, 7] - [2, 13] + (interpreted_string_literal_content)))) ; [2, 8] - [2, 12] + (comment) ; [4, 0] - [4, 52] + (type_declaration ; [5, 0] - [10, 1] + (type_spec ; [5, 5] - [10, 1] + name: (type_identifier) ; [5, 5] - [5, 13] + type: (struct_type ; [5, 14] - [10, 1] + (field_declaration_list ; [5, 21] - [10, 1] + (field_declaration ; [6, 4] - [6, 52] + name: (field_identifier) ; [6, 4] - [6, 6] + type: (type_identifier) ; [6, 14] - [6, 19] + tag: (raw_string_literal ; [6, 24] - [6, 52] + (raw_string_literal_content))) ; [6, 25] - [6, 51] + (field_declaration ; [7, 4] - [7, 43] + name: (field_identifier) ; [7, 4] - [7, 13] + type: (qualified_type ; [7, 14] - [7, 23] + package: (package_identifier) ; [7, 14] - [7, 18] + name: (type_identifier)) ; [7, 19] - [7, 23] + tag: (raw_string_literal ; [7, 24] - [7, 43] + (raw_string_literal_content))) ; [7, 25] - [7, 42] + (field_declaration ; [8, 4] - [8, 42] + name: (field_identifier) ; [8, 4] - [8, 12] + type: (type_identifier) ; [8, 14] - [8, 18] + tag: (raw_string_literal ; [8, 24] - [8, 42] + (raw_string_literal_content))) ; [8, 25] - [8, 41] + (field_declaration ; [9, 4] - [9, 40] + name: (field_identifier) ; [9, 4] - [9, 11] + type: (type_identifier) ; [9, 14] - [9, 20] + tag: (raw_string_literal ; [9, 24] - [9, 40] + (raw_string_literal_content))))))) ; [9, 25] - [9, 39] + (comment) ; [12, 0] - [12, 65] + (type_declaration ; [13, 0] - [36, 1] + (type_spec ; [13, 5] - [36, 1] + name: (type_identifier) ; [13, 5] - [13, 16] + type: (struct_type ; [13, 17] - [36, 1] + (field_declaration_list ; [13, 24] - [36, 1] + (comment) ; [14, 4] - [14, 57] + (field_declaration ; [15, 4] - [15, 12] + type: (type_identifier)) ; [15, 4] - [15, 12] + (comment) ; [17, 4] - [17, 36] + (field_declaration ; [18, 4] - [18, 38] + name: (field_identifier) ; [18, 4] - [18, 12] + type: (pointer_type ; [18, 13] - [18, 20] + (type_identifier)) ; [18, 14] - [18, 20] + tag: (raw_string_literal ; [18, 21] - [18, 38] + (raw_string_literal_content))) ; [18, 22] - [18, 37] + (field_declaration ; [19, 4] - [19, 35] + name: (field_identifier) ; [19, 4] - [19, 9] + type: (type_identifier) ; [19, 13] - [19, 19] + tag: (raw_string_literal ; [19, 21] - [19, 35] + (raw_string_literal_content))) ; [19, 22] - [19, 34] + (comment) ; [21, 4] - [21, 38] + (field_declaration ; [22, 4] - [22, 45] + name: (field_identifier) ; [22, 4] - [22, 9] + type: (slice_type ; [22, 13] - [22, 21] + element: (type_identifier)) ; [22, 15] - [22, 21] + tag: (raw_string_literal ; [22, 31] - [22, 45] + (raw_string_literal_content))) ; [22, 32] - [22, 44] + (field_declaration ; [23, 4] - [23, 48] + name: (field_identifier) ; [23, 4] - [23, 12] + type: (map_type ; [23, 13] - [23, 30] + key: (type_identifier) ; [23, 17] - [23, 23] + value: (type_identifier)) ; [23, 24] - [23, 30] + tag: (raw_string_literal ; [23, 31] - [23, 48] + (raw_string_literal_content))) ; [23, 32] - [23, 47] + (comment) ; [25, 4] - [25, 33] + (field_declaration ; [26, 4] - [29, 22] + name: (field_identifier) ; [26, 4] - [26, 11] + type: (struct_type ; [26, 12] - [29, 5] + (field_declaration_list ; [26, 19] - [29, 5] + (field_declaration ; [27, 8] - [27, 36] + name: (field_identifier) ; [27, 8] - [27, 12] + type: (type_identifier) ; [27, 16] - [27, 22] + tag: (raw_string_literal ; [27, 23] - [27, 36] + (raw_string_literal_content))) ; [27, 24] - [27, 35] + (field_declaration ; [28, 8] - [28, 40] + name: (field_identifier) ; [28, 8] - [28, 15] + type: (type_identifier) ; [28, 16] - [28, 19] + tag: (raw_string_literal ; [28, 23] - [28, 40] + (raw_string_literal_content))))) ; [28, 24] - [28, 39] + tag: (raw_string_literal ; [29, 6] - [29, 22] + (raw_string_literal_content))) ; [29, 7] - [29, 21] + (comment) ; [31, 4] - [31, 51] + (field_declaration ; [32, 4] - [32, 40] + name: (field_identifier) ; [32, 4] - [32, 15] + type: (type_identifier) ; [32, 16] - [32, 19] + tag: (raw_string_literal ; [32, 20] - [32, 40] + (raw_string_literal_content))) ; [32, 21] - [32, 39] + (comment) ; [34, 4] - [34, 39] + (field_declaration ; [35, 4] - [35, 34] + name: (field_identifier) ; [35, 4] - [35, 14] + type: (channel_type ; [35, 15] - [35, 23] + value: (type_identifier)) ; [35, 20] - [35, 23] + tag: (raw_string_literal ; [35, 24] - [35, 34] + (raw_string_literal_content))))))) ; [35, 25] - [35, 33] + (comment) ; [38, 0] - [38, 63] + (function_declaration ; [39, 0] - [45, 1] + name: (identifier) ; [39, 5] - [39, 19] + parameters: (parameter_list ; [39, 19] - [39, 32] + (parameter_declaration ; [39, 20] - [39, 31] + name: (identifier) ; [39, 20] - [39, 24] + type: (type_identifier))) ; [39, 25] - [39, 31] + result: (pointer_type ; [39, 33] - [39, 45] + (type_identifier)) ; [39, 34] - [39, 45] + body: (block ; [39, 46] - [45, 1] + (return_statement ; [40, 4] - [44, 5] + (expression_list ; [40, 11] - [44, 5] + (unary_expression ; [40, 11] - [44, 5] + operand: (composite_literal ; [40, 12] - [44, 5] + type: (type_identifier) ; [40, 12] - [40, 23] + body: (literal_value ; [40, 23] - [44, 5] + (keyed_element ; [41, 8] - [41, 23] + key: (literal_element ; [41, 8] - [41, 16] + (identifier)) ; [41, 8] - [41, 16] + value: (literal_element ; [41, 18] - [41, 23] + (unary_expression ; [41, 18] - [41, 23] + operand: (identifier)))) ; [41, 19] - [41, 23] + (keyed_element ; [42, 8] - [42, 43] + key: (literal_element ; [42, 8] - [42, 13] + (identifier)) ; [42, 8] - [42, 13] + value: (literal_element ; [42, 18] - [42, 43] + (composite_literal ; [42, 18] - [42, 43] + type: (slice_type ; [42, 18] - [42, 26] + element: (type_identifier)) ; [42, 20] - [42, 26] + body: (literal_value ; [42, 26] - [42, 43] + (literal_element ; [42, 27] - [42, 33] + (interpreted_string_literal ; [42, 27] - [42, 33] + (interpreted_string_literal_content))) ; [42, 28] - [42, 32] + (literal_element ; [42, 35] - [42, 42] + (interpreted_string_literal ; [42, 35] - [42, 42] + (interpreted_string_literal_content))))))) ; [42, 36] - [42, 41] + (keyed_element ; [43, 8] - [43, 41] + key: (literal_element ; [43, 8] - [43, 16] + (identifier)) ; [43, 8] - [43, 16] + value: (literal_element ; [43, 18] - [43, 41] + (call_expression ; [43, 18] - [43, 41] + function: (identifier) ; [43, 18] - [43, 22] + arguments: (argument_list ; [43, 22] - [43, 41] + (map_type ; [43, 23] - [43, 40] + key: (type_identifier) ; [43, 27] - [43, 33] + value: (type_identifier)))))))))))))) ; [43, 34] - [43, 40] diff --git a/tests/filetypes/markdown/markdown_spec.lua b/tests/filetypes/markdown/markdown_spec.lua new file mode 100644 index 0000000..4ea5067 --- /dev/null +++ b/tests/filetypes/markdown/markdown_spec.lua @@ -0,0 +1,266 @@ +local cursor = require("bufsitter.cursor") +local io = require("bufsitter.io") +local h = require("tests.helpers") + +local SAMPLE = "tests/filetypes/markdown/sample.md" + +describe("ft.markdown", function() + local bufnr + + before_each(function() + bufnr = h.buf_from_file(SAMPLE) + end) + + after_each(h.clean_bufs) + + local function heading_text(text) + return function(b, node) + for i = 0, node:named_child_count() - 1 do + local child = node:named_child(i) + if child:type() == "inline" then + return vim.trim(vim.treesitter.get_node_text(child, b)) == text + end + end + return false + end + end + + local function section_with(match) + return function(b, node) + for i = 0, node:named_child_count() - 1 do + local child = node:named_child(i) + if child:type() == "atx_heading" and match(b, child) then + return true + end + end + return false + end + end + + local function heading_level(level) + return function(b, node) + for i = 0, node:child_count() - 1 do + if node:child(i):type() == ("atx_h%d_marker"):format(level) then + return true + end + end + return false + end + end + + local function fenced_lang(lang) + return function(b, node) + for i = 0, node:named_child_count() - 1 do + local child = node:named_child(i) + if child:type() == "info_string" then + return vim.trim(vim.treesitter.get_node_text(child, b)) == lang + end + end + return false + end + end + + describe("section navigation", function() + it("document has 3 top-level sections", function() + local items = cursor.root():children():exec(bufnr) + assert.are.same(3, #items) + end) + + it("finds Installation section by heading text", function() + local items = cursor + .root() + :children() + :filter(section_with(heading_text("Installation"))) + :first() + :exec(bufnr) + assert.are.same(true, #items > 0) + local actual = items[1]:type() + assert.are.same("section", actual) + end) + + it("finds Configuration section (last)", function() + local items = cursor.root():children():last():exec(bufnr) + assert.are.same(true, #items > 0) + local heading = nil + for i = 0, items[1]:named_child_count() - 1 do + if items[1]:named_child(i):type() == "atx_heading" then + heading = items[1]:named_child(i) + break + end + end + assert.are.same(true, heading ~= nil) + local actual = vim.treesitter.get_node_text(heading, bufnr) + assert.are.same(true, actual:find("Configuration") ~= nil) + end) + + it("navigates to next section from Installation", function() + local items = cursor + .root() + :children() + :filter(section_with(heading_text("Installation"))) + :first() + :next_siblings({ types = { "section" } }) + :first() + :exec(bufnr) + assert.are.same(true, #items > 0) + local actual = vim.treesitter.get_node_text(items[1]:named_child(0), bufnr) + assert.are.same(true, actual:find("Usage") ~= nil) + end) + + it("navigates to prev section from Configuration", function() + local items = cursor + .root() + :children() + :last() + :prev_siblings({ types = { "section" } }) + :first() + :exec(bufnr) + assert.are.same(true, #items > 0) + local actual = vim.treesitter.get_node_text(items[1]:named_child(0), bufnr) + assert.are.same(true, actual:find("Usage") ~= nil) + end) + end) + + describe("heading navigation", function() + it("finds atx_heading inside Installation section", function() + local items = cursor + .root() + :children() + :filter(section_with(heading_text("Installation"))) + :first() + :children({ types = { "atx_heading" } }) + :first() + :exec(bufnr) + assert.are.same(true, #items > 0) + local actual = items[1]:type() + assert.are.same("atx_heading", actual) + end) + + it("finds h2 heading by level inside Installation", function() + local items = cursor + .root() + :children() + :filter(section_with(heading_text("Installation"))) + :first() + :children({ types = { "section" } }) + :first() + :children() + :filter(heading_level(2)) + :first() + :exec(bufnr) + assert.are.same(true, #items > 0) + local actual = items[1]:type() + assert.are.same("atx_heading", actual) + end) + end) + + describe("fenced_code_block navigation", function() + it("finds lua code block in Installation section", function() + local items = cursor + .root() + :children() + :filter(section_with(heading_text("Installation"))) + :first() + :children() + :filter(fenced_lang("lua")) + :first() + :exec(bufnr) + assert.are.same(true, #items > 0) + local actual = items[1]:type() + assert.are.same("fenced_code_block", actual) + end) + + it("finds xml code block in Configuration section", function() + local items = cursor + .root() + :children() + :filter(section_with(heading_text("Configuration"))) + :first() + :children() + :filter(fenced_lang("xml")) + :first() + :exec(bufnr) + assert.are.same(true, #items > 0) + end) + + it("returns empty for nonexistent language", function() + local items = cursor + .root() + :children() + :filter(section_with(heading_text("Installation"))) + :first() + :children() + :filter(fenced_lang("python")) + :first() + :exec(bufnr) + assert.are.same(0, #items) + end) + end) + + describe("io integration", function() + it("io.select returns content of Installation section", function() + local results = io.select(bufnr, { + cursor = cursor + .root() + :children() + :filter(section_with(heading_text("Installation"))) + :first(), + }) + assert.are.same(true, results ~= nil and #results > 0) + assert.are.same(true, results[1][1]:find("Installation") ~= nil) + end) + + it("io.insert appends after Installation without touching Usage", function() + local before_usage_row + local lines_before = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + for i, l in ipairs(lines_before) do + if l == "# Usage" then + before_usage_row = i - 1 + end + end + + io.insert(bufnr, { "" }, { + cursor = cursor + .root() + :children() + :filter(section_with(heading_text("Installation"))) + :first(), + }) + + local lines_after = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local injected_row, usage_row + for i, l in ipairs(lines_after) do + if l == "" then + injected_row = i - 1 + end + if l == "# Usage" then + usage_row = i - 1 + end + end + assert.are.same(true, injected_row ~= nil) + assert.are.same(true, injected_row < usage_row) + end) + + it("io.delete removes Installation section, Usage remains", function() + io.delete(bufnr, { + cursor = cursor + .root() + :children() + :filter(section_with(heading_text("Installation"))) + :first(), + }) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local has_installation, has_usage = false, false + for _, l in ipairs(lines) do + if l == "# Installation" then + has_installation = true + end + if l == "# Usage" then + has_usage = true + end + end + assert.are.same(false, has_installation) + assert.are.same(true, has_usage) + end) + end) +end) diff --git a/tests/filetypes/markdown/sample.md b/tests/filetypes/markdown/sample.md new file mode 100644 index 0000000..b3e79d5 --- /dev/null +++ b/tests/filetypes/markdown/sample.md @@ -0,0 +1,35 @@ +# Installation + +Install the plugin using lazy.nvim. + +```lua +require("bufsitter").setup() +``` + +## Advanced Setup + +More configuration options. + +```lua +require("bufsitter").setup({ + option = true, +}) +``` + +# Usage + +Basic usage examples. + +## Commands + +Available commands and keymaps. + +# Configuration + +Plugin configuration reference. + +```xml + + +``` diff --git a/tests/filetypes/markdown/sample.md.tree b/tests/filetypes/markdown/sample.md.tree new file mode 100644 index 0000000..c983fc9 --- /dev/null +++ b/tests/filetypes/markdown/sample.md.tree @@ -0,0 +1,108 @@ +(document ; [0, 0] - [35, 0] + (section ; [0, 0] - [18, 0] + (atx_heading ; [0, 0] - [1, 0] + (atx_h1_marker) ; [0, 0] - [0, 1] + heading_content: (inline ; [0, 2] - [0, 14] + (inline))) ; [0, 2] - [0, 14] + (paragraph ; [2, 0] - [3, 0] + (inline ; [2, 0] - [2, 35] + (inline))) ; [2, 0] - [2, 35] + (fenced_code_block ; [4, 0] - [7, 0] + (fenced_code_block_delimiter) ; [4, 0] - [4, 3] + (info_string ; [4, 3] - [4, 6] + (language)) ; [4, 3] - [4, 6] + (block_continuation) ; [5, 0] - [5, 0] + (code_fence_content ; [5, 0] - [6, 0] + (chunk ; [5, 0] - [6, 0] + (function_call ; [5, 0] - [5, 28] + name: (dot_index_expression ; [5, 0] - [5, 26] + table: (function_call ; [5, 0] - [5, 20] + name: (identifier) ; [5, 0] - [5, 7] + arguments: (arguments ; [5, 7] - [5, 20] + (string ; [5, 8] - [5, 19] + content: (string_content)))) ; [5, 9] - [5, 18] + field: (identifier)) ; [5, 21] - [5, 26] + arguments: (arguments))) ; [5, 26] - [5, 28] + (block_continuation)) ; [6, 0] - [6, 0] + (fenced_code_block_delimiter)) ; [6, 0] - [6, 3] + (section ; [8, 0] - [18, 0] + (atx_heading ; [8, 0] - [9, 0] + (atx_h2_marker) ; [8, 0] - [8, 2] + heading_content: (inline ; [8, 3] - [8, 17] + (inline))) ; [8, 3] - [8, 17] + (paragraph ; [10, 0] - [11, 0] + (inline ; [10, 0] - [10, 27] + (inline))) ; [10, 0] - [10, 27] + (fenced_code_block ; [12, 0] - [17, 0] + (fenced_code_block_delimiter) ; [12, 0] - [12, 3] + (info_string ; [12, 3] - [12, 6] + (language)) ; [12, 3] - [12, 6] + (block_continuation) ; [13, 0] - [13, 0] + (code_fence_content ; [13, 0] - [16, 0] + (chunk ; [13, 0] - [16, 0] + (function_call ; [13, 0] - [15, 2] + name: (dot_index_expression ; [13, 0] - [13, 26] + table: (function_call ; [13, 0] - [13, 20] + name: (identifier) ; [13, 0] - [13, 7] + arguments: (arguments ; [13, 7] - [13, 20] + (string ; [13, 8] - [13, 19] + content: (string_content)))) ; [13, 9] - [13, 18] + field: (identifier)) ; [13, 21] - [13, 26] + arguments: (arguments ; [13, 26] - [15, 2] + (table_constructor ; [13, 27] - [15, 1] + (field ; [14, 2] - [14, 15] + name: (identifier) ; [14, 2] - [14, 8] + value: (true)))))) ; [14, 11] - [14, 15] + (block_continuation) ; [14, 0] - [14, 0] + (block_continuation) ; [15, 0] - [15, 0] + (block_continuation)) ; [16, 0] - [16, 0] + (fenced_code_block_delimiter)))) ; [16, 0] - [16, 3] + (section ; [18, 0] - [26, 0] + (atx_heading ; [18, 0] - [19, 0] + (atx_h1_marker) ; [18, 0] - [18, 1] + heading_content: (inline ; [18, 2] - [18, 7] + (inline))) ; [18, 2] - [18, 7] + (paragraph ; [20, 0] - [21, 0] + (inline ; [20, 0] - [20, 21] + (inline))) ; [20, 0] - [20, 21] + (section ; [22, 0] - [26, 0] + (atx_heading ; [22, 0] - [23, 0] + (atx_h2_marker) ; [22, 0] - [22, 2] + heading_content: (inline ; [22, 3] - [22, 11] + (inline))) ; [22, 3] - [22, 11] + (paragraph ; [24, 0] - [25, 0] + (inline ; [24, 0] - [24, 31] + (inline))))) ; [24, 0] - [24, 31] + (section ; [26, 0] - [35, 0] + (atx_heading ; [26, 0] - [27, 0] + (atx_h1_marker) ; [26, 0] - [26, 1] + heading_content: (inline ; [26, 2] - [26, 15] + (inline))) ; [26, 2] - [26, 15] + (paragraph ; [28, 0] - [29, 0] + (inline ; [28, 0] - [28, 31] + (inline))) ; [28, 0] - [28, 31] + (fenced_code_block ; [30, 0] - [35, 0] + (fenced_code_block_delimiter) ; [30, 0] - [30, 3] + (info_string ; [30, 3] - [30, 6] + (language)) ; [30, 3] - [30, 6] + (block_continuation) ; [31, 0] - [31, 0] + (code_fence_content ; [31, 0] - [34, 0] + (document ; [31, 0] - [34, 0] + root: (element ; [31, 0] - [33, 9] + (STag ; [31, 0] - [31, 8] + (Name)) ; [31, 1] - [31, 7] + (content ; [31, 8] - [33, 0] + (CharData) ; [31, 8] - [32, 2] + (element ; [32, 2] - [32, 24] + (EmptyElemTag ; [32, 2] - [32, 24] + (Name) ; [32, 3] - [32, 9] + (Attribute ; [32, 10] - [32, 21] + (Name) ; [32, 10] - [32, 13] + (AttValue)))) ; [32, 14] - [32, 21] + (CharData)) ; [32, 24] - [33, 0] + (ETag ; [33, 0] - [33, 9] + (Name)))) ; [33, 2] - [33, 8] + (block_continuation) ; [32, 0] - [32, 0] + (block_continuation) ; [33, 0] - [33, 0] + (block_continuation)) ; [34, 0] - [34, 0] + (fenced_code_block_delimiter)))) ; [34, 0] - [34, 3] diff --git a/tests/filetypes/typst/sample.typ b/tests/filetypes/typst/sample.typ new file mode 100644 index 0000000..d39f64e --- /dev/null +++ b/tests/filetypes/typst/sample.typ @@ -0,0 +1,23 @@ += Installation + +Install the plugin. + +== Basic Setup + +Configure the plugin with default options. + += Usage + +Basic usage examples. + +== Commands + +Available commands and keymaps. + += Configuration + +Full configuration reference. + +== Advanced + +Advanced options for power users. diff --git a/tests/filetypes/typst/sample.typ.tree b/tests/filetypes/typst/sample.typ.tree new file mode 100644 index 0000000..7f88854 --- /dev/null +++ b/tests/filetypes/typst/sample.typ.tree @@ -0,0 +1,42 @@ +(source_file ; [0, 0] - [23, 0] + (section ; [0, 0] - [8, 0] + (heading ; [0, 0] - [0, 14] + (text)) ; [0, 2] - [0, 14] + (content ; [0, 14] - [8, 0] + (parbreak) ; [0, 14] - [2, 0] + (text) ; [2, 0] - [2, 19] + (parbreak) ; [2, 19] - [4, 0] + (section ; [4, 0] - [8, 0] + (heading ; [4, 0] - [4, 14] + (text)) ; [4, 3] - [4, 14] + (content ; [4, 14] - [8, 0] + (parbreak) ; [4, 14] - [6, 0] + (text) ; [6, 0] - [6, 42] + (parbreak))))) ; [6, 42] - [8, 0] + (section ; [8, 0] - [16, 0] + (heading ; [8, 0] - [8, 7] + (text)) ; [8, 2] - [8, 7] + (content ; [8, 7] - [16, 0] + (parbreak) ; [8, 7] - [10, 0] + (text) ; [10, 0] - [10, 21] + (parbreak) ; [10, 21] - [12, 0] + (section ; [12, 0] - [16, 0] + (heading ; [12, 0] - [12, 11] + (text)) ; [12, 3] - [12, 11] + (content ; [12, 11] - [16, 0] + (parbreak) ; [12, 11] - [14, 0] + (text) ; [14, 0] - [14, 31] + (parbreak))))) ; [14, 31] - [16, 0] + (section ; [16, 0] - [23, 0] + (heading ; [16, 0] - [16, 15] + (text)) ; [16, 2] - [16, 15] + (content ; [16, 15] - [23, 0] + (parbreak) ; [16, 15] - [18, 0] + (text) ; [18, 0] - [18, 29] + (parbreak) ; [18, 29] - [20, 0] + (section ; [20, 0] - [23, 0] + (heading ; [20, 0] - [20, 11] + (text)) ; [20, 3] - [20, 11] + (content ; [20, 11] - [23, 0] + (parbreak) ; [20, 11] - [22, 0] + (text)))))) ; [22, 0] - [22, 33] diff --git a/tests/filetypes/typst/typ_spec.lua b/tests/filetypes/typst/typ_spec.lua new file mode 100644 index 0000000..f0c3059 --- /dev/null +++ b/tests/filetypes/typst/typ_spec.lua @@ -0,0 +1,212 @@ +local cursor = require("bufsitter.cursor") +local io = require("bufsitter.io") +local h = require("tests.helpers") + +local SAMPLE = "tests/filetypes/typst/sample.typ" + +describe("ft.typst", function() + local bufnr + + before_each(function() + bufnr = h.buf_from_file(SAMPLE) + end) + + after_each(h.clean_bufs) + + local function heading_text(text) + return function(b, node) + local raw = vim.treesitter.get_node_text(node, b) + return vim.trim(raw:gsub("^=+%s*", "")) == text + end + end + + local function heading_level(level) + return function(b, node) + for i = 0, node:child_count() - 1 do + local t = node:child(i):type() + if t:match("^=+$") and #t == level then + return true + end + end + return false + end + end + + local function section_with(match) + return function(b, node) + for i = 0, node:named_child_count() - 1 do + local child = node:named_child(i) + if child:type() == "heading" and match(b, child) then + return true + end + end + return false + end + end + + describe("section navigation", function() + it("document has 3 top-level sections", function() + local items = cursor.root():children():exec(bufnr) + assert.are.same(3, #items) + end) + + it("finds Installation section by heading text", function() + local items = cursor + .root() + :children() + :filter(section_with(heading_text("Installation"))) + :first() + :exec(bufnr) + assert.are.same(true, #items > 0) + local actual = items[1]:type() + assert.are.same("section", actual) + end) + + it("finds Configuration section (last)", function() + local items = cursor.root():children():last():exec(bufnr) + assert.are.same(true, #items > 0) + local heading = nil + for i = 0, items[1]:named_child_count() - 1 do + if items[1]:named_child(i):type() == "heading" then + heading = items[1]:named_child(i) + break + end + end + local actual = vim.treesitter.get_node_text(heading, bufnr) + assert.are.same(true, actual:find("Configuration") ~= nil) + end) + + it("navigates to next section from Installation", function() + local items = cursor + .root() + :children() + :filter(section_with(heading_text("Installation"))) + :first() + :next_siblings({ types = { "section" } }) + :first() + :exec(bufnr) + assert.are.same(true, #items > 0) + local actual = vim.treesitter.get_node_text(items[1]:named_child(0), bufnr) + assert.are.same(true, actual:find("Usage") ~= nil) + end) + + it("navigates to prev section from Configuration", function() + local items = cursor + .root() + :children() + :last() + :prev_siblings({ types = { "section" } }) + :first() + :exec(bufnr) + assert.are.same(true, #items > 0) + local actual = vim.treesitter.get_node_text(items[1]:named_child(0), bufnr) + assert.are.same(true, actual:find("Usage") ~= nil) + end) + end) + + describe("heading navigation", function() + it("finds heading inside Installation section", function() + local items = cursor + .root() + :children() + :filter(section_with(heading_text("Installation"))) + :first() + :children({ types = { "heading" } }) + :first() + :exec(bufnr) + assert.are.same(true, #items > 0) + local actual = items[1]:type() + assert.are.same("heading", actual) + end) + + it("finds level-1 heading in Installation", function() + local items = cursor + .root() + :children() + :filter(section_with(heading_text("Installation"))) + :first() + :children() + :filter(heading_level(1)) + :first() + :exec(bufnr) + assert.are.same(true, #items > 0) + end) + + it("finds level-2 heading (Basic Setup) inside Installation", function() + local items = cursor + .root() + :children() + :filter(section_with(heading_text("Installation"))) + :first() + :children({ types = { "content" } }) + :first() + :children({ types = { "section" } }) + :first() + :children() + :filter(heading_level(2)) + :first() + :exec(bufnr) + assert.are.same(true, #items > 0) + local actual = vim.treesitter.get_node_text(items[1], bufnr) + assert.are.same(true, actual:find("Basic Setup") ~= nil) + end) + end) + + describe("io integration", function() + it("io.select returns content of Installation section", function() + local results = io.select(bufnr, { + cursor = cursor + .root() + :children() + :filter(section_with(heading_text("Installation"))) + :first(), + }) + assert.are.same(true, results ~= nil and #results > 0) + assert.are.same(true, results[1][1]:find("Installation") ~= nil) + end) + + it("io.insert appends after Installation without touching Usage", function() + io.insert(bufnr, { "// injected" }, { + cursor = cursor + .root() + :children() + :filter(section_with(heading_text("Installation"))) + :first(), + }) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local injected_row, usage_row + for i, l in ipairs(lines) do + if l == "// injected" then + injected_row = i - 1 + end + if l == "= Usage" then + usage_row = i - 1 + end + end + assert.are.same(true, injected_row ~= nil) + assert.are.same(true, injected_row < usage_row) + end) + + it("io.delete removes Installation section, Usage remains", function() + io.delete(bufnr, { + cursor = cursor + .root() + :children() + :filter(section_with(heading_text("Installation"))) + :first(), + }) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local has_installation, has_usage = false, false + for _, l in ipairs(lines) do + if l == "= Installation" then + has_installation = true + end + if l == "= Usage" then + has_usage = true + end + end + assert.are.same(false, has_installation) + assert.are.same(true, has_usage) + end) + end) +end) diff --git a/tests/helpers.lua b/tests/helpers.lua new file mode 100644 index 0000000..c35371d --- /dev/null +++ b/tests/helpers.lua @@ -0,0 +1,41 @@ +local M = {} + +function M.clean_bufs() + for _, b in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_is_valid(b) and vim.bo[b].buftype == "nofile" then + vim.api.nvim_buf_delete(b, { force = true }) + end + end +end + +---@param path string +---@return integer +function M.buf_from_file(path) + local lines = vim.fn.readfile(path) + local ft = vim.filetype.match({ filename = path, contents = lines }) + return M.make_buf(lines, ft) +end + +---@param lines string[] +---@param ft? string +---@return integer +function M.make_buf(lines, ft) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + local ei = vim.o.eventignore + vim.o.eventignore = "all" + vim.bo[bufnr].filetype = ft + vim.o.eventignore = ei + return bufnr +end + +---@param bufnr integer +---@param ft? string +---@return any +function M.get_root(bufnr, ft) + ft = ft or vim.bo[bufnr].filetype + local parser = vim.treesitter.get_parser(bufnr, ft) + return parser:parse()[1]:root() +end + +return M diff --git a/tests/minimal_init.lua b/tests/minimal_init.lua new file mode 100644 index 0000000..faad932 --- /dev/null +++ b/tests/minimal_init.lua @@ -0,0 +1,10 @@ +vim.cmd([[set runtimepath+=.]]) + +vim.opt.swapfile = false +vim.opt.undofile = false + +local deps_dir = vim.fn.getcwd() .. "/.deps/start/" +vim.opt.runtimepath:append(deps_dir .. "plenary.nvim") +vim.opt.runtimepath:append(deps_dir .. "nvim-treesitter") + +require("plenary.busted") From a60f6ed8cd3117787215ebf10fa7ed5effb507a8 Mon Sep 17 00:00:00 2001 From: xvzc Date: Tue, 21 Apr 2026 21:13:53 +0900 Subject: [PATCH 02/13] fix: apply copilot review suggestions - cursor: pass prev to new_multi/new_single in children(), parents(), nth() so or_else() works correctly after those steps - io: apply clamp_end before inline append insert to handle ec=0 correctly - scratch: apply win_opts to existing window via nvim_win_set_config in show() - shell.nix: fix plugin name nix:promdown.nvim -> nix:bufsitter.nvim - sample.go: remove trailing whitespace on blank lines --- lua/bufsitter/cursor.lua | 6 +++--- lua/bufsitter/io.lua | 3 ++- lua/bufsitter/scratch.lua | 1 + shell.nix | 2 +- tests/filetypes/go/go_spec.lua | 16 ++++++++-------- tests/filetypes/go/sample.go | 4 ++-- 6 files changed, 17 insertions(+), 15 deletions(-) diff --git a/lua/bufsitter/cursor.lua b/lua/bufsitter/cursor.lua index d3a04fe..c9c9514 100644 --- a/lua/bufsitter/cursor.lua +++ b/lua/bufsitter/cursor.lua @@ -173,7 +173,7 @@ function Base:children(opts) end end return result - end) + end, prev) end local function make_or_else(self, constructor) @@ -237,7 +237,7 @@ function Multi:parents(opts) end end return result - end) + end, prev) end ---Falls back to the previous cursor step if the current step yields no nodes. @@ -274,7 +274,7 @@ function Multi:nth(n) local idx = n > 0 and n or (#nodes + n + 1) local node = nodes[idx] return node and { node } or {} - end) + end, prev) end ---Selects the first node. Equivalent to `nth(1)`. diff --git a/lua/bufsitter/io.lua b/lua/bufsitter/io.lua index 0636ada..ce5d674 100644 --- a/lua/bufsitter/io.lua +++ b/lua/bufsitter/io.lua @@ -241,7 +241,8 @@ function M.insert(bufnr, contents, opts) contents ) else - vim.api.nvim_buf_set_text(bufnr, er, ec, er, ec, contents) + local end_row, end_col = clamp_end(bufnr, er, ec) + vim.api.nvim_buf_set_text(bufnr, end_row, end_col, end_row, end_col, contents) end else -- ec=0 means exclusive end (before row er), so insert at er; otherwise after er diff --git a/lua/bufsitter/scratch.lua b/lua/bufsitter/scratch.lua index a70adff..9f55cbc 100644 --- a/lua/bufsitter/scratch.lua +++ b/lua/bufsitter/scratch.lua @@ -130,6 +130,7 @@ function Scratch:show(win_opts) if self:is_visible() then vim.api.nvim_win_set_buf(self._winid, self._bufnr) + vim.api.nvim_win_set_config(self._winid, wopts) else self._winid = vim.api.nvim_open_win(self._bufnr, true, wopts) end diff --git a/shell.nix b/shell.nix index 6a12786..60343e5 100644 --- a/shell.nix +++ b/shell.nix @@ -15,7 +15,7 @@ pkgs.mkShell { ]; shellHook = # sh '' - export name="nix:promdown.nvim" + export name="nix:bufsitter.nvim" export NVIM_APPNAME="nvim" ''; } diff --git a/tests/filetypes/go/go_spec.lua b/tests/filetypes/go/go_spec.lua index bb1307b..25931fe 100644 --- a/tests/filetypes/go/go_spec.lua +++ b/tests/filetypes/go/go_spec.lua @@ -218,7 +218,7 @@ describe("ft.go", function() " // 2. Basic pointers and strings", ' Username *string `json:"username"`', ' Email string `json:"email"`', - " ", + "", " // 3. Collections: Slices and Maps", ' Roles []string `json:"roles"`', ' Settings map[string]string `json:"settings"`', @@ -231,7 +231,7 @@ describe("ft.go", function() "", " // 5. Interface member (for polymorphism tests)", ' Permissions any `json:"permissions"`', - " ", + "", " // 6. Channel for concurrency tests", ' StatusChan chan int `json:"-"`', "}", @@ -274,7 +274,7 @@ describe("ft.go", function() " // 2. Basic pointers and strings", ' Username *string `json:"username"`', ' Email string `json:"email"`', - " ", + "", " // 3. Collections: Slices and Maps", ' Roles []string `json:"roles"`', ' Settings map[string]string `json:"settings"`', @@ -287,7 +287,7 @@ describe("ft.go", function() "", " // 5. Interface member (for polymorphism tests)", ' Permissions any `json:"permissions"`', - " ", + "", " // 6. Channel for concurrency tests", ' StatusChan chan int `json:"-"`', "}", @@ -325,7 +325,7 @@ describe("ft.go", function() " // 2. Basic pointers and strings", ' Username *string `json:"username"`', ' Email string `json:"email"`', - " ", + "", " // 3. Collections: Slices and Maps", ' Roles []string `json:"roles"`', ' Settings map[string]string `json:"settings"`', @@ -338,7 +338,7 @@ describe("ft.go", function() "", " // 5. Interface member (for polymorphism tests)", ' Permissions any `json:"permissions"`', - " ", + "", " // 6. Channel for concurrency tests", ' StatusChan chan int `json:"-"`', "}", @@ -386,7 +386,7 @@ describe("ft.go", function() " // 2. Basic pointers and strings", ' Username *string `json:"username"`', ' Email string `json:"email"`', - " ", + "", " // 3. Collections: Slices and Maps", ' Roles []string `json:"roles"`', ' Settings map[string]string `json:"settings"`', @@ -399,7 +399,7 @@ describe("ft.go", function() "", " // 5. Interface member (for polymorphism tests)", ' Permissions any `json:"permissions"`', - " ", + "", " // 6. Channel for concurrency tests", ' StatusChan chan int `json:"-"`', "}", diff --git a/tests/filetypes/go/sample.go b/tests/filetypes/go/sample.go index 4c1bd07..5cfdd58 100644 --- a/tests/filetypes/go/sample.go +++ b/tests/filetypes/go/sample.go @@ -18,7 +18,7 @@ type UserProfile struct { // 2. Basic pointers and strings Username *string `json:"username"` Email string `json:"email"` - + // 3. Collections: Slices and Maps Roles []string `json:"roles"` Settings map[string]string `json:"settings"` @@ -31,7 +31,7 @@ type UserProfile struct { // 5. Interface member (for polymorphism tests) Permissions any `json:"permissions"` - + // 6. Channel for concurrency tests StatusChan chan int `json:"-"` } From 01b1617ea08a76546dc375b33ea28f41547bf1e8 Mon Sep 17 00:00:00 2001 From: xvzc Date: Tue, 21 Apr 2026 21:34:12 +0900 Subject: [PATCH 03/13] fix: access config via require('bufsitter').config directly --- lua/bufsitter/io.lua | 6 ++---- lua/bufsitter/scratch.lua | 4 +--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/lua/bufsitter/io.lua b/lua/bufsitter/io.lua index ce5d674..3b65b51 100644 --- a/lua/bufsitter/io.lua +++ b/lua/bufsitter/io.lua @@ -46,13 +46,11 @@ ---@field on_error? fun(err: string) ---@field hook? fun(bufnr: integer, contents: string[]): string[]? -local config = require("bufsitter") - local M = {} local function eval_cursor(cursor_fn, bufnr, on_error) - local handler = on_error - or (config.config and config.config.io and config.config.io.on_error) + local config = require("bufsitter").config + local handler = on_error or (config and config.io and config.io.on_error) if handler then local ok, result = pcall(function() return cursor_fn:exec(bufnr) diff --git a/lua/bufsitter/scratch.lua b/lua/bufsitter/scratch.lua index 9f55cbc..4e85129 100644 --- a/lua/bufsitter/scratch.lua +++ b/lua/bufsitter/scratch.lua @@ -30,8 +30,6 @@ local Scratch = {} Scratch.__index = Scratch -local config = require("bufsitter") - ---Creates a new scratch buffer, deep-merging `opts` over the global defaults. ---Sets the filetype, writes `init_contents`, and calls `on_attach` if provided. ---@param opts? bufsitter.scratch.opts @@ -47,7 +45,7 @@ local config = require("bufsitter") ---}) ---@usage ]] function Scratch.new(opts) - opts = vim.tbl_deep_extend("force", config.config.scratch, opts or {}) + opts = vim.tbl_deep_extend("force", require("bufsitter").config.scratch, opts or {}) local bufnr = vim.api.nvim_create_buf(false, true) vim.bo[bufnr].filetype = opts.ft From 65116e0ac9427ba38c933e7f39c6220499e4d74c Mon Sep 17 00:00:00 2001 From: xvzc Date: Tue, 21 Apr 2026 21:39:25 +0900 Subject: [PATCH 04/13] fix: initialize M.config with defaults at module load time --- lua/bufsitter/init.lua | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lua/bufsitter/init.lua b/lua/bufsitter/init.lua index ddf2819..0a8559f 100644 --- a/lua/bufsitter/init.lua +++ b/lua/bufsitter/init.lua @@ -55,6 +55,8 @@ local default = { }, } +M.config = default + ---Initializes bufsitter with the given options, deep-merged over the defaults. ---Must be called once before using any other bufsitter API. ---@param opts? bufsitter.config.opts From cc305018bf5cb381f2448853e7d4652e08e56c9a Mon Sep 17 00:00:00 2001 From: xvzc Date: Tue, 21 Apr 2026 21:44:32 +0900 Subject: [PATCH 05/13] fix: guard setup() with vim.g.bufsitter_loaded to prevent config reset on reload --- lua/bufsitter/init.lua | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lua/bufsitter/init.lua b/lua/bufsitter/init.lua index 0a8559f..75ddf66 100644 --- a/lua/bufsitter/init.lua +++ b/lua/bufsitter/init.lua @@ -55,8 +55,6 @@ local default = { }, } -M.config = default - ---Initializes bufsitter with the given options, deep-merged over the defaults. ---Must be called once before using any other bufsitter API. ---@param opts? bufsitter.config.opts @@ -73,6 +71,11 @@ M.config = default function M.setup(opts) ---@type bufsitter.config.opts M.config = vim.tbl_deep_extend("force", default, opts or {}) + vim.g.bufsitter_loaded = 1 +end + +if not vim.g.bufsitter_loaded then + M.setup() end return M From 7329d359f712f741020f112323335a9d53f69049 Mon Sep 17 00:00:00 2001 From: xvzc Date: Tue, 21 Apr 2026 21:47:11 +0900 Subject: [PATCH 06/13] feat: resize floating window on VimResized --- lua/bufsitter/scratch.lua | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/lua/bufsitter/scratch.lua b/lua/bufsitter/scratch.lua index 4e85129..25534c6 100644 --- a/lua/bufsitter/scratch.lua +++ b/lua/bufsitter/scratch.lua @@ -27,6 +27,7 @@ ---@field private _bufnr integer ---@field private _winid integer|nil ---@field private _win_opts bufsitter.scratch.win.opts +---@field private _augroup integer|nil local Scratch = {} Scratch.__index = Scratch @@ -69,6 +70,7 @@ function Scratch.new(opts) self._bufnr = bufnr self._winid = nil self._win_opts = opts.win or {} + self._augroup = nil return self end @@ -133,6 +135,17 @@ function Scratch:show(win_opts) self._winid = vim.api.nvim_open_win(self._bufnr, true, wopts) end + self._augroup = + vim.api.nvim_create_augroup("BufsitterScratch_" .. self._bufnr, { clear = true }) + vim.api.nvim_create_autocmd("VimResized", { + group = self._augroup, + callback = function() + if self:is_visible() then + vim.api.nvim_win_set_config(self._winid, self._win_opts) + end + end, + }) + return self._winid end @@ -148,6 +161,10 @@ function Scratch:hide() end vim.api.nvim_win_close(self._winid, false) self._winid = nil + if self._augroup then + pcall(vim.api.nvim_del_augroup_by_id, self._augroup) + self._augroup = nil + end end ---Hides the window if visible, shows it otherwise. @@ -176,6 +193,10 @@ function Scratch:delete() if self._winid and vim.api.nvim_win_is_valid(self._winid) then vim.api.nvim_win_close(self._winid, false) end + if self._augroup then + pcall(vim.api.nvim_del_augroup_by_id, self._augroup) + self._augroup = nil + end if vim.api.nvim_buf_is_valid(self._bufnr) then vim.api.nvim_buf_delete(self._bufnr, { force = true }) end From a3c916c709e4ee698eb1ca924b0f689b8abab4d3 Mon Sep 17 00:00:00 2001 From: xvzc Date: Tue, 21 Apr 2026 21:54:56 +0900 Subject: [PATCH 07/13] feat: center floating window and support ratio-based width/height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolve width/height as ratio of editor size when value is 0–1 - compute centered row/col at show() time when not explicitly set - remove fixed row/col from default config - default width/height changed to 0.6/0.4 - remove unused VimResized autocmd code --- lua/bufsitter/init.lua | 8 ++---- lua/bufsitter/scratch.lua | 51 ++++++++++++++------------------- tests/bufsitter/config_spec.lua | 10 +++---- 3 files changed, 30 insertions(+), 39 deletions(-) diff --git a/lua/bufsitter/init.lua b/lua/bufsitter/init.lua index 75ddf66..e945e8a 100644 --- a/lua/bufsitter/init.lua +++ b/lua/bufsitter/init.lua @@ -14,7 +14,7 @@ ---@field ft? string ---@field init_contents? string[] | fun(): string[] ---@field on_attach? fun(bufnr: integer) ----@field win? vim.api.keyset.win_config +---@field win? bufsitter.scratch.win.opts ---@class bufsitter.config.ref.opts ---@field expand? boolean @@ -39,10 +39,8 @@ local default = { on_attach = nil, win = { relative = "editor", - width = 80, - height = 20, - row = 5, - col = 10, + width = 0.6, + height = 0.4, style = "minimal", border = "rounded", }, diff --git a/lua/bufsitter/scratch.lua b/lua/bufsitter/scratch.lua index 25534c6..fd1636c 100644 --- a/lua/bufsitter/scratch.lua +++ b/lua/bufsitter/scratch.lua @@ -10,10 +10,10 @@ ---@class bufsitter.scratch.win.opts ---@field relative? string ----@field width? integer ----@field height? integer ----@field row? integer ----@field col? integer +---@field width? number Width in columns, or a ratio 0–1 relative to editor width +---@field height? number Height in rows, or a ratio 0–1 relative to editor height +---@field row? integer Top row of the window (computed from center when omitted) +---@field col? integer Left column of the window (computed from center when omitted) ---@field style? string ---@field border? string @@ -27,10 +27,16 @@ ---@field private _bufnr integer ---@field private _winid integer|nil ---@field private _win_opts bufsitter.scratch.win.opts ----@field private _augroup integer|nil local Scratch = {} Scratch.__index = Scratch +local function resolve_dim(value, total) + if value and value > 0 and value < 1 then + return math.floor(total * value) + end + return value +end + ---Creates a new scratch buffer, deep-merging `opts` over the global defaults. ---Sets the filetype, writes `init_contents`, and calls `on_attach` if provided. ---@param opts? bufsitter.scratch.opts @@ -70,7 +76,6 @@ function Scratch.new(opts) self._bufnr = bufnr self._winid = nil self._win_opts = opts.win or {} - self._augroup = nil return self end @@ -111,22 +116,29 @@ function Scratch:is_visible() return self._winid ~= nil and vim.api.nvim_win_is_valid(self._winid) end ----Opens the floating window. If it is already visible, reattaches the buffer ----to the existing window. Returns the window id, or nil if the buffer is invalid. +---Opens the floating window. Width and height ratios (0–1) are resolved against +---the current editor size, and the window is centered unless `row`/`col` are +---explicitly provided. Returns the window id, or nil if the buffer is invalid. ---@param win_opts? bufsitter.scratch.win.opts ---@return integer|nil ---@usage [[ ---local Scratch = require("bufsitter.scratch") ---local s = Scratch.new() ---s:show() ----s:show({ width = 100, height = 30 }) +---s:show({ width = 0.8, height = 0.6 }) ---@usage ]] function Scratch:show(win_opts) if not self:is_valid() then return nil end - local wopts = vim.tbl_deep_extend("force", self._win_opts, win_opts or {}) + local merged = vim.tbl_deep_extend("force", self._win_opts, win_opts or {}) + local width = resolve_dim(merged.width, vim.o.columns) + local height = resolve_dim(merged.height, vim.o.lines) + local wopts = vim.tbl_deep_extend("force", { + row = math.floor((vim.o.lines - height) / 2), + col = math.floor((vim.o.columns - width) / 2), + }, merged, { width = width, height = height }) if self:is_visible() then vim.api.nvim_win_set_buf(self._winid, self._bufnr) @@ -135,17 +147,6 @@ function Scratch:show(win_opts) self._winid = vim.api.nvim_open_win(self._bufnr, true, wopts) end - self._augroup = - vim.api.nvim_create_augroup("BufsitterScratch_" .. self._bufnr, { clear = true }) - vim.api.nvim_create_autocmd("VimResized", { - group = self._augroup, - callback = function() - if self:is_visible() then - vim.api.nvim_win_set_config(self._winid, self._win_opts) - end - end, - }) - return self._winid end @@ -161,10 +162,6 @@ function Scratch:hide() end vim.api.nvim_win_close(self._winid, false) self._winid = nil - if self._augroup then - pcall(vim.api.nvim_del_augroup_by_id, self._augroup) - self._augroup = nil - end end ---Hides the window if visible, shows it otherwise. @@ -193,10 +190,6 @@ function Scratch:delete() if self._winid and vim.api.nvim_win_is_valid(self._winid) then vim.api.nvim_win_close(self._winid, false) end - if self._augroup then - pcall(vim.api.nvim_del_augroup_by_id, self._augroup) - self._augroup = nil - end if vim.api.nvim_buf_is_valid(self._bufnr) then vim.api.nvim_buf_delete(self._bufnr, { force = true }) end diff --git a/tests/bufsitter/config_spec.lua b/tests/bufsitter/config_spec.lua index a72a5a6..dba68d3 100644 --- a/tests/bufsitter/config_spec.lua +++ b/tests/bufsitter/config_spec.lua @@ -22,10 +22,10 @@ describe("config", function() it("should set default win options", function() local win = config.config.scratch.win assert.are.same("editor", win.relative) - assert.are.same(80, win.width) - assert.are.same(20, win.height) - assert.are.same(5, win.row) - assert.are.same(10, win.col) + assert.are.same(0.6, win.width) + assert.are.same(0.4, win.height) + assert.is_nil(win.row) + assert.is_nil(win.col) assert.are.same("minimal", win.style) assert.are.same("rounded", win.border) end) @@ -50,7 +50,7 @@ describe("config", function() config.setup({ scratch = { win = { width = 100 } } }) local win = config.config.scratch.win assert.are.same(100, win.width) - assert.are.same(20, win.height) + assert.are.same(0.4, win.height) assert.are.same("rounded", win.border) end) From 08eca749b133c8ea170d8c7fcf429036b1e50ecf Mon Sep 17 00:00:00 2001 From: xvzc Date: Wed, 22 Apr 2026 00:53:03 +0900 Subject: [PATCH 08/13] feat: add min_width/min_height, row/col defaults, trim_end option, and io comments - scratch: add min_width/min_height support (absolute or 0-1 ratio) to win.opts - scratch: expose row/col as explicit nil defaults; add bufsitter.config.scratch.win.opts type - io: fix cursor-based operations inserting after trailing blank lines by adding resolve_end() with trim_end option (default true) across select/insert/delete/replace - io: annotate all internal logic with inline comments --- .claude/rules/git.md | 3 + lua/bufsitter/init.lua | 21 ++++++- lua/bufsitter/io.lua | 88 ++++++++++++++++++++---------- lua/bufsitter/scratch.lua | 34 +++++++++--- tests/bufsitter/config_spec.lua | 15 ++++- tests/filetypes/typst/typ_spec.lua | 42 ++++++++++++++ 6 files changed, 159 insertions(+), 44 deletions(-) create mode 100644 .claude/rules/git.md diff --git a/.claude/rules/git.md b/.claude/rules/git.md new file mode 100644 index 0000000..f453c5b --- /dev/null +++ b/.claude/rules/git.md @@ -0,0 +1,3 @@ +# Git + +Never create commits unless the user explicitly asks for it. diff --git a/lua/bufsitter/init.lua b/lua/bufsitter/init.lua index e945e8a..acdf22c 100644 --- a/lua/bufsitter/init.lua +++ b/lua/bufsitter/init.lua @@ -10,11 +10,22 @@ ---< ---@brief ]] +---@class bufsitter.config.scratch.win.opts +---@field relative? string +---@field width? number Width in columns, or a ratio 0–1 relative to editor width +---@field height? number Height in rows, or a ratio 0–1 relative to editor height +---@field min_width? number Minimum width in columns, or a ratio 0–1 relative to editor width +---@field min_height? number Minimum height in rows, or a ratio 0–1 relative to editor height +---@field row? number Top row, or a ratio 0–1 relative to editor height (centered when omitted) +---@field col? number Left column, or a ratio 0–1 relative to editor width (centered when omitted) +---@field style? string +---@field border? string + ---@class bufsitter.config.scratch.opts ---@field ft? string ---@field init_contents? string[] | fun(): string[] ---@field on_attach? fun(bufnr: integer) ----@field win? bufsitter.scratch.win.opts +---@field win? bufsitter.config.scratch.win.opts ---@class bufsitter.config.ref.opts ---@field expand? boolean @@ -39,8 +50,12 @@ local default = { on_attach = nil, win = { relative = "editor", - width = 0.6, - height = 0.4, + width = 0.5, + height = 0.7, + min_width = nil, + min_height = nil, + row = nil, + col = nil, style = "minimal", border = "rounded", }, diff --git a/lua/bufsitter/io.lua b/lua/bufsitter/io.lua index 3b65b51..f597200 100644 --- a/lua/bufsitter/io.lua +++ b/lua/bufsitter/io.lua @@ -17,6 +17,7 @@ ---@field end_col? integer ---@field on_error? fun(err: string) ---@field hook? fun(bufnr: integer, contents: string[]): string[]? +---@field trim_end? boolean Strip trailing blank lines from the node end when using cursor (default true) ---@class bufsitter.io.insert.opts ---@field cursor? bufsitter.Cursor @@ -28,6 +29,7 @@ ---@field hook? fun(bufnr: integer, contents: string[]): string[]? ---@field prepend? boolean ---@field inline? boolean +---@field trim_end? boolean Strip trailing blank lines from the node end when using cursor (default true) ---@class bufsitter.io.delete.opts ---@field cursor? bufsitter.Cursor @@ -36,6 +38,7 @@ ---@field end_row? integer ---@field end_col? integer ---@field on_error? fun(err: string) +---@field trim_end? boolean Strip trailing blank lines from the node end when using cursor (default true) ---@class bufsitter.io.replace.opts ---@field cursor? bufsitter.Cursor @@ -45,9 +48,12 @@ ---@field end_col? integer ---@field on_error? fun(err: string) ---@field hook? fun(bufnr: integer, contents: string[]): string[]? +---@field trim_end? boolean Strip trailing blank lines from the node end when using cursor (default true) local M = {} +-- Execute a cursor chain against a buffer, routing errors through on_error or +-- the global config handler when available. Returns nil on error. local function eval_cursor(cursor_fn, bufnr, on_error) local config = require("bufsitter").config local handler = on_error or (config and config.io and config.io.on_error) @@ -74,6 +80,27 @@ local function clamp_end(bufnr, er, ec) return er, ec end +-- Clamp the exclusive end, then optionally walk back past trailing blank lines +-- so the resolved position lands on the last character of real content. +-- trim defaults to true when nil; pass false to keep the clamped position as-is. +local function resolve_end(bufnr, er, ec, trim) + local row, col = clamp_end(bufnr, er, ec) + if trim == false then + return row, col + end + -- col==0 after clamping means we landed at the start of a blank line; step back. + while col == 0 and row > 0 do + local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or "" + if line ~= "" then + break + end + row = row - 1 + local prev = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or "" + col = #prev + end + return row, col +end + ---Reads text from `bufnr`. Returns one `string[]` per matched node when ---`cursor` is used, or a single-element wrapper otherwise. ---Returns `nil` if the buffer is invalid or the cursor yields nothing. @@ -103,7 +130,8 @@ function M.select(bufnr, opts) local results = {} for _, node in ipairs(items) do local sr, sc, er, ec = node:range() - er, ec = clamp_end(bufnr, er, ec) + -- resolve_end trims trailing blank lines so the selection ends at real content. + er, ec = resolve_end(bufnr, er, ec, opts.trim_end) local lines = vim.api.nvim_buf_get_text(bufnr, sr, sc, er, ec, {}) if type(opts.hook) == "function" then local res = opts.hook(bufnr, lines) @@ -118,10 +146,12 @@ function M.select(bufnr, opts) local lines if opts.start_row ~= nil and opts.end_row ~= nil then + -- Explicit range: clamp the exclusive end but do not trim (caller owns the coords). local er, ec = clamp_end(bufnr, opts.end_row, opts.end_col or 0) lines = vim.api.nvim_buf_get_text(bufnr, opts.start_row, opts.start_col or 0, er, ec, {}) else + -- No range: read the whole buffer. lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) end @@ -200,6 +230,9 @@ function M.insert(bufnr, contents, opts) return end + -- Process nodes in reverse source order so earlier row indices stay valid + -- after each insertion shifts subsequent lines down. + -- prepend sorts by start row descending; append sorts by end row descending. table.sort(items, function(a, b) local a_sr, _, a_er = a:range() local b_sr, _, b_er = b:range() @@ -215,39 +248,23 @@ function M.insert(bufnr, contents, opts) local sr, sc, er, ec = node:range() if opts.prepend then if opts.inline then - -- attach at exact character position, no newline added + -- Attach at the exact start character of the node, no newline added. vim.api.nvim_buf_set_text(bufnr, sr, sc, sr, sc, contents) else + -- Insert as new lines immediately above the node's first row. vim.api.nvim_buf_set_lines(bufnr, sr, sr, false, contents) end else if opts.inline then - -- attach at exact character position, no newline added - if er >= line_count then - local last = vim.api.nvim_buf_get_lines( - bufnr, - line_count - 1, - line_count, - false - )[1] or "" - vim.api.nvim_buf_set_text( - bufnr, - line_count - 1, - #last, - line_count - 1, - #last, - contents - ) - else - local end_row, end_col = clamp_end(bufnr, er, ec) - vim.api.nvim_buf_set_text(bufnr, end_row, end_col, end_row, end_col, contents) - end + -- Attach right after the last real content character (trailing blank + -- lines skipped by resolve_end), no newline added. + local end_row, end_col = resolve_end(bufnr, er, ec, opts.trim_end) + vim.api.nvim_buf_set_text(bufnr, end_row, end_col, end_row, end_col, contents) else - -- ec=0 means exclusive end (before row er), so insert at er; otherwise after er - local row = (ec == 0) and er or (er + 1) - if row > line_count then - row = line_count - end + -- Insert as new lines on the row immediately after the last real + -- content row (trailing blank lines skipped by resolve_end). + local last_row, _ = resolve_end(bufnr, er, ec, opts.trim_end) + local row = math.min(last_row + 1, line_count) vim.api.nvim_buf_set_lines(bufnr, row, row, false, contents) end end @@ -258,6 +275,7 @@ function M.insert(bufnr, contents, opts) if opts.start_row ~= nil and opts.end_row ~= nil then if opts.prepend then if opts.inline then + -- Attach at the exact start position of the range. vim.api.nvim_buf_set_text( bufnr, opts.start_row, @@ -267,10 +285,12 @@ function M.insert(bufnr, contents, opts) contents ) else + -- Insert as new lines above start_row. vim.api.nvim_buf_set_lines(bufnr, opts.start_row, opts.start_row, false, contents) end else if opts.inline then + -- Attach at the exact end position of the range. vim.api.nvim_buf_set_text( bufnr, opts.end_row, @@ -280,6 +300,7 @@ function M.insert(bufnr, contents, opts) contents ) else + -- Append a newline after end_col then the contents as a new line. local rep = vim.list_extend(vim.deepcopy(contents), { "" }) vim.api.nvim_buf_set_text( bufnr, @@ -294,6 +315,7 @@ function M.insert(bufnr, contents, opts) return end + -- No cursor or range: append after the very last line of the buffer. local last_row = vim.api.nvim_buf_line_count(bufnr) - 1 local last_line = vim.api.nvim_buf_get_lines(bufnr, last_row, last_row + 1, false)[1] or "" @@ -347,6 +369,7 @@ function M.delete(bufnr, opts) return end + -- Reverse order so deleting a node doesn't shift the rows of nodes not yet deleted. table.sort(items, function(a, b) local a_sr = a:range() local b_sr = b:range() @@ -354,13 +377,15 @@ function M.delete(bufnr, opts) end) for _, node in ipairs(items) do local sr, sc, er, ec = node:range() - er, ec = clamp_end(bufnr, er, ec) + -- resolve_end trims trailing blank lines; the deletion stops at real content. + er, ec = resolve_end(bufnr, er, ec, opts.trim_end) vim.api.nvim_buf_set_text(bufnr, sr, sc, er, ec, {}) end return end if opts.start_row ~= nil and opts.end_row ~= nil then + -- Explicit range: delete exactly the specified span. vim.api.nvim_buf_set_text( bufnr, opts.start_row, @@ -404,6 +429,7 @@ function M.replace(bufnr, contents, opts) return end + -- Reverse order so replacing a node doesn't invalidate rows of later nodes. table.sort(items, function(a, b) local a_sr = a:range() local b_sr = b:range() @@ -411,13 +437,15 @@ function M.replace(bufnr, contents, opts) end) for _, node in ipairs(items) do local sr, sc, er, ec = node:range() - er, ec = clamp_end(bufnr, er, ec) + -- resolve_end trims trailing blank lines; replacement covers real content only. + er, ec = resolve_end(bufnr, er, ec, opts.trim_end) vim.api.nvim_buf_set_text(bufnr, sr, sc, er, ec, vim.deepcopy(contents)) end return end if opts.start_row ~= nil and opts.end_row ~= nil then + -- Explicit range: clamp the exclusive end but do not trim (caller owns the coords). local er, ec = clamp_end(bufnr, opts.end_row, opts.end_col or 0) vim.api.nvim_buf_set_text( bufnr, @@ -457,6 +485,8 @@ function M.clear(bufnr) if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end + -- Replace the entire buffer content with a single empty string, + -- which leaves exactly one empty line. local last_row = vim.api.nvim_buf_line_count(bufnr) - 1 local last_line = vim.api.nvim_buf_get_lines(bufnr, last_row, last_row + 1, false)[1] or "" diff --git a/lua/bufsitter/scratch.lua b/lua/bufsitter/scratch.lua index fd1636c..26c9086 100644 --- a/lua/bufsitter/scratch.lua +++ b/lua/bufsitter/scratch.lua @@ -12,8 +12,10 @@ ---@field relative? string ---@field width? number Width in columns, or a ratio 0–1 relative to editor width ---@field height? number Height in rows, or a ratio 0–1 relative to editor height ----@field row? integer Top row of the window (computed from center when omitted) ----@field col? integer Left column of the window (computed from center when omitted) +---@field min_width? number Minimum width in columns, or a ratio 0–1 relative to editor width +---@field min_height? number Minimum height in rows, or a ratio 0–1 relative to editor height +---@field row? number Top row, or a ratio 0–1 relative to editor height (centered when omitted) +---@field col? number Left column, or a ratio 0–1 relative to editor width (centered when omitted) ---@field style? string ---@field border? string @@ -116,9 +118,10 @@ function Scratch:is_visible() return self._winid ~= nil and vim.api.nvim_win_is_valid(self._winid) end ----Opens the floating window. Width and height ratios (0–1) are resolved against ----the current editor size, and the window is centered unless `row`/`col` are ----explicitly provided. Returns the window id, or nil if the buffer is invalid. +---Opens the floating window. All of `width`, `height`, `row`, and `col` accept +---either an absolute integer or a 0–1 ratio relative to the editor size. +---`row` and `col` default to centered when omitted. +---Returns the window id, or nil if the buffer is invalid. ---@param win_opts? bufsitter.scratch.win.opts ---@return integer|nil ---@usage [[ @@ -126,6 +129,7 @@ end ---local s = Scratch.new() ---s:show() ---s:show({ width = 0.8, height = 0.6 }) +---s:show({ width = 0.8, height = 0.6, row = 0.1, col = 0.1 }) ---@usage ]] function Scratch:show(win_opts) if not self:is_valid() then @@ -135,10 +139,22 @@ function Scratch:show(win_opts) local merged = vim.tbl_deep_extend("force", self._win_opts, win_opts or {}) local width = resolve_dim(merged.width, vim.o.columns) local height = resolve_dim(merged.height, vim.o.lines) - local wopts = vim.tbl_deep_extend("force", { - row = math.floor((vim.o.lines - height) / 2), - col = math.floor((vim.o.columns - width) / 2), - }, merged, { width = width, height = height }) + if merged.min_width then + width = math.max(width, resolve_dim(merged.min_width, vim.o.columns)) + end + if merged.min_height then + height = math.max(height, resolve_dim(merged.min_height, vim.o.lines)) + end + local row = merged.row and resolve_dim(merged.row, vim.o.lines) + or math.floor((vim.o.lines - height) / 2) + local col = merged.col and resolve_dim(merged.col, vim.o.columns) + or math.floor((vim.o.columns - width) / 2) + local wopts = vim.tbl_deep_extend("force", merged, { + width = width, + height = height, + row = row, + col = col, + }) if self:is_visible() then vim.api.nvim_win_set_buf(self._winid, self._bufnr) diff --git a/tests/bufsitter/config_spec.lua b/tests/bufsitter/config_spec.lua index dba68d3..b85fdc7 100644 --- a/tests/bufsitter/config_spec.lua +++ b/tests/bufsitter/config_spec.lua @@ -22,8 +22,10 @@ describe("config", function() it("should set default win options", function() local win = config.config.scratch.win assert.are.same("editor", win.relative) - assert.are.same(0.6, win.width) - assert.are.same(0.4, win.height) + assert.are.same(0.5, win.width) + assert.are.same(0.7, win.height) + assert.is_nil(win.min_width) + assert.is_nil(win.min_height) assert.is_nil(win.row) assert.is_nil(win.col) assert.are.same("minimal", win.style) @@ -50,7 +52,7 @@ describe("config", function() config.setup({ scratch = { win = { width = 100 } } }) local win = config.config.scratch.win assert.are.same(100, win.width) - assert.are.same(0.4, win.height) + assert.are.same(0.7, win.height) assert.are.same("rounded", win.border) end) @@ -65,6 +67,13 @@ describe("config", function() config.setup({ ref = { expand = true } }) assert.is_true(config.config.ref.expand) end) + + it("should set min_width and min_height in scratch.win", function() + config.setup({ scratch = { win = { min_width = 40, min_height = 10 } } }) + local win = config.config.scratch.win + assert.are.same(40, win.min_width) + assert.are.same(10, win.min_height) + end) end) end) end) diff --git a/tests/filetypes/typst/typ_spec.lua b/tests/filetypes/typst/typ_spec.lua index f0c3059..8687cdc 100644 --- a/tests/filetypes/typst/typ_spec.lua +++ b/tests/filetypes/typst/typ_spec.lua @@ -187,6 +187,48 @@ describe("ft.typst", function() assert.are.same(true, injected_row < usage_row) end) + it("io.insert lands immediately after last non-blank line of section", function() + io.insert(bufnr, { "// injected" }, { + cursor = cursor + .root() + :children() + :filter(section_with(heading_text("Installation"))) + :first(), + }) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local injected_idx + for i, l in ipairs(lines) do + if l == "// injected" then + injected_idx = i + break + end + end + assert.is_not_nil(injected_idx) + local line_before = injected_idx > 1 and lines[injected_idx - 1] or "" + assert.are_not.same("", line_before) + end) + + it("io.insert inline lands at end of last non-blank character of section", function() + io.insert(bufnr, { "[suffix]" }, { + inline = true, + cursor = cursor + .root() + :children() + :filter(section_with(heading_text("Installation"))) + :first(), + }) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local suffixed + for _, l in ipairs(lines) do + if l:find("%[suffix%]$") then + suffixed = l + break + end + end + assert.is_not_nil(suffixed) + assert.are_not.same("[suffix]", suffixed) + end) + it("io.delete removes Installation section, Usage remains", function() io.delete(bufnr, { cursor = cursor From ef846e3c1f7d2b1b11b879764a3b3866117b9f10 Mon Sep 17 00:00:00 2001 From: xvzc Date: Wed, 22 Apr 2026 01:04:59 +0900 Subject: [PATCH 09/13] ci: add release-please workflow --- .github/workflows/release.yml | 19 +++++++++++++++++++ version.txt | 1 + 2 files changed, 20 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 version.txt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..807a65b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,19 @@ +name: Release + +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + name: Release Please + runs-on: ubuntu-latest + steps: + - uses: googleapis/release-please-action@v4 + with: + release-type: simple diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +0.1.0 From d8833b941a6620702e3086b0a5d0d931faf1bca2 Mon Sep 17 00:00:00 2001 From: xvzc Date: Wed, 22 Apr 2026 01:05:47 +0900 Subject: [PATCH 10/13] docs: add README with setup, module overview, and usage examples --- README.md | 211 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..185d9b1 --- /dev/null +++ b/README.md @@ -0,0 +1,211 @@ +# bufsitter.nvim + +Treesitter-powered buffer manipulation for Neovim. + +bufsitter provides a chainable cursor API for traversing syntax trees, and a set of IO primitives for reading, writing, and transforming buffer content — all driven by treesitter node ranges. + +## Requirements + +- Neovim >= 0.12.1 +- A treesitter parser installed for the filetype you want to work with + +## Installation + +**lazy.nvim** + +```lua +{ + "xvzc/bufsitter.nvim", + config = function() + require("bufsitter").setup() + end, +} +``` + +## Configuration + +```lua +require("bufsitter").setup({ + scratch = { + ft = "markdown", + init_contents = {}, -- string[] or fun(): string[] + on_attach = nil, -- fun(bufnr: integer) + win = { + relative = "editor", + width = 0.5, -- absolute columns, or 0–1 ratio + height = 0.7, -- absolute rows, or 0–1 ratio + min_width = nil, -- clamp width to a minimum + min_height = nil, + row = nil, -- nil = centered + col = nil, -- nil = centered + style = "minimal", + border = "rounded", + }, + }, + io = { + on_error = nil, -- fun(err: string) — global error handler for cursor ops + }, + ref = { + expand = false, -- expand paths to absolute when true + }, +}) +``` + +## Modules + +### `bufsitter.cursor` + +Lazy, chainable treesitter traversal. A cursor describes a traversal but does not touch any buffer until `:exec(bufnr)` is called. The same cursor can be reused across buffers. + +```lua +local cursor = require("bufsitter.cursor") + +-- All top-level function declarations +cursor.root():children({ types = { "function_declaration" } }):exec(bufnr) + +-- First function declaration +cursor.root():children({ types = { "function_declaration" } }):first():exec(bufnr) + +-- Filter by node text +cursor.root():children():filter(function(b, node) + return vim.treesitter.get_node_text(node, b) == "main" +end):exec(bufnr) + +-- Navigate up +cursor.root():children():first():parent():exec(bufnr) + +-- Siblings +cursor.root():children():first():next_siblings():exec(bufnr) + +-- Treesitter query +cursor.query("(function_declaration name: (identifier) @name)"):exec(bufnr) +``` + +### `bufsitter.io` + +Buffer read/write operations. Accepts either a `cursor` or an explicit `start_row`/`end_row` range. When a cursor is used, operations apply to every matched node. + +```lua +local io = require("bufsitter.io") +local cursor = require("bufsitter.cursor") + +local bufnr = vim.api.nvim_get_current_buf() +local fns = cursor.root():children({ types = { "function_declaration" } }) + +-- Read +local results = io.select(bufnr, { cursor = fns }) +-- results[1] == { "func foo() {", " ...", "}" } + +local texts = io.select_text(bufnr, { cursor = fns }) +-- texts[1] == "func foo() {\n ...\n}" + +-- Insert after each matched node +io.insert(bufnr, { "// generated" }, { cursor = fns }) + +-- Insert before +io.insert(bufnr, { "// generated" }, { cursor = fns, prepend = true }) + +-- Insert inline (no newline) +io.insert(bufnr, { " // note" }, { cursor = fns:first(), inline = true }) + +-- Delete +io.delete(bufnr, { cursor = fns:first() }) + +-- Replace +io.replace(bufnr, { "func foo() {}" }, { cursor = fns:first() }) + +-- Clear buffer +io.clear(bufnr) +``` + +**`trim_end`** (default `true`): when using a cursor, the operation endpoint is resolved to the last non-blank line of the node. Set `trim_end = false` to use the raw treesitter range instead. + +**`hook`**: transform content before it is written. + +```lua +io.insert(bufnr, { "hello" }, { + cursor = fns, + hook = function(b, lines) + return vim.tbl_map(function(l) return "-- " .. l end, lines) + end, +}) +``` + +### `bufsitter.scratch` + +Floating scratch buffer with show/hide/toggle lifecycle. + +```lua +local Scratch = require("bufsitter.scratch") + +local s = Scratch.new({ + ft = "markdown", + init_contents = { "# Notes", "" }, + on_attach = function(bufnr) + vim.keymap.set("n", "q", "close", { buffer = bufnr }) + end, +}) + +s:show() +s:hide() +s:toggle() +s:delete() + +-- Override window options at show time +s:show({ width = 0.9, height = 0.8 }) +``` + +### `bufsitter.ref` + +Generates a human-readable reference string for the current buffer or visual selection. + +```lua +local ref = require("bufsitter.ref") + +ref.buffer() -- "~/project/main.lua" +ref.buffer({ expand = true }) -- "/home/user/project/main.lua" +ref.visual_selection() -- "~/project/main.lua:L10~L15" +ref.get() -- visual_selection in visual mode, buffer otherwise +``` + +## Example: AI-assisted code editing + +A common pattern is combining `scratch`, `cursor`, `io`, and `ref` to build a lightweight AI editing workflow. + +```lua +local Scratch = require("bufsitter.scratch") +local cursor = require("bufsitter.cursor") +local io = require("bufsitter.io") +local ref = require("bufsitter.ref") + +-- Open a scratch buffer as a prompt pad +local pad = Scratch.new({ + ft = "markdown", + on_attach = function(bufnr) + -- Submit the prompt on in normal mode + vim.keymap.set("n", "", function() + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + -- send lines to your AI backend of choice + end, { buffer = bufnr }) + vim.keymap.set("n", "q", "close", { buffer = bufnr }) + end, +}) + +-- Yank a reference to the selected code into the prompt pad +vim.keymap.set("v", "ar", function() + local r = ref.get() + io.insert(pad:bufnr(), { r }) + pad:show() +end) + +-- Insert AI output after the function the cursor is on +vim.keymap.set("n", "ai", function() + local bufnr = vim.api.nvim_get_current_buf() + io.insert(bufnr, { "-- TODO: generated" }, { + cursor = cursor + .root() + :children({ types = { "function_declaration" } }) + :first(), + }) +end) +``` From 62ddd62d53aa362edc747ca044f1aa9c5f03fa5b Mon Sep 17 00:00:00 2001 From: xvzc Date: Wed, 22 Apr 2026 01:35:33 +0900 Subject: [PATCH 11/13] feat: add texts filter to cursor opts and update README with usage example --- README.md | 171 ++++----------------- lua/bufsitter/cursor.lua | 41 +++-- tests/filetypes/markdown/markdown_spec.lua | 38 +++++ 3 files changed, 98 insertions(+), 152 deletions(-) diff --git a/README.md b/README.md index 185d9b1..d3f7580 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,17 @@ # bufsitter.nvim +> **Experimental.** APIs may change without notice. Not recommended for production use. + Treesitter-powered buffer manipulation for Neovim. bufsitter provides a chainable cursor API for traversing syntax trees, and a set of IO primitives for reading, writing, and transforming buffer content — all driven by treesitter node ranges. +> **Note:** bufsitter depends on [nvim-treesitter](https://github.com/nvim-treesitter/nvim-treesitter), which is currently archived. Future Neovim versions may introduce breaking changes that affect stability. + ## Requirements - Neovim >= 0.12.1 -- A treesitter parser installed for the filetype you want to work with +- [nvim-treesitter](https://github.com/nvim-treesitter/nvim-treesitter) with parsers installed for the filetypes you want to work with ## Installation @@ -16,6 +20,7 @@ bufsitter provides a chainable cursor API for traversing syntax trees, and a set ```lua { "xvzc/bufsitter.nvim", + dependencies = { "nvim-treesitter/nvim-treesitter" }, config = function() require("bufsitter").setup() end, @@ -51,161 +56,47 @@ require("bufsitter").setup({ }) ``` -## Modules - -### `bufsitter.cursor` - -Lazy, chainable treesitter traversal. A cursor describes a traversal but does not touch any buffer until `:exec(bufnr)` is called. The same cursor can be reused across buffers. - -```lua -local cursor = require("bufsitter.cursor") - --- All top-level function declarations -cursor.root():children({ types = { "function_declaration" } }):exec(bufnr) - --- First function declaration -cursor.root():children({ types = { "function_declaration" } }):first():exec(bufnr) +## Usage --- Filter by node text -cursor.root():children():filter(function(b, node) - return vim.treesitter.get_node_text(node, b) == "main" -end):exec(bufnr) +Given a markdown buffer: --- Navigate up -cursor.root():children():first():parent():exec(bufnr) +```markdown +# Shopping List --- Siblings -cursor.root():children():first():next_siblings():exec(bufnr) +- apples +- oranges --- Treesitter query -cursor.query("(function_declaration name: (identifier) @name)"):exec(bufnr) +# Todo ``` -### `bufsitter.io` - -Buffer read/write operations. Accepts either a `cursor` or an explicit `start_row`/`end_row` range. When a cursor is used, operations apply to every matched node. +Insert a new item into the `Shopping List` section by matching the heading text: ```lua -local io = require("bufsitter.io") local cursor = require("bufsitter.cursor") +local io = require("bufsitter.io") local bufnr = vim.api.nvim_get_current_buf() -local fns = cursor.root():children({ types = { "function_declaration" } }) - --- Read -local results = io.select(bufnr, { cursor = fns }) --- results[1] == { "func foo() {", " ...", "}" } - -local texts = io.select_text(bufnr, { cursor = fns }) --- texts[1] == "func foo() {\n ...\n}" - --- Insert after each matched node -io.insert(bufnr, { "// generated" }, { cursor = fns }) - --- Insert before -io.insert(bufnr, { "// generated" }, { cursor = fns, prepend = true }) - --- Insert inline (no newline) -io.insert(bufnr, { " // note" }, { cursor = fns:first(), inline = true }) - --- Delete -io.delete(bufnr, { cursor = fns:first() }) - --- Replace -io.replace(bufnr, { "func foo() {}" }, { cursor = fns:first() }) - --- Clear buffer -io.clear(bufnr) -``` - -**`trim_end`** (default `true`): when using a cursor, the operation endpoint is resolved to the last non-blank line of the node. Set `trim_end = false` to use the raw treesitter range instead. - -**`hook`**: transform content before it is written. -```lua -io.insert(bufnr, { "hello" }, { - cursor = fns, - hook = function(b, lines) - return vim.tbl_map(function(l) return "-- " .. l end, lines) - end, -}) -``` - -### `bufsitter.scratch` - -Floating scratch buffer with show/hide/toggle lifecycle. - -```lua -local Scratch = require("bufsitter.scratch") - -local s = Scratch.new({ - ft = "markdown", - init_contents = { "# Notes", "" }, - on_attach = function(bufnr) - vim.keymap.set("n", "q", "close", { buffer = bufnr }) - end, +io.insert(bufnr, { "- milk" }, { + cursor = cursor + .root() + :children({ types = { "section" } }) + :children({ types = { "atx_heading" } }) + :children({ names = { "heading_content" }, texts = { "Shopping List" } }) + :first() + :parent() + :parent(), }) - -s:show() -s:hide() -s:toggle() -s:delete() - --- Override window options at show time -s:show({ width = 0.9, height = 0.8 }) ``` -### `bufsitter.ref` +Result: -Generates a human-readable reference string for the current buffer or visual selection. +```markdown +# Shopping List -```lua -local ref = require("bufsitter.ref") - -ref.buffer() -- "~/project/main.lua" -ref.buffer({ expand = true }) -- "/home/user/project/main.lua" -ref.visual_selection() -- "~/project/main.lua:L10~L15" -ref.get() -- visual_selection in visual mode, buffer otherwise -``` - -## Example: AI-assisted code editing - -A common pattern is combining `scratch`, `cursor`, `io`, and `ref` to build a lightweight AI editing workflow. - -```lua -local Scratch = require("bufsitter.scratch") -local cursor = require("bufsitter.cursor") -local io = require("bufsitter.io") -local ref = require("bufsitter.ref") - --- Open a scratch buffer as a prompt pad -local pad = Scratch.new({ - ft = "markdown", - on_attach = function(bufnr) - -- Submit the prompt on in normal mode - vim.keymap.set("n", "", function() - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - -- send lines to your AI backend of choice - end, { buffer = bufnr }) - vim.keymap.set("n", "q", "close", { buffer = bufnr }) - end, -}) +- apples +- oranges +- milk --- Yank a reference to the selected code into the prompt pad -vim.keymap.set("v", "ar", function() - local r = ref.get() - io.insert(pad:bufnr(), { r }) - pad:show() -end) - --- Insert AI output after the function the cursor is on -vim.keymap.set("n", "ai", function() - local bufnr = vim.api.nvim_get_current_buf() - io.insert(bufnr, { "-- TODO: generated" }, { - cursor = cursor - .root() - :children({ types = { "function_declaration" } }) - :first(), - }) -end) +# Todo ``` diff --git a/lua/bufsitter/cursor.lua b/lua/bufsitter/cursor.lua index c9c9514..9ea7d87 100644 --- a/lua/bufsitter/cursor.lua +++ b/lua/bufsitter/cursor.lua @@ -23,6 +23,7 @@ ---@class bufsitter.cursor.opts ---@field names? string[] ---@field types? string[] +---@field texts? string[] ---@alias bufsitter.cursor.fn fun(bufnr: integer, node: TSNode): boolean @@ -73,6 +74,19 @@ local function type_matches(node, types) return false end +local function text_matches(node, bufnr, texts) + if not texts or #texts == 0 then + return true + end + local text = vim.treesitter.get_node_text(node, bufnr) + for _, t in ipairs(texts) do + if text == t then + return true + end + end + return false +end + local function node_in_field(node, parent, name) for _, f in ipairs(parent:field(name)) do if f == node then @@ -82,8 +96,8 @@ local function node_in_field(node, parent, name) return false end --- collect named children of `parent`, filtered by opts (names AND types) -local function collect_children(parent, opts) +-- collect named children of `parent`, filtered by opts (names AND types AND texts) +local function collect_children(parent, opts, bufnr) local candidates = {} if opts and opts.names and #opts.names > 0 then local seen = {} @@ -100,26 +114,29 @@ local function collect_children(parent, opts) table.insert(candidates, parent:named_child(i)) end end - if not opts or not opts.types or #opts.types == 0 then + if not opts or (not opts.types and not opts.texts) then return candidates end local result = {} for _, child in ipairs(candidates) do - if type_matches(child, opts.types) then + if type_matches(child, opts.types) and text_matches(child, bufnr, opts.texts) then table.insert(result, child) end end return result end --- check if `node` matches opts, given its `parent` for field-name checking (names AND types) -local function node_matches(node, parent, opts) +-- check if `node` matches opts, given its `parent` for field-name checking (names AND types AND texts) +local function node_matches(node, parent, opts, bufnr) if not opts then return true end if not type_matches(node, opts.types) then return false end + if not text_matches(node, bufnr, opts.texts) then + return false + end if opts.names and #opts.names > 0 then if not parent then return false @@ -168,7 +185,7 @@ function Base:children(opts) return new_multi(function(bufnr) local result = {} for _, node in ipairs(prev._exec(bufnr)) do - for _, child in ipairs(collect_children(node, opts)) do + for _, child in ipairs(collect_children(node, opts, bufnr)) do table.insert(result, child) end end @@ -232,7 +249,7 @@ function Multi:parents(opts) local result = {} for _, node in ipairs(prev._exec(bufnr)) do local p = node:parent() - if p and node_matches(p, p:parent(), opts) then + if p and node_matches(p, p:parent(), opts, bufnr) then table.insert(result, p) end end @@ -361,7 +378,7 @@ function Single:parent(opts) local result = {} for _, node in ipairs(prev._exec(bufnr)) do local p = node:parent() - if p and node_matches(p, p:parent(), opts) then + if p and node_matches(p, p:parent(), opts, bufnr) then table.insert(result, p) end end @@ -385,7 +402,7 @@ function Single:siblings(opts) for _, node in ipairs(prev._exec(bufnr)) do local p = node:parent() if p then - for _, sib in ipairs(collect_children(p, opts)) do + for _, sib in ipairs(collect_children(p, opts, bufnr)) do if sib ~= node then table.insert(result, sib) end @@ -414,7 +431,7 @@ function Single:next_siblings(opts) local p = node:parent() local sib = node:next_named_sibling() while sib ~= nil do - if node_matches(sib, p, opts) then + if node_matches(sib, p, opts, bufnr) then table.insert(result, sib) end sib = sib:next_named_sibling() @@ -442,7 +459,7 @@ function Single:prev_siblings(opts) local p = node:parent() local sib = node:prev_named_sibling() while sib ~= nil do - if node_matches(sib, p, opts) then + if node_matches(sib, p, opts, bufnr) then table.insert(result, sib) end sib = sib:prev_named_sibling() diff --git a/tests/filetypes/markdown/markdown_spec.lua b/tests/filetypes/markdown/markdown_spec.lua index 4ea5067..ae20e0b 100644 --- a/tests/filetypes/markdown/markdown_spec.lua +++ b/tests/filetypes/markdown/markdown_spec.lua @@ -263,4 +263,42 @@ describe("ft.markdown", function() assert.are.same(true, has_usage) end) end) + + describe("usage example", function() + it("inserts a list item into a section matched by heading text", function() + local input = { + "# Shopping List", + "", + "- apples", + "- oranges", + "", + "# Todo", + } + local expected = { + "# Shopping List", + "", + "- apples", + "- oranges", + "- milk", + "", + "# Todo", + } + + local example_bufnr = h.make_buf(input, "markdown") + + io.insert(example_bufnr, { "- milk" }, { + cursor = cursor + .root() + :children({ types = { "section" } }) + :children({ types = { "atx_heading" } }) + :children({ types = { "inline" }, texts = { "Shopping List" } }) + :first() + :parent() + :parent(), + }) + + local actual = vim.api.nvim_buf_get_lines(example_bufnr, 0, -1, false) + assert.are.same(expected, actual) + end) + end) end) From 266c9e9509c7c7db3333f25bc1d5980aacc146ef Mon Sep 17 00:00:00 2001 From: xvzc Date: Wed, 22 Apr 2026 01:41:20 +0900 Subject: [PATCH 12/13] feat: add texts filter to cursor opts and update README usage example --- README.md | 3 ++- tests/filetypes/markdown/markdown_spec.lua | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d3f7580..a079ac3 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,8 @@ io.insert(bufnr, { "- milk" }, { :children({ names = { "heading_content" }, texts = { "Shopping List" } }) :first() :parent() - :parent(), + :siblings({ types = { "list" } }) + :last(), }) ``` diff --git a/tests/filetypes/markdown/markdown_spec.lua b/tests/filetypes/markdown/markdown_spec.lua index ae20e0b..76818b5 100644 --- a/tests/filetypes/markdown/markdown_spec.lua +++ b/tests/filetypes/markdown/markdown_spec.lua @@ -291,10 +291,11 @@ describe("ft.markdown", function() .root() :children({ types = { "section" } }) :children({ types = { "atx_heading" } }) - :children({ types = { "inline" }, texts = { "Shopping List" } }) + :children({ names = { "heading_content" }, texts = { "Shopping List" } }) :first() :parent() - :parent(), + :siblings({ types = { "list" } }) + :last(), }) local actual = vim.api.nvim_buf_get_lines(example_bufnr, 0, -1, false) From 92145b38932f8573dcdb72f93474dbe10b686211 Mon Sep 17 00:00:00 2001 From: xvzc Date: Wed, 22 Apr 2026 01:42:55 +0900 Subject: [PATCH 13/13] docs: regenerate helpdoc --- doc/bufsitter.nvim.txt | 43 +++++++++++++++++++++++++++++++----------- doc/tags | 1 + 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/doc/bufsitter.nvim.txt b/doc/bufsitter.nvim.txt index 0f9ef9b..4bb0768 100644 --- a/doc/bufsitter.nvim.txt +++ b/doc/bufsitter.nvim.txt @@ -17,13 +17,27 @@ bufsitter.nvim *bufsitter* require("bufsitter").setup() < +bufsitter.config.scratch.win.opts *bufsitter.config.scratch.win.opts* + + Fields: ~ + {relative?} (string) + {width?} (number) Width in columns, or a ratio 0–1 relative to editor width + {height?} (number) Height in rows, or a ratio 0–1 relative to editor height + {min_width?} (number) Minimum width in columns, or a ratio 0–1 relative to editor width + {min_height?} (number) Minimum height in rows, or a ratio 0–1 relative to editor height + {row?} (number) Top row, or a ratio 0–1 relative to editor height (centered when omitted) + {col?} (number) Left column, or a ratio 0–1 relative to editor width (centered when omitted) + {style?} (string) + {border?} (string) + + bufsitter.config.scratch.opts *bufsitter.config.scratch.opts* Fields: ~ {ft?} (string) {init_contents?} (string[]|fun():string[]) {on_attach?} (fun(bufnr:integer)) - {win?} (vim.api.keyset.win_config) + {win?} (bufsitter.config.scratch.win.opts) bufsitter.config.ref.opts *bufsitter.config.ref.opts* @@ -105,6 +119,7 @@ bufsitter.cursor.opts *bufsitter.cursor.opts* Fields: ~ {names?} (string[]) {types?} (string[]) + {texts?} (string[]) bufsitter.cursor.fn *bufsitter.cursor.fn* @@ -201,6 +216,7 @@ bufsitter.io.delete.opts *bufsitter.io.delete.opts* {end_row?} (integer) {end_col?} (integer) {on_error?} (fun(err:string)) + {trim_end?} (boolean) Strip trailing blank lines from the node end when using cursor (default true) bufsitter.io.replace.opts *bufsitter.io.replace.opts* @@ -466,13 +482,15 @@ Initial content can be provided as a string array or a function, and an bufsitter.scratch.win.opts *bufsitter.scratch.win.opts* Fields: ~ - {relative?} (string) - {width?} (integer) - {height?} (integer) - {row?} (integer) - {col?} (integer) - {style?} (string) - {border?} (string) + {relative?} (string) + {width?} (number) Width in columns, or a ratio 0–1 relative to editor width + {height?} (number) Height in rows, or a ratio 0–1 relative to editor height + {min_width?} (number) Minimum width in columns, or a ratio 0–1 relative to editor width + {min_height?} (number) Minimum height in rows, or a ratio 0–1 relative to editor height + {row?} (number) Top row, or a ratio 0–1 relative to editor height (centered when omitted) + {col?} (number) Left column, or a ratio 0–1 relative to editor width (centered when omitted) + {style?} (string) + {border?} (string) bufsitter.scratch.opts *bufsitter.scratch.opts* @@ -560,8 +578,10 @@ Scratch:is_visible() *bufsitter.scratch:is_visible* Scratch:show({win_opts?}) *bufsitter.scratch:show* - Opens the floating window. If it is already visible, reattaches the buffer - to the existing window. Returns the window id, or nil if the buffer is invalid. + Opens the floating window. All of `width`, `height`, `row`, and `col` accept + either an absolute integer or a 0–1 ratio relative to the editor size. + `row` and `col` default to centered when omitted. + Returns the window id, or nil if the buffer is invalid. Parameters: ~ {win_opts?} (bufsitter.scratch.win.opts) @@ -574,7 +594,8 @@ Scratch:show({win_opts?}) *bufsitter.scratch:show* local Scratch = require("bufsitter.scratch") local s = Scratch.new() s:show() - s:show({ width = 100, height = 30 }) + s:show({ width = 0.8, height = 0.6 }) + s:show({ width = 0.8, height = 0.6, row = 0.1, col = 0.1 }) < diff --git a/doc/tags b/doc/tags index db3c88c..0ce00fe 100644 --- a/doc/tags +++ b/doc/tags @@ -9,6 +9,7 @@ bufsitter.config.io.opts bufsitter.nvim.txt /*bufsitter.config.io.opts* bufsitter.config.opts bufsitter.nvim.txt /*bufsitter.config.opts* bufsitter.config.ref.opts bufsitter.nvim.txt /*bufsitter.config.ref.opts* bufsitter.config.scratch.opts bufsitter.nvim.txt /*bufsitter.config.scratch.opts* +bufsitter.config.scratch.win.opts bufsitter.nvim.txt /*bufsitter.config.scratch.win.opts* bufsitter.contents bufsitter.nvim.txt /*bufsitter.contents* bufsitter.cursor bufsitter.nvim.txt /*bufsitter.cursor* bufsitter.cursor.fn bufsitter.nvim.txt /*bufsitter.cursor.fn*