Skip to main content

yggdrasil/api/code_runner/
languages.rs

1//! 语言注册表与围栏代码块 info string 解析。
2//!
3//! 支持的语言通过 [`LANGUAGES`] 注册(镜像名、运行命令、默认资源限制、是否允许网络)。
4//! [`parse_fence_info`] 解析 markdown 围栏代码块的信息串,识别 `runnable` 标记与
5//! 可选的 JSON 资源覆盖(如 `python runnable {"timeout_secs":10}`)。
6//!
7//! 实际可用语言默认即注册表里的全部;若设置了 `CODE_RUNNER_LANGUAGES`
8//! 环境变量,则进一步收窄到该白名单内([`is_supported_lang`])。
9//!
10//! **别名归一化**:[`normalize_lang`] 把 `js`/`javascript` 归一为 `node`、
11//! `rs` 归一为 `rust`、`ts`/`typescript` 归一为 `bun`。归一化在 [`parse_fence_info`]
12//! 与 [`is_supported_lang`] 内完成,markdown 渲染期就把别名换成 canonical key,
13//! 下游 `data-lang` / `ExecRequest.language` / `LANGUAGES.get` 只见到 canonical
14//! 名(`python`/`node`/`go`/`rust`/`bun`),不再出现别名,避免查表落空。
15
16use std::collections::HashMap;
17use std::sync::LazyLock;
18
19use crate::infra::docker::CacheVolume;
20use crate::infra::runner_config::{ResourceLimits, RUNNER_CONFIG};
21
22/// 单个语言的运行定义。语言名即 [`LANGUAGES`] 的 key,不再冗余存字段。
23pub struct LanguageDef {
24    /// 容器镜像(task 12 的 docker build 产出,如 `yggdrasil-runner-python:latest`)。
25    pub image: String,
26    /// 容器内执行命令(源码会注入到 `/code/main.{ext}`)。
27    pub run_cmd: String,
28    pub extension: String,
29    pub default_limits: ResourceLimits,
30    /// 该语言本身是否允许网络(与全局/请求级 allow_network 取与)。
31    pub allow_network: bool,
32    /// 跨运行持久化的编译缓存卷:docker.rs 把该 named volume 挂载到 mount_path,
33    /// 使每次运行复用上次编译产物(仅编译型语言使用;解释型为 None)。
34    pub cache_volume: Option<CacheVolume>,
35}
36
37/// 内置语言注册表。新增语言时在此 `insert` 即可默认启用;
38/// 若运维需要收窄,设置 `CODE_RUNNER_LANGUAGES` 为逗号分隔列表。
39pub static LANGUAGES: LazyLock<HashMap<String, LanguageDef>> = LazyLock::new(|| {
40    let mut m = HashMap::new();
41
42    m.insert(
43        "python".to_string(),
44        LanguageDef {
45            image: "yggdrasil-runner-python:latest".to_string(),
46            // -u (unbuffered):强制 stdout/stderr 行刷新。
47            // 容器 attach 用 pipe(非 TTY),Python 默认对 pipe 做块缓冲(4KB),
48            // 导致流式输出失效——print 的内容攒在缓冲区,进程退出才一次性刷出。
49            // -u 等价于 PYTHONUNBUFFERED=1,让每行 print 立即写出。
50            run_cmd: "python -u /code/main.py".to_string(),
51            extension: "py".to_string(),
52            default_limits: ResourceLimits {
53                cpu_cores: 1.0,
54                memory_mb: 256,
55                timeout_secs: 5,
56                output_bytes: 1_048_576,
57                allow_network: false,
58            },
59            allow_network: false,
60            cache_volume: None, // 解释型,无编译缓存
61        },
62    );
63
64    m.insert(
65        "node".to_string(),
66        LanguageDef {
67            image: "yggdrasil-runner-node:latest".to_string(),
68            run_cmd: "node /code/main.js".to_string(),
69            extension: "js".to_string(),
70            default_limits: ResourceLimits {
71                cpu_cores: 1.0,
72                memory_mb: 256,
73                timeout_secs: 5,
74                output_bytes: 1_048_576,
75                allow_network: false,
76            },
77            allow_network: false,
78            cache_volume: None, // 解释型,无编译缓存
79        },
80    );
81
82    // 编译型语言:go run 是单条命令(内部编译 + 运行),可直接作为 run_cmd。
83    // 只读根文件系统下 $HOME/.cache 不可写。GOCACHE 指向共享卷 /go-cache
84    // (cache_volume 由 docker.rs 挂载,跨运行持久化 + 镜像构建期预热,见
85    // docker/runner-go/Dockerfile);GOTMPDIR/GOPATH 仍指向 /tmp tmpfs。
86    // Go ≥1.20 不分发预编译 stdlib,无缓存时每次冷编译,timeout 提到 10s。
87    m.insert(
88        "go".to_string(),
89        LanguageDef {
90            image: "yggdrasil-runner-go:latest".to_string(),
91            run_cmd: "go run /code/main.go".to_string(),
92            extension: "go".to_string(),
93            default_limits: ResourceLimits {
94                cpu_cores: 1.0,
95                memory_mb: 256,
96                timeout_secs: 10,
97                output_bytes: 1_048_576,
98                allow_network: false,
99            },
100            allow_network: false,
101            cache_volume: Some(CacheVolume {
102                name: "yggdrasil-gocache".to_string(),
103                mount_path: "/go-cache".to_string(),
104            }),
105        },
106    );
107
108    // rustc 编译 + 运行是两步,但 docker.rs 注入脚本用 exec 执行 run_cmd,
109    // exec 替换 shell 进程后 "A && B" 后半段不执行,故镜像内置 run-rust.sh wrapper。
110    // rustc 内存开销大、编译慢,memory 提到 512MB、timeout 提到 15s。
111    m.insert(
112        "rust".to_string(),
113        LanguageDef {
114            image: "yggdrasil-runner-rust:latest".to_string(),
115            run_cmd: "/usr/local/bin/run-rust.sh".to_string(),
116            extension: "rs".to_string(),
117            default_limits: ResourceLimits {
118                cpu_cores: 1.0,
119                memory_mb: 512,
120                timeout_secs: 15,
121                output_bytes: 1_048_576,
122                allow_network: false,
123            },
124            allow_network: false,
125            cache_volume: None, // std 预编译随工具链分发,冷编译仅 ~1s,无需缓存卷
126        },
127    );
128
129    // bun:原生 TypeScript 运行器(无需 tsc 预编译)。`ts`/`typescript` 别名
130    // 归一到 `bun`(见 LANG_ALIASES)。bun 镜像用官方安装脚本装在 alpine 上,
131    // 单步 `bun run /code/main.ts` 直接执行,无需 wrapper。
132    // 默认资源与 node 对齐:JS 运行时开销相近,256MB/5s 足够。
133    m.insert(
134        "bun".to_string(),
135        LanguageDef {
136            image: "yggdrasil-runner-bun:latest".to_string(),
137            run_cmd: "bun run /code/main.ts".to_string(),
138            extension: "ts".to_string(),
139            default_limits: ResourceLimits {
140                cpu_cores: 1.0,
141                memory_mb: 256,
142                timeout_secs: 5,
143                output_bytes: 1_048_576,
144                allow_network: false,
145            },
146            allow_network: false,
147            cache_volume: None, // 解释型,无编译缓存
148        },
149    );
150
151    m
152});
153
154/// 语言别名 → canonical key 映射。
155///
156/// canonical key(`python`/`node`/`go`/`rust`/`bun`)本身不在表里——它们原样
157/// 通过。仅收录读者/作者习惯的简写:`js`/`javascript`→`node`、`rs`→`rust`、
158/// `ts`/`typescript`→`bun`。比较大小写不敏感(见 [`normalize_lang`])。
159const LANG_ALIASES: &[(&str, &str)] = &[
160    ("js", "node"),
161    ("javascript", "node"),
162    ("rs", "rust"),
163    ("ts", "bun"),
164    ("typescript", "bun"),
165];
166
167/// 把语言标识归一化为 `LANGUAGES` 注册表里的 canonical key。
168///
169/// 步骤:trim → lowercase → 查 [`LANG_ALIASES`],命中返回映射值,未命中返回
170/// trim+lowercase 后的原值(保证 canonical 名与未注册字符串都能被调用方处理)。
171///
172/// 归一化是 [`is_supported_lang`] / [`parse_fence_info`] 的前置步骤,也是
173/// `execute.rs` 在 `LANGUAGES.get` 前必须调用的——否则用别名查表会落空。
174pub fn normalize_lang(lang: &str) -> String {
175    let clean = lang.trim().to_lowercase();
176    for &(from, to) in LANG_ALIASES {
177        if clean == from {
178            return to.to_string();
179        }
180    }
181    clean
182}
183
184/// 是否支持该语言:先 [`normalize_lang`] 归一化,再查 `LANGUAGES` 注册表。
185/// 若设置了 `CODE_RUNNER_LANGUAGES`,还需同时在该白名单内(用于收窄可用语言,
186/// 白名单用 canonical key 比较——运维写 `node` 而非 `js`);未设置则全部放行。
187pub fn is_supported_lang(lang: &str) -> bool {
188    let canonical = normalize_lang(lang);
189    LANGUAGES.contains_key(&canonical)
190        && RUNNER_CONFIG
191            .languages
192            .as_ref()
193            .is_none_or(|list| list.iter().any(|l| l == &canonical))
194}
195
196/// 解析围栏代码块的 info string。
197///
198/// 格式:`<lang> [runnable|run] [ {<ResourceLimits JSON>} ]`
199///
200/// 返回 `(lang, runnable, overrides)`。`lang` 已经过 [`normalize_lang`] 归一化
201/// (`js`→`node`、`ts`→`bun` 等),下游 markdown 渲染 / ExecRequest.language /
202/// `LANGUAGES.get` 都拿到 canonical key。未知 token 静默忽略;JSON 解析失败时
203/// overrides 为 None。
204pub fn parse_fence_info(info: &str) -> (String, bool, Option<ResourceLimits>) {
205    let tokens: Vec<&str> = info.split_whitespace().collect();
206    if tokens.is_empty() {
207        return ("".to_string(), false, None);
208    }
209    let lang = normalize_lang(tokens[0]);
210    let mut runnable = false;
211    let mut overrides = None;
212
213    if tokens.len() > 1 {
214        for &tok in &tokens[1..] {
215            if tok == "runnable" || tok == "run" {
216                runnable = true;
217            } else if tok.starts_with('{') {
218                if let Ok(limits) = serde_json::from_str::<ResourceLimits>(tok) {
219                    overrides = Some(limits);
220                }
221            }
222        }
223    }
224
225    (lang, runnable, overrides)
226}
227
228#[cfg(all(test, feature = "server"))]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn parse_fence_info_runnable_marker() {
234        let (lang, runnable, overrides) = parse_fence_info("python runnable");
235        assert_eq!(lang, "python");
236        assert!(runnable);
237        assert!(overrides.is_none());
238    }
239
240    #[test]
241    fn parse_fence_info_run_alias() {
242        let (lang, runnable, _) = parse_fence_info("node run");
243        assert_eq!(lang, "node");
244        assert!(runnable);
245    }
246
247    #[test]
248    fn parse_fence_info_with_overrides() {
249        let (lang, runnable, overrides) = parse_fence_info(
250            r#"node runnable {"timeout_secs":10,"memory_mb":512,"allow_network":true,"cpu_cores":1.0,"output_bytes":1024}"#,
251        );
252        assert_eq!(lang, "node");
253        assert!(runnable);
254        let limits = overrides.unwrap();
255        assert_eq!(limits.timeout_secs, 10);
256        assert_eq!(limits.memory_mb, 512);
257        assert!(limits.allow_network);
258    }
259
260    #[test]
261    fn parse_fence_info_not_runnable() {
262        let (lang, runnable, _) = parse_fence_info("rust");
263        assert_eq!(lang, "rust");
264        assert!(!runnable);
265    }
266
267    #[test]
268    fn parse_fence_info_empty() {
269        let (lang, runnable, overrides) = parse_fence_info("");
270        assert_eq!(lang, "");
271        assert!(!runnable);
272        assert!(overrides.is_none());
273    }
274
275    #[test]
276    fn parse_fence_info_case_insensitive_lang() {
277        let (lang, _, _) = parse_fence_info("PYTHON runnable");
278        assert_eq!(lang, "python");
279    }
280
281    #[test]
282    fn parse_fence_info_malformed_json_yields_none() {
283        let (lang, runnable, overrides) = parse_fence_info(r#"python runnable {not valid json}"#);
284        assert_eq!(lang, "python");
285        assert!(runnable);
286        assert!(overrides.is_none(), "malformed JSON should yield None");
287    }
288
289    #[test]
290    fn is_supported_lang_default_all_open() {
291        // 默认未设 CODE_RUNNER_LANGUAGES:注册表里的语言全部放行。
292        assert!(is_supported_lang("python"));
293        assert!(is_supported_lang("node"));
294        assert!(is_supported_lang("go"));
295        assert!(is_supported_lang("rust"));
296        // 未注册的语言仍不支持。
297        assert!(!is_supported_lang("ruby"));
298        assert!(!is_supported_lang(""));
299    }
300
301    #[test]
302    fn is_supported_lang_case_and_whitespace_insensitive() {
303        assert!(is_supported_lang(" Python "));
304        assert!(is_supported_lang("NODE"));
305    }
306
307    // ---- 别名归一化(normalize_lang / parse_fence_info / is_supported_lang 三处协同)----
308
309    #[test]
310    fn normalize_lang_canonical_passthrough() {
311        // canonical key 原样通过(仅 trim+lowercase)。
312        assert_eq!(normalize_lang("python"), "python");
313        assert_eq!(normalize_lang("node"), "node");
314        assert_eq!(normalize_lang("go"), "go");
315        assert_eq!(normalize_lang("rust"), "rust");
316        assert_eq!(normalize_lang("bun"), "bun");
317    }
318
319    #[test]
320    fn normalize_lang_aliases() {
321        // 别名 → canonical。
322        assert_eq!(normalize_lang("js"), "node");
323        assert_eq!(normalize_lang("javascript"), "node");
324        assert_eq!(normalize_lang("rs"), "rust");
325        assert_eq!(normalize_lang("ts"), "bun");
326        assert_eq!(normalize_lang("typescript"), "bun");
327    }
328
329    #[test]
330    fn normalize_lang_case_and_whitespace_insensitive() {
331        // 输入容忍:大小写、首尾空白。匹配 is_supported_lang 的既有契约。
332        assert_eq!(normalize_lang("  JS  "), "node");
333        assert_eq!(normalize_lang("JavaScript"), "node");
334        assert_eq!(normalize_lang("\tRS\t"), "rust");
335        assert_eq!(normalize_lang("TypeScript"), "bun");
336    }
337
338    #[test]
339    fn normalize_lang_unregistered_passthrough() {
340        // 未在别名表、未在注册表的语言原样返回(trim+lowercase 后),
341        // 由调用方决定是否拒绝。normalize_lang 不做存在性判断。
342        assert_eq!(normalize_lang("ruby"), "ruby");
343        assert_eq!(normalize_lang("Brainfuck"), "brainfuck");
344        assert_eq!(normalize_lang(""), "");
345    }
346
347    #[test]
348    fn is_supported_lang_accepts_aliases() {
349        // 别名经 normalize_lang 归一后命中注册表,应放行。
350        assert!(is_supported_lang("js"));
351        assert!(is_supported_lang("javascript"));
352        assert!(is_supported_lang("rs"));
353        assert!(is_supported_lang("ts"));
354        assert!(is_supported_lang("typescript"));
355    }
356
357    #[test]
358    fn is_supported_lang_accepts_bun_canonical() {
359        // bun 作为新增 canonical key,默认放行。
360        assert!(is_supported_lang("bun"));
361    }
362
363    #[test]
364    fn parse_fence_info_normalizes_alias_to_canonical() {
365        // 关键契约:parse_fence_info 返回的 lang 是 canonical key,
366        // 下游 markdown.rs 的 data-lang / class="language-{lang}" 直接用此值。
367        let (lang, runnable, _) = parse_fence_info("js runnable");
368        assert_eq!(lang, "node");
369        assert!(runnable);
370
371        let (lang, _, _) = parse_fence_info("typescript runnable");
372        assert_eq!(lang, "bun");
373
374        let (lang, _, _) = parse_fence_info("rs runnable");
375        assert_eq!(lang, "rust");
376
377        // 大小写不敏感。
378        let (lang, _, _) = parse_fence_info("JavaScript runnable");
379        assert_eq!(lang, "node");
380    }
381
382    #[test]
383    fn parse_fence_info_bun_canonical_runs_as_ts() {
384        // bun 自身是 canonical,runnable 块以 bun 执行。
385        let (lang, runnable, _) = parse_fence_info("bun runnable");
386        assert_eq!(lang, "bun");
387        assert!(runnable);
388    }
389}