diff --git a/docs/architecture.md b/docs/architecture.md index ee8d1f7..28287b9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. @@ -27,6 +28,7 @@ Frontends inspect `palette()` for the optional read-only query, filtered command 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. 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. diff --git a/docs/configuration.md b/docs/configuration.md index 37a2ae1..8e5bc2f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -55,6 +55,8 @@ 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. +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 +64,7 @@ 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 | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` | | Normal | `search`, `paste_before`, `paste_after` | | Normal | `paste_system_before`, `paste_system_after` | diff --git a/docs/development.md b/docs/development.md index d8eb264..e293831 100644 --- a/docs/development.md +++ b/docs/development.md @@ -33,6 +33,7 @@ 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. - 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. - 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. diff --git a/docs/editing.md b/docs/editing.md index e65964f..7c5ca8d 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -16,6 +16,7 @@ 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, 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 | @@ -170,6 +171,22 @@ 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. +## 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. diff --git a/src/editor.rs b/src/editor.rs index 0315d30..3fa3c08 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -291,6 +291,7 @@ enum Action { Palette, FilePicker, FileExplorer, + FollowLink, LspDefinition, LspHover, LspDiagnostics, @@ -471,6 +472,7 @@ const CORE_ACTIONS: &[(&str, Action, &[Mode])] = &[ Action::FileExplorer, &[Mode::Normal, Mode::Visual], ), + ("core.follow_link", Action::FollowLink, &[Mode::Normal]), ( "core.lsp.definition", Action::LspDefinition, @@ -887,6 +889,7 @@ pub struct Editor { leader_bindings: HashMap, Binding>>, leader_suffix: Vec, leader_display: String, + g_pending: bool, palette: Option, hover: Option, startup_diagnostic: Option, @@ -946,6 +949,7 @@ impl Editor { leader_bindings: HashMap::new(), leader_suffix: Vec::new(), leader_display: String::new(), + g_pending: false, palette: None, hover: None, startup_diagnostic: None, @@ -975,6 +979,7 @@ impl Editor { "bprev", "bdelete", "bdelete!", + "follow_link", "lsp.definition", "lsp.hover", "lsp.diagnostics", @@ -1287,12 +1292,18 @@ impl Editor { /// Reuses loaded (including dirty) buffers and leaves the source unchanged on failure. pub fn navigate_to(&mut self, path: PathBuf, position: TextPosition) -> Result<(), String> { + self.cancel_leader(); self.clear_hover(); if path.as_os_str().is_empty() || path.to_string_lossy().chars().any(char::is_control) { return Err("Navigation path must be nonempty and contain no controls".into()); } self.edit_buffer(path, false) .map_err(|error| error.to_string())?; + self.place_navigation(position); + Ok(()) + } + + fn place_navigation(&mut self, position: TextPosition) { self.park_source(); self.active.mode = Mode::Normal; let row = position.row.min(self.active.lines.len() - 1); @@ -1304,6 +1315,72 @@ impl Editor { self.active.search_match = None; self.active.visual_motions = None; self.normalize_targets(); + } + + fn follow_link(&mut self) -> Result<(), String> { + if self + .active + .path + .as_deref() + .is_some_and(|path| !crate::syntax::is_markdown_path(path)) + { + return Err("follow_link requires a Markdown document".into()); + } + let source = self.active.lines.join("\n"); + let cursor = self.cursor(); + let target = crate::markdown::link_at( + &source, + TextPosition { + row: cursor.row, + column: cursor.column, + }, + )?; + let Some(path) = target.path else { + let row = match target.fragment { + Some(fragment) => crate::markdown::heading_row(&source, &fragment)?, + None => 0, + }; + self.place_navigation(TextPosition { row, column: 0 }); + return Ok(()); + }; + let path = if path.is_absolute() { + path + } else { + let source_path = self + .active + .identity + .as_deref() + .ok_or("Relative links require a named Markdown document")?; + source_path + .parent() + .ok_or("Markdown document has no parent directory")? + .join(path) + }; + if path.to_string_lossy().chars().any(char::is_control) { + return Err("Link path cannot contain control characters".into()); + } + let identity = path_identity(&path).map_err(|error| error.to_string())?; + let heading_row = |buffer: &Buffer| { + target.fragment.as_deref().map_or(Ok(0), |fragment| { + crate::markdown::heading_row(&buffer.lines.join("\n"), fragment) + }) + }; + let loaded = std::iter::once(&self.active) + .chain(self.parked.values()) + .find(|buffer| buffer.identity.as_ref() == Some(&identity)); + let row = if let Some(buffer) = loaded { + let row = heading_row(buffer)?; + let id = buffer.id; + self.switch_buffer(id); + row + } else { + let buffer = Buffer::open(self.next_buffer_id, Some(path), Some(identity), false) + .map_err(|error| format!("Cannot open link target: {error}"))?; + let row = heading_row(&buffer)?; + self.add_buffer(buffer); + row + }; + self.place_navigation(TextPosition { row, column: 0 }); Ok(()) } @@ -1497,13 +1574,19 @@ impl Editor { (!self.leader_display.is_empty()).then_some(self.leader_display.as_str()) } + /// Whether the built-in Normal `gf` prefix is awaiting its second key. + pub fn g_pending(&self) -> bool { + self.g_pending + } + fn clear_leader(&mut self) { + self.g_pending = false; self.leader_suffix.clear(); self.leader_display.clear(); } fn cancel_leader(&mut self) { - if self.leader_pending().is_some() { + if self.leader_pending().is_some() || self.g_pending { self.count = None; self.clear_leader(); } @@ -1892,6 +1975,16 @@ impl Editor { }) .map(|(input, _)| display_key(input)) .collect(); + if action == Some(Action::FollowLink) + && self.active.mode == Mode::Normal + && self.leader != Input::Char('g') + && !self + .bindings + .get(&Mode::Normal) + .is_some_and(|keys| keys.contains_key(&Input::Char('g'))) + { + bindings.push("gf".into()); + } if !self .bindings .get(&self.active.mode) @@ -2108,6 +2201,24 @@ impl Editor { Input::Ctrl(ch) => control_key(ch), input => input, }; + if std::mem::take(&mut self.g_pending) { + if self.active.mode == Mode::Normal + && self.startup_diagnostic.is_none() + && self.hover().is_none() + && self.palette.is_none() + && !self + .file_picker + .as_ref() + .is_some_and(|picker| picker.focused) + && input == Input::Char('f') + { + return self.act(Action::FollowLink); + } + self.count = None; + if input == Input::Escape { + return None; + } + } if let Some(diagnostic) = &mut self.startup_diagnostic { match input { Input::Enter | Input::Escape => self.startup_diagnostic = None, @@ -2212,6 +2323,11 @@ impl Editor { append_key(&mut self.leader_display, &input); return None; } + if self.active.mode == Mode::Normal && input == Input::Char('g') { + self.count = None; + self.g_pending = true; + return None; + } self.count = None; match (self.active.mode, input) { (Mode::Insert, Input::Char(ch)) if !ch.is_control() || ch == '\t' => { @@ -2273,6 +2389,11 @@ impl Editor { } Action::FilePicker => self.open_file_picker(FilePresentation::Floating), Action::FileExplorer => self.open_file_picker(FilePresentation::Docked), + Action::FollowLink => { + if let Err(error) = self.follow_link() { + self.set_message(error); + } + } Action::LspDefinition => return self.execute("lsp.definition"), Action::LspHover => return self.execute("lsp.hover"), Action::LspDiagnostics => return self.execute("lsp.diagnostics"), @@ -2562,9 +2683,13 @@ impl Editor { Some(identity), allow_missing, )?; + self.add_buffer(buffer); + Ok(()) + } + + fn add_buffer(&mut self, buffer: Buffer) { self.next_buffer_id += 1; self.activate(buffer); - Ok(()) } fn delete_buffer(&mut self, id: usize, force: bool) { @@ -2679,6 +2804,15 @@ impl Editor { if self.buffer_command(name, args) { return None; } + // Link preflight must precede Ex cleanup: failure retains carets and text. + if name == "follow_link" { + if !args.is_empty() { + self.set_message("The follow_link command takes no arguments"); + } else if let Err(error) = self.follow_link() { + self.set_message(error); + } + return None; + } if self.active.mode == Mode::Insert { self.finish_insert(); } @@ -4418,6 +4552,318 @@ mod tests { editor } + #[test] + fn follow_link_resolves_relative_paths_from_source_not_cwd_or_project_root() { + let directory = Directory::new(); + let nested = directory.path("notes"); + fs::create_dir(&nested).unwrap(); + let source = nested.join("source.md"); + let target = directory.path("Target Name.MD"); + fs::write(&source, "[go](../Target%20Name.MD#target-heading)").unwrap(); + fs::write(&target, "intro\n\n# Target heading\nbody").unwrap(); + let mut editor = Editor::open(Some(source), vec![]).unwrap(); + assert_ne!(std::env::current_dir().unwrap(), nested); + editor.handle(Input::Char('g')); + assert!(editor.g_pending()); + editor.handle(Input::Char('f')); + assert!(!editor.g_pending()); + assert_eq!(editor.buffer_id(), 2); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); + assert_eq!(editor.context().text, "intro\n\n# Target heading\nbody"); + assert_eq!(editor.buffer_count(), 2); + } + + #[test] + fn follow_link_reference_and_duplicate_heading_use_native_ex_command() { + let directory = Directory::new(); + let source = directory.path("source.md"); + let target = directory.path("target.md"); + fs::write( + &source, + "[go][section]\n\n[section]: target.md#hello-world-1", + ) + .unwrap(); + fs::write( + &target, + "# Hello [world](ignored.md)\n\nHello *world*\n=============\n", + ) + .unwrap(); + let mut editor = Editor::open(Some(source), vec![]).unwrap(); + type_text(&mut editor, ":follow_link\n"); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); + assert_eq!(editor.lines()[0], "# Hello [world](ignored.md)"); + } + + #[test] + fn follow_link_reuses_dirty_target_even_after_disk_file_disappears() { + let directory = Directory::new(); + let source = directory.path("source.md"); + let target = directory.path("target.md"); + fs::write(&source, "[go](target.md#dirty-heading)").unwrap(); + fs::write(&target, "# Disk heading").unwrap(); + let mut editor = Editor::open(Some(source), vec![]).unwrap(); + editor.execute(&format!("edit {}", target.display())); + let target_id = editor.buffer_id(); + editor + .apply(vec![Effect::Replace("unsaved\n\n# Dirty heading".into())]) + .unwrap(); + let revision = editor.text_revision(); + editor.execute("buffer 1"); + fs::remove_file(&target).unwrap(); + editor + .bind("normal", "z", Some("core.follow_link".into())) + .unwrap(); + assert_eq!(editor.handle(Input::Char('z')), None); + assert_eq!(editor.buffer_id(), target_id); + assert_eq!(editor.buffer_count(), 2); + assert_eq!(editor.context().text, "unsaved\n\n# Dirty heading"); + assert_eq!(editor.text_revision(), revision); + assert!(editor.dirty()); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); + } + + #[test] + fn follow_link_failures_leave_source_carets_selections_text_and_buffer_ids_untouched() { + let directory = Directory::new(); + fs::write(directory.path("target.md"), "# Existing").unwrap(); + for (filename, text, command) in [ + ("source.md", "[x](missing.md)", "follow_link"), + ("source.md", "[x](target.md#missing)", "follow_link"), + ("source.md", "[x](#missing)", "follow_link"), + ("source.md", "[missing][undefined]", "follow_link"), + ("source.md", "[x](https://example.com)", "follow_link"), + ("source.md", "not a link", "follow_link"), + ("source.txt", "[x](target.md)", "follow_link"), + ("source.md", "[x](target.md)", "follow_link extra"), + ] { + let source = directory.path(filename); + fs::write(&source, text).unwrap(); + let mut editor = Editor::open(Some(source), vec![]).unwrap(); + type_text(&mut editor, "vl"); + let carets = editor.carets(); + let selections = editor.selections(); + let revision = editor.text_revision(); + assert_eq!(editor.execute(command), None); + assert_eq!(editor.mode(), Mode::Visual); + assert_eq!(editor.buffer_id(), 1); + assert_eq!(editor.buffer_count(), 1); + assert_eq!(editor.carets(), carets); + assert_eq!(editor.selections(), selections); + assert_eq!(editor.context().text, text); + assert_eq!(editor.text_revision(), revision); + assert!(!editor.message().is_empty()); + if command == "follow_link" { + editor.handle(Input::Escape); + let carets = editor.carets(); + type_text(&mut editor, "gf"); + assert_eq!(editor.buffer_id(), 1); + assert_eq!(editor.buffer_count(), 1); + assert_eq!(editor.carets(), carets); + assert_eq!(editor.context().text, text); + } + editor.execute(&format!("edit {}", directory.path("target.md").display())); + assert_eq!(editor.buffer_id(), 2); + } + } + + #[test] + fn follow_link_same_document_fragments_work_without_named_buffer() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace( + "[go](#hello-world)\n\n# Hello **world**\n".into(), + )]) + .unwrap(); + let revision = editor.text_revision(); + editor + .bind("normal", "leader f", Some("core.follow_link".into())) + .unwrap(); + type_text(&mut editor, " f"); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); + assert_eq!(editor.buffer_count(), 1); + assert_eq!(editor.path(), None); + assert_eq!(editor.text_revision(), revision); + assert_eq!( + editor.context().text, + "[go](#hello-world)\n\n# Hello **world**\n" + ); + editor.handle(Input::Home); + editor.handle(Input::Up); + editor.handle(Input::Up); + editor + .apply(vec![Effect::Replace("[go](target.md)".into())]) + .unwrap(); + editor.execute("follow_link"); + assert_eq!(editor.buffer_count(), 1); + assert_eq!(editor.cursor(), Cursor::default()); + assert!(editor.message().contains("named")); + } + + #[test] + fn follow_link_explicit_same_file_path_reuses_current_dirty_buffer() { + let directory = Directory::new(); + let source = directory.path("source.md"); + fs::write(&source, "# Old disk heading").unwrap(); + let mut editor = Editor::open(Some(source), vec![]).unwrap(); + let text = "[go](source.md#dirty-heading)\n\n# Dirty heading"; + editor.apply(vec![Effect::Replace(text.into())]).unwrap(); + let revision = editor.text_revision(); + type_text(&mut editor, "gf"); + assert_eq!(editor.buffer_id(), 1); + assert_eq!(editor.buffer_count(), 1); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); + assert_eq!(editor.context().text, text); + assert_eq!(editor.text_revision(), revision); + assert!(editor.dirty()); + } + + #[test] + fn follow_link_uses_only_primary_caret_once() { + let directory = Directory::new(); + let source = directory.path("source.md"); + let target = directory.path("target.md"); + fs::write(&source, "[bad](missing.md)\n[go](target.md)").unwrap(); + fs::write(&target, "# Target").unwrap(); + let mut editor = Editor::open(Some(source), vec![]).unwrap(); + editor.handle(Input::Ctrl('e')); + assert_eq!(editor.carets().len(), 2); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); + type_text(&mut editor, "gf"); + assert_eq!(editor.buffer_id(), 2); + assert_eq!(editor.buffer_count(), 2); + assert_eq!(editor.carets(), vec![Cursor::default()]); + assert_eq!(editor.lines(), ["# Target"]); + } + + #[test] + fn follow_link_accepts_nonmarkdown_targets_without_fragments() { + let directory = Directory::new(); + let source = directory.path("source.md"); + let target = directory.path("main.rs"); + fs::write(&source, "[code](main.rs)").unwrap(); + fs::write(&target, "fn main() {}").unwrap(); + let mut editor = Editor::open(Some(source), vec![]).unwrap(); + type_text(&mut editor, "9gf"); + assert_eq!(editor.cursor(), Cursor::default()); + assert_eq!(editor.count(), None); + assert_eq!(editor.buffer_count(), 2); + assert_eq!(editor.lines(), ["fn main() {}"]); + } + + #[test] + fn native_gf_prefix_yields_to_configured_g_commands_and_preserves_counts() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("a\nb\nc\nd".into())]) + .unwrap(); + editor.handle(Input::Ctrl('p')); + assert_eq!( + palette_entry(&editor, "core.follow_link").bindings, + vec!["gf"] + ); + assert!(palette_entry(&editor, "core.follow_link").enabled); + editor.handle(Input::Escape); + editor + .bind("normal", "g", Some("core.down".into())) + .unwrap(); + type_text(&mut editor, "2g"); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); + assert_eq!(editor.count(), None); + assert!(!editor.g_pending()); + editor.handle(Input::Ctrl('p')); + assert!( + palette_entry(&editor, "core.follow_link") + .bindings + .is_empty() + ); + editor.handle(Input::Escape); + editor.bind("normal", "g", None).unwrap(); + type_text(&mut editor, "g"); + assert!(!editor.g_pending()); + editor.register(plugin("fixture", &["greet"])).unwrap(); + editor.bind("normal", "g", Some("greet".into())).unwrap(); + type_text(&mut editor, "3"); + assert_eq!( + editor.handle(Input::Char('g')), + Some(Invocation { + plugin: "fixture".into(), + command: "greet".into(), + args: String::new(), + }) + ); + assert_eq!(editor.count(), None); + assert!(!editor.g_pending()); + for mode in ["insert", "visual", "command", "search"] { + assert!( + editor + .bind(mode, "z", Some("core.follow_link".into())) + .is_err() + ); + } + editor.handle(Input::Char('v')); + editor.handle(Input::Ctrl('p')); + assert!(!palette_entry(&editor, "core.follow_link").enabled); + assert!( + palette_entry(&editor, "core.follow_link") + .bindings + .is_empty() + ); + } + + #[test] + fn native_g_prefix_cancels_on_escape_intervening_input_commands_and_lifecycle() { + let directory = Directory::new(); + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("a\nb\nc".into())]) + .unwrap(); + type_text(&mut editor, "4g"); + editor.handle(Input::Escape); + assert!(!editor.g_pending()); + assert_eq!(editor.count(), None); + type_text(&mut editor, "4gj"); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); + assert!(!editor.g_pending()); + type_text(&mut editor, "gi"); + assert_eq!(editor.mode(), Mode::Insert); + assert!(!editor.g_pending()); + editor.handle(Input::Char('f')); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "a\nfb\nc"); + for command in ["plugins", "bnext", "bprev", "buffer 1", "bdelete!"] { + editor.handle(Input::Char('g')); + assert!(editor.g_pending()); + editor.execute(command); + assert!(!editor.g_pending()); + } + editor.handle(Input::Char('g')); + editor.handle(Input::Paste("f".into())); + assert!(!editor.g_pending()); + editor.handle(Input::Char('g')); + editor + .navigate_to( + directory.path("missing.md"), + TextPosition { row: 0, column: 0 }, + ) + .unwrap_err(); + assert!(!editor.g_pending()); + editor.handle(Input::Char('g')); + editor + .bind("normal", "z", Some("core.follow_link".into())) + .unwrap(); + assert!(!editor.g_pending()); + editor.set_leader("g").unwrap(); + editor.handle(Input::Char('g')); + assert!(!editor.g_pending()); + assert_eq!(editor.leader_pending(), Some("g")); + editor.handle(Input::Escape); + editor.handle(Input::Ctrl('p')); + assert_eq!( + palette_entry(&editor, "core.follow_link").bindings, + vec!["z"] + ); + } + #[test] fn semantic_snapshots_are_borrowed_buffer_local_and_invalidated_by_text_and_path() { use crate::plugin::SemanticTokenKind; diff --git a/src/lib.rs b/src/lib.rs index f1e9fde..f4352cf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/main.rs b/src/main.rs index fb96c44..39fd14b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ 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) 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 +33,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. diff --git a/src/markdown.rs b/src/markdown.rs new file mode 100644 index 0000000..2635f46 --- /dev/null +++ b/src/markdown.rs @@ -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, + pub fragment: Option, +} + +pub(crate) fn link_at(text: &str, position: TextPosition) -> Result { + 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 { + 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::::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 { + 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 { + 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), 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> { + let mut cursor = node.walk(); + node.named_children(&mut cursor) + .find(|child| child.kind() == kind) +} + +fn definitions<'a>(root: Node<'a>, text: &str) -> HashMap> { + 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 { + 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, + 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>, text: &str) -> Result { + 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 { + 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 { + 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>, + 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 { + 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> \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"), + ("", "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&b c.md)", "file").unwrap(), + target(Some("a&b c.md"), None) + ); + assert_eq!( + at("[file](<name>)", "file").unwrap(), + target(Some(""), None) + ); + assert!( + at("[file](a©b.md)", "file") + .unwrap_err() + .contains("Unsupported Markdown HTML entity") + ); + assert_eq!(heading_row("# A & B é\n", "a--b-é").unwrap(), 0); + assert!( + heading_row("# ©\n", "copy") + .unwrap_err() + .contains("Unsupported Markdown HTML entity") + ); + assert_eq!(heading_row("# ©\n# Valid\n", "valid").unwrap(), 1); + assert_eq!( + heading_row("> First\n> Second\n> ------\n", "first-second").unwrap(), + 0 + ); + } +} diff --git a/src/syntax.rs b/src/syntax.rs index e18a5b1..81327c0 100644 --- a/src/syntax.rs +++ b/src/syntax.rs @@ -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, diff --git a/src/tui.rs b/src/tui.rs index c5b9255..e682f03 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -551,6 +551,9 @@ fn draw( if let Some(sequence) = editor.leader_pending() { write!(status, " leader: {sequence}").unwrap(); } + if editor.g_pending() { + status.push_str(" g"); + } if editor.buffer_count() > 1 { write!( status,