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