Skip to main content

command_stream/fy/
mod.rs

1//! `$fy`: rule-based translation of shell scripts into command-stream modules.
2//!
3//! The pipeline has two halves, exactly as link-foundation/meta-language models
4//! translation:
5//!
6//!   1. Formalize:  shell source -> links network   ([`formalizer`])
7//!   2. Substitute: links network -> JavaScript     ([`rules`] + [`engine`])
8//!
9//! Step 2 contains no shell knowledge beyond the rule table, and step 1
10//! contains no JavaScript knowledge at all.
11
12pub mod cli;
13pub mod source;
14pub use source::{
15    translate_competitor_source, translate_shell_program, translate_source, SourceOptions,
16};
17
18pub mod engine;
19pub mod formalizer;
20pub mod lexer;
21pub mod parser;
22pub mod rules;
23pub mod word_expander;
24
25use std::collections::HashSet;
26
27use engine::RuleEngine;
28use formalizer::{formalize_shell, DeclaredNames};
29use rules::{build_rule_set, language_fallbacks, modes, TARGET};
30
31/// Terms whose presence requires `const args = process.argv.slice(2)`.
32const ARGUMENT_TERMS: [&str; 3] = ["positional", "all-arguments", "argument-count"];
33
34/// Options for [`translate_shell_to_mjs`].
35#[derive(Debug, Clone)]
36pub struct TranslateOptions {
37    /// Emit a `#!/usr/bin/env node` line.
38    pub shebang: bool,
39    /// Module to import `$` from.
40    pub module_name: String,
41}
42
43impl Default for TranslateOptions {
44    fn default() -> Self {
45        TranslateOptions {
46            shebang: true,
47            module_name: "command-stream".to_string(),
48        }
49    }
50}
51
52/// A translated module.
53#[derive(Debug, Clone)]
54pub struct Translation {
55    /// `preamble` followed by `body`.
56    pub code: String,
57    pub preamble: String,
58    pub body: String,
59    /// Constructs the rule set could not translate.
60    pub diagnostics: Vec<String>,
61}
62
63/// Builds the module header the translated statements need.
64fn build_preamble(
65    terms: &HashSet<String>,
66    names: &DeclaredNames,
67    options: &TranslateOptions,
68) -> String {
69    let mut imports = vec!["$"];
70    if terms.contains("set-option") {
71        imports.push("shell");
72    }
73
74    let mut lines: Vec<String> = Vec::new();
75    if options.shebang {
76        lines.push("#!/usr/bin/env node".to_string());
77    }
78    lines.push(format!(
79        "import {{ {} }} from '{}';",
80        imports.join(", "),
81        options.module_name
82    ));
83    lines.push(String::new());
84
85    if ARGUMENT_TERMS.iter().any(|term| terms.contains(*term)) {
86        lines.push("const args = process.argv.slice(2);".to_string());
87    }
88    if terms.contains("exit-status") {
89        lines.push("let exitCode = 0;".to_string());
90    }
91    // Shell variables have no block scope, so every non-`local` binding is
92    // hoisted to the top of the module rather than declared at its first
93    // assignment (which would break on reassignment inside a loop or branch).
94    let hoisted = names.hoisted();
95    if !hoisted.is_empty() {
96        lines.push(format!("let {};", hoisted.join(", ")));
97    }
98    if lines.last().is_some_and(|line| !line.is_empty()) {
99        lines.push(String::new());
100    }
101    // The trailing empty entry plus this newline separate the preamble from the
102    // first translated statement by exactly one blank line.
103    format!("{}\n", lines.join("\n"))
104}
105
106/// Collapses runs of three or more newlines into a blank line.
107fn collapse_blank_lines(text: &str) -> String {
108    let mut output = String::with_capacity(text.len());
109    let mut newlines = 0usize;
110    for character in text.chars() {
111        if character == '\n' {
112            newlines += 1;
113            if newlines > 2 {
114                continue;
115            }
116        } else {
117            newlines = 0;
118        }
119        output.push(character);
120    }
121    output
122}
123
124/// Translates a shell script into a command-stream ES module.
125pub fn translate_shell_to_mjs(source: &str, options: &TranslateOptions) -> Translation {
126    // A shell script checked out with CRLF endings is still a shell script:
127    // `\r` is not a token, so it is dropped before anything else looks at the
128    // text.
129    let normalized = source.replace("\r\n", "\n").replace('\r', "\n");
130    let source = normalized.as_str();
131    // A `#!/bin/sh` line selects the interpreter for the *shell* script; the
132    // translated module gets its own shebang from `build_preamble`.
133    let script = match source.strip_prefix("#!") {
134        Some(rest) => rest.split_once('\n').map_or("", |(_, rest)| rest),
135        None => source,
136    };
137
138    let formalization = formalize_shell(script);
139    let rule_set = build_rule_set(formalization.terms.contains("exit-status"));
140    let mut engine = RuleEngine::new(
141        &formalization.network,
142        &rule_set,
143        TARGET,
144        language_fallbacks(),
145        modes(),
146    );
147
148    let rendered = engine.render_root(formalization.root);
149    let body = format!("{}\n", collapse_blank_lines(&rendered).trim_end());
150    let preamble = build_preamble(&formalization.terms, &formalization.names, options);
151
152    Translation {
153        code: format!("{preamble}{body}"),
154        preamble,
155        body,
156        diagnostics: engine.report(),
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    fn translate(source: &str) -> Translation {
165        translate_shell_to_mjs(source, &TranslateOptions::default())
166    }
167
168    #[test]
169    fn translates_a_command_into_an_awaited_template_literal() {
170        let translation = translate("ls -la\n");
171        assert_eq!(translation.body, "await $`ls -la`;\n");
172        assert!(translation.diagnostics.is_empty());
173    }
174
175    #[test]
176    fn emits_a_preamble_with_the_import_and_hoisted_bindings() {
177        let translation = translate("NAME=world\necho $NAME\n");
178        assert!(translation
179            .preamble
180            .starts_with("#!/usr/bin/env node\nimport { $ } from 'command-stream';"));
181        assert!(translation.preamble.contains("let NAME;"));
182        assert!(translation.body.contains("NAME = `world`;"));
183        assert!(translation.body.contains("await $`echo ${NAME}`;"));
184    }
185
186    #[test]
187    fn translates_control_flow_into_javascript_control_flow() {
188        let translation = translate("if [ -f x ]; then\n  echo yes\nelse\n  echo no\nfi\n");
189        assert!(translation
190            .body
191            .contains("if ((await $`[ -f x ]`).code === 0) {"));
192        assert!(translation.body.contains("} else {"));
193    }
194
195    #[test]
196    fn translates_a_for_loop_and_a_pipeline() {
197        let translation = translate("for i in a b; do\n  echo $i | tr a-z A-Z\ndone\n");
198        assert!(translation.body.contains("for (const i of [`a`, `b`]) {"));
199        assert!(translation
200            .body
201            .contains("await $`echo ${i} | tr a-z A-Z`;"));
202    }
203
204    #[test]
205    fn tracks_the_exit_code_only_when_the_script_reads_it() {
206        let plain = translate("false\n");
207        assert!(!plain.body.contains("exitCode"));
208
209        let tracked = translate("false\necho $?\n");
210        assert!(tracked.preamble.contains("let exitCode = 0;"));
211        assert!(tracked.body.contains("exitCode = (await $`false`).code;"));
212    }
213
214    #[test]
215    fn maps_expansions_onto_javascript_expressions() {
216        let translation = translate("echo \"$1 $# ${HOME}\"\n");
217        assert!(translation.body.contains("${args[0]}"));
218        assert!(translation.body.contains("${args.length}"));
219        assert!(translation.body.contains("${process.env.HOME}"));
220    }
221
222    #[test]
223    fn keeps_comments_and_drops_the_source_shebang() {
224        let translation = translate("#!/bin/sh\n# note\nls\n");
225        assert_eq!(translation.body, "// note\nawait $`ls`;\n");
226    }
227
228    #[test]
229    fn translates_a_script_with_crlf_line_endings() {
230        let translation = translate("ls\r\necho hi\r\n");
231        assert_eq!(translation.body, "await $`ls`;\nawait $`echo hi`;\n");
232        assert!(translation.diagnostics.is_empty());
233    }
234
235    #[test]
236    fn honours_the_options() {
237        let translation = translate_shell_to_mjs(
238            "ls\n",
239            &TranslateOptions {
240                shebang: false,
241                module_name: "../src/index.mjs".to_string(),
242            },
243        );
244        assert!(!translation.preamble.contains("#!"));
245        assert!(translation.preamble.contains("from '../src/index.mjs';"));
246    }
247}