From b21b67598079f25f9b363932ec4621ca2274730b Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Thu, 1 Oct 2026 16:31:41 +0200 Subject: [PATCH] Add //resolve for link target resolution A read-write 9P file per document: write a link target, read back its structural resolution. resolve_target dispatches by target syntax, mirroring Emacs org-link-search precedence but terminating in a section id rather than a buffer position: #custom-id / id:uuid / *Headline -> dedicated (exact section) http(s)/mailto/ftp/file -> external (OS-handled) bare target naming a heading -> fuzzy (section) otherwise -> notfound Response schema carries kind/section/offset; the offset field is wired now (0 for current cases) so Phase 2 fuzzy text/<>/#+NAME search with an intra-section byte offset slots in without changing the contract. Resolved ids are directly valid 9P paths (//). --- crates/ork-server/src/namespace.rs | 12 +++ crates/ork-server/src/state.rs | 165 +++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+) diff --git a/crates/ork-server/src/namespace.rs b/crates/ork-server/src/namespace.rs index efaac60..8ae77ad 100644 --- a/crates/ork-server/src/namespace.rs +++ b/crates/ork-server/src/namespace.rs @@ -144,6 +144,18 @@ fn build_doc_node(state: Arc, doc_name: &str) -> FsNode { } })), + // resolve - write a link target, read its structural resolution + // (kind + section id + offset). See OrkState::resolve_target. + virtfs::file("resolve", 0o666, virtfs::rdwr({ + let state = state.clone(); + let name = name.clone(); + move |data| { + let target = String::from_utf8_lossy(data); + state.resolve_target(&name, &target) + .map(|s| s.into_bytes()) + } + })), + // new - create top-level section virtfs::file("new", 0o666, virtfs::rdwr({ let state = state.clone(); diff --git a/crates/ork-server/src/state.rs b/crates/ork-server/src/state.rs index cfceb7d..6597fa6 100644 --- a/crates/ork-server/src/state.rs +++ b/crates/ork-server/src/state.rs @@ -1858,6 +1858,106 @@ impl OrkState { lines.sort(); Ok(lines.join("\n") + if lines.is_empty() { "" } else { "\n" }) } + + /// Resolve a link target to a structural (and optionally textual) address + /// within a document. Mirrors Emacs `org-link-search`'s precedence ladder, + /// but terminates each branch in a section id + offset rather than moving + /// point. + /// + /// Returns a small structured blob: + /// ```text + /// kind: dedicated | fuzzy | external | notfound + /// section: # omitted for external/notfound + /// offset: # byte offset within the section, 0 for dedicated + /// ``` + /// + /// Dispatch by target syntax: + /// - `#custom-id` -> dedicated (CUSTOM_ID match) + /// - `id:uuid` -> dedicated (ID match) + /// - `*Headline` -> dedicated (exact headline title) + /// - external URL schemes -> external (handed to the OS by callers) + /// - anything else (fuzzy) -> fuzzy section match (Phase 2: + offset) + pub fn resolve_target(&self, doc_name: &str, target: &str) -> Result { + let target = target.trim(); + if target.is_empty() { + return Ok("kind: notfound\n".to_string()); + } + + // External URL schemes are not navigation within the model. + if is_external_target(target) { + return Ok("kind: external\n".to_string()); + } + + let Some(d) = self.get_doc(doc_name) else { + return Ok("kind: notfound\n".to_string()); + }; + + // Dedicated: custom-id (#id) and id: links map straight to a section id, + // because section_id() already prefers CUSTOM_ID > ID. + if let Some(cid) = target.strip_prefix('#') { + if let Some(section) = self.find_section(&d.doc, cid) { + return Ok(dedicated_result(&self.section_id(section))); + } + return Ok("kind: notfound\n".to_string()); + } + if let Some(uuid) = target.strip_prefix("id:") { + if let Some(section) = self.find_section(&d.doc, uuid) { + return Ok(dedicated_result(&self.section_id(section))); + } + return Ok("kind: notfound\n".to_string()); + } + + // Dedicated: *Headline is an exact headline-title match. + if let Some(headline) = target.strip_prefix('*') { + let want = normalize_search(headline); + if let Some(section) = d.doc.all_sections() + .find(|s| normalize_search(&s.headline.title_text()) == want) + { + return Ok(dedicated_result(&self.section_id(section))); + } + return Ok("kind: notfound\n".to_string()); + } + + // Fuzzy: first try an exact headline-title match (a bare target that + // happens to name a heading), then fall back to the slug id. Phase 2 + // will extend this with <>/#+NAME/plain-text search returning a + // byte offset within the owning section. + let want = normalize_search(target); + if let Some(section) = d.doc.all_sections() + .find(|s| normalize_search(&s.headline.title_text()) == want) + { + return Ok(fuzzy_result(&self.section_id(section), 0)); + } + if let Some(section) = self.find_section(&d.doc, target) { + return Ok(fuzzy_result(&self.section_id(section), 0)); + } + + Ok("kind: notfound\n".to_string()) + } +} + +/// Whether a link target is an external URL scheme (handed to the OS, not +/// resolved within the document model). +fn is_external_target(target: &str) -> bool { + const SCHEMES: &[&str] = &["http:", "https:", "mailto:", "ftp:", "file:"]; + let lower = target.to_ascii_lowercase(); + SCHEMES.iter().any(|s| lower.starts_with(s)) +} + +/// Normalize a search string the way Emacs `org-link-search` does: collapse +/// internal whitespace runs to a single space and trim the ends. +fn normalize_search(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Format a dedicated (exact structural) resolution result. +fn dedicated_result(section_id: &str) -> String { + format!("kind: dedicated\nsection: {}\noffset: 0\n", section_id) +} + +/// Format a fuzzy resolution result with an intra-section byte offset. +fn fuzzy_result(section_id: &str, offset: usize) -> String { + format!("kind: fuzzy\nsection: {}\noffset: {}\n", section_id, offset) } /// One parsed query term. @@ -2074,6 +2174,71 @@ mod tests { state.move_section("w", "three", MoveDir::Down).unwrap(); } + #[test] + fn test_resolve_custom_id() { + let (_d, state) = state_with( + "#+TITLE: W\n* Alpha\n:PROPERTIES:\n:CUSTOM_ID: my-anchor\n:END:\n* Beta\n"); + let r = state.resolve_target("w", "#my-anchor").unwrap(); + assert!(r.contains("kind: dedicated"), "{}", r); + assert!(r.contains("section: my-anchor"), "{}", r); + assert!(r.contains("offset: 0"), "{}", r); + } + + #[test] + fn test_resolve_id() { + let (_d, state) = state_with( + "#+TITLE: W\n* Alpha\n:PROPERTIES:\n:ID: abc-123\n:END:\n"); + let r = state.resolve_target("w", "id:abc-123").unwrap(); + assert!(r.contains("kind: dedicated"), "{}", r); + assert!(r.contains("section: abc-123"), "{}", r); + } + + #[test] + fn test_resolve_headline() { + let (_d, state) = state_with("#+TITLE: W\n* Project Plan\n* Other\n"); + let r = state.resolve_target("w", "*Project Plan").unwrap(); + assert!(r.contains("kind: dedicated"), "{}", r); + assert!(r.contains("section: project-plan"), "{}", r); + } + + #[test] + fn test_resolve_headline_whitespace_insensitive() { + let (_d, state) = state_with("#+TITLE: W\n* Project Plan\n"); + // Extra internal whitespace in the target still matches. + let r = state.resolve_target("w", "*Project Plan").unwrap(); + assert!(r.contains("kind: dedicated"), "{}", r); + assert!(r.contains("section: project-plan"), "{}", r); + } + + #[test] + fn test_resolve_fuzzy_headline() { + let (_d, state) = state_with("#+TITLE: W\n* Project Plan\n"); + // Bare target naming a heading resolves as fuzzy. + let r = state.resolve_target("w", "Project Plan").unwrap(); + assert!(r.contains("kind: fuzzy"), "{}", r); + assert!(r.contains("section: project-plan"), "{}", r); + } + + #[test] + fn test_resolve_external() { + let (_d, state) = state_with("#+TITLE: W\n* One\n"); + for t in ["https://orgmode.org", "mailto:a@b.c", "file:notes.org"] { + let r = state.resolve_target("w", t).unwrap(); + assert!(r.contains("kind: external"), "{} -> {}", t, r); + } + } + + #[test] + fn test_resolve_notfound() { + let (_d, state) = state_with("#+TITLE: W\n* One\n"); + let r = state.resolve_target("w", "#nope").unwrap(); + assert!(r.contains("kind: notfound"), "{}", r); + let r = state.resolve_target("w", "*No Such Heading").unwrap(); + assert!(r.contains("kind: notfound"), "{}", r); + let r = state.resolve_target("w", "").unwrap(); + assert!(r.contains("kind: notfound"), "{}", r); + } + #[test] fn test_relevel_section() { let (_d, state) = state_with("#+TITLE: W\n* Parent\n** Child\n* Other\n");