Skip to main content

normalize_surface_syntax/output/
lua.rs

1//! Lua writer for surface-syntax IR.
2//!
3//! Emits surface-syntax IR as Lua source code.
4
5use crate::ir::*;
6use crate::traits::Writer;
7
8/// Static instance of the Lua writer for registry.
9pub static LUA_WRITER: LuaWriterImpl = LuaWriterImpl;
10
11/// Lua writer implementing the Writer trait.
12pub struct LuaWriterImpl;
13
14impl Writer for LuaWriterImpl {
15    fn language(&self) -> &'static str {
16        "lua"
17    }
18
19    fn extension(&self) -> &'static str {
20        "lua"
21    }
22
23    fn write(&self, program: &Program) -> String {
24        LuaWriter::emit(program)
25    }
26}
27
28/// Emits IR as Lua source code.
29pub struct LuaWriter {
30    output: String,
31    indent: usize,
32}
33
34impl LuaWriter {
35    pub fn new() -> Self {
36        Self {
37            output: String::new(),
38            indent: 0,
39        }
40    }
41
42    /// Emit a program to Lua source.
43    pub fn emit(program: &Program) -> String {
44        let mut writer = Self::new();
45        writer.write_program(program);
46        writer.output
47    }
48
49    fn write_program(&mut self, program: &Program) {
50        for stmt in &program.body {
51            self.write_stmt(stmt);
52            self.output.push('\n');
53        }
54    }
55
56    fn write_indent(&mut self) {
57        for _ in 0..self.indent {
58            self.output.push_str("  ");
59        }
60    }
61
62    fn write_stmt(&mut self, stmt: &Stmt) {
63        self.write_indent();
64        match stmt {
65            Stmt::Expr(expr) => {
66                self.write_expr(expr);
67            }
68
69            Stmt::Let { name, init, .. } => {
70                self.output.push_str("local ");
71                self.output.push_str(name);
72                if let Some(init) = init {
73                    self.output.push_str(" = ");
74                    self.write_expr(init);
75                }
76            }
77
78            Stmt::Destructure { pat, value, .. } => {
79                // Lua: `local a, b = table.unpack(expr)` — emit as multi-assignment
80                self.output.push_str("local ");
81                self.write_lua_pat(pat);
82                self.output.push_str(" = table.unpack(");
83                self.write_expr(value);
84                self.output.push(')');
85            }
86
87            Stmt::Block(stmts) => {
88                self.output.push_str("do\n");
89                self.indent += 1;
90                for s in stmts {
91                    self.write_stmt(s);
92                    self.output.push('\n');
93                }
94                self.indent -= 1;
95                self.write_indent();
96                self.output.push_str("end");
97            }
98
99            Stmt::If {
100                test,
101                consequent,
102                alternate,
103                ..
104            } => {
105                self.output.push_str("if ");
106                self.write_expr(test);
107                self.output.push_str(" then\n");
108                self.indent += 1;
109                self.write_stmt_body(consequent);
110                self.indent -= 1;
111                if let Some(alt) = alternate {
112                    self.write_indent();
113                    self.output.push_str("else\n");
114                    self.indent += 1;
115                    self.write_stmt_body(alt);
116                    self.indent -= 1;
117                }
118                self.write_indent();
119                self.output.push_str("end");
120            }
121
122            Stmt::While { test, body, .. } => {
123                self.output.push_str("while ");
124                self.write_expr(test);
125                self.output.push_str(" do\n");
126                self.indent += 1;
127                self.write_stmt_body(body);
128                self.indent -= 1;
129                self.write_indent();
130                self.output.push_str("end");
131            }
132
133            Stmt::For {
134                init,
135                test,
136                update,
137                body,
138                ..
139            } => {
140                // Lua doesn't have C-style for loops, emit as while
141                if let Some(init) = init {
142                    self.write_stmt(init);
143                    self.output.push('\n');
144                    self.write_indent();
145                }
146                self.output.push_str("while ");
147                if let Some(test) = test {
148                    self.write_expr(test);
149                } else {
150                    self.output.push_str("true");
151                }
152                self.output.push_str(" do\n");
153                self.indent += 1;
154                self.write_stmt_body(body);
155                if let Some(update) = update {
156                    self.write_indent();
157                    self.write_expr(update);
158                    self.output.push('\n');
159                }
160                self.indent -= 1;
161                self.write_indent();
162                self.output.push_str("end");
163            }
164
165            Stmt::ForIn {
166                variable,
167                iterable,
168                body,
169                ..
170            } => {
171                self.output.push_str("for ");
172                self.output.push_str(variable);
173                self.output.push_str(" in pairs(");
174                self.write_expr(iterable);
175                self.output.push_str(") do\n");
176                self.indent += 1;
177                self.write_stmt_body(body);
178                self.indent -= 1;
179                self.write_indent();
180                self.output.push_str("end");
181            }
182
183            Stmt::Return(expr) => {
184                self.output.push_str("return");
185                if let Some(e) = expr {
186                    self.output.push(' ');
187                    self.write_expr(e);
188                }
189            }
190
191            Stmt::Break => {
192                self.output.push_str("break");
193            }
194
195            Stmt::Continue => {
196                // Lua 5.1 doesn't have continue, use goto in 5.2+
197                self.output
198                    .push_str("-- continue (not supported in Lua 5.1)");
199            }
200
201            Stmt::TryCatch {
202                body,
203                catch_param,
204                catch_body,
205                finally_body,
206                ..
207            } => {
208                // Lua uses pcall/xpcall for error handling
209                let param = catch_param.as_deref().unwrap_or("_err");
210                self.output.push_str("local _ok, ");
211                self.output.push_str(param);
212                self.output.push_str(" = pcall(function()\n");
213                self.indent += 1;
214                self.write_stmt_body(body);
215                self.indent -= 1;
216                self.write_indent();
217                self.output.push_str("end)\n");
218                if let Some(cb) = catch_body {
219                    self.write_indent();
220                    self.output.push_str("if not _ok then\n");
221                    self.indent += 1;
222                    self.write_stmt_body(cb);
223                    self.indent -= 1;
224                    self.write_indent();
225                    self.output.push_str("end");
226                }
227                if let Some(fb) = finally_body {
228                    self.output.push('\n');
229                    self.write_stmt_body(fb);
230                }
231            }
232
233            Stmt::Function(f) => {
234                self.write_function(f);
235            }
236
237            Stmt::Import { source, names, .. } => {
238                // Lua has no native import/require syntax at IR level.
239                // Emit as `require('source')` call, assigning names if any.
240                if names.is_empty() {
241                    self.output.push_str("require('");
242                    self.output.push_str(source);
243                    self.output.push_str("')");
244                } else if names.len() == 1 && !names[0].is_namespace {
245                    let local_name = names[0].alias.as_deref().unwrap_or(&names[0].name);
246                    self.output.push_str("local ");
247                    self.output.push_str(local_name);
248                    self.output.push_str(" = require('");
249                    self.output.push_str(source);
250                    self.output.push_str("')");
251                } else {
252                    // Multiple names: local _mod = require('source'); local x = _mod.x; ...
253                    self.output.push_str("local _mod_");
254                    // Use a sanitised version of source as a variable name
255                    let mod_var: String = source
256                        .chars()
257                        .map(|c| if c.is_alphanumeric() { c } else { '_' })
258                        .collect();
259                    self.output.push_str(&mod_var);
260                    self.output.push_str(" = require('");
261                    self.output.push_str(source);
262                    self.output.push_str("')");
263                    for n in names {
264                        if n.is_namespace {
265                            continue;
266                        }
267                        self.output.push('\n');
268                        self.write_indent();
269                        let local_name = n.alias.as_deref().unwrap_or(&n.name);
270                        self.output.push_str("local ");
271                        self.output.push_str(local_name);
272                        self.output.push_str(" = _mod_");
273                        self.output.push_str(&mod_var);
274                        self.output.push('.');
275                        self.output.push_str(&n.name);
276                    }
277                }
278            }
279
280            Stmt::Export { .. } => {
281                // Lua has no export statements; emit a comment
282                self.output.push_str("-- export (not applicable in Lua)");
283            }
284
285            Stmt::Class {
286                name,
287                extends,
288                methods,
289                ..
290            } => {
291                // Lower Lua class to metatable pattern:
292                // local ClassName = {}
293                // ClassName.__index = ClassName
294                // [if extends: setmetatable(ClassName, { __index = Base })]
295                // function ClassName:method(...) ... end
296                // function ClassName.new(...) ... end  (for constructor)
297                self.output.push_str("local ");
298                self.output.push_str(name);
299                self.output.push_str(" = {}");
300                self.output.push('\n');
301                self.write_indent();
302                self.output.push_str(name);
303                self.output.push_str(".__index = ");
304                self.output.push_str(name);
305                if let Some(base) = extends {
306                    self.output.push('\n');
307                    self.write_indent();
308                    self.output.push_str("setmetatable(");
309                    self.output.push_str(name);
310                    self.output.push_str(", { __index = ");
311                    self.output.push_str(base);
312                    self.output.push_str(" })");
313                }
314                for method in methods {
315                    self.output.push('\n');
316                    self.write_indent();
317                    if method.name == "constructor" || method.name == "__init__" {
318                        // Emit as ClassName.new(...)
319                        self.output.push_str("function ");
320                        self.output.push_str(name);
321                        self.output.push_str(".new(");
322                        let params: Vec<_> =
323                            method.params.iter().filter(|p| p.name != "self").collect();
324                        for (i, p) in params.iter().enumerate() {
325                            if i > 0 {
326                                self.output.push_str(", ");
327                            }
328                            self.output.push_str(&p.name);
329                        }
330                        self.output.push_str(")\n");
331                        self.indent += 1;
332                        self.write_indent();
333                        self.output.push_str("local self = setmetatable({}, ");
334                        self.output.push_str(name);
335                        self.output.push_str(")\n");
336                        for s in &method.body {
337                            self.write_stmt(s);
338                            self.output.push('\n');
339                        }
340                        self.write_indent();
341                        self.output.push_str("return self\n");
342                        self.indent -= 1;
343                        self.write_indent();
344                        self.output.push_str("end");
345                    } else if method.is_static {
346                        self.output.push_str("function ");
347                        self.output.push_str(name);
348                        self.output.push('.');
349                        self.output.push_str(&method.name);
350                        self.output.push('(');
351                        for (i, p) in method.params.iter().enumerate() {
352                            if i > 0 {
353                                self.output.push_str(", ");
354                            }
355                            self.output.push_str(&p.name);
356                        }
357                        self.output.push_str(")\n");
358                        self.indent += 1;
359                        for s in &method.body {
360                            self.write_stmt(s);
361                            self.output.push('\n');
362                        }
363                        self.indent -= 1;
364                        self.write_indent();
365                        self.output.push_str("end");
366                    } else {
367                        self.output.push_str("function ");
368                        self.output.push_str(name);
369                        self.output.push(':');
370                        self.output.push_str(&method.name);
371                        self.output.push('(');
372                        let params: Vec<_> =
373                            method.params.iter().filter(|p| p.name != "self").collect();
374                        for (i, p) in params.iter().enumerate() {
375                            if i > 0 {
376                                self.output.push_str(", ");
377                            }
378                            self.output.push_str(&p.name);
379                        }
380                        self.output.push_str(")\n");
381                        self.indent += 1;
382                        for s in &method.body {
383                            self.write_stmt(s);
384                            self.output.push('\n');
385                        }
386                        self.indent -= 1;
387                        self.write_indent();
388                        self.output.push_str("end");
389                    }
390                }
391            }
392
393            Stmt::Comment { text, block, .. } => {
394                if *block {
395                    self.output.push_str("--[[");
396                    self.output.push_str(text);
397                    self.output.push_str("]]");
398                } else {
399                    self.output.push_str("-- ");
400                    self.output.push_str(text);
401                }
402            }
403        }
404    }
405
406    fn write_stmt_body(&mut self, stmt: &Stmt) {
407        match stmt {
408            Stmt::Block(stmts) => {
409                for s in stmts {
410                    self.write_stmt(s);
411                    self.output.push('\n');
412                }
413            }
414            _ => {
415                self.write_stmt(stmt);
416                self.output.push('\n');
417            }
418        }
419    }
420
421    fn write_function(&mut self, f: &Function) {
422        if f.name.is_empty() {
423            self.output.push_str("function(");
424        } else {
425            self.output.push_str("function ");
426            self.output.push_str(&f.name);
427            self.output.push('(');
428        }
429        for (i, param) in f.params.iter().enumerate() {
430            if i > 0 {
431                self.output.push_str(", ");
432            }
433            // Lua has no type annotations — emit name only
434            self.output.push_str(&param.name);
435        }
436        self.output.push_str(")\n");
437        self.indent += 1;
438        for stmt in &f.body {
439            self.write_stmt(stmt);
440            self.output.push('\n');
441        }
442        self.indent -= 1;
443        self.write_indent();
444        self.output.push_str("end");
445    }
446
447    fn write_expr(&mut self, expr: &Expr) {
448        match expr {
449            Expr::Literal(lit) => self.write_literal(lit),
450
451            Expr::Ident(name) => {
452                self.output.push_str(name);
453            }
454
455            Expr::Binary {
456                left, op, right, ..
457            } => {
458                self.output.push('(');
459                self.write_expr(left);
460                self.output.push(' ');
461                self.write_binary_op(*op);
462                self.output.push(' ');
463                self.write_expr(right);
464                self.output.push(')');
465            }
466
467            Expr::Unary { op, expr, .. } => {
468                self.write_unary_op(*op);
469                self.write_expr(expr);
470            }
471
472            Expr::Call { callee, args, .. } => {
473                self.write_expr(callee);
474                self.output.push('(');
475                for (i, arg) in args.iter().enumerate() {
476                    if i > 0 {
477                        self.output.push_str(", ");
478                    }
479                    self.write_expr(arg);
480                }
481                self.output.push(')');
482            }
483
484            Expr::Member {
485                object,
486                property,
487                computed,
488                ..
489            } => {
490                self.write_expr(object);
491                if *computed {
492                    self.output.push('[');
493                    self.write_expr(property);
494                    self.output.push(']');
495                } else if let Expr::Literal(Literal::String(s)) = property.as_ref() {
496                    self.output.push('.');
497                    self.output.push_str(s);
498                } else {
499                    self.output.push('[');
500                    self.write_expr(property);
501                    self.output.push(']');
502                }
503            }
504
505            Expr::Array(items) => {
506                self.output.push('{');
507                for (i, item) in items.iter().enumerate() {
508                    if i > 0 {
509                        self.output.push_str(", ");
510                    }
511                    self.write_expr(item);
512                }
513                self.output.push('}');
514            }
515
516            Expr::Object(pairs) => {
517                self.output.push('{');
518                for (i, (key, value)) in pairs.iter().enumerate() {
519                    if i > 0 {
520                        self.output.push_str(", ");
521                    }
522                    if is_lua_identifier(key) {
523                        // Use idiomatic `key = value` syntax for valid Lua identifiers.
524                        self.output.push_str(key);
525                        self.output.push_str(" = ");
526                    } else {
527                        // Fall back to bracket syntax for non-identifier keys.
528                        self.output.push_str("[\"");
529                        self.output.push_str(&escape_string(key));
530                        self.output.push_str("\"] = ");
531                    }
532                    self.write_expr(value);
533                }
534                self.output.push('}');
535            }
536
537            Expr::Function(f) => {
538                self.write_function(f);
539            }
540
541            Expr::Conditional {
542                test,
543                consequent,
544                alternate,
545                ..
546            } => {
547                // Lua doesn't have ternary, use `a and b or c` pattern
548                self.output.push('(');
549                self.write_expr(test);
550                self.output.push_str(" and ");
551                self.write_expr(consequent);
552                self.output.push_str(" or ");
553                self.write_expr(alternate);
554                self.output.push(')');
555            }
556
557            Expr::Assign { target, value, .. } => {
558                self.write_expr(target);
559                self.output.push_str(" = ");
560                self.write_expr(value);
561            }
562
563            Expr::TemplateLiteral(parts) => {
564                // Lua has no string interpolation — emit as `..` concatenation
565                if parts.is_empty() {
566                    self.output.push_str("\"\"");
567                    return;
568                }
569                let exprs: Vec<Expr> = parts
570                    .iter()
571                    .filter_map(|p| match p {
572                        TemplatePart::Text(s) if s.is_empty() => None,
573                        TemplatePart::Text(s) => Some(Expr::string(s.clone())),
574                        TemplatePart::Expr(e) => Some(*e.clone()),
575                    })
576                    .collect();
577                if exprs.is_empty() {
578                    self.output.push_str("\"\"");
579                    return;
580                }
581                if exprs.len() == 1 {
582                    self.write_expr(&exprs[0]);
583                    return;
584                }
585                self.output.push('(');
586                for (i, e) in exprs.iter().enumerate() {
587                    if i > 0 {
588                        self.output.push_str(" .. ");
589                    }
590                    self.write_expr(e);
591                }
592                self.output.push(')');
593            }
594        }
595    }
596
597    fn write_literal(&mut self, lit: &Literal) {
598        match lit {
599            Literal::Null => self.output.push_str("nil"),
600            Literal::Bool(b) => self.output.push_str(if *b { "true" } else { "false" }),
601            Literal::Number(n) => self.output.push_str(&n.to_string()),
602            Literal::String(s) => {
603                self.output.push('"');
604                self.output.push_str(&escape_string(s));
605                self.output.push('"');
606            }
607        }
608    }
609
610    fn write_binary_op(&mut self, op: BinaryOp) {
611        let s = match op {
612            BinaryOp::Add => "+",
613            BinaryOp::Sub => "-",
614            BinaryOp::Mul => "*",
615            BinaryOp::Div => "/",
616            BinaryOp::Mod => "%",
617            BinaryOp::Eq => "==",
618            BinaryOp::Ne => "~=",
619            BinaryOp::Lt => "<",
620            BinaryOp::Le => "<=",
621            BinaryOp::Gt => ">",
622            BinaryOp::Ge => ">=",
623            BinaryOp::And => "and",
624            BinaryOp::Or => "or",
625            BinaryOp::Concat => "..",
626        };
627        self.output.push_str(s);
628    }
629
630    fn write_unary_op(&mut self, op: UnaryOp) {
631        let s = match op {
632            UnaryOp::Neg => "-",
633            UnaryOp::Not => "not ",
634        };
635        self.output.push_str(s);
636    }
637
638    /// Write a pattern as a comma-separated list of names (for Lua multi-assignment).
639    fn write_lua_pat(&mut self, pat: &Pat) {
640        match pat {
641            Pat::Ident(name) => {
642                self.output.push_str(name);
643            }
644            Pat::Array(elements, rest) => {
645                for (i, elem) in elements.iter().enumerate() {
646                    if i > 0 {
647                        self.output.push_str(", ");
648                    }
649                    match elem {
650                        None => self.output.push('_'),
651                        Some(p) => self.write_lua_pat(p),
652                    }
653                }
654                if let Some(rest_name) = rest {
655                    if !elements.is_empty() {
656                        self.output.push_str(", ");
657                    }
658                    self.output.push_str(rest_name);
659                }
660            }
661            Pat::Object(fields) => {
662                for (i, field) in fields.iter().enumerate() {
663                    if i > 0 {
664                        self.output.push_str(", ");
665                    }
666                    self.write_lua_pat(&field.pat);
667                }
668            }
669            Pat::Rest(inner) => {
670                self.write_lua_pat(inner);
671            }
672        }
673    }
674}
675
676impl Default for LuaWriter {
677    fn default() -> Self {
678        Self::new()
679    }
680}
681
682fn escape_string(s: &str) -> String {
683    let mut out = String::with_capacity(s.len());
684    for ch in s.chars() {
685        match ch {
686            '\\' => out.push_str("\\\\"),
687            '"' => out.push_str("\\\""),
688            '\n' => out.push_str("\\n"),
689            '\r' => out.push_str("\\r"),
690            '\t' => out.push_str("\\t"),
691            '\0' => out.push_str("\\0"),
692            c => out.push(c),
693        }
694    }
695    out
696}
697
698/// Returns true if `s` is a valid Lua identifier (can be used as a bare table key).
699fn is_lua_identifier(s: &str) -> bool {
700    if s.is_empty() {
701        return false;
702    }
703    let mut chars = s.chars();
704    let first = chars.next().unwrap();
705    if !first.is_ascii_alphabetic() && first != '_' {
706        return false;
707    }
708    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714
715    #[test]
716    fn test_simple_let() {
717        let program = Program::new(vec![Stmt::const_decl("x", Expr::number(42))]);
718        let lua = LuaWriter::emit(&program);
719        assert_eq!(lua.trim(), "local x = 42");
720    }
721
722    #[test]
723    fn test_function_call() {
724        let program = Program::new(vec![Stmt::expr(Expr::call(
725            Expr::member(Expr::ident("console"), "log"),
726            vec![Expr::string("hello")],
727        ))]);
728        let lua = LuaWriter::emit(&program);
729        assert_eq!(lua.trim(), "console.log(\"hello\")");
730    }
731
732    #[test]
733    fn test_binary_expr() {
734        let program = Program::new(vec![Stmt::const_decl(
735            "sum",
736            Expr::binary(Expr::number(1), BinaryOp::Add, Expr::number(2)),
737        )]);
738        let lua = LuaWriter::emit(&program);
739        assert_eq!(lua.trim(), "local sum = (1 + 2)");
740    }
741
742    #[test]
743    fn test_logical_operators_idiomatic() {
744        // Lua uses `and`/`or`/`not`, never `&&`/`||`/`!`
745        let program = Program::new(vec![Stmt::const_decl(
746            "b",
747            Expr::binary(
748                Expr::bool(true),
749                BinaryOp::And,
750                Expr::binary(Expr::bool(false), BinaryOp::Or, Expr::bool(true)),
751            ),
752        )]);
753        let lua = LuaWriter::emit(&program);
754        assert!(lua.contains("and"), "should use `and`, got: {lua}");
755        assert!(lua.contains("or"), "should use `or`, got: {lua}");
756        assert!(!lua.contains("&&"), "should not use `&&`, got: {lua}");
757        assert!(!lua.contains("||"), "should not use `||`, got: {lua}");
758    }
759
760    #[test]
761    fn test_inequality_idiomatic() {
762        // Lua uses `~=`, never `!=`
763        let program = Program::new(vec![Stmt::expr(Expr::binary(
764            Expr::ident("a"),
765            BinaryOp::Ne,
766            Expr::ident("b"),
767        ))]);
768        let lua = LuaWriter::emit(&program);
769        assert!(lua.contains("~="), "should use `~=`, got: {lua}");
770        assert!(!lua.contains("!="), "should not use `!=`, got: {lua}");
771    }
772
773    #[test]
774    fn test_null_is_nil() {
775        let program = Program::new(vec![Stmt::const_decl("x", Expr::null())]);
776        let lua = LuaWriter::emit(&program);
777        assert!(lua.contains("nil"), "should use `nil`, got: {lua}");
778        assert!(!lua.contains("null"), "should not use `null`, got: {lua}");
779    }
780
781    #[test]
782    fn test_object_idiomatic_keys() {
783        // Valid identifier keys should be emitted as `key = value`, not `["key"] = value`
784        let program = Program::new(vec![Stmt::const_decl(
785            "t",
786            Expr::object(vec![
787                ("x".to_string(), Expr::number(1)),
788                ("__index".to_string(), Expr::null()),
789                ("1".to_string(), Expr::number(99)), // numeric key: not a valid ident
790            ]),
791        )]);
792        let lua = LuaWriter::emit(&program);
793        assert!(
794            lua.contains("x = 1"),
795            "plain key should be bare, got: {lua}"
796        );
797        assert!(
798            lua.contains("__index = nil"),
799            "metamethod key should be bare, got: {lua}"
800        );
801        assert!(
802            lua.contains("[\"1\"] = 99"),
803            "numeric key should use brackets, got: {lua}"
804        );
805    }
806
807    #[test]
808    fn test_string_escaping() {
809        let program = Program::new(vec![Stmt::const_decl(
810            "s",
811            Expr::string("line1\nline2\ttab\"quote\\backslash\0null"),
812        )]);
813        let lua = LuaWriter::emit(&program);
814        assert!(lua.contains("\\n"), "newline should be escaped");
815        assert!(lua.contains("\\t"), "tab should be escaped");
816        assert!(lua.contains("\\\""), "quote should be escaped");
817        assert!(lua.contains("\\\\"), "backslash should be escaped");
818        assert!(lua.contains("\\0"), "null byte should be escaped");
819    }
820
821    #[test]
822    fn test_not_operator() {
823        let program = Program::new(vec![Stmt::expr(Expr::unary(
824            UnaryOp::Not,
825            Expr::bool(true),
826        ))]);
827        let lua = LuaWriter::emit(&program);
828        assert!(lua.contains("not "), "should use `not `, got: {lua}");
829        assert!(!lua.contains('!'), "should not use `!`, got: {lua}");
830    }
831
832    #[test]
833    fn test_for_in_multi_var() {
834        let program = Program::new(vec![Stmt::for_in(
835            "k, v",
836            Expr::call(Expr::ident("pairs"), vec![Expr::ident("t")]),
837            Stmt::block(vec![]),
838        )]);
839        let lua = LuaWriter::emit(&program);
840        assert!(
841            lua.contains("for k, v in pairs"),
842            "should preserve both loop vars, got: {lua}"
843        );
844    }
845
846    #[test]
847    fn test_unicode_string_preserved() {
848        let program = Program::new(vec![Stmt::const_decl("s", Expr::string("こんにちは 🌍"))]);
849        let lua = LuaWriter::emit(&program);
850        assert!(
851            lua.contains("こんにちは 🌍"),
852            "unicode should pass through unescaped, got: {lua}"
853        );
854    }
855
856    #[test]
857    fn test_line_comment() {
858        let program = Program::new(vec![
859            Stmt::comment_line("This is a comment"),
860            Stmt::let_decl("x", Some(Expr::number(1))),
861        ]);
862        let lua = LuaWriter::emit(&program);
863        assert!(lua.contains("-- This is a comment"), "got: {lua}");
864        assert!(lua.contains("local x = 1"), "got: {lua}");
865    }
866
867    #[test]
868    fn test_block_comment() {
869        let program = Program::new(vec![Stmt::comment_block("block comment")]);
870        let lua = LuaWriter::emit(&program);
871        assert!(lua.contains("--[[block comment]]"), "got: {lua}");
872    }
873}