Skip to main content

vihaco_parser/
lib.rs

1// SPDX-FileCopyrightText: 2026 The vihaco Authors
2// SPDX-License-Identifier: MIT
3
4pub mod impls;
5
6pub use impls::{bare_token, ident, metadata_key, trivia, BareToken, Ident, QuotedString};
7
8use chumsky::error::Simple;
9use chumsky::extra;
10
11/// Marker for enums whose pattern-derived parser represents instruction
12/// syntax.
13///
14/// `#[derive(vihaco_parser_derive::Parse)]` implements this trait for enums annotated
15/// with `#[syntax_class(instruction)]` or `#[syntax_class(instruction, head = "...")]`.
16pub trait SurfaceInstruction {}
17
18/// A parser whose input is `&'src str` (char stream) and whose error type is `Simple<char>`.
19///
20/// The lifetime `'src` is the input lifetime. Output type `Self` is owned and does not borrow
21/// from the input.
22pub trait Parse<'src>: Sized {
23    fn parser() -> impl chumsky::Parser<'src, &'src str, Self, extra::Err<Simple<'src, char>>>;
24}
25
26#[cfg(test)]
27mod tests {
28    use super::*;
29    use chumsky::Parser;
30
31    fn parses<'src, T: Parse<'src>>(input: &'src str) -> T {
32        T::parser().parse(input).into_result().unwrap()
33    }
34
35    #[test]
36    fn i64_basic() {
37        assert_eq!(parses::<i64>("42"), 42);
38    }
39    #[test]
40    fn i32_basic() {
41        assert_eq!(parses::<i32>("7"), 7);
42    }
43    #[test]
44    fn u64_basic() {
45        assert_eq!(parses::<u64>("100"), 100);
46    }
47    #[test]
48    fn u32_basic() {
49        assert_eq!(parses::<u32>("0"), 0);
50    }
51    #[test]
52    fn usize_basic() {
53        assert_eq!(parses::<usize>("9"), 9);
54    }
55    #[test]
56    fn f64_int() {
57        assert_eq!(parses::<f64>("3"), 3.0);
58    }
59    #[test]
60    #[allow(clippy::approx_constant)]
61    fn f64_float() {
62        assert_eq!(parses::<f64>("3.14"), 3.14);
63    }
64    #[test]
65    fn f32_float() {
66        assert!((parses::<f32>("1.5") - 1.5f32).abs() < 1e-6);
67    }
68    #[test]
69    fn i64_negative() {
70        assert_eq!(parses::<i64>("-42"), -42);
71    }
72    #[test]
73    fn i32_negative() {
74        assert_eq!(parses::<i32>("-7"), -7);
75    }
76    #[test]
77    fn f64_negative() {
78        assert_eq!(parses::<f64>("-0.5"), -0.5);
79    }
80    #[test]
81    fn f64_negative_scientific() {
82        assert_eq!(parses::<f64>("-1.0e-3"), -1.0e-3);
83    }
84    #[test]
85    fn u64_rejects_negative() {
86        assert!(u64::parser().parse("-1").into_result().is_err());
87    }
88    #[test]
89    fn integer_overflow_is_a_parse_error() {
90        assert!(i32::parser().parse("2147483648").has_errors());
91        assert!(i32::parser().parse("-2147483649").has_errors());
92        assert!(i64::parser().parse("9223372036854775808").has_errors());
93        assert!(u32::parser().parse("4294967296").has_errors());
94        assert!(u64::parser().parse("18446744073709551616").has_errors());
95        assert_eq!(parses::<i32>("-2147483648"), i32::MIN);
96        assert_eq!(parses::<u64>("18446744073709551615"), u64::MAX);
97    }
98
99    #[test]
100    fn bool_true() {
101        assert!(parses::<bool>("true"));
102    }
103    #[test]
104    fn bool_false() {
105        assert!(!parses::<bool>("false"));
106    }
107    #[test]
108    fn ident_word() {
109        assert_eq!(parses::<Ident>("hello"), Ident("hello".to_owned()));
110    }
111
112    #[test]
113    fn ident_stops_at_whitespace() {
114        // Without a trailing end(), Parser::parse() requires consuming all input — so a
115        // token parser given "hello world" fails because " world" is left unconsumed.
116        // Use lazy() / nested combinators for composition; that's not this test's job.
117        let result = Ident::parser().parse("hello world").into_result();
118        assert!(result.is_err());
119    }
120
121    #[test]
122    fn ident_operand_with_colons() {
123        assert_eq!(
124            ident().parse("AOD0:T1:A").into_result().unwrap(),
125            "AOD0:T1:A"
126        );
127    }
128
129    #[test]
130    fn ident_stops_at_comma() {
131        let result = ident()
132            .then_ignore(chumsky::primitive::just(','))
133            .parse("foo,")
134            .into_result();
135        assert_eq!(result.unwrap(), "foo");
136    }
137
138    #[test]
139    fn ident_allows_dots() {
140        assert_eq!(ident().parse("a.b.c").into_result().unwrap(), "a.b.c");
141    }
142
143    #[test]
144    fn ident_digi_target() {
145        assert_eq!(ident().parse("DIGI:0").into_result().unwrap(), "DIGI:0");
146    }
147
148    #[test]
149    fn ident_rejects_symbol_sigil_and_quote_characters() {
150        assert!(Ident::parser().parse("@target").has_errors());
151        assert!(Ident::parser().parse("\"target\"").has_errors());
152        assert!(Ident::parser().parse("'target").has_errors());
153        assert!(Ident::parser().parse("`target`").has_errors());
154    }
155
156    #[test]
157    fn bare_token_accepts_symbol_sigil_but_rejects_quotes() {
158        assert_eq!(
159            parses::<BareToken>("@target"),
160            BareToken("@target".to_owned())
161        );
162        assert!(BareToken::parser().parse("\"target\"").has_errors());
163    }
164
165    #[test]
166    fn ident_rejects_empty() {
167        assert!(ident().parse("").into_result().is_err());
168    }
169
170    #[test]
171    fn ident_rejects_leading_ws() {
172        assert!(ident().parse("  hello").into_result().is_err());
173    }
174
175    #[test]
176    fn ident_stops_at_open_paren() {
177        let result = ident()
178            .then_ignore(chumsky::primitive::just('('))
179            .parse("foo(")
180            .into_result();
181        assert_eq!(result.unwrap(), "foo");
182    }
183
184    #[test]
185    fn ident_stops_at_brace() {
186        let result = ident()
187            .then_ignore(chumsky::primitive::just('{'))
188            .parse("device{")
189            .into_result();
190        assert_eq!(result.unwrap(), "device");
191    }
192
193    #[test]
194    fn quoted_string_supports_spaces_and_escapes() {
195        assert_eq!(
196            parses::<QuotedString>("\"hello\\nworld\""),
197            QuotedString("hello\nworld".to_owned())
198        );
199    }
200
201    #[test]
202    fn lexical_newtypes_expose_owned_and_borrowed_text() {
203        let ident = Ident("target".to_owned());
204        assert_eq!(ident.as_str(), "target");
205        assert_eq!(ident.to_string(), "target");
206        assert_eq!(String::from(ident), "target");
207    }
208
209    #[test]
210    fn vec_uses_square_brackets_and_commas() {
211        assert_eq!(parses::<Vec<f64>>("[1.0, 2.5]"), vec![1.0, 2.5]);
212        assert!(parses::<Vec<f64>>("[]").is_empty());
213    }
214
215    #[test]
216    fn tuple_uses_parentheses_and_a_comma() {
217        assert_eq!(parses::<(i64, f64)>("(1, 2.5)"), (1, 2.5));
218    }
219
220    #[test]
221    fn vec_supports_nested_tuple_items() {
222        assert_eq!(
223            parses::<Vec<(f64, f64)>>("[(1.0, 2.0), (3.0, 4.0)]"),
224            vec![(1.0, 2.0), (3.0, 4.0)]
225        );
226    }
227}