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}