1use std::collections::HashMap;
20
21use meta_language::link_network::LinkType;
22use meta_language::query::LinkQuery;
23use meta_language::translation_rules::{TranslationRule, TranslationRuleSet};
24
25use super::engine::Mode;
26use super::formalizer::SHELL_LANGUAGE;
27
28pub const TARGET: &str = "JavaScript";
29pub const COMMAND: &str = "JavaScript:command";
30pub const VALUE: &str = "JavaScript:value";
31pub const EXPRESSION: &str = "JavaScript:expression";
32
33pub fn language_fallbacks() -> HashMap<String, Vec<String>> {
35 HashMap::from([(VALUE.to_string(), vec![COMMAND.to_string()])])
36}
37
38const SET_FLAGS: [&str; 3] = ["e", "v", "x"];
40
41fn escape_template(text: &str) -> String {
43 let mut escaped = String::with_capacity(text.len());
44 let mut characters = text.chars().peekable();
45 while let Some(character) = characters.next() {
46 match character {
47 '`' | '\\' => {
48 escaped.push('\\');
49 escaped.push(character);
50 }
51 '$' if characters.peek() == Some(&'{') => {
52 escaped.push_str("\\$");
53 }
54 _ => escaped.push(character),
55 }
56 }
57 escaped
58}
59
60fn strip_shell_quotes(text: &str) -> String {
65 let characters: Vec<char> = text.chars().collect();
66 let mut output = String::new();
67 let mut quote: Option<char> = None;
68 let mut index = 0usize;
69
70 while index < characters.len() {
71 let character = characters[index];
72 if character == '\\' && quote != Some('\'') {
73 if let Some(&next) = characters.get(index + 1) {
74 output.push(next);
75 }
76 index += 2;
77 continue;
78 }
79 if quote.is_none() && (character == '"' || character == '\'') {
80 quote = Some(character);
81 index += 1;
82 continue;
83 }
84 if Some(character) == quote {
85 quote = None;
86 index += 1;
87 continue;
88 }
89 output.push(character);
90 index += 1;
91 }
92 output
93}
94
95fn quote_json(text: &str) -> String {
96 let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
97 format!("\"{escaped}\"")
98}
99
100fn as_string(text: &str) -> String {
101 format!("`{text}`")
102}
103
104fn as_unquoted(text: &str) -> String {
105 escape_template(&strip_shell_quotes(text))
106}
107
108fn as_flag(text: &str) -> String {
109 let letter = text.trim_start_matches('-');
110 if SET_FLAGS.contains(&letter) {
111 quote_json(letter)
112 } else {
113 quote_json(text)
114 }
115}
116
117pub fn modes() -> HashMap<String, Mode> {
119 let mode = |language: Option<&str>, transform: Option<fn(&str) -> String>| Mode {
120 language: language.map(str::to_string),
121 transform,
122 };
123 HashMap::from([
124 ("text".to_string(), mode(None, None)),
126 ("command".to_string(), mode(Some(COMMAND), None)),
128 ("value".to_string(), mode(Some(VALUE), None)),
130 ("expr".to_string(), mode(Some(EXPRESSION), None)),
132 ("string".to_string(), mode(Some(VALUE), Some(as_string))),
134 ("literal".to_string(), mode(None, Some(escape_template))),
136 ("unquoted".to_string(), mode(None, Some(as_unquoted))),
138 ("flag".to_string(), mode(None, Some(as_flag))),
140 ])
141}
142
143fn syntax(term: &str) -> LinkQuery {
144 LinkQuery::new()
145 .with_link_type(LinkType::Syntax)
146 .with_language(SHELL_LANGUAGE)
147 .with_term(term)
148}
149
150fn rule(term: &str, captures: &[(&str, usize)], templates: &[(&str, &str)]) -> TranslationRule {
153 let mut created = TranslationRule::new(term, syntax(term));
154 for (name, index) in captures {
155 created = created.with_reference_capture(*name, *index);
156 }
157 for (language, text) in templates {
158 created = created.with_template(*language, *text);
159 }
160 created
161}
162
163fn expansion(term: &str, expression: &str) -> TranslationRule {
165 let interpolated = format!("${{{{{expression}}}}}");
167 rule(
168 term,
169 &[("name", 0)],
170 &[
171 (COMMAND, interpolated.as_str()),
172 (VALUE, interpolated.as_str()),
173 (EXPRESSION, expression),
174 ],
175 )
176}
177
178pub fn build_rule_set(track_exit_code: bool) -> TranslationRuleSet {
183 let run_command = if track_exit_code {
184 "exitCode = (await $`{.:command}`).code;"
185 } else {
186 "await $`{.:command}`;"
187 };
188
189 let mut rule_set = TranslationRuleSet::new("shell-to-javascript");
190 for created in structure_rules(run_command)
191 .into_iter()
192 .chain(control_flow_rules())
193 .chain(declaration_rules())
194 .chain(expansion_rules())
195 {
196 rule_set.add_rule(created);
197 }
198 rule_set
199}
200
201fn structure_rules(run_command: &str) -> Vec<TranslationRule> {
203 vec![
204 rule(
206 "script",
207 &[],
208 &[(TARGET, "{*.|\\n}"), (COMMAND, "{*.:command|; }")],
209 ),
210 rule(
211 "block",
212 &[],
213 &[(TARGET, "{*.|\\n}"), (COMMAND, "{*.:command|; }")],
214 ),
215 rule("blank", &[], &[(TARGET, "")]),
216 rule("comment", &[("body", 0)], &[(TARGET, "// {body:text}")]),
217 rule(
219 "command",
220 &[("words", 0), ("redirects", 1), ("prefix", 2)],
221 &[
222 (TARGET, run_command),
223 (
224 COMMAND,
225 "{?prefix}{prefix:command} {/prefix}{words:command}{redirects:command}",
226 ),
227 ],
228 ),
229 rule("word-list", &[], &[(COMMAND, "{*.:command| }")]),
230 rule("redirect-list", &[], &[(COMMAND, "{*.:command}")]),
231 rule(
232 "redirect",
233 &[("operator", 0), ("target", 1)],
234 &[(COMMAND, " {operator:text}{target:command}")],
235 ),
236 rule("assignment-list", &[], &[(COMMAND, "{*.:command| }")]),
237 rule(
238 "word",
239 &[],
240 &[(COMMAND, "{*.:command}"), (VALUE, "{*.:value}")],
241 ),
242 rule(
243 "pipeline",
244 &[],
245 &[(TARGET, run_command), (COMMAND, "{*.:command| | }")],
246 ),
247 rule(
248 "subshell",
249 &[("body", 0)],
250 &[(TARGET, run_command), (COMMAND, "({body:command})")],
251 ),
252 rule(
256 "and",
257 &[("left", 0), ("right", 1)],
258 &[
259 (
260 TARGET,
261 "if ((await $`{left:command}`).code === 0) {\n {right}\n}",
262 ),
263 (COMMAND, "{left:command} && {right:command}"),
264 ],
265 ),
266 rule(
267 "or",
268 &[("left", 0), ("right", 1)],
269 &[
270 (
271 TARGET,
272 "if ((await $`{left:command}`).code !== 0) {\n {right}\n}",
273 ),
274 (COMMAND, "{left:command} || {right:command}"),
275 ],
276 ),
277 ]
278}
279
280fn control_flow_rules() -> Vec<TranslationRule> {
282 vec![
283 rule(
284 "if",
285 &[("condition", 0), ("consequent", 1), ("alternative", 2)],
286 &[(
287 TARGET,
288 concat!(
289 "if ((await $`{condition:command}`).code === 0) {\n {consequent}\n}",
290 "{?alternative} else {\n {alternative}\n}{/alternative}"
291 ),
292 )],
293 ),
294 rule(
295 "while",
296 &[("condition", 0), ("body", 1)],
297 &[(
298 TARGET,
299 "while ((await $`{condition:command}`).code === 0) {\n {body}\n}",
300 )],
301 ),
302 rule(
303 "until",
304 &[("condition", 0), ("body", 1)],
305 &[(
306 TARGET,
307 "while ((await $`{condition:command}`).code !== 0) {\n {body}\n}",
308 )],
309 ),
310 rule(
311 "for",
312 &[("name", 0), ("items", 1), ("body", 2)],
313 &[(
314 TARGET,
315 "for (const {name:text} of [{*items:string|, }]) {\n {body}\n}",
316 )],
317 ),
318 rule(
319 "case",
320 &[("subject", 0), ("branches", 1)],
321 &[(TARGET, "switch ({subject:string}) {\n {*branches|\n }\n}")],
322 ),
323 rule(
324 "case-branch",
325 &[("patterns", 0), ("body", 1)],
326 &[(TARGET, "{patterns}\n {body}\n break;")],
327 ),
328 rule("pattern-list", &[], &[(TARGET, "{*.|\\n}")]),
329 rule(
330 "pattern",
331 &[("name", 0)],
332 &[(TARGET, "case {name:string}:")],
333 ),
334 rule("pattern-default", &[], &[(TARGET, "default:")]),
336 rule(
337 "function",
338 &[("name", 0), ("body", 1)],
339 &[(TARGET, "async function {name:text}(...args) {\n {body}\n}")],
340 ),
341 rule(
342 "name",
343 &[("name", 0)],
344 &[(TARGET, "{name:text}"), (COMMAND, "{name:text}")],
345 ),
346 ]
347}
348
349fn declaration_rules() -> Vec<TranslationRule> {
351 vec![
352 rule(
353 "assignment",
354 &[("name", 0), ("value", 1)],
355 &[(TARGET, "{name:text} = {value:string};")],
356 ),
357 rule(
358 "local",
359 &[("name", 0), ("value", 1)],
360 &[(TARGET, "let {name:text} = {value:string};")],
361 ),
362 rule(
363 "export",
364 &[("name", 0), ("value", 1)],
365 &[(TARGET, "process.env.{name:text} = {value:string};")],
366 ),
367 rule("set-option", &[], &[(TARGET, "shell.set({*.:flag|, });")]),
368 rule("exit", &[], &[(TARGET, "process.exit({*.:value|});")]),
369 rule(
370 "return",
371 &[("first", 0)],
372 &[(TARGET, "return{?first} {first:string}{/first};")],
373 ),
374 rule(
375 "source",
376 &[("file", 0)],
377 &[(TARGET, "await import({file:string});")],
378 ),
379 ]
380}
381
382fn expansion_rules() -> Vec<TranslationRule> {
384 vec![
385 rule(
386 "literal",
387 &[("name", 0)],
388 &[
389 (COMMAND, "{name:literal}"),
390 (VALUE, "{name:unquoted}"),
391 (EXPRESSION, "{name:string}"),
392 ],
393 ),
394 expansion("variable", "{name:text}"),
395 expansion("env-variable", "process.env.{name:text}"),
396 expansion("positional", "args[{name:text}]"),
397 expansion("script-name", "process.argv[1]"),
398 expansion("all-arguments", "args.join(' ')"),
399 expansion("argument-count", "args.length"),
400 expansion("exit-status", "exitCode"),
401 expansion("process-id", "process.pid"),
402 rule(
403 "default-expansion",
404 &[("name", 0), ("value", 1), ("fallback", 2)],
405 &[
406 (COMMAND, "${{{value:expr} ?? {fallback:string}}}"),
407 (VALUE, "${{{value:expr} ?? {fallback:string}}}"),
408 (EXPRESSION, "({value:expr} ?? {fallback:string})"),
409 ],
410 ),
411 rule(
412 "substitution",
413 &[("body", 0)],
414 &[
415 (COMMAND, "${{(await $`{body:command}`).stdout.trim()}}"),
416 (VALUE, "${{(await $`{body:command}`).stdout.trim()}}"),
417 (EXPRESSION, "(await $`{body:command}`).stdout.trim()"),
418 ],
419 ),
420 rule(
424 "unsupported-expansion",
425 &[("name", 0)],
426 &[
427 (COMMAND, "\\${{{name:text}}}"),
428 (VALUE, "\\${{{name:text}}}"),
429 ],
430 ),
431 ]
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437
438 #[test]
439 fn escapes_template_literal_syntax() {
440 assert_eq!(escape_template("a`b"), "a\\`b");
441 assert_eq!(escape_template("${x}"), "\\${x}");
442 }
443
444 #[test]
445 fn strips_shell_quoting() {
446 assert_eq!(strip_shell_quotes("\"hi there\""), "hi there");
447 assert_eq!(strip_shell_quotes("'a\\b'"), "a\\b");
448 }
449
450 #[test]
451 fn maps_set_flags() {
452 assert_eq!(as_flag("-e"), "\"e\"");
453 assert_eq!(as_flag("-o"), "\"-o\"");
454 }
455
456 #[test]
457 fn every_rule_is_uniquely_named() {
458 let rule_set = build_rule_set(false);
459 let mut names: Vec<&str> = rule_set.rules().iter().map(TranslationRule::name).collect();
460 let count = names.len();
461 names.sort_unstable();
462 names.dedup();
463 assert_eq!(names.len(), count);
464 }
465}