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}