Skip to main content

weaveffi_model/
parse.rs

1//! Multi-format IDL parsing: turn YAML, JSON, or TOML source text into an
2//! [`Api`].
3//!
4//! [`parse_api_str`] is the entry point. On failure it returns a [`ParseError`]
5//! that carries the source text and, where available, a `miette` span, so the
6//! CLI can render a caret-annotated diagnostic at the offending line and column.
7
8use crate::ir::Api;
9use miette::{Diagnostic, SourceSpan};
10
11/// Everything that can go wrong while turning IDL source text into an [`Api`].
12///
13/// Every variant implements [`miette::Diagnostic`], pairing a message with a
14/// `help` hint and, when the underlying parser reports a location, a labeled
15/// span into the original source so the CLI can render a caret-annotated error.
16#[derive(Debug, thiserror::Error, Diagnostic)]
17pub enum ParseError {
18    /// The requested format wasn't one of `yaml`, `yml`, `json`, or `toml`.
19    /// Carries the unrecognized format string.
20    #[error("unsupported format: {0}")]
21    #[diagnostic(help("supported formats are 'yaml', 'yml', 'json', and 'toml'"))]
22    UnsupportedFormat(String),
23    /// The YAML deserializer rejected the document.
24    #[error("YAML parse error at line {line}, column {column}: {message}")]
25    #[diagnostic(help(
26        "check YAML indentation, quoting, and that all required fields have valid values"
27    ))]
28    Yaml {
29        /// 1-indexed line of the error, or `0` when the location is unknown.
30        line: usize,
31        /// 1-indexed column of the error, or `0` when the location is unknown.
32        column: usize,
33        /// Message reported by the underlying deserializer.
34        message: String,
35        /// Full source text, retained so the diagnostic can render a snippet.
36        #[source_code]
37        src: String,
38        /// Byte span of the offending location within `src`, when known.
39        #[label("here")]
40        span: Option<SourceSpan>,
41    },
42    /// The TOML deserializer rejected the document.
43    #[error("TOML parse error: {message}")]
44    #[diagnostic(help(
45        "check TOML syntax: keys, table headers, and that values use the correct types"
46    ))]
47    Toml {
48        /// Message reported by the underlying deserializer.
49        message: String,
50        /// Full source text, retained so the diagnostic can render a snippet.
51        #[source_code]
52        src: String,
53        /// Byte span of the offending location within `src`, when known.
54        #[label("here")]
55        span: Option<SourceSpan>,
56    },
57    /// The JSON deserializer rejected the document.
58    #[error("JSON parse error at line {line}, column {column}: {message}")]
59    #[diagnostic(help(
60        "check JSON syntax: matching braces/brackets, quoted keys, and trailing commas"
61    ))]
62    Json {
63        /// 1-indexed line of the error, or `0` when the location is unknown.
64        line: usize,
65        /// 1-indexed column of the error, or `0` when the location is unknown.
66        column: usize,
67        /// Message reported by the underlying deserializer.
68        message: String,
69        /// Full source text, retained so the diagnostic can render a snippet.
70        #[source_code]
71        src: String,
72        /// Byte span of the offending location within `src`, when known.
73        #[label("here")]
74        span: Option<SourceSpan>,
75    },
76}
77
78/// Convert a 1-indexed `(line, col)` pair into a 0-indexed byte offset within
79/// `src`. Returns `0` when either coordinate is `0` (i.e., unknown), and
80/// clamps to `src.len()` when the requested location is past the end.
81pub fn line_col_to_offset(src: &str, line: usize, col: usize) -> usize {
82    if line == 0 {
83        return 0;
84    }
85    let line_idx = line - 1;
86    let col_idx = col.saturating_sub(1);
87
88    let mut start = 0usize;
89    for (i, l) in src.split('\n').enumerate() {
90        if i == line_idx {
91            let line_offset = l
92                .char_indices()
93                .nth(col_idx)
94                .map(|(b, _)| b)
95                .unwrap_or(l.len());
96            return (start + line_offset).min(src.len());
97        }
98        start += l.len() + 1;
99    }
100    src.len()
101}
102
103/// Parse IDL source text in the given format into an [`Api`].
104///
105/// `format` selects the deserializer: `yaml` or `yml` for YAML, `json` for
106/// JSON, and `toml` for TOML. On failure the returned [`ParseError`] captures
107/// the source text and, when the deserializer reports one, a span for a rich
108/// diagnostic.
109///
110/// # Errors
111///
112/// Returns [`ParseError::UnsupportedFormat`] when `format` isn't a recognized
113/// format string, or the matching [`Yaml`](ParseError::Yaml),
114/// [`Json`](ParseError::Json), or [`Toml`](ParseError::Toml) variant when the
115/// source text is malformed or doesn't match the schema.
116pub fn parse_api_str(s: &str, format: &str) -> Result<Api, ParseError> {
117    match format {
118        "yaml" | "yml" => serde_yaml::from_str(s).map_err(|e| {
119            let (line, column) = e
120                .location()
121                .map(|m| (m.line(), m.column()))
122                .unwrap_or((0, 0));
123            let span = if line > 0 && column > 0 {
124                Some(SourceSpan::new(
125                    line_col_to_offset(s, line, column).into(),
126                    1,
127                ))
128            } else {
129                None
130            };
131            ParseError::Yaml {
132                line,
133                column,
134                message: e.to_string(),
135                src: s.to_string(),
136                span,
137            }
138        }),
139        "json" => serde_json::from_str(s).map_err(|e| {
140            let line = e.line();
141            let column = e.column();
142            let span = if line > 0 && column > 0 {
143                Some(SourceSpan::new(
144                    line_col_to_offset(s, line, column).into(),
145                    1,
146                ))
147            } else {
148                None
149            };
150            ParseError::Json {
151                line,
152                column,
153                message: e.to_string(),
154                src: s.to_string(),
155                span,
156            }
157        }),
158        "toml" => toml::from_str(s).map_err(|e| {
159            let span = e.span().map(|r| SourceSpan::new(r.start.into(), r.len()));
160            ParseError::Toml {
161                message: e.to_string(),
162                src: s.to_string(),
163                span,
164            }
165        }),
166        other => Err(ParseError::UnsupportedFormat(other.to_string())),
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::ir::{Function, Module, Param, TypeRef, CURRENT_SCHEMA_VERSION};
174
175    fn expected_api() -> Api {
176        Api {
177            version: CURRENT_SCHEMA_VERSION.to_string(),
178            modules: vec![Module {
179                name: "math".to_string(),
180                doc: None,
181                functions: vec![Function {
182                    name: "add".to_string(),
183                    params: vec![
184                        Param {
185                            name: "a".to_string(),
186                            ty: TypeRef::I32,
187                            doc: None,
188                        },
189                        Param {
190                            name: "b".to_string(),
191                            ty: TypeRef::I32,
192                            doc: None,
193                        },
194                    ],
195                    returns: Some(TypeRef::I32),
196                    doc: Some("Adds two numbers".to_string()),
197                    throws: false,
198                    r#async: false,
199                    cancellable: false,
200                    deprecated: None,
201                }],
202                interfaces: vec![],
203                callback_interfaces: vec![],
204                structs: vec![],
205                enums: vec![],
206                errors: None,
207                modules: vec![],
208            }],
209        }
210    }
211
212    #[test]
213    fn every_format_parses_the_same_document() {
214        let yaml = r#"
215version: "0.10.0"
216modules:
217  - name: math
218    functions:
219      - name: add
220        params:
221          - name: a
222            type: i32
223          - name: b
224            type: i32
225        return: i32
226        doc: "Adds two numbers"
227"#;
228        let json = r#"{
229            "version": "0.10.0",
230            "modules": [{
231                "name": "math",
232                "functions": [{
233                    "name": "add",
234                    "params": [
235                        {"name": "a", "type": "i32"},
236                        {"name": "b", "type": "i32"}
237                    ],
238                    "return": "i32",
239                    "doc": "Adds two numbers"
240                }]
241            }]
242        }"#;
243        let toml_str = r#"
244version = "0.10.0"
245
246[[modules]]
247name = "math"
248
249[[modules.functions]]
250name = "add"
251return = "i32"
252doc = "Adds two numbers"
253
254[[modules.functions.params]]
255name = "a"
256type = "i32"
257
258[[modules.functions.params]]
259name = "b"
260type = "i32"
261"#;
262        assert_eq!(parse_api_str(yaml, "yaml").unwrap(), expected_api());
263        assert_eq!(parse_api_str(yaml, "yml").unwrap(), expected_api());
264        assert_eq!(parse_api_str(json, "json").unwrap(), expected_api());
265        assert_eq!(parse_api_str(toml_str, "toml").unwrap(), expected_api());
266    }
267
268    #[test]
269    fn unsupported_format_returns_error() {
270        assert!(matches!(
271            parse_api_str("", "xml"),
272            Err(ParseError::UnsupportedFormat(f)) if f == "xml"
273        ));
274    }
275
276    #[test]
277    fn line_col_to_offset_maps_and_clamps() {
278        let src = "ab\ncd\nef";
279        assert_eq!(line_col_to_offset(src, 1, 1), 0);
280        assert_eq!(line_col_to_offset(src, 2, 2), 4);
281        assert_eq!(line_col_to_offset(src, 0, 0), 0);
282        assert_eq!(line_col_to_offset(src, 99, 1), src.len());
283        assert_eq!(line_col_to_offset(src, 1, 99), 2);
284    }
285
286    #[test]
287    fn parse_errors_carry_spans() {
288        let yaml = "version: \"0.10.0\"\nmodules:\n  - name: [oops\n";
289        match parse_api_str(yaml, "yaml").unwrap_err() {
290            ParseError::Yaml { line, span, .. } => {
291                assert!(line > 0);
292                assert!(span.is_some());
293            }
294            other => panic!("expected YAML error, got {other:?}"),
295        }
296        match parse_api_str("{\"version\": }", "json").unwrap_err() {
297            ParseError::Json { line, column, .. } => assert!(line > 0 && column > 0),
298            other => panic!("expected JSON error, got {other:?}"),
299        }
300        match parse_api_str("version = ", "toml").unwrap_err() {
301            ParseError::Toml { span, .. } => assert!(span.is_some()),
302            other => panic!("expected TOML error, got {other:?}"),
303        }
304    }
305
306    #[test]
307    fn unknown_type_syntax_is_a_parse_error() {
308        let yaml = "version: \"0.10.0\"\nmodules:\n  - name: m\n    functions:\n      - name: f\n        params: [{ name: x, type: \"{string}\" }]\n";
309        let err = parse_api_str(yaml, "yaml").unwrap_err();
310        assert!(err.to_string().contains("map type missing"), "{err}");
311    }
312}