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