1use std::collections::BTreeMap;
2
3use crate::snapshot::{SourceFileRecord, SourceId, SymbolRecord, WorkspaceSnapshot};
4
5use super::model::{Note, NoteResolution, ResolvedNote};
6
7pub fn resolve_notes(notes: &[Note], snapshot: &WorkspaceSnapshot) -> Vec<ResolvedNote> {
8 let sources = source_by_id(snapshot);
9 let sources_by_uri = source_by_uri(snapshot);
10 let symbols = symbol_by_identity(snapshot);
11 notes
12 .iter()
13 .cloned()
14 .map(|note| {
15 let resolution = resolve_note(¬e, &symbols, &sources, &sources_by_uri);
16 ResolvedNote { note, resolution }
17 })
18 .collect()
19}
20
21fn resolve_note(
22 note: &Note,
23 symbols: &BTreeMap<&str, &SymbolRecord>,
24 sources: &BTreeMap<SourceId, &SourceFileRecord>,
25 sources_by_uri: &BTreeMap<&str, &SourceFileRecord>,
26) -> NoteResolution {
27 if let Some(target_label) = navigation_target_label(note.moniker.as_str()) {
28 return NoteResolution::Resolved {
29 target_label,
30 target_file: String::new(),
31 target_slice: None,
32 };
33 }
34 if let Some(target_label) = view_target_label(note.moniker.as_str()) {
35 return NoteResolution::Resolved {
36 target_label,
37 target_file: String::new(),
38 target_slice: None,
39 };
40 }
41 if let Some(source) = sources_by_uri.get(note.moniker.as_str()).copied() {
42 return NoteResolution::Resolved {
43 target_label: format!("file {}", source.rel_path),
44 target_file: source.rel_path.clone(),
45 target_slice: None,
46 };
47 }
48 let Some(symbol) = symbols.get(note.moniker.as_str()).copied() else {
49 return NoteResolution::Orphan;
50 };
51 let Some(source) = sources.get(&symbol.source) else {
52 return NoteResolution::Orphan;
53 };
54 NoteResolution::Resolved {
55 target_label: format!("{} {}", symbol.kind, symbol.name),
56 target_file: source.rel_path.clone(),
57 target_slice: symbol.line_range,
58 }
59}
60
61fn navigation_target_label(moniker: &str) -> Option<String> {
62 workspace_uri_suffix(moniker, "navigation/")
63 .map(|target| format!("navigation {}", decode_navigation_id(target)))
64}
65
66fn view_target_label(moniker: &str) -> Option<String> {
67 workspace_uri_suffix(moniker, "views/").map(|target| format!("view {target}"))
68}
69
70fn workspace_uri_suffix<'a>(moniker: &'a str, path: &str) -> Option<&'a str> {
71 let workspace_path = format!("workspace/{path}");
72 moniker.strip_prefix(&workspace_path).or_else(|| {
73 moniker
74 .split_once(&workspace_path)
75 .map(|(_, suffix)| suffix)
76 })
77}
78
79fn decode_navigation_id(value: &str) -> String {
80 let mut decoded = Vec::new();
81 let bytes = value.as_bytes();
82 let mut idx = 0;
83 while idx < bytes.len() {
84 if bytes[idx] == b'%' && idx + 2 < bytes.len() {
85 if let Ok(hex) = std::str::from_utf8(&bytes[idx + 1..idx + 3]) {
86 if let Ok(byte) = u8::from_str_radix(hex, 16) {
87 decoded.push(byte);
88 idx += 3;
89 continue;
90 }
91 }
92 }
93 decoded.push(bytes[idx]);
94 idx += 1;
95 }
96 String::from_utf8(decoded).unwrap_or_else(|_| value.to_string())
97}
98
99fn source_by_id(snapshot: &WorkspaceSnapshot) -> BTreeMap<SourceId, &SourceFileRecord> {
100 snapshot
101 .index
102 .sources
103 .iter()
104 .map(|source| (source.id, source))
105 .collect()
106}
107
108fn source_by_uri(snapshot: &WorkspaceSnapshot) -> BTreeMap<&str, &SourceFileRecord> {
109 snapshot
110 .index
111 .sources
112 .iter()
113 .map(|source| (source.uri.as_str(), source))
114 .collect()
115}
116
117fn symbol_by_identity(snapshot: &WorkspaceSnapshot) -> BTreeMap<&str, &SymbolRecord> {
118 snapshot
119 .index
120 .symbols
121 .iter()
122 .map(|symbol| (symbol.identity.as_ref(), symbol))
123 .collect()
124}
125
126#[cfg(test)]
127mod tests {
128 use std::sync::Arc;
129
130 use crate::snapshot::{
131 ChangeOverlay, CodeIndex, CodeIndexTimings, LinkageSnapshot, RecordTable,
132 ResourceGeneration, SourceCatalog, SourceFileRecord, SourceId, SymbolId,
133 SymbolInventoryIndex, SymbolRecord, WorkspaceSnapshot, WorkspaceTimings,
134 };
135
136 use super::*;
137 use crate::notes::{NoteAuthor, NoteId, NoteKind, NoteStatus};
138
139 #[test]
140 fn resolves_matching_moniker_and_keeps_orphans() {
141 let moniker = "code+moniker://./lang:rs/module:example/fn:run()";
142 let notes = vec![
143 sample_note("note_1", moniker),
144 sample_note("note_2", "code+moniker://./lang:rs/module:missing"),
145 ];
146 let snapshot = snapshot_with_symbol(moniker);
147
148 let resolved = resolve_notes(¬es, &snapshot);
149
150 assert_eq!(resolved.len(), 2);
151 assert_eq!(
152 resolved[0].resolution,
153 NoteResolution::Resolved {
154 target_label: "fn run()".to_string(),
155 target_file: "src/lib.rs".to_string(),
156 target_slice: Some((3, 7)),
157 }
158 );
159 assert!(resolved[1].resolution.is_orphan());
160 }
161
162 #[test]
163 fn treats_symbol_without_source_as_orphan() {
164 let moniker = "code+moniker://./lang:rs/module:example/fn:run()";
165 let notes = vec![sample_note("note_1", moniker)];
166 let snapshot = snapshot_with_orphaned_symbol(moniker);
167
168 let resolved = resolve_notes(¬es, &snapshot);
169
170 assert!(resolved[0].resolution.is_orphan());
171 }
172
173 #[test]
174 fn resolves_navigation_note_targets() {
175 let notes = vec![sample_note(
176 "note_nav",
177 "code+moniker://workspace/navigation/root%3Aexplorer",
178 )];
179 let snapshot = snapshot_with_symbol("code+moniker://./lang:rs/module:example/fn:run()");
180
181 let resolved = resolve_notes(¬es, &snapshot);
182
183 assert_eq!(
184 resolved[0].resolution,
185 NoteResolution::Resolved {
186 target_label: "navigation root:explorer".to_string(),
187 target_file: String::new(),
188 target_slice: None,
189 }
190 );
191 }
192
193 fn sample_note(id: &str, moniker: &str) -> Note {
194 Note {
195 id: NoteId::new(id),
196 moniker: moniker.to_string(),
197 kind: NoteKind::Todo,
198 status: NoteStatus::Pending,
199 title: "Title".to_string(),
200 body: "Body".to_string(),
201 created_by: NoteAuthor::User,
202 created_at: "2026-06-02T00:00:00Z".to_string(),
203 updated_at: "2026-06-02T00:00:00Z".to_string(),
204 }
205 }
206
207 fn snapshot_with_symbol(moniker: &str) -> WorkspaceSnapshot {
208 let source = SourceFileRecord {
209 id: SourceId::at(1),
210 uri: "code+moniker://./lang:rs/path:src/path:lib.rs".to_string(),
211 source_root: 0,
212 path: "/tmp/src/lib.rs".to_string(),
213 rel_path: "src/lib.rs".to_string(),
214 anchor: ".".to_string(),
215 language: "rs".to_string(),
216 text: "pub fn run() {}\n".to_string(),
217 };
218 let symbol = SymbolRecord {
219 id: SymbolId::at(0, 1),
220 source: source.id,
221 identity: std::sync::Arc::from(moniker.to_string()),
222 name: "run()".to_string(),
223 kind: "fn".to_string(),
224 visibility: "public".to_string(),
225 signature: "run()".to_string(),
226 call_name: None,
227 call_arity: None,
228 navigable: true,
229 line_range: Some((3, 7)),
230 parent: None,
231 };
232 WorkspaceSnapshot {
233 generation: ResourceGeneration::new(1),
234 catalog: SourceCatalog::new(ResourceGeneration::new(1), Vec::new()),
235 index: CodeIndex {
236 generation: ResourceGeneration::new(1),
237 catalog_generation: ResourceGeneration::new(1),
238 identity_scheme: "code+moniker://".to_string(),
239 sources: vec![source],
240 symbols: RecordTable::from_records(vec![symbol]),
241 references: RecordTable::from_records(Vec::new()),
242 inventory: Arc::new(SymbolInventoryIndex::default()),
243 timings: CodeIndexTimings::default(),
244 },
245 linkage: LinkageSnapshot::new(
246 ResourceGeneration::new(1),
247 ResourceGeneration::new(1),
248 0,
249 0,
250 ),
251 changes: ChangeOverlay::new(
252 ResourceGeneration::new(1),
253 ResourceGeneration::new(1),
254 ResourceGeneration::new(1),
255 Vec::new(),
256 ),
257 timings: WorkspaceTimings::default(),
258 }
259 }
260
261 fn snapshot_with_orphaned_symbol(moniker: &str) -> WorkspaceSnapshot {
262 let symbol = SymbolRecord {
263 id: SymbolId::at(0, 1),
264 source: SourceId::at(9999),
265 identity: std::sync::Arc::from(moniker.to_string()),
266 name: "run()".to_string(),
267 kind: "fn".to_string(),
268 visibility: "public".to_string(),
269 signature: "run()".to_string(),
270 call_name: None,
271 call_arity: None,
272 navigable: true,
273 line_range: Some((3, 7)),
274 parent: None,
275 };
276 WorkspaceSnapshot {
277 generation: ResourceGeneration::new(1),
278 catalog: SourceCatalog::new(ResourceGeneration::new(1), Vec::new()),
279 index: CodeIndex {
280 generation: ResourceGeneration::new(1),
281 catalog_generation: ResourceGeneration::new(1),
282 identity_scheme: "code+moniker://".to_string(),
283 sources: Vec::new(),
284 symbols: RecordTable::from_records(vec![symbol]),
285 references: RecordTable::from_records(Vec::new()),
286 inventory: Arc::new(SymbolInventoryIndex::default()),
287 timings: CodeIndexTimings::default(),
288 },
289 linkage: LinkageSnapshot::new(
290 ResourceGeneration::new(1),
291 ResourceGeneration::new(1),
292 0,
293 0,
294 ),
295 changes: ChangeOverlay::new(
296 ResourceGeneration::new(1),
297 ResourceGeneration::new(1),
298 ResourceGeneration::new(1),
299 Vec::new(),
300 ),
301 timings: WorkspaceTimings::default(),
302 }
303 }
304}