Skip to main content

code_moniker_workspace/notes/
resolve.rs

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(&note, &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(&notes, &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(&notes, &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(&notes, &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}