Skip to main content

max / shop

shop-vt: 21 tests + fix OSC empty-leading-param bug Recording TestPerform + coverage for the sequences that matter: - Ground state: ASCII print, C0 exec, UTF-8 2/3/4-byte, chunk-boundary UTF-8 restart. - CSI: single/multi params, `:`-subparams, private markers, no-params, space intermediate for DECSCUSR. - OSC: BEL and ST termination. - APC: BEL and ST termination, chunk-boundary reassembly. - ESC: bare-letter dispatch. - DCS: hook / put / unhook / trailing esc. - Recovery: CAN aborts escape, excess intermediates set ignore. Test caught a real bug: osc_start seeded a sentinel `(0,0)` in osc_params, then the ; handler *pushed* the closing pair instead of finalizing the sentinel — producing a spurious leading `[]` field on every OSC. shop-grid's title handler reads params[0] for the OSC id, so `\e]0;title\a` has been silently no-op-ing since the "minor batch" commit. Fixed by having ; finalize the currently-open range and open a new one, and by renaming `osc_end` -> `osc_close_current` for clarity.
Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-24 00:54 UTC
Signed with PGP, not checked
Commit: dcd1c0a46536cfe86b7e1dfd15b6adaefd7ec716
Parent: fe9badd
1 file changed, +271 insertions, -17 deletions
@@ -426,14 +426,14 @@
426 426 fn osc_string<P: Perform>(&mut self, perform: &mut P, b: u8) {
427 427 match b {
428 428 0x07 => {
429 - self.osc_end();
429 + self.osc_close_current();
430 430 self.dispatch_osc(perform, true);
431 431 self.state = State::Ground;
432 432 }
433 433 0x1B => {
434 434 // Possible ST — end the OSC now, transition to Escape so a
435 435 // following `\` gets consumed as a no-op esc_dispatch.
436 - self.osc_end();
436 + self.osc_close_current();
437 437 self.dispatch_osc(perform, false);
438 438 self.prev_string_state = State::OscString;
439 439 self.state = State::Escape;
@@ -441,8 +441,9 @@
441 441 }
442 442 0x3B => {
443 443 // Parameter separator: close current field, open next.
444 - self.osc_params.push((self.osc_last_start(), self.osc_buf.len()));
445 - self.osc_mark_new();
444 + self.osc_close_current();
445 + let end = self.osc_buf.len();
446 + self.osc_params.push((end, end));
446 447 }
447 448 _ => self.osc_buf.push(b),
448 449 }
@@ -451,21 +452,12 @@
451 452 fn osc_start(&mut self) {
452 453 self.osc_buf.clear();
453 454 self.osc_params.clear();
454 - // Open the first parameter.
455 - self.osc_mark_new();
455 + // Open the first parameter with a placeholder end index that
456 + // `osc_close_current` finalizes on ; / BEL / ST.
457 + self.osc_params.push((0, 0));
456 458 }
457 459
458 - fn osc_mark_new(&mut self) {
459 - let start = self.osc_buf.len();
460 - // Sentinel start pair for the current (still-being-written) field.
461 - self.osc_params.push((start, start));
462 - }
463 -
464 - fn osc_last_start(&self) -> usize {
465 - self.osc_params.last().map(|p| p.0).unwrap_or(0)
466 - }
467 -
468 - fn osc_end(&mut self) {
460 + fn osc_close_current(&mut self) {
469 461 if let Some(last) = self.osc_params.last_mut() {
470 462 last.1 = self.osc_buf.len();
471 463 }
@@ -617,3 +609,265 @@
617 609 self.params.clear();
618 610 }
619 611 }
612 +
613 + #[cfg(test)]
614 + mod tests {
615 + use super::*;
616 +
617 + /// Recording Perform: every callback appends a stringly summary so tests
618 + /// can assert on the whole event log.
619 + #[derive(Default)]
620 + struct Rec(Vec<String>);
621 +
622 + impl Perform for Rec {
623 + fn print(&mut self, c: char) {
624 + self.0.push(format!("print({c:?})"));
625 + }
626 + fn execute(&mut self, byte: u8) {
627 + self.0.push(format!("exec({byte:#04x})"));
628 + }
629 + fn csi_dispatch(
630 + &mut self,
631 + params: &Params,
632 + intermediates: &[u8],
633 + ignore: bool,
634 + action: char,
635 + ) {
636 + let p: Vec<Vec<u16>> = params.iter().map(|s| s.to_vec()).collect();
637 + self.0.push(format!(
638 + "csi(params={p:?}, intermediates={:?}, ignore={ignore}, action={action:?})",
639 + intermediates
640 + ));
641 + }
642 + fn esc_dispatch(&mut self, intermediates: &[u8], ignore: bool, byte: u8) {
643 + self.0.push(format!(
644 + "esc(intermediates={intermediates:?}, ignore={ignore}, byte={byte:#04x})"
645 + ));
646 + }
647 + fn osc_dispatch(&mut self, params: &[&[u8]], bell_terminated: bool) {
648 + let p: Vec<Vec<u8>> = params.iter().map(|s| s.to_vec()).collect();
649 + self.0.push(format!("osc(params={p:?}, bell={bell_terminated})"));
650 + }
651 + fn apc_dispatch(&mut self, data: &[u8]) {
652 + self.0.push(format!("apc({data:?})"));
653 + }
654 + fn hook(
655 + &mut self,
656 + params: &Params,
657 + intermediates: &[u8],
658 + ignore: bool,
659 + action: char,
660 + ) {
661 + let p: Vec<Vec<u16>> = params.iter().map(|s| s.to_vec()).collect();
662 + self.0.push(format!(
663 + "hook(params={p:?}, intermediates={intermediates:?}, ignore={ignore}, action={action:?})"
664 + ));
665 + }
666 + fn put(&mut self, byte: u8) {
667 + self.0.push(format!("put({byte:#04x})"));
668 + }
669 + fn unhook(&mut self) {
670 + self.0.push("unhook".into());
671 + }
672 + }
673 +
674 + fn run(bytes: &[u8]) -> Vec<String> {
675 + let mut p = Parser::new();
676 + let mut r = Rec::default();
677 + p.advance(&mut r, bytes);
678 + r.0
679 + }
680 +
681 + // ---- Ground / print / execute -------------------------------------
682 +
683 + #[test]
684 + fn plain_ascii_prints() {
685 + assert_eq!(
686 + run(b"abc"),
687 + vec!["print('a')", "print('b')", "print('c')"]
688 + );
689 + }
690 +
691 + #[test]
692 + fn c0_control_executes() {
693 + assert_eq!(run(b"\r\n"), vec!["exec(0x0d)", "exec(0x0a)"]);
694 + }
695 +
696 + #[test]
697 + fn utf8_two_byte_prints_one_char() {
698 + // U+00E9 (é) = 0xC3 0xA9
699 + assert_eq!(run(&[0xC3, 0xA9]), vec!["print('é')"]);
700 + }
701 +
702 + #[test]
703 + fn utf8_three_byte_prints_one_char() {
704 + // U+2603 (☃) = 0xE2 0x98 0x83
705 + assert_eq!(run(&[0xE2, 0x98, 0x83]), vec!["print('☃')"]);
706 + }
707 +
708 + #[test]
709 + fn utf8_four_byte_prints_one_char() {
710 + // U+1F980 (🦀) = 0xF0 0x9F 0xA6 0x80
711 + assert_eq!(run(&[0xF0, 0x9F, 0xA6, 0x80]), vec!["print('🦀')"]);
712 + }
713 +
714 + #[test]
715 + fn utf8_survives_chunk_boundary() {
716 + let mut p = Parser::new();
717 + let mut r = Rec::default();
718 + // 🦀 split 2 / 2
719 + p.advance(&mut r, &[0xF0, 0x9F]);
720 + p.advance(&mut r, &[0xA6, 0x80]);
721 + assert_eq!(r.0, vec!["print('🦀')"]);
722 + }
723 +
724 + // ---- CSI -----------------------------------------------------------
725 +
726 + #[test]
727 + fn csi_single_param() {
728 + assert_eq!(
729 + run(b"\x1b[10A"),
730 + vec!["csi(params=[[10]], intermediates=[], ignore=false, action='A')"]
731 + );
732 + }
733 +
734 + #[test]
735 + fn csi_multi_params() {
736 + assert_eq!(
737 + run(b"\x1b[1;2;3m"),
738 + vec!["csi(params=[[1], [2], [3]], intermediates=[], ignore=false, action='m')"]
739 + );
740 + }
741 +
742 + #[test]
743 + fn csi_subparams_colon() {
744 + // `\e[38:2::1:2:3m` groups into one param with six subparams.
745 + assert_eq!(
746 + run(b"\x1b[38:2::1:2:3m"),
747 + vec![
748 + "csi(params=[[38, 2, 0, 1, 2, 3]], intermediates=[], ignore=false, action='m')"
749 + ]
750 + );
751 + }
752 +
753 + #[test]
754 + fn csi_private_marker() {
755 + assert_eq!(
756 + run(b"\x1b[?25h"),
757 + vec![
758 + "csi(params=[[25]], intermediates=[63], ignore=false, action='h')"
759 + ]
760 + );
761 + }
762 +
763 + #[test]
764 + fn csi_no_params() {
765 + assert_eq!(
766 + run(b"\x1b[m"),
767 + vec!["csi(params=[], intermediates=[], ignore=false, action='m')"]
768 + );
769 + }
770 +
771 + #[test]
772 + fn csi_with_intermediate_space_q() {
773 + // DECSCUSR: `CSI Ps SP q` — space (0x20) intermediate then 'q'.
774 + assert_eq!(
775 + run(b"\x1b[2 q"),
776 + vec!["csi(params=[[2]], intermediates=[32], ignore=false, action='q')"]
777 + );
778 + }
779 +
780 + // ---- OSC -----------------------------------------------------------
781 +
782 + #[test]
783 + fn osc_st_terminated() {
784 + assert_eq!(
785 + run(b"\x1b]0;title\x1b\\"),
786 + vec![
787 + "osc(params=[[48], [116, 105, 116, 108, 101]], bell=false)",
788 + // The trailing `\` after ESC dispatches as a no-op esc byte.
789 + "esc(intermediates=[], ignore=false, byte=0x5c)"
790 + ]
791 + );
792 + }
793 +
794 + #[test]
795 + fn osc_bel_terminated() {
796 + assert_eq!(
797 + run(b"\x1b]2;shop\x07"),
798 + vec!["osc(params=[[50], [115, 104, 111, 112]], bell=true)"]
799 + );
800 + }
801 +
802 + // ---- APC (kitty graphics) ------------------------------------------
803 +
804 + #[test]
805 + fn apc_st_terminated() {
806 + let events = run(b"\x1b_Ga=T,f=32;PAYLOAD\x1b\\");
807 + assert_eq!(events.len(), 2);
808 + assert!(events[0].starts_with("apc("));
809 + assert!(events[0].contains("71"), "payload should carry 'G' (0x47=71)");
810 + }
811 +
812 + #[test]
813 + fn apc_bel_terminated() {
814 + let events = run(b"\x1b_hello\x07");
815 + assert_eq!(events, vec!["apc([104, 101, 108, 108, 111])"]);
816 + }
817 +
818 + #[test]
819 + fn apc_survives_chunk_boundary() {
820 + let mut p = Parser::new();
821 + let mut r = Rec::default();
822 + p.advance(&mut r, b"\x1b_Ga=");
823 + assert!(r.0.is_empty(), "no dispatch mid-APC");
824 + p.advance(&mut r, b"T;X\x07");
825 + assert_eq!(r.0, vec!["apc([71, 97, 61, 84, 59, 88])"]);
826 + }
827 +
828 + // ---- ESC dispatch --------------------------------------------------
829 +
830 + #[test]
831 + fn esc_bare_letter() {
832 + assert_eq!(
833 + run(b"\x1bM"),
834 + vec!["esc(intermediates=[], ignore=false, byte=0x4d)"]
835 + );
836 + }
837 +
838 + // ---- DCS -----------------------------------------------------------
839 +
840 + #[test]
841 + fn dcs_passthrough_st_terminated() {
842 + // `\eP1$r0m\e\\` — DECRQSS-response shape. `r` triggers hook (into
843 + // passthrough), `0`/`m` are the two put bytes, ESC unhooks, `\` is
844 + // the no-op esc byte closing the ST.
845 + let events = run(b"\x1bP1$r0m\x1b\\");
846 + let names: Vec<&str> = events
847 + .iter()
848 + .map(|s| s.split('(').next().unwrap())
849 + .collect();
850 + assert_eq!(names, vec!["hook", "put", "put", "unhook", "esc"]);
851 + }
852 +
853 + // ---- State recovery ------------------------------------------------
854 +
855 + #[test]
856 + fn cancel_aborts_escape() {
857 + // ESC then CAN (0x18) — the CAN executes and returns to Ground.
858 + let events = run(b"\x1b\x18X");
859 + assert_eq!(events, vec!["exec(0x18)", "print('X')"]);
860 + }
861 +
862 + #[test]
863 + fn csi_ignoring_after_extra_intermediates() {
864 + // Only two intermediates fit; the third sets the ignore flag.
865 + let events = run(b"\x1b[!\"# X");
866 + // The parser should still dispatch on the final byte, with ignore=true.
867 + assert!(
868 + events.last().is_some_and(|s| s.contains("ignore=true")),
869 + "dispatch should carry ignore=true, got {events:?}"
870 + );
871 + }
872 + }
873 +