feat(code-runner): 添加语言别名归一化与 bun 运行器注册

新增 normalize_lang 别名表,把读者/作者习惯的语言简写归一到 canonical key:
- js / javascript → node
- rs              → rust
- ts / typescript → bun

归一化在 markdown 渲染期(parse_fence_info)即完成,使 data-lang、
ExecRequest.language、ExecResult.language、LANGUAGES.get 全程只见到
canonical 名(python/node/go/rust/bun),避免前端用别名调 StartExec 时
查表落空。execute.rs 的 start_exec / start_exec_stream 在 spawn 前显式
normalize_lang,保证 ExecResult.language 回显 canonical。

同时注册 bun 语言(canonical):镜像 yggdrasil-runner-bun,run_cmd
'bun run /code/main.ts',默认资源与 node 对齐(256MB/5s)。Dockerfile
与 build-runners.sh 跟进。

测试覆盖三处协同契约:languages.rs 的 normalize_lang 别名表、
execute.rs 的 validate 接受别名、markdown.rs 的 data-lang 归一化。
This commit is contained in:
xfy 2026-07-21 15:21:58 +08:00
parent 460088fa43
commit 22d226d4e8
3 changed files with 213 additions and 13 deletions

View File

@ -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<String, ServerFnError> {
// 后台执行:信号量限并发 → clamp_limits → run_in_container
let task_id_clone = task_id.clone();
let lang_key = req.language.clone();
// 归一化为 canonical keyjs→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<String, ServerFnError
gc_old_tasks();
let task_id_clone = task_id.clone();
let lang_key = req.language.clone();
// 同 start_exec归一化别名LANGUAGES.get 用 canonical key。
let lang_key = normalize_lang(&req.language);
tokio::spawn(async move {
let sem = &*RUNNER_SEMAPHORE;
@ -361,8 +364,8 @@ mod tests {
#[test]
fn validate_accepts_registered_language() {
// python/node/go/rust 默认注册,应放行。
for lang in ["python", "node", "go", "rust"] {
// python/node/go/rust/bun 默认注册,应放行。
for lang in ["python", "node", "go", "rust", "bun"] {
assert!(
validate_exec_request(&req(lang, "x")).is_ok(),
"{lang} 应被支持"
@ -370,6 +373,19 @@ mod tests {
}
}
#[test]
fn validate_accepts_aliases() {
// 别名经 is_supported_lang 内的 normalize_lang 归一后命中注册表,应放行。
// 锁定该契约:前端 CodeRunner 可能带着别名(如历史渲染的 data-lang="js"
// 调 StartExec校验链不能因别名拒绝。
for lang in ["js", "javascript", "rs", "ts", "typescript"] {
assert!(
validate_exec_request(&req(lang, "x")).is_ok(),
"别名 {lang} 应被支持"
);
}
}
#[test]
fn validate_language_is_case_and_whitespace_insensitive() {
// is_supported_lang 内部 trim+lowercase校验链应透传该容忍。

View File

@ -6,6 +6,12 @@
//!
//! 实际可用语言默认即注册表里的全部;若设置了 `CODE_RUNNER_LANGUAGES`
//! 环境变量,则进一步收窄到该白名单内([`is_supported_lang`])。
//!
//! **别名归一化**[`normalize_lang`] 把 `js`/`javascript` 归一为 `node`、
//! `rs` 归一为 `rust`、`ts`/`typescript` 归一为 `bun`。归一化在 [`parse_fence_info`]
//! 与 [`is_supported_lang`] 内完成markdown 渲染期就把别名换成 canonical key
//! 下游 `data-lang` / `ExecRequest.language` / `LANGUAGES.get` 只见到 canonical
//! 名(`python`/`node`/`go`/`rust`/`bun`),不再出现别名,避免查表落空。
use std::collections::HashMap;
use std::sync::LazyLock;
@ -107,32 +113,86 @@ pub static LANGUAGES: LazyLock<HashMap<String, LanguageDef>> = 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。
///
/// 格式:`<lang> [runnable|run] [ {<ResourceLimits JSON>} ]`
///
/// 返回 `(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<ResourceLimits>) {
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 自身是 canonicalrunnable 块以 bun 执行。
let (lang, runnable, _) = parse_fence_info("bun runnable");
assert_eq!(lang, "bun");
assert!(runnable);
}
}

View File

@ -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 是 canonicalStartExec 的
// 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 标记被忽略,输出普通代码块。