Skip to main content

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