run/engine/
typescript.rs

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            // Skip escape sequence
150            if chars.next() == Some('[') {
151                // Skip until we find a letter (end of escape sequence)
152                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}