Skip to main content

sqry_core/graph/unified/
materialize.rs

1//! Core node materialization and seed lookup contracts.
2//!
3//! These helpers operate on `GraphSnapshot` and produce crate-agnostic output
4//! types. Consumer crates (LSP, MCP, CLI) build their protocol-specific types
5//! from the `MaterializedNode` output.
6
7use 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/// Crate-agnostic representation of a fully resolved graph node.
17///
18/// Consumers (LSP, MCP, CLI) convert this into their protocol-specific types
19/// without reimplementing resolution or formatting logic.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct MaterializedNode {
22    /// Graph node identity — stable within a snapshot for index-based edge linking.
23    pub node_id: NodeId,
24    /// Simple (unqualified) name of the symbol.
25    pub name: String,
26    /// Language-aware qualified name (e.g., `MyApp.User.GetName` for C#).
27    pub qualified_name: String,
28    /// Lowercased debug representation of the `NodeKind` variant.
29    pub kind: String,
30    /// Lowercased language name derived from the file registry.
31    pub language: String,
32    /// Display path of the file containing the symbol.
33    pub file_path: String,
34    /// One-indexed start line of the symbol span.
35    pub start_line: u32,
36    /// One-indexed end line of the symbol span.
37    pub end_line: u32,
38}
39
40/// Build a language-aware display qualified name for a `NodeEntry`.
41///
42/// This is the **canonical** implementation. Consumer crates (LSP, MCP, CLI)
43/// should call this instead of maintaining their own copy.
44///
45/// Resolves the entry's `qualified_name` through the string interner, applies
46/// language-specific display normalization (e.g., `::` → `.` for C#), and falls
47/// back to `fallback_name` when no qualified name is stored.
48#[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/// Resolve one symbol query into ordered candidate seeds.
77///
78/// Uses exact lookup first, then falls back to `FileScope::Any` and
79/// `ResolutionMode::AllowSuffixCandidates` when there are no exact matches.
80/// This keeps qualified display keys deterministic while preserving broad
81/// suffix lookup for traversal entry points that receive abbreviated names.
82/// Dot- and Ruby-`#` qualified display names try the graph-canonical `::`
83/// form only when the literal display lookup has no candidates. This preserves
84/// exact display keys such as Kotlin `function.T` when a Rust
85/// `function::T` also exists.
86#[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/// Resolve several symbol queries into a stable, deduplicated seed set.
130///
131/// Calls [`find_nodes_by_name`] for each symbol, then sorts and deduplicates
132/// the combined result for deterministic downstream processing.
133#[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/// Resolve a graph node id to its display qualified name.
145///
146/// Returns `None` if the node does not exist or has an empty qualified name
147/// after resolution.
148#[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/// Materialize a graph node into a crate-agnostic `MaterializedNode`.
164///
165/// Returns `None` if the node does not exist or has an empty qualified name
166/// after resolution. Consumers build protocol-specific types from the returned
167/// value.
168#[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        // Query the same symbol twice — should deduplicate.
343        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        // C# uses `.` as separator, so `::` should be normalized.
374        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        // Node with no qualified name and an empty simple name — produces an
414        // empty qualified name after resolution, so materialization should skip.
415        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}