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