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}