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