Files
OJ2/apps/web/src/oj/problem/utils/pythonError.ts
T
xuyueandClaude Opus 5.5 2a4405d2ea
Deploy / deploy (push) Canceled after 0s
feat(题目): Python 语法错误改成中文说明,在编辑器里标出位置,中文标点可以一键换成英文
中职学生看不懂英文报错。原来提交前用浏览器里的 Skulpt 拦语法错误,只弹一句
「第 N 行存在语法错误」——Skulpt 九成报的是 bad input / bad token,没有列号。

- 提交前的语法检查挪到服务端:/code/format 先用 CPython 编译一遍(只编译不执行),
  api 镜像加 python3-minimal(3.13,与判题机同版本,+17MB),前端删掉 Skulpt
- 报错按规则表翻成中文(pythonError.ts),生产库 2695 条 Python 编译错误覆盖 99.7%;
  提交前拦下的和判题回来的编译错误共用同一张卡片
- 编辑器里标红出错行和 ^ 指的位置
- 中文标点(历史编译错误的 22%)一键换成英文,跳过字符串和注释、处理 f 字符串花括号;
  594 条里修好 593 条,经 tokenize 核对没有改到字符串内容;走编辑器事务,可 Ctrl+Z

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:47:01 +08:00

275 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 把 Python 的编译错误(`statistic_info.err_info`)翻成中文。
*
* 学生是中职生,英文报错等于没有报错。好在句式非常集中:2025 秋以来 2700 条
* Python 编译错误里,前 12 种句子占 91%、前 16 种占 97%,所以这里是一张规则表,
* 不是通用翻译。第二名「invalid character」(22%)几乎全是中文标点,另有一键修复,
* 见 chinesePunctuation.ts。
*
* 判题机给的原文有两种形状:
*
* File "/judger/run/<随机目录>/solution.py", line 7
* print("{:.0f}"."{:.2f}".format(x))
* ^^^^^^^^
* SyntaxError: invalid syntax
*
* Sorry: IndentationError: unexpected indent (solution.py, line 3)
*
* 第一种的代码行是 Python 去掉**行首空格**之后再垫 4 个空格打出来的,`^` 的列号
* 按去掉之后的算(traceback 的 `_format_syntax_error`,3.10 起如此)。
*/
export interface PythonErrorExplanation {
/** 出错的行,1 起 */
line: number | null
/** 判题机打出来的那一行代码(去掉了行首空格),用来和编辑器里的对位 */
sourceLine: string | null
/** `^` 在 sourceLine 里标的范围,[from, to) */
caret: { from: number; to: number } | null
/** 给学生看的中文说明,不含行号 */
message: string
/** 报错点名的那个中文标点;有它才出「一键换成英文」 */
punctuation: string | null
}
const WHERE_RE = /^File "[^"]*", line (\d+)/m
const SHORT_RE = /^Sorry: (\w+Error): (.*) \([^,()]*, line (\d+)\)\s*$/m
/**
* 中文空格(U+3000)。写成常量而不是字面量:Oxfmt 会把字符串里的 `\u3000` 转义
* 还原成原字符,那样源码里就是一个看不见的空白。
*/
const IDEOGRAPHIC_SPACE = String.fromCharCode(0x3000)
/** 中文标点 → 英文。「。」不在表里:它多半是句末多打的,换成「.」照样错,只能删 */
export const PUNCTUATION_MAP: Record<string, string> = {
"(": "(",
")": ")",
",": ",",
":": ":",
";": ";",
"【": "[",
"】": "]",
"{": "{",
"}": "}",
"!": "!",
"?": "?",
"=": "=",
"+": "+",
"-": "-",
"*": "*",
"/": "/",
"<": "<",
">": ">",
"“": '"',
"”": '"',
"‘": "'",
"’": "'",
""": '"',
"×": "*",
"÷": "/",
[IDEOGRAPHIC_SPACE]: " ",
}
/**
* 标点的名字。说明里**只说名字、不拿字形对比**:「:」和「:」、「,」和「,」在正文字体里
* 几乎一样,「把「:」换成「:」」这句话学生看不出区别。「冒号是中文的,要换成英文的」
* 看得懂,要换哪一个由卡片里那行代码的标红来指。
*/
const PUNCTUATION_NAMES: Record<string, string> = {
"(": "左括号",
")": "右括号",
",": "逗号",
":": "冒号",
";": "分号",
"【": "左方括号",
"】": "右方括号",
"{": "左花括号",
"}": "右花括号",
"!": "感叹号",
"?": "问号",
"=": "等号",
"+": "加号",
"-": "减号",
"*": "星号",
"/": "斜杠",
"<": "小于号",
">": "大于号",
"“": "引号",
"”": "引号",
"‘": "单引号",
"’": "单引号",
""": "引号",
[IDEOGRAPHIC_SPACE]: "空格",
}
type Rule = [RegExp, (match: RegExpMatchArray) => string]
const RULES: Rule[] = [
[
/^invalid character '(.)'/,
([, char]) => {
// 数学符号不是「中文的」,是写法不对:代码里乘除用 * 和 /
if (char === "×") return "这里用了乘号「×」,代码里的乘法要写成星号「*」。"
if (char === "÷") return "这里用了除号「÷」,代码里的除法要写成斜杠「/」。"
const name = PUNCTUATION_NAMES[char!]
if (name) {
return `这里的${name}是中文输入法打出来的,要换成英文的${name}。写代码的时候先切换到英文输入法。`
}
if (char === "。") return "这里多了一个中文句号「。」,代码里不用句号,删掉它。"
return `这里有一个不能出现在代码里的字符「${char}」,多半是中文输入法打出来的,删掉后用英文输入法重新打。`
},
],
[
/^invalid non-printable character/,
() => "这里有一个看不见的特殊字符,多半是从别处复制过来的。把这一行删掉,自己重新打一遍。",
],
[
/^expected ':'/,
() => "这一行末尾少了英文冒号「:」。if、elif、else、for、while、def 这些行的最后都要有冒号。",
],
[
/^expected an indented block after '(\w+)' statement on line (\d+)/,
([, keyword, owner]) =>
`这一行要往里缩进(按一下 Tab 键):它是第 ${owner} 行那个 ${keyword} 要管的代码。`,
],
[
/^expected an indented block/,
() => "这一行要往里缩进(按一下 Tab 键):上一行以冒号结尾,它下面的代码都要缩进。",
],
[
/^unexpected indent/,
() => "这一行前面多了空格。只有 if、for 这类以冒号结尾的行,它下面的代码才要往里缩进。",
],
[
/^unindent does not match any outer indentation level/,
() =>
"这一行前面的空格数和上面对不齐。同一层的代码,前面的空格要一样多,建议统一用 Tab 键缩进。",
],
[
/^inconsistent use of tabs and spaces/,
() => "这一行的缩进混用了 Tab 键和空格键,统一只用 Tab 键缩进。",
],
[
/^invalid syntax\. Perhaps you forgot a comma\?/,
() => "这里好像少了逗号。括号里的几样东西之间,要用英文逗号「,」隔开。",
],
[
/Maybe you meant '==' instead of '='|Maybe you meant '==' or ':=' instead of '='|perhaps you meant "=="/,
() =>
"判断「是不是相等」要用两个等号「==」。一个等号「=」的意思是「把右边的值存进左边的变量」。",
],
[
/^cannot assign to /,
() =>
"等号「=」的左边只能是一个变量名,意思是「把右边算出来的值存进这个变量」。比如要写成 c = a + b,不能写成 a + b = c。",
],
[
/^unterminated (triple-quoted )?(f-)?string literal/,
() => "这一行的引号没有配对。字符串要用一对英文引号括起来,开头一个、结尾一个。",
],
[
/^'(.)' was never closed/,
([, open]) => `这里的「${open}」没有配对的另一半。数一数左右括号是不是一样多。`,
],
[/^unmatched '(.)'/, ([, close]) => `这里多了一个「${close}」,前面没有和它配对的括号。`],
[
/^closing parenthesis '(.)' does not match opening parenthesis '(.)'/,
([, close, open]) => `括号配错了:「${open}」要用和它配对的括号来关,不能用「${close}」。`,
],
[
/^Missing parentheses in call to '(\w+)'/,
([, name]) => `${name} 后面要加括号,写成 ${name}(...)。`,
],
[
/^invalid decimal literal/,
() => "数字后面紧跟着字母了。比如「2a」要写成「2*a」;变量名也不能用数字开头。",
],
[
/^unexpected character after line continuation character/,
() => "这里的反斜杠「\\」用错了。除法要用「/」,「\\」只能出现在引号里面。",
],
[
/^'(break|continue)' (not properly in|outside) loop/,
([, keyword]) => `${keyword} 只能写在 for 或 while 循环里面(要缩进在循环的下面)。`,
],
[
/^'return' outside function/,
() => "return 只能写在 def 定义的函数里面(要缩进在 def 的下面)。",
],
[
/^f-string: (expecting '}'|single '}' is not allowed)/,
() => "f 字符串里的花括号「{ }」没有配对,每个「{」都要有一个「}」。",
],
[
/^f-string: valid expression required/,
() => "f 字符串的花括号「{ }」里要写变量名或算式,不能空着。",
],
[
/^leading zeros in decimal integer literals/,
() => "整数不能用 0 开头,比如「07」要写成「7」。",
],
[
/^invalid syntax/,
() =>
"标出来的地方写法不对。对照教程检查这一行:括号、引号、冒号、运算符有没有写错、漏写或多写。",
],
]
function translate(message: string) {
for (const [pattern, render] of RULES) {
const match = message.match(pattern)
if (match) return render(match)
}
return null
}
/** 翻不出来(判题机自己的报错、没见过的句式)就返回 null,界面照旧显示原文 */
export function explainPythonCompileError(errInfo: string): PythonErrorExplanation | null {
const text = errInfo.trim()
const short = text.match(SHORT_RE)
if (short) {
const [, , detail, line] = short
const message = translate(detail!)
if (!message) return null
return {
line: Number(line),
sourceLine: null,
caret: null,
message,
punctuation: null,
}
}
const where = text.match(WHERE_RE)
const lines = text.split("\n")
const last = lines[lines.length - 1]!
const detail = last.replace(/^\w+Error: /, "")
if (detail === last) return null
const message = translate(detail)
if (!message) return null
let sourceLine: string | null = null
let caret: PythonErrorExplanation["caret"] = null
if (where) {
const index = lines.findIndex((line) => WHERE_RE.test(line))
const code = lines[index + 1]
const marks = lines[index + 2]
if (code?.startsWith(" ")) sourceLine = code.slice(4)
if (sourceLine !== null && marks?.startsWith(" ")) {
const from = marks.indexOf("^")
if (from >= 4) caret = { from: from - 4, to: marks.lastIndexOf("^") - 4 + 1 }
}
}
const char = detail.match(/^invalid character '(.)'/)?.[1]
return {
line: where ? Number(where[1]) : null,
sourceLine,
caret,
message,
punctuation: char && PUNCTUATION_MAP[char] ? char : null,
}
}