styx_wasm/
lib.rs

1#![doc = include_str!("../README.md")]
2//! WebAssembly bindings for the Styx parser.
3//!
4//! This crate provides JavaScript-callable functions for parsing Styx documents,
5//! converting to JSON, and getting diagnostics.
6
7use serde::Serialize;
8use serde_json::json;
9use styx_parse::{ScalarKind, Separator};
10use styx_tree::{Entry, Object, Payload, Scalar, Sequence, Tag, Value};
11use wasm_bindgen::prelude::*;
12
13/// Serialize a value to JsValue using plain objects (not Maps).
14fn to_js_value<T: Serialize>(value: &T) -> Result<JsValue, serde_wasm_bindgen::Error> {
15    let serializer = serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true);
16    value.serialize(&serializer)
17}
18
19/// A diagnostic message from the parser.
20#[derive(Debug, Clone, Serialize)]
21pub struct Diagnostic {
22    /// The kind of error.
23    pub message: String,
24    /// Start offset in the source.
25    pub start: u32,
26    /// End offset in the source.
27    pub end: u32,
28    /// Severity: "error" or "warning".
29    pub severity: String,
30}
31
32/// Result of parsing a Styx document.
33#[derive(Debug, Clone, Serialize)]
34pub struct ParseResult {
35    /// Whether parsing succeeded (no errors).
36    pub success: bool,
37    /// List of diagnostics (errors and warnings).
38    pub diagnostics: Vec<Diagnostic>,
39}
40
41/// Parse a Styx document and return diagnostics.
42///
43/// Returns a JSON object with `success` boolean and `diagnostics` array.
44#[wasm_bindgen]
45pub fn parse(source: &str) -> JsValue {
46    let parser = styx_parse::Parser::new(source);
47    let mut events = Vec::new();
48    parser.parse(&mut events);
49
50    let mut diagnostics = Vec::new();
51    for event in events {
52        if let styx_parse::Event::Error { span, kind } = event {
53            diagnostics.push(Diagnostic {
54                message: format_error(&kind),
55                start: span.start,
56                end: span.end,
57                severity: "error".to_string(),
58            });
59        }
60    }
61
62    let result = ParseResult {
63        success: diagnostics.is_empty(),
64        diagnostics,
65    };
66
67    to_js_value(&result).unwrap_or(JsValue::NULL)
68}
69
70/// Convert a Styx document to JSON.
71///
72/// Returns a JSON string representation of the Styx document.
73/// Tags are represented as `{"$tag": "tagname", "$value": ...}`.
74/// Returns an error object if parsing fails.
75#[wasm_bindgen]
76pub fn to_json(source: &str) -> JsValue {
77    match styx_tree::parse(source) {
78        Ok(value) => {
79            let json_value = value_to_json(&value);
80            let json_string =
81                serde_json::to_string_pretty(&json_value).unwrap_or_else(|e| e.to_string());
82
83            to_js_value(&json!({
84                "success": true,
85                "json": json_value,
86                "jsonString": json_string
87            }))
88            .unwrap_or(JsValue::NULL)
89        }
90        Err(e) => to_js_value(&json!({
91            "success": false,
92            "error": e.to_string()
93        }))
94        .unwrap_or(JsValue::NULL),
95    }
96}
97
98/// Convert a Styx Value to a JSON value.
99fn value_to_json(value: &Value) -> serde_json::Value {
100    let tag = value.tag.as_ref().map(|t| t.name.as_str());
101    let payload = value.payload.as_ref().map(payload_to_json);
102
103    match (tag, payload) {
104        // Unit with no tag: null
105        (None, None) => json!(null),
106        // Scalar/sequence/object with no tag: just the payload
107        (None, Some(p)) => p,
108        // Tag with no payload: {"$tag": "name"}
109        (Some(t), None) => json!({"$tag": t}),
110        // Tagged value: {"$tag": "name", "$value": payload}
111        (Some(t), Some(p)) => json!({"$tag": t, "$value": p}),
112    }
113}
114
115/// Convert a Styx Payload to a JSON value.
116fn payload_to_json(payload: &Payload) -> serde_json::Value {
117    match payload {
118        Payload::Scalar(s) => {
119            // Try to parse as number or boolean
120            if let Ok(n) = s.text.parse::<i64>() {
121                json!(n)
122            } else if let Ok(n) = s.text.parse::<f64>() {
123                json!(n)
124            } else if s.text == "true" {
125                json!(true)
126            } else if s.text == "false" {
127                json!(false)
128            } else if s.text == "null" {
129                json!(null)
130            } else {
131                json!(s.text)
132            }
133        }
134        Payload::Sequence(seq) => sequence_to_json(seq),
135        Payload::Object(obj) => object_to_json(obj),
136    }
137}
138
139/// Convert a Styx Sequence to a JSON array.
140fn sequence_to_json(seq: &Sequence) -> serde_json::Value {
141    let items: Vec<serde_json::Value> = seq.items.iter().map(value_to_json).collect();
142    json!(items)
143}
144
145/// Convert a Styx Object to a JSON object.
146fn object_to_json(obj: &Object) -> serde_json::Value {
147    let mut map = serde_json::Map::new();
148
149    for entry in &obj.entries {
150        // Get key as string
151        let key = if entry.key.is_unit() {
152            "@".to_string()
153        } else if let Some(s) = entry.key.as_str() {
154            s.to_string()
155        } else if let Some(tag) = entry.key.tag_name() {
156            format!("@{}", tag)
157        } else {
158            // Complex key - serialize it
159            format!("{:?}", entry.key)
160        };
161
162        map.insert(key, value_to_json(&entry.value));
163    }
164
165    serde_json::Value::Object(map)
166}
167
168/// Format a parse error kind into a human-readable message.
169fn format_error(kind: &styx_parse::ParseErrorKind) -> String {
170    use styx_parse::ParseErrorKind::*;
171    match kind {
172        DuplicateKey { .. } => "Duplicate key in object".to_string(),
173        MixedSeparators => "Mixed separators: use either commas or newlines, not both".to_string(),
174        UnclosedObject => "Unclosed object: missing '}'".to_string(),
175        UnclosedSequence => "Unclosed sequence: missing ')'".to_string(),
176        InvalidEscape(seq) => format!("Invalid escape sequence: '{}'", seq),
177        UnexpectedToken => "Unexpected token".to_string(),
178        ExpectedKey => "Expected a key".to_string(),
179        ExpectedValue => "Expected a value".to_string(),
180        UnexpectedEof => "Unexpected end of input".to_string(),
181        InvalidTagName => "Invalid tag name: must match @[A-Za-z_][A-Za-z0-9_.-]*".to_string(),
182        InvalidKey => "Invalid key: cannot use objects, sequences, or heredocs as keys".to_string(),
183        DanglingDocComment => "Doc comment (///) must be followed by an entry".to_string(),
184        TooManyAtoms => {
185            "Too many atoms: did you mean @tag{}? No whitespace between tag and payload".to_string()
186        }
187        ReopenedPath { closed_path } => {
188            format!(
189                "Cannot reopen path '{}': sibling paths must appear contiguously",
190                closed_path.join(".")
191            )
192        }
193        NestIntoTerminal { terminal_path } => {
194            format!(
195                "Cannot nest into '{}': path already has a terminal value",
196                terminal_path.join(".")
197            )
198        }
199        CommaInSequence => "Sequences use whitespace separators, not commas".to_string(),
200        MissingWhitespaceBeforeBlock => "Missing whitespace before '{' or '(' after bare key (to distinguish from tags like @tag{})".to_string(),
201    }
202}
203
204/// Validate a Styx document and return whether it's valid.
205#[wasm_bindgen]
206pub fn validate(source: &str) -> bool {
207    let parser = styx_parse::Parser::new(source);
208    let mut events = Vec::new();
209    parser.parse(&mut events);
210    !events
211        .iter()
212        .any(|e| matches!(e, styx_parse::Event::Error { .. }))
213}
214
215/// Convert a JSON string to Styx format.
216///
217/// Returns a Styx document string representation of the JSON.
218/// Tagged values ({"$tag": "name", "$value": ...}) are converted back to tags.
219#[wasm_bindgen]
220pub fn from_json(json_source: &str) -> JsValue {
221    match serde_json::from_str::<serde_json::Value>(json_source) {
222        Ok(json_value) => {
223            let styx_value = json_to_value(&json_value);
224            let styx_string =
225                styx_format::format_value(&styx_value, styx_format::FormatOptions::default());
226
227            to_js_value(&json!({
228                "success": true,
229                "styxString": styx_string
230            }))
231            .unwrap_or(JsValue::NULL)
232        }
233        Err(e) => to_js_value(&json!({
234            "success": false,
235            "error": e.to_string()
236        }))
237        .unwrap_or(JsValue::NULL),
238    }
239}
240
241/// Convert a JSON value to a Styx Value.
242fn json_to_value(json: &serde_json::Value) -> Value {
243    match json {
244        serde_json::Value::Null => Value::unit(),
245
246        serde_json::Value::Bool(b) => Value {
247            tag: None,
248            payload: Some(Payload::Scalar(Scalar {
249                text: b.to_string(),
250                kind: ScalarKind::Bare,
251                span: None,
252            })),
253            span: None,
254        },
255
256        serde_json::Value::Number(n) => Value {
257            tag: None,
258            payload: Some(Payload::Scalar(Scalar {
259                text: n.to_string(),
260                kind: ScalarKind::Bare,
261                span: None,
262            })),
263            span: None,
264        },
265
266        serde_json::Value::String(s) => {
267            // Check if it needs quoting
268            let kind = if needs_quoting(s) {
269                ScalarKind::Quoted
270            } else {
271                ScalarKind::Bare
272            };
273            Value {
274                tag: None,
275                payload: Some(Payload::Scalar(Scalar {
276                    text: s.clone(),
277                    kind,
278                    span: None,
279                })),
280                span: None,
281            }
282        }
283
284        serde_json::Value::Array(arr) => {
285            let items = arr.iter().map(json_to_value).collect();
286            Value {
287                tag: None,
288                payload: Some(Payload::Sequence(Sequence { items, span: None })),
289                span: None,
290            }
291        }
292
293        serde_json::Value::Object(obj) => {
294            // Check for tagged value: {"$tag": "name", "$value": ...}
295            if let Some(serde_json::Value::String(tag_name)) = obj.get("$tag") {
296                let payload = obj.get("$value").and_then(|v| json_to_value(v).payload);
297                return Value {
298                    tag: Some(Tag {
299                        name: tag_name.clone(),
300                        span: None,
301                    }),
302                    payload,
303                    span: None,
304                };
305            }
306
307            // Regular object
308            let entries = obj
309                .iter()
310                .map(|(k, v)| Entry {
311                    key: Value {
312                        tag: None,
313                        payload: Some(Payload::Scalar(Scalar {
314                            text: k.clone(),
315                            kind: if needs_quoting(k) {
316                                ScalarKind::Quoted
317                            } else {
318                                ScalarKind::Bare
319                            },
320                            span: None,
321                        })),
322                        span: None,
323                    },
324                    value: json_to_value(v),
325                    doc_comment: None,
326                })
327                .collect();
328
329            Value {
330                tag: None,
331                payload: Some(Payload::Object(Object {
332                    entries,
333                    separator: Separator::Newline,
334                    span: None,
335                })),
336                span: None,
337            }
338        }
339    }
340}
341
342/// Check if a string needs quoting in Styx.
343fn needs_quoting(s: &str) -> bool {
344    if s.is_empty() {
345        return true;
346    }
347
348    // Check for characters that require quoting
349    s.chars().any(|c| {
350        matches!(
351            c,
352            ' ' | '\t' | '\n' | '\r' | '"' | '{' | '}' | '(' | ')' | ',' | '@' | '>' | '/'
353        )
354    }) || s.starts_with("//")
355}
356
357/// Get the version of the Styx WASM library.
358#[wasm_bindgen]
359pub fn version() -> String {
360    env!("CARGO_PKG_VERSION").to_string()
361}