Skip to main content

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