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