seqc/
ast.rs

1//! Abstract Syntax Tree for Seq
2//!
3//! Minimal AST sufficient for hello-world and basic programs.
4//! Will be extended as we add more language features.
5
6use crate::types::{Effect, StackType, Type};
7use std::path::PathBuf;
8
9/// Source location for error reporting and tooling
10#[derive(Debug, Clone, PartialEq)]
11pub struct SourceLocation {
12    pub file: PathBuf,
13    /// Start line (0-indexed for LSP compatibility)
14    pub start_line: usize,
15    /// End line (0-indexed, inclusive)
16    pub end_line: usize,
17}
18
19impl SourceLocation {
20    /// Create a new source location with just a single line (for backward compatibility)
21    pub fn new(file: PathBuf, line: usize) -> Self {
22        SourceLocation {
23            file,
24            start_line: line,
25            end_line: line,
26        }
27    }
28
29    /// Create a source location spanning multiple lines
30    pub fn span(file: PathBuf, start_line: usize, end_line: usize) -> Self {
31        debug_assert!(
32            start_line <= end_line,
33            "SourceLocation: start_line ({}) must be <= end_line ({})",
34            start_line,
35            end_line
36        );
37        SourceLocation {
38            file,
39            start_line,
40            end_line,
41        }
42    }
43
44    /// Get the line number (for backward compatibility, returns start_line)
45    pub fn line(&self) -> usize {
46        self.start_line
47    }
48}
49
50impl std::fmt::Display for SourceLocation {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        if self.start_line == self.end_line {
53            write!(f, "{}:{}", self.file.display(), self.start_line + 1)
54        } else {
55            write!(
56                f,
57                "{}:{}-{}",
58                self.file.display(),
59                self.start_line + 1,
60                self.end_line + 1
61            )
62        }
63    }
64}
65
66/// Include statement
67#[derive(Debug, Clone, PartialEq)]
68pub enum Include {
69    /// Standard library include: `include std:http`
70    Std(String),
71    /// Relative path include: `include "my-utils"`
72    Relative(String),
73    /// FFI library include: `include ffi:readline`
74    Ffi(String),
75}
76
77// ============================================================================
78//                     ALGEBRAIC DATA TYPES (ADTs)
79// ============================================================================
80
81/// A field in a union variant
82/// Example: `response-chan: Int`
83#[derive(Debug, Clone, PartialEq)]
84pub struct UnionField {
85    pub name: String,
86    pub type_name: String, // For now, just store the type name as string
87}
88
89/// A variant in a union type
90/// Example: `Get { response-chan: Int }`
91#[derive(Debug, Clone, PartialEq)]
92pub struct UnionVariant {
93    pub name: String,
94    pub fields: Vec<UnionField>,
95    pub source: Option<SourceLocation>,
96}
97
98/// A union type definition
99/// Example:
100/// ```seq
101/// union Message {
102///   Get { response-chan: Int }
103///   Increment { response-chan: Int }
104///   Report { op: Int, delta: Int, total: Int }
105/// }
106/// ```
107#[derive(Debug, Clone, PartialEq)]
108pub struct UnionDef {
109    pub name: String,
110    pub variants: Vec<UnionVariant>,
111    pub source: Option<SourceLocation>,
112}
113
114/// A pattern in a match expression
115/// For Phase 1: just the variant name (stack-based matching)
116/// Later phases will add field bindings: `Get { chan }`
117#[derive(Debug, Clone, PartialEq)]
118pub enum Pattern {
119    /// Match a variant by name, pushing all fields to stack
120    /// Example: `Get ->` pushes response-chan to stack
121    Variant(String),
122
123    /// Match a variant with named field bindings (Phase 5)
124    /// Example: `Get { chan } ->` binds chan to the response-chan field
125    VariantWithBindings { name: String, bindings: Vec<String> },
126}
127
128/// A single arm in a match expression
129#[derive(Debug, Clone, PartialEq)]
130pub struct MatchArm {
131    pub pattern: Pattern,
132    pub body: Vec<Statement>,
133}
134
135#[derive(Debug, Clone, PartialEq)]
136pub struct Program {
137    pub includes: Vec<Include>,
138    pub unions: Vec<UnionDef>,
139    pub words: Vec<WordDef>,
140}
141
142#[derive(Debug, Clone, PartialEq)]
143pub struct WordDef {
144    pub name: String,
145    /// Optional stack effect declaration
146    /// Example: ( ..a Int -- ..a Bool )
147    pub effect: Option<Effect>,
148    pub body: Vec<Statement>,
149    /// Source location for error reporting (collision detection)
150    pub source: Option<SourceLocation>,
151}
152
153/// Source span for a single token or expression
154#[derive(Debug, Clone, PartialEq, Default)]
155pub struct Span {
156    /// Line number (0-indexed)
157    pub line: usize,
158    /// Start column (0-indexed)
159    pub column: usize,
160    /// Length of the span in characters
161    pub length: usize,
162}
163
164impl Span {
165    pub fn new(line: usize, column: usize, length: usize) -> Self {
166        Span {
167            line,
168            column,
169            length,
170        }
171    }
172}
173
174/// Source span for a quotation, supporting multi-line ranges
175#[derive(Debug, Clone, PartialEq, Default)]
176pub struct QuotationSpan {
177    /// Start line (0-indexed)
178    pub start_line: usize,
179    /// Start column (0-indexed)
180    pub start_column: usize,
181    /// End line (0-indexed)
182    pub end_line: usize,
183    /// End column (0-indexed, exclusive)
184    pub end_column: usize,
185}
186
187impl QuotationSpan {
188    pub fn new(start_line: usize, start_column: usize, end_line: usize, end_column: usize) -> Self {
189        QuotationSpan {
190            start_line,
191            start_column,
192            end_line,
193            end_column,
194        }
195    }
196
197    /// Check if a position (line, column) falls within this span
198    pub fn contains(&self, line: usize, column: usize) -> bool {
199        if line < self.start_line || line > self.end_line {
200            return false;
201        }
202        if line == self.start_line && column < self.start_column {
203            return false;
204        }
205        if line == self.end_line && column >= self.end_column {
206            return false;
207        }
208        true
209    }
210}
211
212#[derive(Debug, Clone, PartialEq)]
213pub enum Statement {
214    /// Integer literal: pushes value onto stack
215    IntLiteral(i64),
216
217    /// Floating-point literal: pushes IEEE 754 double onto stack
218    FloatLiteral(f64),
219
220    /// Boolean literal: pushes true/false onto stack
221    BoolLiteral(bool),
222
223    /// String literal: pushes string onto stack
224    StringLiteral(String),
225
226    /// Symbol literal: pushes symbol onto stack
227    /// Syntax: :foo, :some-name, :ok
228    /// Used for dynamic variant construction and SON.
229    /// Note: Symbols are not currently interned (future optimization).
230    Symbol(String),
231
232    /// Word call: calls another word or built-in
233    /// Contains the word name and optional source span for precise diagnostics
234    WordCall { name: String, span: Option<Span> },
235
236    /// Conditional: if/else/then
237    ///
238    /// Pops an integer from the stack (0 = zero, non-zero = non-zero)
239    /// and executes the appropriate branch
240    If {
241        /// Statements to execute when condition is non-zero (the 'then' clause)
242        then_branch: Vec<Statement>,
243        /// Optional statements to execute when condition is zero (the 'else' clause)
244        else_branch: Option<Vec<Statement>>,
245    },
246
247    /// Quotation: [ ... ]
248    ///
249    /// A block of deferred code (quotation/lambda)
250    /// Quotations are first-class values that can be pushed onto the stack
251    /// and executed later with combinators like `call`, `times`, or `while`
252    ///
253    /// The id field is used by the typechecker to track the inferred type
254    /// (Quotation vs Closure) for this quotation. The id is assigned during parsing.
255    /// The span field records the source location for LSP hover support.
256    Quotation {
257        id: usize,
258        body: Vec<Statement>,
259        span: Option<QuotationSpan>,
260    },
261
262    /// Match expression: pattern matching on union types
263    ///
264    /// Pops a union value from the stack and dispatches to the
265    /// appropriate arm based on the variant tag.
266    ///
267    /// Example:
268    /// ```seq
269    /// match
270    ///   Get -> send-response
271    ///   Increment -> do-increment send-response
272    ///   Report -> aggregate-add
273    /// end
274    /// ```
275    Match {
276        /// The match arms in order
277        arms: Vec<MatchArm>,
278    },
279}
280
281impl Program {
282    pub fn new() -> Self {
283        Program {
284            includes: Vec::new(),
285            unions: Vec::new(),
286            words: Vec::new(),
287        }
288    }
289
290    /// Find a union definition by name
291    pub fn find_union(&self, name: &str) -> Option<&UnionDef> {
292        self.unions.iter().find(|u| u.name == name)
293    }
294
295    pub fn find_word(&self, name: &str) -> Option<&WordDef> {
296        self.words.iter().find(|w| w.name == name)
297    }
298
299    /// Validate that all word calls reference either a defined word or a built-in
300    pub fn validate_word_calls(&self) -> Result<(), String> {
301        self.validate_word_calls_with_externals(&[])
302    }
303
304    /// Validate that all word calls reference a defined word, built-in, or external word.
305    ///
306    /// The `external_words` parameter should contain names of words available from
307    /// external sources (e.g., included modules) that should be considered valid.
308    pub fn validate_word_calls_with_externals(
309        &self,
310        external_words: &[&str],
311    ) -> Result<(), String> {
312        // List of known runtime built-ins
313        // IMPORTANT: Keep this in sync with codegen.rs WordCall matching
314        let builtins = [
315            // I/O operations
316            "io.write",
317            "io.write-line",
318            "io.read-line",
319            "io.read-line+",
320            "io.read-n",
321            "int->string",
322            "symbol->string",
323            "string->symbol",
324            // Command-line arguments
325            "args.count",
326            "args.at",
327            // File operations
328            "file.slurp",
329            "file.exists?",
330            "file.for-each-line+",
331            // String operations
332            "string.concat",
333            "string.length",
334            "string.byte-length",
335            "string.char-at",
336            "string.substring",
337            "char->string",
338            "string.find",
339            "string.split",
340            "string.contains",
341            "string.starts-with",
342            "string.empty?",
343            "string.trim",
344            "string.chomp",
345            "string.to-upper",
346            "string.to-lower",
347            "string.equal?",
348            "string.json-escape",
349            "string->int",
350            // Symbol operations
351            "symbol.=",
352            // Encoding operations
353            "encoding.base64-encode",
354            "encoding.base64-decode",
355            "encoding.base64url-encode",
356            "encoding.base64url-decode",
357            "encoding.hex-encode",
358            "encoding.hex-decode",
359            // Crypto operations
360            "crypto.sha256",
361            "crypto.hmac-sha256",
362            "crypto.constant-time-eq",
363            "crypto.random-bytes",
364            "crypto.uuid4",
365            // HTTP client operations
366            "http.get",
367            "http.post",
368            "http.put",
369            "http.delete",
370            // List operations
371            "list.make",
372            "list.push",
373            "list.get",
374            "list.set",
375            "list.map",
376            "list.filter",
377            "list.fold",
378            "list.each",
379            "list.length",
380            "list.empty?",
381            // Map operations
382            "map.make",
383            "map.get",
384            "map.set",
385            "map.has?",
386            "map.remove",
387            "map.keys",
388            "map.values",
389            "map.size",
390            "map.empty?",
391            // Variant operations
392            "variant.field-count",
393            "variant.tag",
394            "variant.field-at",
395            "variant.append",
396            "variant.last",
397            "variant.init",
398            "variant.make-0",
399            "variant.make-1",
400            "variant.make-2",
401            "variant.make-3",
402            "variant.make-4",
403            // SON wrap aliases
404            "wrap-0",
405            "wrap-1",
406            "wrap-2",
407            "wrap-3",
408            "wrap-4",
409            // Integer arithmetic operations
410            "i.add",
411            "i.subtract",
412            "i.multiply",
413            "i.divide",
414            "i.modulo",
415            // Terse integer arithmetic
416            "i.+",
417            "i.-",
418            "i.*",
419            "i./",
420            "i.%",
421            // Integer comparison operations (return 0 or 1)
422            "i.=",
423            "i.<",
424            "i.>",
425            "i.<=",
426            "i.>=",
427            "i.<>",
428            // Integer comparison operations (verbose form)
429            "i.eq",
430            "i.lt",
431            "i.gt",
432            "i.lte",
433            "i.gte",
434            "i.neq",
435            // Stack operations (simple - no parameters)
436            "dup",
437            "drop",
438            "swap",
439            "over",
440            "rot",
441            "nip",
442            "tuck",
443            "2dup",
444            "3drop",
445            "pick",
446            "roll",
447            // Boolean operations
448            "and",
449            "or",
450            "not",
451            // Bitwise operations
452            "band",
453            "bor",
454            "bxor",
455            "bnot",
456            "shl",
457            "shr",
458            "popcount",
459            "clz",
460            "ctz",
461            "int-bits",
462            // Channel operations
463            "chan.make",
464            "chan.send",
465            "chan.receive",
466            "chan.close",
467            "chan.yield",
468            // Quotation operations
469            "call",
470            "times",
471            "while",
472            "until",
473            "strand.spawn",
474            "strand.weave",
475            "strand.resume",
476            "strand.weave-cancel",
477            "yield",
478            "cond",
479            // TCP operations
480            "tcp.listen",
481            "tcp.accept",
482            "tcp.read",
483            "tcp.write",
484            "tcp.close",
485            // OS operations
486            "os.getenv",
487            "os.home-dir",
488            "os.current-dir",
489            "os.path-exists",
490            "os.path-is-file",
491            "os.path-is-dir",
492            "os.path-join",
493            "os.path-parent",
494            "os.path-filename",
495            "os.exit",
496            "os.name",
497            "os.arch",
498            // Float arithmetic operations (verbose form)
499            "f.add",
500            "f.subtract",
501            "f.multiply",
502            "f.divide",
503            // Float arithmetic operations (terse form)
504            "f.+",
505            "f.-",
506            "f.*",
507            "f./",
508            // Float comparison operations (symbol form)
509            "f.=",
510            "f.<",
511            "f.>",
512            "f.<=",
513            "f.>=",
514            "f.<>",
515            // Float comparison operations (verbose form)
516            "f.eq",
517            "f.lt",
518            "f.gt",
519            "f.lte",
520            "f.gte",
521            "f.neq",
522            // Type conversions
523            "int->float",
524            "float->int",
525            "float->string",
526            "string->float",
527            // Test framework operations
528            "test.init",
529            "test.finish",
530            "test.has-failures",
531            "test.assert",
532            "test.assert-not",
533            "test.assert-eq",
534            "test.assert-eq-str",
535            "test.fail",
536            "test.pass-count",
537            "test.fail-count",
538            // Time operations
539            "time.now",
540            "time.nanos",
541            "time.sleep-ms",
542            // SON serialization
543            "son.dump",
544            "son.dump-pretty",
545            // Stack introspection (for REPL)
546            "stack.dump",
547        ];
548
549        for word in &self.words {
550            self.validate_statements(&word.body, &word.name, &builtins, external_words)?;
551        }
552
553        Ok(())
554    }
555
556    /// Helper to validate word calls in a list of statements (recursively)
557    fn validate_statements(
558        &self,
559        statements: &[Statement],
560        word_name: &str,
561        builtins: &[&str],
562        external_words: &[&str],
563    ) -> Result<(), String> {
564        for statement in statements {
565            match statement {
566                Statement::WordCall { name, .. } => {
567                    // Check if it's a built-in
568                    if builtins.contains(&name.as_str()) {
569                        continue;
570                    }
571                    // Check if it's a user-defined word
572                    if self.find_word(name).is_some() {
573                        continue;
574                    }
575                    // Check if it's an external word (from includes)
576                    if external_words.contains(&name.as_str()) {
577                        continue;
578                    }
579                    // Undefined word!
580                    return Err(format!(
581                        "Undefined word '{}' called in word '{}'. \
582                         Did you forget to define it or misspell a built-in?",
583                        name, word_name
584                    ));
585                }
586                Statement::If {
587                    then_branch,
588                    else_branch,
589                } => {
590                    // Recursively validate both branches
591                    self.validate_statements(then_branch, word_name, builtins, external_words)?;
592                    if let Some(eb) = else_branch {
593                        self.validate_statements(eb, word_name, builtins, external_words)?;
594                    }
595                }
596                Statement::Quotation { body, .. } => {
597                    // Recursively validate quotation body
598                    self.validate_statements(body, word_name, builtins, external_words)?;
599                }
600                Statement::Match { arms } => {
601                    // Recursively validate each match arm's body
602                    for arm in arms {
603                        self.validate_statements(&arm.body, word_name, builtins, external_words)?;
604                    }
605                }
606                _ => {} // Literals don't need validation
607            }
608        }
609        Ok(())
610    }
611
612    /// Generate constructor words for all union definitions
613    ///
614    /// Maximum number of fields a variant can have (limited by runtime support)
615    pub const MAX_VARIANT_FIELDS: usize = 4;
616
617    /// For each union variant, generates a `Make-VariantName` word that:
618    /// 1. Takes the variant's field values from the stack
619    /// 2. Pushes the variant tag (index)
620    /// 3. Calls the appropriate `variant.make-N` builtin
621    ///
622    /// Example: For `union Message { Get { chan: Int } }`
623    /// Generates: `: Make-Get ( Int -- Message ) 0 variant.make-1 ;`
624    ///
625    /// Returns an error if any variant exceeds the maximum field count.
626    pub fn generate_constructors(&mut self) -> Result<(), String> {
627        let mut new_words = Vec::new();
628
629        for union_def in &self.unions {
630            for variant in &union_def.variants {
631                let constructor_name = format!("Make-{}", variant.name);
632                let field_count = variant.fields.len();
633
634                // Check field count limit before generating constructor
635                if field_count > Self::MAX_VARIANT_FIELDS {
636                    return Err(format!(
637                        "Variant '{}' in union '{}' has {} fields, but the maximum is {}. \
638                         Consider grouping fields into nested union types.",
639                        variant.name,
640                        union_def.name,
641                        field_count,
642                        Self::MAX_VARIANT_FIELDS
643                    ));
644                }
645
646                // Build the stack effect: ( field_types... -- UnionType )
647                // Input stack has fields in declaration order
648                let mut input_stack = StackType::RowVar("a".to_string());
649                for field in &variant.fields {
650                    let field_type = parse_type_name(&field.type_name);
651                    input_stack = input_stack.push(field_type);
652                }
653
654                // Output stack has the union type
655                let output_stack =
656                    StackType::RowVar("a".to_string()).push(Type::Union(union_def.name.clone()));
657
658                let effect = Effect::new(input_stack, output_stack);
659
660                // Build the body:
661                // 1. Push the variant name as a symbol (for dynamic matching)
662                // 2. Call variant.make-N which now accepts Symbol tags
663                let body = vec![
664                    Statement::Symbol(variant.name.clone()),
665                    Statement::WordCall {
666                        name: format!("variant.make-{}", field_count),
667                        span: None, // Generated code, no source span
668                    },
669                ];
670
671                new_words.push(WordDef {
672                    name: constructor_name,
673                    effect: Some(effect),
674                    body,
675                    source: variant.source.clone(),
676                });
677            }
678        }
679
680        self.words.extend(new_words);
681        Ok(())
682    }
683}
684
685/// Parse a type name string into a Type
686/// Used by constructor generation to build stack effects
687fn parse_type_name(name: &str) -> Type {
688    match name {
689        "Int" => Type::Int,
690        "Float" => Type::Float,
691        "Bool" => Type::Bool,
692        "String" => Type::String,
693        "Channel" => Type::Channel,
694        other => Type::Union(other.to_string()),
695    }
696}
697
698impl Default for Program {
699    fn default() -> Self {
700        Self::new()
701    }
702}
703
704#[cfg(test)]
705mod tests {
706    use super::*;
707
708    #[test]
709    fn test_validate_builtin_words() {
710        let program = Program {
711            includes: vec![],
712            unions: vec![],
713            words: vec![WordDef {
714                name: "main".to_string(),
715                effect: None,
716                body: vec![
717                    Statement::IntLiteral(2),
718                    Statement::IntLiteral(3),
719                    Statement::WordCall {
720                        name: "i.add".to_string(),
721                        span: None,
722                    },
723                    Statement::WordCall {
724                        name: "io.write-line".to_string(),
725                        span: None,
726                    },
727                ],
728                source: None,
729            }],
730        };
731
732        // Should succeed - i.add and io.write-line are built-ins
733        assert!(program.validate_word_calls().is_ok());
734    }
735
736    #[test]
737    fn test_validate_user_defined_words() {
738        let program = Program {
739            includes: vec![],
740            unions: vec![],
741            words: vec![
742                WordDef {
743                    name: "helper".to_string(),
744                    effect: None,
745                    body: vec![Statement::IntLiteral(42)],
746                    source: None,
747                },
748                WordDef {
749                    name: "main".to_string(),
750                    effect: None,
751                    body: vec![Statement::WordCall {
752                        name: "helper".to_string(),
753                        span: None,
754                    }],
755                    source: None,
756                },
757            ],
758        };
759
760        // Should succeed - helper is defined
761        assert!(program.validate_word_calls().is_ok());
762    }
763
764    #[test]
765    fn test_validate_undefined_word() {
766        let program = Program {
767            includes: vec![],
768            unions: vec![],
769            words: vec![WordDef {
770                name: "main".to_string(),
771                effect: None,
772                body: vec![Statement::WordCall {
773                    name: "undefined_word".to_string(),
774                    span: None,
775                }],
776                source: None,
777            }],
778        };
779
780        // Should fail - undefined_word is not a built-in or user-defined word
781        let result = program.validate_word_calls();
782        assert!(result.is_err());
783        let error = result.unwrap_err();
784        assert!(error.contains("undefined_word"));
785        assert!(error.contains("main"));
786    }
787
788    #[test]
789    fn test_validate_misspelled_builtin() {
790        let program = Program {
791            includes: vec![],
792            unions: vec![],
793            words: vec![WordDef {
794                name: "main".to_string(),
795                effect: None,
796                body: vec![Statement::WordCall {
797                    name: "wrte_line".to_string(),
798                    span: None,
799                }], // typo
800                source: None,
801            }],
802        };
803
804        // Should fail with helpful message
805        let result = program.validate_word_calls();
806        assert!(result.is_err());
807        let error = result.unwrap_err();
808        assert!(error.contains("wrte_line"));
809        assert!(error.contains("misspell"));
810    }
811
812    #[test]
813    fn test_generate_constructors() {
814        let mut program = Program {
815            includes: vec![],
816            unions: vec![UnionDef {
817                name: "Message".to_string(),
818                variants: vec![
819                    UnionVariant {
820                        name: "Get".to_string(),
821                        fields: vec![UnionField {
822                            name: "response-chan".to_string(),
823                            type_name: "Int".to_string(),
824                        }],
825                        source: None,
826                    },
827                    UnionVariant {
828                        name: "Put".to_string(),
829                        fields: vec![
830                            UnionField {
831                                name: "value".to_string(),
832                                type_name: "String".to_string(),
833                            },
834                            UnionField {
835                                name: "response-chan".to_string(),
836                                type_name: "Int".to_string(),
837                            },
838                        ],
839                        source: None,
840                    },
841                ],
842                source: None,
843            }],
844            words: vec![],
845        };
846
847        // Generate constructors
848        program.generate_constructors().unwrap();
849
850        // Should have 2 constructor words
851        assert_eq!(program.words.len(), 2);
852
853        // Check Make-Get constructor
854        let make_get = program
855            .find_word("Make-Get")
856            .expect("Make-Get should exist");
857        assert_eq!(make_get.name, "Make-Get");
858        assert!(make_get.effect.is_some());
859        let effect = make_get.effect.as_ref().unwrap();
860        // Input: ( ..a Int -- )
861        // Output: ( ..a Message -- )
862        assert_eq!(
863            format!("{:?}", effect.outputs),
864            "Cons { rest: RowVar(\"a\"), top: Union(\"Message\") }"
865        );
866
867        // Check Make-Put constructor
868        let make_put = program
869            .find_word("Make-Put")
870            .expect("Make-Put should exist");
871        assert_eq!(make_put.name, "Make-Put");
872        assert!(make_put.effect.is_some());
873
874        // Check the body generates correct code
875        // Make-Get should be: :Get variant.make-1
876        assert_eq!(make_get.body.len(), 2);
877        match &make_get.body[0] {
878            Statement::Symbol(s) if s == "Get" => {}
879            other => panic!("Expected Symbol(\"Get\") for variant tag, got {:?}", other),
880        }
881        match &make_get.body[1] {
882            Statement::WordCall { name, span: None } if name == "variant.make-1" => {}
883            _ => panic!("Expected WordCall(variant.make-1)"),
884        }
885
886        // Make-Put should be: :Put variant.make-2
887        assert_eq!(make_put.body.len(), 2);
888        match &make_put.body[0] {
889            Statement::Symbol(s) if s == "Put" => {}
890            other => panic!("Expected Symbol(\"Put\") for variant tag, got {:?}", other),
891        }
892        match &make_put.body[1] {
893            Statement::WordCall { name, span: None } if name == "variant.make-2" => {}
894            _ => panic!("Expected WordCall(variant.make-2)"),
895        }
896    }
897}