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