Skip to main content

incurs_codemode/
normalize.rs

1/// Normalizes model-generated JavaScript into an async zero-argument function.
2pub fn normalize_code(code: &str) -> String {
3    let source = strip_fence(code.trim()).trim();
4    if source.is_empty() {
5        return "async () => {}".to_string();
6    }
7    if is_arrow(source) {
8        return source.to_string();
9    }
10    if let Some(inner) = source.strip_prefix("export default ") {
11        return normalize_code(inner.trim_end_matches(';'));
12    }
13    if let Some(name) = single_function_name(source) {
14        return format!("async () => {{\n{source}\nreturn {name}();\n}}");
15    }
16    if let Some((before, expression)) = split_last_expression(source) {
17        return format!("async () => {{\n{before}return ({expression})\n}}");
18    }
19    format!("async () => {{\n{source}\n}}")
20}
21
22fn strip_fence(source: &str) -> &str {
23    let Some(after) = source.strip_prefix("```") else {
24        return source;
25    };
26    let Some(newline) = after.find('\n') else {
27        return source;
28    };
29    let body = &after[newline + 1..];
30    body.strip_suffix("```")
31        .map(str::trim_end)
32        .unwrap_or(source)
33}
34
35fn is_arrow(source: &str) -> bool {
36    let Some(arrow) = top_level_arrow(source) else {
37        return false;
38    };
39    let prefix = source[..arrow].trim();
40    let prefix = prefix.strip_prefix("async ").map_or(prefix, str::trim_start);
41    // The prefix has to be the arrow's own parameter list and nothing else.
42    //
43    // Testing only that it ends with `)` accepted any program whose first
44    // top-level `=>` happened to follow a parenthesised group, so
45    // `const f = (a) => a; return f(1);` was mistaken for a single arrow
46    // expression, left unwrapped, and failed to parse — while the same program
47    // written `const f = a => a;` worked. A parameter list is either a bare
48    // identifier or one balanced group spanning the whole prefix.
49    is_identifier(prefix) || is_parameter_list(prefix)
50}
51
52/// Returns whether the text is exactly one parenthesised group.
53///
54/// The group must open at the first character and close at the last, so
55/// `(a, b)` qualifies while `const f = (a)` and `(a) + (b)` do not.
56fn is_parameter_list(prefix: &str) -> bool {
57    if !prefix.starts_with('(') || !prefix.ends_with(')') {
58        return false;
59    }
60    let mut depth = 0usize;
61    for (index, ch) in prefix.char_indices() {
62        match ch {
63            '(' | '[' | '{' => depth += 1,
64            ')' | ']' | '}' => {
65                depth = depth.saturating_sub(1);
66                if depth == 0 && index + ch.len_utf8() != prefix.len() {
67                    return false;
68                }
69            }
70            _ => {}
71        }
72    }
73    depth == 0
74}
75
76fn top_level_arrow(source: &str) -> Option<usize> {
77    let mut depth = 0;
78    let mut quote = None;
79    let mut escaped = false;
80    let mut chars = source.char_indices().peekable();
81    while let Some((index, ch)) = chars.next() {
82        if let Some(active) = quote {
83            if escaped {
84                escaped = false;
85            } else if ch == '\\' {
86                escaped = true;
87            } else if ch == active {
88                quote = None;
89            }
90            continue;
91        }
92        match ch {
93            '\'' | '"' | '`' => quote = Some(ch),
94            '(' | '[' | '{' => depth += 1,
95            ')' | ']' | '}' => depth -= 1,
96            '=' if depth == 0 && chars.peek().is_some_and(|(_, next)| *next == '>') => {
97                return Some(index);
98            }
99            _ => {}
100        }
101    }
102    None
103}
104
105fn single_function_name(source: &str) -> Option<&str> {
106    let rest = source
107        .strip_prefix("async function ")
108        .or_else(|| source.strip_prefix("function "))?;
109    let end = rest.find('(')?;
110    let name = rest[..end].trim();
111    if is_identifier(name) && balanced(source) {
112        Some(name)
113    } else {
114        None
115    }
116}
117
118fn split_last_expression(source: &str) -> Option<(&str, &str)> {
119    if !balanced(source) || source.ends_with('}') {
120        return None;
121    }
122    let mut depth = 0;
123    let mut quote = None;
124    let mut escaped = false;
125    let mut split = None;
126    for (index, ch) in source.char_indices() {
127        if let Some(active) = quote {
128            if escaped {
129                escaped = false;
130            } else if ch == '\\' {
131                escaped = true;
132            } else if ch == active {
133                quote = None;
134            }
135            continue;
136        }
137        match ch {
138            '\'' | '"' | '`' => quote = Some(ch),
139            '(' | '[' | '{' => depth += 1,
140            ')' | ']' | '}' => depth -= 1,
141            ';' if depth == 0 => split = Some(index + 1),
142            _ => {}
143        }
144    }
145    let mut index = split.unwrap_or(0);
146    while source[index..]
147        .chars()
148        .next()
149        .is_some_and(char::is_whitespace)
150    {
151        index += source[index..].chars().next().unwrap().len_utf8();
152    }
153    let expression = source[index..].trim().trim_end_matches(';').trim();
154    if expression.is_empty() || starts_statement(expression) {
155        None
156    } else {
157        Some((&source[..index], expression))
158    }
159}
160
161fn starts_statement(source: &str) -> bool {
162    [
163        "const ",
164        "let ",
165        "var ",
166        "return ",
167        "throw ",
168        "if ",
169        "for ",
170        "while ",
171        "class ",
172        "function ",
173        "import ",
174        "export ",
175        "try ",
176        "switch ",
177    ]
178    .iter()
179    .any(|prefix| source.starts_with(prefix))
180}
181
182fn balanced(source: &str) -> bool {
183    let mut stack = Vec::new();
184    let mut quote = None;
185    let mut escaped = false;
186    for ch in source.chars() {
187        if let Some(active) = quote {
188            if escaped {
189                escaped = false;
190            } else if ch == '\\' {
191                escaped = true;
192            } else if ch == active {
193                quote = None;
194            }
195            continue;
196        }
197        match ch {
198            '\'' | '"' | '`' => quote = Some(ch),
199            '(' | '[' | '{' => stack.push(ch),
200            ')' if stack.pop() != Some('(') => return false,
201            ']' if stack.pop() != Some('[') => return false,
202            '}' if stack.pop() != Some('{') => return false,
203            _ => {}
204        }
205    }
206    stack.is_empty() && quote.is_none()
207}
208
209fn is_identifier(value: &str) -> bool {
210    let mut chars = value.chars();
211    chars
212        .next()
213        .is_some_and(|ch| ch == '_' || ch == '$' || ch.is_ascii_alphabetic())
214        && chars.all(|ch| ch == '_' || ch == '$' || ch.is_ascii_alphanumeric())
215}
216
217#[cfg(test)]
218mod tests {
219
220    /// A program that names an arrow with a parenthesised parameter list was
221    /// mistaken for a single arrow expression and left unwrapped, so it reached
222    /// the sandbox as a statement list in expression position and failed with an
223    /// opaque "Exception generated by QuickJS". Writing the same program with a
224    /// bare parameter worked, which is what made it look like a sandbox fault
225    /// rather than a normalisation one.
226    #[test]
227    fn a_named_arrow_with_a_parenthesised_parameter_is_still_a_program() {
228        for source in [
229            "const f = (a) => a + 1; return f(1);",
230            "const f = (a, b) => a + b; return f(1, 2);",
231            "const run = (dir, t) => dir + t;\nreturn run(\"a\", \"b\");",
232            "const f = async (a) => a; return await f(1);",
233        ] {
234            let normalized = normalize_code(source);
235            assert!(
236                normalized.starts_with("async () => {"),
237                "must be wrapped as a program, got: {normalized}"
238            );
239        }
240    }
241
242    /// The other half of the same boundary: a source that really is one arrow
243    /// must still be passed through untouched.
244    #[test]
245    fn a_bare_arrow_expression_is_left_alone() {
246        for source in [
247            "(a) => a + 1",
248            "(a, b) => a + b",
249            "async (a) => a",
250            "a => a + 1",
251            "async () => { return 1; }",
252        ] {
253            assert_eq!(
254                normalize_code(source),
255                source,
256                "a genuine arrow must not be wrapped"
257            );
258        }
259    }
260
261    /// A parenthesised group that is not a parameter list must not be read as one.
262    #[test]
263    fn a_parenthesised_expression_before_an_arrow_is_not_a_parameter_list() {
264        assert!(!is_parameter_list("const f = (a)"));
265        assert!(!is_parameter_list("(a) + (b)"));
266        assert!(is_parameter_list("(a)"));
267        assert!(is_parameter_list("(a, b = (1))"));
268    }
269    use super::*;
270
271    #[test]
272    fn normalizes_common_model_outputs() {
273        assert_eq!(normalize_code(""), "async () => {}");
274        assert_eq!(normalize_code("async () => 1"), "async () => 1");
275        assert_eq!(
276            normalize_code("const x = 1;\nx + 2"),
277            "async () => {\nconst x = 1;\nreturn (x + 2)\n}"
278        );
279        assert_eq!(
280            normalize_code("```js\nstate.read({ id: 1 })\n```"),
281            "async () => {\nreturn (state.read({ id: 1 }))\n}"
282        );
283        assert_eq!(
284            normalize_code(
285                "const token = await codemode.step(\"token\", async () => \"stable\");\nreturn token;"
286            ),
287            "async () => {\nconst token = await codemode.step(\"token\", async () => \"stable\");\nreturn token;\n}"
288        );
289    }
290}