diff --git a/src/api/code_runner/execute.rs b/src/api/code_runner/execute.rs index c9414b1..909072d 100644 --- a/src/api/code_runner/execute.rs +++ b/src/api/code_runner/execute.rs @@ -26,7 +26,7 @@ use crate::api::code_runner::{ExecResult, ExecStatus}; #[cfg(feature = "server")] use crate::api::auth::get_current_admin_user; #[cfg(feature = "server")] -use crate::api::code_runner::languages::{is_supported_lang, LANGUAGES}; +use crate::api::code_runner::languages::{is_supported_lang, normalize_lang, LANGUAGES}; #[cfg(feature = "server")] use crate::api::code_runner::progress::{ gc_old_tasks, insert_task, update_task_result, update_task_stage, StreamEntry, EXEC_STREAMS, @@ -115,7 +115,9 @@ pub async fn start_exec(req: ExecRequest) -> Result { // 后台执行:信号量限并发 → clamp_limits → run_in_container let task_id_clone = task_id.clone(); - let lang_key = req.language.clone(); + // 归一化为 canonical key(js→node / ts→bun / rs→rust),确保 LANGUAGES.get 命中、 + // ExecResult.language 回显 canonical(与 markdown 渲染期的 data-lang 一致)。 + let lang_key = normalize_lang(&req.language); tokio::spawn(async move { let sem = &*RUNNER_SEMAPHORE; @@ -243,7 +245,8 @@ pub async fn start_exec_stream(req: ExecRequest) -> Result> = LazyLock::new(|| }, ); + // bun:原生 TypeScript 运行器(无需 tsc 预编译)。`ts`/`typescript` 别名 + // 归一到 `bun`(见 LANG_ALIASES)。bun 镜像用官方安装脚本装在 alpine 上, + // 单步 `bun run /code/main.ts` 直接执行,无需 wrapper。 + // 默认资源与 node 对齐:JS 运行时开销相近,256MB/5s 足够。 + m.insert( + "bun".to_string(), + LanguageDef { + image: "yggdrasil-runner-bun:latest".to_string(), + run_cmd: "bun run /code/main.ts".to_string(), + extension: "ts".to_string(), + default_limits: ResourceLimits { + cpu_cores: 1.0, + memory_mb: 256, + timeout_secs: 5, + output_bytes: 1_048_576, + allow_network: false, + }, + allow_network: false, + }, + ); + m }); -/// 是否支持该语言:必须在 LANGUAGES 注册表中存在。 -/// 若设置了 `CODE_RUNNER_LANGUAGES`,还需同时在该白名单内(用于收窄可用语言); -/// 未设置则注册表里的语言全部放行。 -pub fn is_supported_lang(lang: &str) -> bool { +/// 语言别名 → canonical key 映射。 +/// +/// canonical key(`python`/`node`/`go`/`rust`/`bun`)本身不在表里——它们原样 +/// 通过。仅收录读者/作者习惯的简写:`js`/`javascript`→`node`、`rs`→`rust`、 +/// `ts`/`typescript`→`bun`。比较大小写不敏感(见 [`normalize_lang`])。 +const LANG_ALIASES: &[(&str, &str)] = &[ + ("js", "node"), + ("javascript", "node"), + ("rs", "rust"), + ("ts", "bun"), + ("typescript", "bun"), +]; + +/// 把语言标识归一化为 `LANGUAGES` 注册表里的 canonical key。 +/// +/// 步骤:trim → lowercase → 查 [`LANG_ALIASES`],命中返回映射值,未命中返回 +/// trim+lowercase 后的原值(保证 canonical 名与未注册字符串都能被调用方处理)。 +/// +/// 归一化是 [`is_supported_lang`] / [`parse_fence_info`] 的前置步骤,也是 +/// `execute.rs` 在 `LANGUAGES.get` 前必须调用的——否则用别名查表会落空。 +pub fn normalize_lang(lang: &str) -> String { let clean = lang.trim().to_lowercase(); - LANGUAGES.contains_key(&clean) + for &(from, to) in LANG_ALIASES { + if clean == from { + return to.to_string(); + } + } + clean +} + +/// 是否支持该语言:先 [`normalize_lang`] 归一化,再查 `LANGUAGES` 注册表。 +/// 若设置了 `CODE_RUNNER_LANGUAGES`,还需同时在该白名单内(用于收窄可用语言, +/// 白名单用 canonical key 比较——运维写 `node` 而非 `js`);未设置则全部放行。 +pub fn is_supported_lang(lang: &str) -> bool { + let canonical = normalize_lang(lang); + LANGUAGES.contains_key(&canonical) && RUNNER_CONFIG .languages .as_ref() - .is_none_or(|list| list.iter().any(|l| l == &clean)) + .is_none_or(|list| list.iter().any(|l| l == &canonical)) } /// 解析围栏代码块的 info string。 /// /// 格式:` [runnable|run] [ {} ]` /// -/// 返回 `(lang, runnable, overrides)`。未知 token 静默忽略;JSON 解析失败时 overrides 为 None。 +/// 返回 `(lang, runnable, overrides)`。`lang` 已经过 [`normalize_lang`] 归一化 +/// (`js`→`node`、`ts`→`bun` 等),下游 markdown 渲染 / ExecRequest.language / +/// `LANGUAGES.get` 都拿到 canonical key。未知 token 静默忽略;JSON 解析失败时 +/// overrides 为 None。 pub fn parse_fence_info(info: &str) -> (String, bool, Option) { let tokens: Vec<&str> = info.split_whitespace().collect(); if tokens.is_empty() { return ("".to_string(), false, None); } - let lang = tokens[0].trim().to_lowercase(); + let lang = normalize_lang(tokens[0]); let mut runnable = false; let mut overrides = None; @@ -229,4 +289,87 @@ mod tests { assert!(is_supported_lang(" Python ")); assert!(is_supported_lang("NODE")); } + + // ---- 别名归一化(normalize_lang / parse_fence_info / is_supported_lang 三处协同)---- + + #[test] + fn normalize_lang_canonical_passthrough() { + // canonical key 原样通过(仅 trim+lowercase)。 + assert_eq!(normalize_lang("python"), "python"); + assert_eq!(normalize_lang("node"), "node"); + assert_eq!(normalize_lang("go"), "go"); + assert_eq!(normalize_lang("rust"), "rust"); + assert_eq!(normalize_lang("bun"), "bun"); + } + + #[test] + fn normalize_lang_aliases() { + // 别名 → canonical。 + assert_eq!(normalize_lang("js"), "node"); + assert_eq!(normalize_lang("javascript"), "node"); + assert_eq!(normalize_lang("rs"), "rust"); + assert_eq!(normalize_lang("ts"), "bun"); + assert_eq!(normalize_lang("typescript"), "bun"); + } + + #[test] + fn normalize_lang_case_and_whitespace_insensitive() { + // 输入容忍:大小写、首尾空白。匹配 is_supported_lang 的既有契约。 + assert_eq!(normalize_lang(" JS "), "node"); + assert_eq!(normalize_lang("JavaScript"), "node"); + assert_eq!(normalize_lang("\tRS\t"), "rust"); + assert_eq!(normalize_lang("TypeScript"), "bun"); + } + + #[test] + fn normalize_lang_unregistered_passthrough() { + // 未在别名表、未在注册表的语言原样返回(trim+lowercase 后), + // 由调用方决定是否拒绝。normalize_lang 不做存在性判断。 + assert_eq!(normalize_lang("ruby"), "ruby"); + assert_eq!(normalize_lang("Brainfuck"), "brainfuck"); + assert_eq!(normalize_lang(""), ""); + } + + #[test] + fn is_supported_lang_accepts_aliases() { + // 别名经 normalize_lang 归一后命中注册表,应放行。 + assert!(is_supported_lang("js")); + assert!(is_supported_lang("javascript")); + assert!(is_supported_lang("rs")); + assert!(is_supported_lang("ts")); + assert!(is_supported_lang("typescript")); + } + + #[test] + fn is_supported_lang_accepts_bun_canonical() { + // bun 作为新增 canonical key,默认放行。 + assert!(is_supported_lang("bun")); + } + + #[test] + fn parse_fence_info_normalizes_alias_to_canonical() { + // 关键契约:parse_fence_info 返回的 lang 是 canonical key, + // 下游 markdown.rs 的 data-lang / class="language-{lang}" 直接用此值。 + let (lang, runnable, _) = parse_fence_info("js runnable"); + assert_eq!(lang, "node"); + assert!(runnable); + + let (lang, _, _) = parse_fence_info("typescript runnable"); + assert_eq!(lang, "bun"); + + let (lang, _, _) = parse_fence_info("rs runnable"); + assert_eq!(lang, "rust"); + + // 大小写不敏感。 + let (lang, _, _) = parse_fence_info("JavaScript runnable"); + assert_eq!(lang, "node"); + } + + #[test] + fn parse_fence_info_bun_canonical_runs_as_ts() { + // bun 自身是 canonical,runnable 块以 bun 执行。 + let (lang, runnable, _) = parse_fence_info("bun runnable"); + assert_eq!(lang, "bun"); + assert!(runnable); + } } diff --git a/src/api/markdown.rs b/src/api/markdown.rs index d577357..740dd7a 100644 --- a/src/api/markdown.rs +++ b/src/api/markdown.rs @@ -950,6 +950,47 @@ console.log(1) ); } + #[test] + fn render_markdown_runnable_block_alias_normalized_to_canonical() { + // 关键契约:parse_fence_info 在 markdown 渲染期就把别名归一为 canonical key, + // 故 `js runnable` 渲染出的 data-lang 是 "node"(而非 "js")。 + // 这保证阅读器 CodeRunner 拿到的 language 是 canonical,StartExec 的 + // LANGUAGES.get 能直接命中,无需前端再做别名映射。 + let cases = [ + ("js", "node"), + ("javascript", "node"), + ("rs", "rust"), + ("ts", "bun"), + ("typescript", "bun"), + // 大小写不敏感。 + ("JavaScript", "node"), + ("TypeScript", "bun"), + ]; + for (alias, canonical) in cases { + let src = format!("```{alias} runnable\nconsole.log(1)\n```"); + let result = render_markdown_enhanced(&src); + assert!( + result.html.contains(&format!(r#"data-lang="{canonical}""#)), + "别名 {alias} 应归一为 data-lang={canonical}, got: {}", + result.html + ); + // 不应残留原始别名作为 data-lang。 + let bad = format!(r#"data-lang="{alias}""#); + assert!( + !result.html.contains(&bad), + "data-lang 不应保留别名 {alias}, got: {}", + result.html + ); + } + } + + #[test] + fn render_markdown_runnable_block_bun_canonical() { + // bun 自身是 canonical(不是别名),runnable 块以 bun 执行。 + let result = render_markdown_enhanced("```bun runnable\nconsole.log('hi')\n```"); + assert!(result.html.contains(r#"data-lang="bun""#), "got: {}", result.html); + } + #[test] fn render_markdown_runnable_marker_on_unsupported_lang_ignored() { // 语言不在白名单(rust 已在白名单,改用 ruby):runnable 标记被忽略,输出普通代码块。