feat(题目): Python 语法错误改成中文说明,在编辑器里标出位置,中文标点可以一键换成英文
Deploy / deploy (push) Canceled after 0s

中职学生看不懂英文报错。原来提交前用浏览器里的 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>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-27 21:47:01 +08:00
1 parent 039992dc86
commit 2a4405d2ea
16 files changed
+733 -100

No files matched your search

@@ -0,0 +1,94 @@
import { PUNCTUATION_MAP } from "./pythonError"
export interface PunctuationFix {
from: number
to: number
insert: string
}
/**
* 找出 Python 代码里**字符串和注释之外**的中文标点,给出换成英文的改动。
*
* 字符串里的中文标点是合法的,`print("你好,世界")` 里那个逗号动了就改了输出,
* 所以不能整篇替换。中文引号「“ ”」另算:它们出现在代码里,本身就是没切输入法
* 打出来的字符串引号,要换成英文引号,而**它们中间的内容原样保留** —— 否则
* `print(“你好,世界”)` 会被改成 `print("你好,世界")`,输出跟着变了。
*
* f 字符串花括号里的是代码不是文字(`f"{s:.2f}"`、`f"{len(a)}"`),照样要换;
* `{{` 是转义出来的字面花括号,不算。拿生产库 591 条中文标点的编译错误核过:
* 修完之后报错点名的那个字符还在的只剩 1 条,是学生自己的引号就是乱的。
*
* 不是完整的词法分析:只认引号、三引号、字符串前缀、反斜杠转义和 `#` 注释,
* 这对学生代码够用。单引号字符串遇到换行就当它结束,免得一个没配对的引号把
* 后面整篇都当成字符串。
*/
export function findChinesePunctuation(code: string): PunctuationFix[] {
const fixes: PunctuationFix[] = []
function fix(at: number) {
const english = PUNCTUATION_MAP[code[at]!]
if (english) fixes.push({ from: at, to: at + 1, insert: english })
}
/** 从 start 往后扫一个字符串的内容,返回结束位置(收尾引号之后) */
function skipString(start: number, triple: boolean, fstring: boolean, closers: string[]) {
let depth = 0
let j = start
while (j < code.length) {
const ch = code[j]!
if (ch === "\\") {
j += 2
continue
}
if (!triple && ch === "\n") return j
if (fstring) {
if (ch === "{" && depth === 0 && code[j + 1] === "{") {
j += 2
continue
}
if (ch === "{") depth++
else if (ch === "}" && depth > 0) depth--
else if (depth > 0) fix(j)
if (depth > 0 || ch === "{" || ch === "}") {
j++
continue
}
}
const closer = closers.find((c) => code.startsWith(c, j))
if (closer) {
// 中文收尾引号也要换成英文
if (closer.length === 1) fix(j)
return j + closer.length
}
j++
}
return j
}
let i = 0
while (i < code.length) {
const ch = code[i]!
if (ch === "#") {
const end = code.indexOf("\n", i)
i = end === -1 ? code.length : end
continue
}
if (ch === '"' || ch === "'") {
// 前缀是紧挨着引号的 f / r / b / u 组合,前面不能再连着别的标识符字符
const prefix = code.slice(0, i).match(/(?<![\w])[rRbBfFuU]{1,2}$/)?.[0] ?? ""
const quote = code.startsWith(ch.repeat(3), i) ? ch.repeat(3) : ch
i = skipString(i + quote.length, quote.length === 3, /f/i.test(prefix), [quote])
continue
}
if (ch === "“" || ch === "”" || ch === "‘" || ch === "’") {
const double = ch === "“" || ch === "”"
fix(i)
// 收尾可能打成中文也可能打成英文,两种都认
i = skipString(i + 1, false, false, double ? ["”", "“", '"'] : ["’", "‘", "'"])
continue
}
fix(i)
i++
}
return fixes
}
+123
View File
@@ -0,0 +1,123 @@
import { StateEffect, StateField, type EditorState } from "@codemirror/state"
import { Decoration, EditorView, ViewPlugin, type DecorationSet } from "@codemirror/view"
import type { PunctuationFix } from "./chinesePunctuation"
/**
* 在题目页的编辑器里标出编译错误的位置:整行淡红底,`^` 标的那一段加红波浪线。
*
* 中职学生读不懂英文报错,「看这里」比任何文字都直接。标记跟着编辑映射位置
* (在上面插一行,标记跟着往下走),下一次提交时清掉。
*/
interface Mark {
line: number
/** 行内列号,[from, to);没有就只标整行 */
from?: number
to?: number
}
const setMark = StateEffect.define<Mark | null>()
const lineDeco = Decoration.line({ class: "cm-error-line" })
const rangeDeco = Decoration.mark({ class: "cm-error-range" })
function build(state: EditorState, mark: Mark): DecorationSet {
if (mark.line < 1 || mark.line > state.doc.lines) return Decoration.none
const line = state.doc.line(mark.line)
const ranges = [lineDeco.range(line.from)]
if (mark.from !== undefined && mark.to !== undefined) {
const from = line.from + Math.min(mark.from, line.length)
const to = line.from + Math.min(mark.to, line.length)
if (to > from) ranges.push(rangeDeco.range(from, to))
}
return Decoration.set(ranges)
}
const markField = StateField.define<DecorationSet>({
create: () => Decoration.none,
update(deco, tr) {
deco = deco.map(tr.changes)
for (const effect of tr.effects) {
if (effect.is(setMark)) deco = effect.value ? build(tr.state, effect.value) : Decoration.none
}
return deco
},
provide: (field) => EditorView.decorations.from(field),
})
/**
* 题目页当前那个编辑器。结果面板和编辑器是兄弟组件,隔着好几层,
* 与其一路透传 ref,不如让编辑器挂载时自己登记。只挂在题目页主编辑器上
* (ProblemEditor 的扩展数组),自测、流程图那些编辑器不登记。
*/
let current: EditorView | null = null
const tracker = ViewPlugin.define((view) => {
current = view
return {
destroy() {
if (current === view) current = null
},
}
})
const theme = EditorView.baseTheme({
".cm-error-line": { backgroundColor: "rgba(208, 48, 80, 0.12)" },
".cm-error-range": {
textDecoration: "underline wavy #d03050",
textUnderlineOffset: "3px",
backgroundColor: "rgba(208, 48, 80, 0.18)",
},
})
export const errorMarkExtensions = [markField, tracker, theme]
/**
* 标出第 `line` 行并滚过去。`sourceLine` 是报错里打出来的那一行(去了行首空格),
* 编辑器里这一行对得上才标列范围 —— 学生看结果之前可能已经改过代码了,
* 对不上时列号没有意义,只标整行。
*/
export function showErrorMark(
line: number,
sourceLine: string | null,
caret: { from: number; to: number } | null,
) {
const view = current
if (!view || line < 1 || line > view.state.doc.lines) return
const text = view.state.doc.line(line).text
const mark: Mark = { line }
if (caret && sourceLine !== null) {
const stripped = text.replace(/^ +/, "")
if (stripped.trimEnd() === sourceLine.trimEnd()) {
const indent = text.length - stripped.length
mark.from = indent + caret.from
mark.to = indent + caret.to
}
}
const pos = view.state.doc.line(line).from
view.dispatch({
effects: [setMark.of(mark), EditorView.scrollIntoView(pos, { y: "center" })],
})
}
export function clearErrorMark() {
current?.dispatch({ effects: setMark.of(null) })
}
/** 当前编辑器里的代码;编辑器没挂载时为 null */
export function currentCode() {
return current?.state.doc.toString() ?? null
}
/**
* 在编辑器里批量替换。走编辑器事务而不是直接改 store 里的字符串:
* 这样一次 Ctrl+Z 就能撤回,协作中的对方也能同步到。
*
* 不打 userEvent:editTrace 按 `isUserEvent("input")` 数「自己敲的字」,
* 打成 `input.*` 就会把这次替换算成学生手打的。
*/
export function applyFixes(fixes: PunctuationFix[]) {
if (!current || !fixes.length) return false
current.dispatch({ changes: fixes, effects: setMark.of(null) })
return true
}
@@ -0,0 +1,274 @@
/**
* 把 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,
}
}
@@ -1,53 +0,0 @@
export interface PythonSyntaxError {
line: number
}
let skulptPromise: Promise<any> | null = null
/**
* 按需加载 Skulpt(约 233KB gzip),只在提交 Python 代码时才下载。
* 结果缓存,同一页面只加载一次。
*/
function loadSkulpt(): Promise<any> {
if (!skulptPromise) {
// @ts-ignore - skulpt has no type definitions
skulptPromise = import("skulpt").then((m) => m.default ?? m)
}
return skulptPromise
}
/**
* 提前把 Skulpt 拉下来,避免点提交时才开始下载。
* 失败不影响功能,提交时会再试一次。
*/
export function prefetchPythonSyntaxChecker() {
loadSkulpt().catch(() => {
skulptPromise = null
})
}
/**
* 用 Skulpt 检测 Python 代码中的语法错误。
* 只编译不执行,不受 input() 等 IO 调用影响。
* 加载失败时返回 null(放行提交),交给后端判题兜底。
*/
export async function checkPythonSyntax(code: string): Promise<PythonSyntaxError | null> {
let Sk: any
try {
Sk = await loadSkulpt()
} catch {
skulptPromise = null
return null
}
Sk.configure({ output: () => {} })
try {
Sk.compile(code, "prog.py", "exec")
return null
} catch (e) {
// Skulpt 的语法错误对象,不是 Error 的子类
const traceback = (e as { traceback?: { lineno?: number }[] }).traceback
const line = traceback?.[0]?.lineno ?? 1
return { line }
}
}