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