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}