Skip to main content

bloqade_lanes_bytecode_core/isa/
text.rs

1//! Text (`.sst`) codec for the vihaco-backed ISA — vihaco `ParsedModule` grammar.
2//!
3//! The grammar is:
4//! ```text
5//! version <major>.<minor>;
6//! fn @main() {
7//!   <instruction>
8//!   ...
9//! }
10//! ```
11//!
12//! `LanesHeader` parses the `version <major>.<minor>` directive.
13//! `LanesResolver` lowers a `ParsedModule<Instruction, LanesHeader>` into a
14//! `Program` via [`super::program::from_code`].
15
16use chumsky::prelude::*;
17use vihaco::syntax::{ParsedModule, Resolve};
18use vihaco_parser_core::Parse;
19
20use super::Instruction;
21use super::program::{Program, from_code};
22use crate::version::Version;
23
24/// Error from text (`.sst`) parsing.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum TextError {
27    /// No `version M.N;` header was found before the first instruction.
28    MissingVersion,
29    /// The version header's value could not be parsed.
30    /// Currently unreachable: a malformed version fails the whole parse via `BadInstruction`.
31    /// Retained for API stability and potential future use.
32    InvalidVersion { line: usize, value: String },
33    /// A line could not be parsed as an instruction. `line` is currently always
34    /// `0` for parse-level failures via the vihaco `ParsedModule` parser.
35    BadInstruction { line: usize, text: String },
36}
37
38impl std::fmt::Display for TextError {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        match self {
41            TextError::MissingVersion => write!(f, "missing version header"),
42            TextError::InvalidVersion { line, value } => {
43                write!(f, "line {line}: invalid version '{value}'")
44            }
45            TextError::BadInstruction { line, text } => {
46                write!(f, "line {line}: cannot parse instruction '{text}'")
47            }
48        }
49    }
50}
51
52impl std::error::Error for TextError {}
53
54// ── LanesHeader ──────────────────────────────────────────────────────────────
55
56/// The only header we support: `version <major>.<minor>`.
57#[derive(Debug, Clone, PartialEq)]
58pub enum LanesHeader {
59    Version(Version),
60}
61
62impl<'src> Parse<'src> for LanesHeader {
63    fn parser() -> impl chumsky::Parser<
64        'src,
65        &'src str,
66        Self,
67        chumsky::extra::Err<chumsky::error::Simple<'src, char>>,
68    > {
69        let uint = || {
70            any()
71                .filter(|c: &char| c.is_ascii_digit())
72                .repeated()
73                .at_least(1)
74                .collect::<String>()
75        };
76        just("version")
77            .ignore_then(chumsky::text::whitespace())
78            .ignore_then(uint().then_ignore(just('.')).then(uint()))
79            .try_map(|(maj, min), span| {
80                let major = maj
81                    .parse::<u16>()
82                    .map_err(|_| chumsky::error::Simple::new(None, span))?;
83                let minor = min
84                    .parse::<u16>()
85                    .map_err(|_| chumsky::error::Simple::new(None, span))?;
86                Ok(LanesHeader::Version(Version::new(major, minor)))
87            })
88    }
89}
90
91// ── LanesResolver ─────────────────────────────────────────────────────────────
92
93struct LanesResolver;
94
95impl Resolve<Instruction, LanesHeader> for LanesResolver {
96    type Module = Program;
97
98    fn resolve_module(
99        &mut self,
100        parsed: ParsedModule<Instruction, LanesHeader>,
101    ) -> eyre::Result<Program> {
102        // Exactly one `version M.N;` header. Additional headers almost always
103        // signal a mistake (conflicting versions), so reject them rather than
104        // silently taking the first.
105        let version = match parsed.headers.as_slice() {
106            [] => eyre::bail!("missing version header"),
107            [LanesHeader::Version(v)] => *v,
108            _ => eyre::bail!("multiple version headers"),
109        };
110
111        // Exactly one function, and it must be `@main`. The parser strips the
112        // leading `@`, so the resolved name is bare `"main"`.
113        let func = match parsed.functions.as_slice() {
114            [f] => f,
115            _ => eyre::bail!("expected exactly one function (@main)"),
116        };
117        if func.name != "main" {
118            eyre::bail!("expected function @main, found @{}", func.name);
119        }
120
121        // Default resolve_body: all lanes instructions are Direct; Raw is an error.
122        let code = self.resolve_body(func.body.clone())?;
123        Ok(from_code(version, code))
124    }
125}
126
127// ── map_resolve_err ───────────────────────────────────────────────────────────
128
129fn map_resolve_err(err: &eyre::Report) -> TextError {
130    let msg = err.to_string();
131    if msg.contains("missing version header") {
132        TextError::MissingVersion
133    } else {
134        TextError::BadInstruction { line: 0, text: msg }
135    }
136}
137
138// ── Public API ────────────────────────────────────────────────────────────────
139
140/// Parse the vihaco `fn @main` grammar into a [`Program`].
141///
142/// Expected format:
143/// ```text
144/// version <major>.<minor>;
145/// fn @main() {
146///   <instruction>
147///   ...
148/// }
149/// ```
150///
151/// Note: `TextError::BadInstruction` errors have `line: 0` for parse-level
152/// failures via vihaco `ParsedModule`; the underlying chumsky diagnostics are
153/// preserved in `text` even though a precise line number is not yet available.
154pub fn parse_text(src: &str) -> Result<Program, TextError> {
155    let parsed = ParsedModule::<Instruction, LanesHeader>::parser()
156        .parse(src)
157        .into_result()
158        .map_err(|errs| TextError::BadInstruction {
159            line: 0,
160            text: errs
161                .iter()
162                .map(|e| e.to_string())
163                .collect::<Vec<_>>()
164                .join("; "),
165        })?;
166    LanesResolver
167        .resolve_module(parsed)
168        .map_err(|e| map_resolve_err(&e))
169}
170
171/// Emit the program as the vihaco `fn @main` grammar.
172///
173/// The output is accepted by [`parse_text`] and round-trips losslessly.
174pub fn to_text(program: &Program) -> String {
175    let mut out = format!(
176        "version {}.{};\nfn @main() {{\n",
177        program.extra.version.major, program.extra.version.minor
178    );
179    for inst in &program.code {
180        out.push_str("  ");
181        out.push_str(&inst.to_string());
182        out.push('\n');
183    }
184    out.push_str("}\n");
185    out
186}
187
188// ── Tests ─────────────────────────────────────────────────────────────────────
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use vihaco::value::Value;
194    use vihaco_cpu::Instruction as Cpu;
195
196    fn sample() -> Program {
197        from_code(
198            Version::new(1, 2),
199            vec![
200                Instruction::Cpu(Cpu::Const(Value::F64(1.5))),
201                Instruction::Cpu(Cpu::Const(Value::I64(-42))),
202                Instruction::Cpu(Cpu::Dup),
203                Instruction::ConstLoc(0x0000_0000_0100_0000),
204                Instruction::ConstLane(0x0000_0000_0000_0001),
205                Instruction::ConstZone(0x0000_0003),
206                Instruction::InitialFill(2),
207                Instruction::Move(1),
208                Instruction::LocalRz(1),
209                Instruction::LocalR(3),
210                Instruction::GlobalRz,
211                Instruction::Cz,
212                Instruction::Measure(1),
213                Instruction::AwaitMeasure,
214                Instruction::NewArray(2, 10, 20),
215                Instruction::GetItem(2),
216                Instruction::SetDetector,
217                Instruction::Cpu(Cpu::Halt),
218                Instruction::Return,
219            ],
220        )
221    }
222
223    #[test]
224    fn text_round_trips_fn_main() {
225        let src = "version 1.2;\nfn @main() {\n  const_loc 0x0000000000000000\n  initial_fill 1\n  halt\n}\n";
226        let p = parse_text(src).unwrap();
227        assert_eq!(p.extra.version, Version::new(1, 2));
228        assert_eq!(p.code.len(), 3);
229        assert_eq!(parse_text(&to_text(&p)).unwrap(), p);
230    }
231
232    #[test]
233    fn text_round_trips_full_sample() {
234        let program = sample();
235        let text = to_text(&program);
236        assert_eq!(parse_text(&text).unwrap(), program);
237    }
238
239    #[test]
240    fn to_text_contains_fn_main_header() {
241        let text = to_text(&sample());
242        assert!(text.starts_with("version 1.2;\n"));
243        assert!(text.contains("fn @main() {"));
244        assert!(text.ends_with("}\n"));
245    }
246
247    #[test]
248    fn to_text_indents_instructions() {
249        let prog = from_code(Version::new(1, 0), vec![Instruction::Cpu(Cpu::Halt)]);
250        let text = to_text(&prog);
251        assert!(text.contains("  halt\n"));
252    }
253
254    #[test]
255    fn empty_program_round_trips() {
256        let prog = from_code(Version::new(1, 0), vec![]);
257        assert_eq!(parse_text(&to_text(&prog)).unwrap(), prog);
258    }
259
260    #[test]
261    fn missing_version_header_returns_error() {
262        let src = "fn @main() {\n  halt\n}\n";
263        assert_eq!(parse_text(src), Err(TextError::MissingVersion));
264    }
265
266    #[test]
267    fn bad_instruction_returns_error() {
268        let src = "version 1.0;\nfn @main() {\n  nope_nope\n}\n";
269        assert!(matches!(
270            parse_text(src),
271            Err(TextError::BadInstruction { .. })
272        ));
273    }
274
275    #[test]
276    fn version_preserved() {
277        let prog = from_code(Version::new(3, 7), vec![]);
278        let text = to_text(&prog);
279        assert!(text.starts_with("version 3.7;\n"));
280        let reparsed = parse_text(&text).unwrap();
281        assert_eq!(reparsed.extra.version, Version::new(3, 7));
282    }
283
284    #[test]
285    fn text_error_display_strings() {
286        assert_eq!(
287            TextError::MissingVersion.to_string(),
288            "missing version header"
289        );
290        assert_eq!(
291            TextError::InvalidVersion {
292                line: 2,
293                value: "1.x".into()
294            }
295            .to_string(),
296            "line 2: invalid version '1.x'"
297        );
298        assert_eq!(
299            TextError::BadInstruction {
300                line: 3,
301                text: "nope".into()
302            }
303            .to_string(),
304            "line 3: cannot parse instruction 'nope'"
305        );
306    }
307
308    #[test]
309    fn multiple_functions_rejected() {
310        // The grammar admits several `fn` blocks, but a lanes program is a
311        // single flat `@main`; the resolver rejects anything but exactly one.
312        let src = "version 1.0;\nfn @main() {\n  halt\n}\nfn @extra() {\n  halt\n}\n";
313        assert!(matches!(
314            parse_text(src),
315            Err(TextError::BadInstruction { .. })
316        ));
317    }
318
319    #[test]
320    fn syntactically_broken_source_is_a_parse_error() {
321        // An unterminated function body fails the `ParsedModule` parser itself
322        // (before resolution), so `parse_text` maps it to `BadInstruction`.
323        let src = "version 1.0;\nfn @main() {\n  halt\n";
324        assert!(matches!(
325            parse_text(src),
326            Err(TextError::BadInstruction { .. })
327        ));
328    }
329
330    #[test]
331    fn parse_error_preserves_diagnostic_text() {
332        // Parse-level failures surface the underlying chumsky diagnostic rather
333        // than a bare "parse error" placeholder, so CLI/Python errors are
334        // debuggable.
335        let src = "version 1.0;\nfn @main() {\n  halt\n";
336        match parse_text(src) {
337            Err(TextError::BadInstruction { text, .. }) => {
338                assert_ne!(text, "parse error");
339                assert!(!text.is_empty(), "diagnostic text should be non-empty");
340            }
341            other => panic!("expected BadInstruction, got {other:?}"),
342        }
343    }
344
345    #[test]
346    fn duplicate_version_headers_rejected() {
347        // Two `version` directives are ambiguous; the resolver rejects them
348        // rather than silently taking the first.
349        let src = "version 1.0;\nversion 2.0;\nfn @main() {\n  halt\n}\n";
350        assert!(matches!(
351            parse_text(src),
352            Err(TextError::BadInstruction { .. })
353        ));
354    }
355
356    #[test]
357    fn non_main_function_rejected() {
358        // The single function must be named `@main`.
359        let src = "version 1.0;\nfn @extra() {\n  halt\n}\n";
360        assert!(matches!(
361            parse_text(src),
362            Err(TextError::BadInstruction { .. })
363        ));
364    }
365}