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