Skip to main content

run/engine/
kotlin.rs

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        // Check jar cache for inline/stdin payloads
134        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        // Cache the compiled jar
187        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}