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}