Skip to main content

run/
app.rs

1use std::fs;
2use std::io::{self, Write};
3use std::path::Path;
4use std::sync::mpsc;
5use std::time::SystemTime;
6
7use anyhow::{Context, Result};
8
9use crate::cli::{CacheAction, Command, ExecutionSpec, InputSource};
10use crate::engine::{
11    ExecutionPayload, LanguageRegistry, build_install_command, default_language,
12    detect_language_for_source, ensure_known_language, perf_reset, perf_snapshot,
13};
14use crate::language::LanguageSpec;
15use crate::output;
16use crate::repl;
17use crate::version;
18
19pub fn run(command: Command) -> Result<i32> {
20    match command {
21        Command::ShowVersion => {
22            println!("{}", version::describe());
23            Ok(0)
24        }
25        Command::PerfReport => {
26            print_perf_report();
27            Ok(0)
28        }
29        Command::PerfReset => {
30            perf_reset();
31            eprintln!("\x1b[2m[perf] counters reset\x1b[0m");
32            Ok(0)
33        }
34        Command::Cache { action } => cache_command(action),
35        other => run_with_registry(other),
36    }
37}
38
39fn run_with_registry(command: Command) -> Result<i32> {
40    let registry = LanguageRegistry::bootstrap();
41
42    match command {
43        Command::Execute(spec) => execute_once(spec, &registry),
44        Command::Repl {
45            initial_language,
46            detect_language,
47        } => {
48            let language = resolve_language(initial_language, detect_language, None, &registry)?;
49            repl::run_repl(language, registry, detect_language)
50        }
51        Command::ShowVersion => unreachable!("handled before registry bootstrap"),
52        Command::CheckToolchains => check_toolchains(&registry),
53        Command::ShowVersions { language } => show_versions(&registry, language),
54        Command::Install { language, package } => {
55            let lang = language.unwrap_or_else(|| LanguageSpec::new(default_language()));
56            install_package(&lang, &package)
57        }
58        Command::Bench { spec, iterations } => bench_run(spec, &registry, iterations),
59        Command::Watch { spec } => watch_run(spec, &registry),
60        Command::WatchFile {
61            path,
62            language,
63            args,
64        } => watch_run(
65            ExecutionSpec {
66                language,
67                source: InputSource::File(path),
68                detect_language: true,
69                args,
70                json: false,
71            },
72            &registry,
73        ),
74        Command::Format { path } => format_file(&path),
75        Command::Snippet {
76            language,
77            name,
78            list,
79        } => snippet_command(language, name, list),
80        Command::Doctor => doctor(&registry),
81        Command::Cache { .. } => unreachable!("handled before registry bootstrap"),
82        Command::Share { path, port } => share_file(&path, port, &registry),
83        Command::PerfReport | Command::PerfReset => {
84            unreachable!("handled before registry bootstrap")
85        }
86    }
87}
88
89fn print_perf_report() {
90    let rows = perf_snapshot();
91    if rows.is_empty() {
92        println!("perf_counter,count");
93        return;
94    }
95    println!("perf_counter,count");
96    for (key, value) in rows {
97        println!("{key},{value}");
98    }
99}
100
101fn check_toolchains(registry: &LanguageRegistry) -> Result<i32> {
102    println!("Checking language toolchains...\n");
103
104    let mut available = 0u32;
105    let mut missing = 0u32;
106
107    let mut languages: Vec<_> = registry.known_languages();
108    languages.sort();
109
110    for lang_id in &languages {
111        let spec = LanguageSpec::new(lang_id.to_string());
112        if let Some(engine) = registry.resolve(&spec) {
113            let status = match engine.validate() {
114                Ok(()) => {
115                    available += 1;
116                    "\x1b[32m OK \x1b[0m"
117                }
118                Err(_) => {
119                    missing += 1;
120                    "\x1b[31mMISS\x1b[0m"
121                }
122            };
123            println!("  [{status}] {:<14} {}", engine.display_name(), lang_id);
124        }
125    }
126
127    println!();
128    println!(
129        "  {} available, {} missing, {} total",
130        available,
131        missing,
132        available + missing
133    );
134
135    if missing > 0 {
136        println!("\n  Tip: Install missing toolchains to enable those languages.");
137    }
138
139    Ok(0)
140}
141
142fn show_versions(registry: &LanguageRegistry, language: Option<LanguageSpec>) -> Result<i32> {
143    println!("Language toolchain versions...\n");
144
145    let mut available = 0u32;
146    let mut missing = 0u32;
147
148    let mut languages: Vec<String> = if let Some(lang) = language {
149        vec![lang.canonical_id().to_string()]
150    } else {
151        registry
152            .known_languages()
153            .into_iter()
154            .map(|value| value.to_string())
155            .collect()
156    };
157    languages.sort();
158
159    for lang_id in &languages {
160        let spec = LanguageSpec::new(lang_id.to_string());
161        if let Some(engine) = registry.resolve(&spec) {
162            match engine.toolchain_version() {
163                Ok(Some(version)) => {
164                    available += 1;
165                    println!(
166                        "  [\x1b[32m OK \x1b[0m] {:<14} {} - {}",
167                        engine.display_name(),
168                        lang_id,
169                        version
170                    );
171                }
172                Ok(None) => {
173                    available += 1;
174                    println!(
175                        "  [\x1b[33m ?? \x1b[0m] {:<14} {} - unknown",
176                        engine.display_name(),
177                        lang_id
178                    );
179                }
180                Err(_) => {
181                    missing += 1;
182                    println!(
183                        "  [\x1b[31mMISS\x1b[0m] {:<14} {}",
184                        engine.display_name(),
185                        lang_id
186                    );
187                }
188            }
189        }
190    }
191
192    println!();
193    println!(
194        "  {} available, {} missing, {} total",
195        available,
196        missing,
197        available + missing
198    );
199
200    if missing > 0 {
201        println!("\n  Tip: Install missing toolchains to enable those languages.");
202    }
203
204    Ok(0)
205}
206
207fn execute_once(spec: ExecutionSpec, registry: &LanguageRegistry) -> Result<i32> {
208    let payload = ExecutionPayload::from_input_source(&spec.source, &spec.args)
209        .context("failed to materialize execution payload")?;
210    let language = resolve_language(
211        spec.language,
212        spec.detect_language,
213        Some(&payload),
214        registry,
215    )?;
216
217    let engine = registry
218        .resolve(&language)
219        .context("failed to resolve language engine")?;
220
221    if let Err(e) = engine.validate() {
222        let display = engine.display_name();
223        let id = engine.id();
224        eprintln!(
225            "Warning: {display} ({id}) toolchain not found: {e:#}\n\
226             Install the required toolchain and ensure it is on your PATH."
227        );
228        return Err(e.context(format!("{display} is not available")));
229    }
230
231    let outcome = match engine.execute(&payload) {
232        Ok(outcome) => outcome,
233        Err(err) if err.to_string().contains("Execution timed out") => {
234            eprintln!(
235                "[run] Execution timed out after {}s",
236                crate::runtime::timeout_secs()
237            );
238            return Ok(124);
239        }
240        Err(err) => return Err(err),
241    };
242
243    if spec.json {
244        let exit_code = outcome
245            .exit_code
246            .unwrap_or(if outcome.success() { 0 } else { 1 });
247        let version = engine
248            .toolchain_version()
249            .ok()
250            .flatten()
251            .unwrap_or_default();
252        let envelope = serde_json::json!({
253            "language": engine.id(),
254            "stdout": outcome.stdout,
255            "stderr": outcome.stderr,
256            "exit_code": exit_code,
257            "duration_ms": outcome.duration.as_millis(),
258            "toolchain_version": version,
259        });
260        println!("{}", serde_json::to_string(&envelope)?);
261        return Ok(exit_code);
262    }
263
264    if !outcome.stdout.is_empty() {
265        print!("{}", outcome.stdout);
266        io::stdout().flush().ok();
267    }
268    if !outcome.stderr.is_empty() {
269        let formatted =
270            output::format_stderr(engine.display_name(), &outcome.stderr, outcome.success());
271        eprint!("{formatted}");
272        io::stderr().flush().ok();
273    }
274
275    // Show timing on stderr if requested or if execution was slow (>1s)
276    let show_timing = crate::runtime::timing_enabled();
277    if show_timing || outcome.duration.as_millis() > 1000 {
278        eprintln!(
279            "\x1b[2m[{} {}ms]\x1b[0m",
280            engine.display_name(),
281            outcome.duration.as_millis()
282        );
283    }
284
285    if std::env::var("RUN_PERF_REPORT").is_ok_and(|v| v == "1" || v == "true") {
286        eprintln!("\x1b[2m[perf]\x1b[0m");
287        for (key, value) in perf_snapshot() {
288            eprintln!("\x1b[2m  {key}={value}\x1b[0m");
289        }
290    }
291
292    Ok(outcome
293        .exit_code
294        .unwrap_or(if outcome.success() { 0 } else { 1 }))
295}
296
297fn install_package(language: &LanguageSpec, package: &str) -> Result<i32> {
298    let lang_id = language.canonical_id();
299    let override_key = format!("RUN_INSTALL_COMMAND_{}", lang_id.to_ascii_uppercase());
300    let override_value = std::env::var(&override_key).ok();
301
302    let Some(mut cmd) = build_install_command(lang_id, package) else {
303        if override_value.is_some() {
304            eprintln!(
305                "\x1b[31mError:\x1b[0m {override_key} is set but could not be parsed.\n\
306                 Provide a valid command, e.g. {override_key}=\"uv pip install {{package}}\""
307            );
308            return Ok(1);
309        }
310        eprintln!(
311            "\x1b[31mError:\x1b[0m No package manager available for '{lang_id}'.\n\
312             This language doesn't have a standard CLI package manager.\n\
313             Tip: You can override with {override_key}=\"<cmd> {{package}}\"",
314        );
315        return Ok(1);
316    };
317
318    eprintln!("\x1b[36m[run]\x1b[0m Installing '{package}' for {lang_id}...");
319
320    let result = cmd
321        .stdin(std::process::Stdio::inherit())
322        .stdout(std::process::Stdio::inherit())
323        .stderr(std::process::Stdio::inherit())
324        .status();
325
326    match result {
327        Ok(status) if status.success() => {
328            eprintln!("\x1b[32m[run]\x1b[0m Successfully installed '{package}' for {lang_id}");
329            Ok(0)
330        }
331        Ok(status) => {
332            eprintln!("\x1b[31m[run]\x1b[0m Failed to install '{package}' for {lang_id}");
333            Ok(status.code().unwrap_or(1))
334        }
335        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
336            let program = cmd.get_program().to_string_lossy();
337            eprintln!("\x1b[31m[run]\x1b[0m Package manager not found: {program}");
338            eprintln!("Tip: install it or set {override_key}=\"<cmd> {{package}}\"");
339            Ok(1)
340        }
341        Err(err) => {
342            Err(err).with_context(|| format!("failed to run package manager for {lang_id}"))
343        }
344    }
345}
346
347fn cache_command(action: CacheAction) -> Result<i32> {
348    match action {
349        CacheAction::Stats => {
350            let stats = crate::cache::stats()?;
351            println!("Cache: {}", crate::cache::root_dir().display());
352            println!("entries: {}", stats.entries);
353            println!("bytes: {}", stats.total_bytes);
354            for (lang, count) in stats.by_language {
355                println!("{lang}: {count}");
356            }
357            Ok(0)
358        }
359        CacheAction::Clear => {
360            crate::cache::clear()?;
361            println!("[run] cache cleared");
362            Ok(0)
363        }
364        CacheAction::ClearLang(lang) => {
365            crate::cache::clear_lang(&lang)?;
366            println!("[run] cache cleared for {lang}");
367            Ok(0)
368        }
369    }
370}
371
372fn snippet_command(language: LanguageSpec, name: Option<String>, list: bool) -> Result<i32> {
373    let language = language.canonical_id().to_string();
374    let names = crate::templates::names_for_language(&language);
375    if list {
376        if names.is_empty() {
377            eprintln!("[run] No snippets available for {language}");
378            return Ok(2);
379        }
380        for name in names {
381            println!("{name}");
382        }
383        return Ok(0);
384    }
385
386    let Some(name) = name else {
387        eprintln!(
388            "[run] snippet requires a template name. Available: {}",
389            names.join(", ")
390        );
391        return Ok(2);
392    };
393
394    if let Some(template) = crate::templates::find(&language, &name) {
395        print!("{}", template.source);
396        io::stdout().flush().ok();
397        Ok(0)
398    } else {
399        eprintln!(
400            "[run] Unknown snippet '{name}' for {language}. Available: {}",
401            names.join(", ")
402        );
403        Ok(2)
404    }
405}
406
407fn format_file(path: &Path) -> Result<i32> {
408    if !path.is_file() {
409        eprintln!("[run] File not found: {}", path.display());
410        return Ok(1);
411    }
412
413    let Some(lang) = language_from_path(path) else {
414        eprintln!("[run] No formatter available for unknown");
415        return Ok(2);
416    };
417    let candidates: &[(&str, &[&str])] = match lang {
418        "python" => &[("black", &[]), ("autopep8", &["-i"])],
419        "javascript" | "typescript" => &[("prettier", &["--write"])],
420        "rust" => &[("rustfmt", &[])],
421        "go" => &[("gofmt", &["-w"])],
422        "c" | "cpp" => &[("clang-format", &["-i"])],
423        "java" => &[("google-java-format", &["-i"])],
424        _ => &[],
425    };
426    if candidates.is_empty() {
427        eprintln!("[run] No formatter available for {lang}");
428        return Ok(2);
429    }
430
431    for (program, args) in candidates {
432        let Ok(binary) = which::which(program) else {
433            continue;
434        };
435        let status = std::process::Command::new(binary)
436            .args(*args)
437            .arg(path)
438            .status()
439            .with_context(|| format!("failed to run formatter {program}"))?;
440        return Ok(if status.success() {
441            0
442        } else {
443            eprintln!("[run] formatter {program} failed");
444            1
445        });
446    }
447
448    eprintln!("[run] Formatter not found for {lang}");
449    Ok(2)
450}
451
452fn doctor(registry: &LanguageRegistry) -> Result<i32> {
453    println!(
454        "{:<12} {:<16} {:<24} Status",
455        "Language", "Toolchain", "Version"
456    );
457    println!("────────────────────────────────────────────────────────────");
458    let mut missing = 0;
459    let mut languages = registry.known_languages();
460    languages.sort();
461    for lang in languages {
462        let spec = LanguageSpec::new(lang.clone());
463        if let Some(engine) = registry.resolve(&spec) {
464            let toolchain = toolchain_name(engine.id());
465            match engine.validate() {
466                Ok(()) => {
467                    let version = engine
468                        .toolchain_version()
469                        .ok()
470                        .flatten()
471                        .unwrap_or_else(|| "unknown".to_string());
472                    let status = if version == "unknown" {
473                        "⚠ Unknown"
474                    } else {
475                        "✓ OK"
476                    };
477                    println!(
478                        "{:<12} {:<16} {:<24} {}",
479                        engine.display_name(),
480                        toolchain,
481                        version.lines().next().unwrap_or("unknown"),
482                        status
483                    );
484                }
485                Err(_) => {
486                    missing += 1;
487                    println!(
488                        "{:<12} {:<16} {:<24} ✗ MISSING",
489                        engine.display_name(),
490                        toolchain,
491                        "✗ Not found"
492                    );
493                }
494            }
495        }
496    }
497    Ok(if missing == 0 { 0 } else { 1 })
498}
499
500fn share_file(path: &Path, port: Option<u16>, registry: &LanguageRegistry) -> Result<i32> {
501    if !path.is_file() {
502        eprintln!("[run] File not found: {}", path.display());
503        return Ok(1);
504    }
505    let address = format!("127.0.0.1:{}", port.unwrap_or(0));
506    let server = tiny_http::Server::http(&address)
507        .map_err(|err| anyhow::anyhow!("failed to start share server: {err}"))?;
508    let url = format!("http://{}", server.server_addr());
509    println!("Sharing at {url} (Ctrl-C to stop)");
510
511    let lang = language_from_path(path).unwrap_or("text");
512    let spec = ExecutionSpec {
513        language: (lang != "text").then(|| LanguageSpec::new(lang.to_string())),
514        source: InputSource::File(path.to_path_buf()),
515        detect_language: true,
516        args: Vec::new(),
517        json: false,
518    };
519    let output = execute_capture(spec, registry).unwrap_or_default();
520    for request in server.incoming_requests() {
521        let route = request.url().to_string();
522        if route == "/raw" {
523            let text = fs::read_to_string(path).unwrap_or_default();
524            let _ = request.respond(tiny_http::Response::from_string(text));
525            continue;
526        }
527        let body = render_share_html(path, lang, &output);
528        let response = tiny_http::Response::from_string(body).with_header(
529            tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
530                .unwrap(),
531        );
532        let _ = request.respond(response);
533    }
534    Ok(0)
535}
536
537fn bench_run(spec: ExecutionSpec, registry: &LanguageRegistry, iterations: u32) -> Result<i32> {
538    let payload = ExecutionPayload::from_input_source(&spec.source, &spec.args)
539        .context("failed to materialize execution payload")?;
540    let language = resolve_language(
541        spec.language,
542        spec.detect_language,
543        Some(&payload),
544        registry,
545    )?;
546
547    let engine = registry
548        .resolve(&language)
549        .context("failed to resolve language engine")?;
550
551    engine
552        .validate()
553        .with_context(|| format!("{} is not available", engine.display_name()))?;
554
555    eprintln!(
556        "\x1b[1mBenchmark:\x1b[0m {} — {} iteration{}",
557        engine.display_name(),
558        iterations,
559        if iterations == 1 { "" } else { "s" }
560    );
561
562    // Warmup run (not counted)
563    let warmup = engine.execute(&payload)?;
564    if !warmup.success() {
565        eprintln!("\x1b[31mError:\x1b[0m Code failed during warmup run");
566        if !warmup.stderr.is_empty() {
567            eprint!("{}", warmup.stderr);
568        }
569        return Ok(1);
570    }
571    eprintln!("\x1b[2m  warmup: {}ms\x1b[0m", warmup.duration.as_millis());
572
573    let mut times: Vec<f64> = Vec::with_capacity(iterations as usize);
574
575    for i in 0..iterations {
576        let outcome = engine.execute(&payload)?;
577        let ms = outcome.duration.as_secs_f64() * 1000.0;
578        times.push(ms);
579
580        if i < 3 || i == iterations - 1 || (i + 1) % 10 == 0 {
581            eprintln!("\x1b[2m  run {}: {:.2}ms\x1b[0m", i + 1, ms);
582        }
583    }
584
585    times.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
586    let total: f64 = times.iter().sum();
587    let avg = total / times.len() as f64;
588    let min = times.first().copied().unwrap_or(0.0);
589    let max = times.last().copied().unwrap_or(0.0);
590    let median = if times.len().is_multiple_of(2) && times.len() >= 2 {
591        (times[times.len() / 2 - 1] + times[times.len() / 2]) / 2.0
592    } else {
593        times[times.len() / 2]
594    };
595
596    // Standard deviation
597    let variance: f64 = times.iter().map(|t| (t - avg).powi(2)).sum::<f64>() / times.len() as f64;
598    let stddev = variance.sqrt();
599
600    eprintln!();
601    eprintln!("\x1b[1mResults ({} runs):\x1b[0m", iterations);
602    eprintln!("  min:    \x1b[32m{:.2}ms\x1b[0m", min);
603    eprintln!("  max:    \x1b[33m{:.2}ms\x1b[0m", max);
604    eprintln!("  avg:    \x1b[36m{:.2}ms\x1b[0m", avg);
605    eprintln!("  median: \x1b[36m{:.2}ms\x1b[0m", median);
606    eprintln!("  stddev: {:.2}ms", stddev);
607
608    if !warmup.stdout.is_empty() {
609        print!("{}", warmup.stdout);
610        io::stdout().flush().ok();
611    }
612
613    Ok(0)
614}
615
616fn watch_run(spec: ExecutionSpec, registry: &LanguageRegistry) -> Result<i32> {
617    use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
618
619    let file_path = match &spec.source {
620        InputSource::File(p) => p.clone(),
621        _ => anyhow::bail!("--watch requires a file path (use -f or pass a file as argument)"),
622    };
623
624    if !file_path.exists() {
625        anyhow::bail!("File not found: {}", file_path.display());
626    }
627
628    let payload = ExecutionPayload::from_input_source(&spec.source, &spec.args)
629        .context("failed to materialize execution payload")?;
630    let language = resolve_language(
631        spec.language.clone(),
632        spec.detect_language,
633        Some(&payload),
634        registry,
635    )?;
636
637    let engine = registry
638        .resolve(&language)
639        .context("failed to resolve language engine")?;
640
641    engine
642        .validate()
643        .with_context(|| format!("{} is not available", engine.display_name()))?;
644
645    println!(
646        "[run watch] watching {} ({}) — Ctrl-C to stop",
647        file_path.display(),
648        engine.display_name()
649    );
650
651    let mut run_count = 0u32;
652
653    run_count += 1;
654    print!("\x1b[2J\x1b[H");
655    println!("[run watch] run #{run_count}");
656    run_file_once(&file_path, engine, &spec.args);
657
658    let (tx, rx) = mpsc::channel();
659    let mut watcher = RecommendedWatcher::new(
660        move |res| {
661            let _ = tx.send(res);
662        },
663        Config::default(),
664    )?;
665    watcher.watch(&file_path, RecursiveMode::NonRecursive)?;
666
667    loop {
668        match rx.recv() {
669            Ok(Ok(_event)) => {
670                while rx
671                    .recv_timeout(std::time::Duration::from_millis(150))
672                    .is_ok()
673                {}
674                run_count += 1;
675                print!("\x1b[2J\x1b[H");
676                let now = SystemTime::now()
677                    .duration_since(SystemTime::UNIX_EPOCH)
678                    .map(|duration| duration.as_secs())
679                    .unwrap_or(0);
680                println!("[run watch] run #{run_count} at {now}");
681                run_file_once(&file_path, engine, &spec.args);
682            }
683            Ok(Err(err)) => eprintln!("[run] watch error: {err}"),
684            Err(err) => anyhow::bail!("[run] watch channel closed: {err}"),
685        }
686    }
687}
688
689fn run_file_once(file_path: &Path, engine: &dyn crate::engine::LanguageEngine, args: &[String]) {
690    let payload = ExecutionPayload::File {
691        path: file_path.to_path_buf(),
692        args: args.to_vec(),
693    };
694    match engine.execute(&payload) {
695        Ok(outcome) => {
696            if !outcome.stdout.is_empty() {
697                print!("{}", outcome.stdout);
698                io::stdout().flush().ok();
699            }
700            if !outcome.stderr.is_empty() {
701                eprint!("\x1b[31m{}\x1b[0m", outcome.stderr);
702                io::stderr().flush().ok();
703            }
704            let ms = outcome.duration.as_millis();
705            let status = if outcome.success() {
706                "\x1b[32mOK\x1b[0m"
707            } else {
708                "\x1b[31mFAIL\x1b[0m"
709            };
710            eprintln!("\x1b[2m[{status} {ms}ms]\x1b[0m");
711        }
712        Err(e) => {
713            eprintln!("\x1b[31mError:\x1b[0m {e:#}");
714        }
715    }
716}
717
718fn execute_capture(spec: ExecutionSpec, registry: &LanguageRegistry) -> Result<String> {
719    let payload = ExecutionPayload::from_input_source(&spec.source, &spec.args)
720        .context("failed to materialize execution payload")?;
721    let language = resolve_language(
722        spec.language,
723        spec.detect_language,
724        Some(&payload),
725        registry,
726    )?;
727    let engine = registry
728        .resolve(&language)
729        .context("failed to resolve language engine")?;
730    let outcome = engine.execute(&payload)?;
731    let mut output = outcome.stdout;
732    output.push_str(&outcome.stderr);
733    Ok(output)
734}
735
736fn render_share_html(path: &Path, language: &str, output: &str) -> String {
737    let code = fs::read_to_string(path).unwrap_or_default();
738    let syntax_set = syntect::parsing::SyntaxSet::load_defaults_newlines();
739    let theme_set = syntect::highlighting::ThemeSet::load_defaults();
740    let syntax = syntax_set
741        .find_syntax_by_extension(path.extension().and_then(|ext| ext.to_str()).unwrap_or(""))
742        .unwrap_or_else(|| syntax_set.find_syntax_plain_text());
743    let rendered_code = theme_set
744        .themes
745        .get("base16-ocean.dark")
746        .and_then(|theme| {
747            syntect::html::highlighted_html_for_string(&code, &syntax_set, syntax, theme).ok()
748        })
749        .unwrap_or_else(|| format!("<pre>{}</pre>", html_escape(&code)));
750    format!(
751        "<!doctype html><meta charset=\"utf-8\"><title>{}</title>\
752         <style>body{{font-family:system-ui;margin:2rem;background:#111;color:#eee}}pre{{padding:1rem;overflow:auto;background:#1b1b1b}}.out{{white-space:pre-wrap}}</style>\
753         <h1>{}</h1><p>Language: {}</p>{}<h2>Last output</h2><pre class=\"out\">{}</pre>",
754        html_escape(&path.display().to_string()),
755        html_escape(&path.display().to_string()),
756        html_escape(language),
757        rendered_code,
758        html_escape(output)
759    )
760}
761
762fn html_escape(text: &str) -> String {
763    text.replace('&', "&amp;")
764        .replace('<', "&lt;")
765        .replace('>', "&gt;")
766        .replace('"', "&quot;")
767}
768
769fn language_from_path(path: &Path) -> Option<&'static str> {
770    let ext = path.extension()?.to_str()?.to_ascii_lowercase();
771    match ext.as_str() {
772        "py" | "pyw" => Some("python"),
773        "js" | "jsx" | "mjs" | "cjs" => Some("javascript"),
774        "ts" | "tsx" => Some("typescript"),
775        "rs" => Some("rust"),
776        "go" => Some("go"),
777        "c" | "h" => Some("c"),
778        "cc" | "cpp" | "cxx" | "hpp" | "hxx" => Some("cpp"),
779        "java" => Some("java"),
780        "rb" => Some("ruby"),
781        "sh" | "bash" | "zsh" => Some("bash"),
782        _ => None,
783    }
784}
785
786fn toolchain_name(language: &str) -> &'static str {
787    match language {
788        "python" => "python3",
789        "javascript" => "node",
790        "typescript" => "deno",
791        "rust" => "rustc",
792        "go" => "go",
793        "c" => "cc",
794        "cpp" => "c++",
795        "java" => "javac/java",
796        "kotlin" => "kotlinc",
797        "csharp" => "dotnet",
798        "bash" => "bash",
799        "ruby" => "ruby",
800        "lua" => "lua",
801        "php" => "php",
802        "r" => "Rscript",
803        "dart" => "dart",
804        "swift" => "swift",
805        "perl" => "perl",
806        "julia" => "julia",
807        "haskell" => "runghc",
808        "elixir" => "elixir",
809        "crystal" => "crystal",
810        "zig" => "zig",
811        "nim" => "nim",
812        "groovy" => "groovy",
813        _ => "unknown",
814    }
815}
816
817fn resolve_language(
818    explicit: Option<LanguageSpec>,
819    allow_detect: bool,
820    payload: Option<&ExecutionPayload>,
821    registry: &LanguageRegistry,
822) -> Result<LanguageSpec> {
823    if let Some(spec) = explicit {
824        ensure_known_language(&spec, registry)?;
825        return Ok(spec);
826    }
827
828    if allow_detect
829        && let Some(payload) = payload
830        && let Some(detected) = detect_language_for_source(payload, registry)
831    {
832        return Ok(detected);
833    }
834
835    let default = LanguageSpec::new(default_language());
836    ensure_known_language(&default, registry)?;
837    Ok(default)
838}