Skip to main content

command_stream/fy/
rules.rs

1//! The Shell -> JavaScript substitution rules.
2//!
3//! Every construct the formalizer can produce has exactly one
4//! `TranslationRule` here: a `LinkQuery` selecting the links it owns, plus one
5//! template per target language. Nothing about the translation lives outside
6//! this file — [`super::engine`] only walks the network and substitutes.
7//!
8//! Four target languages express the four contexts a node can appear in:
9//!
10//! | language                | context             | example                |
11//! |-------------------------|---------------------|------------------------|
12//! | `JavaScript`            | a statement         | ``await $`ls`;``       |
13//! | `JavaScript:command`    | text inside ``$`…```| `ls`                   |
14//! | `JavaScript:value`      | text inside `` `…` `` | `ls` without quoting |
15//! | `JavaScript:expression` | a JavaScript value  | `process.env.HOME`     |
16//!
17//! Placeholder syntax is documented in [`super::engine`].
18
19use 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
33/// A `:value` rendering falls back to the `:command` one when unquoting is a no-op.
34pub fn language_fallbacks() -> HashMap<String, Vec<String>> {
35    HashMap::from([(VALUE.to_string(), vec![COMMAND.to_string()])])
36}
37
38/// `set -e` and friends map onto `shell.set('e')`.
39const SET_FLAGS: [&str; 3] = ["e", "v", "x"];
40
41/// Escapes text so it survives inside a JavaScript template literal.
42fn 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
60/// Removes the shell quoting from a literal.
61///
62/// Quotes are meaningful to the shell (word splitting, globbing) so they are
63/// kept in `:command` context, but a JavaScript value must not contain them.
64fn 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
117/// Placeholder modes available to the templates below.
118pub 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        // Raw shell source text of the captured link.
125        ("text".to_string(), mode(None, None)),
126        // The captured link rendered as shell command text.
127        ("command".to_string(), mode(Some(COMMAND), None)),
128        // The captured link rendered as an unquoted value.
129        ("value".to_string(), mode(Some(VALUE), None)),
130        // The captured link rendered as a JavaScript expression.
131        ("expr".to_string(), mode(Some(EXPRESSION), None)),
132        // The captured link as a JavaScript string (a template literal).
133        ("string".to_string(), mode(Some(VALUE), Some(as_string))),
134        // Literal source text, escaped for a template literal.
135        ("literal".to_string(), mode(None, Some(escape_template))),
136        // Literal source text with shell quoting removed.
137        ("unquoted".to_string(), mode(None, Some(as_unquoted))),
138        // A `set` operand such as `-e`, as a `shell.set` argument.
139        ("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
150/// Declares one rule: the node term it owns, its capture name -> reference
151/// index bindings, and one template per target language.
152fn 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
163/// A word expansion: one JavaScript expression, interpolated in command text.
164fn expansion(term: &str, expression: &str) -> TranslationRule {
165    // `{{`/`}}` are literal braces, so this emits `${<expression>}`.
166    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
178/// Builds the Shell -> JavaScript rule set.
179///
180/// `track_exit_code` emits `exitCode = ...` for each command, which is only
181/// needed when the script reads `$?`.
182pub 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
201/// Script structure, commands, pipelines and the `&&`/`||` operators.
202fn structure_rules(run_command: &str) -> Vec<TranslationRule> {
203    vec![
204        // ---- Script structure ----------------------------------------------
205        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        // ---- Commands -------------------------------------------------------
218        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        // ---- Operators ------------------------------------------------------
253        // `a && b` is a conditional, not a boolean expression: `b` runs only
254        // when `a` exits zero. In command position the shell operator is kept.
255        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
280/// `if`, loops, `case` and function definitions.
281fn 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        // `*` is the shell's catch-all pattern.
335        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
349/// Assignments, `export`/`local`, and the shell builtins with a JS equivalent.
350fn 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
382/// The leaves produced by the word expander.
383fn 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        // Anything the expander could not classify stays a shell expansion: the
421        // escaped `\${...}` reaches command-stream's shell verbatim rather than
422        // being silently mistranslated.
423        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}