1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::{Command, Stdio};
4use std::time::{Duration, Instant};
5
6use anyhow::{Context, Result};
7use tempfile::{Builder, TempDir};
8
9use super::{ExecutionOutcome, ExecutionPayload, LanguageEngine, LanguageSession, hash_source};
10
11pub struct KotlinEngine {
12 compiler: Option<PathBuf>,
13 java: Option<PathBuf>,
14}
15
16impl Default for KotlinEngine {
17 fn default() -> Self {
18 Self::new()
19 }
20}
21
22impl KotlinEngine {
23 pub fn new() -> Self {
24 Self {
25 compiler: resolve_kotlinc_binary(),
26 java: resolve_java_binary(),
27 }
28 }
29
30 fn ensure_compiler(&self) -> Result<&Path> {
31 self.compiler.as_deref().ok_or_else(|| {
32 anyhow::anyhow!(
33 "Kotlin support requires the `kotlinc` compiler. Install it from https://kotlinlang.org/docs/command-line.html and ensure it is on your PATH."
34 )
35 })
36 }
37
38 fn ensure_java(&self) -> Result<&Path> {
39 self.java.as_deref().ok_or_else(|| {
40 anyhow::anyhow!(
41 "Kotlin execution requires a `java` runtime. Install a JDK and ensure `java` is on your PATH."
42 )
43 })
44 }
45
46 fn write_inline_source(&self, code: &str, dir: &Path) -> Result<PathBuf> {
47 let source_path = dir.join("Main.kt");
48 let wrapped = wrap_inline_kotlin(code);
49 std::fs::write(&source_path, wrapped).with_context(|| {
50 format!(
51 "failed to write temporary Kotlin source to {}",
52 source_path.display()
53 )
54 })?;
55 Ok(source_path)
56 }
57
58 fn copy_source(&self, original: &Path, dir: &Path) -> Result<PathBuf> {
59 let file_name = original
60 .file_name()
61 .map(|f| f.to_owned())
62 .ok_or_else(|| anyhow::anyhow!("invalid Kotlin source path"))?;
63 let target = dir.join(&file_name);
64 std::fs::copy(original, &target).with_context(|| {
65 format!(
66 "failed to copy Kotlin source from {} to {}",
67 original.display(),
68 target.display()
69 )
70 })?;
71 Ok(target)
72 }
73
74 fn compile(&self, source: &Path, jar: &Path) -> Result<std::process::Output> {
75 let compiler = self.ensure_compiler()?;
76 invoke_kotlin_compiler(compiler, source, jar)
77 }
78
79 fn run(&self, jar: &Path) -> Result<std::process::Output> {
80 let java = self.ensure_java()?;
81 run_kotlin_jar(java, jar)
82 }
83}
84
85impl LanguageEngine for KotlinEngine {
86 fn id(&self) -> &'static str {
87 "kotlin"
88 }
89
90 fn display_name(&self) -> &'static str {
91 "Kotlin"
92 }
93
94 fn aliases(&self) -> &[&'static str] {
95 &["kt"]
96 }
97
98 fn supports_sessions(&self) -> bool {
99 self.compiler.is_some() && self.java.is_some()
100 }
101
102 fn validate(&self) -> Result<()> {
103 let compiler = self.ensure_compiler()?;
104 let mut compile_check = Command::new(compiler);
105 compile_check
106 .arg("-version")
107 .stdout(Stdio::null())
108 .stderr(Stdio::null());
109 compile_check
110 .status()
111 .with_context(|| format!("failed to invoke {}", compiler.display()))?
112 .success()
113 .then_some(())
114 .ok_or_else(|| anyhow::anyhow!("{} is not executable", compiler.display()))?;
115
116 let java = self.ensure_java()?;
117 let mut java_check = Command::new(java);
118 java_check
119 .arg("-version")
120 .stdout(Stdio::null())
121 .stderr(Stdio::null());
122 java_check
123 .status()
124 .with_context(|| format!("failed to invoke {}", java.display()))?
125 .success()
126 .then_some(())
127 .ok_or_else(|| anyhow::anyhow!("{} is not executable", java.display()))?;
128
129 Ok(())
130 }
131
132 fn execute(&self, payload: &ExecutionPayload) -> Result<ExecutionOutcome> {
133 if let Some(code) = match payload {
135 ExecutionPayload::Inline { code } | ExecutionPayload::Stdin { code } => {
136 Some(code.as_str())
137 }
138 _ => None,
139 } {
140 let wrapped = wrap_inline_kotlin(code);
141 let src_hash = hash_source(&wrapped);
142 let cached_jar = std::env::temp_dir()
143 .join("run-compile-cache")
144 .join(format!("kotlin-{:016x}.jar", src_hash));
145 if cached_jar.exists() {
146 let start = Instant::now();
147 if let Ok(output) = self.run(&cached_jar) {
148 return Ok(ExecutionOutcome {
149 language: self.id().to_string(),
150 exit_code: output.status.code(),
151 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
152 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
153 duration: start.elapsed(),
154 });
155 }
156 }
157 }
158
159 let temp_dir = Builder::new()
160 .prefix("run-kotlin")
161 .tempdir()
162 .context("failed to create temporary directory for kotlin build")?;
163 let dir_path = temp_dir.path();
164
165 let source_path = match payload {
166 ExecutionPayload::Inline { code } | ExecutionPayload::Stdin { code } => {
167 self.write_inline_source(code, dir_path)?
168 }
169 ExecutionPayload::File { path } => self.copy_source(path, dir_path)?,
170 };
171
172 let jar_path = dir_path.join("snippet.jar");
173 let start = Instant::now();
174
175 let compile_output = self.compile(&source_path, &jar_path)?;
176 if !compile_output.status.success() {
177 return Ok(ExecutionOutcome {
178 language: self.id().to_string(),
179 exit_code: compile_output.status.code(),
180 stdout: String::from_utf8_lossy(&compile_output.stdout).into_owned(),
181 stderr: String::from_utf8_lossy(&compile_output.stderr).into_owned(),
182 duration: start.elapsed(),
183 });
184 }
185
186 if let Some(code) = match payload {
188 ExecutionPayload::Inline { code } | ExecutionPayload::Stdin { code } => {
189 Some(code.as_str())
190 }
191 _ => None,
192 } {
193 let wrapped = wrap_inline_kotlin(code);
194 let src_hash = hash_source(&wrapped);
195 let cache_dir = std::env::temp_dir().join("run-compile-cache");
196 let _ = std::fs::create_dir_all(&cache_dir);
197 let cached_jar = cache_dir.join(format!("kotlin-{:016x}.jar", src_hash));
198 let _ = std::fs::copy(&jar_path, &cached_jar);
199 }
200
201 let run_output = self.run(&jar_path)?;
202 Ok(ExecutionOutcome {
203 language: self.id().to_string(),
204 exit_code: run_output.status.code(),
205 stdout: String::from_utf8_lossy(&run_output.stdout).into_owned(),
206 stderr: String::from_utf8_lossy(&run_output.stderr).into_owned(),
207 duration: start.elapsed(),
208 })
209 }
210
211 fn start_session(&self) -> Result<Box<dyn LanguageSession>> {
212 let compiler = self.ensure_compiler()?.to_path_buf();
213 let java = self.ensure_java()?.to_path_buf();
214
215 let dir = Builder::new()
216 .prefix("run-kotlin-repl")
217 .tempdir()
218 .context("failed to create temporary directory for kotlin repl")?;
219 let dir_path = dir.path();
220
221 let source_path = dir_path.join("Session.kt");
222 let jar_path = dir_path.join("session.jar");
223 fs::write(&source_path, "// Kotlin REPL session\n").with_context(|| {
224 format!(
225 "failed to initialize Kotlin session source at {}",
226 source_path.display()
227 )
228 })?;
229
230 Ok(Box::new(KotlinSession {
231 compiler,
232 java,
233 _dir: dir,
234 source_path,
235 jar_path,
236 definitions: Vec::new(),
237 statements: Vec::new(),
238 previous_stdout: String::new(),
239 previous_stderr: String::new(),
240 }))
241 }
242}
243
244fn resolve_kotlinc_binary() -> Option<PathBuf> {
245 which::which("kotlinc").ok()
246}
247
248fn resolve_java_binary() -> Option<PathBuf> {
249 which::which("java").ok()
250}
251
252fn wrap_inline_kotlin(body: &str) -> String {
253 if body.contains("fun main") {
254 return body.to_string();
255 }
256
257 let mut header_lines = Vec::new();
258 let mut rest_lines = Vec::new();
259 let mut in_header = true;
260
261 for line in body.lines() {
262 let trimmed = line.trim_start();
263 if in_header && (trimmed.starts_with("import ") || trimmed.starts_with("package ")) {
264 header_lines.push(line);
265 continue;
266 }
267 in_header = false;
268 rest_lines.push(line);
269 }
270
271 let mut result = String::new();
272 if !header_lines.is_empty() {
273 for hl in header_lines {
274 result.push_str(hl);
275 if !hl.ends_with('\n') {
276 result.push('\n');
277 }
278 }
279 result.push('\n');
280 }
281
282 result.push_str("fun main() {\n");
283 for line in rest_lines {
284 if line.trim().is_empty() {
285 result.push_str(" \n");
286 } else {
287 result.push_str(" ");
288 result.push_str(line);
289 result.push('\n');
290 }
291 }
292 result.push_str("}\n");
293 result
294}
295
296fn contains_main_function(code: &str) -> bool {
297 code.lines()
298 .any(|line| line.trim_start().starts_with("fun main"))
299}
300
301#[derive(Debug, Clone, Copy, PartialEq, Eq)]
302enum SnippetKind {
303 Definition,
304 Statement,
305 Expression,
306}
307
308fn classify_snippet(code: &str) -> SnippetKind {
309 let trimmed = code.trim();
310 if trimmed.is_empty() {
311 return SnippetKind::Statement;
312 }
313
314 const DEF_PREFIXES: [&str; 13] = [
315 "fun ",
316 "class ",
317 "object ",
318 "interface ",
319 "enum ",
320 "sealed ",
321 "data class ",
322 "annotation ",
323 "typealias ",
324 "package ",
325 "import ",
326 "val ",
327 "var ",
328 ];
329 if DEF_PREFIXES
330 .iter()
331 .any(|prefix| trimmed.starts_with(prefix))
332 {
333 return SnippetKind::Definition;
334 }
335
336 if trimmed.starts_with('@') {
337 return SnippetKind::Definition;
338 }
339
340 if is_kotlin_expression(trimmed) {
341 return SnippetKind::Expression;
342 }
343
344 SnippetKind::Statement
345}
346
347fn is_kotlin_expression(code: &str) -> bool {
348 if code.contains('\n') {
349 return false;
350 }
351 if code.ends_with(';') {
352 return false;
353 }
354
355 let lowered = code.trim_start().to_ascii_lowercase();
356 const DISALLOWED_PREFIXES: [&str; 14] = [
357 "while ", "for ", "do ", "try ", "catch", "finally", "return ", "throw ", "break",
358 "continue", "val ", "var ", "fun ", "class ",
359 ];
360 if DISALLOWED_PREFIXES
361 .iter()
362 .any(|prefix| lowered.starts_with(prefix))
363 {
364 return false;
365 }
366
367 if code.starts_with("print") {
368 return false;
369 }
370
371 if code == "true" || code == "false" {
372 return true;
373 }
374 if code.parse::<f64>().is_ok() {
375 return true;
376 }
377 if code.starts_with('"') && code.ends_with('"') && code.len() >= 2 {
378 return true;
379 }
380 if code.contains("==")
381 || code.contains("!=")
382 || code.contains("<=")
383 || code.contains(">=")
384 || code.contains("&&")
385 || code.contains("||")
386 {
387 return true;
388 }
389 const ASSIGN_OPS: [&str; 7] = ["=", "+=", "-=", "*=", "/=", "%=", "= "];
390 if ASSIGN_OPS.iter().any(|op| code.contains(op))
391 && !code.contains("==")
392 && !code.contains("!=")
393 && !code.contains(">=")
394 && !code.contains("<=")
395 && !code.contains("=>")
396 {
397 return false;
398 }
399
400 if code.chars().any(|c| "+-*/%<>^|&".contains(c)) {
401 return true;
402 }
403
404 if code
405 .chars()
406 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '$')
407 {
408 return true;
409 }
410
411 code.contains('(') && code.contains(')')
412}
413
414fn wrap_kotlin_expression(code: &str, index: usize) -> String {
415 format!("val __repl_val_{index} = ({code})\nprintln(__repl_val_{index})\n")
416}
417
418fn ensure_trailing_newline(code: &str) -> String {
419 let mut owned = code.to_string();
420 if !owned.ends_with('\n') {
421 owned.push('\n');
422 }
423 owned
424}
425
426fn diff_output(previous: &str, current: &str) -> String {
427 if let Some(stripped) = current.strip_prefix(previous) {
428 stripped.to_string()
429 } else {
430 current.to_string()
431 }
432}
433
434fn normalize_output(bytes: &[u8]) -> String {
435 String::from_utf8_lossy(bytes)
436 .replace("\r\n", "\n")
437 .replace('\r', "")
438}
439
440fn invoke_kotlin_compiler(
441 compiler: &Path,
442 source: &Path,
443 jar: &Path,
444) -> Result<std::process::Output> {
445 let mut cmd = Command::new(compiler);
446 cmd.arg(source)
447 .arg("-include-runtime")
448 .arg("-d")
449 .arg(jar)
450 .stdout(Stdio::piped())
451 .stderr(Stdio::piped());
452 cmd.output().with_context(|| {
453 format!(
454 "failed to invoke {} to compile {}",
455 compiler.display(),
456 source.display()
457 )
458 })
459}
460
461fn run_kotlin_jar(java: &Path, jar: &Path) -> Result<std::process::Output> {
462 let mut cmd = Command::new(java);
463 cmd.arg("-jar")
464 .arg(jar)
465 .stdout(Stdio::piped())
466 .stderr(Stdio::piped());
467 cmd.stdin(Stdio::inherit());
468 cmd.output().with_context(|| {
469 format!(
470 "failed to execute {} -jar {}",
471 java.display(),
472 jar.display()
473 )
474 })
475}
476struct KotlinSession {
477 compiler: PathBuf,
478 java: PathBuf,
479 _dir: TempDir,
480 source_path: PathBuf,
481 jar_path: PathBuf,
482 definitions: Vec<String>,
483 statements: Vec<String>,
484 previous_stdout: String,
485 previous_stderr: String,
486}
487
488impl KotlinSession {
489 fn render_prelude(&self) -> String {
490 let mut source = String::from("import kotlin.math.*\n\n");
491 for def in &self.definitions {
492 source.push_str(def);
493 if !def.ends_with('\n') {
494 source.push('\n');
495 }
496 source.push('\n');
497 }
498 source
499 }
500
501 fn render_source(&self) -> String {
502 let mut source = self.render_prelude();
503 source.push_str("fun main() {\n");
504 for stmt in &self.statements {
505 for line in stmt.lines() {
506 source.push_str(" ");
507 source.push_str(line);
508 source.push('\n');
509 }
510 if !stmt.ends_with('\n') {
511 source.push('\n');
512 }
513 }
514 source.push_str("}\n");
515 source
516 }
517
518 fn write_source(&self, contents: &str) -> Result<()> {
519 fs::write(&self.source_path, contents).with_context(|| {
520 format!(
521 "failed to write generated Kotlin REPL source to {}",
522 self.source_path.display()
523 )
524 })
525 }
526
527 fn compile_and_run(&mut self) -> Result<(std::process::Output, Duration)> {
528 let start = Instant::now();
529 let source = self.render_source();
530 self.write_source(&source)?;
531 let compile_output =
532 invoke_kotlin_compiler(&self.compiler, &self.source_path, &self.jar_path)?;
533 if !compile_output.status.success() {
534 return Ok((compile_output, start.elapsed()));
535 }
536 let run_output = run_kotlin_jar(&self.java, &self.jar_path)?;
537 Ok((run_output, start.elapsed()))
538 }
539
540 fn diff_outputs(
541 &mut self,
542 output: &std::process::Output,
543 duration: Duration,
544 ) -> ExecutionOutcome {
545 let stdout_full = normalize_output(&output.stdout);
546 let stderr_full = normalize_output(&output.stderr);
547
548 let stdout_delta = diff_output(&self.previous_stdout, &stdout_full);
549 let stderr_delta = diff_output(&self.previous_stderr, &stderr_full);
550
551 if output.status.success() {
552 self.previous_stdout = stdout_full;
553 self.previous_stderr = stderr_full;
554 }
555
556 ExecutionOutcome {
557 language: "kotlin".to_string(),
558 exit_code: output.status.code(),
559 stdout: stdout_delta,
560 stderr: stderr_delta,
561 duration,
562 }
563 }
564
565 fn add_definition(&mut self, snippet: String) {
566 self.definitions.push(snippet);
567 }
568
569 fn add_statement(&mut self, snippet: String) {
570 self.statements.push(snippet);
571 }
572
573 fn remove_last_definition(&mut self) {
574 let _ = self.definitions.pop();
575 }
576
577 fn remove_last_statement(&mut self) {
578 let _ = self.statements.pop();
579 }
580
581 fn reset_state(&mut self) -> Result<()> {
582 self.definitions.clear();
583 self.statements.clear();
584 self.previous_stdout.clear();
585 self.previous_stderr.clear();
586 let source = self.render_source();
587 self.write_source(&source)
588 }
589
590 fn run_standalone_program(&mut self, code: &str) -> Result<ExecutionOutcome> {
591 let start = Instant::now();
592 let mut source = self.render_prelude();
593 if !source.ends_with('\n') {
594 source.push('\n');
595 }
596 source.push_str(code);
597 if !code.ends_with('\n') {
598 source.push('\n');
599 }
600
601 let standalone_path = self
602 .source_path
603 .parent()
604 .unwrap_or_else(|| Path::new("."))
605 .join("standalone.kt");
606 fs::write(&standalone_path, &source).with_context(|| {
607 format!(
608 "failed to write standalone Kotlin source to {}",
609 standalone_path.display()
610 )
611 })?;
612
613 let compile_output =
614 invoke_kotlin_compiler(&self.compiler, &standalone_path, &self.jar_path)?;
615 if !compile_output.status.success() {
616 return Ok(ExecutionOutcome {
617 language: "kotlin".to_string(),
618 exit_code: compile_output.status.code(),
619 stdout: normalize_output(&compile_output.stdout),
620 stderr: normalize_output(&compile_output.stderr),
621 duration: start.elapsed(),
622 });
623 }
624
625 let run_output = run_kotlin_jar(&self.java, &self.jar_path)?;
626 Ok(ExecutionOutcome {
627 language: "kotlin".to_string(),
628 exit_code: run_output.status.code(),
629 stdout: normalize_output(&run_output.stdout),
630 stderr: normalize_output(&run_output.stderr),
631 duration: start.elapsed(),
632 })
633 }
634}
635
636impl LanguageSession for KotlinSession {
637 fn language_id(&self) -> &str {
638 "kotlin"
639 }
640
641 fn eval(&mut self, code: &str) -> Result<ExecutionOutcome> {
642 let trimmed = code.trim();
643 if trimmed.is_empty() {
644 return Ok(ExecutionOutcome {
645 language: self.language_id().to_string(),
646 exit_code: None,
647 stdout: String::new(),
648 stderr: String::new(),
649 duration: Duration::default(),
650 });
651 }
652
653 if trimmed.eq_ignore_ascii_case(":reset") {
654 self.reset_state()?;
655 return Ok(ExecutionOutcome {
656 language: self.language_id().to_string(),
657 exit_code: None,
658 stdout: String::new(),
659 stderr: String::new(),
660 duration: Duration::default(),
661 });
662 }
663
664 if trimmed.eq_ignore_ascii_case(":help") {
665 return Ok(ExecutionOutcome {
666 language: self.language_id().to_string(),
667 exit_code: None,
668 stdout:
669 "Kotlin commands:\n :reset - clear session state\n :help - show this message\n"
670 .to_string(),
671 stderr: String::new(),
672 duration: Duration::default(),
673 });
674 }
675
676 if contains_main_function(code) {
677 return self.run_standalone_program(code);
678 }
679
680 let classification = classify_snippet(trimmed);
681 match classification {
682 SnippetKind::Definition => {
683 self.add_definition(code.to_string());
684 let (output, duration) = self.compile_and_run()?;
685 if !output.status.success() {
686 self.remove_last_definition();
687 }
688 Ok(self.diff_outputs(&output, duration))
689 }
690 SnippetKind::Expression => {
691 let wrapped = wrap_kotlin_expression(trimmed, self.statements.len());
692 self.add_statement(wrapped);
693 let (output, duration) = self.compile_and_run()?;
694 if !output.status.success() {
695 self.remove_last_statement();
696 }
697 Ok(self.diff_outputs(&output, duration))
698 }
699 SnippetKind::Statement => {
700 let stmt = ensure_trailing_newline(code);
701 self.add_statement(stmt);
702 let (output, duration) = self.compile_and_run()?;
703 if !output.status.success() {
704 self.remove_last_statement();
705 }
706 Ok(self.diff_outputs(&output, duration))
707 }
708 }
709 }
710
711 fn shutdown(&mut self) -> Result<()> {
712 Ok(())
713 }
714}