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