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::{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}