Compare commits

..

9 Commits

14 changed files with 3948 additions and 162 deletions
Generated
+35
View File
@@ -180,6 +180,12 @@ dependencies = [
"libc",
]
[[package]]
name = "critical-section"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "crossbeam-deque"
version = "0.8.8"
@@ -804,6 +810,12 @@ dependencies = [
"libc",
]
[[package]]
name = "numtoa"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6aa2c4e539b869820a2b82e1aef6ff40aa85e65decdd5185e83fb4b1249cd00f"
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -1053,6 +1065,7 @@ dependencies = [
"ratatui-core",
"ratatui-crossterm",
"ratatui-termina",
"ratatui-termion",
"ratatui-termwiz",
"ratatui-widgets",
"serde",
@@ -1066,6 +1079,7 @@ checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c"
dependencies = [
"bitflags 2.13.2",
"compact_str",
"critical-section",
"hashbrown 0.17.1",
"itertools",
"kasuari",
@@ -1102,6 +1116,17 @@ dependencies = [
"termina",
]
[[package]]
name = "ratatui-termion"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87c732202fa5a71a9da0991013f0853e53f87048f45198e3a1ca3ee722accc2f"
dependencies = [
"instability",
"ratatui-core",
"termion",
]
[[package]]
name = "ratatui-termwiz"
version = "0.1.2"
@@ -1445,6 +1470,16 @@ dependencies = [
"phf_codegen",
]
[[package]]
name = "termion"
version = "4.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f44138a9ae08f0f502f24104d82517ef4da7330c35acd638f1f29d3cd5475ecb"
dependencies = [
"libc",
"numtoa",
]
[[package]]
name = "termios"
version = "0.3.3"
+1 -1
View File
@@ -6,7 +6,7 @@ edition = "2024"
[dependencies]
crossterm = "0.29"
ignore = "0.4"
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "underline-color"] }
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "underline-color", "unstable-backend-writer"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tree-sitter = "0.27.1"
+14 -2
View File
@@ -12,6 +12,7 @@ One crate; no workspace or terminal-coordinate types in the editing backend:
- `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations.
- `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets.
- `syntax`: owns Tree-sitter grammar detection, per-buffer incremental trees, bundled highlight/local/injection queries, and syntax byte spans; it has no terminal color/geometry or editing dependency.
- `markdown`: owns native Markdown link/reference resolution, local destination decoding, and heading fragment lookup through the installed block/inline grammars. It has no editor lifecycle, terminal, LSP, or external-process dependency.
- `plugin`: owns generic document snapshots/events, zero-based grapheme positions, diagnostics, frontend-independent semantic token kinds/modifiers, and versioned asynchronous update contracts. Semantic token updates are native-only, not part of the Ruby wire protocol; it has no LSP protocol types.
- `lsp`: owns opt-in native server workers, bounded framed JSON-RPC, synchronization/capability negotiation, settings, UTF-16/file-URI conversion, definition/hover results, and semantic token requests/legend decoding. Its private configuration resolver supplies presets without restricting language IDs.
- `session`: `EditorSession` owns the editor, native LSP, and Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, tracks document open/change/save/close events, polls native and plugin results, validates versioned diagnostics/navigation, and retires failed plugin ownership.
@@ -23,22 +24,33 @@ One crate; no workspace or terminal-coordinate types in the editing backend:
Frontends drive `EditorSession::handle` or `execute`, periodically call `poll()` even without input, and inspect `editor()`; there is no mutable editor getter that could independently alter plugin registration. `poll()` reports whether background activity requires redrawing. Pasted text uses `Input::Paste(String)` through the same input seam as keys. Plugin effects are applied internally so a rejected result also retires its owner; there is no generic frontend effects entry point.
Frontends inspect `selections()` for document-ordered, end-exclusive Visual ranges of grapheme coordinates (empty outside Visual), `carets()` for all active cursors in document order, `cursor()` for the primary, and `selection_count()` for the total target count. A range ending at the next row's column 0 includes the previous row's newline; an empty final-row range contains no text. Selection normalization, clipboard contents, edit planning, and history remain inside the editor module.
The TUI also observes the editor's logical caret-reveal generation. Core motion/navigation dispatch advances it even when the resulting position is unchanged, so a detached viewport can resume following without duplicating key-binding policy or changing text revisions and LSP freshness.
Frontends inspect `palette()` for the optional read-only query, filtered command entries, selected index, effective bindings, and eligibility. Palette input handling and acceptance stay in the editor; registered command execution stays in the session.
Frontends use cached `selection_navigation_hint()` for command lists and `file_navigation_hint()` for the active file view/context, without reconstructing input policy.
Frontends inspect `count()` for the optional pending motion prefix; parsing and counted motion dispatch remain editor-owned.
Frontends inspect `leader_pending()` for the cached display of a pending leader sequence; prefix recognition, cancellation, and dispatch remain editor-owned.
Frontends inspect `g_pending()` for the built-in Normal `gf` prefix. Markdown following preflights links and headings before buffer/caret mutation; an unloaded target is validated/prepared once, while loaded targets use their current unsaved text. File identity and activation stay editor-owned.
Editor-owned jump history is a bounded 100-location `VecDeque` of buffer IDs and grapheme cursors plus a current index, with no copied paths/text. Successful high-level navigation records departure/destination once; traversal uses unrecorded buffer activation and position placement, skips closed/clamped-duplicate locations, and truncates forward entries only on a distinct new jump. Session synchronization permanently invalidates pending LSP position requests after their source buffer/revision/caret/mode is left, so returning through history cannot revive an old definition reply.
Frontends inspect `buffer_id()`, `buffer_count()`, `has_buffer(id)`, and the ascending-ID `buffers()` iterator of borrowed path/dirty/active metadata. Existing text, cursor, selection, search, and mode methods describe only the active buffer.
Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned.
Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by core LSP or a plugin and buffer revision; text changes clear them. Native server retirement removes only that server's contribution, preserving sibling diagnostics. The TUI underlines annotated text, colors affected gutter numbers by severity, and displays the primary caret's diagnostic message without replacing search/selection backgrounds.
Frontends borrow `semantic_tokens()` for sorted, single-line grapheme spans from the active buffer. Immutable `Arc` slices are shared by native LSP and buffer storage; painting does not copy tokens. Text/path changes discard overlays, and native updates validate the whole applicable annotation batch before mutation. The TUI merges semantic styling into its existing glyph pass above syntax foregrounds but below diagnostic/search/selection/caret styling.
Frontends inspect `hover()` for optional read-only content and logical-line/grapheme scroll offsets. Popup request eligibility, dismissal, and scrolling stay editor/session-owned; the TUI clips the panel inside the document workspace without changing the primary cursor or viewport.
Frontends inspect `hover()` for optional read-only content and logical-line/grapheme scroll offsets. Popup request eligibility, dismissal, and scrolling stay editor/session-owned; the TUI clips the panel inside the document workspace without changing the primary cursor or viewport. Its hover-local syntax cache treats content as Markdown, including named fenced-code injections, and rebuilds only when content changes. The popup shares the document syntax palette and preserves raw Markdown text and logical scroll coordinates.
## Terminal lifecycle and layout
The terminal adapter owns raw mode, alternate-screen state, bracketed paste, and its panic hook for one session lifetime. Entry failure, normal return, I/O errors, and unwinding panics restore the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while that session is active.
The terminal adapter owns raw mode, alternate-screen state, bracketed paste, mouse capture, and its panic hook for one session lifetime. Entry failure, normal return, I/O errors, and unwinding panics restore the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while that session is active.
Redraws stage Ratatui's buffer diff, cursor placement, and DEC synchronized-update commands (mode 2026) in a reusable byte buffer, then write and flush the complete frame to the terminal. Frame computation never suspends terminal presentation; synchronization covers transmitted painting rather than layout/highlighting work. The hardware cursor is hidden before painting even when synchronization is unsupported. Terminal cleanup releases synchronization before leaving the alternate screen or invoking caller panic diagnostics. Backend-writer access is enabled solely for this staging seam.
The TUI uses Ratatui constraint-based layout for a flexible buffer viewport, a one-row status bar, and a one-row prompt/message area. Scrolling, clipping, highlights, and cursor placement use the allocated regions, not terminal-wide offsets. Below three rows the status bar is hidden; at one row the buffer remains visible unless an active command/search prompt takes priority. Zero-sized terminals render nothing. Layout and terminal-cell text mapping remain frontend-owned.
Mouse wheel input uses the last rendered document/panel rectangles for hit-testing. Document offsets and explorer scroll state remain frontend-owned; three-row wheel steps never become document cursor motions. Manual document views persist until logical caret-reveal, caret/selection, mode, revision, or buffer changes; offscreen primary and browsing-selection cursors are not placed. Hover wheel input reuses the existing editor-owned popup scrolling.
Already-ready mouse reports share a redraw: at most 64 reports are applied in their original order before painting, without dropping or summing away wheel steps. Readiness checks do not wait. The first non-mouse event is retained for the next iteration after rendering, so keys, paste, and resizes cannot reuse geometry from before their layout changes. The batch bound keeps continuous mouse input from starving drawing or background polling.
Ratatui's bounded layout cache is enabled: unchanged geometry and constraints reuse solved rectangles during scrolling; resizes and panel-layout changes use their own cache keys.
The buffer viewport includes a fixed left gutter with right-aligned hybrid line numbers: the highlighted primary cursor line shows its one-based absolute number; every other line shows its distance from the primary. Secondary carets do not establish separate numbering origins. Gutter width follows the total line count plus one separator cell, including an empty final row after a trailing newline. Horizontal scrolling affects only text; search/selection highlights and carets never paint over the gutter. Rows beyond the buffer have no number. The gutter is hidden when it would leave no text column.
## Syntax caches
+8 -2
View File
@@ -55,6 +55,10 @@ Key notation follows [Herdr's readable style](https://herdr.dev/docs/configurati
`ctrl+i` aliases `tab`, and `ctrl+m` aliases `enter`, including inside leader sequences. Ctrl+h and Ctrl+j are distinct control keys, not Backspace/Enter aliases. Only a single modifier on an ASCII letter is supported. Arbitrary non-leader key sequences, Alt/Cmd/Super, combined modifiers, modified special keys, and Vim-style angle-bracket notation are rejected rather than silently treated as another key.
Normal mode binds `ctrl+o` to `config.core.jump_back` and `ctrl+i`/`tab` to `config.core.jump_forward`. Insert-mode Tab remains literal text. Override or disable these keys with the ordinary per-mode binding interface; both jump actions also appear in the command palette.
The built-in Normal `gf` sequence follows Markdown file links. It is not a configurable general sequence prefix: bind `config.core.follow_link` to a single key or leader sequence to add another route. A configured or disabled Normal `g`, or choosing `g` as the leader, overrides the built-in prefix.
## Action reference
| Available modes | Methods on `config.core` |
@@ -62,6 +66,8 @@ Key notation follows [Herdr's readable style](https://herdr.dev/docs/configurati
| Normal | `insert`, `append`, `open_below`, `open_above` |
| Normal, Visual | `insert_first_nonblank`, `append_line_end`, `clone_above`, `clone_below` |
| Normal | `join_lines`, `undo`, `redo`, `command` |
| Normal | `follow_link` |
| Normal | `jump_back`, `jump_forward` |
| Normal | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` |
| Normal | `search`, `paste_before`, `paste_after` |
| Normal | `paste_system_before`, `paste_system_after` |
@@ -193,10 +199,10 @@ Diagnostics appear as severity-colored gutter numbers and text underlines; the p
`:lsp.hover` or a configured hover action requests information at the primary caret once, regardless of count or selection count. The response opens a read-only floating popup: arrows/Ctrl+h/j/k/l scroll vertically/horizontally, Home/End select the first/last logical line, and Escape dismisses without collapsing carets. Other input dismisses and continues normal editing. Changes to the source buffer/revision/caret, intervening input, or newer requests prevent pending hover replies from reopening it.
Hover accepts plaintext/Markdown MarkupContent and string/code MarkedString forms, including arrays. Multiline content is preserved; Markdown is shown as plain text, not rendered markup. Horizontal scrolling exposes long lines without splitting graphemes. Missing/unsupported/invalid hover results report a message without changing text, selections, or undo history. Content is bounded to 4 MiB and terminal controls are sanitized.
Hover accepts plaintext/Markdown MarkupContent and string/code MarkedString forms, including arrays. The popup grows up to 100 columns and 24 rows, clamped to the available document workspace and content height. Multiline content and Markdown markers remain visible, with the same Markdown syntax colors as document buffers; named fenced code blocks use their language's bundled highlighter. Highlighting is cached across scrolling and redraws. Horizontal scrolling exposes long lines without splitting graphemes. Missing/unsupported/invalid hover results report a message without changing text, selections, or undo history. Content is bounded to 4 MiB and terminal controls are sanitized.
Semantic highlighting is **enabled automatically** when the first server advertises [LSP semantic tokens](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_semanticTokens). No additional configuration or binding is needed. Full-document requests are preferred; range-only servers receive a whole-document range request. Token colors override Tree-sitter where recognized; unsupported servers, unknown custom token types, missing tokens, and failed replies retain syntax highlighting. Text/path changes clear old semantic colors immediately, and stale replies cannot restore them. Same-text refreshes keep the current overlay until the replacement arrives.
rust-analyzer and ty support semantic tokens. Solargraph 0.61.0 and Ruff do not; Ruby therefore retains Tree-sitter colors with the Solargraph preset, while Python uses ty's tokens and Ruff's diagnostics. Declaration/definition modifiers use bold, deprecated symbols use strikethrough, and readonly variables/properties use yellow. Diagnostics, search, selections, and carets retain their existing styling precedence.
rust-analyzer and ty support semantic tokens. Solargraph 0.61.0 and Ruff do not; Ruby therefore retains Tree-sitter colors with the Solargraph preset, while Python uses ty's tokens and Ruff's diagnostics. Declaration/definition modifiers use bold, deprecated symbols use strikethrough, and readonly variables/parameters/properties use italics without changing their role's color. Diagnostics, search, selections, and carets retain their existing styling precedence.
Native LSP currently provides synchronization, diagnostics, go-to-definition, hover, and semantic highlighting—not completion, rename, formatting, code actions, or workspace edits. Semantic token deltas, dynamic registration, and partial-result streaming are not implemented.
+10 -3
View File
@@ -33,17 +33,24 @@ Rust embeds the Ruby implementation at compile time: [`ruby_values.rb`](../src/r
Rust tests live alongside their modules; native LSP integration checks are in [`tests/lsp.rs`](../tests/lsp.rs).
- Editor checks cover Unicode editing, multi-target state, undo/redo, saving failures, and modal paste acceptance and rejection atomicity.
- Markdown navigation checks cover inline/reference destinations, escaped/percent-encoded paths, heading markup and duplicates, same-document fragments, dirty target reuse, failed navigation atomicity, primary-only following, and `gf` prefix cancellation/configuration overrides.
- Jump-history checks cover cross-buffer/same-document navigation, search and picker recording, counts and repeated visits, bounded history, branch truncation, closed buffers, edited-coordinate clamping, dirty text/undo preservation, Ctrl+i/Tab aliases, and custom bindings. Real LSP fixtures cover definition round-trips and delayed replies after leaving and returning to the same source location.
- Session and Ruby checks exercise real Ruby execution, command/capability ownership, effect validation, configuration fallback, plugin retirement, crash/timeout recovery, and inherited-pipe cleanup.
- Unix terminal-hook checks use isolated child processes and real PTYs to keep process-global hooks out of the parallel runner. They cover cleanup on entry failure, normal return, I/O errors, and panics, including original panic-payload propagation.
- Unix terminal-hook checks use isolated child processes and real PTYs to keep process-global hooks out of the parallel runner. They cover raw/alternate-screen/mouse-capture cleanup on entry failure, normal return, I/O errors, and panics during frame construction, including original panic-payload propagation.
- Redraw checks inspect actual Crossterm output during repeated scrolling: supporting terminals receive synchronized frames, terminals ignoring synchronization never paint with a visible hardware cursor, and the final caret remains visible. A protocol-state regression verifies that the terminal is not left in synchronized-output mode while the next frame is computed.
- Wheel checks render document/panel views and cover three-row scrolling, unchanged carets/selections/text, first/last-page clamping, offscreen cursor hiding, no-op/custom-bound navigation reveal, manual persistence through idle/resize/palette updates, and floating/unfocused-docked explorer selection and focus preservation. Queued-input checks verify that the next frame shows ordered wheel steps without crossing a key or resize boundary.
- Native LSP integration checks launch framed-protocol fixture subprocesses, covering initialization/settings, opt-in startup, synchronization, UTF-16/file-URI conversion, dirty targets, stale requests/diagnostics, and transport failures. Stack checks cover independent initialization, diagnostic merging/clearing, sibling failure isolation in active/parked buffers, same-revision rename/close identity, aggregate limits, first-server position routing, and both shutdowns. Semantic ordering uses protocol/file gates; elapsed deadlines only bound stuck subprocesses.
- Hover regression checks cover content forms, sanitization, cancelled/superseded replies, invalid results, scrolling/dismissal, Unicode clipping, and preserved editing state.
- Semantic-token checks cover full/range requests, legend/modifier remapping, UTF-16 boundaries and grapheme rounding, refresh/change coalescing, stale rename/close replies, null/empty/error fallback, primary/sibling retirement, and rendered cell precedence with syntax, diagnostics, search, selections, and carets.
- Hover regression checks cover content forms, sanitization, cancelled/superseded replies, invalid results, scrolling/dismissal, Unicode clipping, and preserved editing state. Rendered checks cover longer/wider documentation visibility, Markdown heading and fenced-code colors, scrolled color coordinates, and replacement content without stale highlighting.
- Semantic-token checks cover full/range requests, legend/modifier remapping, UTF-16 boundaries and grapheme rounding, ASCII tab and Unicode column bounds, refresh/change coalescing, stale rename/close replies, null/empty/error fallback, primary/sibling retirement, and rendered cell precedence with syntax, diagnostics, search, selections, and carets.
Clipboard checks use isolated providers rather than reading or overwriting the desktop clipboard.
## Performance and process limits
- Undo uses full-buffer snapshots and movement scans the current line. This favors straightforward correctness over large-file performance.
- Rapid-wheel verification must replay bursts against the running editor and measure arrival at the final viewport after input stops, not just individual frame duration. Cheap frames can still accumulate a visible backlog if each queued report requires its own redraw.
- PTY throughput alone cannot prove terminal presentation stays smooth: capture the editor and terminal while the user reproduces a pause. Synchronized-output commands must not reach the terminal during frame computation, even when individual frames and queued-input catch-up appear fast.
- Annotation coordinate validation fast-paths ASCII grapheme lengths using byte lengths with CRLF pair accounting, retaining Unicode segmentation for non-ASCII text. Validation remains atomic and still checks every token; large Unicode buffers or diagnostic batches can require foreground work.
- Legacy synchronous plugin invocation may pause input for up to 500 ms before a host timeout. Async plugins opt into background replies; pending requests and transport queues are bounded. Config/plugin startup has a 2-second deadline per host. Buffer context/document snapshots must fit within a 4 MiB JSON frame; a too-large frame disables that plugin, not editing.
See [syntax caches](architecture.md#syntax-caches) for the current full-tree highlighting ceiling.
+36 -2
View File
@@ -16,6 +16,8 @@ Default controls and editing behavior. For custom bindings, see [configuration](
| Normal, Visual | `I` (Shift+i) / `A` (Shift+a) insert at first nonblank / line end; Visual splits each touched row into a caret first |
| Normal | `h`/`j`/`k`/`l` or arrows move every caret; `0`/`$` or Home/End |
| Normal | `w`/`b`/`e` move every caret to next word start, previous/current word start, word end; `W`/`B`/`E` for whitespace-delimited WORDs |
| Normal | `gf` follows a Markdown file link or heading fragment at the primary caret |
| Normal | Ctrl+o goes to an older jump location; Ctrl+i/Tab goes forward; counts select multiple locations |
| Normal, Visual | Decimal prefixes repeat cursor/word/search motions: `5j`, `3k`, `2w`, `4e`, `2n`; Esc cancels a pending count |
| Normal | `J` (Shift+j) joins each targeted line with the next line |
| Normal | `x`/Delete delete; `u` undo; `U` (Shift+u) redo; `:` enter command mode |
@@ -105,9 +107,15 @@ Commands: `:w [path]`, `:q`, `:q!`, `:wq [path]`, `:plugins`, and `:plugin-comma
Paste is backend input: it is ignored in Normal, Command, Search, and Visual modes, and inserts literal text in Insert mode, with CRLF/CR normalization and a 4 MiB limit. Pasted text is one undo action, separate from typing before and after it. Rejected paste reports an error without changing text, cursor, mode, or pending insert history. Internal clipboard actions are separate from `Input::Paste` and use already-validated buffer text.
## Mouse wheel scrolling
Mouse-wheel up/down scrolls the document view three rows per event without moving the primary or secondary carets, changing selections, or editing text. The caret may scroll offscreen; its terminal cursor is hidden until visible again. Navigation (including a motion blocked at the document boundary), editing, or buffer navigation restores caret-following. Idle updates and resizing preserve a wheel-scrolled view, clamped to the document.
Wheel input over a hover popup or floating/docked file explorer scrolls that panel instead. Explorer scrolling preserves the selected file, filter, and keyboard focus; keyboard input resumes selection-following. An offscreen selected entry has no browsing cursor, while the filter input keeps its own cursor. Command palettes and startup notices consume wheel input without scrolling the background. Mouse clicks and dragging are not editing actions.
## Buffers
One buffer is visible at a time. Each retains its text, path, saved state, directed selections and primary, mode, search state, and undo/redo history. The TUI retains separate vertical/horizontal scroll offsets by stable buffer ID, adjusting them only as needed to keep the primary visible after resizing. Clipboard fragments, bindings, plugin hosts, and plugin retirement remain session-wide. Plugin context and effects refer to the active buffer.
One buffer is visible at a time. Each retains its text, path, saved state, directed selections and primary, mode, search state, and undo/redo history. The TUI retains separate vertical/horizontal scroll offsets by stable buffer ID. Caret-following adjusts them as needed; mouse-wheel scrolling can temporarily detach the view from the caret. Clipboard fragments, bindings, plugin hosts, and plugin retirement remain session-wide. Plugin context and effects refer to the active buffer.
| Command | Behavior |
| --- | --- |
@@ -170,6 +178,32 @@ Esc or Ctrl+p cancels without changing text, selections, the previous command/se
Space begins a leader sequence in Normal/Visual by default. Press the suffix keys in order; nested prefixes wait without a timeout. The status bar shows the pending sequence. Esc cancels the sequence and pending count without leaving Visual or collapsing carets. See [leader configuration](configuration.md#leader-key) for remapping and declaration rules.
## Jump history
Normal Ctrl+o moves back through previous locations; Ctrl+i moves forward. Traditional terminals encode Ctrl+i as Tab, so Normal Tab performs the same action while Insert Tab still inserts text. Counts work in either direction, such as `3` followed by Ctrl+o, and stop at the available history.
The session keeps up to 100 primary-caret locations across buffers. Successful Markdown links, accepted LSP definitions, file-picker opens, `:edit`, `:buffer`, `:bnext`/`:bprev`, and search `/`/`n`/`N` jumps record locations. Ordinary cursor/word motion, edits, failed navigation, and jumps that do not change location do not add entries. Traversal itself does not record another jump.
Moving away from a visited point before traversing saves the actual departure position for the return trip. A successful new jump after going back discards the forward branch; failed/no-op jumps preserve it. Closed buffers are skipped rather than reopened. Stored row/grapheme coordinates clamp to the current text after edits; text, dirty state, undo/redo, and file contents are unchanged by traversal. History navigation returns to Normal with one primary caret.
The typed actions are `config.core.jump_back` and `config.core.jump_forward`; custom bindings can use any supported single key or leader sequence. History is in memory for this session only.
## Markdown links
Normal `gf` or `:follow_link` follows the link under the primary caret, without an LSP. Inline links and full/collapsed/shortcut reference links are supported; reference definitions can appear elsewhere in the document. The label or destination can be under the caret. Images, code blocks, and ordinary text are not file links.
Local paths resolve relative to the source file, not the working directory; percent-encoded UTF-8 paths and Markdown-escaped punctuation are decoded. Targets can be any supported text file. Existing buffers are reused, including unsaved text and files removed from disk. Missing files, undefined references, invalid destinations, or missing headings report an error without switching buffers or moving carets.
`[Section](#section)` stays in the current document; `[Section](other.md#section)` opens the target and moves to its heading. ATX and Setext headings use GitHub-style slugs derived from visible inline text: lowercase, punctuation removed, whitespace replaced by hyphens, and duplicate suffixes `-1`, `-2`, etc. Heading lookup uses unsaved target text. Relative file links require a named source; same-document fragments also work in unnamed buffers.
External URL schemes/network paths are rejected; this action never launches a browser or shell. Numeric HTML entities and common named entities (`amp`, `lt`, `gt`, `quot`, `apos`, `nbsp` and their supported aliases) are decoded; other recognized named entities report an unsupported-entity error rather than guessing. Unicode slugs/reference matching use standard-library case conversion, not a complete Unicode case-fold/category table.
`g` waits for `f`, shows in status, and Escape cancels. Other input cancels the prefix and follows ordinary input handling. A configured Normal `g` binding overrides this built-in prefix. Custom bindings use the generated action:
```ruby
config.bind "leader f", config.core.follow_link
```
## Syntax highlighting
Buffers highlight automatically using [Tree-sitter](https://tree-sitter.github.io/tree-sitter/) and compiled-in grammar/query packages. The selected language set is Rust, Ruby, Python, JSON, Bash, Makefile, Markdown, JavaScript/JSX, TypeScript/TSX, HTML, CSS, YAML, TOML, C, C++, and Go. Detection uses filename/extension or the in-memory first-line shebang, including common special filenames such as Makefile, Gemfile, and Cargo.lock; no file reread occurs. Unsupported formats use the normal foreground. The detected language appears in status, and save-as redetects it.
@@ -178,4 +212,4 @@ Colors retain the terminal palette: comments dark gray, strings green, keywords
Markdown heading markers (`#` through `######`, or Setext underlines) share the heading text's markup color. Plain-text hashes and other punctuation keep their existing styling.
An enabled supporting LSP server supplies semantic tokens automatically, overriding syntax colors only on recognized spans. Variables/parameters use light cyan, properties blue, and readonly variables/properties yellow; declarations/definitions are bold, documentation comments italic, and deprecated symbols struck through. Tree-sitter fills gaps and remains available while new tokens are pending after edits. Search, Visual selections, diagnostics, and carets keep their existing precedence. See [native LSP configuration](configuration.md#native-lsp) for provider support and server-stack routing.
An enabled supporting LSP server supplies semantic tokens automatically, overriding syntax colors only on recognized spans. Semantic colors also use the terminal palette: namespaces neutral gray, types cyan, variables/parameters light cyan, and properties blue. Readonly variables/parameters/properties keep those role colors and add italics; declarations/definitions are bold, documentation comments italic, and deprecated symbols struck through. Italic emphasis depends on terminal support. Tree-sitter fills gaps and remains available while new tokens are pending after edits. Search, Visual selections, diagnostics, and carets keep their existing precedence. See [native LSP configuration](configuration.md#native-lsp) for provider support and server-stack routing.
+1011 -4
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -3,6 +3,7 @@ pub mod editor;
pub mod files;
pub mod lsp;
mod lsp_config;
mod markdown;
pub mod plugin;
pub mod ruby;
pub mod session;
+3
View File
@@ -17,6 +17,8 @@ Files: Space o floating tree INSERT; Space e docked tree NORMAL (right by defaul
Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
h/j/k/l move, 0/$ line ends, x delete, J join lines
w/b/e word next/previous/end, W/B/E whitespace-delimited WORDs
gf follows Markdown file links/reference links and heading fragments (no LSP)
Ctrl+o older jump location; Ctrl+i/Tab newer (counts supported; Insert Tab unchanged)
Normal/Visual: prefix motions with counts (5j, 2w, 3n); Esc cancels count
u undo, U redo, : command, / search, n/N next/previous match
Normal/Visual: . repeats last native edit at current targets (one undo group)
@@ -32,6 +34,7 @@ Search: Enter finds next literal match, Esc cancels (case-sensitive, wraps)
Commands: :w [PATH], :q, :q!, :wq [PATH], :plugins, :PLUGIN [ARGS]
Buffers: :edit PATH, :buffers, :buffer N, :bnext, :bprev, :bdelete[!] [N]
:q refuses any dirty buffer; :wq saves active and quits only if all are clean
Markdown links: :follow_link (primary caret; local files only)
Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb
Set VAMP_RUBY to choose the Ruby executable.
+707
View File
@@ -0,0 +1,707 @@
use std::collections::HashMap;
use std::path::PathBuf;
use tree_sitter::{Node, Parser, Range, Tree};
use unicode_segmentation::UnicodeSegmentation;
use crate::plugin::TextPosition;
#[derive(Debug, Eq, PartialEq)]
pub(crate) struct LinkTarget {
pub path: Option<PathBuf>,
pub fragment: Option<String>,
}
pub(crate) fn link_at(text: &str, position: TextPosition) -> Result<LinkTarget, String> {
let byte = position_byte(text, position)?;
let blocks = block_tree(text)?;
let mut node = blocks
.root_node()
.descendant_for_byte_range(byte, byte)
.ok_or_else(no_link)?;
loop {
if node.kind() == "link_reference_definition" {
return destination(child(node, "link_destination"), text);
}
if matches!(node.kind(), "inline" | "pipe_table_cell") {
break;
}
node = node.parent().ok_or_else(no_link)?;
}
let (inline, ranges) = inline_tree(text, node)?;
let mut node = inline
.root_node()
.descendant_for_byte_range(byte, byte)
.ok_or_else(no_link)?;
let mut link = None;
loop {
if !(node.start_byte() <= byte && byte < node.end_byte()) {
return Err(no_link());
}
match node.kind() {
"image" => return Err("Markdown images are not navigation links".into()),
"uri_autolink" | "email_autolink" => {
return Err("External Markdown URLs are not supported".into());
}
"inline_link"
| "full_reference_link"
| "collapsed_reference_link"
| "shortcut_link" => {
link = Some(node);
}
_ => {}
}
let Some(parent) = node.parent() else { break };
node = parent;
}
let link = link.ok_or_else(no_link)?;
if link.kind() == "inline_link" {
return destination(child(link, "link_destination"), text);
}
let label = reference_label(link, text, &ranges).ok_or_else(no_link)?;
let definitions = definitions(blocks.root_node(), text);
let target = definitions
.get(&label)
.ok_or_else(|| format!("Markdown reference [{label}] is not defined"))?;
destination(Some(*target), text)
}
pub(crate) fn heading_row(text: &str, fragment: &str) -> Result<usize, String> {
if fragment.is_empty() {
return Err("Markdown heading fragment is empty".into());
}
let blocks = block_tree(text)?;
let definitions = definitions(blocks.root_node(), text);
let mut slugs = HashMap::<String, usize>::new();
let mut result = None;
let mut error = None;
walk(blocks.root_node(), |heading| {
if result.is_some() || !matches!(heading.kind(), "atx_heading" | "setext_heading") {
return;
}
let mut title = String::new();
let mut heading_error = None;
walk(heading, |node| {
if node.kind() != "inline" {
return;
}
match inline_tree(text, node) {
Ok((tree, ranges)) => {
if let Err(err) =
visible_text(tree.root_node(), text, &ranges, &definitions, &mut title)
{
heading_error = Some(err);
}
}
Err(err) => heading_error = Some(err),
}
});
if let Some(err) = heading_error {
error = Some(err);
return;
}
// The block grammar leaves optional closing ATX hashes in the inline node.
if heading.kind() == "atx_heading"
&& let Some(content) = heading.child_by_field_name("heading_content")
{
let raw = source(content, text).trim_end();
let without_hashes = raw.trim_end_matches('#');
if without_hashes.len() < raw.len()
&& without_hashes
.chars()
.next_back()
.is_some_and(char::is_whitespace)
{
let hashes = raw.len() - without_hashes.len();
title.truncate(title.trim_end().len().saturating_sub(hashes));
}
}
let base = slug(title.trim());
let mut suffix = *slugs.get(&base).unwrap_or(&0);
let mut candidate = base.clone();
while slugs.contains_key(&candidate) {
suffix += 1;
candidate = format!("{base}-{suffix}");
}
slugs.insert(base, suffix);
slugs.entry(candidate.clone()).or_insert(0);
if candidate == fragment {
result = Some(heading.start_position().row);
}
});
if let Some(row) = result {
return Ok(row);
}
if let Some(error) = error {
return Err(error);
}
Err(format!("Markdown heading #{fragment} was not found"))
}
fn no_link() -> String {
"No Markdown link under the cursor".into()
}
fn position_byte(text: &str, position: TextPosition) -> Result<usize, String> {
let mut start = 0;
for (row, line) in text.split_inclusive('\n').enumerate() {
if row == position.row {
let line = line.trim_end_matches(['\r', '\n']);
return line
.grapheme_indices(true)
.nth(position.column)
.map(|(offset, _)| start + offset)
.ok_or_else(no_link);
}
start += line.len();
}
Err(no_link())
}
fn block_tree(text: &str) -> Result<Tree, String> {
let mut parser = Parser::new();
parser
.set_language(&tree_sitter_md::LANGUAGE.into())
.map_err(|error| format!("Cannot load Markdown grammar: {error}"))?;
parser
.parse(text, None)
.ok_or_else(|| "Cannot parse Markdown document".into())
}
fn inline_tree(text: &str, node: Node<'_>) -> Result<(Tree, Vec<Range>), String> {
let mut ranges = Vec::new();
let mut remaining = node.range();
let mut cursor = node.walk();
for continuation in node.named_children(&mut cursor) {
if continuation.kind() != "block_continuation" {
continue;
}
if remaining.start_byte < continuation.start_byte() {
ranges.push(Range {
end_byte: continuation.start_byte(),
end_point: continuation.start_position(),
..remaining
});
}
remaining.start_byte = continuation.end_byte();
remaining.start_point = continuation.end_position();
}
if remaining.start_byte < remaining.end_byte {
ranges.push(remaining);
}
let mut parser = Parser::new();
parser
.set_language(&tree_sitter_md::INLINE_LANGUAGE.into())
.map_err(|error| format!("Cannot load Markdown inline grammar: {error}"))?;
parser
.set_included_ranges(&ranges)
.map_err(|error| format!("Cannot parse Markdown inline ranges: {error}"))?;
let tree = parser
.parse(text, None)
.ok_or_else(|| "Cannot parse Markdown inline content".to_string())?;
Ok((tree, ranges))
}
fn walk<'a>(root: Node<'a>, mut visit: impl FnMut(Node<'a>)) {
let mut cursor = root.walk();
loop {
visit(cursor.node());
if cursor.goto_first_child() {
continue;
}
while !cursor.goto_next_sibling() {
if !cursor.goto_parent() {
return;
}
}
}
}
fn source<'a>(node: Node<'_>, text: &'a str) -> &'a str {
&text[node.byte_range()]
}
fn child<'a>(node: Node<'a>, kind: &str) -> Option<Node<'a>> {
let mut cursor = node.walk();
node.named_children(&mut cursor)
.find(|child| child.kind() == kind)
}
fn definitions<'a>(root: Node<'a>, text: &str) -> HashMap<String, Node<'a>> {
let mut definitions = HashMap::new();
walk(root, |node| {
if node.kind() == "link_reference_definition"
&& let (Some(label), Some(destination)) =
(child(node, "link_label"), child(node, "link_destination"))
{
let raw = source(label, text);
let mut label_text = String::with_capacity(raw.len());
let mut offset = label.start_byte() + 1;
let mut cursor = label.walk();
for continuation in label.named_children(&mut cursor) {
if continuation.kind() == "block_continuation" {
label_text.push_str(&text[offset..continuation.start_byte()]);
offset = continuation.end_byte();
}
}
label_text.push_str(&text[offset..label.end_byte() - 1]);
definitions
.entry(normalize_label(&label_text))
.or_insert(destination);
}
});
definitions
}
fn normalize_label(label: &str) -> String {
// Normalize Unicode case, including ß/ẞ/SS and final sigma.
let mut normalized = String::with_capacity(label.len());
for word in label.split_whitespace() {
if !normalized.is_empty() {
normalized.push(' ');
}
normalized.extend(
word.chars()
.flat_map(char::to_lowercase)
.flat_map(char::to_uppercase),
);
}
normalized
}
fn reference_label(node: Node<'_>, text: &str, ranges: &[Range]) -> Option<String> {
let label = child(
node,
if node.kind() == "full_reference_link" {
"link_label"
} else {
"link_text"
},
)?;
let mut raw = String::new();
let range = if node.kind() == "full_reference_link" {
label.start_byte() + 1..label.end_byte() - 1
} else {
label.byte_range()
};
append_source(text, ranges, range, &mut raw);
Some(normalize_label(&raw))
}
fn append_source(
text: &str,
ranges: &[Range],
source: std::ops::Range<usize>,
output: &mut String,
) {
for range in ranges {
let start = range.start_byte.max(source.start);
let end = range.end_byte.min(source.end);
if start < end {
output.push_str(&text[start..end]);
}
}
}
fn destination(node: Option<Node<'_>>, text: &str) -> Result<LinkTarget, String> {
let raw = node
.map(|node| decoded_text(node, text))
.transpose()?
.unwrap_or_default();
let angled = node.is_some_and(|node| source(node, text).starts_with('<'));
let raw = if angled {
&raw[1..raw.len() - 1]
} else {
raw.as_str()
};
let (path, fragment) = raw
.split_once('#')
.map_or((raw, None), |(path, fragment)| (path, Some(fragment)));
let path = percent_decode(path)?;
let fragment = fragment.map(percent_decode).transpose()?;
if path.starts_with("//") || path.starts_with("\\\\") || has_scheme(&path) {
return Err("External Markdown URLs are not supported; use a local file path".into());
}
if path.contains('\0')
|| fragment
.as_ref()
.is_some_and(|fragment| fragment.contains('\0'))
{
return Err("Markdown link contains a NUL character".into());
}
if fragment.as_deref() == Some("") {
return Err("Markdown heading fragment is empty".into());
}
Ok(LinkTarget {
path: (!path.is_empty()).then(|| PathBuf::from(path)),
fragment,
})
}
fn has_scheme(path: &str) -> bool {
let Some((scheme, _)) = path.split_once(':') else {
return false;
};
scheme
.as_bytes()
.first()
.is_some_and(u8::is_ascii_alphabetic)
&& scheme
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.'))
}
fn percent_decode(value: &str) -> Result<String, String> {
if !value.contains('%') {
return Ok(value.to_owned());
}
let mut bytes = Vec::with_capacity(value.len());
let raw = value.as_bytes();
let mut index = 0;
while index < raw.len() {
if raw[index] == b'%' {
let hex = raw
.get(index + 1..index + 3)
.and_then(|hex| std::str::from_utf8(hex).ok())
.and_then(|hex| u8::from_str_radix(hex, 16).ok())
.ok_or_else(|| "Invalid percent escape in Markdown link".to_string())?;
bytes.push(hex);
index += 3;
} else {
bytes.push(raw[index]);
index += 1;
}
}
String::from_utf8(bytes).map_err(|_| "Markdown link percent escapes are not valid UTF-8".into())
}
fn append_unescaped(value: &str, result: &mut String) {
let mut chars = value.chars().peekable();
while let Some(character) = chars.next() {
if character == '\\' && chars.peek().is_some_and(char::is_ascii_punctuation) {
result.push(chars.next().unwrap());
} else {
result.push(character);
}
}
}
fn decoded_text(node: Node<'_>, text: &str) -> Result<String, String> {
let mut output = String::with_capacity(node.end_byte() - node.start_byte());
append_decoded(node, text, &mut output)?;
Ok(output)
}
fn append_decoded(node: Node<'_>, text: &str, output: &mut String) -> Result<(), String> {
if matches!(
node.kind(),
"entity_reference" | "numeric_character_reference"
) {
return decode_entity(source(node, text), output);
}
let mut offset = node.start_byte();
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
append_unescaped(&text[offset..child.start_byte()], output);
append_decoded(child, text, output)?;
offset = child.end_byte();
}
append_unescaped(&text[offset..node.end_byte()], output);
Ok(())
}
fn decode_entity(entity: &str, output: &mut String) -> Result<(), String> {
let name = &entity[1..entity.len() - 1];
let value = match name {
"amp" | "AMP" => "&",
"lt" | "LT" => "<",
"gt" | "GT" => ">",
"quot" | "QUOT" => "\"",
"apos" => "'",
"nbsp" | "NonBreakingSpace" => "\u{a0}",
_ => {
if let Some(number) = name.strip_prefix('#') {
let (number, radix) = number
.strip_prefix(['x', 'X'])
.map_or((number, 10), |number| (number, 16));
let value = u32::from_str_radix(number, radix)
.ok()
.filter(|value| *value != 0)
.and_then(char::from_u32)
.unwrap_or('\u{fffd}');
output.push(value);
return Ok(());
}
return Err(format!(
"Unsupported Markdown HTML entity {entity}; use literal Unicode or a numeric entity"
));
}
};
output.push_str(value);
Ok(())
}
fn visible_text(
node: Node<'_>,
text: &str,
ranges: &[Range],
definitions: &HashMap<String, Node<'_>>,
output: &mut String,
) -> Result<(), String> {
match node.kind() {
"html_tag" | "link_destination" | "link_title" | "block_continuation" => return Ok(()),
kind if kind.ends_with("_delimiter") => return Ok(()),
"entity_reference" | "numeric_character_reference" => {
return decode_entity(source(node, text), output);
}
"backslash_escape" => {
output.push_str(
source(node, text)
.strip_prefix('\\')
.unwrap_or(source(node, text)),
);
return Ok(());
}
"code_span" => {
let mut raw = String::new();
append_source(text, ranges, node.byte_range(), &mut raw);
let delimiter = raw.bytes().take_while(|byte| *byte == b'`').count();
let mut code = String::with_capacity(raw.len() - delimiter * 2);
let mut chars = raw[delimiter..raw.len() - delimiter].chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\r' {
if chars.peek() == Some(&'\n') {
chars.next();
}
code.push(' ');
} else {
code.push(if ch == '\n' { ' ' } else { ch });
}
}
if code.starts_with(' ') && code.ends_with(' ') && code.chars().any(|ch| ch != ' ') {
output.push_str(&code[1..code.len() - 1]);
} else {
output.push_str(&code);
}
return Ok(());
}
"inline_link" => {
if let Some(label) = child(node, "link_text") {
visible_text(label, text, ranges, definitions, output)?;
}
return Ok(());
}
"image" => {
let mut cursor = node.walk();
let inline_image = node.children(&mut cursor).any(|child| child.kind() == "(");
let label = child(node, "image_description");
if inline_image {
if let Some(label) = label {
visible_text(label, text, ranges, definitions, output)?;
}
return Ok(());
}
let reference = if let Some(reference) = child(node, "link_label") {
let raw = source(reference, text);
Some(normalize_label(&raw[1..raw.len() - 1]))
} else {
label.map(|label| {
let mut raw = String::new();
append_source(text, ranges, label.byte_range(), &mut raw);
normalize_label(&raw)
})
};
if reference.is_some_and(|label| definitions.contains_key(&label)) {
if let Some(label) = label {
visible_text(label, text, ranges, definitions, output)?;
}
return Ok(());
}
}
"full_reference_link" | "collapsed_reference_link" | "shortcut_link" => {
if reference_label(node, text, ranges)
.is_some_and(|label| definitions.contains_key(&label))
{
if let Some(label) = child(node, "link_text") {
visible_text(label, text, ranges, definitions, output)?;
}
return Ok(());
}
}
_ => {}
}
let mut offset = node.start_byte();
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
append_source(text, ranges, offset..child.start_byte(), output);
visible_text(child, text, ranges, definitions, output)?;
offset = child.end_byte();
}
append_source(text, ranges, offset..node.end_byte(), output);
Ok(())
}
fn slug(title: &str) -> String {
let lower = title.to_lowercase();
let mut slug = String::with_capacity(lower.len());
for grapheme in lower.graphemes(true) {
if grapheme.chars().next().is_some_and(char::is_whitespace) {
slug.push('-');
} else if grapheme
.chars()
.next()
.is_some_and(|ch| ch.is_alphanumeric() || ch == '-' || ch == '_')
{
// Keep combining marks attached to Unicode letters, not just ASCII words.
slug.push_str(grapheme);
}
}
slug
}
#[cfg(test)]
mod tests {
use super::*;
fn at(text: &str, needle: &str) -> Result<LinkTarget, String> {
let byte = text.find(needle).unwrap();
let prefix = &text[..byte];
let row = prefix.bytes().filter(|byte| *byte == b'\n').count();
let column = prefix.rsplit('\n').next().unwrap().graphemes(true).count();
link_at(text, TextPosition { row, column })
}
fn target(path: Option<&str>, fragment: Option<&str>) -> LinkTarget {
LinkTarget {
path: path.map(PathBuf::from),
fragment: fragment.map(str::to_owned),
}
}
#[test]
fn follows_inline_labels_destinations_and_grapheme_columns() {
let text = "👩‍💻 [go **there**](<../My%20Notes/%E6%97%A5%E6%9C%AC\\(1\\).md#caf%C3%A9>)";
let expected = target(Some("../My Notes/日本(1).md"), Some("café"));
assert_eq!(at(text, "there").unwrap(), expected);
assert_eq!(at(text, "My%20").unwrap(), expected);
assert_eq!(
at("[absolute](/Tmp/Note.md)", "absolute").unwrap(),
target(Some("/Tmp/Note.md"), None)
);
assert_eq!(
at("[here](#title)", "here").unwrap(),
target(None, Some("title"))
);
assert_eq!(at("[top]()", "top").unwrap(), target(None, None));
assert_eq!(
at("[here](#title)", "[here").unwrap(),
target(None, Some("title"))
);
assert_eq!(
at("[file](literal%23name.md)", "file").unwrap(),
target(Some("literal#name.md"), None)
);
assert_eq!(
at("[file](My\\[Notes\\].md)", "file").unwrap(),
target(Some("My[Notes].md"), None)
);
}
#[test]
fn resolves_all_reference_forms_and_multiline_block_definitions() {
let text = "[full][ A B ] [a b][] [A B]\n\n> [a b]:\n> <My%20Notes.md#title>\n> \"A title\"\n\n[a b]: ignored.md\n";
let expected = target(Some("My Notes.md"), Some("title"));
for needle in ["full", " A B ", "a b][]", "A B]"] {
assert_eq!(at(text, needle).unwrap(), expected);
}
assert_eq!(
at("[Straße]\n\n[STRASSE]: file.md", "Straße").unwrap(),
target(Some("file.md"), None)
);
let text = "> [multi\n> line][target]\n\n[target]: other.md\n";
assert_eq!(at(text, "line]").unwrap(), target(Some("other.md"), None));
}
#[test]
fn rejects_images_code_plaintext_and_external_urls() {
for (text, needle) in [
("![image](file.md)", "image"),
("`[code](file.md)`", "code"),
("```md\n[code](file.md)\n```", "file"),
(" [code](file.md)", "code"),
("plain file.md text", "file"),
("[web](https://example.com)", "web"),
("[web](//example.com/a)", "web"),
("[web](mailto:a@example.com)", "web"),
("<https://example.com>", "example"),
] {
assert!(at(text, needle).is_err(), "{text}");
}
assert!(
at("[missing]", "missing")
.unwrap_err()
.contains("not defined")
);
assert!(
at("[bad](file%ZZ.md)", "bad")
.unwrap_err()
.contains("percent")
);
assert!(
at("[bad](file%FF.md)", "bad")
.unwrap_err()
.contains("UTF-8")
);
assert!(at("[bad](#)", "bad").unwrap_err().contains("empty"));
}
#[test]
fn locates_visible_unicode_headings_setext_and_unique_slugs() {
let text = "# **Café** [日本](destination.md) `code` ###\n\n> ## Café 日本 code\n\nCafé 日本 code\n====\n\n# Café 日本 code-1\n\n# Cafe\u{301} _name_ ![alt](hidden.md)\n\n```md\n# Hidden\n```\n";
assert_eq!(heading_row(text, "café-日本-code").unwrap(), 0);
assert_eq!(heading_row(text, "café-日本-code-1").unwrap(), 2);
assert_eq!(heading_row(text, "café-日本-code-2").unwrap(), 4);
assert_eq!(heading_row(text, "café-日本-code-1-1").unwrap(), 7);
assert_eq!(heading_row(text, "cafe\u{301}-name-alt").unwrap(), 9);
assert!(heading_row(text, "hidden").is_err());
assert!(heading_row(text, "destinationmd").is_err());
assert!(heading_row(text, "").is_err());
assert_eq!(
heading_row("# [Heading][ref]\n\n[ref]: ignored.md", "heading").unwrap(),
0
);
assert_eq!(
heading_row("# ![Alt][ref]\n\n[ref]: ignored.md", "alt").unwrap(),
0
);
assert_eq!(heading_row("# ![Alt][missing]", "altmissing").unwrap(), 0);
}
#[test]
fn renders_entities_or_reports_the_explicit_entity_ceiling() {
assert_eq!(
at("[file](a&amp;b&#x20;c.md)", "file").unwrap(),
target(Some("a&b c.md"), None)
);
assert_eq!(
at("[file](&lt;name&gt;)", "file").unwrap(),
target(Some("<name>"), None)
);
assert!(
at("[file](a&copy;b.md)", "file")
.unwrap_err()
.contains("Unsupported Markdown HTML entity")
);
assert_eq!(heading_row("# A &amp; B &#xE9;\n", "a--b-é").unwrap(), 0);
assert!(
heading_row("# &copy;\n", "copy")
.unwrap_err()
.contains("Unsupported Markdown HTML entity")
);
assert_eq!(heading_row("# &copy;\n# Valid\n", "valid").unwrap(), 1);
assert_eq!(
heading_row("> First\n> Second\n> ------\n", "first-second").unwrap(),
0
);
}
}
+13
View File
@@ -237,6 +237,19 @@ impl EditorSession {
}
fn synchronize_documents(&mut self) -> bool {
// Leaving a request's source invalidates it permanently, even if history returns there.
if let Some(requests) = self.requests.get_mut("core")
&& requests.latest.is_some_and(|(_, source)| {
source.buffer_id != self.editor.buffer_id()
|| source.revision != self.editor.text_revision()
|| self.lsp_source_cursor != Some(self.editor.cursor())
|| self.editor.mode() != Mode::Normal
})
{
requests.latest = None;
self.lsp_source_cursor = None;
self.pending_hover = false;
}
if !self.lsp.is_enabled() && !self.plugins.values().any(RubyPlugin::is_async) {
self.documents.clear();
return false;
+16
View File
@@ -9,6 +9,10 @@ use tree_sitter::{
mod languages;
use languages::LanguageSpec;
pub(crate) fn is_markdown_path(path: &Path) -> bool {
languages::detect(Some(path), None).is_some_and(|syntax| syntax.name == "Markdown")
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SyntaxKind {
Comment,
@@ -60,6 +64,18 @@ impl SyntaxCache {
Self::default()
}
pub(crate) fn update_text(&mut self, text: &str, path: &Path) {
if self.source == text && self.path.as_deref() == Some(path) {
return;
}
let lines: Vec<String> = text.split('\n').map(str::to_owned).collect();
self.update(
&lines,
Some(path),
self.revision.unwrap_or(0).wrapping_add(1),
);
}
/// `revision` must change whenever line content changes; path changes are detected separately.
pub fn update(&mut self, lines: &[String], path: Option<&Path>, revision: u64) {
let path_changed = self.path.as_deref() != path;
+1957 -125
View File
File diff suppressed because it is too large Load Diff
+113
View File
@@ -585,6 +585,17 @@ fn lsp_framing_lifecycle_diagnostics_and_unicode_dirty_definition() {
definition["params"]["position"],
json!({"line": 0, "character": 5})
);
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor().buffer_id(), 1);
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 3 });
assert_eq!(session.editor().lines(), &["A😀e\u{301}Z", ""]);
session.handle(Input::Ctrl('i'));
assert_eq!(session.editor().buffer_id(), target_id);
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 1 });
assert_eq!(session.editor().lines(), &["😀e\u{301}disk", ""]);
assert!(session.editor().dirty());
session.poll();
assert_eq!(fixture.method_count("textDocument/definition"), 1);
session.execute("buffer 1");
session.execute("lsp.definition");
@@ -604,6 +615,13 @@ fn lsp_framing_lifecycle_diagnostics_and_unicode_dirty_definition() {
session.editor().message().contains("no definition found")
});
assert_eq!(session.editor().buffer_id(), 1);
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor().buffer_id(), target_id);
assert_eq!(session.editor().cursor(), Cursor { row: 1, column: 0 });
session.handle(Input::Ctrl('i'));
assert_eq!(session.editor().buffer_id(), 1);
session.poll();
assert_eq!(fixture.method_count("textDocument/definition"), 3);
session.handle(Input::Char('A'));
session.handle(Input::Char('!'));
@@ -729,6 +747,14 @@ fn lsp_old_source_and_superseded_definitions_never_steal_focus() {
session.handle(Input::Escape);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["A😀e\u{301}Z", ""]);
let cursor = session.editor().cursor();
for input in [Input::Ctrl('o'), Input::Ctrl('i')] {
session.handle(input);
assert_eq!(session.editor().buffer_id(), 1);
assert_eq!(session.editor().cursor(), cursor);
}
session.poll();
assert_eq!(fixture.method_count("textDocument/definition"), 1);
drop(session);
let fixture = Fixture::new();
@@ -752,6 +778,49 @@ fn lsp_old_source_and_superseded_definitions_never_steal_focus() {
assert_eq!(session.editor().buffer_id(), active_id);
assert_eq!(session.editor().path(), Some(fixture.alternate.as_path()));
assert_eq!(session.editor().buffer_count(), 2);
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor().buffer_id(), 1);
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 0 });
session.handle(Input::Ctrl('i'));
assert_eq!(session.editor().buffer_id(), active_id);
session.handle(Input::Ctrl('i'));
assert_eq!(session.editor().buffer_id(), active_id);
session.poll();
assert_eq!(fixture.method_count("textDocument/definition"), 1);
drop(session);
let fixture = Fixture::new();
let mut session = fixture.open("stale");
until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2
});
let source_cursor = session.editor().cursor();
session.execute("lsp.definition");
until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/definition") == 1
});
session.execute(&format!("edit {}", fixture.alternate.display()));
let alternate_id = session.editor().buffer_id();
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor().buffer_id(), 1);
assert_eq!(session.editor().cursor(), source_cursor);
fixture.release();
until(&mut session, &fixture, |session| {
session
.editor()
.message()
.contains("stale response complete")
});
assert_eq!(session.editor().buffer_id(), 1);
assert_eq!(session.editor().cursor(), source_cursor);
assert_eq!(session.editor().buffer_count(), 2);
session.handle(Input::Ctrl('i'));
assert_eq!(session.editor().buffer_id(), alternate_id);
assert_eq!(session.editor().path(), Some(fixture.alternate.as_path()));
session.handle(Input::Ctrl('i'));
assert_eq!(session.editor().buffer_id(), alternate_id);
session.poll();
assert_eq!(fixture.method_count("textDocument/definition"), 1);
drop(session);
let fixture = Fixture::new();
@@ -772,6 +841,21 @@ fn lsp_old_source_and_superseded_definitions_never_steal_focus() {
.buffers()
.any(|buffer| buffer.path == Some(fixture.alternate.as_path()))
);
let target_id = session.editor().buffer_id();
let destination = session.editor().cursor();
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor().buffer_id(), 1);
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 0 });
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor().buffer_id(), 1);
session.handle(Input::Ctrl('i'));
assert_eq!(session.editor().buffer_id(), target_id);
assert_eq!(session.editor().cursor(), destination);
session.handle(Input::Ctrl('i'));
assert_eq!(session.editor().buffer_id(), target_id);
assert_eq!(session.editor().buffer_count(), 2);
session.poll();
assert_eq!(fixture.method_count("textDocument/definition"), 2);
}
#[test]
@@ -816,6 +900,15 @@ fn lsp_server_failures_and_unsupported_results_leave_native_editor_available() {
}
}
assert_eq!(session.editor().buffer_id(), 1, "{mode}");
let cursor = session.editor().cursor();
let definitions = fixture.method_count("textDocument/definition");
for input in [Input::Ctrl('o'), Input::Ctrl('i')] {
session.handle(input);
assert_eq!(session.editor().buffer_id(), 1, "{mode}");
assert_eq!(session.editor().cursor(), cursor, "{mode}");
}
session.poll();
assert_eq!(fixture.method_count("textDocument/definition"), definitions);
session.handle(Input::Char('A'));
session.handle(Input::Char('!'));
session.handle(Input::Escape);
@@ -907,6 +1000,19 @@ fn lsp_definition_reuses_dirty_targets_without_an_enabled_target_language() {
assert!(session.editor().dirty());
assert_eq!(fs::read_to_string(&fixture.target).unwrap(), "disk\n");
assert_eq!(fixture.method_count("textDocument/didOpen"), 1);
session.handle(Input::Ctrl('o'));
assert_eq!(session.editor().buffer_id(), 1);
session.handle(Input::Ctrl('i'));
assert_eq!(session.editor().buffer_id(), target_id);
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 1 });
assert_eq!(session.editor().lines(), &["😀e\u{301}disk", ""]);
assert!(session.editor().dirty());
session.poll();
assert_eq!(fixture.method_count("textDocument/definition"), 1);
assert_eq!(fixture.method_count("textDocument/didOpen"), 1);
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["disk", ""]);
assert_eq!(fs::read_to_string(&fixture.target).unwrap(), "disk\n");
}
#[test]
@@ -931,6 +1037,13 @@ fn lsp_definition_does_not_move_a_caret_that_has_moved_since_the_request() {
assert_eq!(session.editor().buffer_id(), 1);
assert_eq!(session.editor().buffer_count(), 1);
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 1 });
for input in [Input::Ctrl('o'), Input::Ctrl('i')] {
session.handle(input);
assert_eq!(session.editor().buffer_id(), 1);
assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 1 });
}
session.poll();
assert_eq!(fixture.method_count("textDocument/definition"), 1);
}
#[test]