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    // Pull any leading import/package lines out so they remain at top-level
211    // (imports are not allowed inside a function). We'll keep those lines
212    // verbatim, then wrap the remaining snippet body inside fun main().
213    let mut header_lines = Vec::new();
214    let mut rest_lines = Vec::new();
215    let mut in_header = true;
216
217    for line in body.lines() {
218        let trimmed = line.trim_start();
219        if in_header && (trimmed.starts_with("import ") || trimmed.starts_with("package ")) {
220            header_lines.push(line);
221            continue;
222        }
223        in_header = false;
224        rest_lines.push(line);
225    }
226
227    let mut result = String::new();
228    if !header_lines.is_empty() {
229        for hl in header_lines {
230            result.push_str(hl);
231            if !hl.ends_with('\n') {
232                result.push('\n');
233            }
234        }
235        result.push('\n');
236    }
237
238    result.push_str("fun main() {\n");
239    for line in rest_lines {
240        if line.trim().is_empty() {
241            result.push_str("    \n");
242        } else {
243            result.push_str("    ");
244            result.push_str(line);
245            result.push('\n');
246        }
247    }
248    result.push_str("}\n");
249    result
250}
251
252fn contains_main_function(code: &str) -> bool {
253    code.lines()
254        .any(|line| line.trim_start().starts_with("fun main"))
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258enum SnippetKind {
259    Definition,
260    Statement,
261    Expression,
262}
263
264fn classify_snippet(code: &str) -> SnippetKind {
265    let trimmed = code.trim();
266    if trimmed.is_empty() {
267        return SnippetKind::Statement;
268    }
269
270    const DEF_PREFIXES: [&str; 13] = [
271        "fun ",
272        "class ",
273        "object ",
274        "interface ",
275        "enum ",
276        "sealed ",
277        "data class ",
278        "annotation ",
279        "typealias ",
280        "package ",
281        "import ",
282        "val ",
283        "var ",
284    ];
285    if DEF_PREFIXES
286        .iter()
287        .any(|prefix| trimmed.starts_with(prefix))
288    {
289        return SnippetKind::Definition;
290    }
291
292    if trimmed.starts_with('@') {
293        return SnippetKind::Definition;
294    }
295
296    if is_kotlin_expression(trimmed) {
297        return SnippetKind::Expression;
298    }
299
300    SnippetKind::Statement
301}
302
303fn is_kotlin_expression(code: &str) -> bool {
304    if code.contains('\n') {
305        return false;
306    }
307    if code.ends_with(';') {
308        return false;
309    }
310
311    let lowered = code.trim_start().to_ascii_lowercase();
312    const DISALLOWED_PREFIXES: [&str; 14] = [
313        "while ", "for ", "do ", "try ", "catch", "finally", "return ", "throw ", "break",
314        "continue", "val ", "var ", "fun ", "class ",
315    ];
316    if DISALLOWED_PREFIXES
317        .iter()
318        .any(|prefix| lowered.starts_with(prefix))
319    {
320        return false;
321    }
322
323    if code.starts_with("print") {
324        return false;
325    }
326
327    if code == "true" || code == "false" {
328        return true;
329    }
330    if code.parse::<f64>().is_ok() {
331        return true;
332    }
333    if code.starts_with('"') && code.ends_with('"') && code.len() >= 2 {
334        return true;
335    }
336    if code.contains("==")
337        || code.contains("!=")
338        || code.contains("<=")
339        || code.contains(">=")
340        || code.contains("&&")
341        || code.contains("||")
342    {
343        return true;
344    }
345    const ASSIGN_OPS: [&str; 7] = ["=", "+=", "-=", "*=", "/=", "%=", "= "];
346    if ASSIGN_OPS.iter().any(|op| code.contains(op))
347        && !code.contains("==")
348        && !code.contains("!=")
349        && !code.contains(">=")
350        && !code.contains("<=")
351        && !code.contains("=>")
352    {
353        return false;
354    }
355
356    if code.chars().any(|c| "+-*/%<>^|&".contains(c)) {
357        return true;
358    }
359
360    if code
361        .chars()
362        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '$')
363    {
364        return true;
365    }
366
367    code.contains('(') && code.contains(')')
368}
369
370fn wrap_kotlin_expression(code: &str, index: usize) -> String {
371    format!("val __repl_val_{index} = ({code})\nprintln(__repl_val_{index})\n")
372}
373
374fn ensure_trailing_newline(code: &str) -> String {
375    let mut owned = code.to_string();
376    if !owned.ends_with('\n') {
377        owned.push('\n');
378    }
379    owned
380}
381
382fn diff_output(previous: &str, current: &str) -> String {
383    if let Some(stripped) = current.strip_prefix(previous) {
384        stripped.to_string()
385    } else {
386        current.to_string()
387    }
388}
389
390fn normalize_output(bytes: &[u8]) -> String {
391    String::from_utf8_lossy(bytes)
392        .replace("\r\n", "\n")
393        .replace('\r', "")
394}
395
396fn invoke_kotlin_compiler(
397    compiler: &Path,
398    source: &Path,
399    jar: &Path,
400) -> Result<std::process::Output> {
401    let mut cmd = Command::new(compiler);
402    cmd.arg(source)
403        .arg("-include-runtime")
404        .arg("-d")
405        .arg(jar)
406        .stdout(Stdio::piped())
407        .stderr(Stdio::piped());
408    cmd.output().with_context(|| {
409        format!(
410            "failed to invoke {} to compile {}",
411            compiler.display(),
412            source.display()
413        )
414    })
415}
416
417fn run_kotlin_jar(java: &Path, jar: &Path) -> Result<std::process::Output> {
418    let mut cmd = Command::new(java);
419    cmd.arg("-jar")
420        .arg(jar)
421        .stdout(Stdio::piped())
422        .stderr(Stdio::piped());
423    cmd.stdin(Stdio::inherit());
424    cmd.output().with_context(|| {
425        format!(
426            "failed to execute {} -jar {}",
427            java.display(),
428            jar.display()
429        )
430    })
431}
432struct KotlinSession {
433    compiler: PathBuf,
434    java: PathBuf,
435    _dir: TempDir,
436    source_path: PathBuf,
437    jar_path: PathBuf,
438    definitions: Vec<String>,
439    statements: Vec<String>,
440    previous_stdout: String,
441    previous_stderr: String,
442}
443
444impl KotlinSession {
445    fn render_prelude(&self) -> String {
446        let mut source = String::from("import kotlin.math.*\n\n");
447        for def in &self.definitions {
448            source.push_str(def);
449            if !def.ends_with('\n') {
450                source.push('\n');
451            }
452            source.push('\n');
453        }
454        source
455    }
456
457    fn render_source(&self) -> String {
458        let mut source = self.render_prelude();
459        source.push_str("fun main() {\n");
460        for stmt in &self.statements {
461            for line in stmt.lines() {
462                source.push_str("    ");
463                source.push_str(line);
464                source.push('\n');
465            }
466            if !stmt.ends_with('\n') {
467                source.push('\n');
468            }
469        }
470        source.push_str("}\n");
471        source
472    }
473
474    fn write_source(&self, contents: &str) -> Result<()> {
475        fs::write(&self.source_path, contents).with_context(|| {
476            format!(
477                "failed to write generated Kotlin REPL source to {}",
478                self.source_path.display()
479            )
480        })
481    }
482
483    fn compile_and_run(&mut self) -> Result<(std::process::Output, Duration)> {
484        let start = Instant::now();
485        let source = self.render_source();
486        self.write_source(&source)?;
487        let compile_output =
488            invoke_kotlin_compiler(&self.compiler, &self.source_path, &self.jar_path)?;
489        if !compile_output.status.success() {
490            return Ok((compile_output, start.elapsed()));
491        }
492        let run_output = run_kotlin_jar(&self.java, &self.jar_path)?;
493        Ok((run_output, start.elapsed()))
494    }
495
496    fn diff_outputs(
497        &mut self,
498        output: &std::process::Output,
499        duration: Duration,
500    ) -> ExecutionOutcome {
501        let stdout_full = normalize_output(&output.stdout);
502        let stderr_full = normalize_output(&output.stderr);
503
504        let stdout_delta = diff_output(&self.previous_stdout, &stdout_full);
505        let stderr_delta = diff_output(&self.previous_stderr, &stderr_full);
506
507        if output.status.success() {
508            self.previous_stdout = stdout_full;
509            self.previous_stderr = stderr_full;
510        }
511
512        ExecutionOutcome {
513            language: "kotlin".to_string(),
514            exit_code: output.status.code(),
515            stdout: stdout_delta,
516            stderr: stderr_delta,
517            duration,
518        }
519    }
520
521    fn add_definition(&mut self, snippet: String) {
522        self.definitions.push(snippet);
523    }
524
525    fn add_statement(&mut self, snippet: String) {
526        self.statements.push(snippet);
527    }
528
529    fn remove_last_definition(&mut self) {
530        let _ = self.definitions.pop();
531    }
532
533    fn remove_last_statement(&mut self) {
534        let _ = self.statements.pop();
535    }
536
537    fn reset_state(&mut self) -> Result<()> {
538        self.definitions.clear();
539        self.statements.clear();
540        self.previous_stdout.clear();
541        self.previous_stderr.clear();
542        let source = self.render_source();
543        self.write_source(&source)
544    }
545
546    fn run_standalone_program(&mut self, code: &str) -> Result<ExecutionOutcome> {
547        let start = Instant::now();
548        let mut source = self.render_prelude();
549        if !source.ends_with('\n') {
550            source.push('\n');
551        }
552        source.push_str(code);
553        if !code.ends_with('\n') {
554            source.push('\n');
555        }
556
557        let standalone_path = self
558            .source_path
559            .parent()
560            .unwrap_or_else(|| Path::new("."))
561            .join("standalone.kt");
562        fs::write(&standalone_path, &source).with_context(|| {
563            format!(
564                "failed to write standalone Kotlin source to {}",
565                standalone_path.display()
566            )
567        })?;
568
569        let compile_output =
570            invoke_kotlin_compiler(&self.compiler, &standalone_path, &self.jar_path)?;
571        if !compile_output.status.success() {
572            return Ok(ExecutionOutcome {
573                language: "kotlin".to_string(),
574                exit_code: compile_output.status.code(),
575                stdout: normalize_output(&compile_output.stdout),
576                stderr: normalize_output(&compile_output.stderr),
577                duration: start.elapsed(),
578            });
579        }
580
581        let run_output = run_kotlin_jar(&self.java, &self.jar_path)?;
582        Ok(ExecutionOutcome {
583            language: "kotlin".to_string(),
584            exit_code: run_output.status.code(),
585            stdout: normalize_output(&run_output.stdout),
586            stderr: normalize_output(&run_output.stderr),
587            duration: start.elapsed(),
588        })
589    }
590}
591
592impl LanguageSession for KotlinSession {
593    fn language_id(&self) -> &str {
594        "kotlin"
595    }
596
597    fn eval(&mut self, code: &str) -> Result<ExecutionOutcome> {
598        let trimmed = code.trim();
599        if trimmed.is_empty() {
600            return Ok(ExecutionOutcome {
601                language: self.language_id().to_string(),
602                exit_code: None,
603                stdout: String::new(),
604                stderr: String::new(),
605                duration: Duration::default(),
606            });
607        }
608
609        if trimmed.eq_ignore_ascii_case(":reset") {
610            self.reset_state()?;
611            return Ok(ExecutionOutcome {
612                language: self.language_id().to_string(),
613                exit_code: None,
614                stdout: String::new(),
615                stderr: String::new(),
616                duration: Duration::default(),
617            });
618        }
619
620        if trimmed.eq_ignore_ascii_case(":help") {
621            return Ok(ExecutionOutcome {
622                language: self.language_id().to_string(),
623                exit_code: None,
624                stdout:
625                    "Kotlin commands:\n  :reset — clear session state\n  :help  — show this message\n"
626                        .to_string(),
627                stderr: String::new(),
628                duration: Duration::default(),
629            });
630        }
631
632        if contains_main_function(code) {
633            return self.run_standalone_program(code);
634        }
635
636        let classification = classify_snippet(trimmed);
637        match classification {
638            SnippetKind::Definition => {
639                self.add_definition(code.to_string());
640                let (output, duration) = self.compile_and_run()?;
641                if !output.status.success() {
642                    self.remove_last_definition();
643                }
644                Ok(self.diff_outputs(&output, duration))
645            }
646            SnippetKind::Expression => {
647                let wrapped = wrap_kotlin_expression(trimmed, self.statements.len());
648                self.add_statement(wrapped);
649                let (output, duration) = self.compile_and_run()?;
650                if !output.status.success() {
651                    self.remove_last_statement();
652                }
653                Ok(self.diff_outputs(&output, duration))
654            }
655            SnippetKind::Statement => {
656                let stmt = ensure_trailing_newline(code);
657                self.add_statement(stmt);
658                let (output, duration) = self.compile_and_run()?;
659                if !output.status.success() {
660                    self.remove_last_statement();
661                }
662                Ok(self.diff_outputs(&output, duration))
663            }
664        }
665    }
666
667    fn shutdown(&mut self) -> Result<()> {
668        // TempDir cleanup is automatic when dropped.
669        Ok(())
670    }
671}