1use std::collections::BTreeMap;
35
36use anyhow::{Context, anyhow, bail, ensure};
37
38use crate::script::{BuildSpec, ColumnSpec, Command, ConfigSetting, ExportSpec, ImportSpec};
39
40pub enum Item {
43 Verbatim(String),
45 Stanza(Stanza),
47}
48
49pub struct Stanza {
51 pub input: String,
53 pub expected: String,
55 pub command: Command,
57}
58
59pub fn parse_file(content: &str) -> anyhow::Result<Vec<Item>> {
62 let lines: Vec<&str> = content.lines().collect();
63 let mut items = Vec::new();
64 let mut i = 0;
65 while i < lines.len() {
66 let line = lines[i];
67 if line.trim().is_empty() || line.starts_with('#') {
69 items.push(Item::Verbatim(line.to_string()));
70 i += 1;
71 continue;
72 }
73 let start = i;
75 while i < lines.len() && lines[i] != "----" {
76 i += 1;
77 }
78 ensure!(
79 i < lines.len(),
80 "stanza starting at line {} has no `----` separator",
81 start + 1
82 );
83 let input = lines[start..i].join("\n");
84 i += 1; let exp_start = i;
87 while i < lines.len() && !lines[i].trim().is_empty() {
88 i += 1;
89 }
90 let expected = lines[exp_start..i].join("\n");
91 let command = parse_command(&input)
92 .with_context(|| format!("parsing stanza at line {}", start + 1))?;
93 items.push(Item::Stanza(Stanza {
94 input,
95 expected,
96 command,
97 }));
98 }
99 Ok(items)
100}
101
102pub fn rewrite(items: &[Item], actuals: &[String]) -> String {
106 let mut out = String::new();
107 let mut next = 0;
108 for item in items {
109 match item {
110 Item::Verbatim(line) => {
111 out.push_str(line);
112 out.push('\n');
113 }
114 Item::Stanza(stanza) => {
115 let actual = &actuals[next];
116 next += 1;
117 out.push_str(&stanza.input);
118 out.push_str("\n----\n");
119 out.push_str(actual);
120 if !actual.is_empty() {
121 out.push('\n');
122 }
123 }
124 }
125 }
126 out
127}
128
129struct Line {
131 indent: usize,
132 text: String,
133}
134
135fn lex(block: &str) -> Vec<Line> {
137 block
138 .lines()
139 .filter_map(|raw| {
140 let trimmed = raw.trim_end();
141 if trimmed.trim().is_empty() {
142 return None;
143 }
144 let indent = trimmed.len() - trimmed.trim_start().len();
145 Some(Line {
146 indent,
147 text: trimmed.trim_start().to_string(),
148 })
149 })
150 .collect()
151}
152
153fn group(lines: &[Line]) -> anyhow::Result<Vec<(&Line, &[Line])>> {
156 if lines.is_empty() {
157 return Ok(vec![]);
158 }
159 let base = lines.iter().map(|l| l.indent).min().expect("non-empty");
160 let mut groups = Vec::new();
161 let mut i = 0;
162 while i < lines.len() {
163 ensure!(
164 lines[i].indent == base,
165 "inconsistent indentation: `{}`",
166 lines[i].text
167 );
168 let start = i + 1;
169 let mut j = start;
170 while j < lines.len() && lines[j].indent > base {
171 j += 1;
172 }
173 groups.push((&lines[i], &lines[start..j]));
174 i = j;
175 }
176 Ok(groups)
177}
178
179fn body_text(lines: &[Line]) -> String {
182 if lines.is_empty() {
183 return String::new();
184 }
185 let base = lines.iter().map(|l| l.indent).min().expect("non-empty");
186 lines
187 .iter()
188 .map(|l| format!("{}{}", " ".repeat(l.indent - base), l.text))
189 .collect::<Vec<_>>()
190 .join("\n")
191}
192
193fn tokenize(s: &str) -> anyhow::Result<Vec<String>> {
196 let mut tokens = Vec::new();
197 let mut cur = String::new();
198 let mut depth = 0i32;
199 let mut in_quote = false;
200 for c in s.chars() {
201 match c {
202 '"' => {
203 in_quote = !in_quote;
204 cur.push(c);
205 }
206 '[' if !in_quote => {
207 depth += 1;
208 cur.push(c);
209 }
210 ']' if !in_quote => {
211 depth -= 1;
212 cur.push(c);
213 }
214 c if c.is_whitespace() && !in_quote && depth == 0 => {
215 if !cur.is_empty() {
216 tokens.push(std::mem::take(&mut cur));
217 }
218 }
219 c => cur.push(c),
220 }
221 }
222 ensure!(!in_quote, "unterminated string in `{s}`");
223 ensure!(depth == 0, "unterminated list in `{s}`");
224 if !cur.is_empty() {
225 tokens.push(cur);
226 }
227 Ok(tokens)
228}
229
230fn parse_header(header: &str) -> anyhow::Result<(String, BTreeMap<String, String>, Vec<String>)> {
232 let mut tokens = tokenize(header)?.into_iter();
233 let verb = tokens.next().context("empty command")?;
234 let mut args = BTreeMap::new();
235 let mut flags = Vec::new();
236 for token in tokens {
237 match token.split_once('=') {
238 Some((key, value)) => {
239 args.insert(key.to_string(), value.to_string());
240 }
241 None => flags.push(token),
242 }
243 }
244 Ok((verb, args, flags))
245}
246
247fn req<'a>(args: &'a BTreeMap<String, String>, key: &str) -> anyhow::Result<&'a str> {
248 args.get(key)
249 .map(String::as_str)
250 .ok_or_else(|| anyhow!("missing argument `{key}`"))
251}
252
253fn req_u64(args: &BTreeMap<String, String>, key: &str) -> anyhow::Result<u64> {
254 req(args, key)?
255 .parse()
256 .with_context(|| format!("argument `{key}` is not an integer"))
257}
258
259fn opt_u64(args: &BTreeMap<String, String>, key: &str) -> anyhow::Result<Option<u64>> {
260 args.get(key)
261 .map(|v| {
262 v.parse()
263 .with_context(|| format!("argument `{key}` is not an integer"))
264 })
265 .transpose()
266}
267
268fn opt_usize(args: &BTreeMap<String, String>, key: &str) -> anyhow::Result<Option<usize>> {
269 args.get(key)
270 .map(|v| {
271 v.parse()
272 .with_context(|| format!("argument `{key}` is not an integer"))
273 })
274 .transpose()
275}
276
277fn opt_string(args: &BTreeMap<String, String>, key: &str) -> Option<String> {
278 args.get(key).cloned()
279}
280
281fn parse_usize_list(s: &str) -> anyhow::Result<Vec<usize>> {
283 let inner = s
284 .strip_prefix('[')
285 .and_then(|s| s.strip_suffix(']'))
286 .ok_or_else(|| anyhow!("expected a list like `[0,1]`, got `{s}`"))?;
287 if inner.trim().is_empty() {
288 return Ok(vec![]);
289 }
290 inner
291 .split(',')
292 .map(|part| {
293 part.trim()
294 .parse()
295 .with_context(|| format!("bad list element `{part}`"))
296 })
297 .collect()
298}
299
300fn parse_export(args: &BTreeMap<String, String>) -> anyhow::Result<ExportSpec> {
303 let on_id = req_u64(args, "on")?;
304 Ok(
305 match args.get("kind").map(String::as_str).unwrap_or("index") {
306 "index" => ExportSpec::Index {
307 index_id: req_u64(args, "index")?,
308 on_id,
309 key: parse_usize_list(req(args, "key")?)?,
310 },
311 "materialized-view" => ExportSpec::MaterializedView {
312 sink_id: req_u64(args, "sink")?,
313 on_id,
314 shard: req(args, "shard")?.to_string(),
315 schema: opt_string(args, "schema"),
316 },
317 "subscribe" => ExportSpec::Subscribe {
318 sink_id: req_u64(args, "sink")?,
319 on_id,
320 schema: opt_string(args, "schema"),
321 up_to: opt_u64(args, "up-to")?,
322 },
323 "copy-to" => bail!("copy-to export is not implemented"),
324 other => bail!("unknown export kind `{other}`"),
325 },
326 )
327}
328
329fn rows_from_body(body: &[Line]) -> anyhow::Result<Vec<Vec<String>>> {
332 body.iter().map(|l| tokenize(&l.text)).collect()
333}
334
335fn settings_from_body(body: &[Line]) -> anyhow::Result<Vec<ConfigSetting>> {
337 body.iter()
338 .map(|l| {
339 let tokens = tokenize(&l.text)?;
340 ensure!(
341 tokens.len() == 3,
342 "config setting needs `name type value`, got `{}`",
343 l.text
344 );
345 Ok(ConfigSetting {
346 name: tokens[0].clone(),
347 ty: tokens[1].clone(),
348 value: tokens[2].clone(),
349 })
350 })
351 .collect()
352}
353
354fn columns_from_body(body: &[Line]) -> anyhow::Result<Vec<ColumnSpec>> {
356 body.iter()
357 .map(|l| {
358 let tokens = tokenize(&l.text)?;
359 ensure!(
360 tokens.len() >= 2,
361 "column needs `name type [nullable]`, got `{}`",
362 l.text
363 );
364 Ok(ColumnSpec {
365 name: tokens[0].clone(),
366 ty: tokens[1].clone(),
367 nullable: tokens.get(2).is_some_and(|t| t == "nullable"),
368 })
369 })
370 .collect()
371}
372
373fn parse_create_dataflow(
376 args: &BTreeMap<String, String>,
377 flags: &[String],
378 body: &[Line],
379) -> anyhow::Result<Command> {
380 let name = opt_string(args, "name");
381 let as_of = req_u64(args, "as-of")?;
382 let optimize = flags.iter().any(|f| f == "optimize");
383 let mut imports = Vec::new();
384 let mut builds = Vec::new();
385 let mut exports = Vec::new();
386 for (header, sub_body) in group(body)? {
387 let (verb, args, _flags) = parse_header(&header.text)?;
388 match verb.as_str() {
389 "import" => {
390 if let Some(index_id) = args.get("index") {
391 imports.push(ImportSpec::Index {
392 index_id: index_id
393 .parse()
394 .with_context(|| format!("bad index id `{index_id}`"))?,
395 });
396 } else {
397 imports.push(ImportSpec::Source {
398 id: req_u64(&args, "source")?,
399 shard: req(&args, "shard")?.to_string(),
400 schema: opt_string(&args, "schema"),
401 upper: req_u64(&args, "upper")?,
402 });
403 }
404 }
405 "build" => {
406 ensure!(!sub_body.is_empty(), "`build` needs a MIR body");
407 builds.push(BuildSpec {
408 id: req_u64(&args, "id")?,
409 expr: body_text(sub_body),
410 });
411 }
412 "export" => exports.push(parse_export(&args)?),
413 other => bail!("unknown `create-dataflow` sub-command `{other}`"),
414 }
415 }
416 Ok(Command::CreateDataflow {
417 name,
418 imports,
419 builds,
420 exports,
421 as_of,
422 optimize,
423 })
424}
425
426fn parse_command(input: &str) -> anyhow::Result<Command> {
428 let lines = lex(input);
429 let (header, body) = lines.split_first().context("empty command")?;
430 ensure!(header.indent == 0, "directive must not be indented");
431 let (verb, args, flags) = parse_header(&header.text)?;
432 let command = match verb.as_str() {
433 "define-schema" => Command::DefineSchema {
434 name: req(&args, "name")?.to_string(),
435 columns: columns_from_body(body)?,
436 },
437 "write-single-ts" => Command::WriteSingleTs {
438 shard: req(&args, "shard")?.to_string(),
439 schema: opt_string(&args, "schema"),
440 ts: req_u64(&args, "ts")?,
441 count: req_u64(&args, "count")?,
442 start: opt_u64(&args, "start")?.unwrap_or(0),
443 row_bytes: opt_usize(&args, "row-bytes")?,
444 },
445 "write-spread" => Command::WriteSpread {
446 shard: req(&args, "shard")?.to_string(),
447 schema: opt_string(&args, "schema"),
448 count: req_u64(&args, "count")?,
449 n_ts: req_u64(&args, "n-ts")?,
450 start: opt_u64(&args, "start")?.unwrap_or(0),
451 row_bytes: opt_usize(&args, "row-bytes")?,
452 },
453 "write-rows" => Command::WriteRows {
454 shard: req(&args, "shard")?.to_string(),
455 schema: opt_string(&args, "schema"),
456 ts: req_u64(&args, "ts")?,
457 rows: rows_from_body(body)?,
458 },
459 "define-index" => Command::DefineIndex {
460 source_id: req_u64(&args, "source")?,
461 index_id: req_u64(&args, "index")?,
462 shard: req(&args, "shard")?.to_string(),
463 schema: opt_string(&args, "schema"),
464 key: parse_usize_list(req(&args, "key")?)?,
465 as_of: req_u64(&args, "as-of")?,
466 upper: req_u64(&args, "upper")?,
467 },
468 "schedule" => Command::Schedule {
469 id: req_u64(&args, "id")?,
470 },
471 "allow-compaction" => Command::AllowCompaction {
472 id: req_u64(&args, "id")?,
473 frontier: req_u64(&args, "frontier")?,
474 },
475 "allow-writes" => Command::AllowWrites {
476 id: req_u64(&args, "id")?,
477 },
478 "await-frontier" => Command::AwaitFrontier {
479 id: req_u64(&args, "id")?,
480 ts: req_u64(&args, "ts")?,
481 timeout_secs: opt_u64(&args, "timeout-secs")?,
482 allow_timeout: flags.iter().any(|f| f == "allow-timeout"),
483 },
484 "count" => Command::Count {
485 id: req_u64(&args, "id")?,
486 ts: req_u64(&args, "ts")?,
487 },
488 "peek" => Command::Peek {
489 id: req_u64(&args, "id")?,
490 schema: opt_string(&args, "schema"),
491 ts: req_u64(&args, "ts")?,
492 },
493 "await-subscribe" => Command::AwaitSubscribe {
494 id: req_u64(&args, "id")?,
495 up_to: req_u64(&args, "up-to")?,
496 timeout_secs: opt_u64(&args, "timeout-secs")?,
497 },
498 "create-dataflow" => parse_create_dataflow(&args, &flags, body)?,
499 "create-instance" => Command::CreateInstance {
500 expiration_offset: opt_string(&args, "expiration-offset"),
501 arrangement_dictionary_compression: args
502 .get("arrangement-dictionary-compression")
503 .map(|v| v.parse())
504 .transpose()
505 .context("argument `arrangement-dictionary-compression` is not a bool")?
506 .unwrap_or(false),
507 initial_config: settings_from_body(body)?,
509 },
510 "update-configuration" => Command::UpdateConfiguration {
511 updates: settings_from_body(body)?,
512 },
513 "reconnect" => Command::Reconnect,
514 "initialization-complete" => Command::InitializationComplete,
515 other => bail!("unknown command `{other}`"),
516 };
517 Ok(command)
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523
524 #[mz_ore::test]
526 fn parses_simple_command() {
527 let cmd = parse_command("write-single-ts shard=data ts=0 count=5000").unwrap();
528 assert_eq!(
529 cmd,
530 Command::WriteSingleTs {
531 shard: "data".to_string(),
532 schema: None,
533 ts: 0,
534 count: 5000,
535 start: 0,
536 row_bytes: None,
537 }
538 );
539 }
540
541 #[mz_ore::test]
543 fn parses_flag_and_list() {
544 let cmd =
545 parse_command("await-frontier id=1001 ts=1 timeout-secs=3 allow-timeout").unwrap();
546 assert_eq!(
547 cmd,
548 Command::AwaitFrontier {
549 id: 1001,
550 ts: 1,
551 timeout_secs: Some(3),
552 allow_timeout: true,
553 }
554 );
555 let cmd =
556 parse_command("define-index source=1000 index=1001 shard=d key=[0] as-of=0 upper=1")
557 .unwrap();
558 assert_eq!(
559 cmd,
560 Command::DefineIndex {
561 source_id: 1000,
562 index_id: 1001,
563 shard: "d".to_string(),
564 schema: None,
565 key: vec![0],
566 as_of: 0,
567 upper: 1,
568 }
569 );
570 }
571
572 #[mz_ore::test]
574 fn parses_bodies() {
575 let cmd = parse_command(
576 "define-schema name=events\n key bigint\n flag boolean\n label text nullable",
577 )
578 .unwrap();
579 assert_eq!(
580 cmd,
581 Command::DefineSchema {
582 name: "events".to_string(),
583 columns: vec![
584 ColumnSpec {
585 name: "key".to_string(),
586 ty: "bigint".to_string(),
587 nullable: false
588 },
589 ColumnSpec {
590 name: "flag".to_string(),
591 ty: "boolean".to_string(),
592 nullable: false
593 },
594 ColumnSpec {
595 name: "label".to_string(),
596 ty: "text".to_string(),
597 nullable: true
598 },
599 ],
600 }
601 );
602
603 let cmd = parse_command(
604 "write-rows shard=ev schema=events ts=1\n 1000 true alpha\n 1001 false null",
605 )
606 .unwrap();
607 assert_eq!(
608 cmd,
609 Command::WriteRows {
610 shard: "ev".to_string(),
611 schema: Some("events".to_string()),
612 ts: 1,
613 rows: vec![
614 vec!["1000".to_string(), "true".to_string(), "alpha".to_string()],
615 vec!["1001".to_string(), "false".to_string(), "null".to_string()],
616 ],
617 }
618 );
619 }
620
621 #[mz_ore::test]
625 fn parses_create_dataflow_with_mir() {
626 let input = "create-dataflow name=count as-of=0\n import index=1001\n build id=2000\n Reduce aggregates=[count(*)]\n Get u1000\n export index=2001 on=2000 key=[0]";
627 let cmd = parse_command(input).unwrap();
628 assert_eq!(
629 cmd,
630 Command::CreateDataflow {
631 name: Some("count".to_string()),
632 imports: vec![ImportSpec::Index { index_id: 1001 }],
633 builds: vec![BuildSpec {
634 id: 2000,
635 expr: "Reduce aggregates=[count(*)]\n Get u1000".to_string(),
636 }],
637 exports: vec![ExportSpec::Index {
638 index_id: 2001,
639 on_id: 2000,
640 key: vec![0]
641 }],
642 as_of: 0,
643 optimize: false,
644 }
645 );
646
647 let optimized = parse_command(
649 "create-dataflow name=j as-of=0 optimize\n import source=1000 shard=l upper=1\n build id=2000\n Get u1000\n export index=2001 on=2000 key=[0]",
650 )
651 .unwrap();
652 assert!(matches!(
653 optimized,
654 Command::CreateDataflow { optimize: true, .. }
655 ));
656 }
657
658 #[mz_ore::test]
661 fn parses_sink_export_kinds() {
662 let mv = "create-dataflow name=mv as-of=0\n import source=1000 shard=r upper=1\n build id=2000\n Get u1000\n export kind=materialized-view sink=2001 on=2000 shard=out schema=kv";
663 let Command::CreateDataflow { exports, .. } = parse_command(mv).unwrap() else {
664 panic!("expected create-dataflow");
665 };
666 assert_eq!(
667 exports,
668 vec![ExportSpec::MaterializedView {
669 sink_id: 2001,
670 on_id: 2000,
671 shard: "out".to_string(),
672 schema: Some("kv".to_string()),
673 }]
674 );
675
676 let sub = "create-dataflow name=sub as-of=0\n import source=1000 shard=s upper=2\n build id=2000\n Get u1000\n export kind=subscribe sink=2001 on=2000 up-to=2";
677 let Command::CreateDataflow { exports, .. } = parse_command(sub).unwrap() else {
678 panic!("expected create-dataflow");
679 };
680 assert_eq!(
681 exports,
682 vec![ExportSpec::Subscribe {
683 sink_id: 2001,
684 on_id: 2000,
685 schema: None,
686 up_to: Some(2),
687 }]
688 );
689
690 let copy = "create-dataflow name=c as-of=0\n import source=1000 shard=s upper=1\n build id=2000\n Get u1000\n export kind=copy-to sink=2001 on=2000";
692 assert!(parse_command(copy).is_err());
693 }
694
695 #[mz_ore::test]
698 fn parses_handshake_config() {
699 assert_eq!(
700 parse_command("create-instance").unwrap(),
701 Command::CreateInstance {
702 expiration_offset: None,
703 arrangement_dictionary_compression: false,
704 initial_config: vec![],
705 }
706 );
707 assert_eq!(
708 parse_command(
709 "create-instance expiration-offset=30s arrangement-dictionary-compression=true"
710 )
711 .unwrap(),
712 Command::CreateInstance {
713 expiration_offset: Some("30s".to_string()),
714 arrangement_dictionary_compression: true,
715 initial_config: vec![],
716 }
717 );
718 assert_eq!(
720 parse_command("create-instance\n enable_my_flag bool true").unwrap(),
721 Command::CreateInstance {
722 expiration_offset: None,
723 arrangement_dictionary_compression: false,
724 initial_config: vec![ConfigSetting {
725 name: "enable_my_flag".to_string(),
726 ty: "bool".to_string(),
727 value: "true".to_string(),
728 }],
729 }
730 );
731
732 assert_eq!(
733 parse_command("update-configuration").unwrap(),
734 Command::UpdateConfiguration { updates: vec![] }
735 );
736 assert_eq!(
737 parse_command("update-configuration\n enable_my_flag bool true\n my_dur duration 1s")
738 .unwrap(),
739 Command::UpdateConfiguration {
740 updates: vec![
741 ConfigSetting {
742 name: "enable_my_flag".to_string(),
743 ty: "bool".to_string(),
744 value: "true".to_string(),
745 },
746 ConfigSetting {
747 name: "my_dur".to_string(),
748 ty: "duration".to_string(),
749 value: "1s".to_string(),
750 },
751 ],
752 }
753 );
754 }
755
756 #[mz_ore::test]
758 fn parses_await_subscribe() {
759 let cmd = parse_command("await-subscribe id=2001 up-to=2 timeout-secs=5").unwrap();
760 assert_eq!(
761 cmd,
762 Command::AwaitSubscribe {
763 id: 2001,
764 up_to: 2,
765 timeout_secs: Some(5),
766 }
767 );
768 }
769
770 #[mz_ore::test]
773 fn parses_and_rewrites_file() {
774 let content =
775 "# a comment\nschedule id=1001\n----\nok\n\ncount id=1001 ts=5\n----\n10000\n";
776 let items = parse_file(content).unwrap();
777 let stanzas: Vec<_> = items
778 .iter()
779 .filter_map(|i| match i {
780 Item::Stanza(s) => Some(s),
781 Item::Verbatim(_) => None,
782 })
783 .collect();
784 assert_eq!(stanzas.len(), 2);
785 assert_eq!(stanzas[0].command, Command::Schedule { id: 1001 });
786 assert_eq!(stanzas[0].expected, "ok");
787 assert_eq!(stanzas[1].expected, "10000");
788
789 let actuals = vec!["ok".to_string(), "10000".to_string()];
791 assert_eq!(rewrite(&items, &actuals), content);
792 }
793}