run-kit 0.7.1

Universal multi-language runner and smart REPL
Documentation
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};

use anyhow::{Context, Result};
use tempfile::{Builder, TempDir};

use super::{
    ExecutionOutcome, ExecutionPayload, LanguageEngine, LanguageSession, run_version_command,
};

pub struct CrystalEngine {
    executable: Option<PathBuf>,
}

impl Default for CrystalEngine {
    fn default() -> Self {
        Self::new()
    }
}

impl CrystalEngine {
    pub fn new() -> Self {
        Self {
            executable: resolve_crystal_binary(),
        }
    }

    fn ensure_executable(&self) -> Result<&Path> {
        self.executable.as_deref().ok_or_else(|| {
            anyhow::anyhow!(
                "Crystal support requires the `crystal` executable. Install it from https://crystal-lang.org/install/ and ensure it is on your PATH."
            )
        })
    }

    fn write_temp_source(&self, code: &str) -> Result<(TempDir, PathBuf)> {
        let dir = Builder::new()
            .prefix("run-crystal")
            .tempdir()
            .context("failed to create temporary directory for Crystal source")?;
        let path = dir.path().join("snippet.cr");
        let mut contents = code.to_string();
        if !contents.ends_with('\n') {
            contents.push('\n');
        }
        std::fs::write(&path, contents).with_context(|| {
            format!(
                "failed to write temporary Crystal source to {}",
                path.display()
            )
        })?;
        Ok((dir, path))
    }

    fn run_source(&self, source: &Path, args: &[String]) -> Result<std::process::Output> {
        let executable = self.ensure_executable()?;
        let mut cmd = Command::new(executable);
        cmd.arg("run")
            .arg(source)
            .arg("--no-color")
            .stdout(Stdio::piped())
            .stderr(Stdio::piped());
        if !args.is_empty() {
            cmd.arg("--").args(args);
        }
        cmd.stdin(Stdio::inherit());
        if let Some(dir) = source.parent() {
            cmd.current_dir(dir);
        }
        cmd.output().with_context(|| {
            format!(
                "failed to execute {} with source {}",
                executable.display(),
                source.display()
            )
        })
    }
}

impl LanguageEngine for CrystalEngine {
    fn id(&self) -> &'static str {
        "crystal"
    }

    fn display_name(&self) -> &'static str {
        "Crystal"
    }

    fn aliases(&self) -> &[&'static str] {
        &["cr", "crystal-lang"]
    }

    fn supports_sessions(&self) -> bool {
        self.executable.is_some()
    }

    fn validate(&self) -> Result<()> {
        let executable = self.ensure_executable()?;
        let mut cmd = Command::new(executable);
        cmd.arg("--version")
            .stdout(Stdio::null())
            .stderr(Stdio::null());
        cmd.status()
            .with_context(|| format!("failed to invoke {}", executable.display()))?
            .success()
            .then_some(())
            .ok_or_else(|| anyhow::anyhow!("{} is not executable", executable.display()))
    }

    fn toolchain_version(&self) -> Result<Option<String>> {
        let executable = self.ensure_executable()?;
        let mut cmd = Command::new(executable);
        cmd.arg("--version");
        let context = format!("{}", executable.display());
        run_version_command(cmd, &context)
    }

    fn execute(&self, payload: &ExecutionPayload) -> Result<ExecutionOutcome> {
        let start = Instant::now();
        let (temp_dir, source_path) = match payload {
            ExecutionPayload::Inline { code, .. } | ExecutionPayload::Stdin { code, .. } => {
                let (dir, path) = self.write_temp_source(code)?;
                (Some(dir), path)
            }
            ExecutionPayload::File { path, .. } => (None, path.clone()),
        };

        let output = self.run_source(&source_path, payload.args())?;
        drop(temp_dir);

        Ok(ExecutionOutcome {
            language: self.id().to_string(),
            exit_code: output.status.code(),
            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
            duration: start.elapsed(),
        })
    }

    fn start_session(&self) -> Result<Box<dyn LanguageSession>> {
        let executable = self.ensure_executable()?.to_path_buf();
        Ok(Box::new(CrystalSession::new(executable)?))
    }
}

fn resolve_crystal_binary() -> Option<PathBuf> {
    which::which("crystal").ok()
}

struct CrystalSession {
    executable: PathBuf,
    workspace: TempDir,
    snippets: Vec<String>,
    last_stdout: String,
    last_stderr: String,
}

impl CrystalSession {
    fn new(executable: PathBuf) -> Result<Self> {
        let workspace = TempDir::new().context("failed to create Crystal session workspace")?;
        let session = Self {
            executable,
            workspace,
            snippets: Vec::new(),
            last_stdout: String::new(),
            last_stderr: String::new(),
        };
        session.persist_source()?;
        Ok(session)
    }

    fn source_path(&self) -> PathBuf {
        self.workspace.path().join("session.cr")
    }

    fn persist_source(&self) -> Result<()> {
        let source = self.render_source();
        fs::write(self.source_path(), source)
            .with_context(|| "failed to write Crystal session source".to_string())
    }

