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 crate::snapshot::{
129 ChangeOverlay, CodeIndex, CodeIndexTimings, LinkageSnapshot, RecordTable,
130 ResourceGeneration, SourceCatalog, SourceFileRecord, SourceId, SymbolId, SymbolRecord,
131 WorkspaceSnapshot, WorkspaceTimings,
132 };
133
134 use super::*;
135 use crate::notes::{NoteAuthor, NoteId, NoteKind, NoteStatus};
136
137 #[test]
138 fn resolves_matching_moniker_and_keeps_orphans() {
139 let moniker = "code+moniker://./lang:rs/module:example/fn:run()";
140 let notes = vec![
141 sample_note("note_1", moniker),
142 sample_note("note_2", "code+moniker://./lang:rs/module:missing"),
143 ];
144 let snapshot = snapshot_with_symbol(moniker);
145
146 let resolved = resolve_notes(¬es, &snapshot);
147
148 assert_eq!(resolved.len(), 2);
149 assert_eq!(
150 resolved[0].resolution,
151 NoteResolution::Resolved {
152 target_label: "fn run()".to_string(),
153 target_file: "src/lib.rs".to_string(),
154 target_slice: Some((3, 7)),
155 }
156 );
157 assert!(resolved[1].resolution.is_orphan());
158 }
159
160 #[test]
161 fn treats_symbol_without_source_as_orphan() {
162 let moniker = "code+moniker://./lang:rs/module:example/fn:run()";
163 let notes = vec![sample_note("note_1", moniker)];
164 let snapshot = snapshot_with_orphaned_symbol(moniker);
165
166 let resolved = resolve_notes(¬es, &snapshot);
167
168 assert!(resolved[0].resolution.is_orphan());
169 }
170
171 #[test]
172 fn resolves_navigation_note_targets() {
173 let notes = vec![sample_note(
174 "note_nav",
175 "code+moniker://workspace/navigation/root%3Aexplorer",
176 )];
177 let snapshot = snapshot_with_symbol("code+moniker://./lang:rs/module:example/fn:run()");
178
179 let resolved = resolve_notes(¬es, &snapshot);
180
181 assert_eq!(
182 resolved[0].resolution,
183 NoteResolution::Resolved {
184 target_label: "navigation root:explorer".to_string(),
185 target_file: String::new(),
186 target_slice: None,
187 }
188 );
189 }
190
191 fn sample_note(id: &str, moniker: &str) -> Note {
192 Note {
193 id: NoteId::new(id),
194 moniker: moniker.to_string(),
195 kind: NoteKind::Todo,
196 status: NoteStatus::Pending,
197 title: "Title".to_string(),
198 body: "Body".to_string(),
199 created_by: NoteAuthor::User,
200 created_at: "2026-06-02T00:00:00Z".to_string(),
201 updated_at: "2026-06-02T00:00:00Z".to_string(),
202 }
203 }
204
205 fn snapshot_with_symbol(moniker: &str) -> WorkspaceSnapshot {
206 let source = SourceFileRecord {
207 id: SourceId::at(1),
208 uri: "code+moniker://./lang:rs/path:src/path:lib.rs".to_string(),
209 source_root: 0,
210 path: "/tmp/src/lib.rs".to_string(),
211 rel_path: "src/lib.rs".to_string(),
212 anchor: ".".to_string(),
213 language: "rs".to_string(),
214 text: "pub fn run() {}\n".to_string(),
215 };
216 let symbol = SymbolRecord {
217 id: SymbolId::at(0, 1),
218 source: source.id,
219 identity: std::sync::Arc::from(moniker.to_string()),
220 name: "run()".to_string(),
221 kind: "fn".to_string(),
222 visibility: "public".to_string(),
223 signature: "run()".to_string(),
224 navigable: true,
225 line_range: Some((3, 7)),
226 parent: None,
227 };
228 WorkspaceSnapshot {
229 generation: ResourceGeneration::new(1),
230 catalog: SourceCatalog::new(ResourceGeneration::new(1), Vec::new()),
231 index: CodeIndex {
232 generation: ResourceGeneration::new(1),
233 catalog_generation: ResourceGeneration::new(1),
234 identity_scheme: "code+moniker://".to_string(),
235 sources: vec![source],
236 symbols: RecordTable::from_records(vec![symbol]),
237 references: RecordTable::from_records(Vec::new()),
238 timings: CodeIndexTimings::default(),
239 },
240 linkage: LinkageSnapshot::new(
241 ResourceGeneration::new(1),
242 ResourceGeneration::new(1),
243 0,
244 0,
245 ),
246 changes: ChangeOverlay::new(
247 ResourceGeneration::new(1),
248 ResourceGeneration::new(1),
249 ResourceGeneration::new(1),
250 Vec::new(),
251 ),
252 timings: WorkspaceTimings::default(),
253 }
254 }
255
256 fn snapshot_with_orphaned_symbol(moniker: &str) -> WorkspaceSnapshot {
257 let symbol = SymbolRecord {
258 id: SymbolId::at(0, 1),
259 source: SourceId::at(9999),
260 identity: std::sync::Arc::from(moniker.to_string()),
261 name: "run()".to_string(),
262 kind: "fn".to_string(),
263 visibility: "public".to_string(),
264 signature: "run()".to_string(),
265 navigable: true,
266 line_range: Some((3, 7)),
267 parent: None,
268 };
269 WorkspaceSnapshot {
270 generation: ResourceGeneration::new(1),
271 catalog: SourceCatalog::new(ResourceGeneration::new(1), Vec::new()),
272 index: CodeIndex {
273 generation: ResourceGeneration::new(1),
274 catalog_generation: ResourceGeneration::new(1),
275 identity_scheme: "code+moniker://".to_string(),
276 sources: Vec::new(),
277 symbols: RecordTable::from_records(vec![symbol]),
278 references: RecordTable::from_records(Vec::new()),
279 timings: CodeIndexTimings::default(),
280 },
281 linkage: LinkageSnapshot::new(
282 ResourceGeneration::new(1),
283 ResourceGeneration::new(1),
284 0,
285 0,
286 ),
287 changes: ChangeOverlay::new(
288 ResourceGeneration::new(1),
289 ResourceGeneration::new(1),
290 ResourceGeneration::new(1),
291 Vec::new(),
292 ),
293 timings: WorkspaceTimings::default(),
294 }
295 }
296}