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