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();
211 let mut rest_lines = Vec::new();
212 let mut in_header = true;
213
214 for line in body.lines() {
215 let trimmed = line.trim_start();
216 if in_header && (trimmed.starts_with("import ") || trimmed.starts_with("package ")) {
217 header_lines.push(line);
218 continue;
219 }
220 in_header = false;
221 rest_lines.push(line);
222 }
223
224 let mut result = String::new();
225 if !header_lines.is_empty() {
226 for hl in header_lines {
227 result.push_str(hl);
228 if !hl.ends_with('\n') {
229 result.push('\n');
230 }
231 }
232 result.push('\n');
233 }
234
235 result.push_str("fun main() {\n");
236 for line in rest_lines {
237 if line.trim().is_empty() {
238 result.push_str(" \n");
239 } else {
240 result.push_str(" ");
241 result.push_str(line);
242 result.push('\n');
243 }
244 }
245 result.push_str("}\n");
246 result
247}
248
249fn contains_main_function(code: &str) -> bool {
250 code.lines()
251 .any(|line| line.trim_start().starts_with("fun main"))
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255enum SnippetKind {
256 Definition,
257 Statement,
258 Expression,
259}
260
261fn classify_snippet(code: &str) -> SnippetKind {
262 let trimmed = code.trim();
263 if trimmed.is_empty() {
264 return SnippetKind::Statement;
265 }
266
267 const DEF_PREFIXES: [&str; 13] = [
268 "fun ",
269 "class ",
270 "object ",
271 "interface ",
272 "enum ",
273 "sealed ",
274 "data class ",
275 "annotation ",
276 "typealias ",
277 "package ",
278 "import ",
279 "val ",
280 "var ",
281 ];
282 if DEF_PREFIXES
283 .iter()
284 .any(|prefix| trimmed.starts_with(prefix))
285 {
286 return SnippetKind::Definition;
287 }
288
289 if trimmed.starts_with('@') {
290 return SnippetKind::Definition;
291 }
292
293 if is_kotlin_expression(trimmed) {
294 return SnippetKind::Expression;
295 }
296
297 SnippetKind::Statement
298}
299
300fn is_kotlin_expression(code: &str) -> bool {
301 if code.contains('\n') {
302 return false;
303 }
304 if code.ends_with(';') {
305 return false;
306 }
307
308 let lowered = code.trim_start().to_ascii_lowercase();
309 const DISALLOWED_PREFIXES: [&str; 14] = [
310 "while ", "for ", "do ", "try ", "catch", "finally", "return ", "throw ", "break",
311 "continue", "val ", "var ", "fun ", "class ",
312 ];
313 if DISALLOWED_PREFIXES
314 .iter()
315 .any(|prefix| lowered.starts_with(prefix))
316 {
317 return false;
318 }
319
320 if code.starts_with("print") {
321 return false;
322 }
323
324 if code == "true" || code == "false" {
325 return true;
326 }
327 if code.parse::<f64>().is_ok() {
328 return true;
329 }
330 if code.starts_with('"') && code.ends_with('"') && code.len() >= 2 {
331 return true;
332 }
333 if code.contains("==")
334 || code.contains("!=")
335 || code.contains("<=")
336 || code.contains(">=")
337 || code.contains("&&")
338 || code.contains("||")
339 {
340 return true;
341 }
342 const ASSIGN_OPS: [&str; 7] = ["=", "+=", "-=", "*=", "/=", "%=", "= "];
343 if ASSIGN_OPS.iter().any(|op| code.contains(op))
344 && !code.contains("==")
345 && !code.contains("!=")
346 && !code.contains(">=")
347 && !code.contains("<=")
348 && !code.contains("=>")
349 {
350 return false;
351 }
352
353 if code.chars().any(|c| "+-*/%<>^|&".contains(c)) {
354 return true;
355 }
356
357 if code
358 .chars()
359 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '$')
360 {
361 return true;
362 }
363
364 code.contains('(') && code.contains(')')
365}
366
367fn wrap_kotlin_expression(code: &str, index: usize) -> String {
368 format!("val __repl_val_{index} = ({code})\nprintln(__repl_val_{index})\n")
369}
370
371fn ensure_trailing_newline(code: &str) -> String {
372 let mut owned = code.to_string();
373 if !owned.ends_with('\n') {
374 owned.push('\n');
375 }
376 owned
377}
378
379fn diff_output(previous: &str, current: &str) -> String {
380 if let Some(stripped) = current.strip_prefix(previous) {
381 stripped.to_string()
382 } else {
383 current.to_string()
384 }
385}
386
387fn normalize_output(bytes: &[u8]) -> String {
388 String::from_utf8_lossy(bytes)
389 .replace("\r\n", "\n")
390 .replace('\r', "")
391}
392
393fn invoke_kotlin_compiler(
394 compiler: &Path,
395 source: &Path,
396 jar: &Path,
397) -> Result<std::process::Output> {
398 let mut cmd = Command::new(compiler);
399 cmd.arg(source)
400 .arg("-include-runtime")
401 .arg("-d")
402 .arg(jar)
403 .stdout(Stdio::piped())
404 .stderr(Stdio::piped());
405 cmd.output().with_context(|| {
406 format!(
407 "failed to invoke {} to compile {}",
408 compiler.display(),
409 source.display()
410 )
411 })
412}
413
414fn run_kotlin_jar(java: &Path, jar: &Path) -> Result<std::process::Output> {
415 let mut cmd = Command::new(java);
416 cmd.arg("-jar")
417 .arg(jar)
418 .stdout(Stdio::piped())
419 .stderr(Stdio::piped());
420 cmd.stdin(Stdio::inherit());
421 cmd.output().with_context(|| {
422 format!(
423 "failed to execute {} -jar {}",
424 java.display(),
425 jar.display()
426 )
427 })
428}
429struct KotlinSession {
430 compiler: PathBuf,
431 java: PathBuf,
432 _dir: TempDir,
433 source_path: PathBuf,
434 jar_path: PathBuf,
435 definitions: Vec<String>,
436 statements: Vec<String>,
437 previous_stdout: String,
438 previous_stderr: String,
439}
440
441impl KotlinSession {
442 fn render_prelude(&self) -> String {
443 let mut source = String::from("import kotlin.math.*\n\n");
444 for def in &self.definitions {
445 source.push_str(def);
446 if !def.ends_with('\n') {
447 source.push('\n');
448 }
449 source.push('\n');
450 }
451 source
452 }
453
454 fn render_source(&self) -> String {
455 let mut source = self.render_prelude();
456 source.push_str("fun main() {\n");
457 for stmt in &self.statements {
458 for line in stmt.lines() {
459 source.push_str(" ");
460 source.push_str(line);
461 source.push('\n');
462 }
463 if !stmt.ends_with('\n') {
464 source.push('\n');
465 }
466 }
467 source.push_str("}\n");
468 source
469 }
470
471 fn write_source(&self, contents: &str) -> Result<()> {
472 fs::write(&self.source_path, contents).with_context(|| {
473 format!(
474 "failed to write generated Kotlin REPL source to {}",
475 self.source_path.display()
476 )
477 })
478 }
479
480 fn compile_and_run(&mut self) -> Result<(std::process::Output, Duration)> {
481 let start = Instant::now();
482 let source = self.render_source();
483 self.write_source(&source)?;
484 let compile_output =
485 invoke_kotlin_compiler(&self.compiler, &self.source_path, &self.jar_path)?;
486 if !compile_output.status.success() {
487 return Ok((compile_output, start.elapsed()));
488 }
489 let run_output = run_kotlin_jar(&self.java, &self.jar_path)?;
490 Ok((run_output, start.elapsed()))
491 }
492
493 fn diff_outputs(
494 &mut self,
495 output: &std::process::Output,
496 duration: Duration,
497 ) -> ExecutionOutcome {
498 let stdout_full = normalize_output(&output.stdout);
499 let stderr_full = normalize_output(&output.stderr);
500
501 let stdout_delta = diff_output(&self.previous_stdout, &stdout_full);
502 let stderr_delta = diff_output(&self.previous_stderr, &stderr_full);
503
504 if output.status.success() {
505 self.previous_stdout = stdout_full;
506 self.previous_stderr = stderr_full;
507 }
508
509 ExecutionOutcome {
510 language: "kotlin".to_string(),
511 exit_code: output.status.code(),
512 stdout: stdout_delta,
513 stderr: stderr_delta,
514 duration,
515 }
516 }
517
518 fn add_definition(&mut self, snippet: String) {
519 self.definitions.push(snippet);
520 }
521
522 fn add_statement(&mut self, snippet: String) {
523 self.statements.push(snippet);
524 }
525
526 fn remove_last_definition(&mut self) {
527 let _ = self.definitions.pop();
528 }
529
530 fn remove_last_statement(&mut self) {
531 let _ = self.statements.pop();
532 }
533
534 fn reset_state(&mut self) -> Result<()> {
535 self.definitions.clear();
536 self.statements.clear();
537 self.previous_stdout.clear();
538 self.previous_stderr.clear();
539 let source = self.render_source();
540 self.write_source(&source)
541 }
542
543 fn run_standalone_program(&mut self, code: &str) -> Result<ExecutionOutcome> {
544 let start = Instant::now();
545 let mut source = self.render_prelude();
546 if !source.ends_with('\n') {
547 source.push('\n');
548 }
549 source.push_str(code);
550 if !code.ends_with('\n') {
551 source.push('\n');
552 }
553
554 let standalone_path = self
555 .source_path
556 .parent()
557 .unwrap_or_else(|| Path::new("."))
558 .join("standalone.kt");
559 fs::write(&standalone_path, &source).with_context(|| {
560 format!(
561 "failed to write standalone Kotlin source to {}",
562 standalone_path.display()
563 )
564 })?;
565
566 let compile_output =
567 invoke_kotlin_compiler(&self.compiler, &standalone_path, &self.jar_path)?;
568 if !compile_output.status.success() {
569 return Ok(ExecutionOutcome {
570 language: "kotlin".to_string(),
571 exit_code: compile_output.status.code(),
572 stdout: normalize_output(&compile_output.stdout),
573 stderr: normalize_output(&compile_output.stderr),
574 duration: start.elapsed(),
575 });
576 }
577
578 let run_output = run_kotlin_jar(&self.java, &self.jar_path)?;
579 Ok(ExecutionOutcome {
580 language: "kotlin".to_string(),
581 exit_code: run_output.status.code(),
582 stdout: normalize_output(&run_output.stdout),
583 stderr: normalize_output(&run_output.stderr),
584 duration: start.elapsed(),
585 })
586 }
587}
588
589impl LanguageSession for KotlinSession {
590 fn language_id(&self) -> &str {
591 "kotlin"
592 }
593
594 fn eval(&mut self, code: &str) -> Result<ExecutionOutcome> {
595 let trimmed = code.trim();
596 if trimmed.is_empty() {
597 return Ok(ExecutionOutcome {
598 language: self.language_id().to_string(),
599 exit_code: None,
600 stdout: String::new(),
601 stderr: String::new(),
602 duration: Duration::default(),
603 });
604 }
605
606 if trimmed.eq_ignore_ascii_case(":reset") {
607 self.reset_state()?;
608 return Ok(ExecutionOutcome {
609 language: self.language_id().to_string(),
610 exit_code: None,
611 stdout: String::new(),
612 stderr: String::new(),
613 duration: Duration::default(),
614 });
615 }
616
617 if trimmed.eq_ignore_ascii_case(":help") {
618 return Ok(ExecutionOutcome {
619 language: self.language_id().to_string(),
620 exit_code: None,
621 stdout:
622 "Kotlin commands:\n :reset - clear session state\n :help - show this message\n"
623 .to_string(),
624 stderr: String::new(),
625 duration: Duration::default(),
626 });
627 }
628
629 if contains_main_function(code) {
630 return self.run_standalone_program(code);
631 }
632
633 let classification = classify_snippet(trimmed);
634 match classification {
635 SnippetKind::Definition => {
636 self.add_definition(code.to_string());
637 let (output, duration) = self.compile_and_run()?;
638 if !output.status.success() {
639 self.remove_last_definition();
640 }
641 Ok(self.diff_outputs(&output, duration))
642 }
643 SnippetKind::Expression => {
644 let wrapped = wrap_kotlin_expression(trimmed, self.statements.len());
645 self.add_statement(wrapped);
646 let (output, duration) = self.compile_and_run()?;
647 if !output.status.success() {
648 self.remove_last_statement();
649 }
650 Ok(self.diff_outputs(&output, duration))
651 }
652 SnippetKind::Statement => {
653 let stmt = ensure_trailing_newline(code);
654 self.add_statement(stmt);
655 let (output, duration) = self.compile_and_run()?;
656 if !output.status.success() {
657 self.remove_last_statement();
658 }
659 Ok(self.diff_outputs(&output, duration))
660 }
661 }
662 }
663
664 fn shutdown(&mut self) -> Result<()> {
665 Ok(())
666 }
667}