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}