pub struct Table {
pub start: usize,
pub end: usize,
pub lines: Vec<String>,
}Fields§
§start: usizeFirst line index (0-based, inclusive).
end: usizeLast line index (inclusive).
lines: Vec<String>Implementations§
Source§impl Table
impl Table
pub fn header(&self) -> &str
pub fn delimiter(&self) -> &str
pub fn body(&self) -> &[String]
Sourcepub fn widths(&self) -> Vec<Vec<usize>>
pub fn widths(&self) -> Vec<Vec<usize>>
Character count between pipes, per row. Equal vectors mean aligned.
Character count, not byte count. A byte count would read a multi-byte cell as wider than it prints, and since padding is emitted in characters the row would come out over-padded and the column ragged.
The corpus alone does not prove this: its aligned tables do hold
non-ASCII cells, but only short ones (—, –) that are never the
widest in their column, so the two counts agree everywhere it matters.
bench/difftest.py adds a deliberately wide non-ASCII value for that
reason – without it the byte-count mutation survives, which is how the
gap was found (bench/mutate.py).
Sourcepub fn is_aligned(&self) -> bool
pub fn is_aligned(&self) -> bool
Examples found in repository?
340fn run(content: &str, op: &str, a: &Args) -> (&'static str, String) {
341 match op {
342 "py_repr" => {
343 let text = a.get("text").map(String::as_str).unwrap_or("");
344 match json::parse(text) {
345 None => ("ok", "UNPARSED".to_string()),
346 Some(v) => ("ok", json::py_repr(&v)),
347 }
348 }
349 "check_args" => {
350 let f = a.get("fn").map(String::as_str).unwrap_or("");
351 let text = a.get("text").map(String::as_str).unwrap_or("");
352 match arg_case(f, text) {
353 Ok(s) => ("ok", s),
354 Err(e) => ("err", e.message().to_string()),
355 }
356 }
357 "render_table_list" => (
358 "ok",
359 render_table_list(content, a.get("path").map(String::as_str).unwrap_or("")),
360 ),
361 "list_tables" => {
362 let dump = list_tables(content)
363 .iter()
364 .map(|e| {
365 format!(
366 "{}|{}|{}|{}|{}",
367 e.ordinal,
368 e.heading,
369 e.caption,
370 e.columns.join(" | "),
371 e.rows
372 )
373 })
374 .collect::<Vec<_>>()
375 .join("\n");
376 ("ok", dump)
377 }
378 "find_tables" => {
379 let dump = find_tables(content)
380 .iter()
381 .map(|t| format!("{}|{}|{}|{}", t.start, t.end, t.is_aligned(), t.has_tabs()))
382 .collect::<Vec<_>>()
383 .join("\n");
384 ("ok", dump)
385 }
386 "find_sections" => {
387 let dump = find_sections(content)
388 .iter()
389 .map(|s| {
390 format!(
391 "{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
392 s.start,
393 s.heading_end,
394 s.own_end,
395 s.end,
396 s.level,
397 s.style.as_str(),
398 s.gap_after,
399 s.parent.map(|p| p as isize).unwrap_or(-1),
400 esc(&s.indent),
401 s.marker,
402 esc(&s.space),
403 s.closing,
404 esc(&s.raw_text),
405 s.path.join(" > ")
406 )
407 })
408 .collect::<Vec<_>>()
409 .join("\n");
410 ("ok", dump)
411 }
412 // The list parser, field by field, for the reason `find_sections` is
413 // dumped: an op suite can agree everywhere and still be sitting on a
414 // parser that disagrees about where a list ends. One line per list,
415 // then one indented line per item -- `own_end` and `parent` are in
416 // there because nothing else reads them, so nothing else would notice.
417 "find_lists" => {
418 let mut dump: Vec<String> = Vec::new();
419 for l in find_lists(content) {
420 dump.push(format!(
421 "{}|{}|{}|{}|{}|{}",
422 l.start,
423 l.end,
424 l.ordered,
425 l.bullet,
426 esc(&l.indent),
427 l.loose
428 ));
429 for it in &l.items {
430 dump.push(format!(
431 " {}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
432 it.start,
433 it.own_end,
434 it.end,
435 it.depth,
436 esc(&it.indent),
437 it.marker,
438 it.ordered,
439 opt(it.number.map(|n| n.to_string())),
440 opt(it.delim.map(|c| c.to_string())),
441 opt(it.checkbox.map(|c| c.to_string())),
442 it.parent.map(|p| p as isize).unwrap_or(-1),
443 esc(&it.text)
444 ));
445 }
446 }
447 ("ok", dump.join("\n"))
448 }
449 // The frontmatter parser, field by field, for the reason the other two
450 // are dumped -- plus `rebuilt()`, which no other dump has an analogue
451 // for. Every edit in the family goes out through it, so comparing it
452 // here catches a gap or padding divergence on a line no op in the suite
453 // happens to rewrite.
454 // `parse_path`, typed and round-tripped. Its own case op because the
455 // parser only ever hands out paths it built itself, so the refusal
456 // branch and the index arithmetic are reachable from no fixture.
457 "parse_path" => match parse_path(a.get("text").map(String::as_str).unwrap_or("")) {
458 None => ("ok", "NONE".to_string()),
459 Some(p) => {
460 let kinds = p
461 .iter()
462 .map(|s| match s {
463 Seg::Index(n) => format!("i:{n}"),
464 Seg::Key(k) => format!("k:{k}"),
465 })
466 .collect::<Vec<_>>()
467 .join("|");
468 ("ok", format!("{}\n{kinds}", format_path(&p)))
469 }
470 },
471 "find_frontmatter" => {
472 let fm = find_frontmatter(content);
473 if !fm.present {
474 return ("ok", "absent".to_string());
475 }
476 let mut dump = vec![format!(
477 "{}|{}|{}|{}|{}|{}",
478 match fm.fmt {
479 Some(Fmt::Yaml) => "yaml",
480 _ => "toml",
481 },
482 fm.start,
483 fm.end,
484 fm.delim,
485 fm.close,
486 esc(&fm.eol)
487 )];
488 for e in &fm.entries {
489 dump.push(format!(
490 " {}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
491 format_path(&e.path),
492 e.line,
493 e.end,
494 e.kind.as_str(),
495 esc(&e.prefix),
496 esc(&e.key_text),
497 esc(&e.gap),
498 esc(&e.value),
499 esc(&e.pad),
500 esc(&e.comment),
501 esc(&e.eol),
502 esc(&e.rebuilt(None))
503 ));
504 }
505 ("ok", dump.join("\n"))
506 }
507 // `describe_change`'s case, for the family it does not describe. The
508 // same shape deliberately: both sides apply the op and describe
509 // before-vs-after, so a divergence in either half shows here.
510 "describe_front" => {
511 let name = a.get("opname").map(String::as_str).unwrap_or("");
512 let arg = a.get("args").map(String::as_str).unwrap_or("ABSENT");
513 let parsed = if arg == "ABSENT" {
514 None
515 } else {
516 json::parse(arg)
517 };
518 if arg != "ABSENT" && parsed.is_none() {
519 ("ok", format!("PARSE-FAILED {arg}"))
520 } else {
521 match apply_op(content, name, parsed.as_ref()) {
522 Ok(after) => ("ok", describe_frontmatter_change(content, &after)),
523 Err(e) => ("err", e.0),
524 }
525 }
526 }
527 "render_frontmatter" => (
528 "ok",
529 render_frontmatter(content, a.get("path").map(String::as_str).unwrap_or("")),
530 ),
531 // Through the rendered form for `render_table_get`'s reason — it is
532 // what a model receives — and through the structure as well, because
533 // that is what `keys --json` carries and `grade.py` scores. `state` and
534 // `format` appear in neither rendering, so a port that got them wrong
535 // would pass on the text alone.
536 "frontmatter_get" => {
537 let path = a.get("path").map(String::as_str).unwrap_or("");
538 // Absent and an explicit `null` are the same call here, which is
539 // the asymmetry with `frontmatter-set` the oracle spells with a
540 // `key=None` default. Parsing gives `Value::Null` for the second
541 // and the op filters it, so both arrive as no key.
542 let key = match a.get("key") {
543 None => None,
544 Some(text) => match json::parse(text) {
545 Some(v) => Some(v),
546 None => return ("ok", format!("PARSE-FAILED {text}")),
547 },
548 };
549 let got = match frontmatter_get(content, key.as_ref()) {
550 Ok(g) => g,
551 Err(e) => return ("err", e.0),
552 };
553 let text = match render_frontmatter_get(content, path, key.as_ref()) {
554 Ok(t) => t,
555 Err(e) => return ("err", e.0),
556 };
557 let mut dump = vec![format!(
558 "{}|{}|{}",
559 got.state,
560 match got.format {
561 Some(Fmt::Yaml) => "yaml",
562 Some(Fmt::Toml) => "toml",
563 // Python's `None`, printed as an f-string prints it: the
564 // oracle's dict holds the parser's `fmt` unconverted, and
565 // an absent block has no format at all.
566 None => "None",
567 },
568 got.keys.len()
569 )];
570 for k in &got.keys {
571 dump.push(format!(
572 "{}|{}|{}|{}",
573 k.path,
574 k.kind,
575 esc(&k.value),
576 k.lines
577 ));
578 }
579 dump.push("--".to_string());
580 dump.push(text);
581 ("ok", dump.join("\n"))
582 }
583 // The list summary, field by field. The rendering is compared too, one
584 // case below -- it is the Arm B prompt, so a divergence in it is a
585 // divergence in what the model was measured on, not in a debug format.
586 "list_lists" => {
587 let dump = list_lists(content)
588 .iter()
589 .map(|e| {
590 format!(
591 "{}|{}|{}|{}|{}|{}|{}|{}",
592 e.ordinal, e.heading, e.kind, e.marker, e.items, e.levels, e.loose, e.tasks
593 )
594 })
595 .collect::<Vec<_>>()
596 .join("\n");
597 ("ok", dump)
598 }
599 "render_list_summary" => (
600 "ok",
601 render_list_summary(content, a.get("path").map(String::as_str).unwrap_or("")),
602 ),
603 // The section outline, field by field, and its rendering one case
604 // below. The render is the section family's prompt context, so a
605 // divergence in it is a divergence in what a model was measured on.
606 "section_outline" => {
607 let dump = section_outline(content)
608 .iter()
609 .map(|e| {
610 format!(
611 "{}|{}|{}|{}|{}|{}|{}|{}",
612 e.level,
613 e.path,
614 e.text,
615 e.style,
616 e.ordinal,
617 e.unique,
618 e.has_body,
619 e.subsections
620 )
621 })
622 .collect::<Vec<_>>()
623 .join("\n");
624 ("ok", dump)
625 }
626 "render_section_outline" => (
627 "ok",
628 render_section_outline(content, a.get("path").map(String::as_str).unwrap_or("")),
629 ),
630 // Three ends, not one: `own_end` and `end` are the field the ops
631 // disagree about, so a resolver that found the right section with the
632 // wrong subtree boundary would pass a `start`-only comparison.
633 "resolve_section" => match resolve_section(content, §ion_address(a)) {
634 Ok(s) => (
635 "ok",
636 format!(
637 "{}|{}|{}|{}|{}",
638 s.start, s.heading_end, s.own_end, s.end, s.level
639 ),
640 ),
641 Err(e) => ("err", e.0),
642 },
643 "resolve_list" => match resolve_list(content, &list_address(a)) {
644 Ok(l) => ("ok", format!("{}|{}", l.start, l.end)),
645 Err(e) => ("err", e.0),
646 },
647 "resolve_item" => match resolve_list(content, &list_address(a))
648 .and_then(|l| resolve_item(&l, a.get("item").map(String::as_str), "item"))
649 {
650 Ok(i) => ("ok", i.to_string()),
651 Err(e) => ("err", e.0),
652 },
653 "inert_headings" => {
654 let dump = inert_headings(content)
655 .iter()
656 .map(|h| format!("{}|{}|{}", h.line, h.reason, h.text))
657 .collect::<Vec<_>>()
658 .join("\n");
659 ("ok", dump)
660 }
661 "resolve_table" => match resolve_table(content, &address(a)) {
662 Ok(t) => ("ok", format!("{}|{}", t.start, t.end)),
663 Err(e) => ("err", e.0),
664 },
665 "resolve_row" => match resolve_table(content, &address(a))
666 .and_then(|t| resolve_row(&t, &where_pairs(a)))
667 {
668 Ok(i) => ("ok", i.to_string()),
669 Err(e) => ("err", e.0),
670 },
671 "add_row" => match table_add_row(content, &address(a), &values(a), position(a).as_ref()) {
672 Ok(s) => ("ok", s),
673 Err(e) => ("err", e.0),
674 },
675 "update_cell" => match table_update_cell(
676 content,
677 &address(a),
678 &where_pairs(a),
679 // Both raw, and both defaulted to "" -- the Python side passes
680 // `args.get("column", "")` and `args.get("value", "")`, so an absent
681 // argument is the empty string on this side too.
682 Some(&json::Value::Str(
683 a.get("column").cloned().unwrap_or_default(),
684 )),
685 Some(&json::Value::Str(
686 a.get("value").cloned().unwrap_or_default(),
687 )),
688 ) {
689 Ok(s) => ("ok", s),
690 Err(e) => ("err", e.0),
691 },
692 // The read op, through its rendered form -- which is the whole result:
693 // heading, matched/total counts, columns and every cell all appear in
694 // the text, so comparing the render compares the struct behind it.
695 // Comparing `TableRows` field by field would test a debug format that
696 // no caller ever sees.
697 "table_get" => {
698 // Two wire formats, one op. The flat one is what every table case
699 // uses; `args` is the typed hatch, and a case carrying it takes the
700 // address from there too, or an untyped address would silently
701 // override the typed one.
702 let (addr, filt) = match typed_args(a) {
703 Some(v) => (typed_address(v.get("table")), typed_filter(&v)),
704 None => (address(a), filter_arg(a)),
705 };
706 match render_table_get(content, &addr, filt.as_ref()) {
707 Ok(s) => ("ok", s),
708 Err(e) => ("err", e.0),
709 }
710 }
711 "delete_row" => match table_delete_row(content, &address(a), &where_pairs(a)) {
712 Ok(s) => ("ok", s),
713 Err(e) => ("err", e.0),
714 },
715 // The whole dispatch, arguments and all -- the op name and the raw
716 // argument text, exactly as `difftest.py` hands them to `F.apply_op`.
717 // What is compared is the message a model would actually receive, so
718 // the argument-checking *order* (dispatch.rs's whole reason to exist)
719 // is under test here and nowhere else.
720 "apply_op" => {
721 let name = a.get("opname").map(String::as_str).unwrap_or("");
722 let text = a.get("args").map(String::as_str).unwrap_or("ABSENT");
723 let parsed = if text == "ABSENT" {
724 None
725 } else {
726 json::parse(text)
727 };
728 // Every DISPATCH_ARGS entry but `ABSENT` is valid JSON by
729 // construction, so a `None` here is a `parse` bug. Saying so beats
730 // passing `None` on and quietly testing the absent-argument path.
731 if text != "ABSENT" && parsed.is_none() {
732 ("ok", format!("PARSE-FAILED {text}"))
733 } else {
734 match apply_op(content, name, parsed.as_ref()) {
735 Ok(s) => ("ok", s),
736 Err(e) => ("err", e.0),
737 }
738 }
739 }
740 // `describe_change` needs two documents, and a case carries one
741 // fixture. So it carries the *op* that produces the second: both sides
742 // apply it and describe before-vs-after, which keeps the wire cheap and
743 // reuses a document the corpus already vouches for. A refusal is
744 // returned as itself — those messages are compared by the `apply_op`
745 // family anyway, so nothing is lost and no case is wasted.
746 //
747 // `after` is the escape hatch for the branches no op reaches. It rides
748 // as a JSON string because a raw newline would break the line-framed
749 // case file, which is the same reason `apply_op` passes its arguments
750 // that way.
751 "describe_change" => {
752 if let Some(text) = a.get("after") {
753 return match json::parse(text) {
754 Some(json::Value::Str(s)) => ("ok", describe_change(content, &s)),
755 _ => ("ok", format!("PARSE-FAILED {text}")),
756 };
757 }
758 let name = a.get("opname").map(String::as_str).unwrap_or("");
759 let arg = a.get("args").map(String::as_str).unwrap_or("ABSENT");
760 let parsed = if arg == "ABSENT" {
761 None
762 } else {
763 json::parse(arg)
764 };
765 if arg != "ABSENT" && parsed.is_none() {
766 ("ok", format!("PARSE-FAILED {arg}"))
767 } else {
768 match apply_op(content, name, parsed.as_ref()) {
769 Ok(after) => ("ok", describe_change(content, &after)),
770 Err(e) => ("err", e.0),
771 }
772 }
773 }
774 other => ("err", format!("unknown op {other}")),
775 }
776}pub fn cells(&self, line: &str) -> Vec<String>
pub fn columns(&self) -> Vec<String>
pub fn rows(&self) -> Vec<Vec<String>>
Sourcepub fn has_tabs(&self) -> bool
pub fn has_tabs(&self) -> bool
True if any of the table’s lines contains a tab.
corpus/hazards/whitespace.md pads its cells with tabs. Those cells have
equal character counts, so the alignment check reports aligned and the
re-pad path rewrites every tab to spaces — a silent whitespace rewrite of
lines nobody asked to touch. Character-count alignment is the only kind
that can be maintained arithmetically; tab alignment depends on a tab
stop this tool does not know. So a tab-containing table is treated as
ragged, following §5.2’s rule that a table whose alignment cannot be
verified counts as ragged.
Examples found in repository?
340fn run(content: &str, op: &str, a: &Args) -> (&'static str, String) {
341 match op {
342 "py_repr" => {
343 let text = a.get("text").map(String::as_str).unwrap_or("");
344 match json::parse(text) {
345 None => ("ok", "UNPARSED".to_string()),
346 Some(v) => ("ok", json::py_repr(&v)),
347 }
348 }
349 "check_args" => {
350 let f = a.get("fn").map(String::as_str).unwrap_or("");
351 let text = a.get("text").map(String::as_str).unwrap_or("");
352 match arg_case(f, text) {
353 Ok(s) => ("ok", s),
354 Err(e) => ("err", e.message().to_string()),
355 }
356 }
357 "render_table_list" => (
358 "ok",
359 render_table_list(content, a.get("path").map(String::as_str).unwrap_or("")),
360 ),
361 "list_tables" => {
362 let dump = list_tables(content)
363 .iter()
364 .map(|e| {
365 format!(
366 "{}|{}|{}|{}|{}",
367 e.ordinal,
368 e.heading,
369 e.caption,
370 e.columns.join(" | "),
371 e.rows
372 )
373 })
374 .collect::<Vec<_>>()
375 .join("\n");
376 ("ok", dump)
377 }
378 "find_tables" => {
379 let dump = find_tables(content)
380 .iter()
381 .map(|t| format!("{}|{}|{}|{}", t.start, t.end, t.is_aligned(), t.has_tabs()))
382 .collect::<Vec<_>>()
383 .join("\n");
384 ("ok", dump)
385 }
386 "find_sections" => {
387 let dump = find_sections(content)
388 .iter()
389 .map(|s| {
390 format!(
391 "{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
392 s.start,
393 s.heading_end,
394 s.own_end,
395 s.end,
396 s.level,
397 s.style.as_str(),
398 s.gap_after,
399 s.parent.map(|p| p as isize).unwrap_or(-1),
400 esc(&s.indent),
401 s.marker,
402 esc(&s.space),
403 s.closing,
404 esc(&s.raw_text),
405 s.path.join(" > ")
406 )
407 })
408 .collect::<Vec<_>>()
409 .join("\n");
410 ("ok", dump)
411 }
412 // The list parser, field by field, for the reason `find_sections` is
413 // dumped: an op suite can agree everywhere and still be sitting on a
414 // parser that disagrees about where a list ends. One line per list,
415 // then one indented line per item -- `own_end` and `parent` are in
416 // there because nothing else reads them, so nothing else would notice.
417 "find_lists" => {
418 let mut dump: Vec<String> = Vec::new();
419 for l in find_lists(content) {
420 dump.push(format!(
421 "{}|{}|{}|{}|{}|{}",
422 l.start,
423 l.end,
424 l.ordered,
425 l.bullet,
426 esc(&l.indent),
427 l.loose
428 ));
429 for it in &l.items {
430 dump.push(format!(
431 " {}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
432 it.start,
433 it.own_end,
434 it.end,
435 it.depth,
436 esc(&it.indent),
437 it.marker,
438 it.ordered,
439 opt(it.number.map(|n| n.to_string())),
440 opt(it.delim.map(|c| c.to_string())),
441 opt(it.checkbox.map(|c| c.to_string())),
442 it.parent.map(|p| p as isize).unwrap_or(-1),
443 esc(&it.text)
444 ));
445 }
446 }
447 ("ok", dump.join("\n"))
448 }
449 // The frontmatter parser, field by field, for the reason the other two
450 // are dumped -- plus `rebuilt()`, which no other dump has an analogue
451 // for. Every edit in the family goes out through it, so comparing it
452 // here catches a gap or padding divergence on a line no op in the suite
453 // happens to rewrite.
454 // `parse_path`, typed and round-tripped. Its own case op because the
455 // parser only ever hands out paths it built itself, so the refusal
456 // branch and the index arithmetic are reachable from no fixture.
457 "parse_path" => match parse_path(a.get("text").map(String::as_str).unwrap_or("")) {
458 None => ("ok", "NONE".to_string()),
459 Some(p) => {
460 let kinds = p
461 .iter()
462 .map(|s| match s {
463 Seg::Index(n) => format!("i:{n}"),
464 Seg::Key(k) => format!("k:{k}"),
465 })
466 .collect::<Vec<_>>()
467 .join("|");
468 ("ok", format!("{}\n{kinds}", format_path(&p)))
469 }
470 },
471 "find_frontmatter" => {
472 let fm = find_frontmatter(content);
473 if !fm.present {
474 return ("ok", "absent".to_string());
475 }
476 let mut dump = vec![format!(
477 "{}|{}|{}|{}|{}|{}",
478 match fm.fmt {
479 Some(Fmt::Yaml) => "yaml",
480 _ => "toml",
481 },
482 fm.start,
483 fm.end,
484 fm.delim,
485 fm.close,
486 esc(&fm.eol)
487 )];
488 for e in &fm.entries {
489 dump.push(format!(
490 " {}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
491 format_path(&e.path),
492 e.line,
493 e.end,
494 e.kind.as_str(),
495 esc(&e.prefix),
496 esc(&e.key_text),
497 esc(&e.gap),
498 esc(&e.value),
499 esc(&e.pad),
500 esc(&e.comment),
501 esc(&e.eol),
502 esc(&e.rebuilt(None))
503 ));
504 }
505 ("ok", dump.join("\n"))
506 }
507 // `describe_change`'s case, for the family it does not describe. The
508 // same shape deliberately: both sides apply the op and describe
509 // before-vs-after, so a divergence in either half shows here.
510 "describe_front" => {
511 let name = a.get("opname").map(String::as_str).unwrap_or("");
512 let arg = a.get("args").map(String::as_str).unwrap_or("ABSENT");
513 let parsed = if arg == "ABSENT" {
514 None
515 } else {
516 json::parse(arg)
517 };
518 if arg != "ABSENT" && parsed.is_none() {
519 ("ok", format!("PARSE-FAILED {arg}"))
520 } else {
521 match apply_op(content, name, parsed.as_ref()) {
522 Ok(after) => ("ok", describe_frontmatter_change(content, &after)),
523 Err(e) => ("err", e.0),
524 }
525 }
526 }
527 "render_frontmatter" => (
528 "ok",
529 render_frontmatter(content, a.get("path").map(String::as_str).unwrap_or("")),
530 ),
531 // Through the rendered form for `render_table_get`'s reason — it is
532 // what a model receives — and through the structure as well, because
533 // that is what `keys --json` carries and `grade.py` scores. `state` and
534 // `format` appear in neither rendering, so a port that got them wrong
535 // would pass on the text alone.
536 "frontmatter_get" => {
537 let path = a.get("path").map(String::as_str).unwrap_or("");
538 // Absent and an explicit `null` are the same call here, which is
539 // the asymmetry with `frontmatter-set` the oracle spells with a
540 // `key=None` default. Parsing gives `Value::Null` for the second
541 // and the op filters it, so both arrive as no key.
542 let key = match a.get("key") {
543 None => None,
544 Some(text) => match json::parse(text) {
545 Some(v) => Some(v),
546 None => return ("ok", format!("PARSE-FAILED {text}")),
547 },
548 };
549 let got = match frontmatter_get(content, key.as_ref()) {
550 Ok(g) => g,
551 Err(e) => return ("err", e.0),
552 };
553 let text = match render_frontmatter_get(content, path, key.as_ref()) {
554 Ok(t) => t,
555 Err(e) => return ("err", e.0),
556 };
557 let mut dump = vec![format!(
558 "{}|{}|{}",
559 got.state,
560 match got.format {
561 Some(Fmt::Yaml) => "yaml",
562 Some(Fmt::Toml) => "toml",
563 // Python's `None`, printed as an f-string prints it: the
564 // oracle's dict holds the parser's `fmt` unconverted, and
565 // an absent block has no format at all.
566 None => "None",
567 },
568 got.keys.len()
569 )];
570 for k in &got.keys {
571 dump.push(format!(
572 "{}|{}|{}|{}",
573 k.path,
574 k.kind,
575 esc(&k.value),
576 k.lines
577 ));
578 }
579 dump.push("--".to_string());
580 dump.push(text);
581 ("ok", dump.join("\n"))
582 }
583 // The list summary, field by field. The rendering is compared too, one
584 // case below -- it is the Arm B prompt, so a divergence in it is a
585 // divergence in what the model was measured on, not in a debug format.
586 "list_lists" => {
587 let dump = list_lists(content)
588 .iter()
589 .map(|e| {
590 format!(
591 "{}|{}|{}|{}|{}|{}|{}|{}",
592 e.ordinal, e.heading, e.kind, e.marker, e.items, e.levels, e.loose, e.tasks
593 )
594 })
595 .collect::<Vec<_>>()
596 .join("\n");
597 ("ok", dump)
598 }
599 "render_list_summary" => (
600 "ok",
601 render_list_summary(content, a.get("path").map(String::as_str).unwrap_or("")),
602 ),
603 // The section outline, field by field, and its rendering one case
604 // below. The render is the section family's prompt context, so a
605 // divergence in it is a divergence in what a model was measured on.
606 "section_outline" => {
607 let dump = section_outline(content)
608 .iter()
609 .map(|e| {
610 format!(
611 "{}|{}|{}|{}|{}|{}|{}|{}",
612 e.level,
613 e.path,
614 e.text,
615 e.style,
616 e.ordinal,
617 e.unique,
618 e.has_body,
619 e.subsections
620 )
621 })
622 .collect::<Vec<_>>()
623 .join("\n");
624 ("ok", dump)
625 }
626 "render_section_outline" => (
627 "ok",
628 render_section_outline(content, a.get("path").map(String::as_str).unwrap_or("")),
629 ),
630 // Three ends, not one: `own_end` and `end` are the field the ops
631 // disagree about, so a resolver that found the right section with the
632 // wrong subtree boundary would pass a `start`-only comparison.
633 "resolve_section" => match resolve_section(content, §ion_address(a)) {
634 Ok(s) => (
635 "ok",
636 format!(
637 "{}|{}|{}|{}|{}",
638 s.start, s.heading_end, s.own_end, s.end, s.level
639 ),
640 ),
641 Err(e) => ("err", e.0),
642 },
643 "resolve_list" => match resolve_list(content, &list_address(a)) {
644 Ok(l) => ("ok", format!("{}|{}", l.start, l.end)),
645 Err(e) => ("err", e.0),
646 },
647 "resolve_item" => match resolve_list(content, &list_address(a))
648 .and_then(|l| resolve_item(&l, a.get("item").map(String::as_str), "item"))
649 {
650 Ok(i) => ("ok", i.to_string()),
651 Err(e) => ("err", e.0),
652 },
653 "inert_headings" => {
654 let dump = inert_headings(content)
655 .iter()
656 .map(|h| format!("{}|{}|{}", h.line, h.reason, h.text))
657 .collect::<Vec<_>>()
658 .join("\n");
659 ("ok", dump)
660 }
661 "resolve_table" => match resolve_table(content, &address(a)) {
662 Ok(t) => ("ok", format!("{}|{}", t.start, t.end)),
663 Err(e) => ("err", e.0),
664 },
665 "resolve_row" => match resolve_table(content, &address(a))
666 .and_then(|t| resolve_row(&t, &where_pairs(a)))
667 {
668 Ok(i) => ("ok", i.to_string()),
669 Err(e) => ("err", e.0),
670 },
671 "add_row" => match table_add_row(content, &address(a), &values(a), position(a).as_ref()) {
672 Ok(s) => ("ok", s),
673 Err(e) => ("err", e.0),
674 },
675 "update_cell" => match table_update_cell(
676 content,
677 &address(a),
678 &where_pairs(a),
679 // Both raw, and both defaulted to "" -- the Python side passes
680 // `args.get("column", "")` and `args.get("value", "")`, so an absent
681 // argument is the empty string on this side too.
682 Some(&json::Value::Str(
683 a.get("column").cloned().unwrap_or_default(),
684 )),
685 Some(&json::Value::Str(
686 a.get("value").cloned().unwrap_or_default(),
687 )),
688 ) {
689 Ok(s) => ("ok", s),
690 Err(e) => ("err", e.0),
691 },
692 // The read op, through its rendered form -- which is the whole result:
693 // heading, matched/total counts, columns and every cell all appear in
694 // the text, so comparing the render compares the struct behind it.
695 // Comparing `TableRows` field by field would test a debug format that
696 // no caller ever sees.
697 "table_get" => {
698 // Two wire formats, one op. The flat one is what every table case
699 // uses; `args` is the typed hatch, and a case carrying it takes the
700 // address from there too, or an untyped address would silently
701 // override the typed one.
702 let (addr, filt) = match typed_args(a) {
703 Some(v) => (typed_address(v.get("table")), typed_filter(&v)),
704 None => (address(a), filter_arg(a)),
705 };
706 match render_table_get(content, &addr, filt.as_ref()) {
707 Ok(s) => ("ok", s),
708 Err(e) => ("err", e.0),
709 }
710 }
711 "delete_row" => match table_delete_row(content, &address(a), &where_pairs(a)) {
712 Ok(s) => ("ok", s),
713 Err(e) => ("err", e.0),
714 },
715 // The whole dispatch, arguments and all -- the op name and the raw
716 // argument text, exactly as `difftest.py` hands them to `F.apply_op`.
717 // What is compared is the message a model would actually receive, so
718 // the argument-checking *order* (dispatch.rs's whole reason to exist)
719 // is under test here and nowhere else.
720 "apply_op" => {
721 let name = a.get("opname").map(String::as_str).unwrap_or("");
722 let text = a.get("args").map(String::as_str).unwrap_or("ABSENT");
723 let parsed = if text == "ABSENT" {
724 None
725 } else {
726 json::parse(text)
727 };
728 // Every DISPATCH_ARGS entry but `ABSENT` is valid JSON by
729 // construction, so a `None` here is a `parse` bug. Saying so beats
730 // passing `None` on and quietly testing the absent-argument path.
731 if text != "ABSENT" && parsed.is_none() {
732 ("ok", format!("PARSE-FAILED {text}"))
733 } else {
734 match apply_op(content, name, parsed.as_ref()) {
735 Ok(s) => ("ok", s),
736 Err(e) => ("err", e.0),
737 }
738 }
739 }
740 // `describe_change` needs two documents, and a case carries one
741 // fixture. So it carries the *op* that produces the second: both sides
742 // apply it and describe before-vs-after, which keeps the wire cheap and
743 // reuses a document the corpus already vouches for. A refusal is
744 // returned as itself — those messages are compared by the `apply_op`
745 // family anyway, so nothing is lost and no case is wasted.
746 //
747 // `after` is the escape hatch for the branches no op reaches. It rides
748 // as a JSON string because a raw newline would break the line-framed
749 // case file, which is the same reason `apply_op` passes its arguments
750 // that way.
751 "describe_change" => {
752 if let Some(text) = a.get("after") {
753 return match json::parse(text) {
754 Some(json::Value::Str(s)) => ("ok", describe_change(content, &s)),
755 _ => ("ok", format!("PARSE-FAILED {text}")),
756 };
757 }
758 let name = a.get("opname").map(String::as_str).unwrap_or("");
759 let arg = a.get("args").map(String::as_str).unwrap_or("ABSENT");
760 let parsed = if arg == "ABSENT" {
761 None
762 } else {
763 json::parse(arg)
764 };
765 if arg != "ABSENT" && parsed.is_none() {
766 ("ok", format!("PARSE-FAILED {arg}"))
767 } else {
768 match apply_op(content, name, parsed.as_ref()) {
769 Ok(after) => ("ok", describe_change(content, &after)),
770 Err(e) => ("err", e.0),
771 }
772 }
773 }
774 other => ("err", format!("unknown op {other}")),
775 }
776}