run/engine/
julia.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 JuliaEngine {
13    executable: Option<PathBuf>,
14}
15
16impl JuliaEngine {
17    pub fn new() -> Self {
18        Self {
19            executable: resolve_julia_binary(),
20        }
21    }
22
23    fn ensure_executable(&self) -> Result<&Path> {
24        self.executable.as_deref().ok_or_else(|| {
25            anyhow::anyhow!(
26                "Julia support requires the `julia` executable. Install Julia from https://julialang.org/downloads/ and ensure `julia` 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-julia")
34            .tempdir()
35            .context("failed to create temporary directory for Julia source")?;
36        let path = dir.path().join("snippet.jl");
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 Julia 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("--color=no")
54            .arg("--quiet")
55            .arg(path)
56            .stdout(Stdio::piped())
57            .stderr(Stdio::piped());
58        cmd.stdin(Stdio::inherit());
59        if let Some(parent) = path.parent() {
60            cmd.current_dir(parent);
61        }
62        cmd.output().with_context(|| {
63            format!(
64                "failed to execute {} with script {}",
65                executable.display(),
66                path.display()
67            )
68        })
69    }
70}
71
72impl LanguageEngine for JuliaEngine {
73    fn id(&self) -> &'static str {
74        "julia"
75    }
76
77    fn display_name(&self) -> &'static str {
78        "Julia"
79    }
80
81    fn aliases(&self) -> &[&'static str] {
82        &["jl"]
83    }
84
85    fn supports_sessions(&self) -> bool {
86        self.executable.is_some()
87    }
88
89    fn validate(&self) -> Result<()> {
90        let executable = self.ensure_executable()?;
91        let mut cmd = Command::new(executable);
92        cmd.arg("--version")
93            .stdout(Stdio::null())
94            .stderr(Stdio::null());
95        cmd.status()
96            .with_context(|| format!("failed to invoke {}", executable.display()))?
97            .success()
98            .then_some(())
99            .ok_or_else(|| anyhow::anyhow!("{} is not executable", executable.display()))
100    }
101
102    fn execute(&self, payload: &ExecutionPayload) -> Result<ExecutionOutcome> {
103        let start = Instant::now();
104        let (temp_dir, path) = match payload {
105            ExecutionPayload::Inline { code } | ExecutionPayload::Stdin { code } => {
106                let (dir, path) = self.write_temp_source(code)?;
107                (Some(dir), path)
108            }
109            ExecutionPayload::File { path } => (None, path.clone()),
110        };
111
112        let output = self.execute_path(&path)?;
113        drop(temp_dir);
114
115        Ok(ExecutionOutcome {
116            language: self.id().to_string(),
117            exit_code: output.status.code(),
118            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
119            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
120            duration: start.elapsed(),
121        })
122    }
123
124    fn start_session(&self) -> Result<Box<dyn LanguageSession>> {
125        let executable = self.ensure_executable()?.to_path_buf();
126        Ok(Box::new(JuliaSession::new(executable)?))
127    }
128}
129
130fn resolve_julia_binary() -> Option<PathBuf> {
131    which::which("julia").ok()
132}
133
134#[derive(Default)]
135struct JuliaSessionState {
136    imports: BTreeSet<String>,
137    declarations: Vec<String>,
138    statements: Vec<String>,
139}
140
141struct JuliaSession {
142    executable: PathBuf,
143    workspace: TempDir,
144    state: JuliaSessionState,
145    previous_stdout: String,
146    previous_stderr: String,
147}
148
149impl JuliaSession {
150    fn new(executable: PathBuf) -> Result<Self> {
151        let workspace = Builder::new()
152            .prefix("run-julia-repl")
153            .tempdir()
154            .context("failed to create temporary directory for Julia repl")?;
155        let session = Self {
156            executable,
157            workspace,
158            state: JuliaSessionState::default(),
159            previous_stdout: String::new(),
160            previous_stderr: String::new(),
161        };
162        session.persist_source()?;
163        Ok(session)
164    }
165
166    fn source_path(&self) -> PathBuf {
167        self.workspace.path().join("session.jl")
168    }
169
170    fn persist_source(&self) -> Result<()> {
171        let source = self.render_source();
172        fs::write(self.source_path(), source)
173            .with_context(|| "failed to write Julia session source".to_string())
174    }
175
176    fn render_source(&self) -> String {
177        let mut source = String::new();
178
179        for import in &self.state.imports {
180            let trimmed = import.trim();
181            source.push_str(trimmed);
182            if !trimmed.ends_with('\n') {
183                source.push('\n');
184            }
185        }
186
187        source.push('\n');
188
189        for decl in &self.state.declarations {
190            source.push_str(decl);
191            if !decl.ends_with('\n') {
192                source.push('\n');
193            }
194            source.push('\n');
195        }
196
197        if self.state.statements.is_empty() {
198            source.push_str("# session body\n");
199        } else {
200            for stmt in &self.state.statements {
201                source.push_str(stmt);
202                if !stmt.ends_with('\n') {
203                    source.push('\n');
204                }
205            }
206        }
207
208        source
209    }
210
211    fn run_program(&self) -> Result<std::process::Output> {
212        let mut cmd = Command::new(&self.executable);
213        cmd.arg("--color=no")
214            .arg("--quiet")
215            .arg("session.jl")
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 Julia 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: "julia".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_import(&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 = if trimmed.ends_with(';') {
261                trimmed.trim_end_matches(';').to_string()
262            } else {
263                trimmed.to_string()
264            };
265            if self.state.imports.insert(normalized.clone()) {
266                inserted.push(normalized);
267            }
268        }
269
270        if inserted.is_empty() {
271            return Ok((
272                ExecutionOutcome {
273                    language: "julia".to_string(),
274                    exit_code: None,
275                    stdout: String::new(),
276                    stderr: String::new(),
277                    duration: Duration::default(),
278                },
279                true,
280            ));
281        }
282
283        let start = Instant::now();
284        let (outcome, success) = self.run_current(start)?;
285        if !success {
286            for line in inserted {
287                self.state.imports.remove(&line);
288            }
289            self.persist_source()?;
290        }
291        Ok((outcome, success))
292    }
293
294    fn apply_declaration(&mut self, code: &str) -> Result<(ExecutionOutcome, bool)> {
295        let snippet = ensure_trailing_newline(code);
296        self.state.declarations.push(snippet);
297        let start = Instant::now();
298        let (outcome, success) = self.run_current(start)?;
299        if !success {
300            let _ = self.state.declarations.pop();
301            self.persist_source()?;
302        }
303        Ok((outcome, success))
304    }
305
306    fn apply_statement(&mut self, code: &str) -> Result<(ExecutionOutcome, bool)> {
307        self.state.statements.push(ensure_trailing_newline(code));
308        let start = Instant::now();
309        let (outcome, success) = self.run_current(start)?;
310        if !success {
311            let _ = self.state.statements.pop();
312            self.persist_source()?;
313        }
314        Ok((outcome, success))
315    }
316
317    fn apply_expression(&mut self, code: &str) -> Result<(ExecutionOutcome, bool)> {
318        self.state.statements.push(wrap_expression(code));
319        let start = Instant::now();
320        let (outcome, success) = self.run_current(start)?;
321        if !success {
322            let _ = self.state.statements.pop();
323            self.persist_source()?;
324        }
325        Ok((outcome, success))
326    }
327
328    fn reset(&mut self) -> Result<()> {
329        self.state.imports.clear();
330        self.state.declarations.clear();
331        self.state.statements.clear();
332        self.previous_stdout.clear();
333        self.previous_stderr.clear();
334        self.persist_source()
335    }
336}
337
338impl LanguageSession for JuliaSession {
339    fn language_id(&self) -> &str {
340        "julia"
341    }
342
343    fn eval(&mut self, code: &str) -> Result<ExecutionOutcome> {
344        let trimmed = code.trim();
345        if trimmed.is_empty() {
346            return Ok(ExecutionOutcome {
347                language: "julia".to_string(),
348                exit_code: None,
349                stdout: String::new(),
350                stderr: String::new(),
351                duration: Duration::default(),
352            });
353        }
354
355        if trimmed.eq_ignore_ascii_case(":reset") {
356            self.reset()?;
357            return Ok(ExecutionOutcome {
358                language: "julia".to_string(),
359                exit_code: None,
360                stdout: String::new(),
361                stderr: String::new(),
362                duration: Duration::default(),
363            });
364        }
365
366        if trimmed.eq_ignore_ascii_case(":help") {
367            return Ok(ExecutionOutcome {
368                language: "julia".to_string(),
369                exit_code: None,
370                stdout:
371                    "Julia commands:\n  :reset — clear session state\n  :help  — show this message\n"
372                        .to_string(),
373                stderr: String::new(),
374                duration: Duration::default(),
375            });
376        }
377
378        match classify_snippet(trimmed) {
379            JuliaSnippet::Import => {
380                let (outcome, _) = self.apply_import(code)?;
381                Ok(outcome)
382            }
383            JuliaSnippet::Declaration => {
384                let (outcome, _) = self.apply_declaration(code)?;
385                Ok(outcome)
386            }
387            JuliaSnippet::Expression => {
388                let (outcome, _) = self.apply_expression(trimmed)?;
389                Ok(outcome)
390            }
391            JuliaSnippet::Statement => {
392                let (outcome, _) = self.apply_statement(code)?;
393                Ok(outcome)
394            }
395        }
396    }
397
398    fn shutdown(&mut self) -> Result<()> {
399        Ok(())
400    }
401}
402
403enum JuliaSnippet {
404    Import,
405    Declaration,
406    Statement,
407    Expression,
408}
409
410fn classify_snippet(code: &str) -> JuliaSnippet {
411    if is_import(code) {
412        return JuliaSnippet::Import;
413    }
414
415    if is_declaration(code) {
416        return JuliaSnippet::Declaration;
417    }
418
419    if should_wrap_expression(code) {
420        return JuliaSnippet::Expression;
421    }
422
423    JuliaSnippet::Statement
424}
425
426fn is_import(code: &str) -> bool {
427    code.lines().all(|line| {
428        let trimmed = line.trim_start().to_ascii_lowercase();
429        trimmed.starts_with("using ") || trimmed.starts_with("import ")
430    })
431}
432
433fn is_declaration(code: &str) -> bool {
434    let lowered = code.trim_start().to_ascii_lowercase();
435    const PREFIXES: [&str; 6] = [
436        "function ",
437        "macro ",
438        "struct ",
439        "mutable struct ",
440        "module ",
441        "abstract type ",
442    ];
443    PREFIXES.iter().any(|prefix| lowered.starts_with(prefix))
444}
445
446fn should_wrap_expression(code: &str) -> bool {
447    if code.contains('\n') {
448        return false;
449    }
450
451    let trimmed = code.trim();
452    if trimmed.is_empty() {
453        return false;
454    }
455
456    if trimmed.ends_with(';') {
457        return false;
458    }
459
460    let lowered = trimmed.to_ascii_lowercase();
461    const STATEMENT_PREFIXES: [&str; 10] = [
462        "let ",
463        "for ",
464        "while ",
465        "if ",
466        "begin",
467        "return ",
468        "break",
469        "continue",
470        "function ",
471        "macro ",
472    ];
473
474    if STATEMENT_PREFIXES
475        .iter()
476        .any(|prefix| lowered.starts_with(prefix))
477    {
478        return false;
479    }
480
481    if trimmed.contains('=') {
482        return false;
483    }
484
485    true
486}
487
488fn ensure_trailing_newline(code: &str) -> String {
489    let mut owned = code.to_string();
490    if !owned.ends_with('\n') {
491        owned.push('\n');
492    }
493    owned
494}
495
496fn wrap_expression(code: &str) -> String {
497    format!("println({});\n", code.trim())
498}
499
500fn diff_output(previous: &str, current: &str) -> String {
501    if let Some(stripped) = current.strip_prefix(previous) {
502        stripped.to_string()
503    } else {
504        current.to_string()
505    }
506}
507
508fn normalize_output(bytes: &[u8]) -> String {
509    String::from_utf8_lossy(bytes)
510        .replace("\r\n", "\n")
511        .replace('\r', "")
512}