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