From dd57dc041d29c8f6b8e7a8d81410d2986b6d7d00 Mon Sep 17 00:00:00 2001 From: Connor McDonald Date: Mon, 29 Jun 2026 11:21:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(refs):=20hub=E2=86=92hub=20composition,=20?= =?UTF-8?q?lint=20validation=20pass=20(#4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve `refs` from forward-declared/inert to a validated hub-composition graph — the first of two stages, deliberately stopping short of verdict propagation per the §9.3 unlock discipline (composition compounds the granularity problem, so prove the base before stacking the cascade). A `refs:` entry now names another hub by a path relative to the referencing hub, optionally `> symbol` to address one claim within it (matched against that claim's `at:` anchor, reusing the segment grammar). `surf lint` blocks a ref that is malformed, doesn't resolve to a loaded hub, points at its own hub, or names a claim the target lacks — the same fail-on-typo reasoning as `covers`. The `check` verdict is untouched: a referenced hub going stale does not yet flag its referrers (PR2). The repo's own hubs now declare their cross-hub refs, and the two prior doc-pointing refs were reclassified to prose links (docs aren't hubs). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 8 ++ docs/guides/authoring-hubs.md | 11 +- hubs/anchor.md | 2 +- hubs/cli-check.md | 4 +- hubs/cli-git.md | 3 +- hubs/cli-reference.md | 2 +- hubs/cli-stats.md | 2 +- hubs/cli-workspace.md | 5 +- hubs/hash.md | 3 +- hubs/hub-format.md | 7 +- surf-cli/src/lint.rs | 196 +++++++++++++++++++++++++++++++--- surf-cli/src/workspace.rs | 22 +++- surf-core/src/anchor.rs | 48 +++++---- surf-core/src/lib.rs | 2 + surf-core/src/refs.rs | 106 ++++++++++++++++++ 15 files changed, 375 insertions(+), 46 deletions(-) create mode 100644 surf-core/src/refs.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index abf4167..bcee7a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Added +- **`refs` hub composition — validation pass (#4).** A hub's `refs:` now compose *other hubs*: a + path relative to the referencing hub (`./resolve.md`), optionally `> symbol` to address one claim + within the target (matched against that claim's `at:` anchor). `surf lint` blocks a ref that + doesn't resolve to a loaded hub, points at its own hub, or names a claim the target lacks — the + same fail-on-typo discipline as `covers`. The `check` verdict does **not** read `refs` yet + (staleness does not propagate across hubs); this ships the validated navigation graph first, per + the §9.3 unlock discipline. The repo's own hubs now declare their cross-hub `refs`, and the two + prior doc-pointing `refs` were reclassified to prose links. - **`surf lint` consolidation nudges (#142).** Two advisory warnings push hubs away from the "claim-log" shape (one claim per function) and toward onboarding docs: a *claim-log* warning when a hub has several claims and never once uses a multi-site `at:` list, and a *thin-prose* diff --git a/docs/guides/authoring-hubs.md b/docs/guides/authoring-hubs.md index 05d8b49..855c230 100644 --- a/docs/guides/authoring-hubs.md +++ b/docs/guides/authoring-hubs.md @@ -29,9 +29,14 @@ Prose a human (or agent) reads to understand this domain. - **`at`** — the anchor: where the claim's logic lives (grammar below). - **`hash`** — the seal. Absent until you `surf verify`; the gate treats a hashless claim as *unverified*. -- **`refs` / `covers`** — forward-declared and currently inert. `refs` (hub composition) is parsed - but unused; `covers` (advisory file-scope globs) is parsed and lint-validated but never affects - `surf check`. Leave them empty unless you have a reason — the features that consume them aren't +- **`refs`** — hub composition: paths to *other hubs* this one builds on, written relative to this + hub (`./resolve.md`), optionally `> symbol` to point at one claim within the target + (`./resolve.md > resolve_nodes`, matched against that claim's `at:` anchor). `surf lint` blocks a + ref that doesn't resolve to a hub, points at this hub, or names a claim the target lacks — so a + typo can't rot silently. The `check` *verdict* doesn't read `refs` yet (a referenced hub going + stale won't flag this one); refs are a validated navigation graph for now. +- **`covers`** — advisory file-scope globs; parsed and lint-validated but never affects + `surf check`. Leave it empty unless you have a reason — the feature that consumes it isn't shipped. Where hubs live is configured by the `hubs` glob in `surf.toml` (default `hubs/*.md`); keep them diff --git a/hubs/anchor.md b/hubs/anchor.md index 200ec48..8141959 100644 --- a/hubs/anchor.md +++ b/hubs/anchor.md @@ -6,7 +6,7 @@ anchors: a 1-based `@N` positional suffix for genuine name collisions. Empty/zero/missing parts are typed parse errors. at: surf-core/src/anchor.rs > parse_anchor - hash: 2:5499582e3a55 + hash: 2:a41aca45f340 refs: [] --- diff --git a/hubs/cli-check.md b/hubs/cli-check.md index 2b1de46..133c531 100644 --- a/hubs/cli-check.md +++ b/hubs/cli-check.md @@ -27,7 +27,9 @@ anchors: clean anchors still stamped under v1, so run can nudge the one-time `surf verify` upgrade. at: surf-cli/src/check.rs > check_workspace hash: 2:4f5890aca70c -refs: [] +refs: + - ./cli-git.md + - ./cli-verify.md --- # surf check diff --git a/hubs/cli-git.md b/hubs/cli-git.md index d34b848..4a054e2 100644 --- a/hubs/cli-git.md +++ b/hubs/cli-git.md @@ -32,7 +32,8 @@ anchors: Best-effort: a pure mv with no content match may show as delete+add and go undetected. at: surf-cli/src/git.rs > renamed_to hash: 2:260267073598 -refs: [] +refs: + - ./rename.md --- # git helpers diff --git a/hubs/cli-reference.md b/hubs/cli-reference.md index 43ba106..4601772 100644 --- a/hubs/cli-reference.md +++ b/hubs/cli-reference.md @@ -10,7 +10,7 @@ anchors: before sealing. at: surf-cli/src/main.rs > Command hash: 2:1af394872add -refs: ["../docs/reference/commands.md"] +refs: [] --- # CLI reference surface diff --git a/hubs/cli-stats.md b/hubs/cli-stats.md index 10f48f9..e71b52d 100644 --- a/hubs/cli-stats.md +++ b/hubs/cli-stats.md @@ -20,7 +20,7 @@ anchors: silent zero or a quietly-narrowed hub set. at: surf-cli/src/stats.rs > compute hash: 2:1422981eb9fa -refs: ["../docs/guides/stats.md"] +refs: [] --- # surf stats diff --git a/hubs/cli-workspace.md b/hubs/cli-workspace.md index 316582c..4eb0716 100644 --- a/hubs/cli-workspace.md +++ b/hubs/cli-workspace.md @@ -11,7 +11,10 @@ anchors: deduped. at: surf-cli/src/workspace.rs > Workspace > hub_paths hash: 2:c69c8264bcfd -refs: [] +refs: + - ./cli-check.md + - ./cli-lint.md + - ./config.md --- # Workspace diff --git a/hubs/hash.md b/hubs/hash.md index 69dfec0..4eea9ff 100644 --- a/hubs/hash.md +++ b/hubs/hash.md @@ -35,7 +35,8 @@ anchors: multiple sites combine order-sensitively, so the claim is stale if any listed span changes. at: surf-core/src/hash.rs > combine_site_hashes hash: 2:cbbbbc3b2237 -refs: [] +refs: + - ./cli-verify.md --- # Canonical hashing diff --git a/hubs/hub-format.md b/hubs/hub-format.md index 7f5f03c..f05e519 100644 --- a/hubs/hub-format.md +++ b/hubs/hub-format.md @@ -12,7 +12,9 @@ anchors: replaces/inserts only its hash line, so an unchanged hash is byte-identical. at: surf-core/src/hub.rs > set_anchor_hash hash: 2:29805baa85ea -refs: [] +refs: + - ./cli-lint.md + - ./cli-check.md covers: - surf-core/src/hub.rs --- @@ -23,7 +25,8 @@ A hub is the unit every command reads and writes: a `---`-fenced YAML frontmatte machine-checkable `anchors`) followed by a markdown body (the prose a human or agent reads). `parse_hub` is the contract everything else binds to — its shape is why `at:` can be a scalar or a list, why `hash` is optional until verified, and why unknown fields are rejected (so a typo can't -masquerade as a new field) while the forward-declared `refs`/`covers` are accepted but inert. +masquerade as a new field) while `refs`/`covers` are accepted and lint-validated but never gate the +`check` verdict. **The distinction that drives the design:** a human reviews every write, so edits must be *surgical*. Writes go through the line-level editor (`set_anchor_hash` / `set_anchor_at`) rather diff --git a/surf-cli/src/lint.rs b/surf-cli/src/lint.rs index 007402a..5571f1b 100644 --- a/surf-cli/src/lint.rs +++ b/surf-cli/src/lint.rs @@ -105,14 +105,23 @@ fn lint_workspace(ws: &Workspace) -> Result> { let mut unhealthy: HashSet = HashSet::new(); let mut owner: HashMap = HashMap::new(); - for loaded in ws.iter_hubs()? { - let rel = loaded.rel; - let hub = match loaded.hub { + // `refs` validation needs every other hub on hand, so load once and index the well-formed + // hubs by rel. A malformed hub is absent from the index (it gets its own block below), so a + // ref into it reads as "does not resolve to a hub" — which it effectively doesn't. + let loaded = ws.iter_hubs()?; + let hub_index: HashMap<&str, &surf_core::Hub> = loaded + .iter() + .filter_map(|l| l.hub.as_ref().ok().map(|h| (l.rel.as_str(), h))) + .collect(); + + for loaded_hub in &loaded { + let rel = loaded_hub.rel.as_str(); + let hub = match &loaded_hub.hub { Ok(hub) => hub, Err(e) => { findings.push(Finding { severity: Severity::Block, - hub: rel, + hub: rel.to_string(), claim: String::new(), at: String::new(), message: format!("invalid hub: {e}"), @@ -125,7 +134,7 @@ fn lint_workspace(ws: &Workspace) -> Result> { for site in claim.at.sites() { let outcome = lint_site( ws, - &rel, + rel, &claim.claim, site, claim.hash.as_deref(), @@ -138,11 +147,11 @@ fn lint_workspace(ws: &Workspace) -> Result> { owner .entry(info.file.clone()) .and_modify(|h| { - if rel < *h { - *h = rel.clone(); + if rel < h.as_str() { + *h = rel.to_string(); } }) - .or_insert_with(|| rel.clone()); + .or_insert_with(|| rel.to_string()); if info.resolved { covered.entry(info.file).or_default().insert(info.segments); } else { @@ -152,14 +161,15 @@ fn lint_workspace(ws: &Workspace) -> Result> { } } - lint_covers(&rel, &hub, &mut findings); - lint_claim_log(&rel, &hub, &mut findings); - lint_thin_prose(&rel, &hub, &mut findings); + lint_covers(rel, hub, &mut findings); + lint_refs(rel, hub, &hub_index, &mut findings); + lint_claim_log(rel, hub, &mut findings); + lint_thin_prose(rel, hub, &mut findings); if hub.frontmatter.anchors.len() > MAX_ANCHORS_PER_HUB { findings.push(Finding { severity: Severity::Warn, - hub: rel.clone(), + hub: rel.to_string(), claim: String::new(), at: String::new(), message: format!( @@ -282,6 +292,69 @@ fn lint_covers(rel: &str, hub: &surf_core::Hub, findings: &mut Vec) { } } +/// Validate a hub's `refs` composition (§9.3, #4). Each entry names another hub by a path +/// relative to this one, optionally `> segment` to address a claim within it. A ref that doesn't +/// resolve to a loaded hub, points at its own hub, or names a claim no anchor in the target +/// matches is a structural error and blocks — the same fail-on-typo reasoning as `covers`. The +/// verdict does not read `refs` yet (PR2), so lint is the only thing that acts on them. +fn lint_refs( + rel: &str, + hub: &surf_core::Hub, + hub_index: &HashMap<&str, &surf_core::Hub>, + findings: &mut Vec, +) { + for raw in &hub.frontmatter.refs { + let mut block = |message: String| { + findings.push(Finding { + severity: Severity::Block, + hub: rel.to_string(), + claim: String::new(), + at: raw.clone(), + message, + }); + }; + + let parsed = match surf_core::parse_ref(raw) { + Ok(r) => r, + Err(e) => { + block(format!("invalid `refs` entry \"{raw}\": {e}")); + continue; + } + }; + + let target_rel = crate::workspace::resolve_ref_path(rel, &parsed.path); + if target_rel == rel { + block(format!("ref \"{raw}\" points at its own hub")); + continue; + } + let Some(target) = hub_index.get(target_rel.as_str()) else { + block(format!( + "ref \"{raw}\" does not resolve to a hub (looked for `{target_rel}`) — `refs` compose hubs, not arbitrary files" + )); + continue; + }; + + if !parsed.segments.is_empty() { + let names: Vec<&str> = parsed.segments.iter().map(|s| s.name.as_str()).collect(); + let matched = target.frontmatter.anchors.iter().any(|c| { + c.at.sites().iter().any(|site| { + parse_anchor(site).is_ok_and(|a| { + let anchor_names: Vec<&str> = + a.segments.iter().map(|s| s.name.as_str()).collect(); + anchor_names.ends_with(&names) + }) + }) + }); + if !matched { + block(format!( + "ref \"{raw}\" names a claim `{}` that no anchor in `{target_rel}` matches", + names.join(" > ") + )); + } + } + } +} + /// Markdown link targets (`](target)`) in a fragment of text. fn link_targets(text: &str) -> impl Iterator { text.split("](") @@ -904,6 +977,105 @@ mod tests { assert_eq!(warn.at, "src/auth.rs"); } + #[test] + fn refs_to_existing_hub_is_silent() { + let (_t, ws) = ws_with(&[ + ("src/auth.rs", "pub fn greet() {}\n"), + ( + "hubs/a.md", + "---\nsummary: x\nrefs:\n - ./b.md\nanchors:\n - claim: g\n at: src/auth.rs > greet\n---\n", + ), + ("hubs/b.md", "---\nsummary: y\n---\n# B\n"), + ]); + assert!(lint_workspace(&ws).unwrap().is_empty()); + } + + #[test] + fn refs_to_missing_hub_blocks() { + let (_t, ws) = ws_with(&[("hubs/a.md", "---\nsummary: x\nrefs:\n - ./gone.md\n---\n")]); + let f = lint_workspace(&ws).unwrap(); + let block = f + .iter() + .find(|x| x.message.contains("does not resolve to a hub")) + .expect("expected a dangling-ref error"); + assert_eq!(block.severity, Severity::Block); + assert_eq!(block.at, "./gone.md"); + } + + #[test] + fn refs_to_non_hub_file_blocks() { + // A doc path is not a hub — the reclassification trigger for the two ../docs refs (#4). + let (_t, ws) = ws_with(&[( + "hubs/a.md", + "---\nsummary: x\nrefs:\n - ../docs/guide.md\n---\n", + )]); + let f = lint_workspace(&ws).unwrap(); + assert!(f + .iter() + .any(|x| x.severity == Severity::Block && x.message.contains("does not resolve"))); + } + + #[test] + fn self_ref_blocks() { + let (_t, ws) = ws_with(&[("hubs/a.md", "---\nsummary: x\nrefs:\n - ./a.md\n---\n")]); + let f = lint_workspace(&ws).unwrap(); + let block = f + .iter() + .find(|x| x.message.contains("its own hub")) + .expect("expected a self-ref error"); + assert_eq!(block.severity, Severity::Block); + } + + #[test] + fn malformed_ref_blocks() { + let (_t, ws) = ws_with(&[( + "hubs/a.md", + "---\nsummary: x\nrefs:\n - '> dangling'\n---\n", + )]); + let f = lint_workspace(&ws).unwrap(); + assert!(f + .iter() + .any(|x| x.severity == Severity::Block && x.message.contains("invalid `refs` entry"))); + } + + #[test] + fn claim_ref_matches_anchor_suffix() { + // `./b.md > greet` resolves: b.md has a claim anchored at `src/auth.rs > greet`. + let (_t, ws) = ws_with(&[ + ("src/auth.rs", "pub fn greet() {}\n"), + ( + "hubs/a.md", + "---\nsummary: x\nrefs:\n - ./b.md > greet\n---\n", + ), + ( + "hubs/b.md", + "---\nsummary: y\nanchors:\n - claim: g\n at: src/auth.rs > greet\n---\n", + ), + ]); + assert!(lint_workspace(&ws).unwrap().is_empty()); + } + + #[test] + fn claim_ref_with_no_matching_anchor_blocks() { + let (_t, ws) = ws_with(&[ + ("src/auth.rs", "pub fn greet() {}\n"), + ( + "hubs/a.md", + "---\nsummary: x\nrefs:\n - ./b.md > nonexistent\n---\n", + ), + ( + "hubs/b.md", + "---\nsummary: y\nanchors:\n - claim: g\n at: src/auth.rs > greet\n---\n", + ), + ]); + let f = lint_workspace(&ws).unwrap(); + let block = f + .iter() + .find(|x| x.message.contains("no anchor in")) + .expect("expected a no-matching-claim error"); + assert_eq!(block.severity, Severity::Block); + } + fn agents_findings(ws: &Workspace) -> Vec { lint_workspace(ws) .unwrap() diff --git a/surf-cli/src/workspace.rs b/surf-cli/src/workspace.rs index da6d26b..0c1b5bc 100644 --- a/surf-cli/src/workspace.rs +++ b/surf-cli/src/workspace.rs @@ -3,7 +3,7 @@ //! This is the I/O layer that `surf-core`'s pure parsers feed into. use anyhow::{Context, Result}; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use surf_core::config::{parse_config, Config, CONFIG_FILE}; use surf_core::{parse_anchor, parse_hub, Anchor, Hub, HubError, Lang}; @@ -76,6 +76,26 @@ impl Workspace { } } +/// Normalize a `refs:` path (written relative to the referencing hub, per #4) to a +/// workspace-relative path, so it can be matched against `LoadedHub::rel`. `.`/`..` are +/// resolved by component arithmetic — no filesystem access — and the result uses `/` to match +/// the forward-slash rels `iter_hubs` produces. +pub fn resolve_ref_path(referencing_rel: &str, ref_path: &str) -> String { + let mut stack: Vec = Vec::new(); + let base = Path::new(referencing_rel).parent().unwrap_or(Path::new("")); + for comp in base.components().chain(Path::new(ref_path).components()) { + match comp { + Component::CurDir => {} + Component::ParentDir => { + stack.pop(); + } + Component::Normal(c) => stack.push(c.to_string_lossy().into_owned()), + Component::RootDir | Component::Prefix(_) => {} + } + } + stack.join("/") +} + /// Why a single `at:` site couldn't be loaded for hashing/resolution. Distinct variants so /// `check`/`verify` can report the precise cause rather than a generic "does not resolve". #[derive(Debug)] diff --git a/surf-core/src/anchor.rs b/surf-core/src/anchor.rs index d7fd8ba..53db6de 100644 --- a/surf-core/src/anchor.rs +++ b/surf-core/src/anchor.rs @@ -63,27 +63,7 @@ pub fn parse_anchor(input: &str) -> Result { let mut segments = Vec::new(); for raw in parts { - let seg = raw.trim(); - if seg.is_empty() { - return Err(AnchorParseError::EmptySegment); - } - let (name, index) = match seg.split_once('@') { - Some((name, idx)) => { - let idx = idx.trim(); - let parsed = idx - .parse::() - .map_err(|_| AnchorParseError::BadIndex(idx.to_string()))?; - if parsed == 0 { - return Err(AnchorParseError::BadIndex(idx.to_string())); - } - (name.trim().to_string(), Some(parsed)) - } - None => (seg.to_string(), None), - }; - if name.is_empty() { - return Err(AnchorParseError::EmptySegment); - } - segments.push(Segment { name, index }); + segments.push(parse_segment(raw)?); } if segments.is_empty() { @@ -93,6 +73,32 @@ pub fn parse_anchor(input: &str) -> Result { Ok(Anchor { file, segments }) } +/// Parse one `>`-delimited segment (`name` or `name@N`). Shared by `at:` anchors and `refs:` +/// (#4), so the positional-collision grammar stays identical across both. +pub(crate) fn parse_segment(raw: &str) -> Result { + let seg = raw.trim(); + if seg.is_empty() { + return Err(AnchorParseError::EmptySegment); + } + let (name, index) = match seg.split_once('@') { + Some((name, idx)) => { + let idx = idx.trim(); + let parsed = idx + .parse::() + .map_err(|_| AnchorParseError::BadIndex(idx.to_string()))?; + if parsed == 0 { + return Err(AnchorParseError::BadIndex(idx.to_string())); + } + (name.trim().to_string(), Some(parsed)) + } + None => (seg.to_string(), None), + }; + if name.is_empty() { + return Err(AnchorParseError::EmptySegment); + } + Ok(Segment { name, index }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/surf-core/src/lib.rs b/surf-core/src/lib.rs index 217fb58..291a93a 100644 --- a/surf-core/src/lib.rs +++ b/surf-core/src/lib.rs @@ -5,6 +5,7 @@ pub mod config; pub mod hash; pub mod hub; pub mod lang; +pub mod refs; pub mod rename; pub mod report; pub mod resolve; @@ -17,6 +18,7 @@ pub use hash::{ }; pub use hub::{parse_hub, set_anchor_at, set_anchor_hash, At, Claim, Frontmatter, Hub, HubError}; pub use lang::Lang; +pub use refs::{parse_ref, Ref, RefParseError}; pub use rename::find_renamed; pub use report::{CheckReport, Divergence, DivergenceKind, REPORT_VERSION}; pub use resolve::{ diff --git a/surf-core/src/refs.rs b/surf-core/src/refs.rs new file mode 100644 index 0000000..5d3e149 --- /dev/null +++ b/surf-core/src/refs.rs @@ -0,0 +1,106 @@ +//! Parser for the `refs:` hub-composition grammar (§9.3, #4): `path` (a hub, relative to the +//! referencing hub) optionally followed by `> A > B@N` to address one claim *within* that hub. +//! This is pure parsing — the path is not resolved and the claim is not matched here; that is +//! `lint`'s job over the workspace's loaded hubs. + +use crate::anchor::{parse_segment, AnchorParseError, Segment}; + +/// A parsed `refs:` entry. `segments` is empty for a whole-hub reference; non-empty addresses a +/// specific claim (matched by anchor suffix), reusing the `at:` segment grammar. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Ref { + pub path: String, + pub segments: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RefParseError { + Empty, + EmptyPath, + Segment(AnchorParseError), +} + +impl std::fmt::Display for RefParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RefParseError::Empty => write!(f, "ref is empty"), + RefParseError::EmptyPath => write!(f, "ref has no hub path"), + RefParseError::Segment(e) => write!(f, "{e}"), + } + } +} + +impl std::error::Error for RefParseError {} + +pub fn parse_ref(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err(RefParseError::Empty); + } + + let mut parts = trimmed.split('>'); + let path = parts + .next() + .expect("split yields at least one part") + .trim() + .to_string(); + if path.is_empty() { + return Err(RefParseError::EmptyPath); + } + + let mut segments = Vec::new(); + for raw in parts { + segments.push(parse_segment(raw).map_err(RefParseError::Segment)?); + } + + Ok(Ref { path, segments }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn seg(name: &str, index: Option) -> Segment { + Segment { + name: name.to_string(), + index, + } + } + + #[test] + fn whole_hub_ref_has_no_segments() { + let r = parse_ref("./cli-verify.md").unwrap(); + assert_eq!(r.path, "./cli-verify.md"); + assert!(r.segments.is_empty()); + } + + #[test] + fn claim_ref_carries_segments() { + let r = parse_ref("./resolve.md > resolve_nodes").unwrap(); + assert_eq!(r.path, "./resolve.md"); + assert_eq!(r.segments, vec![seg("resolve_nodes", None)]); + } + + #[test] + fn reuses_anchor_segment_grammar() { + let r = parse_ref("../hubs/m.md > Builder > Set @2").unwrap(); + assert_eq!(r.path, "../hubs/m.md"); + assert_eq!(r.segments, vec![seg("Builder", None), seg("Set", Some(2))]); + } + + #[test] + fn errors() { + assert_eq!(parse_ref(" "), Err(RefParseError::Empty)); + assert_eq!(parse_ref("> resolve_nodes"), Err(RefParseError::EmptyPath)); + assert_eq!( + parse_ref("./a.md > "), + Err(RefParseError::Segment(AnchorParseError::EmptySegment)) + ); + assert_eq!( + parse_ref("./a.md > x @0"), + Err(RefParseError::Segment(AnchorParseError::BadIndex( + "0".to_string() + ))) + ); + } +}