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('[') {
151 for c in chars.by_ref() {
153 if c.is_ascii_alphabetic() {
154 break;
155 }
156 }
157 }
158 } else {
159 result.push(ch);
160 }
161 }
162
163 result
164}
165
166fn handle_deno_io<T>(result: std::io::Result<T>, binary: &Path, action: &str) -> Result<T> {
167 match result {
168 Ok(value) => Ok(value),
169 Err(err) if err.kind() == ErrorKind::NotFound => bail!(
170 "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.",
171 action,
172 binary.display()
173 ),
174 Err(err) => {
175 Err(err).with_context(|| format!("failed to {} using {}", action, binary.display()))
176 }
177 }
178}
179
180struct TypeScriptSession {
181 deno_path: PathBuf,
182 _workspace: TempDir,
183 entrypoint: PathBuf,
184 snippets: Vec<String>,
185 last_stdout: String,
186 last_stderr: String,
187}
188
189impl TypeScriptSession {
190 fn new(deno_path: PathBuf) -> Result<Self> {
191 let workspace = TempDir::new().context("failed to create TypeScript session workspace")?;
192 let entrypoint = workspace.path().join("session.ts");
193 let session = Self {
194 deno_path,
195 _workspace: workspace,
196 entrypoint,
197 snippets: Vec::new(),
198 last_stdout: String::new(),
199 last_stderr: String::new(),
200 };
201 session.persist_source()?;
202 Ok(session)
203 }
204
205 fn language_id(&self) -> &str {
206 "typescript"
207 }
208
209 fn persist_source(&self) -> Result<()> {
210 let source = self.render_source();
211 fs::write(&self.entrypoint, source)
212 .with_context(|| "failed to write TypeScript session source".to_string())
213 }
214
215 fn render_source(&self) -> String {
216 let mut source = String::from(
217 r#"const __print = (value: unknown): void => {
218 if (typeof value === "string") {
219 console.log(value);
220 return;
221 }
222 try {
223 const serialized = JSON.stringify(value, null, 2);
224 if (serialized !== undefined) {
225 console.log(serialized);
226 return;
227 }
228 } catch (_) {
229 // ignore
230 }
231 console.log(String(value));
232};
233
234"#,
235 );
236
237 for snippet in &self.snippets {
238 source.push_str(snippet);
239 if !snippet.ends_with('\n') {
240 source.push('\n');
241 }
242 }
243
244 source
245 }
246
247 fn compile_and_run(&self) -> Result<std::process::Output> {
248 let mut cmd = Command::new(&self.deno_path);
249 cmd.arg("run")
250 .args(["--quiet", "--no-check", "--ext", "ts"])
251 .arg(&self.entrypoint)
252 .env("NO_COLOR", "1");
253 handle_deno_io(
254 cmd.output(),
255 &self.deno_path,
256 "run Deno for the TypeScript session",
257 )
258 }
259
260 fn normalize(text: &str) -> String {
261 strip_ansi_codes(&text.replace("\r\n", "\n").replace('\r', ""))
262 }
263
264 fn diff_outputs(previous: &str, current: &str) -> String {
265 if let Some(suffix) = current.strip_prefix(previous) {
266 suffix.to_string()
267 } else {
268 current.to_string()
269 }
270 }
271
272 fn run_snippet(&mut self, snippet: String) -> Result<(ExecutionOutcome, bool)> {
273 let start = Instant::now();
274 self.snippets.push(snippet);
275 self.persist_source()?;
276 let output = self.compile_and_run()?;
277
278 let stdout_full = Self::normalize(&String::from_utf8_lossy(&output.stdout));
279 let stderr_full = Self::normalize(&String::from_utf8_lossy(&output.stderr));
280
281 let stdout = Self::diff_outputs(&self.last_stdout, &stdout_full);
282 let stderr = Self::diff_outputs(&self.last_stderr, &stderr_full);
283 let success = output.status.success();
284
285 if success {
286 self.last_stdout = stdout_full;
287 self.last_stderr = stderr_full;
288 } else {
289 self.snippets.pop();
290 self.persist_source()?;
291 }
292
293 let outcome = ExecutionOutcome {
294 language: self.language_id().to_string(),
295 exit_code: output.status.code(),
296 stdout,
297 stderr,
298 duration: start.elapsed(),
299 };
300
301 Ok((outcome, success))
302 }
303}
304
305impl LanguageSession for TypeScriptSession {
306 fn language_id(&self) -> &str {
307 TypeScriptSession::language_id(self)
308 }
309
310 fn eval(&mut self, code: &str) -> Result<ExecutionOutcome> {
311 let trimmed = code.trim();
312 if trimmed.is_empty() {
313 return Ok(ExecutionOutcome {
314 language: self.language_id().to_string(),
315 exit_code: None,
316 stdout: String::new(),
317 stderr: String::new(),
318 duration: Instant::now().elapsed(),
319 });
320 }
321
322 if should_treat_as_expression(trimmed) {
323 let snippet = wrap_expression(trimmed);
324 let (outcome, success) = self.run_snippet(snippet)?;
325 if success {
326 return Ok(outcome);
327 }
328 }
329
330 let snippet = prepare_statement(code);
331 let (outcome, _) = self.run_snippet(snippet)?;
332 Ok(outcome)
333 }
334
335 fn shutdown(&mut self) -> Result<()> {
336 Ok(())
337 }
338}
339
340fn wrap_expression(code: &str) -> String {
341 format!("__print(await ({}));\n", code)
342}
343
344fn prepare_statement(code: &str) -> String {
345 let mut snippet = code.to_string();
346 if !snippet.ends_with('\n') {
347 snippet.push('\n');
348 }
349 snippet
350}
351
352fn should_treat_as_expression(code: &str) -> bool {
353 let trimmed = code.trim();
354 if trimmed.is_empty() {
355 return false;
356 }
357 if trimmed.contains('\n') {
358 return false;
359 }
360 if trimmed.ends_with(';') || trimmed.contains(';') {
361 return false;
362 }
363 const KEYWORDS: [&str; 11] = [
364 "const ",
365 "let ",
366 "var ",
367 "function ",
368 "class ",
369 "interface ",
370 "type ",
371 "import ",
372 "export ",
373 "if ",
374 "while ",
375 ];
376 if KEYWORDS
377 .iter()
378 .any(|kw| trimmed.starts_with(kw) || trimmed.starts_with(&kw.to_ascii_uppercase()))
379 {
380 return false;
381 }
382 if trimmed.starts_with("return ") || trimmed.starts_with("throw ") {
383 return false;
384 }
385 true
386}