pasta_lua 0.2.4

Pasta Lua - Lua integration for Pasta DSL
Documentation
//! Lua code generator for Pasta DSL.
//!
//! This module generates Lua source code from Pasta AST nodes.
//! Implements Requirements 1, 3a-3g for Lua code generation.

mod element_gen;
mod scope_gen;
pub mod source_map;

use crate::config::LineEnding;
use crate::error::TranspileError;

use pasta_dsl::parser::Span;
use source_map::SourceMapSink;

use std::io::Write;

/// Lua code generator.
///
/// Generates Lua source code from Pasta AST nodes.
///
/// # Source-map seam (R4: feasibility)
///
/// The generator tracks `out_line`, the 1-based line number of the most recently
/// emitted output line, and carries an optional [`SourceMapSink`]. When a sink is
/// attached (via [`set_source_map`](LuaCodeGenerator::set_source_map)) and a span is
/// known at the emit point, the generator records `(out_line -> span)` so the
/// `.pasta` <-> `.lua` line correspondence is resolvable. When no sink is attached
/// (the default for ALL production transpile callers) the seam is inert and the
/// generated bytes are byte-identical to before this seam existed (Requirement 4.6).
pub struct LuaCodeGenerator<'a, W: Write> {
    /// Output writer
    writer: &'a mut W,
    /// Current indentation level
    indent_level: usize,
    /// Line ending style
    line_ending: LineEnding,
    /// 1-based line number of the most recently emitted output line.
    ///
    /// Starts at 0 (nothing emitted yet) and is incremented by exactly 1 for each
    /// line terminator written through the emit choke points (`writeln`,
    /// `write_blank_line`, the raw expression-statement terminator, and per embedded
    /// `\n` in `write_raw`). Internal only: never alters emitted bytes.
    out_line: u32,
    /// Optional source-map sink (producer side of the `debug -> code_gen` seam).
    ///
    /// `None` for production transpile (byte-identical, zero-cost). When `Some`, the
    /// generator calls `record(out_line, span)` for span-bearing constructs.
    source_map: Option<&'a mut dyn SourceMapSink>,
}

impl<'a, W: Write> LuaCodeGenerator<'a, W> {
    /// Create a new Lua code generator.
    pub fn new(writer: &'a mut W) -> Self {
        Self {
            writer,
            indent_level: 0,
            line_ending: LineEnding::default(),
            out_line: 0,
            source_map: None,
        }
    }

    /// Create a new Lua code generator with specified line ending.
    pub fn with_line_ending(writer: &'a mut W, line_ending: LineEnding) -> Self {
        Self {
            writer,
            indent_level: 0,
            line_ending,
            out_line: 0,
            source_map: None,
        }
    }

    /// Attach a source-map sink (producer side of the source-map seam).
    ///
    /// While a sink is attached, the generator records `(out_line -> span)` for
    /// span-bearing constructs it emits. Attaching a sink NEVER changes the emitted
    /// bytes — it only observes line numbers. The default (no sink) path is
    /// byte-identical and zero-cost (Requirement 4.6).
    pub fn set_source_map(&mut self, sink: &'a mut dyn SourceMapSink) {
        self.source_map = Some(sink);
    }

    /// Current value of the output-line counter (1-based line most recently emitted).
    ///
    /// Returns 0 before any line has been emitted. Exposed for accuracy assertions
    /// and for callers that need the line a construct will land on.
    pub fn out_line(&self) -> u32 {
        self.out_line
    }

    /// Record `(out_line -> span)` if a sink is attached and the span is valid.
    ///
    /// Inert (no-op, zero-cost) when no sink is attached — the production default.
    /// Invalid/default spans (`end_byte == 0`) are skipped so synthetic/headerless
    /// output does not pollute the map.
    ///
    /// # Coverage note (future `pasta-source-map` spec)
    ///
    /// This seam is wired for the representative action-emitting path
    /// (`generate_action`). Full coverage — recording every `generate_*` construct
    /// (var sets, calls, choices, word definitions, scene/function headers, code
    /// blocks) and handling the `currentline` edge cases plus the post-transpile
    /// `normalize_output` line-shift — is OUT OF BOUNDARY here and is owned by the
    /// downstream `pasta-source-map` spec. To extend coverage, thread the relevant
    /// node's `span` to its emit point and call `record_span(span)` immediately
    /// before the corresponding `writeln`.
    fn record_span(&mut self, span: Span) {
        if let Some(sink) = self.source_map.as_deref_mut()
            && span.is_valid()
        {
            sink.record(self.out_line, span);
        }
    }

