Skip to main content

sqry_core/graph/
node.rs

1//! Node types for the unified code graph
2//!
3//! This module defines the core node types that represent code entities
4//! (functions, classes, modules, etc.) in the unified graph architecture.
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8use std::sync::Arc;
9
10/// Language identifier
11#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
12pub enum Language {
13    /// C language
14    C,
15    /// C++ language
16    Cpp,
17    /// C# language
18    CSharp,
19    /// CSS language
20    Css,
21    /// JavaScript language
22    JavaScript,
23    /// Python language
24    Python,
25    /// TypeScript language
26    TypeScript,
27    /// Rust language
28    Rust,
29    /// Go language
30    Go,
31    /// Java language
32    Java,
33    /// Ruby language
34    Ruby,
35    /// PHP language
36    Php,
37    /// Swift language
38    Swift,
39    /// Kotlin language
40    Kotlin,
41    /// Scala language
42    Scala,
43    /// SQL language
44    Sql,
45    /// Dart language
46    Dart,
47    /// Lua language
48    Lua,
49    /// Perl language
50    Perl,
51    /// Shell (Bash) language
52    Shell,
53    /// Groovy language
54    Groovy,
55    /// Elixir language
56    Elixir,
57    /// R language
58    R,
59    /// Haskell language
60    Haskell,
61    /// HTML language
62    Html,
63    /// Svelte language
64    Svelte,
65    /// Vue language
66    Vue,
67    /// Zig language
68    Zig,
69    /// Terraform (HCL) language
70    Terraform,
71    /// Puppet language
72    Puppet,
73    /// Pulumi language
74    Pulumi,
75    /// Virtual language for HTTP endpoints
76    Http,
77    /// Oracle PL/SQL language
78    Plsql,
79    /// Salesforce Apex language
80    Apex,
81    /// SAP ABAP language
82    Abap,
83    /// `ServiceNow` (Xanadu) language
84    ServiceNow,
85    /// JSON configuration files
86    Json,
87}
88
89impl fmt::Display for Language {
90    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
91        // Delegates to `short_name` so the emitted spelling has exactly one
92        // definition. This is the legacy wire form (`ts`, `js`, `py`) and is
93        // deliberately unchanged: manifest confidence keys and textual
94        // `NodeId` both persist it (issue #714).
95        f.write_str(self.short_name())
96    }
97}
98
99impl Language {
100    /// Every language variant, in declaration order.
101    ///
102    /// The single list every generated surface derives from: the query
103    /// field registry's accepted values, validation help text, and the
104    /// documented language table. Hand-maintained copies drift, this does
105    /// not.
106    pub const ALL: &'static [Self] = &[
107        Self::C,
108        Self::Cpp,
109        Self::CSharp,
110        Self::Css,
111        Self::JavaScript,
112        Self::Python,
113        Self::TypeScript,
114        Self::Rust,
115        Self::Go,
116        Self::Java,
117        Self::Ruby,
118        Self::Php,
119        Self::Swift,
120        Self::Kotlin,
121        Self::Scala,
122        Self::Sql,
123        Self::Dart,
124        Self::Lua,
125        Self::Perl,
126        Self::Shell,
127        Self::Groovy,
128        Self::Elixir,
129        Self::R,
130        Self::Haskell,
131        Self::Html,
132        Self::Svelte,
133        Self::Vue,
134        Self::Zig,
135        Self::Terraform,
136        Self::Puppet,
137        Self::Pulumi,
138        Self::Http,
139        Self::Plsql,
140        Self::Apex,
141        Self::Abap,
142        Self::ServiceNow,
143        Self::Json,
144    ];
145
146    /// The canonical machine identifier for this language.
147    ///
148    /// This is the vocabulary every query predicate and machine-facing
149    /// filter speaks (`typescript`, not `ts`). Distinct from
150    /// [`Self::short_name`], which is the legacy display spelling.
151    #[must_use]
152    pub const fn canonical_name(self) -> &'static str {
153        match self {
154            Self::C => "c",
155            Self::Cpp => "cpp",
156            Self::CSharp => "csharp",
157            Self::Css => "css",
158            Self::JavaScript => "javascript",
159            Self::Python => "python",
160            Self::TypeScript => "typescript",
161            Self::Rust => "rust",
162            Self::Go => "go",
163            Self::Java => "java",
164            Self::Ruby => "ruby",
165            Self::Php => "php",
166            Self::Swift => "swift",
167            Self::Kotlin => "kotlin",
168            Self::Scala => "scala",
169            Self::Sql => "sql",
170            Self::Dart => "dart",
171            Self::Lua => "lua",
172            Self::Perl => "perl",
173            Self::Shell => "shell",
174            Self::Groovy => "groovy",
175            Self::Elixir => "elixir",
176            Self::R => "r",
177            Self::Haskell => "haskell",
178            Self::Html => "html",
179            Self::Svelte => "svelte",
180            Self::Vue => "vue",
181            Self::Zig => "zig",
182            Self::Terraform => "terraform",
183            Self::Puppet => "puppet",
184            Self::Pulumi => "pulumi",
185            Self::Http => "http",
186            Self::Plsql => "plsql",
187            Self::Apex => "apex",
188            Self::Abap => "abap",
189            Self::ServiceNow => "servicenow",
190            Self::Json => "json",
191        }
192    }
193
194    /// The short display spelling, as emitted by [`fmt::Display`].
195    ///
196    /// Differs from [`Self::canonical_name`] only for JavaScript (`js`),
197    /// Python (`py`), and TypeScript (`ts`). Kept as a separate concept
198    /// because it is persisted in graph manifests and textual node ids, so
199    /// it cannot be changed without a migration.
200    #[must_use]
201    pub const fn short_name(self) -> &'static str {
202        match self {
203            Self::C => "c",
204            Self::Cpp => "cpp",
205            Self::CSharp => "csharp",
206            Self::Css => "css",
207            Self::JavaScript => "js",
208            Self::Python => "py",
209            Self::TypeScript => "ts",
210            Self::Rust => "rust",
211            Self::Go => "go",
212            Self::Java => "java",
213            Self::Ruby => "ruby",
214            Self::Php => "php",
215            Self::Swift => "swift",
216            Self::Kotlin => "kotlin",
217            Self::Scala => "scala",
218            Self::Sql => "sql",
219            Self::Dart => "dart",
220            Self::Lua => "lua",
221            Self::Perl => "perl",
222            Self::Shell => "shell",
223            Self::Groovy => "groovy",
224            Self::Elixir => "elixir",
225            Self::R => "r",
226            Self::Haskell => "haskell",
227            Self::Html => "html",
228            Self::Svelte => "svelte",
229            Self::Vue => "vue",
230            Self::Zig => "zig",
231            Self::Terraform => "terraform",
232            Self::Puppet => "puppet",
233            Self::Pulumi => "pulumi",
234            Self::Http => "http",
235            Self::Plsql => "plsql",
236            Self::Apex => "apex",
237            Self::Abap => "abap",
238            Self::ServiceNow => "servicenow",
239            Self::Json => "json",
240        }
241    }
242
243    /// Additional accepted spellings beyond the canonical and short names.
244    ///
245    /// [`Self::from_id`] accepts the union of canonical, short, and these,
246    /// so an alias added here is immediately valid everywhere input is
247    /// parsed.
248    #[must_use]
249    pub const fn aliases(self) -> &'static [&'static str] {
250        match self {
251            Self::C => &[],
252            Self::Cpp => &["c++", "cplusplus", "cxx"],
253            Self::CSharp => &["c#", "cs"],
254            Self::Css => &[],
255            Self::JavaScript => &[],
256            Self::Python => &[],
257            Self::TypeScript => &[],
258            Self::Rust => &["rs"],
259            Self::Go => &["golang"],
260            Self::Java => &[],
261            Self::Ruby => &["rb"],
262            Self::Php => &[],
263            Self::Swift => &[],
264            Self::Kotlin => &["kt"],
265            Self::Scala => &[],
266            Self::Sql => &[],
267            Self::Dart => &[],
268            Self::Lua => &[],
269            Self::Perl => &["pl"],
270            Self::Shell => &["bash", "sh"],
271            Self::Groovy => &[],
272            Self::Elixir => &["ex", "exs"],
273            Self::R => &[],
274            Self::Haskell => &["hs"],
275            Self::Html => &["html5"],
276            Self::Svelte => &[],
277            Self::Vue => &[],
278            Self::Zig => &[],
279            Self::Terraform => &["hcl", "tf"],
280            Self::Puppet => &[],
281            Self::Pulumi => &[],
282            Self::Http => &[],
283            Self::Plsql => &["pl/sql", "oracle"],
284            Self::Apex => &["salesforce"],
285            Self::Abap => &[],
286            Self::ServiceNow => &["xanadu"],
287            Self::Json => &[],
288        }
289    }
290
291    /// Every spelling this language accepts on input, canonical first.
292    #[must_use]
293    pub fn accepted_names(self) -> Vec<&'static str> {
294        let mut names = vec![self.canonical_name()];
295        if self.short_name() != self.canonical_name() {
296            names.push(self.short_name());
297        }
298        names.extend_from_slice(self.aliases());
299        names
300    }
301
302    /// The canonical identifier of every language, in declaration order.
303    ///
304    /// Feeds the `lang` field registry and the documented value table so
305    /// neither can fall out of step with the enum.
306    #[must_use]
307    pub fn canonical_names() -> Vec<&'static str> {
308        Self::ALL.iter().map(|lang| lang.canonical_name()).collect()
309    }
310
311    /// Parse a language identifier or common alias into a `Language`.
312    ///
313    /// Accepts the canonical name, the short display name, and every alias,
314    /// after trimming and case folding. This is the only input parser: every
315    /// surface that accepts a user-supplied language routes through it, so
316    /// no surface can accept a different set of spellings than another
317    /// (issue #714).
318    #[must_use]
319    pub fn from_id(value: &str) -> Option<Self> {
320        let needle = value.trim().to_ascii_lowercase();
321        Self::ALL.iter().copied().find(|lang| {
322            lang.canonical_name() == needle
323                || lang.short_name() == needle
324                || lang.aliases().contains(&needle.as_str())
325        })
326    }
327}
328
329/// Universal node identifier with string interning for memory efficiency
330///
331/// Per AGENTS.md:149-151, uses `Arc<str>` to reduce memory usage for
332/// symbol-heavy data structures (saves 10-50 MB for typical repos).
333///
334/// # Examples
335///
336/// ```
337/// use sqry_core::graph::node::{NodeId, Language};
338/// use std::sync::Arc;
339///
340/// let node_id = NodeId::new(
341///     Language::Cpp,
342///     "src/main.cpp",
343///     "main"
344/// );
345///
346/// // Arc<str> makes cloning cheap (only refcount increment)
347/// let cloned = node_id.clone();
348/// assert_eq!(node_id, cloned);
349/// ```
350#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd)]
351pub struct NodeId {
352    /// Language of origin
353    pub language: Language,
354    /// File path (interned via `Arc<str>`)
355    pub file: Arc<str>,
356    /// Qualified name (interned via `Arc<str>`)
357    /// Examples: "`std::vector::push_back`", "MyClass.process", "__main__"
358    pub qualified_name: Arc<str>,
359}
360
361impl NodeId {
362    /// Create a new `NodeId` with string interning
363    ///
364    /// Automatically interns strings via `Arc<str>` for memory efficiency.
365    ///
366    /// # Examples
367    ///
368    /// ```
369    /// use sqry_core::graph::node::{NodeId, Language};
370    ///
371    /// let id = NodeId::new(Language::Python, "api.py", "User.authenticate");
372    /// println!("{}", id); // "py:api.py:User.authenticate"
373    /// ```
374    pub fn new(language: Language, file: impl AsRef<str>, qualified_name: impl AsRef<str>) -> Self {
375        Self {
376            language,
377            file: Arc::from(file.as_ref()),
378            qualified_name: Arc::from(qualified_name.as_ref()),
379        }
380    }
381
382    /// Get the symbol name without namespace qualification
383    ///
384    /// # Examples
385    ///
386    /// ```
387    /// use sqry_core::graph::node::{NodeId, Language};
388    ///
389    /// let id = NodeId::new(Language::Cpp, "main.cpp", "std::vector::push_back");
390    /// assert_eq!(id.symbol_name(), "push_back");
391    /// ```
392    #[must_use]
393    pub fn symbol_name(&self) -> &str {
394        // Try C++ style first (::), then Python/Java style (.)
395        if let Some(name) = self.qualified_name.rsplit("::").next()
396            && name != self.qualified_name.as_ref()
397        {
398            return name;
399        }
400
401        if let Some(name) = self.qualified_name.rsplit('.').next() {
402            return name;
403        }
404
405        &self.qualified_name
406    }
407}
408
409impl fmt::Display for NodeId {
410    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
411        write!(f, "{}:{}:{}", self.language, self.file, self.qualified_name)
412    }
413}
414
415/// Source code span (line and column information)
416#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, Default, Serialize, Deserialize)]
417pub struct Span {
418    /// Starting position
419    pub start: Position,
420    /// Ending position
421    pub end: Position,
422}
423
424impl Span {
425    /// Create a new span
426    #[must_use]
427    pub fn new(start: Position, end: Position) -> Self {
428        Self { start, end }
429    }
430
431    /// Create a span from a tree-sitter node's real row and column.
432    ///
433    /// This is what a plugin should call when it has the node in hand, which
434    /// is nearly always. It takes the node by reference to match the private
435    /// extension traits that the cpp, kotlin and sql plugins had each already
436    /// written for themselves, which this replaces.
437    ///
438    /// Prefer it over [`Span::from_bytes`] without
439    /// exception: the two return the same type and look equally correct at a
440    /// call site, but `from_bytes` stores byte offsets in the line and column
441    /// fields, so anything built from it reports line 1 with a nonsense
442    /// column.
443    #[must_use]
444    pub fn from_node(node: &tree_sitter::Node<'_>) -> Self {
445        let start = node.start_position();
446        let end = node.end_position();
447        Self {
448            start: Position {
449                line: start.row,
450                column: start.column,
451            },
452            end: Position {
453                line: end.row,
454                column: end.column,
455            },
456        }
457    }
458
459    /// Create a span from raw byte offsets, WITHOUT resolving them to a line
460    /// and column.
461    ///
462    /// This is lossy and the loss is invisible: it stores the offsets in the
463    /// `line`/`column` fields and sets `line: 0`, which every display path
464    /// renders as line 1. A `Span` built this way is indistinguishable by type
465    /// from a correct one, which is how ten language plugins came to report
466    /// every declaration at line 1 with the byte offset as the column, found
467    /// by audit on 2026-08-22.
468    ///
469    /// Use [`Span::from_node`] when a node is available. This remains only for
470    /// call sites that genuinely hold nothing but offsets, such as a scope or
471    /// call-context tuple, and whose spans are not surfaced as symbol
472    /// positions.
473    #[must_use]
474    pub fn from_bytes(start: usize, end: usize) -> Self {
475        Self {
476            start: Position {
477                line: 0,
478                column: start,
479            },
480            end: Position {
481                line: 0,
482                column: end,
483            },
484        }
485    }
486}
487
488/// Position in source code (line and column)
489#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq, Default, Serialize, Deserialize)]
490pub struct Position {
491    /// Line number (0-indexed)
492    pub line: usize,
493    /// Column number (0-indexed)
494    pub column: usize,
495}
496
497impl Position {
498    /// Create a new position
499    #[must_use]
500    pub fn new(line: usize, column: usize) -> Self {
501        Self { line, column }
502    }
503}
504
505/// Type of code entity
506#[derive(Debug, Clone, PartialEq)]
507pub enum NodeKind {
508    /// Function or method
509    Function {
510        /// Function parameters
511        params: Vec<Param>,
512        /// Return type (if known)
513        return_type: Option<Type>,
514        /// Whether the function is async
515        is_async: bool,
516    },
517    /// Class or struct
518    Class {
519        /// Base classes
520        bases: Vec<NodeId>,
521        /// Implemented interfaces
522        interfaces: Vec<NodeId>,
523    },
524    /// Module or namespace
525    Module {
526        /// Exported symbols
527        exports: Vec<NodeId>,
528    },
529    /// Variable, constant, or field
530    Variable {
531        /// Variable type (if known)
532        var_type: Option<Type>,
533    },
534}
535
536/// Function parameter
537#[derive(Debug, Clone, PartialEq)]
538pub struct Param {
539    /// Parameter name
540    pub name: String,
541    /// Parameter type (if known)
542    pub param_type: Option<Type>,
543}
544
545/// Type information (simplified for now)
546#[derive(Debug, Clone, PartialEq)]
547pub struct Type {
548    /// Type name
549    pub name: String,
550}
551
552/// Additional metadata for a node
553#[derive(Debug, Clone, Default)]
554pub struct NodeMetadata {
555    /// Visibility (public, private, etc.)
556    pub visibility: Option<String>,
557    /// Documentation string
558    pub doc_comment: Option<String>,
559    /// Attributes/decorators
560    pub attributes: Vec<String>,
561}
562
563/// A node in the code graph representing a code entity
564#[derive(Debug, Clone)]
565pub struct CodeNode {
566    /// Unique identifier
567    pub id: NodeId,
568    /// Node type (function, class, module, etc.)
569    pub kind: NodeKind,
570    /// Source location
571    pub span: Span,
572    /// Additional metadata
573    pub metadata: NodeMetadata,
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579
580    #[test]
581    fn test_node_id_creation() {
582        let id = NodeId::new(Language::Cpp, "src/main.cpp", "main");
583        assert_eq!(id.language, Language::Cpp);
584        assert_eq!(id.file.as_ref(), "src/main.cpp");
585        assert_eq!(id.qualified_name.as_ref(), "main");
586    }
587
588    #[test]
589    fn test_node_id_display() {
590        let id = NodeId::new(Language::Python, "api.py", "User.authenticate");
591        assert_eq!(id.to_string(), "py:api.py:User.authenticate");
592    }
593
594    #[test]
595    fn test_node_id_hash() {
596        use std::collections::HashSet;
597
598        let id1 = NodeId::new(Language::JavaScript, "api.js", "fetchUsers");
599        let id2 = NodeId::new(Language::JavaScript, "api.js", "fetchUsers");
600        let id3 = NodeId::new(Language::JavaScript, "api.js", "createUser");
601
602        let mut set = HashSet::new();
603        set.insert(id1.clone());
604        set.insert(id2.clone());
605        set.insert(id3.clone());
606
607        assert_eq!(set.len(), 2); // id1 and id2 are equal
608    }
609
610    #[test]
611    fn test_node_id_clone_cheap() {
612        let id1 = NodeId::new(Language::Cpp, "src/utils.cpp", "std::vector::push_back");
613        let id2 = id1.clone();
614
615        // Arc<str> means the underlying string is NOT copied
616        assert_eq!(Arc::as_ptr(&id1.file), Arc::as_ptr(&id2.file));
617        assert_eq!(
618            Arc::as_ptr(&id1.qualified_name),
619            Arc::as_ptr(&id2.qualified_name)
620        );
621    }
622
623    #[test]
624    fn test_symbol_name_extraction() {
625        let id1 = NodeId::new(Language::Cpp, "main.cpp", "std::vector::push_back");
626        assert_eq!(id1.symbol_name(), "push_back");
627
628        let id2 = NodeId::new(Language::Python, "api.py", "User.authenticate");
629        assert_eq!(id2.symbol_name(), "authenticate");
630
631        let id3 = NodeId::new(Language::JavaScript, "api.js", "fetchUsers");
632        assert_eq!(id3.symbol_name(), "fetchUsers");
633    }
634
635    #[test]
636    fn test_span_creation() {
637        let span = Span::new(Position::new(10, 0), Position::new(20, 1));
638
639        assert_eq!(span.start.line, 10);
640        assert_eq!(span.end.line, 20);
641    }
642
643    #[test]
644    fn every_variant_round_trips_through_from_id() {
645        // The invariant that makes the #714 bug class unrepresentable: every
646        // spelling a language publishes must parse back to that language.
647        for &lang in Language::ALL {
648            assert_eq!(
649                Language::from_id(lang.canonical_name()),
650                Some(lang),
651                "{} canonical name does not round-trip",
652                lang.canonical_name()
653            );
654            assert_eq!(
655                Language::from_id(lang.short_name()),
656                Some(lang),
657                "{} short name does not round-trip",
658                lang.short_name()
659            );
660            for alias in lang.aliases() {
661                assert_eq!(
662                    Language::from_id(alias),
663                    Some(lang),
664                    "alias {alias} does not round-trip"
665                );
666            }
667            // Case and surrounding whitespace must not change the answer.
668            assert_eq!(
669                Language::from_id(&format!("  {}  ", lang.canonical_name().to_uppercase())),
670                Some(lang)
671            );
672        }
673    }
674
675    #[test]
676    fn language_all_is_complete_and_unique() {
677        assert_eq!(Language::ALL.len(), 37);
678        let names = Language::canonical_names();
679        let unique: std::collections::HashSet<_> = names.iter().collect();
680        assert_eq!(unique.len(), names.len(), "canonical names must be unique");
681    }
682
683    #[test]
684    fn canonical_and_short_names_are_pinned() {
685        // Guards both directions of the split that caused #714. The canonical
686        // name is what every predicate and machine-facing filter speaks; the
687        // short name is persisted in manifest confidence keys and textual
688        // NodeIds, so neither may drift silently.
689        assert_eq!(Language::TypeScript.canonical_name(), "typescript");
690        assert_eq!(Language::TypeScript.short_name(), "ts");
691        assert_eq!(Language::JavaScript.canonical_name(), "javascript");
692        assert_eq!(Language::JavaScript.short_name(), "js");
693        assert_eq!(Language::Python.canonical_name(), "python");
694        assert_eq!(Language::Python.short_name(), "py");
695        // Display is the short name, unchanged: it is a persisted contract.
696        assert_eq!(Language::TypeScript.to_string(), "ts");
697        assert_eq!(Language::JavaScript.to_string(), "js");
698        assert_eq!(Language::Python.to_string(), "py");
699    }
700
701    #[test]
702    fn unknown_is_not_a_language() {
703        // Several surfaces map a language-less file to the literal "unknown".
704        // It must never become a Language variant, or those surfaces would
705        // start conflating "no language" with a real one.
706        assert_eq!(Language::from_id("unknown"), None);
707        assert_eq!(Language::from_id("bogus"), None);
708        assert_eq!(Language::from_id(""), None);
709        // Plugin ids are not language ids.
710        assert_eq!(Language::from_id("servicenow-xanadu"), None);
711        assert_eq!(Language::from_id("servicenow-xml"), None);
712    }
713
714    #[test]
715    fn test_language_display() {
716        assert_eq!(Language::Cpp.to_string(), "cpp");
717        assert_eq!(Language::JavaScript.to_string(), "js");
718        assert_eq!(Language::Python.to_string(), "py");
719        assert_eq!(Language::Ruby.to_string(), "ruby");
720        assert_eq!(Language::Php.to_string(), "php");
721        assert_eq!(Language::Swift.to_string(), "swift");
722        assert_eq!(Language::Kotlin.to_string(), "kotlin");
723        assert_eq!(Language::Scala.to_string(), "scala");
724        assert_eq!(Language::Http.to_string(), "http");
725    }
726
727    #[test]
728    fn test_language_from_id() {
729        assert_eq!(Language::from_id("javascript"), Some(Language::JavaScript));
730        assert_eq!(Language::from_id("js"), Some(Language::JavaScript));
731        assert_eq!(Language::from_id("c#"), Some(Language::CSharp));
732        assert_eq!(Language::from_id("rb"), Some(Language::Ruby));
733        assert_eq!(Language::from_id("json"), Some(Language::Json));
734        assert_eq!(Language::from_id("unknown"), None);
735    }
736}