Skip to main content

lean_ctx/core/
handle.rs

1//! Stable cross-turn symbol handles: `path#name@Lline`.
2//!
3//! A handle is a compact, copy-pasteable identifier for a symbol that an agent
4//! can reuse across turns without re-discovering it: the project-relative file
5//! path, the (possibly qualified) symbol name, and the 1-based start line, e.g.
6//! `src/lib.rs#Config::load@L22`.
7//!
8//! Two delimiters carry the structure:
9//! * `#` separates `path` from `name` — file paths never contain `#`, so the
10//!   first `#` is an unambiguous split point even for trait-impl names that
11//!   embed `::` (`src/x.rs#std::fmt::Display::fmt@L9`).
12//! * `@L<digits>` is an *optional* line suffix, parsed only when it is a real
13//!   `@L<number>` tail, so the rare symbol name containing `@` still round-trips.
14//!
15//! The line is a hint, not an identity: [`crate::core::graph_provider`] resolves
16//! a handle by `(path, name)` first and treats `@Lline` as a tiebreak, so a
17//! handle keeps resolving after the symbol drifts to a new line — strictly more
18//! robust than a brittle line-only reference.
19//!
20//! Determinism (#498): emitting a handle is a pure function of `(path, name,
21//! line)`, so any output carrying handles stays byte-stable across identical
22//! re-reads and provider prompt caching still applies.
23
24/// A parsed symbol handle. `line` is `None` when the source string omitted the
25/// `@LN` suffix; resolution then falls back to `(path, name)` only.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct SymbolHandle {
28    /// Project-relative file path (the index key prefix), e.g. `src/lib.rs`.
29    pub path: String,
30    /// Symbol name, possibly qualified with `::` (`Config::load`).
31    pub name: String,
32    /// 1-based start line, when known. Only ever a resolution tiebreak.
33    pub line: Option<usize>,
34}
35
36impl SymbolHandle {
37    /// Build a handle from its parts (line known).
38    #[must_use]
39    pub fn new(path: impl Into<String>, name: impl Into<String>, line: usize) -> Self {
40        Self {
41            path: path.into(),
42            name: name.into(),
43            line: Some(line),
44        }
45    }
46
47    /// Render `path#name@Lline` (drops the `@Lline` suffix when the line is
48    /// unknown). Inverse of [`SymbolHandle::parse`].
49    #[must_use]
50    pub fn emit(&self) -> String {
51        match self.line {
52            Some(line) => format!("{}#{}@L{}", self.path, self.name, line),
53            None => format!("{}#{}", self.path, self.name),
54        }
55    }
56
57    /// Parse `path#name[@Lline]`. Returns `None` when the `path#name` core is
58    /// missing (no `#`, or an empty path/name). The `@Lline` suffix is consumed
59    /// only when it is a genuine `@L<digits>` tail.
60    #[must_use]
61    pub fn parse(s: &str) -> Option<Self> {
62        let s = s.trim();
63        let (path, rest) = s.split_once('#')?;
64        if path.is_empty() || rest.is_empty() {
65            return None;
66        }
67        if let Some(at) = rest.rfind('@') {
68            let after = &rest[at + 1..];
69            if let Some(digits) = after.strip_prefix('L')
70                && !digits.is_empty()
71                && digits.bytes().all(|b| b.is_ascii_digit())
72            {
73                let name = &rest[..at];
74                if !name.is_empty() {
75                    return Some(Self {
76                        path: path.to_string(),
77                        name: name.to_string(),
78                        line: digits.parse().ok(),
79                    });
80                }
81            }
82        }
83        Some(Self {
84            path: path.to_string(),
85            name: rest.to_string(),
86            line: None,
87        })
88    }
89}
90
91/// Emit a handle string from parts without constructing a [`SymbolHandle`].
92/// The hot path for renderers that already hold `(path, name, line)`.
93#[must_use]
94pub fn emit(path: &str, name: &str, line: usize) -> String {
95    SymbolHandle::new(path, name, line).emit()
96}
97
98/// One-line, self-describing usage hint (GL#580) for outputs that list located
99/// symbols (outline, signatures/map, call-graph). Rather than repeat a full
100/// handle on every line — the file/name/line are already shown — these outputs
101/// carry this single hint telling the agent that each `name @Lstart` is
102/// addressable as a stable handle. Matches the codebase's `↳ …` hint style and
103/// is a constant, so it stays deterministic (#498).
104pub const USAGE_HINT: &str =
105    "↳ re-target any symbol: ctx_search(action=\"symbol\", handle=\"path#name@Lstart\")";
106
107/// Whether `s` looks like a handle (carries a non-empty `path#name` core). Lets
108/// a tool accept either a bare symbol name or a handle in the same argument.
109#[must_use]
110pub fn looks_like_handle(s: &str) -> bool {
111    s.split_once('#')
112        .is_some_and(|(path, rest)| !path.is_empty() && !rest.is_empty())
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn round_trips_emit_parse() {
121        let h = SymbolHandle::new("src/lib.rs", "Config::load", 22);
122        let s = h.emit();
123        assert_eq!(s, "src/lib.rs#Config::load@L22");
124        assert_eq!(SymbolHandle::parse(&s), Some(h));
125    }
126
127    #[test]
128    fn emit_helper_matches_struct() {
129        assert_eq!(
130            emit("a/b.rs", "foo", 7),
131            SymbolHandle::new("a/b.rs", "foo", 7).emit()
132        );
133    }
134
135    #[test]
136    fn parses_without_line_suffix() {
137        let h = SymbolHandle::parse("src/lib.rs#Config::load").unwrap();
138        assert_eq!(h.path, "src/lib.rs");
139        assert_eq!(h.name, "Config::load");
140        assert_eq!(h.line, None);
141    }
142
143    #[test]
144    fn keeps_qualified_names_with_colons() {
145        let h = SymbolHandle::parse("src/x.rs#std::fmt::Display::fmt@L9").unwrap();
146        assert_eq!(h.path, "src/x.rs");
147        assert_eq!(h.name, "std::fmt::Display::fmt");
148        assert_eq!(h.line, Some(9));
149    }
150
151    #[test]
152    fn name_with_at_but_no_line_is_preserved() {
153        // `@foo` is not an `@L<digits>` tail, so it stays part of the name.
154        let h = SymbolHandle::parse("src/x.rs#weird@name").unwrap();
155        assert_eq!(h.name, "weird@name");
156        assert_eq!(h.line, None);
157    }
158
159    #[test]
160    fn rejects_missing_hash_or_empty_parts() {
161        assert_eq!(SymbolHandle::parse("src/lib.rs"), None);
162        assert_eq!(SymbolHandle::parse("#name@L1"), None);
163        assert_eq!(SymbolHandle::parse("src/lib.rs#"), None);
164        assert_eq!(SymbolHandle::parse(""), None);
165    }
166
167    #[test]
168    fn parse_trims_surrounding_whitespace() {
169        let h = SymbolHandle::parse("  src/a.rs#foo@L3  ").unwrap();
170        assert_eq!(h, SymbolHandle::new("src/a.rs", "foo", 3));
171    }
172
173    #[test]
174    fn line_drift_still_parses_same_identity() {
175        // The same (path, name) with a different line is still a valid handle —
176        // the resolver, not the parser, decides identity.
177        let a = SymbolHandle::parse("src/a.rs#foo@L10").unwrap();
178        let b = SymbolHandle::parse("src/a.rs#foo@L999").unwrap();
179        assert_eq!((a.path, a.name), (b.path, b.name));
180    }
181
182    #[test]
183    fn looks_like_handle_detects_core() {
184        assert!(looks_like_handle("src/a.rs#foo@L1"));
185        assert!(looks_like_handle("src/a.rs#foo"));
186        assert!(!looks_like_handle("foo"));
187        assert!(!looks_like_handle("#foo"));
188        assert!(!looks_like_handle("src/a.rs#"));
189    }
190}