plotnik_lib/query/graph_qis.rs
1//! Capture scope detection: QIS and single-capture definitions.
2//!
3//! - QIS triggers when a quantified expression has ≥2 propagating captures.
4//! - Single-capture definitions unwrap to their capture's type directly.
5//!
6//! See ADR-0009 for full specification.
7
8use crate::parser::{ast, token_src};
9
10use super::{QisTrigger, Query};
11
12impl<'a> Query<'a> {
13 /// Detect capture scopes: QIS triggers and single-capture definitions.
14 ///
15 /// - QIS triggers when quantified expression has ≥2 propagating captures
16 /// - Single-capture definitions unwrap (no Field effect, type is capture's type)
17 pub(super) fn detect_capture_scopes(&mut self) {
18 let entries: Vec<_> = self
19 .symbol_table
20 .iter()
21 .map(|(n, b)| (*n, b.clone()))
22 .collect();
23 for (name, body) in &entries {
24 // Detect single-capture and multi-capture definitions
25 let captures = self.collect_propagating_captures(body);
26 if captures.len() == 1 {
27 self.single_capture_defs.insert(*name, captures[0]);
28 } else if captures.len() >= 2 {
29 self.multi_capture_defs.insert(*name);
30 }
31 // Detect QIS within this definition
32 self.detect_qis_in_expr(body);
33 }
34 }
35
36 fn detect_qis_in_expr(&mut self, expr: &ast::Expr) {
37 match expr {
38 ast::Expr::QuantifiedExpr(q) => {
39 if let Some(inner) = q.inner() {
40 let captures = self.collect_propagating_captures(&inner);
41 if captures.len() >= 2 {
42 self.qis_triggers.insert(q.clone(), QisTrigger { captures });
43 }
44 self.detect_qis_in_expr(&inner);
45 }
46 }
47 ast::Expr::CapturedExpr(c) => {
48 // Captures on sequences/alternations absorb inner captures,
49 // but we still recurse to find nested quantifiers.
50 // Special case: captured quantifier with ≥1 nested capture needs QIS
51 // to wrap each iteration with StartObject/EndObject for proper field scoping.
52 if let Some(inner) = c.inner() {
53 // Check if this capture wraps a quantifier with nested captures
54 if let ast::Expr::QuantifiedExpr(q) = &inner
55 && let Some(quant_inner) = q.inner()
56 {
57 let captures = self.collect_propagating_captures(&quant_inner);
58 // Trigger QIS if there's at least 1 capture (not already covered by ≥2 rule)
59 if !captures.is_empty() && !self.qis_triggers.contains_key(q) {
60 self.qis_triggers.insert(q.clone(), QisTrigger { captures });
61 }
62 }
63 self.detect_qis_in_expr(&inner);
64 }
65 }
66 _ => {
67 for child in expr.children() {
68 self.detect_qis_in_expr(&child);
69 }
70 }
71 }
72 }
73
74 /// Collect captures that propagate out of an expression (not absorbed by inner scopes).
75 pub(super) fn collect_propagating_captures(&self, expr: &ast::Expr) -> Vec<&'a str> {
76 let mut captures = Vec::new();
77 self.collect_propagating_captures_impl(expr, &mut captures);
78 captures
79 }
80
81 fn collect_propagating_captures_impl(&self, expr: &ast::Expr, out: &mut Vec<&'a str>) {
82 match expr {
83 ast::Expr::CapturedExpr(c) => {
84 if let Some(name_token) = c.name() {
85 let name = token_src(&name_token, self.source);
86 out.push(name);
87 }
88 // Captured sequence/alternation absorbs inner captures.
89 // Captured quantifiers with nested captures also absorb (they become QIS).
90 if let Some(inner) = c.inner()
91 && !self.is_scope_container(&inner)
92 {
93 self.collect_propagating_captures_impl(&inner, out);
94 }
95 }
96 ast::Expr::QuantifiedExpr(q) => {
97 // Nested quantifier: its captures propagate (with modified cardinality)
98 if let Some(inner) = q.inner() {
99 self.collect_propagating_captures_impl(&inner, out);
100 }
101 }
102 _ => {
103 for child in expr.children() {
104 self.collect_propagating_captures_impl(&child, out);
105 }
106 }
107 }
108 }
109
110 /// Check if an expression is a scope container that absorbs inner captures.
111 /// - Sequences and alternations always absorb
112 /// - Quantifiers absorb if they have nested captures (will become QIS)
113 fn is_scope_container(&self, expr: &ast::Expr) -> bool {
114 match expr {
115 ast::Expr::SeqExpr(_) | ast::Expr::AltExpr(_) => true,
116 ast::Expr::QuantifiedExpr(q) => {
117 if let Some(inner) = q.inner() {
118 // Quantifier with nested captures acts as scope container
119 // (will be treated as QIS, wrapping each element in an object)
120 let nested_captures = self.collect_propagating_captures(&inner);
121 if !nested_captures.is_empty() {
122 return true;
123 }
124 // Otherwise check if inner is a scope container
125 self.is_scope_container(&inner)
126 } else {
127 false
128 }
129 }
130 _ => false,
131 }
132 }
133
134 /// Check if a quantified expression triggers QIS.
135 pub fn is_qis_trigger(&self, q: &ast::QuantifiedExpr) -> bool {
136 self.qis_triggers.contains_key(q)
137 }
138
139 /// Get QIS trigger info for a quantified expression.
140 pub fn qis_trigger(&self, q: &ast::QuantifiedExpr) -> Option<&QisTrigger<'a>> {
141 self.qis_triggers.get(q)
142 }
143
144 /// Check if this capture is the single propagating capture for its definition.
145 /// Only that specific capture should unwrap (skip Field effect).
146 pub fn is_single_capture(&self, def_name: &str, capture_name: &str) -> bool {
147 self.single_capture_defs
148 .get(def_name)
149 .map(|c| *c == capture_name)
150 .unwrap_or(false)
151 }
152
153 /// Check if definition has 2+ propagating captures (needs struct wrapping).
154 pub fn is_multi_capture_def(&self, name: &str) -> bool {
155 self.multi_capture_defs.contains(name)
156 }
157}