    /// Record `(out_line -> pasta_line)` directly if a sink is attached.
    ///
    /// Inert (no-op, zero-cost) when no sink is attached — the production default.
    /// Unlike [`record_span`](Self::record_span), this maps the most recently
    /// emitted output line to an explicit 1-based `.pasta` line rather than a span's
    /// `start_line`. It is the per-line mapping primitive used by multi-line elements
    /// whose output lines correspond 1:1 to distinct `.pasta` source lines — chiefly
    /// code blocks, where in-block breakpoint precision (Requirement 1.3) requires
    /// each generated `.lua` line to resolve to its own originating `.pasta` line.
    ///
    /// `pasta_line` must be 1-based. Callers compute it as
    /// `block.span.start_line + offset` (see `generate_code_block`). A `0` value is
    /// treated as invalid and skipped, mirroring the `span.is_valid()` guard in
    /// `record_span` (the default/uninitialized line is not recorded).
    fn record_block_line(&mut self, pasta_line: u32) {
        if let Some(sink) = self.source_map.as_deref_mut()
            && pasta_line > 0
        {
            sink.record_line(self.out_line, pasta_line);
        }
    }

    /// Write indentation at current level.
    fn write_indent(&mut self) -> Result<(), TranspileError> {
        let indent = "    ".repeat(self.indent_level);
        write!(self.writer, "{}", indent)?;
        Ok(())
    }

    /// Write a line with current indentation.
    ///
    /// Single primary line-emitting choke point: emits exactly one line terminator,
    /// so it advances `out_line` by 1.
    fn writeln(&mut self, s: &str) -> Result<(), TranspileError> {
        self.write_indent()?;
        write!(self.writer, "{}{}", s, self.line_ending.as_str())?;
        self.out_line += 1;
        Ok(())
    }

    /// Write a blank line without indentation.
    ///
    /// Emits exactly one line terminator, so it advances `out_line` by 1.
    fn write_blank_line(&mut self) -> Result<(), TranspileError> {
        write!(self.writer, "{}", self.line_ending.as_str())?;
        self.out_line += 1;
        Ok(())
    }

    /// Write without indentation.
    ///
    /// `s` may contain embedded newlines; `out_line` is advanced by the number of
    /// `\n` characters in `s` so the counter stays accurate regardless of content.
    /// (Current call sites pass fragments without a trailing terminator — the
    /// terminator is emitted separately via [`write_line_terminator`].)
    fn write_raw(&mut self, s: &str) -> Result<(), TranspileError> {
        write!(self.writer, "{}", s)?;
        self.out_line += s.matches('\n').count() as u32;
        Ok(())
    }

    /// Emit a bare line terminator for the open line built via `write_raw` fragments.
    ///
    /// Writes the same byte the previous inline `writeln!(self.writer)` calls wrote
    /// (a single `\n`, later normalized) and advances `out_line` by 1. Keeps the
    /// out-line accounting centralized so the counter cannot drift from the bytes.
    fn write_line_terminator(&mut self) -> Result<(), TranspileError> {
        writeln!(self.writer)?;
        self.out_line += 1;
        Ok(())
    }

    /// Increase indentation level.
    fn indent(&mut self) {
        self.indent_level += 1;
    }

    /// Decrease indentation level.
    fn dedent(&mut self) {
        if self.indent_level > 0 {
            self.indent_level -= 1;
        }
    }

    /// Close a block: dedent, write `end`, and add a blank line.
    ///
    /// Common pattern used by actor, global scene, and local scene generators.
    fn end_block(&mut self) -> Result<(), TranspileError> {
        self.dedent();
        self.writeln("end")?;
        self.write_blank_line()?;
        Ok(())
    }

