Skip to main content

run/engine/
elixir.rs

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}