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