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