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