run/engine/
crystal.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::{Command, Stdio};
4use std::time::{Duration, Instant};
5
6use anyhow::{Context, Result};
7use tempfile::{Builder, TempDir};
8
9use super::{ExecutionOutcome, ExecutionPayload, LanguageEngine, LanguageSession};
10
11pub struct CrystalEngine {
12    executable: Option<PathBuf>,
13}
14
15impl CrystalEngine {
16    pub fn new() -> Self {
17        Self {
18            executable: resolve_crystal_binary(),
19        }
20    }
21
22    fn ensure_executable(&self) -> Result<&Path> {
23        self.executable.as_deref().ok_or_else(|| {
24            anyhow::anyhow!(
25                "Crystal support requires the `crystal` executable. Install it from https://crystal-lang.org/install/ and ensure it is on your PATH."
26            )
27        })
28    }
29
30    fn write_temp_source(&self, code: &str) -> Result<(TempDir, PathBuf)> {
31        let dir = Builder::new()
32            .prefix("run-crystal")
33            .tempdir()
34            .context("failed to create temporary directory for Crystal source")?;
35        let path = dir.path().join("snippet.cr");
36        let mut contents = code.to_string();
37        if !contents.ends_with('\n') {
38            contents.push('\n');
39        }
40        std::fs::write(&path, contents).with_context(|| {
41            format!(
42                "failed to write temporary Crystal source to {}",
43                path.display()
44            )
45        })?;
46        Ok((dir, path))
47    }
48
49    fn run_source(&self, source: &Path) -> Result<std::process::Output> {
50        let executable = self.ensure_executable()?;
51        let mut cmd = Command::new(executable);
52        cmd.arg("run")
53            .arg(source)
54            .arg("--no-color")
55            .stdout(Stdio::piped())
56            .stderr(Stdio::piped());
57        cmd.stdin(Stdio::inherit());
58        if let Some(dir) = source.parent() {
59            cmd.current_dir(dir);
60        }
61        cmd.output().with_context(|| {
62            format!(
63                "failed to execute {} with source {}",
64                executable.display(),
65                source.display()
66            )
67        })
68    }
69}
70
71impl LanguageEngine for CrystalEngine {
72    fn id(&self) -> &'static str {
73        "crystal"
74    }
75
76    fn display_name(&self) -> &'static str {
77        "Crystal"
78    }
79
80    fn aliases(&self) -> &[&'static str] {
81        &["cr", "crystal-lang"]
82    }
83
84    fn supports_sessions(&self) -> bool {
85        self.executable.is_some()
86    }
87
88    fn validate(&self) -> Result<()> {
89        let executable = self.ensure_executable()?;
90        let mut cmd = Command::new(executable);
91        cmd.arg("--version")
92            .stdout(Stdio::null())
93            .stderr(Stdio::null());
94        cmd.status()
95            .with_context(|| format!("failed to invoke {}", executable.display()))?
96            .success()
97            .then_some(())
98            .ok_or_else(|| anyhow::anyhow!("{} is not executable", executable.display()))
99    }
100
101    fn execute(&self, payload: &ExecutionPayload) -> Result<ExecutionOutcome> {
102        let start = Instant::now();
103        let (temp_dir, source_path) = match payload {
104            ExecutionPayload::Inline { code } | ExecutionPayload::Stdin { code } => {
105                let (dir, path) = self.write_temp_source(code)?;
106                (Some(dir), path)
107            }
108            ExecutionPayload::File { path } => (None, path.clone()),
109        };
110
111        let output = self.run_source(&source_path)?;
112        drop(temp_dir);
113
114        Ok(ExecutionOutcome {
115            language: self.id().to_string(),
116            exit_code: output.status.code(),
117            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
118            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
119            duration: start.elapsed(),
120        })
121    }
122
123    fn start_session(&self) -> Result<Box<dyn LanguageSession>> {
124        let executable = self.ensure_executable()?.to_path_buf();
125        Ok(Box::new(CrystalSession::new(executable)?))
126    }
127}
128
129fn resolve_crystal_binary() -> Option<PathBuf> {
130    which::which("crystal").ok()
131}
132
133struct CrystalSession {
134    executable: PathBuf,
135    workspace: TempDir,
136    snippets: Vec<String>,
137    last_stdout: String,
138    last_stderr: String,
139}
140
141impl CrystalSession {
142    fn new(executable: PathBuf) -> Result<Self> {
143        let workspace = TempDir::new().context("failed to create Crystal session workspace")?;
144        let session = Self {
145            executable,
146            workspace,
147            snippets: Vec::new(),
148            last_stdout: String::new(),
149            last_stderr: String::new(),
150        };
151        session.persist_source()?;
152        Ok(session)
153    }
154
155    fn source_path(&self) -> PathBuf {
156        self.workspace.path().join("session.cr")
157    }
158
159    fn persist_source(&self) -> Result<()> {
160        let source = self.render_source();
161        fs::write(self.source_path(), source)
162            .with_context(|| "failed to write Crystal session source".to_string())
163    }
164
165    fn render_source(&self) -> String {
166        if self.snippets.is_empty() {
167            return String::from("# session body\n");
168        }
169
170        let mut source = String::new();
171        for snippet in &self.snippets {
172            source.push_str(snippet);
173            if !snippet.ends_with('\n') {
174                source.push('\n');
175            }
176            source.push('\n');
177        }
178        source
179    }
180
181    fn run_program(&self) -> Result<std::process::Output> {
182        let mut cmd = Command::new(&self.executable);
183        cmd.arg("run")
184            .arg("session.cr")
185            .arg("--no-color")
186            .stdout(Stdio::piped())
187            .stderr(Stdio::piped())
188            .current_dir(self.workspace.path());
189        cmd.output().with_context(|| {
190            format!(
191                "failed to execute {} for Crystal session",
192                self.executable.display()
193            )
194        })
195    }
196
197    fn run_current(&mut self, start: Instant) -> Result<(ExecutionOutcome, bool)> {
198        self.persist_source()?;
199        let output = self.run_program()?;
200        let stdout_full = Self::normalize_output(&output.stdout);
201        let stderr_full = Self::normalize_output(&output.stderr);
202
203        let success = output.status.success();
204        let (stdout, stderr) = if success {
205            let stdout_delta = Self::diff_outputs(&self.last_stdout, &stdout_full);
206            let stderr_delta = Self::diff_outputs(&self.last_stderr, &stderr_full);
207            self.last_stdout = stdout_full;
208            self.last_stderr = stderr_full;
209            (stdout_delta, stderr_delta)
210        } else {
211            (stdout_full, stderr_full)
212        };
213
214        let outcome = ExecutionOutcome {
215            language: "crystal".to_string(),
216            exit_code: output.status.code(),
217            stdout,
218            stderr,
219            duration: start.elapsed(),
220        };
221
222        Ok((outcome, success))
223    }
224
225    fn apply_snippet(&mut self, snippet: String) -> Result<(ExecutionOutcome, bool)> {
226        self.snippets.push(snippet);
227        let start = Instant::now();
228        let (outcome, success) = self.run_current(start)?;
229        if !success {
230            let _ = self.snippets.pop();
231            self.persist_source()?;
232        }
233        Ok((outcome, success))
234    }
235
236    fn reset(&mut self) -> Result<()> {
237        self.snippets.clear();
238        self.last_stdout.clear();
239        self.last_stderr.clear();
240        self.persist_source()
241    }
242
243    fn normalize_output(bytes: &[u8]) -> String {
244        String::from_utf8_lossy(bytes)
245            .replace("\r\n", "\n")
246            .replace('\r', "")
247    }
248
249    fn diff_outputs(previous: &str, current: &str) -> String {
250        current
251            .strip_prefix(previous)
252            .map(|s| s.to_string())
253            .unwrap_or_else(|| current.to_string())
254    }
255}
256
257impl LanguageSession for CrystalSession {
258    fn language_id(&self) -> &str {
259        "crystal"
260    }
261
262    fn eval(&mut self, code: &str) -> Result<ExecutionOutcome> {
263        let trimmed = code.trim();
264        if trimmed.is_empty() {
265            return Ok(ExecutionOutcome {
266                language: "crystal".to_string(),
267                exit_code: None,
268                stdout: String::new(),
269                stderr: String::new(),
270                duration: Duration::default(),
271            });
272        }
273
274        if trimmed.eq_ignore_ascii_case(":reset") {
275            self.reset()?;
276            return Ok(ExecutionOutcome {
277                language: "crystal".to_string(),
278                exit_code: None,
279                stdout: String::new(),
280                stderr: String::new(),
281                duration: Duration::default(),
282            });
283        }
284
285        if trimmed.eq_ignore_ascii_case(":help") {
286            return Ok(ExecutionOutcome {
287                language: "crystal".to_string(),
288                exit_code: None,
289                stdout: "Crystal commands:\n  :reset — clear session state\n  :help  — show this message\n"
290                    .to_string(),
291                stderr: String::new(),
292                duration: Duration::default(),
293            });
294        }
295
296        let snippet = match classify_crystal_snippet(trimmed) {
297            CrystalSnippetKind::Statement => ensure_trailing_newline(code),
298            CrystalSnippetKind::Expression => wrap_expression(trimmed),
299        };
300
301        let (outcome, _) = self.apply_snippet(snippet)?;
302        Ok(outcome)
303    }
304
305    fn shutdown(&mut self) -> Result<()> {
306        Ok(())
307    }
308}
309
310enum CrystalSnippetKind {
311    Statement,
312    Expression,
313}
314
315fn classify_crystal_snippet(code: &str) -> CrystalSnippetKind {
316    if looks_like_crystal_statement(code) {
317        CrystalSnippetKind::Statement
318    } else {
319        CrystalSnippetKind::Expression
320    }
321}
322
323fn looks_like_crystal_statement(code: &str) -> bool {
324    let trimmed = code.trim_start();
325    trimmed.contains('\n')
326        || trimmed.ends_with(';')
327        || trimmed.ends_with('}')
328        || trimmed.ends_with("end")
329        || trimmed.ends_with("do")
330        || trimmed.starts_with("require ")
331        || trimmed.starts_with("def ")
332        || trimmed.starts_with("class ")
333        || trimmed.starts_with("module ")
334        || trimmed.starts_with("struct ")
335        || trimmed.starts_with("record ")
336        || trimmed.starts_with("enum ")
337        || trimmed.starts_with("macro ")
338        || trimmed.starts_with("alias ")
339        || trimmed.starts_with("include ")
340        || trimmed.starts_with("extend ")
341        || trimmed.starts_with("@[")
342}
343
344fn ensure_trailing_newline(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 wrap_expression(code: &str) -> String {
353    format!("p({})\n", code)
354}