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