Skip to main content

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 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}