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