    /// Write the Lua header (require statement).
    pub fn write_header(&mut self) -> Result<(), TranspileError> {
        self.writeln("local PASTA = require \"pasta\"")?;
        self.writeln("local GLOBAL = require \"pasta.global\"")?;
        self.write_blank_line()?;
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Test sink capturing each `(lua_line, pasta_line)` record.
    #[derive(Default)]
    struct CapturingSink {
        records: Vec<(u32, u32)>,
    }

    impl SourceMapSink for CapturingSink {
        fn record_line(&mut self, lua_line: u32, pasta_line: u32) {
            self.records.push((lua_line, pasta_line));
        }
    }

    /// `out_line` accounting contract: starts at 0; `writeln` and
    /// `write_blank_line` advance by exactly 1; `write_raw` advances by the
    /// number of embedded `\n` (0 for plain fragments); `write_line_terminator`
    /// advances by 1. The emitted bytes must match the counter exactly.
    #[test]
    fn out_line_advances_per_choke_point_and_matches_bytes() {
        let mut output = Vec::new();
        {
            let mut cg = LuaCodeGenerator::with_line_ending(&mut output, LineEnding::Lf);
            assert_eq!(cg.out_line(), 0, "no line emitted yet");

            cg.writeln("a").unwrap();
            assert_eq!(cg.out_line(), 1, "writeln advances by 1");

            cg.write_blank_line().unwrap();
            assert_eq!(cg.out_line(), 2, "write_blank_line advances by 1");

            cg.write_raw("frag").unwrap();
            assert_eq!(cg.out_line(), 2, "write_raw without newline does not advance");

            cg.write_raw("x\ny\nz").unwrap();
            assert_eq!(cg.out_line(), 4, "write_raw advances per embedded newline");

            cg.write_line_terminator().unwrap();
            assert_eq!(cg.out_line(), 5, "write_line_terminator advances by 1");
        }
        let text = String::from_utf8(output).unwrap();
        assert_eq!(text, "a\n\nfragx\ny\nz\n");
        assert_eq!(
            text.matches('\n').count(),
            5,
            "byte-level line count must equal final out_line"
        );
    }

    /// `writeln` indents 4 spaces per level; `dedent` decreases one level and
    /// saturates at 0 (extra dedents must not underflow or panic).
    #[test]
    fn indent_dedent_levels_and_saturation_at_zero() {
        let mut output = Vec::new();
        {
            let mut cg = LuaCodeGenerator::with_line_ending(&mut output, LineEnding::Lf);
            cg.dedent(); // saturates: still level 0
            cg.writeln("zero").unwrap();
            cg.indent();
            cg.indent();
            cg.writeln("two").unwrap();
            cg.dedent();
            cg.writeln("one").unwrap();
            cg.dedent();
            cg.dedent(); // extra dedent below 0 saturates
            cg.writeln("zero2").unwrap();
        }
        let text = String::from_utf8(output).unwrap();
        assert_eq!(text, "zero\n        two\n    one\nzero2\n");
    }

    /// `end_block` closes a block: dedents one level, writes `end` at the new
    /// level, then a blank line.
    #[test]
    fn end_block_dedents_then_writes_end_and_blank_line() {
        let mut output = Vec::new();
        {
            let mut cg = LuaCodeGenerator::with_line_ending(&mut output, LineEnding::Lf);
            cg.writeln("do").unwrap();
            cg.indent();
            cg.writeln("body").unwrap();
            cg.end_block().unwrap();
        }
        let text = String::from_utf8(output).unwrap();
        assert_eq!(text, "do\n    body\nend\n\n");
    }

    /// CRLF line ending is honored by both `writeln` and `write_blank_line`,
    /// and each terminator still advances `out_line` by exactly 1.
    #[test]
    fn with_line_ending_crlf_emits_crlf_terminators() {
        let mut output = Vec::new();
        let final_out_line;
        {
            let mut cg = LuaCodeGenerator::with_line_ending(&mut output, LineEnding::CrLf);
            cg.writeln("a").unwrap();
            cg.write_blank_line().unwrap();
            final_out_line = cg.out_line();
        }
        assert_eq!(String::from_utf8(output).unwrap(), "a\r\n\r\n");
        assert_eq!(final_out_line, 2);
    }

    /// `write_header` emits the exact two require lines plus a trailing blank
    /// line (byte-exact with LF endings).
    #[test]
    fn write_header_emits_exact_require_lines() {
        let mut output = Vec::new();
        {
            let mut cg = LuaCodeGenerator::with_line_ending(&mut output, LineEnding::Lf);
            cg.write_header().unwrap();
        }
        assert_eq!(
            String::from_utf8(output).unwrap(),
            "local PASTA = require \"pasta\"\nlocal GLOBAL = require \"pasta.global\"\n\n"
        );
    }

    /// `record_span` guard: with a sink attached, an invalid span
    /// (`end_byte == 0`, e.g. `Span::default()`) is skipped; a valid span is
    /// recorded against the current `out_line` using its `start_line`.
    #[test]
    fn record_span_skips_invalid_span_and_records_valid_span() {
        let mut sink = CapturingSink::default();
        let mut output = Vec::new();
        {
            let mut cg = LuaCodeGenerator::with_line_ending(&mut output, LineEnding::Lf);
            cg.set_source_map(&mut sink);
            cg.writeln("line1").unwrap();
            cg.record_span(Span::default()); // invalid: end_byte == 0 -> skipped
            cg.writeln("line2").unwrap();
            cg.record_span(Span::new(7, 1, 7, 5, 10, 20)); // valid
        }
        assert_eq!(
            sink.records,
            vec![(2, 7)],
            "only the valid span is recorded, mapped to out_line 2 -> pasta line 7"
        );
    }

    /// `record_block_line` guard: `pasta_line == 0` is skipped; a positive
    /// line is recorded against the current `out_line`.
    #[test]
    fn record_block_line_skips_zero_and_records_positive_line() {
        let mut sink = CapturingSink::default();
        let mut output = Vec::new();
        {
            let mut cg = LuaCodeGenerator::with_line_ending(&mut output, LineEnding::Lf);
            cg.set_source_map(&mut sink);
            cg.writeln("line1").unwrap();
            cg.record_block_line(0); // invalid sentinel -> skipped
            cg.record_block_line(9);
        }
        assert_eq!(sink.records, vec![(1, 9)]);
    }

    /// Without a sink attached, `record_span` / `record_block_line` are inert:
    /// no panic, and the emitted bytes are unaffected.
    #[test]
    fn recording_without_sink_is_inert() {
        let mut output = Vec::new();
        {
            let mut cg = LuaCodeGenerator::with_line_ending(&mut output, LineEnding::Lf);
            cg.writeln("a").unwrap();
            cg.record_span(Span::new(1, 1, 1, 2, 0, 5));
            cg.record_block_line(3);
        }
        assert_eq!(String::from_utf8(output).unwrap(), "a\n");
    }
}