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("ed) {
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}