    fn render_source(&self) -> String {
        if self.snippets.is_empty() {
            return String::from("# session body\n");
        }

        let mut source = String::new();
        for snippet in &self.snippets {
            source.push_str(snippet);
            if !snippet.ends_with('\n') {
                source.push('\n');
            }
            source.push('\n');
        }
        source
    }

    fn run_program(&self) -> Result<std::process::Output> {
        let mut cmd = Command::new(&self.executable);
        cmd.arg("run")
            .arg("session.cr")
            .arg("--no-color")
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .current_dir(self.workspace.path());
        cmd.output().with_context(|| {
            format!(
                "failed to execute {} for Crystal session",
                self.executable.display()
            )
        })
    }

    fn run_current(&mut self, start: Instant) -> Result<(ExecutionOutcome, bool)> {
        self.persist_source()?;
        let output = self.run_program()?;
        let stdout_full = Self::normalize_output(&output.stdout);
        let stderr_full = Self::normalize_output(&output.stderr);

        let success = output.status.success();
        let (stdout, stderr) = if success {
            let stdout_delta = Self::diff_outputs(&self.last_stdout, &stdout_full);
            let stderr_delta = Self::diff_outputs(&self.last_stderr, &stderr_full);
            self.last_stdout = stdout_full;
            self.last_stderr = stderr_full;
            (stdout_delta, stderr_delta)
        } else {
            (stdout_full, stderr_full)
        };

        let outcome = ExecutionOutcome {
            language: "crystal".to_string(),
            exit_code: output.status.code(),
            stdout,
            stderr,
            duration: start.elapsed(),
        };

        Ok((outcome, success))
    }

    fn apply_snippet(&mut self, snippet: String) -> Result<(ExecutionOutcome, bool)> {
        self.snippets.push(snippet);
        let start = Instant::now();
        let (outcome, success) = self.run_current(start)?;
        if !success {
            let _ = self.snippets.pop();
            self.persist_source()?;
        }
        Ok((outcome, success))
    }

    fn reset(&mut self) -> Result<()> {
        self.snippets.clear();
        self.last_stdout.clear();
        self.last_stderr.clear();
        self.persist_source()
    }

    fn normalize_output(bytes: &[u8]) -> String {
        String::from_utf8_lossy(bytes)
            .replace("\r\n", "\n")
            .replace('\r', "")
    }

    fn diff_outputs(previous: &str, current: &str) -> String {
        current
            .strip_prefix(previous)
            .map(|s| s.to_string())
            .unwrap_or_else(|| current.to_string())
    }
}

impl LanguageSession for CrystalSession {
    fn language_id(&self) -> &str {
        "crystal"
    }

    fn eval(&mut self, code: &str) -> Result<ExecutionOutcome> {
        let trimmed = code.trim();
        if trimmed.is_empty() {
            return Ok(ExecutionOutcome {
                language: "crystal".to_string(),
                exit_code: None,
                stdout: String::new(),
                stderr: String::new(),
                duration: Duration::default(),
            });
        }

        if trimmed.eq_ignore_ascii_case(":reset") {
            self.reset()?;
            return Ok(ExecutionOutcome {
                language: "crystal".to_string(),
                exit_code: None,
                stdout: String::new(),
                stderr: String::new(),
                duration: Duration::default(),
            });
        }

        if trimmed.eq_ignore_ascii_case(":help") {
            return Ok(ExecutionOutcome {
                language: "crystal".to_string(),
                exit_code: None,
                stdout: "Crystal commands:\n  :reset - clear session state\n  :help  - show this message\n"
                    .to_string(),
                stderr: String::new(),
                duration: Duration::default(),
            });
        }

        let snippet = match classify_crystal_snippet(trimmed) {
            CrystalSnippetKind::Statement => ensure_trailing_newline(code),
            CrystalSnippetKind::Expression => wrap_expression(trimmed),
        };

        let (outcome, _) = self.apply_snippet(snippet)?;
        Ok(outcome)
    }

    fn shutdown(&mut self) -> Result<()> {
        Ok(())
    }
}

enum CrystalSnippetKind {
    Statement,
    Expression,
}

fn classify_crystal_snippet(code: &str) -> CrystalSnippetKind {
    if looks_like_crystal_statement(code) {
        CrystalSnippetKind::Statement
    } else {
        CrystalSnippetKind::Expression
    }
}

fn looks_like_crystal_statement(code: &str) -> bool {
    let trimmed = code.trim_start();
    trimmed.contains('\n')
        || trimmed.ends_with(';')
        || trimmed.ends_with('}')
        || trimmed.ends_with("end")
        || trimmed.ends_with("do")
        || trimmed.starts_with("require ")
        || trimmed.starts_with("def ")
        || trimmed.starts_with("class ")
        || trimmed.starts_with("module ")
        || trimmed.starts_with("struct ")
        || trimmed.starts_with("record ")
        || trimmed.starts_with("enum ")
        || trimmed.starts_with("macro ")
        || trimmed.starts_with("alias ")
        || trimmed.starts_with("include ")
        || trimmed.starts_with("extend ")
        || trimmed.starts_with("@[")
}

fn ensure_trailing_newline(code: &str) -> String {
    let mut snippet = code.to_string();
    if !snippet.ends_with('\n') {
        snippet.push('\n');
    }
    snippet
}

fn wrap_expression(code: &str) -> String {
    format!("p({})\n", code)
}