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}