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    use crate::ty::Prim;
175
176    fn expected_api() -> Api {
177        Api {
178            version: CURRENT_SCHEMA_VERSION.to_string(),
179            modules: vec![Module {
180                name: "math".to_string(),
181                doc: None,
182                functions: vec![Function {
183                    name: "add".to_string(),
184                    params: vec![
185                        Param {
186                            name: "a".to_string(),
187                            ty: TypeRef::Prim(Prim::I32),
188                            doc: None,
189                        },
190                        Param {
191                            name: "b".to_string(),
192                            ty: TypeRef::Prim(Prim::I32),
193                            doc: None,
194                        },
195                    ],
196                    returns: Some(TypeRef::Prim(Prim::I32)),
197                    doc: Some("Adds two numbers".to_string()),
198                    throws: false,
199                    r#async: false,
200                    cancellable: false,
201                    deprecated: None,
202                }],
203                interfaces: vec![],
204                callback_interfaces: vec![],
205                structs: vec![],
206                enums: vec![],
207                errors: None,
208                modules: vec![],
209            }],
210        }
211    }
212
213    #[test]
214    fn every_format_parses_the_same_document() {
215        let yaml = r#"
216version: "0.11.0"
217modules:
218  - name: math
219    functions:
220      - name: add
221        params:
222          - name: a
223            type: i32
224          - name: b
225            type: i32
226        return: i32
227        doc: "Adds two numbers"
228"#;
229        let json = r#"{
230            "version": "0.11.0",
231            "modules": [{
232                "name": "math",
233                "functions": [{
234                    "name": "add",
235                    "params": [
236                        {"name": "a", "type": "i32"},
237                        {"name": "b", "type": "i32"}
238                    ],
239                    "return": "i32",
240                    "doc": "Adds two numbers"
241                }]
242            }]
243        }"#;
244        let toml_str = r#"
245version = "0.11.0"
246
247[[modules]]
248name = "math"
249
250[[modules.functions]]
251name = "add"
252return = "i32"
253doc = "Adds two numbers"
254
255[[modules.functions.params]]
256name = "a"
257type = "i32"
258
259[[modules.functions.params]]
260name = "b"
261type = "i32"
262"#;
263        assert_eq!(parse_api_str(yaml, "yaml").unwrap(), expected_api());
264        assert_eq!(parse_api_str(yaml, "yml").unwrap(), expected_api());
265        assert_eq!(parse_api_str(json, "json").unwrap(), expected_api());
266        assert_eq!(parse_api_str(toml_str, "toml").unwrap(), expected_api());
267    }
268
269    #[test]
270    fn unsupported_format_returns_error() {
271        assert!(matches!(
272            parse_api_str("", "xml"),
273            Err(ParseError::UnsupportedFormat(f)) if f == "xml"
274        ));
275    }
276
277    #[test]
278    fn line_col_to_offset_maps_and_clamps() {
279        let src = "ab\ncd\nef";
280        assert_eq!(line_col_to_offset(src, 1, 1), 0);
281        assert_eq!(line_col_to_offset(src, 2, 2), 4);
282        assert_eq!(line_col_to_offset(src, 0, 0), 0);
283        assert_eq!(line_col_to_offset(src, 99, 1), src.len());
284        assert_eq!(line_col_to_offset(src, 1, 99), 2);
285    }
286
287    #[test]
288    fn parse_errors_carry_spans() {
289        let yaml = "version: \"0.11.0\"\nmodules:\n  - name: [oops\n";
290        match parse_api_str(yaml, "yaml").unwrap_err() {
291            ParseError::Yaml { line, span, .. } => {
292                assert!(line > 0);
293                assert!(span.is_some());
294            }
295            other => panic!("expected YAML error, got {other:?}"),
296        }
297        match parse_api_str("{\"version\": }", "json").unwrap_err() {
298            ParseError::Json { line, column, .. } => assert!(line > 0 && column > 0),
299            other => panic!("expected JSON error, got {other:?}"),
300        }
301        match parse_api_str("version = ", "toml").unwrap_err() {
302            ParseError::Toml { span, .. } => assert!(span.is_some()),
303            other => panic!("expected TOML error, got {other:?}"),
304        }
305    }
306
307    #[test]
308    fn unknown_type_syntax_is_a_parse_error() {
309        let yaml = "version: \"0.11.0\"\nmodules:\n  - name: m\n    functions:\n      - name: f\n        params: [{ name: x, type: \"{string}\" }]\n";
310        let err = parse_api_str(yaml, "yaml").unwrap_err();
311        assert!(err.to_string().contains("map type missing"), "{err}");
312    }
313}