Skip to main content

run/engine/
javascript.rs

1use std::io::{BufRead, BufReader, Read, Write};
2use std::path::{Path, PathBuf};
3use std::process::{Command, Stdio};
4use std::time::Instant;
5
6use anyhow::{Context, Result};
7use std::sync::{Arc, Mutex};
8use std::thread;
9
10use super::{
11    ExecutionOutcome, ExecutionPayload, LanguageEngine, LanguageSession, execution_timeout,
12    run_version_command, wait_with_timeout,
13};
14
15pub struct JavascriptEngine {
16    executable: PathBuf,
17}
18
19impl Default for JavascriptEngine {
20    fn default() -> Self {
21        Self::new()
22    }
23}
24
25impl JavascriptEngine {
26    pub fn new() -> Self {
27        let executable = resolve_node_binary();
28        Self { executable }
29    }
30
31    fn binary(&self) -> &Path {
32        &self.executable
33    }
34
35    fn run_command(&self) -> Command {
36        Command::new(self.binary())
37    }
38}
39
40impl LanguageEngine for JavascriptEngine {
41    fn id(&self) -> &'static str {
42        "javascript"
43    }
44
45    fn display_name(&self) -> &'static str {
46        "JavaScript"
47    }
48
49    fn aliases(&self) -> &[&'static str] {
50        &["js", "node", "nodejs"]
51    }
52
53    fn supports_sessions(&self) -> bool {
54        true
55    }
56
57    fn validate(&self) -> Result<()> {
58        let mut cmd = self.run_command();
59        cmd.arg("--version")
60            .stdout(Stdio::null())
61            .stderr(Stdio::null());
62        cmd.status()
63            .with_context(|| format!("failed to invoke {}", self.binary().display()))?
64            .success()
65            .then_some(())
66            .ok_or_else(|| anyhow::anyhow!("{} is not executable", self.binary().display()))
67    }
68
69    fn toolchain_version(&self) -> Result<Option<String>> {
70        let mut cmd = self.run_command();
71        cmd.arg("--version");
72        let context = format!("{}", self.binary().display());
73        run_version_command(cmd, &context)
74    }
75
76    fn execute(&self, payload: &ExecutionPayload) -> Result<ExecutionOutcome> {
77        let start = Instant::now();
78        let timeout = execution_timeout();
79        let args = payload.args();
80        let output = match payload {
81            ExecutionPayload::Inline { code, .. } => {
82                let mut cmd = self.run_command();
83                cmd.arg("-e")
84                    .arg(code)
85                    .args(args)
86                    .stdin(Stdio::inherit())
87                    .stdout(Stdio::piped())
88                    .stderr(Stdio::piped());
89                let child = cmd
90                    .spawn()
91                    .with_context(|| format!("failed to start {}", self.binary().display()))?;
92                wait_with_timeout(child, timeout)?
93            }
94            ExecutionPayload::File { path, .. } => {
95                let mut cmd = self.run_command();
96                cmd.arg(path)
97                    .args(args)
98                    .stdin(Stdio::inherit())
99                    .stdout(Stdio::piped())
100                    .stderr(Stdio::piped());
101                let child = cmd
102                    .spawn()
103                    .with_context(|| format!("failed to start {}", self.binary().display()))?;
104                wait_with_timeout(child, timeout)?
105            }
106            ExecutionPayload::Stdin { code, .. } => {
107                let mut cmd = self.run_command();
108                cmd.arg("-")
109                    .args(args)
110                    .stdin(Stdio::piped())
111                    .stdout(Stdio::piped())
112                    .stderr(Stdio::piped());
113                let mut child = cmd.spawn().with_context(|| {
114                    format!(
115                        "failed to start {} for stdin execution",
116                        self.binary().display()
117                    )
118                })?;
119                if let Some(mut stdin) = child.stdin.take() {
120                    stdin.write_all(code.as_bytes())?;
121                    if !code.ends_with('\n') {
122                        stdin.write_all(b"\n")?;
123                    }
124                    stdin.flush()?;
125                }
126                wait_with_timeout(child, timeout)?
127            }
128        };
129
130        Ok(ExecutionOutcome {
131            language: self.id().to_string(),
132            exit_code: output.status.code(),
133            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
134            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
135            duration: start.elapsed(),
136        })
137    }
138
139    fn start_session(&self) -> Result<Box<dyn LanguageSession>> {
140        let mut cmd = self.run_command();
141        cmd.arg("--interactive")
142            .arg("--no-warnings")
143            .arg("--experimental-repl-await")
144            .stdin(Stdio::piped())
145            .stdout(Stdio::piped())
146            .stderr(Stdio::piped());
147
148        let mut child = cmd
149            .spawn()
150            .with_context(|| format!("failed to start {} REPL", self.binary().display()))?;
151
152        let stdout = child.stdout.take().context("missing stdout handle")?;
153        let stderr = child.stderr.take().context("missing stderr handle")?;
154
155        let stderr_buffer = Arc::new(Mutex::new(String::new()));
156        let stderr_collector = stderr_buffer.clone();
157        thread::spawn(move || {
158            let mut reader = BufReader::new(stderr);
159            let mut buf = String::new();
160            loop {
161                buf.clear();
162                match reader.read_line(&mut buf) {
163                    Ok(0) => break,
164                    Ok(_) => {
165                        let Ok(mut lock) = stderr_collector.lock() else {
166                            break;
167                        };
168                        lock.push_str(&buf);
169                    }
170                    Err(_) => break,
171                }
172            }
173        });
174
175        let mut session = JavascriptSession {
176            child,
177            stdout: BufReader::new(stdout),
178            stderr: stderr_buffer,
179        };
180
181        session.discard_prompt()?;
182
183        Ok(Box::new(session))
184    }
185}
186
187fn resolve_node_binary() -> PathBuf {
188    let candidates = ["node", "nodejs"];
189    for name in candidates {
190        if let Ok(path) = which::which(name) {
191            return path;
192        }
193    }
194    PathBuf::from("node")
195}
196
197struct JavascriptSession {
198    child: std::process::Child,
199    stdout: BufReader<std::process::ChildStdout>,
200    stderr: Arc<Mutex<String>>,
201}
202
203impl JavascriptSession {
204    fn write_code(&mut self, code: &str) -> Result<()> {
205        let stdin = self
206            .child
207            .stdin
208            .as_mut()
209            .context("javascript session stdin closed")?;
210        stdin.write_all(code.as_bytes())?;
211        if !code.ends_with('\n') {
212            stdin.write_all(b"\n")?;
213        }
214        stdin.flush()?;
215        Ok(())
216    }
217
218    fn read_until_prompt(&mut self) -> Result<String> {
219        const PROMPT: &[u8] = b"> ";
220        const CONT_PROMPT: &[u8] = b"... ";
221        let mut buffer = Vec::new();
222        loop {
223            let mut byte = [0u8; 1];
224            let read = self.stdout.read(&mut byte)?;
225            if read == 0 {
226                break;
227            }
228            buffer.extend_from_slice(&byte[..read]);
229            if buffer.ends_with(PROMPT) && !buffer.ends_with(CONT_PROMPT) {
230                break;
231            }
232        }
233
234        while buffer.ends_with(PROMPT) {
235            buffer.truncate(buffer.len() - PROMPT.len());
236        }
237
238        let mut text = String::from_utf8_lossy(&buffer).into_owned();
239        text = text.replace("\r\n", "\n");
240        text = text.replace('\r', "");
241        text = trim_continuation_prompt(text, "... ");
242        Ok(text.trim_start_matches('\n').to_string())
243    }
244
245    fn take_stderr(&self) -> String {
246        let Ok(mut lock) = self.stderr.lock() else {
247            return String::new();
248        };
249        if lock.is_empty() {
250            String::new()
251        } else {
252            let mut output = String::new();
253            std::mem::swap(&mut output, &mut *lock);
254            output
255        }
256    }
257
258    fn discard_prompt(&mut self) -> Result<()> {
259        let _ = self.read_until_prompt()?;
260        let _ = self.take_stderr();
261        Ok(())
262    }
263}
264
265impl LanguageSession for JavascriptSession {
266    fn language_id(&self) -> &str {
267        "javascript"
268    }
269
270    fn eval(&mut self, code: &str) -> Result<ExecutionOutcome> {
271        // Node.js REPL natively stores the last expression result in `_`.
272        let start = Instant::now();
273        self.write_code(code)?;
274        let stdout = self.read_until_prompt()?;
275        let stderr = self.take_stderr();
276        Ok(ExecutionOutcome {
277            language: self.language_id().to_string(),
278            exit_code: None,
279            stdout,
280            stderr,
281            duration: start.elapsed(),
282        })
283    }
284
285    fn shutdown(&mut self) -> Result<()> {
286        if let Some(mut stdin) = self.child.stdin.take() {
287            let _ = stdin.write_all(b".exit\n");
288            let _ = stdin.flush();
289        }
290        let _ = self.child.wait();
291        Ok(())
292    }
293}
294
295fn trim_continuation_prompt(mut text: String, prompt: &str) -> String {
296    if text.contains(prompt) {
297        text = text
298            .lines()
299            .map(|line| line.strip_prefix(prompt).unwrap_or(line))
300            .collect::<Vec<_>>()
301            .join("\n");
302    }
303    text
304}