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