diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 87a5e4f..4d3b907 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -1,6 +1,6 @@ --- title: Commands -description: The surf CLI — init, new, suggest, lint, check, and verify, with their flags and exit behavior. +description: The surf CLI — init, new, suggest, for, lint, check, and verify, with their flags and exit behavior. --- - **`surf init`** — bootstrap a workspace: write `surf.toml` and create the hubs directory @@ -15,6 +15,11 @@ description: The surf CLI — init, new, suggest, lint, check, and verify, with empty. (Suggestions are callables-only to avoid over-anchoring fatigue; anchoring itself also supports constants, type aliases, and class attributes — see [Authoring hubs](../guides/authoring-hubs.md).) +- **`surf for [symbol] [--format human|json]`** — reverse lookup: list every hub + claim + anchored into ``, so you can pull up the documentation governing a file *before* you edit + it (the inverse of `suggest`). An optional trailing `symbol` narrows to anchors whose first + segment matches. Read-only and always exits 0 — a query, not a gate. `--format json` emits a + versioned envelope (`{version, path, matches}`) for agents. - **`surf lint [--format human|json]`** — validate frontmatter and that every `at:` resolves to exactly one symbol. Blocks on ambiguous or vanished anchors; **warns** (and suggests `verify --follow`) on a symbol that was merely renamed, or a file that git reports has moved. diff --git a/hubs/cli-for.md b/hubs/cli-for.md new file mode 100644 index 0000000..08be830 --- /dev/null +++ b/hubs/cli-for.md @@ -0,0 +1,24 @@ +--- +summary: surf for — reverse lookup of hubs/claims anchored into a file; read-only query. +anchors: + - claim: > + run normalizes the queried path to workspace-root-relative form, finds the matching claims, + and prints them grouped by hub (human) or as a versioned {version, path, matches} envelope + (JSON). It is a query, not a gate, so it always exits 0 whether or not anything matched. + at: surf-cli/src/for_path.rs > run + hash: 0e15525b1340 + - claim: > + find collects every claim whose anchored file equals the queried path (matched on path only — + no source parse), optionally narrowed to anchors whose first segment is the given symbol. + Malformed hubs are skipped rather than erroring, and results are sorted by hub then anchor. + at: surf-cli/src/for_path.rs > find + hash: 749bdeb8e9d6 +refs: [] +--- + +# surf for + +Delivers the discoverability half of the thesis: a fast way to pull up the claims governing a +file before touching its logic. `run` normalizes the queried path to workspace-root-relative form, +calls `find`, and prints matches grouped by hub (human) or as a versioned `{version, path, +matches}` envelope (JSON). No model, no network, no source parse — purely a read over the hub set. diff --git a/hubs/cli-reference.md b/hubs/cli-reference.md index 396ac0a..65a4538 100644 --- a/hubs/cli-reference.md +++ b/hubs/cli-reference.md @@ -4,10 +4,11 @@ anchors: - claim: > The CLI exposes exactly these subcommands with these flags: init; new ; lint [--format]; check [--format] [--base ] [--files ]; verify [] [--follow] - [--format]; suggest [--format]. Adding, removing, or renaming a command or flag, or - changing a default, diverges this anchor — re-read docs/reference/commands.md before sealing. + [--format]; suggest [--format]; for [symbol] [--format]. Adding, removing, or + renaming a command or flag, or changing a default, diverges this anchor — re-read + docs/reference/commands.md before sealing. at: surf-cli/src/main.rs > Command - hash: 9b09340872e6 + hash: 7dcf2e5a1d8b refs: ["../docs/reference/commands.md"] --- diff --git a/surf-cli/src/for_path.rs b/surf-cli/src/for_path.rs new file mode 100644 index 0000000..391bb9d --- /dev/null +++ b/surf-cli/src/for_path.rs @@ -0,0 +1,188 @@ +//! `surf for [symbol]` — reverse lookup: which hubs/claims anchor into a file (#31). +//! The inverse of authoring — pull up the claims governing a file before you edit it. Reuses the +//! hub/anchor machinery and only matches on the anchored *path*, so it stays deterministic with +//! no model, network, or source parse. A query, not a gate: it always exits 0. + +use crate::format::Format; +use crate::workspace::Workspace; +use anyhow::{Context, Result}; +use serde::Serialize; +use std::process::ExitCode; +use surf_core::{parse_anchor, parse_hub, REPORT_VERSION}; + +#[derive(Debug, Clone, Serialize)] +struct ForMatch { + hub: String, + at: String, + claim: String, +} + +#[derive(Debug, Clone, Serialize)] +struct ForReport { + version: u32, + path: String, + matches: Vec, +} + +pub fn run(ws: &Workspace, path: &str, symbol: Option<&str>, format: Format) -> Result { + let query = normalize(ws, path); + let matches = find(ws, &query, symbol)?; + + match format { + Format::Json => { + let report = ForReport { + version: REPORT_VERSION, + path: query, + matches, + }; + println!("{}", serde_json::to_string_pretty(&report)?); + } + Format::Human => print_human(&query, symbol, &matches), + } + Ok(ExitCode::SUCCESS) +} + +/// To workspace-root-relative form (how anchors are written): strip a leading `./`, and make an +/// absolute path under the root relative. Anything else is taken as already root-relative. +fn normalize(ws: &Workspace, path: &str) -> String { + let trimmed = path.strip_prefix("./").unwrap_or(path); + let p = std::path::Path::new(trimmed); + if p.is_absolute() { + if let Ok(rel) = p.strip_prefix(&ws.root) { + return rel.to_string_lossy().into_owned(); + } + } + trimmed.to_string() +} + +fn find(ws: &Workspace, query: &str, symbol: Option<&str>) -> Result> { + let mut out = Vec::new(); + for hub_path in ws.hub_paths()? { + let rel_hub = hub_path + .strip_prefix(&ws.root) + .unwrap_or(&hub_path) + .display() + .to_string(); + let content = std::fs::read_to_string(&hub_path) + .with_context(|| format!("reading {}", hub_path.display()))?; + let Ok(hub) = parse_hub(&content) else { + // Malformed hubs are lint's job; skip rather than error out of a query. + continue; + }; + for claim in &hub.frontmatter.anchors { + for site in claim.at.sites() { + let Ok(anchor) = parse_anchor(site) else { + continue; + }; + if anchor.file != query { + continue; + } + if let Some(sym) = symbol { + if anchor.segments.first().map(|s| s.name.as_str()) != Some(sym) { + continue; + } + } + out.push(ForMatch { + hub: rel_hub.clone(), + at: site.clone(), + claim: claim.claim.trim().to_string(), + }); + } + } + } + out.sort_by(|a, b| (&a.hub, &a.at).cmp(&(&b.hub, &b.at))); + Ok(out) +} + +fn print_human(query: &str, symbol: Option<&str>, matches: &[ForMatch]) { + if matches.is_empty() { + match symbol { + Some(sym) => println!("surf for: no hubs anchor into {query} > {sym}."), + None => println!("surf for: no hubs anchor into {query}."), + } + return; + } + let mut current_hub = ""; + for m in matches { + if m.hub != current_hub { + println!("{}", m.hub); + current_hub = &m.hub; + } + println!(" {}", m.at); + println!(" claim: {}", m.claim); + } + println!("surf for: {} claim(s) anchor into {query}.", matches.len()); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn ws_with(files: &[(&str, &str)]) -> (tempfile::TempDir, Workspace) { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fs::write(root.join("surf.toml"), "").unwrap(); + fs::create_dir_all(root.join("hubs")).unwrap(); + for (rel, content) in files { + let p = root.join(rel); + if let Some(parent) = p.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(p, content).unwrap(); + } + let ws = Workspace::discover(root).unwrap(); + (tmp, ws) + } + + const HUB: &str = "---\nsummary: x\nanchors:\n - claim: foo does a thing\n at: src/x.rs > foo\n - claim: bar does another\n at: src/x.rs > bar\n - claim: elsewhere\n at: src/y.rs > baz\n---\n"; + + #[test] + fn finds_all_claims_anchoring_a_file() { + let (_t, ws) = ws_with(&[("hubs/a.md", HUB)]); + let m = find(&ws, "src/x.rs", None).unwrap(); + let ats: Vec<&str> = m.iter().map(|x| x.at.as_str()).collect(); + assert_eq!(ats, vec!["src/x.rs > bar", "src/x.rs > foo"]); + } + + #[test] + fn symbol_narrows_to_one() { + let (_t, ws) = ws_with(&[("hubs/a.md", HUB)]); + let m = find(&ws, "src/x.rs", Some("foo")).unwrap(); + assert_eq!(m.len(), 1); + assert_eq!(m[0].at, "src/x.rs > foo"); + assert_eq!(m[0].claim, "foo does a thing"); + } + + #[test] + fn no_anchors_is_empty() { + let (_t, ws) = ws_with(&[("hubs/a.md", HUB)]); + assert!(find(&ws, "src/nope.rs", None).unwrap().is_empty()); + } + + #[test] + fn normalize_strips_dot_slash_and_absolute_root() { + let (_t, ws) = ws_with(&[("hubs/a.md", HUB)]); + assert_eq!(normalize(&ws, "./src/x.rs"), "src/x.rs"); + let abs = ws.root.join("src/x.rs"); + assert_eq!(normalize(&ws, abs.to_str().unwrap()), "src/x.rs"); + } + + #[test] + fn json_envelope_is_versioned() { + let (_t, ws) = ws_with(&[("hubs/a.md", HUB)]); + let matches = find(&ws, "src/x.rs", None).unwrap(); + let report = ForReport { + version: REPORT_VERSION, + path: "src/x.rs".to_string(), + matches, + }; + let json = serde_json::to_value(&report).unwrap(); + assert_eq!(json["version"], REPORT_VERSION); + assert_eq!(json["path"], "src/x.rs"); + let first = json["matches"][0].as_object().unwrap(); + for key in ["hub", "at", "claim"] { + assert!(first.contains_key(key), "missing `{key}`"); + } + } +} diff --git a/surf-cli/src/main.rs b/surf-cli/src/main.rs index 544f809..4623d58 100644 --- a/surf-cli/src/main.rs +++ b/surf-cli/src/main.rs @@ -1,6 +1,7 @@ use clap::{Parser, Subcommand}; mod check; +mod for_path; mod format; mod git; mod init; @@ -79,6 +80,16 @@ enum Command { #[arg(long, value_enum, default_value_t = Format::Human)] format: Format, }, + /// Reverse lookup: list every hub + claim anchored into a file (before you edit it). + For { + /// File to look up, relative to the workspace root (e.g. "src/auth/refresh.ts"). + path: String, + /// Optionally narrow to anchors whose first segment is this symbol. + symbol: Option, + /// Output format for the matches. + #[arg(long, value_enum, default_value_t = Format::Human)] + format: Format, + }, } fn main() -> std::process::ExitCode { @@ -116,5 +127,10 @@ fn run() -> anyhow::Result { format, } => verify::run(&ws, target.as_deref(), follow, format), Command::Suggest { globs, format } => suggest::run(&ws, &globs, format), + Command::For { + path, + symbol, + format, + } => for_path::run(&ws, &path, symbol.as_deref(), format), } }