1pub 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
31const ARGUMENT_TERMS: [&str; 3] = ["positional", "all-arguments", "argument-count"];
33
34#[derive(Debug, Clone)]
36pub struct TranslateOptions {
37 pub shebang: bool,
39 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#[derive(Debug, Clone)]
54pub struct Translation {
55 pub code: String,
57 pub preamble: String,
58 pub body: String,
59 pub diagnostics: Vec<String>,
61}
62
63fn 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 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 format!("{}\n", lines.join("\n"))
104}
105
106fn 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
124pub fn translate_shell_to_mjs(source: &str, options: &TranslateOptions) -> Translation {
126 let normalized = source.replace("\r\n", "\n").replace('\r', "\n");
130 let source = normalized.as_str();
131 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}