1use super::concurrent::GraphSnapshot;
8use super::node::id::NodeId;
9use super::resolution::{
10 FileScope, ResolutionMode, SymbolCandidateOutcome, SymbolQuery, display_graph_qualified_name,
11};
12use super::storage::StringInterner;
13use super::storage::arena::NodeEntry;
14use super::storage::registry::FileRegistry;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct MaterializedNode {
22 pub node_id: NodeId,
24 pub name: String,
26 pub qualified_name: String,
28 pub kind: String,
30 pub language: String,
32 pub file_path: String,
34 pub start_line: u32,
36 pub end_line: u32,
38}
39
40#[must_use]
49pub fn display_entry_qualified_name(
50 entry: &NodeEntry,
51 strings: &StringInterner,
52 files: &FileRegistry,
53 fallback_name: &str,
54) -> String {
55 entry
56 .qualified_name
57 .and_then(|qn_id| strings.resolve(qn_id))
58 .map_or_else(
59 || fallback_name.to_string(),
60 |qualified| {
61 files.language_for_file(entry.file).map_or_else(
62 || qualified.to_string(),
63 |language| {
64 display_graph_qualified_name(
65 language,
66 qualified.as_ref(),
67 entry.kind,
68 entry.is_static,
69 )
70 },
71 )
72 },
73 )
74}
75
76#[must_use]
87pub fn find_nodes_by_name(snapshot: &GraphSnapshot, name: &str) -> Vec<NodeId> {
88 let exact_matches = snapshot.find_by_exact_name(name);
89 if !exact_matches.is_empty() {
90 return exact_matches;
91 }
92
93 let mut matches = find_nodes_by_graph_name(snapshot, name);
94 if matches.is_empty()
95 && let Some(canonical_name) = display_name_to_graph_fallback(name)
96 {
97 matches.extend(find_nodes_by_graph_name(snapshot, &canonical_name));
98 matches.sort_unstable();
99 matches.dedup();
100 }
101 matches
102}
103
104fn find_nodes_by_graph_name(snapshot: &GraphSnapshot, name: &str) -> Vec<NodeId> {
105 match snapshot.find_symbol_candidates(&SymbolQuery {
106 symbol: name,
107 file_scope: FileScope::Any,
108 mode: ResolutionMode::AllowSuffixCandidates,
109 }) {
110 SymbolCandidateOutcome::Candidates(matches) => matches,
111 SymbolCandidateOutcome::NotFound | SymbolCandidateOutcome::FileNotIndexed => Vec::new(),
112 }
113}
114
115fn display_name_to_graph_fallback(name: &str) -> Option<String> {
116 if name.contains("::") {
117 return None;
118 }
119
120 if name.contains('#') {
121 Some(name.replace('#', "::"))
122 } else if name.contains('.') {
123 Some(name.replace('.', "::"))
124 } else {
125 None
126 }
127}
128
129#[must_use]
134pub fn collect_symbol_seeds(snapshot: &GraphSnapshot, symbols: &[String]) -> Vec<NodeId> {
135 let mut seeds: Vec<NodeId> = Vec::new();
136 for symbol in symbols {
137 seeds.extend(find_nodes_by_name(snapshot, symbol));
138 }
139 seeds.sort_unstable();
140 seeds.dedup();
141 seeds
142}
143
144#[must_use]
149pub fn qualified_node_name(snapshot: &GraphSnapshot, node_id: NodeId) -> Option<String> {
150 let strings = snapshot.strings();
151 let files = snapshot.files();
152 let entry = snapshot.get_node(node_id)?;
153
154 let name = strings
155 .resolve(entry.name)
156 .map(|value| value.to_string())
157 .unwrap_or_default();
158 let qualified_name = display_entry_qualified_name(entry, strings, files, &name);
159
160 (!qualified_name.is_empty()).then_some(qualified_name)
161}
162
163#[must_use]
169pub fn materialize_node(snapshot: &GraphSnapshot, node_id: NodeId) -> Option<MaterializedNode> {
170 let strings = snapshot.strings();
171 let files = snapshot.files();
172 let entry = snapshot.get_node(node_id)?;
173
174 let name = strings
175 .resolve(entry.name)
176 .map(|value| value.to_string())
177 .unwrap_or_default();
178
179 let qualified_name = display_entry_qualified_name(entry, strings, files, &name);
180 if qualified_name.is_empty() {
181 return None;
182 }
183
184 let kind = format!("{:?}", entry.kind).to_lowercase();
185 let language = files
186 .language_for_file(entry.file)
187 .map_or("unknown".to_string(), |lang| {
188 lang.to_string().to_ascii_lowercase()
189 });
190 let file_path = files
191 .resolve(entry.file)
192 .map(|path| path.display().to_string())
193 .unwrap_or_default();
194
195 Some(MaterializedNode {
196 node_id,
197 name,
198 qualified_name,
199 kind,
200 language,
201 file_path,
202 start_line: entry.start_line,
203 end_line: entry.end_line,
204 })
205}
206
207#[cfg(test)]
208mod tests {
209 use std::path::{Path, PathBuf};
210
211 use crate::graph::node::Language;
212 use crate::graph::unified::concurrent::CodeGraph;
213 use crate::graph::unified::node::id::NodeId;
214 use crate::graph::unified::node::kind::NodeKind;
215 use crate::graph::unified::storage::arena::NodeEntry;
216
217 use super::{
218 MaterializedNode, collect_symbol_seeds, find_nodes_by_name, materialize_node,
219 qualified_node_name,
220 };
221
222 struct TestNode {
223 node_id: NodeId,
224 }
225
226 fn abs_path(relative: &str) -> PathBuf {
227 PathBuf::from("/materialize-tests").join(relative)
228 }
229
230 trait NodeEntryExt {
231 fn with_qualified_name_opt(
232 self,
233 qualified_name: Option<crate::graph::unified::string::id::StringId>,
234 ) -> Self;
235 }
236
237 impl NodeEntryExt for NodeEntry {
238 fn with_qualified_name_opt(
239 mut self,
240 qualified_name: Option<crate::graph::unified::string::id::StringId>,
241 ) -> Self {
242 self.qualified_name = qualified_name;
243 self
244 }
245 }
246
247 fn add_node(
248 graph: &mut CodeGraph,
249 kind: NodeKind,
250 name: &str,
251 qualified_name: Option<&str>,
252 file_path: &Path,
253 language: Option<Language>,
254 start_line: u32,
255 end_line: u32,
256 ) -> TestNode {
257 let name_id = graph.strings_mut().intern(name).unwrap();
258 let qualified_name_id =
259 qualified_name.map(|value| graph.strings_mut().intern(value).unwrap());
260 let file_id = graph
261 .files_mut()
262 .register_with_language(file_path, language)
263 .unwrap();
264
265 let entry = NodeEntry::new(kind, name_id, file_id)
266 .with_qualified_name_opt(qualified_name_id)
267 .with_location(start_line, 0, end_line, 0);
268
269 let node_id = graph.nodes_mut().alloc(entry).unwrap();
270 graph
271 .indices_mut()
272 .add(node_id, kind, name_id, qualified_name_id, file_id);
273
274 TestNode { node_id }
275 }
276
277 #[test]
278 fn find_nodes_by_name_returns_matching_candidates() {
279 let mut graph = CodeGraph::new();
280 let path = abs_path("src/lib.rs");
281 let node = add_node(
282 &mut graph,
283 NodeKind::Function,
284 "process",
285 Some("crate::process"),
286 &path,
287 Some(Language::Rust),
288 1,
289 10,
290 );
291
292 let snapshot = graph.snapshot();
293 let results = find_nodes_by_name(&snapshot, "process");
294 assert!(
295 results.contains(&node.node_id),
296 "expected node_id {:?} in results {:?}",
297 node.node_id,
298 results
299 );
300 }
301
302 #[test]
303 fn find_nodes_by_name_returns_empty_for_nonexistent() {
304 let mut graph = CodeGraph::new();
305 let path = abs_path("src/lib.rs");
306 let _node = add_node(
307 &mut graph,
308 NodeKind::Function,
309 "existing",
310 Some("crate::existing"),
311 &path,
312 Some(Language::Rust),
313 1,
314 5,
315 );
316
317 let snapshot = graph.snapshot();
318 let results = find_nodes_by_name(&snapshot, "nonexistent_symbol_xyz");
319 assert!(
320 results.is_empty(),
321 "expected empty results, got {results:?}"
322 );
323 }
324
325 #[test]
326 fn collect_symbol_seeds_deduplicates() {
327 let mut graph = CodeGraph::new();
328 let path = abs_path("src/lib.rs");
329 let node = add_node(
330 &mut graph,
331 NodeKind::Function,
332 "dedup_target",
333 Some("crate::dedup_target"),
334 &path,
335 Some(Language::Rust),
336 1,
337 10,
338 );
339
340 let snapshot = graph.snapshot();
341
342 let symbols = vec!["dedup_target".to_string(), "dedup_target".to_string()];
344 let seeds = collect_symbol_seeds(&snapshot, &symbols);
345
346 assert_eq!(
347 seeds.iter().filter(|id| **id == node.node_id).count(),
348 1,
349 "expected exactly one occurrence of node_id {:?}, got seeds {:?}",
350 node.node_id,
351 seeds
352 );
353 }
354
355 #[test]
356 fn qualified_node_name_returns_language_aware_name() {
357 let mut graph = CodeGraph::new();
358 let path = abs_path("src/Program.cs");
359 let node = add_node(
360 &mut graph,
361 NodeKind::Method,
362 "GetName",
363 Some("MyApp::User::GetName"),
364 &path,
365 Some(Language::CSharp),
366 5,
367 15,
368 );
369
370 let snapshot = graph.snapshot();
371 let name = qualified_node_name(&snapshot, node.node_id);
372
373 assert_eq!(name, Some("MyApp.User.GetName".to_string()));
375 }
376
377 #[test]
378 fn materialize_node_produces_complete_node() {
379 let mut graph = CodeGraph::new();
380 let path = abs_path("src/main.rs");
381 let node = add_node(
382 &mut graph,
383 NodeKind::Function,
384 "main",
385 Some("crate::main"),
386 &path,
387 Some(Language::Rust),
388 1,
389 20,
390 );
391
392 let snapshot = graph.snapshot();
393 let materialized = materialize_node(&snapshot, node.node_id);
394
395 let expected = MaterializedNode {
396 node_id: node.node_id,
397 name: "main".to_string(),
398 qualified_name: "crate::main".to_string(),
399 kind: "function".to_string(),
400 language: "rust".to_string(),
401 file_path: path.display().to_string(),
402 start_line: 1,
403 end_line: 20,
404 };
405
406 assert_eq!(materialized, Some(expected));
407 }
408
409 #[test]
410 fn materialize_node_returns_none_for_empty_qualified_name() {
411 let mut graph = CodeGraph::new();
412 let path = abs_path("src/lib.rs");
413 let node = add_node(
416 &mut graph,
417 NodeKind::Function,
418 "",
419 None,
420 &path,
421 Some(Language::Rust),
422 1,
423 1,
424 );
425
426 let snapshot = graph.snapshot();
427 let materialized = materialize_node(&snapshot, node.node_id);
428 assert!(
429 materialized.is_none(),
430 "expected None for node with empty qualified name, got {materialized:?}"
431 );
432 }
433}