1use std::collections::BTreeSet;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::process::{Command, Stdio};
5use std::time::{Duration, Instant};
6
7use anyhow::{Context, Result};
8use tempfile::{Builder, TempDir};
9
10use super::{ExecutionOutcome, ExecutionPayload, LanguageEngine, LanguageSession};
11
12pub struct ElixirEngine {
13 executable: Option<PathBuf>,
14}
15
16impl ElixirEngine {
17 pub fn new() -> Self {
18 Self {
19 executable: resolve_elixir_binary(),
20 }
21 }
22
23 fn ensure_executable(&self) -> Result<&Path> {
24 self.executable.as_deref().ok_or_else(|| {
25 anyhow::anyhow!(
26 "Elixir support requires the `elixir` executable. Install Elixir from https://elixir-lang.org/install.html and ensure `elixir` is on your PATH."
27 )
28 })
29 }
30
31 fn write_temp_source(&self, code: &str) -> Result<(TempDir, PathBuf)> {
32 let dir = Builder::new()
33 .prefix("run-elixir")
34 .tempdir()
35 .context("failed to create temporary directory for Elixir source")?;
36 let path = dir.path().join("snippet.exs");
37 let mut contents = code.to_string();
38 if !contents.ends_with('\n') {
39 contents.push('\n');
40 }
41 fs::write(&path, contents).with_context(|| {
42 format!(
43 "failed to write temporary Elixir source to {}",
44 path.display()
45 )
46 })?;
47 Ok((dir, path))
48 }
49
50 fn execute_path(&self, path: &Path) -> Result<std::process::Output> {
51 let executable = self.ensure_executable()?;
52 let mut cmd = Command::new(executable);
53 cmd.arg("--no-color")
54 .arg(path)
55 .stdout(Stdio::piped())
56 .stderr(Stdio::piped());
57 cmd.stdin(Stdio::inherit());
58 if let Some(parent) = path.parent() {
59 cmd.current_dir(parent);
60 }
61 cmd.output().with_context(|| {
62 format!(
63 "failed to execute {} with script {}",
64 executable.display(),
65 path.display()
66 )
67 })
68 }
69}
70
71impl LanguageEngine for ElixirEngine {
72 fn id(&self) -> &'static str {
73 "elixir"
74 }
75
76 fn display_name(&self) -> &'static str {
77 "Elixir"
78 }
79
80 fn aliases(&self) -> &[&'static str] {
81 &["ex", "exs", "iex"]
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, 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.execute_path(&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(ElixirSession::new(executable)?))
126 }
127}
128
129fn resolve_elixir_binary() -> Option<PathBuf> {
130 which::which("elixir").ok()
131}
132
133#[derive(Default)]
134struct ElixirSessionState {
135 directives: BTreeSet<String>,
136 declarations: Vec<String>,
137 statements: Vec<String>,
138}
139
140struct ElixirSession {
141 executable: PathBuf,
142 workspace: TempDir,
143 state: ElixirSessionState,
144 previous_stdout: String,
145 previous_stderr: String,
146}
147
148impl ElixirSession {
149 fn new(executable: PathBuf) -> Result<Self> {
150 let workspace = Builder::new()
151 .prefix("run-elixir-repl")
152 .tempdir()
153 .context("failed to create temporary directory for Elixir repl")?;
154 let session = Self {
155 executable,
156 workspace,
157 state: ElixirSessionState::default(),
158 previous_stdout: String::new(),
159 previous_stderr: String::new(),
160 };
161 session.persist_source()?;
162 Ok(session)
163 }
164
165 fn source_path(&self) -> PathBuf {
166 self.workspace.path().join("session.exs")
167 }
168
169 fn persist_source(&self) -> Result<()> {
170 let source = self.render_source();
171 fs::write(self.source_path(), source)
172 .with_context(|| "failed to write Elixir session source".to_string())
173 }
174
175 fn render_source(&self) -> String {
176 let mut source = String::new();
177
178 for directive in &self.state.directives {
179 source.push_str(directive);
180 if !directive.ends_with('\n') {
181 source.push('\n');
182 }
183 }
184 if !self.state.directives.is_empty() {
185 source.push('\n');
186 }
187
188 for decl in &self.state.declarations {
189 source.push_str(decl);
190 if !decl.ends_with('\n') {
191 source.push('\n');
192 }
193 source.push('\n');
194 }
195
196 for stmt in &self.state.statements {
197 source.push_str(stmt);
198 if !stmt.ends_with('\n') {
199 source.push('\n');
200 }
201 }
202
203 source
204 }
205
206 fn run_program(&self) -> Result<std::process::Output> {
207 let mut cmd = Command::new(&self.executable);
208 cmd.arg("--no-color")
209 .arg("session.exs")
210 .stdout(Stdio::piped())
211 .stderr(Stdio::piped())
212 .current_dir(self.workspace.path());
213 cmd.output().with_context(|| {
214 format!(
215 "failed to execute {} for Elixir session",
216 self.executable.display()
217 )
218 })
219 }
220
221 fn run_current(&mut self, start: Instant) -> Result<(ExecutionOutcome, bool)> {
222 self.persist_source()?;
223 let output = self.run_program()?;
224 let stdout_full = normalize_output(&output.stdout);
225 let stderr_full = normalize_output(&output.stderr);
226
227 let stdout_delta = diff_output(&self.previous_stdout, &stdout_full);
228 let stderr_delta = diff_output(&self.previous_stderr, &stderr_full);
229
230 let success = output.status.success();
231 if success {
232 self.previous_stdout = stdout_full;
233 self.previous_stderr = stderr_full;
234 }
235
236 let outcome = ExecutionOutcome {
237 language: "elixir".to_string(),
238 exit_code: output.status.code(),
239 stdout: stdout_delta,
240 stderr: stderr_delta,
241 duration: start.elapsed(),
242 };
243
244 Ok((outcome, success))
245 }
246
247 fn apply_directive(&mut self, code: &str) -> Result<(ExecutionOutcome, bool)> {
248 let mut inserted = Vec::new();
249 for line in code.lines() {
250 let trimmed = line.trim();
251 if trimmed.is_empty() {
252 continue;
253 }
254 let normalized = trimmed.to_string();
255 if self.state.directives.insert(normalized.clone()) {
256 inserted.push(normalized);
257 }
258 }
259
260 if inserted.is_empty() {
261 return Ok((
262 ExecutionOutcome {
263 language: "elixir".to_string(),
264 exit_code: None,
265 stdout: String::new(),
266 stderr: String::new(),
267 duration: Duration::default(),
268 },
269 true,
270 ));
271 }
272
273 let start = Instant::now();
274 let (outcome, success) = self.run_current(start)?;
275 if !success {
276 for directive in inserted {
277 self.state.directives.remove(&directive);
278 }
279 self.persist_source()?;
280 }
281 Ok((outcome, success))
282 }
283
284 fn apply_declaration(&mut self, code: &str) -> Result<(ExecutionOutcome, bool)> {
285 let snippet = ensure_trailing_newline(code);
286 self.state.declarations.push(snippet);
287 let start = Instant::now();
288 let (outcome, success) = self.run_current(start)?;
289 if !success {
290 let _ = self.state.declarations.pop();
291 self.persist_source()?;
292 }
293 Ok((outcome, success))
294 }
295
296 fn apply_statement(&mut self, code: &str) -> Result<(ExecutionOutcome, bool)> {
297 let snippet = prepare_statement(code);
298 self.state.statements.push(snippet);
299 let start = Instant::now();
300 let (outcome, success) = self.run_current(start)?;
301 if !success {
302 let _ = self.state.statements.pop();
303 self.persist_source()?;
304 }
305 Ok((outcome, success))
306 }
307
308 fn apply_expression(&mut self, code: &str) -> Result<(ExecutionOutcome, bool)> {
309 let wrapped = wrap_expression(code);
310 self.state.statements.push(wrapped);
311 let start = Instant::now();
312 let (outcome, success) = self.run_current(start)?;
313 if !success {
314 let _ = self.state.statements.pop();
315 self.persist_source()?;
316 }
317 Ok((outcome, success))
318 }
319
320 fn reset(&mut self) -> Result<()> {
321 self.state.directives.clear();
322 self.state.declarations.clear();
323 self.state.statements.clear();
324 self.previous_stdout.clear();
325 self.previous_stderr.clear();
326 self.persist_source()
327 }
328}
329
330impl LanguageSession for ElixirSession {
331 fn language_id(&self) -> &str {
332 "elixir"
333 }
334
335 fn eval(&mut self, code: &str) -> Result<ExecutionOutcome> {
336 let trimmed = code.trim();
337 if trimmed.is_empty() {
338 return Ok(ExecutionOutcome {
339 language: "elixir".to_string(),
340 exit_code: None,
341 stdout: String::new(),
342 stderr: String::new(),
343 duration: Duration::default(),
344 });
345 }
346
347 if trimmed.eq_ignore_ascii_case(":reset") {
348 self.reset()?;
349 return Ok(ExecutionOutcome {
350 language: "elixir".to_string(),
351 exit_code: None,
352 stdout: String::new(),
353 stderr: String::new(),
354 duration: Duration::default(),
355 });
356 }
357
358 if trimmed.eq_ignore_ascii_case(":help") {
359 return Ok(ExecutionOutcome {
360 language: "elixir".to_string(),
361 exit_code: None,
362 stdout:
363 "Elixir commands:\n :reset — clear session state\n :help — show this message\n"
364 .to_string(),
365 stderr: String::new(),
366 duration: Duration::default(),
367 });
368 }
369
370 match classify_snippet(trimmed) {
371 ElixirSnippet::Directive => {
372 let (outcome, _) = self.apply_directive(code)?;
373 Ok(outcome)
374 }
375 ElixirSnippet::Declaration => {
376 let (outcome, _) = self.apply_declaration(code)?;
377 Ok(outcome)
378 }
379 ElixirSnippet::Expression => {
380 let (outcome, _) = self.apply_expression(trimmed)?;
381 Ok(outcome)
382 }
383 ElixirSnippet::Statement => {
384 let (outcome, _) = self.apply_statement(code)?;
385 Ok(outcome)
386 }
387 }
388 }
389
390 fn shutdown(&mut self) -> Result<()> {
391 Ok(())
392 }
393}
394
395enum ElixirSnippet {
396 Directive,
397 Declaration,
398 Statement,
399 Expression,
400}
401
402fn classify_snippet(code: &str) -> ElixirSnippet {
403 if is_directive(code) {
404 return ElixirSnippet::Directive;
405 }
406
407 if is_declaration(code) {
408 return ElixirSnippet::Declaration;
409 }
410
411 if should_wrap_expression(code) {
412 return ElixirSnippet::Expression;
413 }
414
415 ElixirSnippet::Statement
416}
417
418fn is_directive(code: &str) -> bool {
419 code.lines().all(|line| {
420 let trimmed = line.trim_start();
421 trimmed.starts_with("import ")
422 || trimmed.starts_with("alias ")
423 || trimmed.starts_with("require ")
424 || trimmed.starts_with("use ")
425 })
426}
427
428fn is_declaration(code: &str) -> bool {
429 let trimmed = code.trim_start();
430 trimmed.starts_with("defmodule ")
431 || trimmed.starts_with("defprotocol ")
432 || trimmed.starts_with("defimpl ")
433}
434
435fn should_wrap_expression(code: &str) -> bool {
436 if code.contains('\n') {
437 return false;
438 }
439
440 let trimmed = code.trim();
441 if trimmed.is_empty() {
442 return false;
443 }
444
445 let lowered = trimmed.to_ascii_lowercase();
446 const STATEMENT_PREFIXES: [&str; 13] = [
447 "import ",
448 "alias ",
449 "require ",
450 "use ",
451 "def ",
452 "defp ",
453 "defmodule ",
454 "defprotocol ",
455 "defimpl ",
456 "case ",
457 "try ",
458 "receive ",
459 "with ",
460 ];
461
462 if STATEMENT_PREFIXES
463 .iter()
464 .any(|prefix| lowered.starts_with(prefix))
465 {
466 return false;
467 }
468
469 if trimmed.contains('=') && !trimmed.starts_with(&[':', '?'][..]) {
470 return false;
471 }
472
473 if trimmed.ends_with("do") || trimmed.contains(" fn ") {
474 return false;
475 }
476
477 true
478}
479
480fn ensure_trailing_newline(code: &str) -> String {
481 let mut owned = code.to_string();
482 if !owned.ends_with('\n') {
483 owned.push('\n');
484 }
485 owned
486}
487
488fn prepare_statement(code: &str) -> String {
489 let mut snippet = ensure_trailing_newline(code);
490 let targets = collect_assignment_targets(code);
491 if targets.is_empty() {
492 return snippet;
493 }
494
495 for target in targets {
496 snippet.push_str("_ = ");
497 snippet.push_str(&target);
498 snippet.push('\n');
499 }
500
501 snippet
502}
503
504fn collect_assignment_targets(code: &str) -> Vec<String> {
505 let mut targets = BTreeSet::new();
506 for line in code.lines() {
507 if let Some(target) = parse_assignment_target(line) {
508 targets.insert(target);
509 }
510 }
511 targets.into_iter().collect()
512}
513
514fn parse_assignment_target(line: &str) -> Option<String> {
515 let trimmed = line.trim();
516 if trimmed.is_empty() || trimmed.starts_with('#') {
517 return None;
518 }
519
520 let (lhs_part, rhs_part) = trimmed.split_once('=')?;
521 let lhs = lhs_part.trim();
522 let rhs = rhs_part.trim();
523 if lhs.is_empty() || rhs.is_empty() {
524 return None;
525 }
526
527 let eq_index = trimmed.find('=')?;
528 let before_char = trimmed[..eq_index]
529 .chars()
530 .rev()
531 .find(|c| !c.is_whitespace());
532 if matches!(
533 before_char,
534 Some('=') | Some('!') | Some('<') | Some('>') | Some('~') | Some(':')
535 ) {
536 return None;
537 }
538
539 let after_char = trimmed[eq_index + 1..].chars().find(|c| !c.is_whitespace());
540 if matches!(after_char, Some('=') | Some('>') | Some('<') | Some('~')) {
541 return None;
542 }
543
544 if !is_elixir_identifier(lhs) {
545 return None;
546 }
547
548 Some(lhs.to_string())
549}
550
551fn is_elixir_identifier(candidate: &str) -> bool {
552 let mut chars = candidate.chars();
553 let first = match chars.next() {
554 Some(ch) => ch,
555 None => return false,
556 };
557
558 if !(first == '_' || first.is_ascii_alphabetic()) {
559 return false;
560 }
561
562 for ch in chars {
563 if !(ch == '_' || ch.is_ascii_alphanumeric()) {
564 return false;
565 }
566 }
567
568 true
569}
570
571fn wrap_expression(code: &str) -> String {
572 format!("IO.inspect(({}))\n", code.trim())
573}
574
575fn diff_output(previous: &str, current: &str) -> String {
576 if let Some(stripped) = current.strip_prefix(previous) {
577 stripped.to_string()
578 } else {
579 current.to_string()
580 }
581}
582
583fn normalize_output(bytes: &[u8]) -> String {
584 String::from_utf8_lossy(bytes)
585 .replace("\r\n", "\n")
586 .replace('\r', "")
587}