Add /<doc>/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/<<target>>/#+NAME search with an intra-section byte offset slots in without changing the contract. Resolved ids are directly valid 9P paths (/<doc>/<id>).
This commit is contained in:
parent
b79970b62f
commit
b21b675980
|
|
@ -144,6 +144,18 @@ fn build_doc_node(state: Arc<OrkState>, 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();
|
||||
|
|
|
|||
|
|
@ -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: <section-id> # omitted for external/notfound
|
||||
/// offset: <n> # 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<String> {
|
||||
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 <<target>>/#+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::<Vec<_>>().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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue