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.strip_prefix("async ").map_or(prefix, str::trim_start);
41 is_identifier(prefix) || is_parameter_list(prefix)
50}
51
52fn 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 #[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 #[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 #[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}