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