Skip to main content

run/engine/
mod.rs

1mod bash;
2mod c;
3mod cpp;
4mod crystal;
5mod csharp;
6mod dart;
7mod elixir;
8mod go;
9mod groovy;
10mod haskell;
11mod java;
12mod javascript;
13mod julia;
14mod kotlin;
15mod lua;
16mod nim;
17mod perl;
18mod php;
19mod python;
20mod r;
21mod ruby;
22mod rust;
23mod swift;
24mod typescript;
25mod zig;
26
27use std::borrow::Cow;
28use std::collections::HashMap;
29use std::path::{Path, PathBuf};
30use std::process::{Child, Command, Output, Stdio};
31use std::sync::atomic::{AtomicBool, Ordering};
32use std::sync::{LazyLock, Mutex, OnceLock};
33use std::time::{Duration, Instant};
34
35use anyhow::{Context, Result, bail};
36
37use crate::cli::InputSource;
38use crate::language::{LanguageSpec, canonical_language_id};
39
40// ---------------------------------------------------------------------------
41// Compilation cache: hash source code -> reuse compiled binaries
42// ---------------------------------------------------------------------------
43
44static COMPILE_CACHE: LazyLock<Mutex<CompileCache>> =
45    LazyLock::new(|| Mutex::new(CompileCache::new()));
46static SCCACHE_INIT: OnceLock<()> = OnceLock::new();
47static SCCACHE_READY: AtomicBool = AtomicBool::new(false);
48static PERF_COUNTERS: LazyLock<Mutex<HashMap<String, u64>>> =
49    LazyLock::new(|| Mutex::new(HashMap::new()));
50
51struct CompileCache {
52    dir: PathBuf,
53    entries: HashMap<String, PathBuf>,
54}
55
56impl CompileCache {
57    fn new() -> Self {
58        let dir = std::env::temp_dir().join("run-compile-cache");
59        let _ = std::fs::create_dir_all(&dir);
60        Self {
61            dir,
62            entries: HashMap::new(),
63        }
64    }
65
66    fn get(&self, cache_id: &str) -> Option<&PathBuf> {
67        self.entries.get(cache_id).filter(|p| p.exists())
68    }
69
70    fn insert(&mut self, cache_id: String, path: PathBuf) {
71        self.entries.insert(cache_id, path);
72    }
73
74    fn cache_dir(&self) -> &Path {
75        &self.dir
76    }
77}
78
79/// Hash source code for cache lookup.
80pub fn hash_source(source: &str) -> u64 {
81    // Simple FNV-1a hash — fast and good enough for cache keys.
82    let mut hash: u64 = 0xcbf29ce484222325;
83    for byte in source.as_bytes() {
84        hash ^= *byte as u64;
85        hash = hash.wrapping_mul(0x100000001b3);
86    }
87    hash
88}
89
90
91fn cache_id(namespace: &str, source_hash: u64) -> String {
92    format!("{namespace}-{:016x}", source_hash)
93}
94
95fn cache_path(dir: &Path, namespace: &str, source_hash: u64) -> PathBuf {
96    let suffix = std::env::consts::EXE_SUFFIX;
97    let cached_name = format!("{}{}", cache_id(namespace, source_hash), suffix);
98    dir.join(cached_name)
99}
100
101fn perf_file_path() -> PathBuf {
102    std::env::temp_dir().join("run-perf-counters.csv")
103}
104
105fn read_perf_file() -> HashMap<String, u64> {
106    let path = perf_file_path();
107    let Ok(text) = std::fs::read_to_string(path) else {
108        return HashMap::new();
109    };
110    let mut map = HashMap::new();
111    for line in text.lines() {
112        if let Some((key, value)) = line.split_once(',')
113            && let Ok(parsed) = value.parse::<u64>()
114        {
115            map.insert(key.to_string(), parsed);
116        }
117    }
118    map
119}
120
121fn write_perf_file(map: &HashMap<String, u64>) {
122    let mut rows = map.iter().collect::<Vec<_>>();
123    rows.sort_by(|a, b| a.0.cmp(b.0));
124    let mut buf = String::new();
125    for (key, value) in rows {
126        buf.push_str(key);
127        buf.push(',');
128        buf.push_str(&value.to_string());
129        buf.push('\n');
130    }
131    let _ = std::fs::write(perf_file_path(), buf);
132}
133
134/// Look up a cached binary for the given language namespace + source hash.
135/// Returns Some(path) if a valid cached binary exists.
136pub fn cache_lookup(namespace: &str, source_hash: u64) -> Option<PathBuf> {
137    let mut cache = COMPILE_CACHE.lock().ok()?;
138    let id = cache_id(namespace, source_hash);
139
140    if let Some(path) = cache.get(&id).cloned() {
141        return Some(path);
142    }
143
144    let disk_path = cache_path(cache.cache_dir(), namespace, source_hash);
145    if disk_path.exists() {
146        cache.insert(id, disk_path.clone());
147        return Some(disk_path);
148    }
149
150    None
151}
152
153/// Store a compiled binary in the cache. Copies the binary to the cache directory.
154pub fn cache_store(namespace: &str, source_hash: u64, binary: &Path) -> Option<PathBuf> {
155    let mut cache = COMPILE_CACHE.lock().ok()?;
156    let cached_path = cache_path(cache.cache_dir(), namespace, source_hash);
157    if std::fs::copy(binary, &cached_path).is_ok() {
158        // Ensure executable permission on Unix
159        #[cfg(unix)]
160        {
161            use std::os::unix::fs::PermissionsExt;
162            let _ = std::fs::set_permissions(&cached_path, std::fs::Permissions::from_mode(0o755));
163        }
164        cache.insert(cache_id(namespace, source_hash), cached_path.clone());
165        Some(cached_path)
166    } else {
167        None
168    }
169}
170
171/// Execute a cached binary, returning the Output. Returns None if no cache entry.
172pub fn try_cached_execution(namespace: &str, source_hash: u64) -> Option<std::process::Output> {
173    let cached = cache_lookup(namespace, source_hash)?;
174    let mut cmd = std::process::Command::new(&cached);
175    cmd.stdout(std::process::Stdio::piped())
176        .stderr(std::process::Stdio::piped())
177        .stdin(std::process::Stdio::inherit());
178    cmd.output().ok()
179}
180
181/// Build a compiler command with optional daemon/cache wrappers.
182///
183/// Behavior controlled by RUN_COMPILER_DAEMON:
184/// - off: use raw compiler
185/// - ccache: force ccache wrapper
186/// - sccache: force sccache wrapper
187/// - auto (default): prefer sccache, then ccache, else raw compiler
188pub fn compiler_command(compiler: &Path) -> Command {
189    let mode = std::env::var("RUN_COMPILER_DAEMON")
190        .unwrap_or_else(|_| "adaptive".to_string())
191        .to_ascii_lowercase();
192    if mode == "off" {
193        perf_record("global", "compiler.raw");
194        return Command::new(compiler);
195    }
196
197    let want_sccache = mode == "sccache" || mode == "auto" || mode == "adaptive";
198    if want_sccache && let Ok(sccache) = which::which("sccache") {
199        if mode == "adaptive" && !SCCACHE_READY.load(Ordering::Relaxed) {
200            let _ = SCCACHE_INIT.get_or_init(|| {
201                let sccache_clone = sccache.clone();
202                std::thread::spawn(move || {
203                    let ready = std::process::Command::new(&sccache_clone)
204                        .arg("--start-server")
205                        .stdout(Stdio::null())
206                        .stderr(Stdio::null())
207                        .status()
208                        .is_ok_and(|s| s.success());
209                    if ready {
210                        SCCACHE_READY.store(true, Ordering::Relaxed);
211                    }
212                });
213            });
214            perf_record("global", "compiler.raw.adaptive_warmup");
215            return Command::new(compiler);
216        }
217        let _ = SCCACHE_INIT.get_or_init(|| {
218            let ready = std::process::Command::new(&sccache)
219                .arg("--start-server")
220                .stdout(Stdio::null())
221                .stderr(Stdio::null())
222                .status()
223                .is_ok_and(|s| s.success());
224            if ready {
225                SCCACHE_READY.store(true, Ordering::Relaxed);
226            }
227        });
228        let mut cmd = Command::new(sccache);
229        cmd.arg(compiler);
230        perf_record("global", "compiler.sccache");
231        return cmd;
232    }
233
234    let want_ccache = mode == "ccache" || mode == "auto" || mode == "adaptive";
235    if want_ccache && let Ok(ccache) = which::which("ccache") {
236        let mut cmd = Command::new(ccache);
237        cmd.arg(compiler);
238        perf_record("global", "compiler.ccache");
239        return cmd;
240    }
241
242    perf_record("global", "compiler.raw.fallback");
243    Command::new(compiler)
244}
245
246pub fn perf_record(language: &str, event: &str) {
247    let key = format!("{language}.{event}");
248    if let Ok(mut counters) = PERF_COUNTERS.lock() {
249        let entry = counters.entry(key).or_insert(0);
250        *entry += 1;
251        let mut disk = read_perf_file();
252        let disk_entry = disk.entry(language.to_string() + "." + event).or_insert(0);
253        *disk_entry += 1;
254        write_perf_file(&disk);
255    }
256}
257
258pub fn perf_snapshot() -> Vec<(String, u64)> {
259    let disk = read_perf_file();
260    let mut rows = if !disk.is_empty() {
261        disk.into_iter().collect::<Vec<_>>()
262    } else if let Ok(counters) = PERF_COUNTERS.lock() {
263        counters
264            .iter()
265            .map(|(k, v)| (k.clone(), *v))
266            .collect::<Vec<_>>()
267    } else {
268        Vec::new()
269    };
270    rows.sort_by(|a, b| a.0.cmp(&b.0));
271    rows
272}
273
274pub fn perf_reset() {
275    if let Ok(mut counters) = PERF_COUNTERS.lock() {
276        counters.clear();
277    }
278    let _ = std::fs::remove_file(perf_file_path());
279}
280
281/// Default execution timeout: 60 seconds.
282/// Override with RUN_TIMEOUT_SECS env var.
283pub fn execution_timeout() -> Duration {
284    let secs = std::env::var("RUN_TIMEOUT_SECS")
285        .ok()
286        .and_then(|v| v.parse::<u64>().ok())
287        .unwrap_or(60);
288    Duration::from_secs(secs)
289}
290
291/// Wait for a child process with a timeout. Kills the process if it exceeds the limit.
292/// Returns the Output on success, or an error on timeout.
293pub fn wait_with_timeout(mut child: Child, timeout: Duration) -> Result<std::process::Output> {
294    let start = Instant::now();
295    let poll_interval = Duration::from_millis(50);
296
297    loop {
298        match child.try_wait() {
299            Ok(Some(_status)) => {
300                
301                return child.wait_with_output().map_err(Into::into);
302            }
303            Ok(None) => {
304                if start.elapsed() > timeout {
305                    let _ = child.kill();
306                    let _ = child.wait(); // reap
307                    bail!(
308                        "Execution timed out after {:.1}s (limit: {}s). \
309                         Set RUN_TIMEOUT_SECS to increase.",
310                        start.elapsed().as_secs_f64(),
311                        timeout.as_secs()
312                    );
313                }
314                std::thread::sleep(poll_interval);
315            }
316            Err(e) => {
317                return Err(e.into());
318            }
319        }
320    }
321}
322
323pub use bash::BashEngine;
324pub use c::CEngine;
325pub use cpp::CppEngine;
326pub use crystal::CrystalEngine;
327pub use csharp::CSharpEngine;
328pub use dart::DartEngine;
329pub use elixir::ElixirEngine;
330pub use go::GoEngine;
331pub use groovy::GroovyEngine;
332pub use haskell::HaskellEngine;
333pub use java::JavaEngine;
334pub use javascript::JavascriptEngine;
335pub use julia::JuliaEngine;
336pub use kotlin::KotlinEngine;
337pub use lua::LuaEngine;
338pub use nim::NimEngine;
339pub use perl::PerlEngine;
340pub use php::PhpEngine;
341pub use python::PythonEngine;
342pub use r::REngine;
343pub use ruby::RubyEngine;
344pub use rust::RustEngine;
345pub use swift::SwiftEngine;
346pub use typescript::TypeScriptEngine;
347pub use zig::ZigEngine;
348
349pub trait LanguageSession {
350    fn language_id(&self) -> &str;
351    fn eval(&mut self, code: &str) -> Result<ExecutionOutcome>;
352    fn shutdown(&mut self) -> Result<()>;
353}
354
355pub trait LanguageEngine {
356    fn id(&self) -> &'static str;
357    fn display_name(&self) -> &'static str {
358        self.id()
359    }
360    fn aliases(&self) -> &[&'static str] {
361        &[]
362    }
363    fn supports_sessions(&self) -> bool {
364        false
365    }
366    fn validate(&self) -> Result<()> {
367        Ok(())
368    }
369    fn toolchain_version(&self) -> Result<Option<String>> {
370        Ok(None)
371    }
372    fn execute(&self, payload: &ExecutionPayload) -> Result<ExecutionOutcome>;
373    fn start_session(&self) -> Result<Box<dyn LanguageSession>> {
374        bail!("{} does not support interactive sessions yet", self.id())
375    }
376}
377
378pub(crate) fn version_line_from_output(output: &Output) -> Option<String> {
379    let stdout = String::from_utf8_lossy(&output.stdout);
380    for line in stdout.lines() {
381        let trimmed = line.trim();
382        if !trimmed.is_empty() {
383            return Some(trimmed.to_string());
384        }
385    }
386    let stderr = String::from_utf8_lossy(&output.stderr);
387    for line in stderr.lines() {
388        let trimmed = line.trim();
389        if !trimmed.is_empty() {
390            return Some(trimmed.to_string());
391        }
392    }
393    None
394}
395
396pub(crate) fn run_version_command(mut cmd: Command, context: &str) -> Result<Option<String>> {
397    cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
398    let output = cmd
399        .output()
400        .with_context(|| format!("failed to invoke {context}"))?;
401    let version = version_line_from_output(&output);
402    if output.status.success() || version.is_some() {
403        Ok(version)
404    } else {
405        bail!("{context} exited with status {}", output.status);
406    }
407}
408
409#[derive(Debug, Clone, PartialEq, Eq)]
410pub enum ExecutionPayload {
411    Inline {
412        code: String,
413        args: Vec<String>,
414    },
415    File {
416        path: std::path::PathBuf,
417        args: Vec<String>,
418    },
419    Stdin {
420        code: String,
421        args: Vec<String>,
422    },
423}
424
425impl ExecutionPayload {
426    pub fn from_input_source(source: &InputSource, args: &[String]) -> Result<Self> {
427        let args = args.to_vec();
428        match source {
429            InputSource::Inline(code) => Ok(Self::Inline {
430                code: normalize_inline_code(code).into_owned(),
431                args,
432            }),
433            InputSource::File(path) => Ok(Self::File {
434                path: path.clone(),
435                args,
436            }),
437            InputSource::Stdin => {
438                use std::io::Read;
439                let mut buffer = String::new();
440                std::io::stdin().read_to_string(&mut buffer)?;
441                Ok(Self::Stdin { code: buffer, args })
442            }
443        }
444    }
445
446    pub fn as_inline(&self) -> Option<&str> {
447        match self {
448            ExecutionPayload::Inline { code, .. } => Some(code.as_str()),
449            ExecutionPayload::Stdin { code, .. } => Some(code.as_str()),
450            ExecutionPayload::File { .. } => None,
451        }
452    }
453
454    pub fn as_file_path(&self) -> Option<&Path> {
455        match self {
456            ExecutionPayload::File { path, .. } => Some(path.as_path()),
457            _ => None,
458        }
459    }
460
461    pub fn args(&self) -> &[String] {
462        match self {
463            ExecutionPayload::Inline { args, .. } => args.as_slice(),
464            ExecutionPayload::File { args, .. } => args.as_slice(),
465            ExecutionPayload::Stdin { args, .. } => args.as_slice(),
466        }
467    }
468}
469
470fn normalize_inline_code(code: &str) -> Cow<'_, str> {
471    if !code.contains('\\') {
472        return Cow::Borrowed(code);
473    }
474
475    let mut result = String::with_capacity(code.len());
476    let mut chars = code.chars().peekable();
477    let mut in_single = false;
478    let mut in_double = false;
479    let mut escape_in_quote = false;
480
481    while let Some(ch) = chars.next() {
482        if in_single {
483            result.push(ch);
484            if escape_in_quote {
485                escape_in_quote = false;
486            } else if ch == '\\' {
487                escape_in_quote = true;
488            } else if ch == '\'' {
489                in_single = false;
490            }
491            continue;
492        }
493
494        if in_double {
495            result.push(ch);
496            if escape_in_quote {
497                escape_in_quote = false;
498            } else if ch == '\\' {
499                escape_in_quote = true;
500            } else if ch == '"' {
501                in_double = false;
502            }
503            continue;
504        }
505
506        match ch {
507            '\'' => {
508                in_single = true;
509                result.push(ch);
510            }
511            '"' => {
512                in_double = true;
513                result.push(ch);
514            }
515            '\\' => match chars.next() {
516                Some('n') => result.push('\n'),
517                Some('r') => result.push('\r'),
518                Some('t') => result.push('\t'),
519                Some('\\') => result.push('\\'),
520                Some(other) => {
521                    result.push('\\');
522                    result.push(other);
523                }
524                None => result.push('\\'),
525            },
526            _ => result.push(ch),
527        }
528    }
529
530    Cow::Owned(result)
531}
532
533#[derive(Debug, Clone, PartialEq, Eq)]
534pub struct ExecutionOutcome {
535    pub language: String,
536    pub exit_code: Option<i32>,
537    pub stdout: String,
538    pub stderr: String,
539    pub duration: Duration,
540}
541
542impl ExecutionOutcome {
543    pub fn success(&self) -> bool {
544        match self.exit_code {
545            Some(code) => code == 0,
546            None => self.stderr.trim().is_empty(),
547        }
548    }
549}
550
551pub struct LanguageRegistry {
552    engines: HashMap<String, Box<dyn LanguageEngine + Send + Sync>>, // keyed by canonical id
553    alias_lookup: HashMap<String, String>,
554}
555
556impl LanguageRegistry {
557    pub fn bootstrap() -> Self {
558        let mut registry = Self {
559            engines: HashMap::new(),
560            alias_lookup: HashMap::new(),
561        };
562
563        registry.register_language(PythonEngine::new());
564        registry.register_language(BashEngine::new());
565        registry.register_language(JavascriptEngine::new());
566        registry.register_language(RubyEngine::new());
567        registry.register_language(RustEngine::new());
568        registry.register_language(GoEngine::new());
569        registry.register_language(CSharpEngine::new());
570        registry.register_language(TypeScriptEngine::new());
571        registry.register_language(LuaEngine::new());
572        registry.register_language(JavaEngine::new());
573        registry.register_language(GroovyEngine::new());
574        registry.register_language(PhpEngine::new());
575        registry.register_language(KotlinEngine::new());
576        registry.register_language(CEngine::new());
577        registry.register_language(CppEngine::new());
578        registry.register_language(REngine::new());
579        registry.register_language(DartEngine::new());
580        registry.register_language(SwiftEngine::new());
581        registry.register_language(PerlEngine::new());
582        registry.register_language(JuliaEngine::new());
583        registry.register_language(HaskellEngine::new());
584        registry.register_language(ElixirEngine::new());
585        registry.register_language(CrystalEngine::new());
586        registry.register_language(ZigEngine::new());
587        registry.register_language(NimEngine::new());
588
589        registry
590    }
591
592    pub fn register_language<E>(&mut self, engine: E)
593    where
594        E: LanguageEngine + Send + Sync + 'static,
595    {
596        let id = engine.id().to_string();
597        for alias in engine.aliases() {
598            self.alias_lookup
599                .insert(canonical_language_id(alias), id.clone());
600        }
601        self.alias_lookup
602            .insert(canonical_language_id(&id), id.clone());
603        self.engines.insert(id, Box::new(engine));
604    }
605
606    pub fn resolve(&self, spec: &LanguageSpec) -> Option<&(dyn LanguageEngine + Send + Sync)> {
607        let canonical = canonical_language_id(spec.canonical_id());
608        let target_id = self
609            .alias_lookup
610            .get(&canonical)
611            .cloned()
612            .unwrap_or(canonical);
613        self.engines
614            .get(&target_id)
615            .map(|engine| engine.as_ref() as _)
616    }
617
618    pub fn resolve_by_id(&self, id: &str) -> Option<&(dyn LanguageEngine + Send + Sync)> {
619        let canonical = canonical_language_id(id);
620        let target_id = self
621            .alias_lookup
622            .get(&canonical)
623            .cloned()
624            .unwrap_or(canonical);
625        self.engines
626            .get(&target_id)
627            .map(|engine| engine.as_ref() as _)
628    }
629
630    pub fn engines(&self) -> impl Iterator<Item = &(dyn LanguageEngine + Send + Sync)> {
631        self.engines.values().map(|engine| engine.as_ref() as _)
632    }
633
634    pub fn known_languages(&self) -> Vec<String> {
635        let mut ids: Vec<_> = self.engines.keys().cloned().collect();
636        ids.sort();
637        ids
638    }
639}
640
641/// Returns the package install command for a language, if one exists.
642/// Returns (binary, args_before_package) so the caller can append the package name.
643pub fn package_install_command(
644    language_id: &str,
645) -> Option<(&'static str, &'static [&'static str])> {
646    match language_id {
647        "python" => Some(("pip", &["install"])),
648        "javascript" | "typescript" => Some(("npm", &["install"])),
649        "rust" => Some(("cargo", &["add"])),
650        "go" => Some(("go", &["get"])),
651        "ruby" => Some(("gem", &["install"])),
652        "php" => Some(("composer", &["require"])),
653        "lua" => Some(("luarocks", &["install"])),
654        "dart" => Some(("dart", &["pub", "add"])),
655        "perl" => Some(("cpanm", &[])),
656        "julia" => Some(("julia", &["-e"])), // special: wraps in Pkg.add()
657        "haskell" => Some(("cabal", &["install"])),
658        "nim" => Some(("nimble", &["install"])),
659        "r" => Some(("Rscript", &["-e"])), // special: wraps in install.packages()
660        "kotlin" => None,                  // no standard CLI package manager
661        "java" => None,                    // maven/gradle are project-based
662        "c" | "cpp" => None,               // system packages
663        "bash" => None,
664        "swift" => None,
665        "crystal" => Some(("shards", &["install"])),
666        "elixir" => None, // mix deps.get is project-based
667        "groovy" => None,
668        "csharp" => Some(("dotnet", &["add", "package"])),
669        "zig" => None,
670        _ => None,
671    }
672}
673
674fn install_override_command(language_id: &str, package: &str) -> Option<std::process::Command> {
675    let key = format!("RUN_INSTALL_COMMAND_{}", language_id.to_ascii_uppercase());
676    let template = std::env::var(&key).ok()?;
677    let expanded = if template.contains("{package}") {
678        template.replace("{package}", package)
679    } else {
680        format!("{template} {package}")
681    };
682    let parts = shell_words::split(&expanded).ok()?;
683    if parts.is_empty() {
684        return None;
685    }
686    let mut cmd = std::process::Command::new(&parts[0]);
687    for arg in &parts[1..] {
688        cmd.arg(arg);
689    }
690    Some(cmd)
691}
692
693/// Build a full install command for a package in the given language.
694/// Returns None if the language has no package manager.
695pub fn build_install_command(language_id: &str, package: &str) -> Option<std::process::Command> {
696    if let Some(cmd) = install_override_command(language_id, package) {
697        return Some(cmd);
698    }
699
700    if language_id == "python" {
701        let python = python::resolve_python_binary();
702        let mut cmd = std::process::Command::new(python);
703        cmd.arg("-m").arg("pip").arg("install").arg(package);
704        return Some(cmd);
705    }
706
707    let (binary, base_args) = package_install_command(language_id)?;
708
709    let mut cmd = std::process::Command::new(binary);
710
711    match language_id {
712        "julia" => {
713            // julia -e 'using Pkg; Pkg.add("package")'
714            cmd.arg("-e")
715                .arg(format!("using Pkg; Pkg.add(\"{package}\")"));
716        }
717        "r" => {
718            // Rscript -e 'install.packages("package", repos="https://cran.r-project.org")'
719            cmd.arg("-e").arg(format!(
720                "install.packages(\"{package}\", repos=\"https://cran.r-project.org\")"
721            ));
722        }
723        _ => {
724            for arg in base_args {
725                cmd.arg(arg);
726            }
727            cmd.arg(package);
728        }
729    }
730
731    Some(cmd)
732}
733
734pub fn default_language() -> &'static str {
735    "python"
736}
737
738pub fn ensure_known_language(spec: &LanguageSpec, registry: &LanguageRegistry) -> Result<()> {
739    if registry.resolve(spec).is_some() {
740        return Ok(());
741    }
742
743    let available = registry.known_languages();
744    bail!(
745        "Unknown language '{}'. Available languages: {}",
746        spec.canonical_id(),
747        available.join(", ")
748    )
749}
750
751pub fn detect_language_for_source(
752    source: &ExecutionPayload,
753    registry: &LanguageRegistry,
754) -> Option<LanguageSpec> {
755    if let Some(path) = source.as_file_path()
756        && let Some(ext) = path.extension().and_then(|e| e.to_str())
757    {
758        let ext_lower = ext.to_ascii_lowercase();
759        if let Some(lang) = extension_to_language(&ext_lower) {
760            let spec = LanguageSpec::new(lang);
761            if registry.resolve(&spec).is_some() {
762                return Some(spec);
763            }
764        }
765    }
766
767    if let Some(code) = source.as_inline()
768        && let Some(lang) = crate::detect::detect_language_from_snippet(code)
769    {
770        let spec = LanguageSpec::new(lang);
771        if registry.resolve(&spec).is_some() {
772            return Some(spec);
773        }
774    }
775
776    None
777}
778
779fn extension_to_language(ext: &str) -> Option<&'static str> {
780    match ext {
781        "py" | "pyw" => Some("python"),
782        "rs" => Some("rust"),
783        "go" => Some("go"),
784        "cs" => Some("csharp"),
785        "ts" | "tsx" => Some("typescript"),
786        "js" | "mjs" | "cjs" | "jsx" => Some("javascript"),
787        "rb" => Some("ruby"),
788        "lua" => Some("lua"),
789        "java" => Some("java"),
790        "groovy" => Some("groovy"),
791        "php" => Some("php"),
792        "kt" | "kts" => Some("kotlin"),
793        "c" => Some("c"),
794        "cpp" | "cc" | "cxx" | "hpp" | "hxx" => Some("cpp"),
795        "sh" | "bash" | "zsh" => Some("bash"),
796        "r" => Some("r"),
797        "dart" => Some("dart"),
798        "swift" => Some("swift"),
799        "perl" | "pl" | "pm" => Some("perl"),
800        "julia" | "jl" => Some("julia"),
801        "hs" => Some("haskell"),
802        "ex" | "exs" => Some("elixir"),
803        "cr" => Some("crystal"),
804        "zig" => Some("zig"),
805        "nim" => Some("nim"),
806        _ => None,
807    }
808}