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    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 execute(&self, payload: &ExecutionPayload) -> Result<ExecutionOutcome> {
70        let start = Instant::now();
71        let timeout = execution_timeout();
72        let output = match payload {
73            ExecutionPayload::Inline { code } => {
74                let mut cmd = self.run_command();
75                cmd.arg("-e")
76                    .arg(code)
77                    .stdin(Stdio::inherit())
78                    .stdout(Stdio::piped())
79                    .stderr(Stdio::piped());
80                let child = cmd
81                    .spawn()
82                    .with_context(|| format!("failed to start {}", self.binary().display()))?;
83                wait_with_timeout(child, timeout)?
84            }
85            ExecutionPayload::File { path } => {
86                let mut cmd = self.run_command();
87                cmd.arg(path)
88                    .stdin(Stdio::inherit())
89                    .stdout(Stdio::piped())
90                    .stderr(Stdio::piped());
91                let child = cmd
92                    .spawn()
93                    .with_context(|| format!("failed to start {}", self.binary().display()))?;
94                wait_with_timeout(child, timeout)?
95            }
96            ExecutionPayload::Stdin { code } => {
97                let mut cmd = self.run_command();
98                cmd.arg("-")
99                    .stdin(Stdio::piped())
100                    .stdout(Stdio::piped())
101                    .stderr(Stdio::piped());
102                let mut child = cmd.spawn().with_context(|| {
103                    format!(
104                        "failed to start {} for stdin execution",
105                        self.binary().display()
106                    )
107                })?;
108                if let Some(mut stdin) = child.stdin.take() {
109                    stdin.write_all(code.as_bytes())?;
110                    if !code.ends_with('\n') {
111                        stdin.write_all(b"\n")?;
112                    }
113                    stdin.flush()?;
114                }
115                wait_with_timeout(child, timeout)?
116            }
117        };
118
119        Ok(ExecutionOutcome {
120            language: self.id().to_string(),
121            exit_code: output.status.code(),
122            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
123            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
124            duration: start.elapsed(),
125        })
126    }
127
128    fn start_session(&self) -> Result<Box<dyn LanguageSession>> {
129        let mut cmd = self.run_command();
130        cmd.arg("--interactive")
131            .arg("--no-warnings")
132            .arg("--experimental-repl-await")
133            .stdin(Stdio::piped())
134            .stdout(Stdio::piped())
135            .stderr(Stdio::piped());
136
137        let mut child = cmd
138            .spawn()
139            .with_context(|| format!("failed to start {} REPL", self.binary().display()))?;
140
141        let stdout = child.stdout.take().context("missing stdout handle")?;
142        let stderr = child.stderr.take().context("missing stderr handle")?;
143
144        let stderr_buffer = Arc::new(Mutex::new(String::new()));
145        let stderr_collector = stderr_buffer.clone();
146        thread::spawn(move || {
147            let mut reader = BufReader::new(stderr);
148            let mut buf = String::new();
149            loop {
150                buf.clear();
151                match reader.read_line(&mut buf) {
152                    Ok(0) => break,
153                    Ok(_) => {
154                        let Ok(mut lock) = stderr_collector.lock() else {
155                            break;
156                        };
157                        lock.push_str(&buf);
158                    }
159                    Err(_) => break,
160                }
161            }
162        });
163
164        let mut session = JavascriptSession {
165            child,
166            stdout: BufReader::new(stdout),
167            stderr: stderr_buffer,
168        };
169
170        session.discard_prompt()?;
171
172        Ok(Box::new(session))
173    }
174}
175
176fn resolve_node_binary() -> PathBuf {
177    let candidates = ["node", "nodejs"];
178    for name in candidates {
179        if let Ok(path) = which::which(name) {
180            return path;
181        }
182    }
183    PathBuf::from("node")
184}
185
186struct JavascriptSession {
187    child: std::process::Child,
188    stdout: BufReader<std::process::ChildStdout>,
189    stderr: Arc<Mutex<String>>,
190}
191
192impl JavascriptSession {
193    fn write_code(&mut self, code: &str) -> Result<()> {
194        let stdin = self
195            .child
196            .stdin
197            .as_mut()
198            .context("javascript session stdin closed")?;
199        stdin.write_all(code.as_bytes())?;
200        if !code.ends_with('\n') {
201            stdin.write_all(b"\n")?;
202        }
203        stdin.flush()?;
204        Ok(())
205    }
206
207    fn read_until_prompt(&mut self) -> Result<String> {
208        const PROMPT: &[u8] = b"> ";
209        const CONT_PROMPT: &[u8] = b"... ";
210        let mut buffer = Vec::new();
211        loop {
212            let mut byte = [0u8; 1];
213            let read = self.stdout.read(&mut byte)?;
214            if read == 0 {
215                break;
216            }
217            buffer.extend_from_slice(&byte[..read]);
218            if buffer.ends_with(PROMPT) && !buffer.ends_with(CONT_PROMPT) {
219                break;
220            }
221        }
222
223        while buffer.ends_with(PROMPT) {
224            buffer.truncate(buffer.len() - PROMPT.len());
225        }
226
227        let mut text = String::from_utf8_lossy(&buffer).into_owned();
228        text = text.replace("\r\n", "\n");
229        text = text.replace('\r', "");
230        text = trim_continuation_prompt(text, "... ");
231        Ok(text.trim_start_matches('\n').to_string())
232    }
233
234    fn take_stderr(&self) -> String {
235        let Ok(mut lock) = self.stderr.lock() else {
236            return String::new();
237        };
238        if lock.is_empty() {
239            String::new()
240        } else {
241            let mut output = String::new();
242            std::mem::swap(&mut output, &mut *lock);
243            output
244        }
245    }
246
247    fn discard_prompt(&mut self) -> Result<()> {
248        let _ = self.read_until_prompt()?;
249        let _ = self.take_stderr();
250        Ok(())
251    }
252}
253
254impl LanguageSession for JavascriptSession {
255    fn language_id(&self) -> &str {
256        "javascript"
257    }
258
259    fn eval(&mut self, code: &str) -> Result<ExecutionOutcome> {
260        // Node.js REPL natively stores the last expression result in `_`.
261        let start = Instant::now();
262        self.write_code(code)?;
263        let stdout = self.read_until_prompt()?;
264        let stderr = self.take_stderr();
265        Ok(ExecutionOutcome {
266            language: self.language_id().to_string(),
267            exit_code: None,
268            stdout,
269            stderr,
270            duration: start.elapsed(),
271        })
272    }
273
274    fn shutdown(&mut self) -> Result<()> {
275        if let Some(mut stdin) = self.child.stdin.take() {
276            let _ = stdin.write_all(b".exit\n");
277            let _ = stdin.flush();
278        }
279        let _ = self.child.wait();
280        Ok(())
281    }
282}
283
284fn trim_continuation_prompt(mut text: String, prompt: &str) -> String {
285    if text.contains(prompt) {
286        text = text
287            .lines()
288            .map(|line| line.strip_prefix(prompt).unwrap_or(line))
289            .collect::<Vec<_>>()
290            .join("\n");
291    }
292    text
293}