panproto_parse/walker.rs
1//! Generic tree-sitter AST walker that converts parse trees to panproto schemas.
2//!
3//! Because theories are auto-derived from the grammar, the walker is fully generic:
4//! one implementation works for all languages. The node's `kind()` IS the panproto
5//! vertex kind; the field name IS the edge kind.
6//!
7//! Named-scope detection (functions, classes, methods, modules, types) is driven
8//! by the grammar's `queries/tags.scm` file via [`ScopeDetector`], not by a
9//! hardcoded node-kind list. This makes scope detection uniformly correct across
10//! every tree-sitter grammar that ships a tags query. See the [`scope_detector`]
11//! module for the full rationale.
12//!
13//! [`scope_detector`]: crate::scope_detector
14//! [`ScopeDetector`]: crate::scope_detector::ScopeDetector
15
16use std::collections::BTreeMap;
17
18use panproto_schema::{Protocol, Schema, SchemaBuilder};
19use rustc_hash::FxHashSet;
20
21use crate::error::ParseError;
22use crate::id_scheme::IdGenerator;
23use crate::scope_detector::{NamedScope, ScopeDetector};
24use crate::theory_extract::ExtractedTheoryMeta;
25
26/// Nodes whose kind names suggest they contain ordered statement sequences.
27///
28/// Unlike scope detection (which is grammar-driven via `tags.scm`), block
29/// grouping is a structural concern: we want sibling statements inside a
30/// block to get positional IDs (`$0`, `$1`, ...) so insertions don't
31/// cascade. Per-language [`WalkerConfig`] overrides extend this list.
32const BLOCK_KINDS: &[&str] = &[
33 "block",
34 "statement_block",
35 "compound_statement",
36 "declaration_list",
37 "field_declaration_list",
38 "enum_body",
39 "class_body",
40 "interface_body",
41 "module_body",
42];
43
44/// Configuration for the walker, allowing per-language customization.
45#[derive(Debug, Clone, Default)]
46pub struct WalkerConfig {
47 /// Additional node kinds that contain ordered statement sequences.
48 ///
49 /// Named-scope detection is handled by [`ScopeDetector`] from the
50 /// grammar's `tags.scm`; no per-language scope configuration is
51 /// required here.
52 ///
53 /// [`ScopeDetector`]: crate::scope_detector::ScopeDetector
54 pub extra_block_kinds: Vec<String>,
55 /// Whether to capture comment nodes as constraints on the following sibling.
56 pub capture_comments: bool,
57 /// Whether to capture whitespace/formatting as constraints.
58 pub capture_formatting: bool,
59}
60
61impl WalkerConfig {
62 /// Construct a config with formatting and comment capture enabled (the
63 /// common default; [`WalkerConfig::default`] returns all-false).
64 #[must_use]
65 pub const fn standard() -> Self {
66 Self {
67 extra_block_kinds: Vec::new(),
68 capture_comments: true,
69 capture_formatting: true,
70 }
71 }
72}
73
74/// Generic AST walker that converts a tree-sitter parse tree to a panproto [`Schema`].
75///
76/// The walker uses the auto-derived theory to determine vertex and edge kinds directly
77/// from the tree-sitter AST, requiring no manual mapping table. Named-scope identity
78/// (the part of the vertex ID that survives insertions) is driven by [`ScopeDetector`]
79/// from the grammar's `tags.scm` query.
80///
81/// [`ScopeDetector`]: crate::scope_detector::ScopeDetector
82pub struct AstWalker<'a> {
83 /// The source code bytes (needed for extracting text of leaf nodes).
84 source: &'a [u8],
85 /// The auto-derived theory metadata. The `vertex_kinds` set is used to
86 /// filter anonymous/internal tree-sitter nodes that are not part of the
87 /// language's public grammar.
88 theory_meta: &'a ExtractedTheoryMeta,
89 /// The protocol definition (for `SchemaBuilder` validation).
90 protocol: &'a Protocol,
91 /// Per-language configuration.
92 config: WalkerConfig,
93 /// Known block kinds (merged from defaults + config).
94 block_kinds: FxHashSet<String>,
95 /// Named scopes indexed by `(start_byte, end_byte)` for O(log n) lookup
96 /// during the tree walk. Derived from a [`ScopeDetector`] run over the
97 /// full source before the walk begins.
98 scope_map: BTreeMap<(usize, usize), NamedScope>,
99}
100
101impl<'a> AstWalker<'a> {
102 /// Create a new walker for the given source, theory, and protocol.
103 ///
104 /// Runs an optional [`ScopeDetector`] over the source to build a
105 /// per-file scope map. Pass `None` to disable named-scope detection
106 /// (every non-root vertex gets a positional ID). Pass `Some(detector)`
107 /// whose [`has_query`] is `false` for the same effect; the detector
108 /// short-circuits to an empty scope list.
109 ///
110 /// [`ScopeDetector`]: crate::scope_detector::ScopeDetector
111 /// [`has_query`]: crate::scope_detector::ScopeDetector::has_query
112 #[must_use]
113 pub fn new(
114 source: &'a [u8],
115 theory_meta: &'a ExtractedTheoryMeta,
116 protocol: &'a Protocol,
117 config: WalkerConfig,
118 scope_detector: Option<&mut ScopeDetector>,
119 ) -> Self {
120 let mut block_kinds: FxHashSet<String> =
121 BLOCK_KINDS.iter().map(|s| (*s).to_owned()).collect();
122 for kind in &config.extra_block_kinds {
123 block_kinds.insert(kind.clone());
124 }
125
126 let mut scope_map: BTreeMap<(usize, usize), NamedScope> = BTreeMap::new();
127 if let Some(det) = scope_detector {
128 for scope in det.scopes(source) {
129 scope_map.insert((scope.node_range.start, scope.node_range.end), scope);
130 }
131 }
132
133 Self {
134 source,
135 theory_meta,
136 protocol,
137 config,
138 block_kinds,
139 scope_map,
140 }
141 }
142
143 /// Walk the entire parse tree and produce a [`Schema`].
144 ///
145 /// # Errors
146 ///
147 /// Returns [`ParseError::SchemaConstruction`] if schema building fails.
148 pub fn walk(&self, tree: &tree_sitter::Tree, file_path: &str) -> Result<Schema, ParseError> {
149 let mut id_gen = IdGenerator::new(file_path);
150 let builder = SchemaBuilder::new(self.protocol);
151 let root = tree.root_node();
152
153 let builder = self.walk_node(root, builder, &mut id_gen, None)?;
154
155 builder.build().map_err(|e| ParseError::SchemaConstruction {
156 reason: e.to_string(),
157 })
158 }
159
160 /// Look up a node's named-scope entry, if any.
161 fn scope_for(&self, node: tree_sitter::Node<'_>) -> Option<&NamedScope> {
162 self.scope_map.get(&(node.start_byte(), node.end_byte()))
163 }
164
165 /// Recursively walk a single node, emitting vertices and edges.
166 fn walk_node(
167 &self,
168 node: tree_sitter::Node<'_>,
169 mut builder: SchemaBuilder,
170 id_gen: &mut IdGenerator,
171 parent_vertex_id: Option<&str>,
172 ) -> Result<SchemaBuilder, ParseError> {
173 // Skip anonymous tokens (punctuation, keywords like `{`, `}`, `,`, etc.).
174 // Error-recovery MISSING anonymous tokens (a zero-width `}`, `)`, `,`,
175 // or keyword tree-sitter *inserts* to recover) are anonymous too, so
176 // they would be dropped here; they are instead surfaced in
177 // `walk_children_with_interstitials`, which scans every node's children
178 // for them before the named-child walk (which also skips them).
179 if !node.is_named() {
180 return Ok(builder);
181 }
182
183 let kind = node.kind();
184
185 // Skip the root "program"/"source_file"/"module" wrapper if it just wraps children.
186 // We still process it to emit its children, but do so by iterating directly.
187 let is_root_wrapper = parent_vertex_id.is_none()
188 && (kind == "program"
189 || kind == "source_file"
190 || kind == "module"
191 || kind == "translation_unit");
192
193 let named_scope = if is_root_wrapper {
194 None
195 } else {
196 self.scope_for(node)
197 };
198
199 // Determine vertex ID.
200 //
201 // For a node that is *both* named-scope and scope-introducing
202 // (the common case: a `function_definition`, `class_definition`,
203 // `module`, etc.) we must disambiguate the name exactly once
204 // and reuse the same disambiguated leaf for both the vertex ID
205 // here and the scope-stack frame pushed below. The
206 // `record_name` / `push_recorded_scope` split on `IdGenerator`
207 // makes that explicit: `record_name` is the side-effecting
208 // step that bumps the parent frame's `seen` counter, the leaf
209 // it returns becomes the trailing component of the vertex ID,
210 // and `push_recorded_scope` later takes the same leaf without
211 // re-recording.
212 let (vertex_id, recorded_named_leaf) = if is_root_wrapper {
213 // Root wrappers get the file path as their ID.
214 (id_gen.current_prefix(), None)
215 } else if let Some(scope) = named_scope {
216 let leaf = id_gen.record_name(&scope.name);
217 let prefix = id_gen.current_prefix();
218 (format!("{prefix}::{leaf}"), Some(leaf))
219 } else {
220 // All other nodes get positional IDs.
221 (id_gen.anonymous_id(), None)
222 };
223
224 // Determine the effective vertex kind. If the theory has extracted vertex kinds,
225 // use those for validation. If the kind is unknown to the theory AND the protocol
226 // has a closed obj_kinds list, fall back to "node".
227 let effective_kind = if self.protocol.obj_kinds.is_empty() {
228 // Open protocol: accept all kinds.
229 kind
230 } else if self.protocol.obj_kinds.iter().any(|k| k == kind) {
231 kind
232 } else if !self.theory_meta.vertex_kinds.is_empty()
233 && self.theory_meta.vertex_kinds.iter().any(|k| k == kind)
234 {
235 // Known in the auto-derived theory even if not in the protocol's obj_kinds.
236 kind
237 } else {
238 "node"
239 };
240
241 builder = builder
242 .vertex(&vertex_id, effective_kind, None)
243 .map_err(|e| ParseError::SchemaConstruction {
244 reason: format!("vertex '{vertex_id}' ({kind}): {e}"),
245 })?;
246
247 // Emit edge from parent to this node.
248 if let Some(parent_id) = parent_vertex_id {
249 // Determine edge kind: use the tree-sitter field name if this node
250 // was accessed via a field, otherwise use "child_of".
251 let edge_kind = node
252 .parent()
253 .and_then(|p| {
254 // Find which field of the parent this node corresponds to.
255 for i in 0..p.child_count() {
256 if let Some(child) = p.child(u32::try_from(i).unwrap_or(0)) {
257 if child.id() == node.id() {
258 return u32::try_from(i)
259 .ok()
260 .and_then(|idx| p.field_name_for_child(idx));
261 }
262 }
263 }
264 None
265 })
266 .unwrap_or("child_of");
267
268 builder = builder
269 .edge(parent_id, &vertex_id, edge_kind, None)
270 .map_err(|e| ParseError::SchemaConstruction {
271 reason: format!("edge {parent_id} -> {vertex_id} ({edge_kind}): {e}"),
272 })?;
273 }
274
275 // Store byte range for position-aware emission.
276 builder = builder.constraint(&vertex_id, "start-byte", &node.start_byte().to_string());
277 builder = builder.constraint(&vertex_id, "end-byte", &node.end_byte().to_string());
278
279 // Capture any leading bytes that precede the document root. Tree-sitter
280 // excludes leading `extra` tokens (a UTF-8 BOM, an awk `\<newline>`
281 // line-continuation, a leading comment) from the root node's span, so
282 // those bytes belong to no vertex's interstitial run and are dropped on
283 // replay. Record them as a `doc-prefix` constraint on the root vertex so
284 // the byte-faithful emit path can reproduce them verbatim. Only the
285 // document root (no parent) with a non-zero start byte carries this;
286 // every other vertex's leading gap is already a sibling interstitial.
287 if parent_vertex_id.is_none() && node.start_byte() > 0 {
288 if let Ok(prefix) = std::str::from_utf8(&self.source[..node.start_byte()]) {
289 if !prefix.is_empty() {
290 builder = builder.constraint(&vertex_id, "doc-prefix", prefix);
291 }
292 }
293 }
294
295 // Record the pre-alias grammar symbol name when it differs from
296 // the post-alias kind. Tree-sitter 0.25 exposes `grammar_name`
297 // (the SYMBOL name as it appears in the rule body, before
298 // `ALIAS { value: V }` rewriting). This is the signal that
299 // disambiguates which production reached this child: when
300 // emit's CHOICE dispatcher sees two alternatives both yielding
301 // a child of kind `K`, one through `SYMBOL K` and one through
302 // `ALIAS { ..., value: K }`, the recorded `pre-alias-symbol`
303 // identifies which.
304 let grammar_name = node.grammar_name();
305 if grammar_name != kind {
306 builder = builder.constraint(&vertex_id, "pre-alias-symbol", grammar_name);
307 }
308
309 // Emit constraints for leaf nodes (literals, identifiers, operators).
310 if node.named_child_count() == 0 {
311 if let Ok(text) = node.utf8_text(self.source) {
312 builder = builder.constraint(&vertex_id, "literal-value", text);
313 }
314 }
315
316 // Capture field-keyed anonymous-token children as `field:<name>`
317 // constraints on this vertex. Tree-sitter rules of the form
318 // `field('op', choice('+', '-', '*', '/'))` produce children
319 // that are field-named but not themselves named nodes, so they
320 // are skipped by the named-child walk above and would otherwise
321 // be invisible to downstream consumers. Emitting the value here
322 // lets consumers read `schema.field_text(vid, name)` directly
323 // rather than reconstructing the text via start-byte / end-byte
324 // arithmetic against the source buffer.
325 builder = self.capture_anonymous_field_constraints(node, &vertex_id, builder);
326
327 // Capture the faithful production trace: the ordered linearization
328 // of this node's children as the parser tokenized them. Each slot
329 // is either `C<kind>` for a named child (a schema edge — the
330 // variant tag the emit review consumes) or `T<text>` for an
331 // anonymous grammar token (a layout literal). This is the
332 // variant-tag fibre of the layout complement; replaying it in the
333 // put direction is byte-faithful by construction (it is the Prism
334 // review *consuming* the parser's CHOICE decision rather than
335 // re-deriving it). Hidden rules are inlined by tree-sitter and
336 // never surface as their own child, so the trace aligns 1:1 with
337 // the emitter's cursor edges and grammar string literals.
338 builder = self.capture_production_trace(node, &vertex_id, builder);
339
340 // Emit formatting constraints if enabled.
341 if self.config.capture_formatting {
342 builder = self.emit_formatting_constraints(node, &vertex_id, builder);
343 }
344
345 // Enter scope if this is a scope-introducing node. For a
346 // named scope we reuse the disambiguated leaf computed above
347 // (so the frame name matches the trailing component of
348 // `vertex_id`); for an anonymous block we push a fresh
349 // positional frame.
350 let entered_scope = if let Some(leaf) = recorded_named_leaf {
351 id_gen.push_recorded_scope(leaf);
352 true
353 } else if !is_root_wrapper && self.block_kinds.contains(kind) {
354 id_gen.push_anonymous_scope();
355 true
356 } else {
357 false
358 };
359
360 builder = self.walk_children_with_interstitials(node, builder, id_gen, &vertex_id)?;
361
362 if entered_scope {
363 id_gen.pop_scope();
364 }
365
366 Ok(builder)
367 }
368
369 /// Emit a zero-width, `ERROR`-kinded marker vertex for a tree-sitter
370 /// error-recovery MISSING anonymous token, attached to `parent_vertex_id`.
371 ///
372 /// The marker is kinded like a genuine `ERROR` node where the protocol or
373 /// theory admits that kind, else the closed-protocol `node` fallback. Its
374 /// zero-width span (`start == end`, as for any inserted token) and its
375 /// `missing` constraint (recording the elided token) let a downstream
376 /// schema walker distinguish a recovered-incomplete parse from a complete
377 /// one; a missing *named* token is already surfaced as a zero-width vertex
378 /// by the normal walk, so this only closes the gap for anonymous ones.
379 fn emit_missing_marker(
380 &self,
381 missing: tree_sitter::Node<'_>,
382 mut builder: SchemaBuilder,
383 id_gen: &mut IdGenerator,
384 parent_vertex_id: &str,
385 ) -> Result<SchemaBuilder, ParseError> {
386 let admits_error = self.protocol.obj_kinds.is_empty()
387 || self.protocol.obj_kinds.iter().any(|k| k == "ERROR")
388 || self.theory_meta.vertex_kinds.iter().any(|k| k == "ERROR");
389 let marker_kind = if admits_error { "ERROR" } else { "node" };
390 let vertex_id = id_gen.anonymous_id();
391 builder = builder.vertex(&vertex_id, marker_kind, None).map_err(|e| {
392 ParseError::SchemaConstruction {
393 reason: format!("missing-token marker '{vertex_id}': {e}"),
394 }
395 })?;
396 builder = builder
397 .edge(parent_vertex_id, &vertex_id, "child_of", None)
398 .map_err(|e| ParseError::SchemaConstruction {
399 reason: format!("missing-token edge {parent_vertex_id} -> {vertex_id}: {e}"),
400 })?;
401 builder = builder.constraint(&vertex_id, "start-byte", &missing.start_byte().to_string());
402 builder = builder.constraint(&vertex_id, "end-byte", &missing.end_byte().to_string());
403 builder = builder.constraint(&vertex_id, "missing", missing.kind());
404 Ok(builder)
405 }
406
407 /// Walk named children, capturing interstitial text between them.
408 ///
409 /// Also computes a `chose-alt-fingerprint` constraint by trimming
410 /// and joining every non-empty interstitial run. This is the
411 /// categorical discriminator for the parent vertex's CHOICE alt:
412 /// it survives the byte-position-stripping that
413 /// `emit_pretty_roundtrip`'s by-construction simulation applies,
414 /// so the CHOICE picker can dispatch deterministically against
415 /// the recorded alternative even after interstitials are removed.
416 /// A by-construction schema can populate this constraint directly
417 /// to control which alternative the emitter picks.
418 fn walk_children_with_interstitials(
419 &self,
420 node: tree_sitter::Node<'_>,
421 mut builder: SchemaBuilder,
422 id_gen: &mut IdGenerator,
423 vertex_id: &str,
424 ) -> Result<SchemaBuilder, ParseError> {
425 let cursor = &mut node.walk();
426 let children: Vec<_> = node.named_children(cursor).collect();
427 let mut interstitial_idx = 0;
428 let mut prev_end = node.start_byte();
429 let mut fingerprint_parts: Vec<String> = Vec::new();
430 let mut child_kinds: Vec<String> = Vec::new();
431
432 for child in &children {
433 let gap_start = prev_end;
434 let gap_end = child.start_byte();
435 builder = self.capture_interstitial(
436 builder,
437 vertex_id,
438 gap_start,
439 gap_end,
440 &mut interstitial_idx,
441 &mut fingerprint_parts,
442 );
443 // Record the named child's kind separately from the
444 // literal-token fingerprint. The CHOICE picker reads the
445 // kind sequence as a secondary, tiebreaker witness when
446 // literal tokens alone don't discriminate alternatives.
447 // Hidden-rule kinds (`_`-prefixed) are tree-sitter
448 // implementation detail and never authored by humans;
449 // omitting them keeps the witness language-clean and
450 // matches the convention that hidden rules are inlined
451 // by the emitter rather than dispatched against.
452 let child_kind = child.kind();
453 if !child_kind.starts_with('_') {
454 child_kinds.push(child_kind.to_owned());
455 }
456 builder = self.walk_node(*child, builder, id_gen, Some(vertex_id))?;
457 prev_end = child.end_byte();
458 }
459
460 // Surface error-recovery MISSING anonymous tokens among this node's
461 // direct children. Tree-sitter inserts a zero-width MISSING token (a
462 // `}`, `)`, `,`, or keyword) to recover from an incomplete construct;
463 // because it is anonymous it is skipped by the named-children walk
464 // above, so a recovered-incomplete parse would otherwise carry no ERROR
465 // vertex and no zero-width vertex — indistinguishable from a complete
466 // parse. Emit a marker for each so schema walkers that reject ERROR /
467 // zero-width vertices detect the recovery.
468 let missing_cursor = &mut node.walk();
469 for child in node.children(missing_cursor) {
470 if child.is_missing() && !child.is_named() {
471 builder = self.emit_missing_marker(child, builder, id_gen, vertex_id)?;
472 }
473 }
474
475 // Trailing interstitial after the last child.
476 builder = self.capture_interstitial(
477 builder,
478 vertex_id,
479 prev_end,
480 node.end_byte(),
481 &mut interstitial_idx,
482 &mut fingerprint_parts,
483 );
484
485 if !fingerprint_parts.is_empty() {
486 builder = builder.constraint(
487 vertex_id,
488 "chose-alt-fingerprint",
489 &fingerprint_parts.join(" "),
490 );
491 }
492 if !child_kinds.is_empty() {
493 builder =
494 builder.constraint(vertex_id, "chose-alt-child-kinds", &child_kinds.join(" "));
495 }
496
497 Ok(builder)
498 }
499
500 /// Capture interstitial text between `gap_start` and `gap_end` as a constraint.
501 /// Walk all children of `node` (including anonymous tokens), and for
502 /// each anonymous-token child that was reached through a tree-sitter
503 /// `field('<name>', ...)` accessor, emit a `field:<name>` constraint
504 /// on the parent vertex carrying the token's text.
505 ///
506 /// Tree-sitter rules like `field('direction', choice('/', '\\'))` or
507 /// `field('func', choice('sigmoid','exp','log','abs'))` attach a
508 /// field name to an unnamed token alternative. The named-children
509 /// walk in [`walk_children_with_interstitials`] omits these (they
510 /// are not named nodes), and downstream consumers previously had
511 /// to recover the value by reading the source buffer between
512 /// recorded byte offsets. This emits the value as a structural
513 /// constraint so [`Schema::field_text`] can return it directly.
514 fn capture_anonymous_field_constraints(
515 &self,
516 node: tree_sitter::Node<'_>,
517 vertex_id: &str,
518 mut builder: SchemaBuilder,
519 ) -> SchemaBuilder {
520 let child_count = node.child_count();
521 for i in 0..child_count {
522 let Some(child) = node.child(u32::try_from(i).unwrap_or(0)) else {
523 continue;
524 };
525 // Named children carry their own vertex (and surface as edges
526 // keyed by the field name in walk_node). We only need to
527 // handle the unnamed tokens here.
528 if child.is_named() {
529 continue;
530 }
531 let Some(field_name) = u32::try_from(i)
532 .ok()
533 .and_then(|idx| node.field_name_for_child(idx))
534 else {
535 continue;
536 };
537 let Ok(text) = child.utf8_text(self.source) else {
538 continue;
539 };
540 let sort = format!("field:{field_name}");
541 builder = builder.constraint(vertex_id, &sort, text);
542 }
543 builder
544 }
545
546 /// Record the faithful production trace `ptrace-<slot>` for `node`.
547 ///
548 /// Walks every child of `node` (named children and anonymous tokens,
549 /// in source order) and records each slot as `C<kind>` (a named
550 /// child) or `T<text>` (an anonymous token's exact text). This is the
551 /// variant-tag fibre of the layout complement
552 /// ([`panproto_lens::layout_complement::TraceSlot`]). See the call
553 /// site for why it is byte-faithful under replay.
554 fn capture_production_trace(
555 &self,
556 node: tree_sitter::Node<'_>,
557 vertex_id: &str,
558 mut builder: SchemaBuilder,
559 ) -> SchemaBuilder {
560 let count = node.child_count();
561 let mut slot = 0usize;
562 for i in 0..count {
563 let Some(child) = u32::try_from(i).ok().and_then(|i| node.child(i)) else {
564 continue;
565 };
566 let value = if child.is_named() {
567 format!("C{}", child.kind())
568 } else if let Ok(text) = child.utf8_text(self.source) {
569 format!("T{text}")
570 } else {
571 continue;
572 };
573 builder = builder.constraint(vertex_id, &format!("ptrace-{slot}"), &value);
574 slot += 1;
575 }
576 builder
577 }
578
579 fn capture_interstitial(
580 &self,
581 mut builder: SchemaBuilder,
582 vertex_id: &str,
583 gap_start: usize,
584 gap_end: usize,
585 idx: &mut usize,
586 fingerprint: &mut Vec<String>,
587 ) -> SchemaBuilder {
588 if gap_end > gap_start && gap_end <= self.source.len() {
589 if let Ok(gap_text) = std::str::from_utf8(&self.source[gap_start..gap_end]) {
590 if !gap_text.is_empty() {
591 let sort = format!("interstitial-{}", *idx);
592 builder = builder.constraint(vertex_id, &sort, gap_text);
593 builder = builder.constraint(
594 vertex_id,
595 &format!("{sort}-start-byte"),
596 &gap_start.to_string(),
597 );
598 *idx += 1;
599 let trimmed = gap_text.trim();
600 if !trimmed.is_empty() {
601 fingerprint.push(trimmed.to_owned());
602 }
603 }
604 }
605 }
606 builder
607 }
608
609 /// Emit formatting constraints for a node (indentation, position).
610 fn emit_formatting_constraints(
611 &self,
612 node: tree_sitter::Node<'_>,
613 vertex_id: &str,
614 mut builder: SchemaBuilder,
615 ) -> SchemaBuilder {
616 let start = node.start_position();
617
618 // Capture indentation (column of first character on the line).
619 if start.column > 0 {
620 // Extract the actual indentation characters from the source.
621 let line_start = node.start_byte().saturating_sub(start.column);
622 if line_start < self.source.len() {
623 let indent_end = line_start + start.column.min(self.source.len() - line_start);
624 if let Ok(indent) = std::str::from_utf8(&self.source[line_start..indent_end]) {
625 // Only capture if the extracted region is pure whitespace.
626 if !indent.is_empty() && indent.trim().is_empty() {
627 builder = builder.constraint(vertex_id, "indent", indent);
628 }
629 }
630 }
631 }
632
633 // Count blank lines before this node by looking at source between
634 // previous sibling's end and this node's start.
635 if let Some(prev) = node.prev_named_sibling() {
636 let gap_start = prev.end_byte();
637 let gap_end = node.start_byte();
638 if gap_start < gap_end && gap_end <= self.source.len() {
639 let gap = &self.source[gap_start..gap_end];
640 let blank_lines = memchr::memchr_iter(b'\n', gap).count().saturating_sub(1);
641 if blank_lines > 0 {
642 builder = builder.constraint(
643 vertex_id,
644 "blank-lines-before",
645 &blank_lines.to_string(),
646 );
647 }
648 }
649 }
650
651 builder
652 }
653}
654
655#[cfg(test)]
656#[allow(clippy::unwrap_used)]
657mod tests {
658 use super::*;
659
660 fn make_test_protocol() -> Protocol {
661 Protocol {
662 name: "test".into(),
663 schema_theory: "ThTest".into(),
664 instance_theory: "ThTestInst".into(),
665 schema_composition: None,
666 instance_composition: None,
667 obj_kinds: vec![], // Empty = open protocol, accepts all kinds.
668 edge_rules: vec![],
669 constraint_sorts: vec![],
670 has_order: true,
671 has_coproducts: false,
672 has_recursion: false,
673 has_causal: false,
674 nominal_identity: false,
675 has_defaults: false,
676 has_coercions: false,
677 has_mergers: false,
678 has_policies: false,
679 }
680 }
681
682 fn make_test_meta() -> ExtractedTheoryMeta {
683 use panproto_gat::{Sort, Theory};
684 ExtractedTheoryMeta {
685 theory: Theory::new("ThTest", vec![Sort::simple("Vertex")], vec![], vec![]),
686 supertypes: FxHashSet::default(),
687 subtype_map: Vec::new(),
688 optional_fields: FxHashSet::default(),
689 ordered_fields: FxHashSet::default(),
690 vertex_kinds: Vec::new(),
691 edge_kinds: Vec::new(),
692 }
693 }
694
695 /// Helper to get a grammar by name from panproto-grammars.
696 #[cfg(feature = "grammars")]
697 fn get_grammar(name: &str) -> panproto_grammars::Grammar {
698 panproto_grammars::grammars()
699 .into_iter()
700 .find(|g| g.name == name)
701 .unwrap_or_else(|| panic!("grammar '{name}' not enabled in features"))
702 }
703
704 #[test]
705 #[cfg(feature = "grammars")]
706 fn walk_simple_typescript() {
707 let source = b"function greet(name: string): string { return name; }";
708 let grammar = get_grammar("typescript");
709
710 let mut parser = tree_sitter::Parser::new();
711 parser.set_language(&grammar.language).unwrap();
712 let tree = parser.parse(source, None).unwrap();
713
714 let protocol = make_test_protocol();
715 let meta = make_test_meta();
716 let mut detector =
717 crate::scope_detector::ScopeDetector::new(&grammar.language, grammar.tags_query, None)
718 .unwrap();
719 let walker = AstWalker::new(
720 source,
721 &meta,
722 &protocol,
723 WalkerConfig::standard(),
724 Some(&mut detector),
725 );
726
727 let schema = walker.walk(&tree, "test.ts").unwrap();
728
729 // Should have produced some vertices.
730 assert!(
731 schema.vertices.len() > 1,
732 "expected multiple vertices, got {}",
733 schema.vertices.len()
734 );
735
736 // The root should be the file.
737 let root_name: panproto_gat::Name = "test.ts".into();
738 assert!(
739 schema.vertices.contains_key(&root_name),
740 "missing root vertex"
741 );
742
743 // When tags.scm is present, the function name should appear in a vertex ID.
744 if detector.has_query() {
745 let has_greet = schema
746 .vertices
747 .keys()
748 .any(|n| n.to_string().ends_with("::greet"));
749 assert!(
750 has_greet,
751 "expected a vertex ID ending in ::greet, got: {:?}",
752 schema
753 .vertices
754 .keys()
755 .map(ToString::to_string)
756 .collect::<Vec<_>>()
757 );
758 }
759 }
760
761 #[test]
762 #[cfg(feature = "grammars")]
763 fn walk_simple_python() {
764 let source = b"def add(a, b):\n return a + b\n";
765 let grammar = get_grammar("python");
766
767 let mut parser = tree_sitter::Parser::new();
768 parser.set_language(&grammar.language).unwrap();
769 let tree = parser.parse(source, None).unwrap();
770
771 let protocol = make_test_protocol();
772 let meta = make_test_meta();
773 let mut detector =
774 crate::scope_detector::ScopeDetector::new(&grammar.language, grammar.tags_query, None)
775 .unwrap();
776 let walker = AstWalker::new(
777 source,
778 &meta,
779 &protocol,
780 WalkerConfig::standard(),
781 Some(&mut detector),
782 );
783
784 let schema = walker.walk(&tree, "test.py").unwrap();
785
786 assert!(
787 schema.vertices.len() > 1,
788 "expected multiple vertices, got {}",
789 schema.vertices.len()
790 );
791
792 if detector.has_query() {
793 let has_add = schema
794 .vertices
795 .keys()
796 .any(|n| n.to_string().ends_with("::add"));
797 assert!(has_add, "expected ::add vertex");
798 }
799 }
800
801 #[test]
802 #[cfg(feature = "grammars")]
803 fn walk_simple_rust() {
804 let source = b"fn verify_push() {}\nstruct Foo;\nimpl Foo { fn bar(&self) {} }\n";
805 let grammar = get_grammar("rust");
806
807 let mut parser = tree_sitter::Parser::new();
808 parser.set_language(&grammar.language).unwrap();
809 let tree = parser.parse(source, None).unwrap();
810
811 let protocol = make_test_protocol();
812 let meta = make_test_meta();
813 let mut detector =
814 crate::scope_detector::ScopeDetector::new(&grammar.language, grammar.tags_query, None)
815 .unwrap();
816 let walker = AstWalker::new(
817 source,
818 &meta,
819 &protocol,
820 WalkerConfig::standard(),
821 Some(&mut detector),
822 );
823
824 let schema = walker.walk(&tree, "test.rs").unwrap();
825
826 assert!(
827 schema.vertices.len() > 1,
828 "expected multiple vertices, got {}",
829 schema.vertices.len()
830 );
831
832 if detector.has_query() {
833 let vertex_ids: Vec<String> = schema.vertices.keys().map(ToString::to_string).collect();
834
835 // Rust's function_item — the regression from issue #34 — must be
836 // detected as a named scope now.
837 assert!(
838 vertex_ids.iter().any(|id| id.ends_with("::verify_push")),
839 "expected ::verify_push named scope, got: {vertex_ids:?}"
840 );
841 assert!(
842 vertex_ids.iter().any(|id| id.ends_with("::Foo")),
843 "expected ::Foo named scope, got: {vertex_ids:?}"
844 );
845 }
846 }
847
848 /// Helper: parse source with a grammar, walk to Schema, emit back, compare.
849 #[cfg(feature = "group-data")]
850 fn assert_roundtrip(grammar_name: &str, source: &[u8], file_path: &str) {
851 use crate::registry::AstParser;
852 let grammar = panproto_grammars::grammars()
853 .into_iter()
854 .find(|g| g.name == grammar_name)
855 .unwrap_or_else(|| panic!("grammar '{grammar_name}' not enabled"));
856
857 let config = crate::languages::walker_configs::walker_config_for(grammar_name);
858 let lang_parser = crate::languages::common::LanguageParser::from_language(
859 grammar_name,
860 grammar.extensions.to_vec(),
861 grammar.language,
862 grammar.node_types,
863 grammar.tags_query,
864 config,
865 )
866 .unwrap();
867
868 let schema = lang_parser.parse(source, file_path).unwrap();
869 let emitted = lang_parser.emit(&schema).unwrap();
870
871 assert_eq!(
872 std::str::from_utf8(source).unwrap(),
873 std::str::from_utf8(&emitted).unwrap(),
874 "round-trip failed for {grammar_name}: emitted bytes differ from source"
875 );
876 }
877
878 #[test]
879 #[cfg(feature = "group-data")]
880 fn roundtrip_json_simple() {
881 assert_roundtrip("json", br#"{"name": "test", "value": 42}"#, "test.json");
882 }
883
884 #[test]
885 #[cfg(feature = "group-data")]
886 fn roundtrip_json_formatted() {
887 let source =
888 b"{\n \"name\": \"test\",\n \"value\": 42,\n \"nested\": {\n \"a\": true\n }\n}";
889 assert_roundtrip("json", source, "test.json");
890 }
891
892 #[test]
893 #[cfg(feature = "group-data")]
894 fn roundtrip_json_array() {
895 let source = b"[\n 1,\n 2,\n 3\n]";
896 assert_roundtrip("json", source, "test.json");
897 }
898
899 #[test]
900 #[cfg(feature = "group-data")]
901 fn roundtrip_xml_simple() {
902 let source = b"<root>\n <child attr=\"val\">text</child>\n</root>";
903 assert_roundtrip("xml", source, "test.xml");
904 }
905
906 #[test]
907 #[cfg(feature = "group-data")]
908 fn roundtrip_yaml_simple() {
909 let source = b"name: test\nvalue: 42\nnested:\n a: true\n";
910 assert_roundtrip("yaml", source, "test.yaml");
911 }
912
913 #[test]
914 #[cfg(feature = "group-data")]
915 fn roundtrip_toml_simple() {
916 let source = b"[package]\nname = \"test\"\nversion = \"0.1.0\"\n";
917 assert_roundtrip("toml", source, "test.toml");
918 }
919
920 #[cfg(feature = "group-data")]
921 fn parse_with(grammar_name: &str, source: &[u8], file_path: &str) -> panproto_schema::Schema {
922 use crate::registry::AstParser;
923 let grammar = panproto_grammars::grammars()
924 .into_iter()
925 .find(|g| g.name == grammar_name)
926 .unwrap_or_else(|| panic!("grammar '{grammar_name}' not enabled"));
927 let config = crate::languages::walker_configs::walker_config_for(grammar_name);
928 let lang_parser = crate::languages::common::LanguageParser::from_language(
929 grammar_name,
930 grammar.extensions.to_vec(),
931 grammar.language,
932 grammar.node_types,
933 grammar.tags_query,
934 config,
935 )
936 .unwrap();
937 lang_parser.parse(source, file_path).unwrap()
938 }
939
940 #[test]
941 #[cfg(feature = "group-data")]
942 fn fingerprint_and_child_kinds_emitted_separately() {
943 // The walker must emit `chose-alt-fingerprint` and
944 // `chose-alt-child-kinds` as TWO distinct constraints. The
945 // CHOICE picker reads them independently: literal-token
946 // matches drive primary scoring, child-kind matches act as a
947 // tiebreaker. Mixing them would let punctuation in kind names
948 // contaminate the literal score.
949 let schema = parse_with("json", br#"{"a": 1}"#, "test.json");
950
951 let saw_fingerprint = schema.constraints.values().any(|cs| {
952 cs.iter()
953 .any(|c| c.sort.as_ref() == "chose-alt-fingerprint")
954 });
955 let saw_child_kinds = schema.constraints.values().any(|cs| {
956 cs.iter()
957 .any(|c| c.sort.as_ref() == "chose-alt-child-kinds")
958 });
959
960 assert!(
961 saw_fingerprint,
962 "walker must emit chose-alt-fingerprint (literal-token witness)"
963 );
964 assert!(
965 saw_child_kinds,
966 "walker must emit chose-alt-child-kinds (named-kind witness)"
967 );
968 }
969
970 #[test]
971 #[cfg(feature = "group-data")]
972 fn child_kinds_excludes_hidden_rules() {
973 // Hidden rules (`_`-prefixed) are tree-sitter implementation
974 // detail and must not appear in the kind witness.
975 let schema = parse_with("json", br#"{"k": "v"}"#, "test.json");
976
977 for cs in schema.constraints.values() {
978 for c in cs {
979 if c.sort.as_ref() == "chose-alt-child-kinds" {
980 for kind in c.value.split_whitespace() {
981 assert!(
982 !kind.starts_with('_'),
983 "hidden-rule kind '{kind}' must not appear in chose-alt-child-kinds"
984 );
985 }
986 }
987 }
988 }
989 }
990}