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 `FileScope::Any` and `ResolutionMode::AllowSuffixCandidates` to produce
79/// the broadest set of matches suitable for graph traversal entry points.
80#[must_use]
81pub fn find_nodes_by_name(snapshot: &GraphSnapshot, name: &str) -> Vec<NodeId> {
82    match snapshot.find_symbol_candidates(&SymbolQuery {
83        symbol: name,
84        file_scope: FileScope::Any,
85        mode: ResolutionMode::AllowSuffixCandidates,
86    }) {
87        SymbolCandidateOutcome::Candidates(matches) => matches,
88        SymbolCandidateOutcome::NotFound | SymbolCandidateOutcome::FileNotIndexed => Vec::new(),
89    }
90}
91
92/// Resolve several symbol queries into a stable, deduplicated seed set.
93///
94/// Calls [`find_nodes_by_name`] for each symbol, then sorts and deduplicates
95/// the combined result for deterministic downstream processing.
96#[must_use]
97pub fn collect_symbol_seeds(snapshot: &GraphSnapshot, symbols: &[String]) -> Vec<NodeId> {
98    let mut seeds: Vec<NodeId> = Vec::new();
99    for symbol in symbols {
100        seeds.extend(find_nodes_by_name(snapshot, symbol));
101    }
102    seeds.sort_unstable();
103    seeds.dedup();
104    seeds
105}
106
107/// Resolve a graph node id to its display qualified name.
108///
109/// Returns `None` if the node does not exist or has an empty qualified name
110/// after resolution.
111#[must_use]
112pub fn qualified_node_name(snapshot: &GraphSnapshot, node_id: NodeId) -> Option<String> {
113    let strings = snapshot.strings();
114    let files = snapshot.files();
115    let entry = snapshot.get_node(node_id)?;
116
117    let name = strings
118        .resolve(entry.name)
119        .map(|value| value.to_string())
120        .unwrap_or_default();
121    let qualified_name = display_entry_qualified_name(entry, strings, files, &name);
122
123    (!qualified_name.is_empty()).then_some(qualified_name)
124}
125
126/// Materialize a graph node into a crate-agnostic `MaterializedNode`.
127///
128/// Returns `None` if the node does not exist or has an empty qualified name
129/// after resolution. Consumers build protocol-specific types from the returned
130/// value.
131#[must_use]
132pub fn materialize_node(snapshot: &GraphSnapshot, node_id: NodeId) -> Option<MaterializedNode> {
133    let strings = snapshot.strings();
134    let files = snapshot.files();
135    let entry = snapshot.get_node(node_id)?;
136
137    let name = strings
138        .resolve(entry.name)
139        .map(|value| value.to_string())
140        .unwrap_or_default();
141
142    let qualified_name = display_entry_qualified_name(entry, strings, files, &name);
143    if qualified_name.is_empty() {
144        return None;
145    }
146
147    let kind = format!("{:?}", entry.kind).to_lowercase();
148    let language = files
149        .language_for_file(entry.file)
150        .map_or("unknown".to_string(), |lang| {
151            lang.to_string().to_ascii_lowercase()
152        });
153    let file_path = files
154        .resolve(entry.file)
155        .map(|path| path.display().to_string())
156        .unwrap_or_default();
157
158    Some(MaterializedNode {
159        node_id,
160        name,
161        qualified_name,
162        kind,
163        language,
164        file_path,
165        start_line: entry.start_line,
166        end_line: entry.end_line,
167    })
168}
169
170#[cfg(test)]
171mod tests {
172    use std::path::{Path, PathBuf};
173
174    use crate::graph::node::Language;
175    use crate::graph::unified::concurrent::CodeGraph;
176    use crate::graph::unified::node::id::NodeId;
177    use crate::graph::unified::node::kind::NodeKind;
178    use crate::graph::unified::storage::arena::NodeEntry;
179
180    use super::{
181        MaterializedNode, collect_symbol_seeds, find_nodes_by_name, materialize_node,
182        qualified_node_name,
183    };
184
185    struct TestNode {
186        node_id: NodeId,
187    }
188
189    fn abs_path(relative: &str) -> PathBuf {
190        PathBuf::from("/materialize-tests").join(relative)
191    }
192
193    trait NodeEntryExt {
194        fn with_qualified_name_opt(
195            self,
196            qualified_name: Option<crate::graph::unified::string::id::StringId>,
197        ) -> Self;
198    }
199
200    impl NodeEntryExt for NodeEntry {
201        fn with_qualified_name_opt(
202            mut self,
203            qualified_name: Option<crate::graph::unified::string::id::StringId>,
204        ) -> Self {
205            self.qualified_name = qualified_name;
206            self
207        }
208    }
209
210    fn add_node(
211        graph: &mut CodeGraph,
212        kind: NodeKind,
213        name: &str,
214        qualified_name: Option<&str>,
215        file_path: &Path,
216        language: Option<Language>,
217        start_line: u32,
218        end_line: u32,
219    ) -> TestNode {
220        let name_id = graph.strings_mut().intern(name).unwrap();
221        let qualified_name_id =
222            qualified_name.map(|value| graph.strings_mut().intern(value).unwrap());
223        let file_id = graph
224            .files_mut()
225            .register_with_language(file_path, language)
226            .unwrap();
227
228        let entry = NodeEntry::new(kind, name_id, file_id)
229            .with_qualified_name_opt(qualified_name_id)
230            .with_location(start_line, 0, end_line, 0);
231
232        let node_id = graph.nodes_mut().alloc(entry).unwrap();
233        graph
234            .indices_mut()
235            .add(node_id, kind, name_id, qualified_name_id, file_id);
236
237        TestNode { node_id }
238    }
239
240    #[test]
241    fn find_nodes_by_name_returns_matching_candidates() {
242        let mut graph = CodeGraph::new();
243        let path = abs_path("src/lib.rs");
244        let node = add_node(
245            &mut graph,
246            NodeKind::Function,
247            "process",
248            Some("crate::process"),
249            &path,
250            Some(Language::Rust),
251            1,
252            10,
253        );
254
255        let snapshot = graph.snapshot();
256        let results = find_nodes_by_name(&snapshot, "process");
257        assert!(
258            results.contains(&node.node_id),
259            "expected node_id {:?} in results {:?}",
260            node.node_id,
261            results
262        );
263    }
264
265    #[test]
266    fn find_nodes_by_name_returns_empty_for_nonexistent() {
267        let mut graph = CodeGraph::new();
268        let path = abs_path("src/lib.rs");
269        let _node = add_node(
270            &mut graph,
271            NodeKind::Function,
272            "existing",
273            Some("crate::existing"),
274            &path,
275            Some(Language::Rust),
276            1,
277            5,
278        );
279
280        let snapshot = graph.snapshot();
281        let results = find_nodes_by_name(&snapshot, "nonexistent_symbol_xyz");
282        assert!(
283            results.is_empty(),
284            "expected empty results, got {results:?}"
285        );
286    }
287
288    #[test]
289    fn collect_symbol_seeds_deduplicates() {
290        let mut graph = CodeGraph::new();
291        let path = abs_path("src/lib.rs");
292        let node = add_node(
293            &mut graph,
294            NodeKind::Function,
295            "dedup_target",
296            Some("crate::dedup_target"),
297            &path,
298            Some(Language::Rust),
299            1,
300            10,
301        );
302
303        let snapshot = graph.snapshot();
304
305        // Query the same symbol twice — should deduplicate.
306        let symbols = vec!["dedup_target".to_string(), "dedup_target".to_string()];
307        let seeds = collect_symbol_seeds(&snapshot, &symbols);
308
309        assert_eq!(
310            seeds.iter().filter(|id| **id == node.node_id).count(),
311            1,
312            "expected exactly one occurrence of node_id {:?}, got seeds {:?}",
313            node.node_id,
314            seeds
315        );
316    }
317
318    #[test]
319    fn qualified_node_name_returns_language_aware_name() {
320        let mut graph = CodeGraph::new();
321        let path = abs_path("src/Program.cs");
322        let node = add_node(
323            &mut graph,
324            NodeKind::Method,
325            "GetName",
326            Some("MyApp::User::GetName"),
327            &path,
328            Some(Language::CSharp),
329            5,
330            15,
331        );
332
333        let snapshot = graph.snapshot();
334        let name = qualified_node_name(&snapshot, node.node_id);
335
336        // C# uses `.` as separator, so `::` should be normalized.
337        assert_eq!(name, Some("MyApp.User.GetName".to_string()));
338    }
339
340    #[test]
341    fn materialize_node_produces_complete_node() {
342        let mut graph = CodeGraph::new();
343        let path = abs_path("src/main.rs");
344        let node = add_node(
345            &mut graph,
346            NodeKind::Function,
347            "main",
348            Some("crate::main"),
349            &path,
350            Some(Language::Rust),
351            1,
352            20,
353        );
354
355        let snapshot = graph.snapshot();
356        let materialized = materialize_node(&snapshot, node.node_id);
357
358        let expected = MaterializedNode {
359            node_id: node.node_id,
360            name: "main".to_string(),
361            qualified_name: "crate::main".to_string(),
362            kind: "function".to_string(),
363            language: "rust".to_string(),
364            file_path: path.display().to_string(),
365            start_line: 1,
366            end_line: 20,
367        };
368
369        assert_eq!(materialized, Some(expected));
370    }
371
372    #[test]
373    fn materialize_node_returns_none_for_empty_qualified_name() {
374        let mut graph = CodeGraph::new();
375        let path = abs_path("src/lib.rs");
376        // Node with no qualified name and an empty simple name — produces an
377        // empty qualified name after resolution, so materialization should skip.
378        let node = add_node(
379            &mut graph,
380            NodeKind::Function,
381            "",
382            None,
383            &path,
384            Some(Language::Rust),
385            1,
386            1,
387        );
388
389        let snapshot = graph.snapshot();
390        let materialized = materialize_node(&snapshot, node.node_id);
391        assert!(
392            materialized.is_none(),
393            "expected None for node with empty qualified name, got {materialized:?}"
394        );
395    }
396}