1use std::fs;
2use std::io::{ErrorKind, Write};
3use std::path::{Path, PathBuf};
4use std::process::{Command, Stdio};
5use std::time::Instant;
6
7use anyhow::{Context, Result, bail};
8use tempfile::{NamedTempFile, TempDir};
9
10use super::{ExecutionOutcome, ExecutionPayload, LanguageEngine, LanguageSession};
11
12pub struct TypeScriptEngine {
13 executable: PathBuf,
14}
15
16impl Default for TypeScriptEngine {
17 fn default() -> Self {
18 Self::new()
19 }
20}
21
22impl TypeScriptEngine {
23 pub fn new() -> Self {
24 let executable = resolve_deno_binary();
25 Self { executable }
26 }
27
28 fn binary(&self) -> &Path {
29 &self.executable
30 }
31
32 fn run_command(&self) -> Command {
33 Command::new(self.binary())
34 }
35}
36
37impl LanguageEngine for TypeScriptEngine {
38 fn id(&self) -> &'static str {
39 "typescript"
40 }
41
42 fn display_name(&self) -> &'static str {
43 "TypeScript"
44 }
45
46 fn aliases(&self) -> &[&'static str] {
47 &["ts", "deno"]
48 }
49
50 fn supports_sessions(&self) -> bool {
51 true
52 }
53
54 fn validate(&self) -> Result<()> {
55 let mut cmd = self.run_command();
56 cmd.arg("--version")
57 .stdout(Stdio::null())
58 .stderr(Stdio::null());
59 let status = handle_deno_io(
60 cmd.status(),
61 self.binary(),
62 "invoke Deno to check its version",
63 )?;
64
65 if status.success() {
66 Ok(())
67 } else {
68 bail!("{} is not executable", self.binary().display());
69 }
70 }
71
72 fn execute(&self, payload: &ExecutionPayload) -> Result<ExecutionOutcome> {
73 let start = Instant::now();
74 let output = match payload {
75 ExecutionPayload::Inline { code } => {
76 let mut script =
77 NamedTempFile::new().context("failed to create temporary TypeScript file")?;
78 script.write_all(code.as_bytes())?;
79 if !code.ends_with('\n') {
80 script.write_all(b"\n")?;
81 }
82 script.flush()?;
83
84 let mut cmd = self.run_command();
85 cmd.arg("run")
86 .args(["--quiet", "--no-check", "--ext", "ts"])
87 .arg(script.path())
88 .env("NO_COLOR", "1");
89 cmd.stdin(Stdio::inherit());
90 handle_deno_io(cmd.output(), self.binary(), "run Deno for inline execution")?
91 }
92 ExecutionPayload::File { path } => {
93 let mut cmd = self.run_command();
94 cmd.arg("run")
95 .args(["--quiet", "--no-check", "--ext", "ts"])
96 .arg(path)
97 .env("NO_COLOR", "1");
98 cmd.stdin(Stdio::inherit());
99 handle_deno_io(cmd.output(), self.binary(), "run Deno for file execution")?
100 }
101 ExecutionPayload::Stdin { code } => {
102 let mut cmd = self.run_command();
103 cmd.arg("run")
104 .args(["--quiet", "--no-check", "--ext", "ts", "-"])
105 .stdin(Stdio::piped())
106 .stdout(Stdio::piped())
107 .stderr(Stdio::piped())
108 .env("NO_COLOR", "1");
109
110 let mut child =
111 handle_deno_io(cmd.spawn(), self.binary(), "start Deno for stdin execution")?;
112
113 if let Some(mut stdin) = child.stdin.take() {
114 stdin.write_all(code.as_bytes())?;
115 if !code.ends_with('\n') {
116 stdin.write_all(b"\n")?;
117 }
118 stdin.flush()?;
119 }
120
121 handle_deno_io(
122 child.wait_with_output(),
123 self.binary(),
124 "read output from Deno stdin execution",
125 )?
126 }
127 };
128
129 Ok(ExecutionOutcome {
130 language: self.id().to_string(),
131 exit_code: output.status.code(),
132 stdout: strip_ansi_codes(&String::from_utf8_lossy(&output.stdout)).replace('\r', ""),
133 stderr: strip_ansi_codes(&String::from_utf8_lossy(&output.stderr)).replace('\r', ""),
134 duration: start.elapsed(),
135 })
136 }
137
138 fn start_session(&self) -> Result<Box<dyn LanguageSession>> {
139 self.validate()?;
140 let session = TypeScriptSession::new(self.binary().to_path_buf())?;
141 Ok(Box::new(session))
142 }
143}
144
145fn resolve_deno_binary() -> PathBuf {
146 which::which("deno").unwrap_or_else(|_| PathBuf::from("deno"))
147}
148
149fn strip_ansi_codes(text: &str) -> String {
150 let mut result = String::with_capacity(text.len());
151 let mut chars = text.chars();
152
153 while let Some(ch) = chars.next() {
154 if ch == '\x1b' {
155 if chars.next() == Some('[') {
156 for c in chars.by_ref() {
157 if c.is_ascii_alphabetic() {
158 break;
159 }
160 }
161 }
162 } else {
163 result.push(ch);
164 }
165 }
166
167 result
168}
169
170fn handle_deno_io<T>(result: std::io::Result<T>, binary: &Path, action: &str) -> Result<T> {
171 match result {
172 Ok(value) => Ok(value),
173 Err(err) if err.kind() == ErrorKind::NotFound => bail!(
174 "failed to {} because '{}' was not found in PATH. Install Deno from https://deno.land/manual/getting_started/installation or ensure the binary is available on your PATH.",
175 action,
176 binary.display()
177 ),
178 Err(err) => {
179 Err(err).with_context(|| format!("failed to {} using {}", action, binary.display()))
180 }
181 }
182}
183
184struct TypeScriptSession {
185 deno_path: PathBuf,
186 _workspace: TempDir,
187 entrypoint: PathBuf,
188 snippets: Vec<String>,
189 last_stdout: String,
190 last_stderr: String,
191}
192
193impl TypeScriptSession {
194 fn new(deno_path: PathBuf) -> Result<Self> {
195 let workspace = TempDir::new().context("failed to create TypeScript session workspace")?;
196 let entrypoint = workspace.path().join("session.ts");
197 let session = Self {
198 deno_path,
199 _workspace: workspace,
200 entrypoint,
201 snippets: Vec::new(),
202 last_stdout: String::new(),
203 last_stderr: String::new(),
204 };
205 session.persist_source()?;
206 Ok(session)
207 }
208
209 fn language_id(&self) -> &str {
210 "typescript"
211 }
212
213 fn persist_source(&self) -> Result<()> {
214 let source = self.render_source();
215 fs::write(&self.entrypoint, source)
216 .with_context(|| "failed to write TypeScript session source".to_string())
217 }
218
219 fn render_source(&self) -> String {
220 let mut source = String::from(
221 r#"const __print = (value: unknown): void => {
222 if (typeof value === "string") {
223 console.log(value);
224 return;
225 }
226 try {
227 const serialized = JSON.stringify(value, null, 2);
228 if (serialized !== undefined) {
229 console.log(serialized);
230 return;
231 }
232 } catch (_) {
233 }
234 console.log(String(value));
235};
236
237"#,
238 );
239
240 for snippet in &self.snippets {
241 source.push_str(snippet);
242 if !snippet.ends_with('\n') {
243 source.push('\n');
244 }
245 }
246
247 source
248 }
249
250 fn compile_and_run(&self) -> Result<std::process::Output> {
251 let mut cmd = Command::new(&self.deno_path);
252 cmd.arg("run")
253 .args(["--quiet", "--no-check", "--ext", "ts"])
254 .arg(&self.entrypoint)
255 .env("NO_COLOR", "1");
256 handle_deno_io(
257 cmd.output(),
258 &self.deno_path,
259 "run Deno for the TypeScript session",
260 )
261 }
262
263 fn normalize(text: &str) -> String {
264 strip_ansi_codes(&text.replace("\r\n", "\n").replace('\r', ""))
265 }
266
267 fn diff_outputs(previous: &str, current: &str) -> String {
268 if let Some(suffix) = current.strip_prefix(previous) {
269 suffix.to_string()
270 } else {
271 current.to_string()
272 }
273 }
274
275 fn run_snippet(&mut self, snippet: String) -> Result<(ExecutionOutcome, bool)> {
276 let start = Instant::now();
277 self.snippets.push(snippet);
278 self.persist_source()?;
279 let output = self.compile_and_run()?;
280
281 let stdout_full = Self::normalize(&String::from_utf8_lossy(&output.stdout));
282 let stderr_full = Self::normalize(&String::from_utf8_lossy(&output.stderr));
283
284 let stdout = Self::diff_outputs(&self.last_stdout, &stdout_full);
285 let stderr = Self::diff_outputs(&self.last_stderr, &stderr_full);
286 let success = output.status.success();
287
288 if success {
289 self.last_stdout = stdout_full;
290 self.last_stderr = stderr_full;
291 } else {
292 self.snippets.pop();
293 self.persist_source()?;
294 }
295
296 let outcome = ExecutionOutcome {
297 language: self.language_id().to_string(),
298 exit_code: output.status.code(),
299 stdout,
300 stderr,
301 duration: start.elapsed(),
302 };
303
304 Ok((outcome, success))
305 }
306}
307
308impl LanguageSession for TypeScriptSession {
309 fn language_id(&self) -> &str {
310 TypeScriptSession::language_id(self)
311 }
312
313 fn eval(&mut self, code: &str) -> Result<ExecutionOutcome> {
314 let trimmed = code.trim();
315 if trimmed.is_empty() {
316 return Ok(ExecutionOutcome {
317 language: self.language_id().to_string(),
318 exit_code: None,
319 stdout: String::new(),
320 stderr: String::new(),
321 duration: Instant::now().elapsed(),
322 });
323 }
324
325 if should_treat_as_expression(trimmed) {
326 let snippet = wrap_expression(trimmed);
327 let (outcome, success) = self.run_snippet(snippet)?;
328 if success {
329 return Ok(outcome);
330 }
331 }
332
333 let snippet = prepare_statement(code);
334 let (outcome, _) = self.run_snippet(snippet)?;
335 Ok(outcome)
336 }
337
338 fn shutdown(&mut self) -> Result<()> {
339 Ok(())
340 }
341}
342
343fn wrap_expression(code: &str) -> String {
344 let expr = code.trim().trim_end_matches(';').trim_end();
345 format!("__print(await ({}));\n", expr)
346}
347
348fn prepare_statement(code: &str) -> String {
349 let mut snippet = code.to_string();
350 if !snippet.ends_with('\n') {
351 snippet.push('\n');
352 }
353 snippet
354}
355
356fn should_treat_as_expression(code: &str) -> bool {
357 let trimmed = code.trim();
358 if trimmed.is_empty() {
359 return false;
360 }
361 if trimmed.contains('\n') {
362 return false;
363 }
364
365 let trimmed = trimmed.trim_end();
366 let without_trailing_semicolon = trimmed.strip_suffix(';').unwrap_or(trimmed).trim_end();
367 if without_trailing_semicolon.is_empty() {
368 return false;
369 }
370 if without_trailing_semicolon.contains(';') {
371 return false;
372 }
373
374 const KEYWORDS: [&str; 11] = [
375 "const ",
376 "let ",
377 "var ",
378 "function ",
379 "class ",
380 "interface ",
381 "type ",
382 "import ",
383 "export ",
384 "if ",
385 "while ",
386 ];
387 if KEYWORDS.iter().any(|kw| {
388 without_trailing_semicolon.starts_with(kw)
389 || without_trailing_semicolon.starts_with(&kw.to_ascii_uppercase())
390 }) {
391 return false;
392 }
393 if without_trailing_semicolon.starts_with("return ")
394 || without_trailing_semicolon.starts_with("throw ")
395 {
396 return false;
397 }
398 true
399}