incurs_codemode/
normalize.rs1pub 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 is_identifier(prefix) || is_parameter_list(prefix)
52}
53
54fn 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 #[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 #[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 #[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}