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