feat: follow Markdown file links and heading fragments
This commit is contained in:
+448
-2
@@ -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<Mode, HashMap<Vec<Input>, Binding>>,
|
||||
leader_suffix: Vec<Input>,
|
||||
leader_display: String,
|
||||
g_pending: bool,
|
||||
palette: Option<Palette>,
|
||||
hover: Option<Hover>,
|
||||
startup_diagnostic: Option<StartupDiagnostic>,
|
||||
@@ -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\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".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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
+707
@@ -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"),
|
||||
("`[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_ \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("<name>"), 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user