Skip to main content

codehelion_core/
ir.rs

1//! The Syntax IR: a language-neutral structural view of one source file.
2//!
3//! Structural mode compares code by shape, not just by token content. Each
4//! frontend parses a file with its real parser and maps the resulting tree
5//! onto this IR: a token stream (the same [`Token`] representation Fast mode
6//! uses) plus a tree of [`IrNode`]s whose [`Shape`]s come from a small
7//! cross-language vocabulary. Nodes that have no cross-language equivalent
8//! keep their native grammar kind instead of being forced into the nearest
9//! common shape — a C++ `template_declaration` stays distinguishable from a
10//! Rust generic function.
11//!
12//! Error tolerance follows the frontend contract: a malformed region becomes
13//! an [`Shape::Error`] node covering its source range and parsing continues.
14//! Consumers that segment the tree into units must search recursively and
15//! judge each found node by its own subtree, never by the mere presence of an
16//! error ancestor: real parsers wrap large healthy regions in error nodes
17//! whose ranges are the union of individually intact children.
18//!
19//! Macros and templates are not expanded in Fast or Structural mode. The IR
20//! records definition sites ([`Shape::MacroDef`]) and invocation sites
21//! ([`Shape::MacroCall`]) as ordinary nodes so later phases can attach
22//! expansion information without changing this schema.
23//!
24//! Byte ranges are the only positions stored on nodes; line/column rendering
25//! is a reporting concern served by the token stream. No position feeds any
26//! stable identifier.
27//!
28//! # Schema versioning
29//!
30//! [`IR_SCHEMA_VERSION`] is carried on every [`SyntaxIrFile`] so a consumer can
31//! tell which schema it holds. It is not an input to any fingerprint or run
32//! identity: the frontend version is, and a schema change that alters what a
33//! frontend emits moves that version.
34
35use core::fmt;
36
37use crate::discovery::Language;
38use crate::frontend::{Diagnostic, Lexeme, Token};
39
40/// Version of the Syntax IR schema, carried per file. Not a fingerprint input.
41pub const IR_SCHEMA_VERSION: u32 = 1;
42
43/// Domain and recipe version for syntax signatures.
44pub const SYNTAX_SIGNATURE_VERSION: &str = "syntax-signature-v1";
45
46/// The 128-bit content key of one normalized function or method signature.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
48pub struct SignatureKey([u8; 16]);
49
50impl SignatureKey {
51    /// Wrap bytes previously produced by this signature recipe.
52    #[must_use]
53    pub const fn from_bytes(bytes: [u8; 16]) -> Self {
54        Self(bytes)
55    }
56
57    /// Return the raw key bytes.
58    #[must_use]
59    pub const fn as_bytes(&self) -> &[u8; 16] {
60        &self.0
61    }
62
63    /// Lowercase hexadecimal form suitable for reports and storage.
64    #[must_use]
65    pub fn to_hex(self) -> String {
66        self.to_string()
67    }
68}
69
70impl fmt::Display for SignatureKey {
71    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
72        for byte in self.0 {
73            write!(formatter, "{byte:02x}")?;
74        }
75        Ok(())
76    }
77}
78
79/// A canonical, position-free function or method signature.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct Signature {
82    /// BLAKE3-128 key derived from the language and normalized form.
83    pub key: SignatureKey,
84    /// Deterministic structured signature text supplied by a frontend.
85    pub normalized: String,
86}
87
88impl Signature {
89    /// Construct a signature and derive its key from one canonical input.
90    #[must_use]
91    pub fn new(language: Language, normalized: impl Into<String>) -> Self {
92        // Frontends own canonicalization (including trivia removal). Keep
93        // their structured text byte-for-byte: whitespace can be payload in
94        // literals and template arguments, and changing it here would make
95        // distinct signatures collide.
96        let normalized = normalized.into();
97        let mut hasher = blake3::Hasher::new();
98        write_signature_part(&mut hasher, SYNTAX_SIGNATURE_VERSION.as_bytes());
99        write_signature_part(&mut hasher, language.name().as_bytes());
100        write_signature_part(&mut hasher, normalized.as_bytes());
101        let mut bytes = [0; 16];
102        bytes.copy_from_slice(&hasher.finalize().as_bytes()[..16]);
103        Self {
104            key: SignatureKey(bytes),
105            normalized,
106        }
107    }
108}
109
110fn write_signature_part(hasher: &mut blake3::Hasher, bytes: &[u8]) {
111    let len = u32::try_from(bytes.len()).unwrap_or(u32::MAX);
112    hasher.update(&len.to_le_bytes());
113    hasher.update(bytes);
114}
115
116/// Maximum number of IR nodes on one root-to-leaf path emitted by the bundled
117/// structural frontends.
118///
119/// Frontends stop descending before this budget can be exceeded and preserve
120/// the omitted source region as an [`Shape::Error`] leaf. This keeps
121/// structural recovery bounded for mechanically generated and adversarial
122/// source files.
123pub const MAX_IR_DEPTH: usize = 500;
124
125/// A half-open byte range into the source text.
126///
127/// Ordering is by start then end, which is source order: it exists so ranges
128/// can be sorted into a deterministic reporting order, and carries no meaning
129/// beyond that.
130#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
131pub struct ByteRange {
132    /// Byte offset of the range start.
133    pub start: usize,
134    /// Byte offset one past the range end.
135    pub end: usize,
136}
137
138impl ByteRange {
139    /// Length of the range in bytes; `0` for a malformed range.
140    #[must_use]
141    pub const fn len(&self) -> usize {
142        self.end.saturating_sub(self.start)
143    }
144
145    /// Whether the range covers no bytes.
146    #[must_use]
147    pub const fn is_empty(&self) -> bool {
148        self.len() == 0
149    }
150
151    /// Whether `other` lies entirely within this range.
152    #[must_use]
153    pub const fn contains(&self, other: &Self) -> bool {
154        self.start <= other.start && other.end <= self.end
155    }
156}
157
158/// The cross-language shape vocabulary.
159///
160/// Every variant except [`Shape::Native`] means the same thing in all three
161/// languages, so structural comparison across files (and, in later phases,
162/// across languages) can work on shapes alone. A frontend maps its grammar
163/// onto these; whatever does not fit is carried as [`Shape::Native`] with the
164/// grammar's own kind name preserved.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum Shape {
167    /// A free function definition.
168    Function,
169    /// A method definition (function inside an `impl`/class/struct body).
170    Method,
171    /// A closure or lambda.
172    Closure,
173    /// A record definition: `struct`, `class`, `union` or `enum`.
174    Record,
175    /// An implementation or member-definition container (`impl`, class body).
176    Impl,
177    /// A braced statement block.
178    Block,
179    /// A loop of any flavour (`for`, `while`, `loop`, do-while).
180    Loop,
181    /// A two-way conditional (`if`/`else` chain member).
182    Branch,
183    /// A multi-way conditional (`match`, `switch`).
184    Match,
185    /// One arm of a multi-way conditional.
186    MatchArm,
187    /// A function, method or macro-like call expression.
188    Call,
189    /// An assignment or compound assignment.
190    Assign,
191    /// A local variable declaration (`let`, C/C++ declaration statement).
192    VarDecl,
193    /// A `return` (or expression-position tail return).
194    Return,
195    /// An early loop exit (`break`).
196    Break,
197    /// A loop continuation (`continue`).
198    Continue,
199    /// Error propagation or handling (`?` operator, `try`/`catch`).
200    Try,
201    /// An expression used as a statement, not covered by a finer shape.
202    ExprStmt,
203    /// A macro or preprocessor definition site.
204    MacroDef,
205    /// A macro invocation site (not expanded).
206    MacroCall,
207    /// A region the parser could not interpret. Children may still be intact.
208    Error,
209    /// A node kept under its native grammar kind because no common shape
210    /// applies. The kind name takes part in structural comparison, so two
211    /// native nodes match only when their grammars call them the same thing.
212    Native(Lexeme),
213}
214
215impl Shape {
216    /// A stable one-byte tag for this shape, for use as fingerprint input.
217    ///
218    /// For [`Shape::Native`] the tag alone is not sufficient input: the
219    /// native kind name must be hashed alongside it, or all native nodes
220    /// would collapse into one shape.
221    #[must_use]
222    pub const fn tag(&self) -> u8 {
223        match self {
224            Self::Function => 1,
225            Self::Method => 2,
226            Self::Closure => 3,
227            Self::Record => 4,
228            Self::Impl => 5,
229            Self::Block => 6,
230            Self::Loop => 7,
231            Self::Branch => 8,
232            Self::Match => 9,
233            Self::MatchArm => 10,
234            Self::Call => 11,
235            Self::Assign => 12,
236            Self::VarDecl => 13,
237            Self::Return => 14,
238            Self::Break => 15,
239            Self::Continue => 16,
240            Self::Try => 17,
241            Self::ExprStmt => 18,
242            Self::MacroDef => 19,
243            Self::MacroCall => 20,
244            Self::Error => 21,
245            Self::Native(_) => 22,
246        }
247    }
248
249    /// Whether this shape opens a lexical scope for alpha renaming.
250    ///
251    /// This is the structural basis normalization uses to rename identifiers
252    /// consistently within — and only within — one scope. The judgement is
253    /// syntactic and shared by all three languages: bodies bind, containers
254    /// and single statements do not.
255    #[must_use]
256    pub const fn introduces_scope(&self) -> bool {
257        matches!(
258            self,
259            Self::Function
260                | Self::Method
261                | Self::Closure
262                | Self::Block
263                | Self::Loop
264                | Self::Branch
265                | Self::Match
266                | Self::MatchArm
267                | Self::Try
268        )
269    }
270
271    /// Whether nodes of this shape are statements for the purposes of
272    /// statement sequences and statement-window fragments.
273    #[must_use]
274    pub const fn is_statement(&self) -> bool {
275        matches!(
276            self,
277            Self::Loop
278                | Self::Branch
279                | Self::Match
280                | Self::Assign
281                | Self::VarDecl
282                | Self::Return
283                | Self::Break
284                | Self::Continue
285                | Self::Try
286                | Self::ExprStmt
287                | Self::MacroCall
288        )
289    }
290}
291
292/// One node of the Syntax IR tree.
293///
294/// A node covers a contiguous token range (`token_start..token_end` indices
295/// into [`SyntaxIrFile::tokens`]) and a contiguous byte range of the source.
296/// Children are in source order and lie within their parent's ranges. Nodes
297/// destroy their descendants iteratively so dropping a recovered file does not
298/// consume stack space proportional to its depth.
299#[derive(Debug, Clone, PartialEq, Eq)]
300pub struct IrNode {
301    /// The node's shape.
302    pub shape: Shape,
303    /// The declared name, when the frontend can recover one (functions,
304    /// methods, records, macro definitions).
305    pub name: Option<Lexeme>,
306    /// Index of the node's first token in the file's token stream.
307    pub token_start: usize,
308    /// Index one past the node's last token.
309    pub token_end: usize,
310    /// Source bytes the node covers.
311    pub range: ByteRange,
312    /// Child nodes in source order.
313    pub children: Vec<Self>,
314}
315
316impl IrNode {
317    /// Number of tokens the node covers; `0` for a malformed range.
318    #[must_use]
319    pub const fn token_len(&self) -> usize {
320        self.token_end.saturating_sub(self.token_start)
321    }
322
323    /// Depth-first pre-order traversal over this node and its descendants.
324    ///
325    /// The traversal is iterative so callers can safely inspect externally
326    /// constructed IR too, even when it did not come from a bounded frontend.
327    pub fn walk(&self, visit: &mut impl FnMut(&Self)) {
328        let mut pending = vec![self];
329        while let Some(node) = pending.pop() {
330            visit(node);
331            pending.extend(node.children.iter().rev());
332        }
333    }
334
335    /// The sequence of statement children, summarised.
336    ///
337    /// This is the per-block statement sequence view of the IR: direct
338    /// children whose shapes are statements, in source order, each reduced
339    /// to its [`StatementSummary`]. Non-statement children (nested items,
340    /// blocks acting as expressions) are skipped, matching how statement
341    /// windows are cut.
342    ///
343    /// [`Shape::Native`] children are included: a native node that is a
344    /// direct child of the node being summarised sits in statement position
345    /// by construction (C `goto`, a preprocessor conditional inside a
346    /// function body), and dropping it would silently shorten the sequence.
347    #[must_use]
348    pub fn statement_summaries(&self, tokens: &[Token]) -> Vec<StatementSummary> {
349        self.children
350            .iter()
351            .filter(|child| child.shape.is_statement() || matches!(child.shape, Shape::Native(_)))
352            .map(|child| StatementSummary::of(child, tokens))
353            .collect()
354    }
355}
356
357impl Drop for IrNode {
358    fn drop(&mut self) {
359        let mut worklist = std::mem::take(&mut self.children);
360        while let Some(mut node) = worklist.pop() {
361            worklist.append(&mut node.children);
362        }
363    }
364}
365
366/// How many leading tokens a [`StatementSummary`] keeps.
367pub const SUMMARY_HEAD_TOKENS: usize = 4;
368
369/// A statement reduced to its shape and the span of its tokens.
370///
371/// The shape carries the rename-invariant signal that aligns two statement
372/// sequences; the span is how the text itself is recovered, for the lexical
373/// comparison that decides whether aligned statements are actually copies.
374///
375/// The span is kept rather than the token texts because a compound statement
376/// covers its whole body: cloning the texts would cost a copy of the token
377/// stream once per level of nesting, while an index pair costs the same
378/// whatever the statement contains. It is a position into one file's stream
379/// and nothing more — identity in this tool is content-derived, and no
380/// fingerprint reads this type.
381#[derive(Debug, Clone, PartialEq, Eq)]
382pub struct StatementSummary {
383    /// Shape tag of the statement (see [`Shape::tag`]).
384    pub shape_tag: u8,
385    /// Native kind name when the statement is a [`Shape::Native`] node.
386    pub native_kind: Option<Lexeme>,
387    /// Index of the statement's first token in its file's stream.
388    pub token_start: usize,
389    /// Index one past the statement's last token in its file's stream.
390    pub token_end: usize,
391}
392
393impl StatementSummary {
394    /// Summarise one node against its file's token stream.
395    #[must_use]
396    pub fn of(node: &IrNode, tokens: &[Token]) -> Self {
397        let native_kind = match &node.shape {
398            Shape::Native(kind) => Some(kind.clone()),
399            _ => None,
400        };
401        let token_end = node.token_end.min(tokens.len());
402        Self {
403            shape_tag: node.shape.tag(),
404            native_kind,
405            token_start: node.token_start.min(token_end),
406            token_end,
407        }
408    }
409
410    /// The statement's tokens, resolved against the stream it was summarised
411    /// from. Empty for any other stream, since the span would not be its own.
412    #[must_use]
413    pub fn tokens<'a>(&self, tokens: &'a [Token]) -> &'a [Token] {
414        tokens.get(self.token_start..self.token_end).unwrap_or(&[])
415    }
416}
417
418/// The Syntax IR of one source file.
419#[derive(Debug, Clone)]
420pub struct SyntaxIrFile {
421    /// Language the file was parsed as.
422    pub language: Language,
423    /// Version tag of the structural frontend that produced this IR; a
424    /// fingerprint input.
425    pub frontend_version: &'static str,
426    /// IR schema version this file conforms to.
427    pub ir_schema_version: u32,
428    /// Tokens in source order, comments and whitespace removed. Same
429    /// representation as Fast mode, so token-level normalization is shared.
430    pub tokens: Vec<Token>,
431    /// Function and method signatures keyed by their exact source range.
432    ///
433    /// This is a reporting and semantic-evidence side table only. It is not
434    /// read by structural or Fast fingerprint construction, so changing a
435    /// signature never changes an existing structural identity.
436    pub signatures: Vec<(ByteRange, Signature)>,
437    /// Top-level IR nodes in source order.
438    pub roots: Vec<IrNode>,
439    /// Recoverable lexical problems, as in Fast mode.
440    pub diagnostics: Vec<Diagnostic>,
441    /// Source regions the parser marked as errors. Overlapping nodes are
442    /// still emitted; these ranges only lower confidence downstream.
443    pub error_ranges: Vec<ByteRange>,
444    /// Whether the frontend stopped descending after reaching its structural
445    /// depth budget.
446    ///
447    /// This is deliberately separate from [`Self::error_ranges`]: malformed
448    /// source is ordinary recovery, while a depth ceiling means the scan was
449    /// intentionally incomplete and must be reported as such.
450    pub depth_truncated: bool,
451    /// Whether the file is the body of a module its tree declares test-only.
452    ///
453    /// Not something a parse can answer: the declaration carrying the marker
454    /// is in another file, so this is settled once the whole set is known and
455    /// left here for the walk that reads a unit's markers to start from. A
456    /// frontend leaves it false. See
457    /// [`declared_test_modules`](crate::test_code::declared_test_modules).
458    pub test_module: bool,
459}
460
461impl SyntaxIrFile {
462    /// Look up a signature by exact source range.
463    #[must_use]
464    pub fn signature_for_range(&self, range: ByteRange) -> Option<&Signature> {
465        debug_assert!(
466            self.signatures.windows(2).all(|pair| pair[0].0 < pair[1].0),
467            "SyntaxIrFile.signatures must be strictly source-order sorted"
468        );
469        self.signatures
470            .binary_search_by_key(&(range.start, range.end), |(candidate, _)| {
471                (candidate.start, candidate.end)
472            })
473            .ok()
474            .and_then(|index| self.signatures.get(index).map(|(_, signature)| signature))
475    }
476
477    /// Depth-first pre-order traversal over every node in the file.
478    pub fn walk(&self, visit: &mut impl FnMut(&IrNode)) {
479        for root in &self.roots {
480            root.walk(visit);
481        }
482    }
483
484    /// Total number of nodes in the file.
485    #[must_use]
486    pub fn node_count(&self) -> usize {
487        let mut count = 0;
488        self.walk(&mut |_| count += 1);
489        count
490    }
491
492    /// Tokens the parser could not attach to any structure: those inside a
493    /// [`Shape::Error`] node that none of its children recovered.
494    ///
495    /// This is the honest measure of what a parse lost, and
496    /// [`error_ranges`](Self::error_ranges) is not. An error-tolerant parser
497    /// recovering from one bad construct routinely wraps everything around it
498    /// in a single error node: a header whose include guard encloses the file
499    /// gets one error region covering every byte of it, with the whole file's
500    /// declarations intact inside. Measured over one project's C++ sources,
501    /// the error regions covered 13.3% of the bytes while the tokens that
502    /// actually failed to parse were 1.82% — the difference is entirely code
503    /// the parser did read, sitting inside a region it had to open.
504    ///
505    /// Nesting is not double-counted: an error node inside another is covered
506    /// by its parent's children, so the parent contributes only the gaps
507    /// around it and the child contributes its own.
508    #[must_use]
509    pub fn unaccounted_tokens(&self) -> usize {
510        let mut lost = 0;
511        self.walk(&mut |node| {
512            if matches!(node.shape, Shape::Error) {
513                let recovered: usize = node.children.iter().map(IrNode::token_len).sum();
514                lost += node.token_len().saturating_sub(recovered);
515            }
516        });
517        lost
518    }
519}
520
521/// Canonicalize frontend-produced signature entries.
522///
523/// Entries are returned in strict source order. Exact duplicate entries are
524/// collapsed deterministically; if one byte range has unequal signatures,
525/// every candidate for that range is discarded because the frontend has no
526/// safe way to choose one interpretation. Structural fingerprints never read
527/// this side table, so dropping an ambiguous entry is preferable to inventing
528/// evidence.
529#[must_use]
530pub fn canonicalize_signatures(
531    mut signatures: Vec<(ByteRange, Signature)>,
532) -> Vec<(ByteRange, Signature)> {
533    signatures.sort_by(|left, right| {
534        left.0
535            .cmp(&right.0)
536            .then_with(|| left.1.normalized.cmp(&right.1.normalized))
537            .then_with(|| left.1.key.cmp(&right.1.key))
538    });
539
540    let mut canonical = Vec::with_capacity(signatures.len());
541    let mut index = 0;
542    while index < signatures.len() {
543        let range = signatures[index].0;
544        let group_start = index;
545        index += 1;
546        while index < signatures.len() && signatures[index].0 == range {
547            index += 1;
548        }
549        let candidate = &signatures[group_start].1;
550        if signatures[group_start + 1..index]
551            .iter()
552            .all(|(_, signature)| signature == candidate)
553        {
554            canonical.push(signatures[group_start].clone());
555        }
556    }
557    canonical
558}
559
560/// A Structural-mode parser for one language.
561///
562/// Like the Fast [`Frontend`](crate::frontend::Frontend), a structural
563/// frontend never executes, expands or resolves anything in the target code:
564/// it parses text and maps the tree. Malformed input degrades to
565/// [`Shape::Error`] nodes plus [`SyntaxIrFile::error_ranges`]. A frontend
566/// that reaches its depth budget records the remaining source region in the
567/// same way instead of continuing recursive descent.
568pub trait StructuralFrontend {
569    /// The language this frontend parses.
570    fn language(&self) -> Language;
571
572    /// The frontend's version tag, used as a fingerprint input.
573    fn frontend_version(&self) -> &'static str;
574
575    /// Parse `source` into a Syntax IR file.
576    fn parse(&self, source: &str) -> SyntaxIrFile;
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582    use crate::frontend::{SourceSpan, TokenKind};
583
584    fn token(text: &str, start_byte: usize) -> Token {
585        Token {
586            kind: TokenKind::Identifier,
587            text: Lexeme::from(text),
588            span: SourceSpan {
589                start_byte,
590                end_byte: start_byte + text.len(),
591                start_line: 1,
592                start_column: 1,
593            },
594        }
595    }
596
597    fn node(shape: Shape, token_start: usize, token_end: usize) -> IrNode {
598        IrNode {
599            shape,
600            name: None,
601            token_start,
602            token_end,
603            range: ByteRange {
604                start: token_start,
605                end: token_end,
606            },
607            children: Vec::new(),
608        }
609    }
610
611    #[test]
612    fn dropping_a_deep_tree_is_iterative() {
613        let mut tree = node(Shape::Block, 0, 0);
614        for _ in 0..10_000 {
615            tree = IrNode {
616                shape: Shape::Block,
617                name: None,
618                token_start: 0,
619                token_end: 0,
620                range: ByteRange { start: 0, end: 0 },
621                children: vec![tree],
622            };
623        }
624
625        drop(tree);
626    }
627
628    #[test]
629    fn shape_tags_are_distinct_and_stable() {
630        let shapes = [
631            Shape::Function,
632            Shape::Method,
633            Shape::Closure,
634            Shape::Record,
635            Shape::Impl,
636            Shape::Block,
637            Shape::Loop,
638            Shape::Branch,
639            Shape::Match,
640            Shape::MatchArm,
641            Shape::Call,
642            Shape::Assign,
643            Shape::VarDecl,
644            Shape::Return,
645            Shape::Break,
646            Shape::Continue,
647            Shape::Try,
648            Shape::ExprStmt,
649            Shape::MacroDef,
650            Shape::MacroCall,
651            Shape::Error,
652            Shape::Native(Lexeme::from("preproc_ifdef")),
653        ];
654        let mut tags: Vec<u8> = shapes.iter().map(Shape::tag).collect();
655        tags.sort_unstable();
656        tags.dedup();
657        assert_eq!(tags.len(), shapes.len(), "shape tags must be distinct");
658        // Tag values are part of the fingerprint schema: spot-pin endpoints.
659        assert_eq!(Shape::Function.tag(), 1);
660        assert_eq!(Shape::Native(Lexeme::from("x")).tag(), 22);
661    }
662
663    #[test]
664    fn native_nodes_share_a_tag_but_keep_their_kind() {
665        let a = Shape::Native(Lexeme::from("preproc_ifdef"));
666        let b = Shape::Native(Lexeme::from("using_declaration"));
667        assert_eq!(a.tag(), b.tag());
668        assert_ne!(a, b, "the kind name still distinguishes native shapes");
669    }
670
671    #[test]
672    fn scope_and_statement_tables_are_consistent() {
673        assert!(Shape::Function.introduces_scope());
674        assert!(Shape::Block.introduces_scope());
675        assert!(!Shape::Call.introduces_scope());
676        assert!(!Shape::Record.introduces_scope());
677
678        assert!(Shape::Return.is_statement());
679        assert!(Shape::MacroCall.is_statement());
680        assert!(!Shape::Function.is_statement(), "items are not statements");
681        assert!(!Shape::Block.is_statement());
682    }
683
684    #[test]
685    fn byte_range_arithmetic_guards_malformed_input() {
686        let range = ByteRange { start: 10, end: 20 };
687        assert_eq!(range.len(), 10);
688        assert!(!range.is_empty());
689        assert!(range.contains(&ByteRange { start: 12, end: 18 }));
690        assert!(!range.contains(&ByteRange { start: 5, end: 18 }));
691
692        let malformed = ByteRange { start: 20, end: 10 };
693        assert_eq!(malformed.len(), 0);
694        assert!(malformed.is_empty());
695    }
696
697    #[test]
698    fn signatures_are_language_scoped_and_exactly_looked_up() {
699        let first = Signature::new(Language::Rust, "rust | params = [i32] | return = ()");
700        assert_eq!(first.normalized, "rust | params = [i32] | return = ()");
701        let same_text_cpp = Signature::new(Language::Cpp, first.normalized.clone());
702        assert_ne!(first.key, same_text_cpp.key);
703        assert_eq!(SYNTAX_SIGNATURE_VERSION, "syntax-signature-v1");
704        assert_eq!(IR_SCHEMA_VERSION, 1);
705        let file = SyntaxIrFile {
706            language: Language::Rust,
707            frontend_version: "test-v1",
708            ir_schema_version: IR_SCHEMA_VERSION,
709            tokens: Vec::new(),
710            signatures: vec![
711                (ByteRange { start: 2, end: 3 }, first.clone()),
712                (ByteRange { start: 8, end: 9 }, same_text_cpp),
713            ],
714            roots: Vec::new(),
715            diagnostics: Vec::new(),
716            error_ranges: Vec::new(),
717            depth_truncated: false,
718            test_module: false,
719        };
720        assert_eq!(
721            file.signature_for_range(ByteRange { start: 2, end: 3 }),
722            Some(&first)
723        );
724        assert!(
725            file.signature_for_range(ByteRange { start: 2, end: 4 })
726                .is_none()
727        );
728    }
729
730    #[test]
731    fn signature_recipe_digest_is_pinned() {
732        let signature = Signature::new(Language::Rust, "receiver=free|params=[i32]|return=()");
733        assert_eq!(signature.normalized, "receiver=free|params=[i32]|return=()");
734        assert_eq!(signature.key.to_hex(), "c32d8883402f5a9c6aa26a437791f51e");
735    }
736
737    #[test]
738    fn signature_constructor_preserves_literal_whitespace_payload() {
739        let with_space = Signature::new(Language::Cpp, r#"return=decltype("a b")"#);
740        let without_space = Signature::new(Language::Cpp, r#"return=decltype("ab")"#);
741        assert_eq!(with_space.normalized, r#"return=decltype("a b")"#);
742        assert_ne!(with_space.key, without_space.key);
743    }
744
745    #[test]
746    fn signature_canonicalization_keeps_exact_duplicates_and_drops_conflicts() {
747        let first = Signature::new(Language::Rust, "rust|receiver=free|return=()");
748        let conflicting = Signature::new(Language::Rust, "rust|receiver=free|return=i32");
749        let range = ByteRange { start: 10, end: 20 };
750        let second_range = ByteRange { start: 30, end: 40 };
751        let canonical = canonicalize_signatures(vec![
752            (second_range, first.clone()),
753            (range, conflicting),
754            (range, first.clone()),
755            (second_range, first.clone()),
756        ]);
757        assert_eq!(canonical, vec![(second_range, first)]);
758        assert!(canonical.windows(2).all(|pair| pair[0].0 < pair[1].0));
759    }
760
761    #[test]
762    fn changing_signatures_does_not_change_structural_features() {
763        let root = IrNode {
764            shape: Shape::Function,
765            name: None,
766            token_start: 0,
767            token_end: 0,
768            range: ByteRange { start: 0, end: 1 },
769            children: Vec::new(),
770        };
771        let mut with_signature = file_of(vec![root.clone()]);
772        with_signature.signatures.push((
773            root.range,
774            Signature::new(Language::Rust, "rust|params=[]|return=()"),
775        ));
776        assert_eq!(
777            crate::features::extract(&file_of(vec![root])),
778            crate::features::extract(&with_signature)
779        );
780    }
781
782    #[test]
783    fn statement_summaries_take_statement_children_in_order() {
784        let tokens: Vec<Token> = ["let", "x", "=", "f", "(", ")", "return", "x"]
785            .iter()
786            .enumerate()
787            .map(|(i, text)| token(text, i * 8))
788            .collect();
789
790        let mut block = node(Shape::Block, 0, 8);
791        block.children = vec![
792            node(Shape::VarDecl, 0, 6),
793            node(Shape::Function, 0, 0), // nested item: not a statement
794            node(Shape::Return, 6, 8),
795        ];
796
797        let summaries = block.statement_summaries(&tokens);
798        assert_eq!(summaries.len(), 2);
799        assert!(
800            summaries.iter().all(|s| s.native_kind.is_none()),
801            "no native statements in this block"
802        );
803        assert_eq!(summaries[0].shape_tag, Shape::VarDecl.tag());
804        let text = |summary: &StatementSummary| -> Vec<String> {
805            summary
806                .tokens(&tokens)
807                .iter()
808                .map(|token| token.text.as_str().to_string())
809                .collect()
810        };
811        assert_eq!(
812            text(&summaries[0]),
813            vec!["let", "x", "=", "f", "(", ")"],
814            "the span covers the whole statement, not just its head"
815        );
816        assert_eq!(summaries[1].shape_tag, Shape::Return.tag());
817        assert_eq!(text(&summaries[1]), vec!["return", "x"]);
818    }
819
820    #[test]
821    fn native_children_count_as_statements_in_position() {
822        let tokens = vec![token("goto", 0), token("fail", 8)];
823        let mut block = node(Shape::Block, 0, 2);
824        block.children = vec![IrNode {
825            shape: Shape::Native(Lexeme::from("goto_statement")),
826            name: None,
827            token_start: 0,
828            token_end: 2,
829            range: ByteRange { start: 0, end: 12 },
830            children: Vec::new(),
831        }];
832
833        let summaries = block.statement_summaries(&tokens);
834        assert_eq!(summaries.len(), 1);
835        assert_eq!(
836            summaries[0].native_kind,
837            Some(Lexeme::from("goto_statement"))
838        );
839    }
840
841    #[test]
842    fn summary_of_out_of_bounds_token_range_is_empty_not_panicking() {
843        let tokens = vec![token("x", 0)];
844        let stray = node(Shape::ExprStmt, 5, 9);
845        let summary = StatementSummary::of(&stray, &tokens);
846        assert!(
847            summary.tokens(&tokens).is_empty(),
848            "{:?}",
849            summary.tokens(&tokens)
850        );
851    }
852
853    #[test]
854    fn walk_visits_every_node_pre_order() {
855        let mut root = node(Shape::Function, 0, 10);
856        let mut block = node(Shape::Block, 1, 9);
857        block.children = vec![node(Shape::Return, 2, 4)];
858        root.children = vec![block];
859
860        let file = SyntaxIrFile {
861            language: Language::Rust,
862            frontend_version: "test-v1",
863            ir_schema_version: IR_SCHEMA_VERSION,
864            tokens: Vec::new(),
865            signatures: Vec::new(),
866            roots: vec![root],
867            diagnostics: Vec::new(),
868            error_ranges: Vec::new(),
869            depth_truncated: false,
870            test_module: false,
871        };
872
873        let mut seen = Vec::new();
874        file.walk(&mut |n| seen.push(n.shape.tag()));
875        assert_eq!(
876            seen,
877            vec![
878                Shape::Function.tag(),
879                Shape::Block.tag(),
880                Shape::Return.tag()
881            ]
882        );
883        assert_eq!(file.node_count(), 3);
884    }
885
886    /// A file whose roots are `roots`, for the traversal tests.
887    fn file_of(roots: Vec<IrNode>) -> SyntaxIrFile {
888        SyntaxIrFile {
889            language: Language::Rust,
890            frontend_version: "test-v1",
891            ir_schema_version: IR_SCHEMA_VERSION,
892            tokens: Vec::new(),
893            signatures: Vec::new(),
894            roots,
895            diagnostics: Vec::new(),
896            error_ranges: Vec::new(),
897            depth_truncated: false,
898            test_module: false,
899        }
900    }
901
902    #[test]
903    fn code_recovered_inside_an_error_node_is_not_counted_as_lost() {
904        // The shape an error-tolerant parser actually produces: one error
905        // node opened by a construct it could not read, holding everything
906        // that followed and parsed cleanly. Counting the node's own extent
907        // would call the whole file unreadable.
908        let mut wrapper = node(Shape::Error, 0, 100);
909        wrapper.children = vec![node(Shape::Function, 3, 60), node(Shape::Function, 60, 100)];
910        assert_eq!(
911            file_of(vec![wrapper]).unaccounted_tokens(),
912            3,
913            "only the tokens no child accounts for"
914        );
915    }
916
917    #[test]
918    fn an_error_node_that_recovered_nothing_loses_all_of_it() {
919        assert_eq!(
920            file_of(vec![node(Shape::Error, 0, 40)]).unaccounted_tokens(),
921            40
922        );
923    }
924
925    #[test]
926    fn a_file_the_parser_followed_loses_nothing() {
927        let mut function = node(Shape::Function, 0, 20);
928        function.children = vec![node(Shape::Block, 4, 20)];
929        assert_eq!(file_of(vec![function]).unaccounted_tokens(), 0);
930    }
931
932    #[test]
933    fn nested_error_nodes_count_their_own_gaps_once() {
934        // The inner error is one of the outer's children, so the outer counts
935        // only what surrounds it and the inner counts what it failed to
936        // recover. Adding both extents would report more than the file holds.
937        let mut inner = node(Shape::Error, 40, 60);
938        inner.children = vec![node(Shape::Return, 45, 55)];
939        let mut outer = node(Shape::Error, 0, 100);
940        outer.children = vec![node(Shape::Function, 0, 40), inner];
941        assert_eq!(
942            file_of(vec![outer]).unaccounted_tokens(),
943            40 + 10,
944            "the outer's trailing gap plus the inner's own"
945        );
946    }
947}