Compare commits

...

2 Commits

Author SHA1 Message Date
91712b482d fix(AST): f-string 规则从上线起就没生效过;加 check:ast 把这类静默错判变成机器检查
Some checks failed
Deploy / deploy (push) Has been cancelled
## check:ast

判题机拿 target 的 node 去比 tree-sitter 节点类型,**对不上不报错**:collectNodes 一个
都收不到,于是「必须使用 X」永远失败、「不能使用 X」永远通过,两头不报错,只有学生
受着。上一个提交把两张表合成一张,杜绝了「漏配」,但「配错」照样静默 —— 所以加一个
检查,逐个 target 去问语法:这个节点类型你到底有没有。

    bun run --filter '@oj2/api' check:ast

升级 tree-sitter-* 之后必须跑:语法改节点名是常事,后果全静默。它只验节点类型存在,
不验语义对不对(把 while_loop 配成 for_statement 这种两个都存在,机器看不出来)。

## 它抓出来的那个

56 个 target 里坏了一个:Python3 的 f_string 一直配的是 format_string,而这个版本的
tree-sitter-python **根本没有这种节点** —— f-string 是一个 string,靠 string_start 为
f" 和内部的 interpolation 子节点来认。也就是说「不能使用 f-string」这条规则从上线起
就一直判成通过,「必须使用 f-string」一直判成失败。

改成 interpolation。实测:带占位符的 f-string(单双引号都有)命中,而 % 格式化、
.format()、普通字符串、字符串拼接都不误伤。代价是 f"abc" 这种没有占位符的 f-string
认不出来 —— 它确实不含 interpolation,但没占位符的 f-string 本来也没意义,比起原来
「一个都认不出来」是严格的改善。这条写在表里的注释上了。

## 验证

给题目 1004 配「必须有 for 循环 + 不能用 f-string」两条规则实跑:

- 有 for、用了 f-string → 修复前 ACCEPTED(0),修复后 AST_CHECK_FAILED(10),
  ast_results 为「必须使用 for 循环/通过」「不能使用 f-string/不通过」;
- 有 for、不用 f-string → ACCEPTED(0);
- check:ast 修复前 exit 1 并指出这一条,修复后 56 个全过、exit 0。

tsc、check:routes、vue-tsc、vite build、单二进制编译均通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
2026-09-10 19:33:19 -06:00
a6ba5cdf07 refactor(AST): 两张 target 表合成一张,加节点类型漏配在结构上不再可能
契约的 AST_NODE_TARGETS_BY_LANGUAGE 是 target → 中文名,judge/ast.ts 的 mappings 是
target → tree-sitter 节点类型,同一批键分在两个包里,靠一句「两边必须同增同减」的注释
维持。只加一边是静默错判:老师给 C 题选到只有 Python 有的 list_comprehension,判题机
拿裸名去比节点类型,C 的语法树里永远不存在它,于是「必须使用列表推导式」永远失败、
「不能使用 f-string」永远通过,两头都不报错,只有学生受着。

现在一个 target 一条 { label, node }:label 给后台下拉和题目页,node 给判题机。加 target
而漏配节点类型在结构上就不可能了。judge/ast.ts 的 mappings 整张删掉,解析统一走契约的
astTargetNodeType()(节点查 node,运算符查运算符表 —— 那张表的值本身就是要比的 token,
判题机原来抄的 and→&& 三条取值逐个相同,纯属重复)。

顺带把同一份数据的四份拷贝收成一份:C 的 14 条原来在契约和判题机里各抄了两遍
(C 一份、C++ 一份),现在 C++ 逐条引用 C_NODE_TARGETS;运算符表的 C++ 改成
{ ...C_OPERATOR_TARGETS, "<<", ">>" }。C++ 那几条仍逐条列出而不是 spread,是为了保住
下拉框的显示顺序(C++ 独有的几条插在中间)。

## 验证

行为零变化,是逐个 target 机械比对过的:把 HEAD 版的两张表原样取出来,对三种语言的
全部 target 比对「label / 运算符文案 / tree-sitter 解析结果 / 下拉框顺序」四项 ——
C 37 个、C++ 47 个、Python3 43 个,全部一致,键集与顺序也一致。

