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}