Skip to main content

nmbrs_workload/
bindpoints.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Bind point detection and field classification.
5//!
6//! Scans op template fields to detect `{name}` bind point references
7//! and `{{expr}}` inline binding definitions. Classifies fields as
8//! static (no bind points) or dynamic (has bind points).
9
10/// Namespace qualifier for a bind point reference.
11#[derive(Debug, Clone, PartialEq)]
12pub enum BindQualifier {
13    /// No qualifier — resolved by priority: bind → capture → input.
14    None,
15    /// `{input:name}` — graph input value.
16    Input,
17    /// `{bind:name}` — Polydat binding output.
18    Bind,
19    /// `{capture:name}` — capture context (volatile or sticky port).
20    /// Also accepts `{port:name}` as an alias.
21    Capture,
22}
23
24/// Workload-author lvalue assertion attached to a [`BindPoint::Reference`].
25///
26/// Spelled as a `:<spec>` suffix inside the brace pair:
27///
28/// - `{meta}` — no marker; strict match. The adapter consults
29///   cluster-side metadata and the binder verifies the wire's
30///   rvalue type matches that.
31/// - `{meta:*}` — wildcard. Polydat is licensed to fuse / coerce;
32///   the binder treats the slot as `Str`-lvalue (which permits any
33///   rvalue per the load-bearing rule).
34/// - `{meta:<type>}` — explicit polydat `PortType` assertion. The
35///   binder uses the asserted type as the lvalue for verification,
36///   overriding what cluster-side metadata would say. The type
37///   name is the lower-snake-case form (`u64`, `i32`, `f64`,
38///   `vec_f32`, `str`, `bytes`, etc.) — matches `PortType`'s
39///   `Display` form.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum LvalueSpec {
42    /// `:*` — workload-author opt-in to type fusion.
43    Wildcard,
44    /// `:<typename>` — workload-author asserts a polydat
45    /// `PortType` by name. The string is preserved verbatim;
46    /// adapters parse it to `PortType` at construction time and
47    /// error on unknown names.
48    Explicit(String),
49}
50
51/// A detected bind point in an op template field.
52#[derive(Debug, Clone, PartialEq)]
53pub enum BindPoint {
54    /// `{name}`, `{qualifier:name}`, `{name:*}`, or `{name:<type>}` —
55    /// references a named value with an optional lvalue assertion.
56    ///
57    /// `lvalue_spec` is the workload-author opt-in for the
58    /// construction-time binder check. `None` (the default) means
59    /// strict matching against cluster-side metadata. See
60    /// [`LvalueSpec`] for the two relaxation forms.
61    Reference {
62        name: String,
63        qualifier: BindQualifier,
64        lvalue_spec: Option<LvalueSpec>,
65    },
66    /// `{{expr}}` — inline binding definition.
67    InlineDefinition(String),
68}
69
70/// The classification of an op template field value.
71#[derive(Debug, Clone, PartialEq)]
72pub enum FieldType {
73    /// No bind points — value is constant across cycles.
74    Static,
75    /// Pure binding reference — the entire value is `{name}`.
76    BindRef(String),
77    /// String template with interleaved literals and bind points.
78    Template(Vec<BindPoint>),
79}
80
81/// Scan a string value for bind points and classify it.
82pub fn classify_field(value: &str) -> FieldType {
83    let bind_points = extract_bind_points(value);
84    if bind_points.is_empty() {
85        FieldType::Static
86    } else if bind_points.len() == 1
87        && value.starts_with('{')
88        && !value.starts_with("{{")
89        && value.ends_with('}')
90    {
91        match &bind_points[0] {
92            BindPoint::Reference { name, .. } => FieldType::BindRef(name.clone()),
93            _ => FieldType::Template(bind_points),
94        }
95    } else {
96        FieldType::Template(bind_points)
97    }
98}
99
100/// Extract all bind points from a string.
101pub fn extract_bind_points(value: &str) -> Vec<BindPoint> {
102    let mut points = Vec::new();
103    let chars: Vec<char> = value.chars().collect();
104    let mut i = 0;
105
106    while i < chars.len() {
107        if chars[i] == '{' {
108            if i + 1 < chars.len() && chars[i + 1] == '{' {
109                // Inline definition: {{expr}}
110                i += 2;
111                let start = i;
112                while i + 1 < chars.len() && !(chars[i] == '}' && chars[i + 1] == '}') {
113                    i += 1;
114                }
115                if i + 1 < chars.len() {
116                    let expr: String = chars[start..i].iter().collect();
117                    points.push(BindPoint::InlineDefinition(expr.trim().to_string()));
118                    i += 2; // skip }}
119                }
120            } else {
121                // Single brace: {name}, {:=expr}, {:=expr:=}, or {expr}
122                // First, peek ahead to check if this is a CQL map literal
123                // (starts with ' or ", possibly after whitespace — covers
124                // multi-line CQL maps where the opening `{` sits on its
125                // own line). If so, skip just the opening brace and
126                // continue scanning — inner {name} refs are still valid.
127                let next_nonspace = chars[i + 1..].iter().find(|c| !c.is_whitespace()).copied();
128                if matches!(next_nonspace, Some(c) if is_literal_start(c)) {
129                    // CQL map literal: {'key': '{value}'} — skip the opening {
130                    // but continue scanning so inner bind points are found.
131                    i += 1;
132                    continue;
133                }
134
135                i += 1;
136                let start = i;
137                let mut depth = 1u32;
138                while i < chars.len() {
139                    if chars[i] == '{' {
140                        depth += 1;
141                    }
142                    if chars[i] == '}' {
143                        depth -= 1;
144                        if depth == 0 {
145                            break;
146                        }
147                    }
148                    i += 1;
149                }
150                if i < chars.len() {
151                    let raw: String = chars[start..i].iter().collect();
152                    let raw = raw.trim();
153
154                    if is_literal_content(raw) {
155                        // Fallback: content has quotes — literal text, not a bind point.
156                        i += 1;
157                    } else if let Some(expr) = raw.strip_prefix(":=") {
158                        // Explicit {:=expr} or {:=expr:=} syntax
159                        let expr = expr.strip_suffix(":=").unwrap_or(expr).trim();
160                        points.push(BindPoint::InlineDefinition(expr.to_string()));
161                        i += 1;
162                    } else if let Some((name_part, spec)) = extract_lvalue_spec(raw) {
163                        // Lvalue-asserted reference: `{name:*}` (wildcard)
164                        // or `{name:<typename>}` (explicit polydat type).
165                        // Detected BEFORE `is_expression` so the `:*` /
166                        // `:type` suffix doesn't get misclassified as
167                        // an operator. The body still routes through
168                        // `parse_qualified_ref` for completeness, though
169                        // qualifier+spec composition is not currently
170                        // supported (the lvalue-spec detector only
171                        // matches when `name_part` is a bare identifier).
172                        let (qualifier, name) = parse_qualified_ref(name_part);
173                        points.push(BindPoint::Reference {
174                            name,
175                            qualifier,
176                            lvalue_spec: Some(spec),
177                        });
178                        i += 1;
179                    } else if is_expression(raw) {
180                        // Content has operators/parens — treat as inline expression
181                        points.push(BindPoint::InlineDefinition(raw.to_string()));
182                        i += 1;
183                    } else {
184                        // Simple identifier — reference bind point
185                        let (qualifier, name) = parse_qualified_ref(raw);
186                        points.push(BindPoint::Reference {
187                            name,
188                            qualifier,
189                            lvalue_spec: None,
190                        });
191                        i += 1;
192                    }
193                }
194            }
195        } else {
196            i += 1;
197        }
198    }
199
200    points
201}
202
203/// Detect whether bind point content is a Polydat expression (not a simple name).
204///
205/// Returns true if the content contains operators, function calls,
206/// or other syntax that can't be a plain identifier.
207pub fn is_expression_public(s: &str) -> bool {
208    is_expression(s)
209}
210
211/// Public form of [`extract_lvalue_spec`] for adapters that need
212/// to interleave bind-point processing with text generation
213/// (e.g., the cassandra-cpp `resolve_structural_and_mark_remaining`
214/// walker, which scans the statement text char-by-char and must
215/// strip a lvalue-spec suffix from a bind-point body before
216/// deciding whether to inline-resolve or `?`-mark the position).
217///
218/// Returns `(bare_name, Some(spec))` when the body carries a
219/// lvalue-spec suffix; `(body, None)` otherwise. The first half
220/// of the tuple is always the body with any `:*` or `:<type>`
221/// suffix stripped.
222pub fn split_lvalue_spec(body: &str) -> (&str, Option<LvalueSpec>) {
223    match extract_lvalue_spec(body) {
224        Some((name_part, spec)) => (name_part, Some(spec)),
225        None => (body, None),
226    }
227}
228
229/// Detect the `:<spec>` lvalue-assertion suffix on a bind-point
230/// body. Returns `Some((name_part, spec))` when the body is the
231/// shape `<bare-identifier>:<*-or-bare-identifier>`; otherwise
232/// `None`.
233///
234/// Matched explicitly (not as part of `is_expression` or the
235/// general qualifier-parse path) because:
236///
237/// - `:*` would otherwise trip the `*` arm of `is_expression` and
238///   get treated as multiplication.
239/// - `:<typename>` would otherwise get treated as
240///   `<qualifier>:<name>` and the `name` could collide with a
241///   real workload-named wire if both parts looked like
242///   identifiers (e.g. `{input:cycle}` — qualifier `input`, name
243///   `cycle` — must continue to parse as qualifier+name, not as
244///   `name=input, lvalue=cycle`).
245///
246/// Both parts of the split are required to be bare identifiers
247/// for the suffix detection to fire. That's how `{input:cycle}`
248/// (qualifier path) is distinguished from `{meta:i32}` (lvalue-
249/// spec path): the name part `meta` is a bare identifier, the
250/// spec part `i32` is a bare identifier — but the LEFT side of
251/// `input:cycle` matches a known qualifier word, so the existing
252/// qualifier parser claims it first (in the outer dispatch this
253/// helper runs from). Within this helper we don't even check
254/// qualifier-ness — we just require both halves to be bare
255/// identifier shape.
256fn extract_lvalue_spec(raw: &str) -> Option<(&str, LvalueSpec)> {
257    let (name_part, spec_part) = raw.rsplit_once(':')?;
258    let name_part = name_part.trim();
259    let spec_part = spec_part.trim();
260    if !is_bare_identifier(name_part) {
261        return None;
262    }
263    // If the left side is a known qualifier (`input`, `bind`,
264    // `capture`, `coord`, `coordinate`), let the existing
265    // qualifier path claim this binding instead — don't shadow it
266    // here. Composition (`{capture:meta:type}`) is not currently
267    // supported; punt on it cleanly by declining.
268    let lower = name_part.to_lowercase();
269    if matches!(
270        lower.as_str(),
271        "input" | "coord" | "coordinate" | "bind" | "capture"
272    ) {
273        return None;
274    }
275    if spec_part == "*" {
276        Some((name_part, LvalueSpec::Wildcard))
277    } else if is_polydat_type_name(spec_part) {
278        Some((name_part, LvalueSpec::Explicit(spec_part.to_string())))
279    } else {
280        // Suffix isn't `*` or a known polydat type name; this is
281        // not a lvalue-spec binding. Fall through so the outer
282        // dispatch lets the unqualified-name path claim it (as
283        // `{port:auth_token}` does — `auth_token` isn't a polydat
284        // type so the whole body becomes the bare name).
285        None
286    }
287}
288
289/// Names that may appear as the `<type>` part of a
290/// `{name:<type>}` lvalue-spec binding. Matches the lower-snake-
291/// case `Display` form of `polydat::ast::PortType` for the
292/// variants that are user-facing as op-template lvalue
293/// assertions. `handle` and `none` are intentionally excluded —
294/// they're internal-only types that a workload author should
295/// never assert.
296///
297/// Hard-coded here (rather than reaching into polydat) so this
298/// crate stays free of a polydat dependency. Drift between this
299/// list and `PortType` becomes a test-time mismatch in
300/// `polydat::ast::PortType::from_str`, which the workload-side
301/// adapter `map_op` calls when it encounters an `Explicit` spec.
302fn is_polydat_type_name(s: &str) -> bool {
303    matches!(
304        s,
305        "u64"
306            | "f64"
307            | "u32"
308            | "i32"
309            | "i64"
310            | "f32"
311            | "bool"
312            | "str"
313            | "bytes"
314            | "json"
315            | "vec_f32"
316            | "vec_i32"
317    )
318}
319
320/// Bare-identifier shape: ASCII alpha or underscore start; rest
321/// alphanumeric / underscore. Used by [`extract_lvalue_spec`] to
322/// gate the suffix detection on shapes that look like names /
323/// type-words and nothing else, and by the `set:` classifier
324/// (SRD-18f §6) to decide a bare value is a wire reference.
325pub(crate) fn is_bare_identifier(s: &str) -> bool {
326    let mut chars = s.chars();
327    match chars.next() {
328        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
329        _ => return false,
330    }
331    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
332}
333
334fn is_expression(s: &str) -> bool {
335    // Simple identifiers: [a-zA-Z_][a-zA-Z0-9_-]*
336    // Hyphens are valid in identifiers (e.g. my-variable), so only treat
337    // `-` as an expression indicator when it is a unary minus (first char
338    // followed by a digit) which marks a negative numeric literal.
339    s.contains('(') || s.contains(')') ||
340    s.contains('+') || s.contains('*') || s.contains('/') ||
341    s.contains('%') || s.contains('^') || s.contains('&') ||
342    s.contains('|') || s.contains('!') || s.contains('<') ||
343    s.contains('>') ||
344    // Numeric literal (starts with digit)
345    s.starts_with(|c: char| c.is_ascii_digit()) ||
346    // Negative literal: starts with - followed by digit
347    (s.starts_with('-') && s.len() > 1 && s.as_bytes()[1].is_ascii_digit())
348}
349
350/// Content between `{` and `}` that is clearly literal text — not a
351/// binding name or Polydat expression. If the content starts with a quote
352/// character, it's a literal value (e.g. CQL map `{'class': ...}`),
353/// never a bind point or expression.
354fn is_literal_content(s: &str) -> bool {
355    s.starts_with('\'') || s.starts_with('"')
356}
357
358/// Check if a character indicates the start of a CQL map/JSON literal
359/// after an opening brace. `{'key': ...}` and `{"key": ...}` are
360/// literal map content, not bind points.
361fn is_literal_start(c: char) -> bool {
362    c == '\'' || c == '"'
363}
364
365/// Parse a qualified reference like "coord:cycle" or just "cycle".
366fn parse_qualified_ref(raw: &str) -> (BindQualifier, String) {
367    if let Some((prefix, name)) = raw.split_once(':') {
368        let qualifier = match prefix.trim().to_lowercase().as_str() {
369            "input" | "coord" | "coordinate" => BindQualifier::Input,
370            "bind" => BindQualifier::Bind,
371            "capture" => BindQualifier::Capture,
372            _ => return (BindQualifier::None, raw.to_string()), // not a known qualifier
373        };
374        (qualifier, name.trim().to_string())
375    } else {
376        (BindQualifier::None, raw.to_string())
377    }
378}
379
380/// Extract all referenced binding names from a string (only `{name}`, not `{{expr}}`).
381/// Returns the bare name without qualifier.
382pub fn referenced_bindings(value: &str) -> Vec<String> {
383    extract_bind_points(value)
384        .into_iter()
385        .filter_map(|bp| match bp {
386            BindPoint::Reference { name, .. } => Some(name),
387            _ => None,
388        })
389        .collect()
390}
391
392/// Replace `{name}` bind point references with `?` markers for
393/// CQL prepared statements. Returns the parameterized statement.
394///
395/// Also strips quotes that immediately surround a bind point:
396/// `'{id}'` → `?` (not `'?'`), because CQL prepared bind markers
397/// must not be inside string literals.
398pub fn replace_bind_points_with_markers(value: &str) -> String {
399    let names = referenced_bindings(value);
400    let mut result = value.to_string();
401    for name in &names {
402        // Try quoted form first: '{name}' → ?
403        let quoted = format!("'{{{name}}}'");
404        if let Some(pos) = result.find(&quoted) {
405            result.replace_range(pos..pos + quoted.len(), "?");
406            continue;
407        }
408        // Bare form: {name} → ?
409        let bare = format!("{{{name}}}");
410        if let Some(pos) = result.find(&bare) {
411            result.replace_range(pos..pos + bare.len(), "?");
412        }
413    }
414    result
415}
416
417// =================================================================
418// Capture points: [name] and [name as alias] in op template strings
419// =================================================================
420
421/// A capture point extracted from an op template.
422///
423/// Capture points mark result fields that should be extracted from
424/// the operation result and stored as named variables for use in
425/// subsequent operations or verification.
426///
427/// Formats:
428/// - `[username]` — capture "username" as "username" (single value)
429/// - `[username as u1]` — capture "username", store as "u1" (single value)
430/// - `[(List) field]` — capture with type assertion
431/// - `[*]` — capture all available fields
432/// - `[@keys]` — **slurp**: collect every row's `keys` column into
433///   a `Value::Json` array. Use when the result has multiple rows
434///   and the consumer needs all per-row column values as a list
435///   (e.g. recall-evaluator's `actual:` reads).
436/// - `[@col as values]` — slurp with alias.
437/// Row-set aggregation for a declarative capture: reduce an
438/// array-of-rows body (or addressed sub-array) to one scalar by
439/// folding the named field across rows. Declared with a
440/// `:min(field)` / `:max(field)` / `:sum(field)` suffix on the
441/// capture path. Motivating case: `system_views.sstable_tasks`
442/// carries several concurrent tasks in MIXED units (a
443/// byte-denominated data compaction plus an ordinal-denominated
444/// index build) — a `/0/...` capture pins whichever row sorts
445/// first, so a saturated byte task masks the still-running merge;
446/// `:min(completion_ratio)` reads the least-complete task, which
447/// is the drain's true state.
448#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
449pub enum CaptureAgg {
450    /// Numeric minimum of `field` across rows.
451    Min(String),
452    /// Numeric maximum of `field` across rows.
453    Max(String),
454    /// Numeric sum of `field` across rows.
455    Sum(String),
456}
457
458#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
459pub struct CapturePoint {
460    /// The field name to capture from the result.
461    pub source_name: String,
462    /// The variable name to store the captured value under.
463    /// Same as source_name if no `as` clause.
464    pub as_name: String,
465    /// Optional type assertion (e.g., "List", "int[]").
466    #[serde(default, skip_serializing_if = "Option::is_none")]
467    pub cast_type: Option<String>,
468    /// `true` when the capture-point was declared with the `@`
469    /// slurp prefix (`[@name]`). Slurp captures collect every
470    /// row's column value across the result body into a single
471    /// `Value::Json` array; non-slurp captures take the first
472    /// row's value as a scalar.
473    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
474    pub slurp: bool,
475    /// Optional JSON-Pointer path (RFC 6901, e.g. `/0/value`,
476    /// `/value/inner/0`). When `Some`, the extractor uses
477    /// `serde_json::Value::pointer(path)` against the body's
478    /// `to_json()` projection rather than the first-row /
479    /// top-level field lookup keyed by `source_name`. Lets a
480    /// declarative `capture:` map address Jolokia bulk-POST
481    /// responses (`[{value:N}, {value:[...]}, ...]`) by
482    /// position + nested field.
483    ///
484    /// The bracket-syntax in op text (e.g. `[name]`) leaves
485    /// this `None` and continues to use the original first-row
486    /// extraction path; only the declarative `capture:` block
487    /// populates it.
488    #[serde(default, skip_serializing_if = "Option::is_none")]
489    pub path: Option<String>,
490    /// Reduce the extracted sub-tree to a u64 count instead of
491    /// capturing it as-is. Array → length; object → key count;
492    /// scalar (number / bool / non-empty string) → 1; null /
493    /// missing → 0. Useful for "is the compactions list empty"
494    /// style predicates without binding the array into the
495    /// kernel as a `Value::Json`. Only honoured when `path` is
496    /// also set (declarative capture form).
497    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
498    pub count: bool,
499    /// Row-set aggregation (`:min(f)` / `:max(f)` / `:sum(f)`
500    /// path suffix): fold the named field across the rows of the
501    /// addressed array instead of capturing a single value. Like
502    /// `count`, only honoured on the declarative capture form.
503    #[serde(default, skip_serializing_if = "Option::is_none")]
504    pub agg: Option<CaptureAgg>,
505    /// Row predicate for an aggregate: `(field, value)`, from the
506    /// `where <field>='<value>'` clause of a capture suffix — e.g.
507    /// `":sum(progress where kind='secondary index build')"`. Rows whose
508    /// `field` does not equal `value` are excluded before folding.
509    ///
510    /// Exists because a result set can hold rows that are not commensurable.
511    /// `system_views.sstable_tasks` lists a data compaction reporting
512    /// `unit=bytes` beside a vector index build reporting
513    /// `unit=token range parts`; summing across them adds unlike quantities,
514    /// and the total silently depends on how many tasks were registered at
515    /// sample time. The predicate cannot live in the query — Cassandra allows
516    /// `GROUP BY` only on PRIMARY KEY columns, and narrowing the poll's own
517    /// `WHERE` would change which rows the drain waits for.
518    #[serde(default, skip_serializing_if = "Option::is_none")]
519    pub row_filter: Option<(String, String)>,
520}
521
522/// Result of parsing capture points from a string.
523#[derive(Debug, Clone)]
524pub struct CaptureParseResult {
525    /// The raw template with capture brackets removed.
526    /// `select [username] from t` → `select username from t`
527    pub raw_template: String,
528    /// The capture points found.
529    pub captures: Vec<CapturePoint>,
530}
531
532/// Parse capture points from an op template string.
533///
534/// Detects `[name]`, `[name as alias]`, `[(Type) name]`, and `[*]`
535/// patterns. Returns the cleaned template (brackets removed) and
536/// the list of capture points.
537pub fn parse_capture_points(template: &str) -> CaptureParseResult {
538    let mut captures = Vec::new();
539    let mut raw = String::with_capacity(template.len());
540    let chars: Vec<char> = template.chars().collect();
541    let mut i = 0;
542
543    while i < chars.len() {
544        if chars[i] == '[' {
545            let bracket_start = i;
546            let attempt_start = i + 1;
547            i = attempt_start;
548
549            // Speculatively parse a capture-point spec. If anything
550            // about the grammar fails to match — including the
551            // source name being absent or not starting with an
552            // identifier character — we reset to `bracket_start + 1`
553            // and emit the `[` as a literal. This keeps JSON / CQL
554            // array literals (`[]`, `["foo", 42]`), JNI type
555            // signatures (`[Ljava.lang.String;`), and other uses of
556            // `[...]` from being silently consumed.
557
558            // Skip whitespace
559            while i < chars.len() && chars[i].is_whitespace() {
560                i += 1;
561            }
562
563            // Optional type cast: (Type)
564            let cast_type = if i < chars.len() && chars[i] == '(' {
565                i += 1;
566                let cast_start = i;
567                while i < chars.len() && chars[i] != ')' {
568                    i += 1;
569                }
570                let cast: String = chars[cast_start..i].iter().collect();
571                if i < chars.len() {
572                    i += 1;
573                } // skip ')'
574                while i < chars.len() && chars[i].is_whitespace() {
575                    i += 1;
576                }
577                Some(cast.trim().to_string())
578            } else {
579                None
580            };
581
582            // Optional slurp modifier: `[@name]` collects every
583            // row's column value into a `Value::Json` array. The
584            // `@` is a syntax-only marker — stripped from the
585            // emitted raw_template so the adapter sees clean
586            // column-reference text.
587            let slurp = if i < chars.len() && chars[i] == '@' {
588                i += 1;
589                while i < chars.len() && chars[i].is_whitespace() {
590                    i += 1;
591                }
592                true
593            } else {
594                false
595            };
596
597            // Source name — must look like a real identifier (or
598            // `*` for wildcard). Starts with `_` or an ASCII alpha
599            // character; continues with alphanumerics, `_`, `-`,
600            // `.`, or `*`. The strict-leading-char rule prevents
601            // JSON array literals like `["..."]`, `[42]`, and JNI
602            // signatures like `[Ljava.lang.String;` from being
603            // misread as captures.
604            let name_start = i;
605            if i < chars.len() {
606                let first = chars[i];
607                let is_valid_first = first.is_ascii_alphabetic() || first == '_' || first == '*';
608                if !is_valid_first {
609                    // Not a capture-point opening — emit `[` and
610                    // resume one char in.
611                    raw.push('[');
612                    i = attempt_start;
613                    continue;
614                }
615            }
616            while i < chars.len()
617                && (chars[i].is_alphanumeric()
618                    || chars[i] == '_'
619                    || chars[i] == '-'
620                    || chars[i] == '.'
621                    || chars[i] == '*')
622            {
623                i += 1;
624            }
625            let source_name: String = chars[name_start..i].iter().collect();
626            if source_name.is_empty() {
627                // Defense-in-depth — the leading-char check above
628                // already excludes this path, but keep it explicit.
629                raw.push('[');
630                i = attempt_start;
631                continue;
632            }
633
634            // Optional "as alias"
635            while i < chars.len() && chars[i].is_whitespace() {
636                i += 1;
637            }
638            let as_name = if i + 2 < chars.len()
639                && (chars[i] == 'a' || chars[i] == 'A')
640                && (chars[i + 1] == 's' || chars[i + 1] == 'S')
641                && chars[i + 2].is_whitespace()
642            {
643                i += 2; // skip "as"
644                while i < chars.len() && chars[i].is_whitespace() {
645                    i += 1;
646                }
647                let alias_start = i;
648                while i < chars.len()
649                    && (chars[i].is_alphanumeric()
650                        || chars[i] == '_'
651                        || chars[i] == '-'
652                        || chars[i] == '.')
653                {
654                    i += 1;
655                }
656                let alias: String = chars[alias_start..i].iter().collect();
657                alias
658            } else {
659                source_name.clone()
660            };
661
662            // Skip whitespace and closing bracket
663            while i < chars.len() && chars[i].is_whitespace() {
664                i += 1;
665            }
666            if i < chars.len() && chars[i] == ']' {
667                i += 1;
668                captures.push(CapturePoint {
669                    // Bracket syntax carries no aggregate, so no predicate.
670                    row_filter: None,
671                    source_name: source_name.clone(),
672                    as_name,
673                    cast_type,
674                    slurp,
675                    path: None,
676                    count: false,
677                    agg: None,
678                });
679                // Emit source name without brackets into raw template
680                raw.push_str(&source_name);
681            } else {
682                // No matching `]` for what otherwise looked like a
683                // capture spec — likely a CQL/JSON array containing
684                // an identifier (e.g. `[foo, bar]`). Treat the
685                // opening `[` as a literal and resume one char in.
686                let _ = bracket_start;
687                raw.push('[');
688                i = attempt_start;
689            }
690        } else {
691            raw.push(chars[i]);
692            i += 1;
693        }
694    }
695
696    CaptureParseResult {
697        raw_template: raw,
698        captures,
699    }
700}
701
702#[cfg(test)]
703mod tests {
704    use super::*;
705
706    #[test]
707    fn is_expression_detects_function_calls() {
708        assert!(is_expression("hash(cycle)"));
709        assert!(is_expression("mod(x, 100)"));
710    }
711
712    #[test]
713    fn is_expression_detects_operators() {
714        assert!(is_expression("x + 1"));
715        assert!(is_expression("a * b"));
716        assert!(is_expression("x & 0xFF"));
717    }
718
719    #[test]
720    fn is_expression_rejects_simple_names() {
721        assert!(!is_expression("cycle"));
722        assert!(!is_expression("my_var"));
723        assert!(!is_expression("user_id"));
724    }
725
726    #[test]
727    fn is_expression_rejects_hyphenated_names() {
728        assert!(!is_expression("my-variable"));
729        assert!(!is_expression("some-long-name"));
730    }
731
732    #[test]
733    fn is_expression_detects_numeric_literals() {
734        assert!(is_expression("42"));
735        assert!(is_expression("3.14"));
736        assert!(is_expression("-5"));
737    }
738
739    #[test]
740    fn static_field() {
741        assert_eq!(classify_field("plain text"), FieldType::Static);
742        assert_eq!(classify_field("42"), FieldType::Static);
743        assert_eq!(classify_field(""), FieldType::Static);
744    }
745
746    #[test]
747    fn pure_bind_ref() {
748        assert_eq!(
749            classify_field("{userid}"),
750            FieldType::BindRef("userid".into())
751        );
752    }
753
754    #[test]
755    fn template_with_bind_points() {
756        let ft = classify_field("SELECT * FROM t WHERE id={id} AND name={name}");
757        match ft {
758            FieldType::Template(points) => {
759                assert_eq!(points.len(), 2);
760                assert_eq!(
761                    points[0],
762                    BindPoint::Reference {
763                        name: "id".into(),
764                        qualifier: BindQualifier::None,
765                        lvalue_spec: None
766                    }
767                );
768                assert_eq!(
769                    points[1],
770                    BindPoint::Reference {
771                        name: "name".into(),
772                        qualifier: BindQualifier::None,
773                        lvalue_spec: None
774                    }
775                );
776            }
777            _ => panic!("expected Template"),
778        }
779    }
780
781    #[test]
782    fn inline_definition() {
783        let ft = classify_field("value is {{Template('user-{}', ToString())}}");
784        match ft {
785            FieldType::Template(points) => {
786                assert_eq!(points.len(), 1);
787                match &points[0] {
788                    BindPoint::InlineDefinition(expr) => {
789                        assert!(expr.contains("Template"));
790                    }
791                    _ => panic!("expected InlineDefinition"),
792                }
793            }
794            _ => panic!("expected Template"),
795        }
796    }
797
798    #[test]
799    fn mixed_references_and_literals() {
800        let refs = referenced_bindings("INSERT INTO t (a, b) VALUES ({col_a}, {col_b})");
801        assert_eq!(refs, vec!["col_a", "col_b"]);
802    }
803
804    #[test]
805    fn no_bind_points() {
806        let refs = referenced_bindings("just a plain string");
807        assert!(refs.is_empty());
808    }
809
810    // --- Qualified bind point tests ---
811
812    #[test]
813    fn qualified_coord() {
814        let points = extract_bind_points("{coord:cycle}");
815        assert_eq!(points.len(), 1);
816        assert_eq!(
817            points[0],
818            BindPoint::Reference {
819                name: "cycle".into(),
820                qualifier: BindQualifier::Input,
821                lvalue_spec: None,
822            }
823        );
824    }
825
826    #[test]
827    fn qualified_capture() {
828        let points = extract_bind_points("{capture:balance}");
829        assert_eq!(points.len(), 1);
830        assert_eq!(
831            points[0],
832            BindPoint::Reference {
833                name: "balance".into(),
834                qualifier: BindQualifier::Capture,
835                lvalue_spec: None,
836            }
837        );
838    }
839
840    #[test]
841    fn qualified_bind() {
842        let points = extract_bind_points("{bind:user_id}");
843        assert_eq!(points.len(), 1);
844        assert_eq!(
845            points[0],
846            BindPoint::Reference {
847                name: "user_id".into(),
848                qualifier: BindQualifier::Bind,
849                lvalue_spec: None,
850            }
851        );
852    }
853
854    #[test]
855    fn unknown_qualifier_becomes_unqualified() {
856        // "port" is not a recognized qualifier — treated as unqualified
857        let points = extract_bind_points("{port:auth_token}");
858        assert_eq!(points.len(), 1);
859        assert_eq!(
860            points[0],
861            BindPoint::Reference {
862                name: "port:auth_token".into(),
863                qualifier: BindQualifier::None,
864                lvalue_spec: None,
865            }
866        );
867    }
868
869    #[test]
870    fn unqualified_still_works() {
871        let points = extract_bind_points("{user_id}");
872        assert_eq!(
873            points[0],
874            BindPoint::Reference {
875                name: "user_id".into(),
876                qualifier: BindQualifier::None,
877                lvalue_spec: None,
878            }
879        );
880    }
881
882    #[test]
883    fn qualified_referenced_bindings_returns_bare_name() {
884        let refs = referenced_bindings("VALUES ({coord:cycle}, {capture:balance}, {user_id})");
885        assert_eq!(refs, vec!["cycle", "balance", "user_id"]);
886    }
887
888    #[test]
889    fn coordinate_long_form() {
890        let points = extract_bind_points("{coordinate:row}");
891        assert_eq!(
892            points[0],
893            BindPoint::Reference {
894                name: "row".into(),
895                qualifier: BindQualifier::Input,
896                lvalue_spec: None,
897            }
898        );
899    }
900
901    // --- Capture point tests ---
902
903    #[test]
904    fn capture_simple() {
905        let result = parse_capture_points("select [username] from users where id={id}");
906        assert_eq!(result.captures.len(), 1);
907        assert_eq!(result.captures[0].source_name, "username");
908        assert_eq!(result.captures[0].as_name, "username");
909        assert_eq!(
910            result.raw_template,
911            "select username from users where id={id}"
912        );
913    }
914
915    #[test]
916    fn capture_with_alias() {
917        let result = parse_capture_points("select [username as u1] from users");
918        assert_eq!(result.captures.len(), 1);
919        assert_eq!(result.captures[0].source_name, "username");
920        assert_eq!(result.captures[0].as_name, "u1");
921        assert_eq!(result.raw_template, "select username from users");
922    }
923
924    #[test]
925    fn capture_with_type_cast() {
926        let result = parse_capture_points("select [(List) items] from orders");
927        assert_eq!(result.captures.len(), 1);
928        assert_eq!(result.captures[0].source_name, "items");
929        assert_eq!(result.captures[0].cast_type, Some("List".into()));
930    }
931
932    #[test]
933    fn capture_wildcard() {
934        let result = parse_capture_points("select [*] from users");
935        assert_eq!(result.captures.len(), 1);
936        assert_eq!(result.captures[0].source_name, "*");
937    }
938
939    #[test]
940    fn capture_empty_brackets_pass_through() {
941        // `[]` (empty JSON array literal) is NOT a capture point.
942        // The parser must emit it verbatim — eating the brackets
943        // would break Jolokia payloads like
944        // `"arguments":["foo",[]]` which became `"arguments":["foo",]`
945        // (invalid JSON, JMX op gets 1 arg instead of 2).
946        let result = parse_capture_points(r#"{"arguments":["foo",[]]}"#);
947        assert!(
948            result.captures.is_empty(),
949            "no capture should be extracted: {:?}",
950            result.captures
951        );
952        assert_eq!(result.raw_template, r#"{"arguments":["foo",[]]}"#);
953    }
954
955    #[test]
956    fn capture_jni_array_signature_pass_through() {
957        // JNI array signature `[Ljava.lang.String;` opens with `[`
958        // but is not a capture point (no closing `]` follows the
959        // identifier chunk). The parser must not consume anything.
960        let template = r#""operation":"forceKeyspaceFlush(java.lang.String,[Ljava.lang.String;)""#;
961        let result = parse_capture_points(template);
962        assert!(
963            result.captures.is_empty(),
964            "JNI signature should not parse as capture: {:?}",
965            result.captures
966        );
967        assert_eq!(result.raw_template, template);
968    }
969
970    #[test]
971    fn capture_json_array_with_string_pass_through() {
972        // JSON string-array literal — opens `[`, content begins
973        // with `"`, no identifier; parser must leave it alone.
974        let result = parse_capture_points(r#"["alpha","beta"]"#);
975        assert!(result.captures.is_empty());
976        assert_eq!(result.raw_template, r#"["alpha","beta"]"#);
977    }
978
979    #[test]
980    fn capture_json_array_with_number_pass_through() {
981        // Numeric-literal-only array — leading digit is not a
982        // valid identifier start, so no capture is extracted.
983        let result = parse_capture_points("[42]");
984        assert!(result.captures.is_empty());
985        assert_eq!(result.raw_template, "[42]");
986    }
987
988    #[test]
989    fn capture_cql_collection_literal_pass_through() {
990        // CQL collection literal like `[1, 2, 3]` — content has
991        // digits + commas, not a valid capture spec. Must
992        // pass through verbatim.
993        let template = "INSERT INTO t (vals) VALUES ([1, 2, 3])";
994        let result = parse_capture_points(template);
995        assert!(result.captures.is_empty());
996        assert_eq!(result.raw_template, template);
997    }
998
999    #[test]
1000    fn capture_multiple() {
1001        let result = parse_capture_points("select [a], [b as x] from t where id={id}");
1002        assert_eq!(result.captures.len(), 2);
1003        assert_eq!(result.captures[0].source_name, "a");
1004        assert_eq!(result.captures[0].as_name, "a");
1005        assert_eq!(result.captures[1].source_name, "b");
1006        assert_eq!(result.captures[1].as_name, "x");
1007    }
1008
1009    #[test]
1010    fn capture_no_captures() {
1011        let result = parse_capture_points("select * from users where id={id}");
1012        assert!(result.captures.is_empty());
1013        assert_eq!(result.raw_template, "select * from users where id={id}");
1014    }
1015
1016    #[test]
1017    fn capture_mixed_with_bind_points() {
1018        let result =
1019            parse_capture_points("select [name], [age as user_age] from users where id={userid}");
1020        assert_eq!(result.captures.len(), 2);
1021        // Bind point {userid} should remain in the raw template
1022        assert!(result.raw_template.contains("{userid}"));
1023        // Capture brackets should be removed
1024        assert!(!result.raw_template.contains('['));
1025        assert!(!result.raw_template.contains(']'));
1026    }
1027}