实跑:给题目 1004 配两条 Python3 规则(必须有 for 循环、不能用 f-string),交一发没有
for 循环的正确答案,判成 AST_CHECK_FAILED(10),statistic_info.ast_results 为
「必须使用 for 循环 / 不通过」「不能使用 f-string / 通过」—— label 与 node 两半都走到了。
再交一发带 for 循环的,判成 ACCEPTED(0)。

tsc、vue-tsc、vite build、单二进制编译均通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
2026-09-10 19:30:35 -06:00
6 changed files with 238 additions and 196 deletions

View File

@@ -51,6 +51,7 @@ bun run dev # api(3000) + worker + web(5173) 一起起
```bash
bun run --filter '@oj2/api' typecheck # 后端类型检查
bun run --filter '@oj2/api' check:routes # 路由遮蔽检查,加完路由跑一下
bun run --filter '@oj2/api' check:ast # AST 节点类型检查,升级 tree-sitter 后跑
cd apps/web && bun run type-check # 前端类型检查
cd apps/web && bun run build # 前端构建
```
@@ -147,16 +148,31 @@ query 里的筛选值要和收窄过的列比较时走 `routes/helpers.ts` 的 `
唯一还留着 `parse` 的地方是 `judge/events.ts``parseSubmissionEvent` ——
那是从 Redis 收回来的报文,真边界,且失败返回 `null` 而不是 500。
### AST 代码规则有两张表,必须同增同减
### AST 代码规则:一张表,外加一个机器检查
契约的 `AST_NODE_TARGETS_BY_LANGUAGE`target → 中文名)决定后台下拉能选什么,
`apps/api/src/judge/ast.ts``mappings`target → tree-sitter 节点类型)决定判题机
认得什么。**加节点类型时两边都要加**运算符表 `AST_OPERATOR_TARGETS_BY_LANGUAGE` 同理
契约的 `AST_NODE_TARGETS_BY_LANGUAGE` 是**唯一**一张表,一个 target 一条
`{ label, node }``label` 给后台下拉和题目页,`node` 给判题机比 tree-sitter 节点类型
运算符表 `AST_OPERATOR_TARGETS_BY_LANGUAGE` 一份两用(它的值既是文案又是要比的 token
判题机侧没有第二张表,解析统一走契约的 `astTargetNodeType()`
只加一边是**静默错判**:判题机 `mapping[target] ?? target` 拿裸名去比节点类型,
C 的语法树里永远不存在 `list_comprehension`,于是「必须使用列表推导式」永远失败、
「不能使用 f-string」永远通过两头都不报错只有学生受着。原来那张表是 C/Python
混在一起的 15 条,整份铺成下拉,给 C 题也能选到 Python 专有节点——就是这么来的。
> 这里原来是两张表:契约那张 target → 中文名,`judge/ast.ts` 的 `mappings` 是
> target → 节点类型,靠一句「两边必须同增同减」的注释维持。**加 target 而漏配节点类型
> 现在在结构上不可能了**,那条注释也就不必再守。
但**配错**仍然可能,而且完全静默:节点类型对不上就是一个都收不到,于是「必须使用 X」
永远失败、「不能使用 X」永远通过两头不报错只有学生受着。所以有
```bash
bun run --filter '@oj2/api' check:ast # 每个 target 的 node 在语法里是否真实存在
```
**升级 `tree-sitter-*` 依赖之后一定要跑一次** —— 语法改节点名是常事,后果全静默。
加这个检查那天56 个 target 里就抓出一个:`f_string` 一直配的是 `format_string`
而这个版本的 tree-sitter-python 根本没有这种节点f-string 是 `string` 里带
`interpolation`),所以「不能使用 f-string」从上线起就没生效过。
它只验节点类型**存在**,不验语义对不对(把 `while_loop` 配成 `for_statement`
这种两个都存在,机器看不出来),语义那层还是得实跑。
判题机只认 `AST_SUPPORTED_LANGUAGES` 里的语言C / C++ / Python3。别的语言配了规则
一条都不会跑,所以后台不给它们开 tab题目页也不把它们的规则展示成「要求」——

View File

@@ -14,6 +14,7 @@
"backfill:problemsets": "bun src/main.ts backfill-problemsets",
"typecheck": "tsc --noEmit",
"check:routes": "bun src/scripts/check-route-shadowing.ts",
"check:ast": "bun src/scripts/check-ast-targets.ts",
"db:pull": "drizzle-kit pull",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate"

View File

@@ -4,6 +4,7 @@ import {
astNodeLabel,
astOperatorLabel,
astRuleIsMeaningful,
astTargetNodeType,
astRuleSchema,
AST_SUPPORTED_LANGUAGES,
type AstRequirement,
@@ -31,91 +32,11 @@ export interface AstResult {
actual?: number
}
/**
* target → tree-sitter 节点类型。恒等的条目(`+`、`==` 这些运算符)不列,
* 走 `mapping[target] ?? target` 回落。
*
* **这里的键集是契约 AST_NODE_TARGETS_BY_LANGUAGE 的另一半**,两边必须同增同减:
* 那边决定后台下拉能选什么,这边决定判题机认得什么。只加一边就是静默错判。
*/
const mappings: Record<string, Record<string, string>> = {
C: {
for_loop: "for_statement",
while_loop: "while_statement",
do_while: "do_statement",
if_statement: "if_statement",
else_clause: "else_clause",
break: "break_statement",
continue: "continue_statement",
function_definition: "function_definition",
return: "return_statement",
switch_statement: "switch_statement",
case_statement: "case_statement",
assignment: "assignment_expression",
struct: "struct_specifier",
include: "preproc_include",
and: "&&",
or: "||",
not: "!",
},
"C++": {
// C 的那 14 条原样通用tree-sitter-cpp 继承 tree-sitter-c 的语法)
for_loop: "for_statement",
while_loop: "while_statement",
do_while: "do_statement",
if_statement: "if_statement",
else_clause: "else_clause",
break: "break_statement",
continue: "continue_statement",
function_definition: "function_definition",
return: "return_statement",
switch_statement: "switch_statement",
case_statement: "case_statement",
assignment: "assignment_expression",
struct: "struct_specifier",
include: "preproc_include",
// C++ 独有
range_for_loop: "for_range_loop",
class_definition: "class_specifier",
try_except: "try_statement",
throw: "throw_statement",
namespace: "namespace_definition",
template: "template_declaration",
lambda: "lambda_expression",
using: "using_declaration",
and: "&&",
or: "||",
not: "!",
},
Python3: {
for_loop: "for_statement",
while_loop: "while_statement",
if_statement: "if_statement",
else_clause: "else_clause",
elif_clause: "elif_clause",
break: "break_statement",
continue: "continue_statement",
function_definition: "function_definition",
return: "return_statement",
try_except: "try_statement",
with_statement: "with_statement",
list_comprehension: "list_comprehension",
list_literal: "list",
dict_literal: "dictionary",
set_literal: "set",
f_string: "format_string",
import: "import_statement",
import_from: "import_from_statement",
assignment: "assignment",
class_definition: "class_definition",
},
}
let initPromise: Promise<void> | undefined
const languages = new Map<string, Language>()
async function loadLanguage(language: string) {
if (!(language in mappings)) return null
if (!AST_SUPPORTED_LANGUAGES.includes(language)) return null
// locateFile 指到内嵌的 tree-sitter.wasmemscripten 默认按脚本所在目录找,
// 单二进制里那个目录是 /$bunfs/root它自己找不着
if (!initPromise) initPromise = Parser.init({ locateFile: () => treeSitterWasmPath })
@@ -240,7 +161,7 @@ export function astRequirements(value: unknown): AstRequirements | null {
/**
* AST 规则的语义校验。zod 只管形状engine 在枚举里、min 是整数),管不了
* 「给 C 题选了只有 Python 才有的 list_comprehension」这类组合 —— 那种规则存得进去,
* 判题时 `mapping[target] ?? target` 拿裸名去比节点类型,永远失败或永远通过,
* 判题时 astTargetNodeType() 找不到就回落成裸名去比节点类型,永远失败或永远通过,
* 两头都不报错,只有学生受着。
*
* 放这儿而不是 astRulesSchema 的 refine 上:那个 schema 同时用于**读**后台题目详情,
@@ -345,14 +266,9 @@ function methodCalls(root: Node, target: string, language: string) {
})
}
function evaluateRule(
root: Node,
rule: AstRule,
language: string,
mapping: Record<string, string>,
): AstResult | null {
function evaluateRule(root: Node, rule: AstRule, language: string): AstResult | null {
const target = rule.target ?? ""
const nodeType = mapping[target] ?? target
const nodeType = astTargetNodeType(target, language)
switch (rule.engine) {
case "must_exist_node":
@@ -409,8 +325,8 @@ function evaluateRule(
case "must_have_nesting": {
const outer = rule.outer ?? ""
const inner = rule.inner ?? ""
const outerType = mapping[outer] ?? outer
const innerType = mapping[inner] ?? inner
const outerType = astTargetNodeType(outer, language)
const innerType = astTargetNodeType(inner, language)
const passed = collectNodes(root, outerType).some((node) =>
node.children.some((child) => hasNode(child, innerType)),
)
@@ -437,10 +353,9 @@ export async function checkAst(
const tree = parser.parse(code)
if (!tree) return { passed: true, results: [] }
try {
const mapping = mappings[language] ?? {}
const results = rules
.filter(astRuleIsMeaningful)
.map((rule) => evaluateRule(tree.rootNode, rule, language, mapping))
.map((rule) => evaluateRule(tree.rootNode, rule, language))
.filter((result): result is AstResult => result !== null)
return { passed: results.every((result) => result.passed), results }
} finally {

View File

@@ -0,0 +1,72 @@
/**
* 检查契约里每个 AST target 的 `node` 在对应语言的语法里真实存在。
*
* bun run --filter '@oj2/api' check:ast
*
* ## 为什么需要这个
*
* 判题机拿 `node` 去比 tree-sitter 的节点类型,**对不上不会报错**collectNodes
* 一个都收不到,于是「必须使用 X」永远失败、「不能使用 X」永远通过。两头都不报错
* 只有学生受着 —— 他明明写了 f-string「不能使用 f-string」却judge成通过。
*
* 这正是本仓库真实踩过的坑:`f_string` 一直配的是 `format_string`,而这个版本的
* tree-sitter-python 里根本没有这种节点f-string 是 `string` 里带 `interpolation`
* 所以那条规则从上线起就没生效过。加这个检查那天56 个 target 里就它一个是坏的。
*
* 升级 tree-sitter-* 依赖之后一定要跑一次:语法改个节点名是很常见的事,
* 而它造成的故障完全静默。
*
* 只验节点类型**存在**,不验语义对不对(比如把 `while_loop` 配成 `for_statement`
* 这种,语法里两个都存在,机器看不出来)。语义那一层还是得靠实跑。
*/
import { AST_NODE_TARGETS_BY_LANGUAGE } from "@oj2/contract"
import { Language, Parser } from "web-tree-sitter"
import cWasmPath from "tree-sitter-c/tree-sitter-c.wasm" with { type: "file" }
import cppWasmPath from "tree-sitter-cpp/tree-sitter-cpp.wasm" with { type: "file" }
import pythonWasmPath from "tree-sitter-python/tree-sitter-python.wasm" with { type: "file" }
import treeSitterWasmPath from "web-tree-sitter/web-tree-sitter.wasm" with { type: "file" }
const WASM_BY_LANGUAGE: Record<string, string> = {
C: cWasmPath,
"C++": cppWasmPath,
Python3: pythonWasmPath,
}
await Parser.init({ locateFile: () => treeSitterWasmPath })
let checked = 0
const missing: Array<{ language: string; target: string; node: string }> = []
for (const [language, table] of Object.entries(AST_NODE_TARGETS_BY_LANGUAGE)) {
const wasmPath = WASM_BY_LANGUAGE[language]
if (!wasmPath) {
console.log(`${language} 在 AST_NODE_TARGETS_BY_LANGUAGE 里,但这个脚本没有它的语法 wasm`)
console.log(` 加语言时记得同步 WASM_BY_LANGUAGE 和 judge/ast.ts 的 loadLanguage`)
process.exit(2)
}
const loaded = await Language.load(wasmPath)
// 语法里声明过的全部节点类型名
const declared = new Set<string>()
for (let id = 0; id < loaded.nodeTypeCount; id++) {
const name = loaded.nodeTypeForId(id)
if (name) declared.add(name)
}
for (const [target, entry] of Object.entries(table)) {
checked++
if (!declared.has(entry.node)) missing.push({ language, target, node: entry.node })
}
}
console.log(`检查了 ${checked} 个 AST target 的节点类型`)
if (missing.length === 0) {
console.log("✓ 每个 target 的 node 都在对应语言的语法里真实存在")
process.exit(0)
}
for (const { language, target, node } of missing) {
console.log(`\n⚠ ${language}${target} → "${node}"`)
console.log(` 这个节点类型在语法里不存在,规则永远失败(或永远通过),且不报错`)
console.log(` 改法:在 packages/contract/src/problem.ts 把它的 node 改成语法里真实的名字`)
}
process.exit(1)

View File

@@ -71,7 +71,7 @@ const ENGINE_OPTIONS: SelectOption[] = [
// (或者反过来,「不能使用 f-string」永远通过两头都不报错。
function nodeTargetOptions(lang: string): SelectOption[] {
return Object.entries(AST_NODE_TARGETS_BY_LANGUAGE[lang] ?? {}).map(
([value, label]) => ({ label, value }),
([value, entry]) => ({ label: entry.label, value }),
)
}
@@ -173,7 +173,7 @@ function getTargetLabel(
engine: string,
target: string,
): string | undefined {
if (isNodeEngine(engine)) return AST_NODE_TARGETS_BY_LANGUAGE[lang]?.[target]
if (isNodeEngine(engine)) return AST_NODE_TARGETS_BY_LANGUAGE[lang]?.[target]?.label
// 运算符不写 label判题结果的文案按语言翻译astOperatorLabel
// 存一个固定 label 反而会把 C 的 && 钉死成 and
return undefined

View File

@@ -110,109 +110,133 @@ export const astRuleSchema = z.object({
export const astRulesSchema = z.record(z.string(), z.array(astRuleSchema))
/**
* 每种语言支持哪些节点 target以及它的中文名。原来这里是一张 15 条的混合表,
* C 和 Python 的节点混在一起铺成后台下拉(更早之前 AstRulesEditor.vue 和
* ProblemContent.vue 还各手抄了一份)。
* 一个 target 一条,`label` 给人看(后台下拉、题目页的「要求」),`node` 给判题机
* 拿去比 tree-sitter 的节点类型。
*
* **键必须和 judge/ast.ts 的 mappings 逐一对齐** —— 那边是 target → tree-sitter
* 节点类型,这边是 target → 中文名。少一边就是静默故障:老师给 C 题选到只有
* Python 有的 `list_comprehension`,判题机 `mapping[target] ?? target` 拿裸名去比
* 节点类型C 的语法树里永远不存在它,于是「必须使用列表推导式」永远失败、
* 「不能使用 f-string」永远通过两头都不报错。
* **这两半原来是分在两个包里的两张表** —— 这边 target → 中文名,
* `apps/api/src/judge/ast.ts` 的 mappings 是 target → tree-sitter 节点类型,靠一句
* 「两边必须同增同减」的注释维持。只加一边是静默错判:老师给 C 题选到只有 Python
* 有的 `list_comprehension`,判题机 `mapping[target] ?? target` 拿裸名去比节点类型,
* C 的语法树里永远不存在它,于是「必须使用列表推导式」永远失败、「不能使用
* f-string」永远通过两头都不报错只有学生受着。并成一张之后加 target 而漏配
* 节点类型在结构上就不可能了。
*
* (更早之前 AstRulesEditor.vue 和 ProblemContent.vue 还各手抄过一份中文名,
* 且 C 和 Python 的节点混在一张 15 条的表里铺成后台下拉。)
*/
export const AST_NODE_TARGETS_BY_LANGUAGE: Record<string, Record<string, string>> = {
C: {
for_loop: "for 循环",
while_loop: "while 循环",
do_while: "do-while 循环",
if_statement: "if 条件",
else_clause: "else 子句",
switch_statement: "switch 语句",
case_statement: "case 分支",
break: "break 语句",
continue: "continue 语句",
return: "return 语句",
function_definition: "函数定义",
assignment: "赋值语句",
struct: "结构体",
include: "#include 指令",
},
// tree-sitter-cpp 继承 tree-sitter-c 的语法C 那 14 条 target 在 C++ 树里
// 逐个实测通用所以这张表是「C 的全集 + C++ 独有的几条」
export interface AstNodeTarget {
/** 后台下拉和题目页展示的中文名 */
label: string
/** tree-sitter 里对应的节点类型,判题机按它 collectNodes */
node: string
}
const C_NODE_TARGETS = {
for_loop: { label: "for 循环", node: "for_statement" },
while_loop: { label: "while 循环", node: "while_statement" },
do_while: { label: "do-while 循环", node: "do_statement" },
if_statement: { label: "if 条件", node: "if_statement" },
else_clause: { label: "else 子句", node: "else_clause" },
switch_statement: { label: "switch 语句", node: "switch_statement" },
case_statement: { label: "case 分支", node: "case_statement" },
break: { label: "break 语句", node: "break_statement" },
continue: { label: "continue 语句", node: "continue_statement" },
return: { label: "return 语句", node: "return_statement" },
function_definition: { label: "函数定义", node: "function_definition" },
assignment: { label: "赋值语句", node: "assignment_expression" },
struct: { label: "结构体", node: "struct_specifier" },
include: { label: "#include 指令", node: "preproc_include" },
} satisfies Record<string, AstNodeTarget>
export const AST_NODE_TARGETS_BY_LANGUAGE: Record<string, Record<string, AstNodeTarget>> = {
C: C_NODE_TARGETS,
/**
* tree-sitter-cpp 继承 tree-sitter-c 的语法C 那 14 条在 C++ 树里逐个实测通用,
* 所以共用的条目一律**引用** C_NODE_TARGETS 而不是抄一遍 —— 原来 C 的 14 行在
* 契约和判题机两个文件里各抄了两遍C 一份、C++ 一份),同一份数据四份拷贝。
* 这里逐条列出来是为了保住下拉框的显示顺序C++ 独有的几条是插在中间的)。
*/
"C++": {
for_loop: "for 循环",
range_for_loop: "范围 for 循环",
while_loop: "while 循环",
do_while: "do-while 循环",
if_statement: "if 条件",
else_clause: "else 子句",
switch_statement: "switch 语句",
case_statement: "case 分支",
break: "break 语句",
continue: "continue 语句",
return: "return 语句",
function_definition: "函数定义",
class_definition: "类定义",
struct: "结构体",
assignment: "赋值语句",
include: "#include 指令",
try_except: "try-catch",
throw: "throw 语句",
namespace: "namespace 定义",
template: "模板定义",
lambda: "lambda 表达式",
using: "using 声明",
for_loop: C_NODE_TARGETS.for_loop,
range_for_loop: { label: "范围 for 循环", node: "for_range_loop" },
while_loop: C_NODE_TARGETS.while_loop,
do_while: C_NODE_TARGETS.do_while,
if_statement: C_NODE_TARGETS.if_statement,
else_clause: C_NODE_TARGETS.else_clause,
switch_statement: C_NODE_TARGETS.switch_statement,
case_statement: C_NODE_TARGETS.case_statement,
break: C_NODE_TARGETS.break,
continue: C_NODE_TARGETS.continue,
return: C_NODE_TARGETS.return,
function_definition: C_NODE_TARGETS.function_definition,
class_definition: { label: "类定义", node: "class_specifier" },
struct: C_NODE_TARGETS.struct,
assignment: C_NODE_TARGETS.assignment,
include: C_NODE_TARGETS.include,
try_except: { label: "try-catch", node: "try_statement" },
throw: { label: "throw 语句", node: "throw_statement" },
namespace: { label: "namespace 定义", node: "namespace_definition" },
template: { label: "模板定义", node: "template_declaration" },
lambda: { label: "lambda 表达式", node: "lambda_expression" },
using: { label: "using 声明", node: "using_declaration" },
},
Python3: {
for_loop: "for 循环",
while_loop: "while 循环",
if_statement: "if 条件",
elif_clause: "elif 子句",
else_clause: "else 子句",
break: "break 语句",
continue: "continue 语句",
return: "return 语句",
function_definition: "函数定义",
class_definition: "类定义",
assignment: "赋值语句",
try_except: "try-except",
with_statement: "with 语句",
import: "import 语句",
import_from: "from-import 语句",
list_comprehension: "列表推导式",
list_literal: "列表",
dict_literal: "字典",
set_literal: "集合",
f_string: "f-string",
for_loop: { label: "for 循环", node: "for_statement" },
while_loop: { label: "while 循环", node: "while_statement" },
if_statement: { label: "if 条件", node: "if_statement" },
elif_clause: { label: "elif 子句", node: "elif_clause" },
else_clause: { label: "else 子句", node: "else_clause" },
break: { label: "break 语句", node: "break_statement" },
continue: { label: "continue 语句", node: "continue_statement" },
return: { label: "return 语句", node: "return_statement" },
function_definition: { label: "函数定义", node: "function_definition" },
class_definition: { label: "类定义", node: "class_definition" },
assignment: { label: "赋值语句", node: "assignment" },
try_except: { label: "try-except", node: "try_statement" },
with_statement: { label: "with 语句", node: "with_statement" },
import: { label: "import 语句", node: "import_statement" },
import_from: { label: "from-import 语句", node: "import_from_statement" },
list_comprehension: { label: "列表推导式", node: "list_comprehension" },
list_literal: { label: "列表", node: "list" },
dict_literal: { label: "字典", node: "dictionary" },
set_literal: { label: "集合", node: "set" },
/**
* f-string 认的是 `interpolation``f"{x}"` 里的 `{x}`),不是 `format_string` ——
* 这个版本的 tree-sitter-python 里**没有** format_string 这种节点f-string 是
* 一个 `string`,靠 `string_start` 为 `f"` 和内部的 interpolation 子节点来认。
* 配成 format_string 的那阵子,「不能使用 f-string」从上线起就一直判成通过。
*
* 代价是 `f"abc"` 这种**没有占位符**的 f-string 认不出来(它确实不含
* interpolation。没占位符的 f-string 本来也没有意义,而且比起「一个都认不出来」
* 这已经是严格的改善。实测 `%` 格式化、`.format()`、普通字符串、字符串拼接
* 都不会误伤。
*/
f_string: { label: "f-string", node: "interpolation" },
},
}
/**
* 运算符 target → 该语言里的实际写法。逻辑名 `and` / `or` / `not` 在 C 里写作
* `&&` / `||` / `!`判题机按 mappings 翻译,文案这边也得翻 —— 否则 C 题的学生
* 看到的要求是「必须使用 and 运算符」,而 C 里根本没有 `and` 这个词。
* `&&` / `||` / `!`文案要翻(否则 C 题的学生看到「必须使用 and 运算符」,而 C 里
* 根本没有 `and` 这个词),判题机也正好拿同一个值去比节点类型 —— 所以这张表**一份
* 两用**,不像节点那样需要两个字段。原来判题机的 mappings 里还抄了一份非恒等的
* `and`→`&&` 那三条),取值逐个相同,纯属重复。
*
* 恒等的那些条目(`+`、`==` …)判题机的 mappings 里已经删掉了,走 `?? target`
* 回落到同一个值;这里保留完整列表是因为它同时是后台下拉的选项来源。
* 恒等的条目(`+`、`==` …)写全是因为这张表同时是后台下拉的选项来源。
*/
const C_OPERATOR_TARGETS = {
"+": "+", "-": "-", "*": "*", "/": "/", "%": "%",
"+=": "+=", "-=": "-=", "*=": "*=", "/=": "/=", "%=": "%=",
"++": "++", "--": "--",
"==": "==", "!=": "!=", ">": ">", ">=": ">=", "<": "<", "<=": "<=",
and: "&&", or: "||", not: "!",
"&": "&", "|": "|",
}
export const AST_OPERATOR_TARGETS_BY_LANGUAGE: Record<string, Record<string, string>> = {
C: {
"+": "+", "-": "-", "*": "*", "/": "/", "%": "%",
"+=": "+=", "-=": "-=", "*=": "*=", "/=": "/=", "%=": "%=",
"++": "++", "--": "--",
"==": "==", "!=": "!=", ">": ">", ">=": ">=", "<": "<", "<=": "<=",
and: "&&", or: "||", not: "!",
"&": "&", "|": "|",
},
C: C_OPERATOR_TARGETS,
// `<<` / `>>` 对 C++ 主要是 cout/cin 的流运算符(位移是同一个 token
"C++": {
"+": "+", "-": "-", "*": "*", "/": "/", "%": "%",
"+=": "+=", "-=": "-=", "*=": "*=", "/=": "/=", "%=": "%=",
"++": "++", "--": "--",
"==": "==", "!=": "!=", ">": ">", ">=": ">=", "<": "<", "<=": "<=",
and: "&&", or: "||", not: "!",
"&": "&", "|": "|", "<<": "<<", ">>": ">>",
},
"C++": { ...C_OPERATOR_TARGETS, "<<": "<<", ">>": ">>" },
Python3: {
"+": "+", "-": "-", "*": "*", "/": "/", "//": "//", "%": "%", "**": "**",
"+=": "+=", "-=": "-=", "*=": "*=", "/=": "/=", "%=": "%=",
@@ -230,14 +254,28 @@ export const AST_OPERATOR_TARGETS_BY_LANGUAGE: Record<string, Record<string, str
export const AST_SUPPORTED_LANGUAGES = Object.keys(AST_NODE_TARGETS_BY_LANGUAGE)
/** 全语言的节点中文名并集,只给拿不到语言的场合做回落。有语言一律走 astNodeLabel() */
export const AST_NODE_TARGET_LABELS: Record<string, string> = Object.assign(
{},
...Object.values(AST_NODE_TARGETS_BY_LANGUAGE),
export const AST_NODE_TARGET_LABELS: Record<string, string> = Object.fromEntries(
Object.values(AST_NODE_TARGETS_BY_LANGUAGE).flatMap((table) =>
Object.entries(table).map(([target, entry]) => [target, entry.label]),
),
)
export function astNodeLabel(target: string, language?: string): string {
const table = language ? AST_NODE_TARGETS_BY_LANGUAGE[language] : undefined
return table?.[target] ?? AST_NODE_TARGET_LABELS[target] ?? target
return table?.[target]?.label ?? AST_NODE_TARGET_LABELS[target] ?? target
}
/**
* target → tree-sitter 节点类型。判题机唯一的解析入口 ——
* 节点走上面那张表的 `node`,运算符走运算符表(它的值本身就是要比的 token
* 都对不上就回落到裸 target恒等的运算符 `+` / `==` 走的就是这条)。
*/
export function astTargetNodeType(target: string, language: string): string {
return (
AST_NODE_TARGETS_BY_LANGUAGE[language]?.[target]?.node ??
AST_OPERATOR_TARGETS_BY_LANGUAGE[language]?.[target] ??
target
)
}
export function astOperatorLabel(target: string, language?: string): string {