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 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}