From 60e96f311eec6420a28cd20fa3e4323086aae0fe Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Tue, 13 Oct 2026 20:27:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E4=BB=A3=E7=A0=81=E9=85=8D=E8=89=B2):=20?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E9=87=8C=E7=9A=84=E5=85=A8=E8=A7=92=E6=A0=87?= =?UTF-8?q?=E7=82=B9=EF=BC=88print=EF=BC=88=E2=80=9C=E4=BD=A0=E5=A5=BD?= =?UTF-8?q?=E2=80=9D=EF=BC=89=E3=80=81=EF=BC=81=3D=E3=80=81=E5=85=A8?= =?UTF-8?q?=E8=A7=92=E7=A9=BA=E6=A0=BC=EF=BC=89=E6=A0=87=E7=BA=A2=E5=8A=A0?= =?UTF-8?q?=E6=B3=A2=E6=B5=AA=E7=BA=BF=EF=BC=9BPython=20=E7=9A=84=20int=20?= =?UTF-8?q?/=20str=20/=20list=20=E4=B8=80=E5=BE=8B=E9=9D=92=E7=BB=BF?= =?UTF-8?q?=E8=89=B2=EF=BC=8Cprint=20=E4=B8=80=E5=BE=8B=E6=98=AF=E5=87=BD?= =?UTF-8?q?=E6=95=B0=E8=89=B2=EF=BC=8C"%.f"=20=E4=B9=9F=E8=AE=A4=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 12 万条 Python 提交里,代码里带全角标点的 1947 条全部编译出错,占 Python 编译错误的五分之一, 原来的配色下 “你好” 被认成变量名,看着和正常代码一样。 int 原来是 int(input()) 里棕色、map(int, …) 里深蓝;print 碰上 print"yes"、print(c) 会变成 关键字蓝。现在都照 VS Code。C 的解析器碰到全角标点会把后半行当成没有引号的「字符串」, 这种不算字符串,里面的全角标点照样标。 补的这几种标记合在 shared/codeMarks.ts 一处(原 formatPlaceholder.ts),编辑器和静态代码共用; Python 解析器的补丁挪到 extensions/pythonLanguage.ts,免得 language → codeMarks → highlight 绕圈。 Co-Authored-By: Claude Opus 5.5 --- apps/web/src/shared/codeMarks.ts | 132 ++++++++++++++++++ apps/web/src/shared/extensions/codeMarks.ts | 67 +++++++++ .../shared/extensions/formatPlaceholder.ts | 45 ------ apps/web/src/shared/extensions/language.ts | 24 +--- .../src/shared/extensions/pythonLanguage.ts | 23 +++ apps/web/src/shared/formatPlaceholder.ts | 27 ---- apps/web/src/shared/highlight.css | 24 +++- apps/web/src/shared/highlight.ts | 31 ++-- 8 files changed, 262 insertions(+), 111 deletions(-) create mode 100644 apps/web/src/shared/codeMarks.ts create mode 100644 apps/web/src/shared/extensions/codeMarks.ts delete mode 100644 apps/web/src/shared/extensions/formatPlaceholder.ts create mode 100644 apps/web/src/shared/extensions/pythonLanguage.ts delete mode 100644 apps/web/src/shared/formatPlaceholder.ts diff --git a/apps/web/src/shared/codeMarks.ts b/apps/web/src/shared/codeMarks.ts new file mode 100644 index 0000000..bf6fc94 --- /dev/null +++ b/apps/web/src/shared/codeMarks.ts @@ -0,0 +1,132 @@ +/** + * 在 lezer 分好的词上再补的几种标记。lezer 给不了、但学生的代码里很常见,所以另外扫一遍。 + * 静态代码在 shared/highlight.ts 里拆 span,编辑器在 shared/extensions/codeMarks.ts 里挂装饰, + * 两边都只调 extraMarks(),颜色才一样。 + * + * - 字符串里的格式占位符:printf 的 %d、%.2f、%%,Python 的 "%d 个" % n。 + * 正则和颜色照 VS Code 的语法文件:C 是 constant.other.placeholder(变量色), + * Python 是 constant.character.format.placeholder(关键字色),所以两种语言类名不同。 + * 写错的(%,2f、%。1f、%S)故意不认,不变色正好提醒学生。 + * - 字符串和注释之外的全角标点(`print(“你好”)`、`!=`、全角空格):标红加波浪线。 + * 12 万条 Python 提交里有 1947 条是这样,全部编译出错,占 Python 编译错误的五分之一, + * 不标的话 `“你好”` 会被认成变量名,看着和正常代码一样。 + * - Python 的内置类型(int、str、list…):照 VS Code 一律是类型色。lezer 只按位置分, + * `int(input())` 里是函数、`map(int, …)` 里是变量,同一行两个颜色。 + */ +import type { syntaxTree } from "@codemirror/language" + +type Tree = ReturnType + +export type CodeFamily = "c" | "python" + +export interface Mark { + from: number + to: number + cls: string +} + +const FORMAT: Record = { + c: /%(\d+\$)?[#0\- +']*(-?\d+|\*)?(\.(-?\d+|\*)?)?(hh|h|ll|l|j|t|z|L)?[diouxXeEfFgGaAcspn%]/g, + python: /%(\([\w\s]*\))?[-+#0 ]*(\d+|\*)?(\.(\d+|\*)?)?[hlL]?[diouxXeEfFgGcrsa%]/g, +} + +const FORMAT_CLASS: Record = { + c: "tok-format", + python: "tok-format-py", +} + +// 全角的 ASCII(!(),:;?a1…)、全角空格、、。〈〉《》「」【】、中文引号 +const FULL_WIDTH = /[!-~ -。〈-】‘’“”]/g + +// VS Code(MagicPython)的 builtin-types +const PY_BUILTIN_TYPES = new Set([ + "bool", + "bytearray", + "bytes", + "classmethod", + "complex", + "dict", + "float", + "frozenset", + "int", + "list", + "object", + "property", + "set", + "slice", + "staticmethod", + "str", + "super", + "tuple", + "type", +]) + +function matches(text: string, re: RegExp, cls: string): Mark[] { + return [...text.matchAll(re)].map((m) => ({ from: m.index, to: m.index + m[0].length, cls })) +} + +/** + * 一个词(lezer 吐出的一段文字和它的类名)上要补的标记,位置相对 text 开头。 + * prev 是这段文字前面那个字符,用来排除 `obj.int` 这种属性 + */ +export function extraMarks( + text: string, + classes: string, + family: CodeFamily, + prev: string, +): Mark[] { + if (classes.includes("tok-string")) return matches(text, FORMAT[family], FORMAT_CLASS[family]) + if (classes.includes("tok-comment")) return [] + if ( + family === "python" && + prev !== "." && + (classes === "tok-function" || classes === "tok-variable") && + PY_BUILTIN_TYPES.has(text) + ) { + return [{ from: 0, to: text.length, cls: "tok-builtin-type" }] + } + return matches(text, FULL_WIDTH, "tok-fullwidth") +} + +/** + * 不是字符串的「字符串」:lezer 的 C 解析器碰到全角标点会把后面一截当成没有引号的 String 节点 + * (`printf("%d\\n", a);` 里的 `, a);`),整截染成字符串色、全角标点也就不标了。 + * 开头不是引号(前面可以有 u8 / L / R / f / b 这类前缀)的 String 一律不算字符串 + */ +export function fakeStrings( + tree: Tree, + read: (from: number, to: number) => string, + from = 0, + to?: number, +): { from: number; to: number }[] { + const out: { from: number; to: number }[] = [] + tree.iterate({ + from, + to, + enter: (n) => { + if (n.name === "String" && !/^[uUlLrRbBfF8]*["']/.test(read(n.from, n.from + 4))) { + out.push({ from: n.from, to: n.to }) + } + }, + }) + return out +} + +/** 按 extraMarks 把一段文字拆开,每段带上补的类名 */ +export function splitMarks( + text: string, + classes: string, + /** 判断补什么标记用的类名,平时就是 classes;假字符串(见 fakeStrings)传空 */ + marksFor: string, + family: CodeFamily, + prev: string, + put: (text: string, classes: string) => void, +) { + let at = 0 + for (const m of extraMarks(text, marksFor, family, prev)) { + if (m.from > at) put(text.slice(at, m.from), classes) + put(text.slice(m.from, m.to), classes ? `${classes} ${m.cls}` : m.cls) + at = m.to + } + if (at < text.length) put(text.slice(at), classes) +} diff --git a/apps/web/src/shared/extensions/codeMarks.ts b/apps/web/src/shared/extensions/codeMarks.ts new file mode 100644 index 0000000..0ef1ffc --- /dev/null +++ b/apps/web/src/shared/extensions/codeMarks.ts @@ -0,0 +1,67 @@ +import { syntaxTree } from "@codemirror/language" +import type { Extension, Range } from "@codemirror/state" +import { Decoration, type DecorationSet, type EditorView, ViewPlugin } from "@codemirror/view" +import { highlightTree } from "@lezer/highlight" +import { type CodeFamily, extraMarks, fakeStrings } from "../codeMarks" +import { codeHighlighter } from "../highlight" + +/** + * 编辑器里补的标记(%d、全角标点、Python 内置类型),规则见 shared/codeMarks.ts。 + * 和静态代码一样按 codeHighlighter 分出来的词逐个过 extraMarks();只扫看得见的部分。 + */ +export function codeMarks(family: CodeFamily): Extension { + const decos = new Map() + const deco = (cls: string) => { + let d = decos.get(cls) + if (!d) decos.set(cls, (d = Decoration.mark({ class: cls }))) + return d + } + + function build(view: EditorView): DecorationSet { + const tree = syntaxTree(view.state) + const ranges: Range[] = [] + for (const { from, to } of view.visibleRanges) { + const text = view.state.doc.sliceString(from, to) + const fake = fakeStrings(tree, (a, b) => view.state.doc.sliceString(a, b), from, to) + const prev = (pos: number) => (pos > from ? text[pos - from - 1] : "") + const add = (start: number, end: number, classes: string) => { + if (fake.some((r) => start >= r.from && start < r.to)) classes = "" + for (const m of extraMarks( + text.slice(start - from, end - from), + classes, + family, + prev(start), + )) { + ranges.push(deco(m.cls).range(start + m.from, start + m.to)) + } + } + // highlightTree 只吐有类名的词,中间没类名的空档(全角标点多半在这里)另外补 + let at = from + highlightTree( + tree, + codeHighlighter, + (start, end, classes) => { + if (start > at) add(at, start, "") + add(start, end, classes) + at = end + }, + from, + to, + ) + if (to > at) add(at, to, "") + } + return Decoration.set(ranges, true) + } + + return ViewPlugin.define( + (view) => ({ + decorations: build(view), + update(u) { + if (u.docChanged || u.viewportChanged || syntaxTree(u.startState) !== syntaxTree(u.state)) { + this.decorations = build(u.view) + } + }, + }), + { decorations: (v) => v.decorations }, + ) +} diff --git a/apps/web/src/shared/extensions/formatPlaceholder.ts b/apps/web/src/shared/extensions/formatPlaceholder.ts deleted file mode 100644 index 93bf9c7..0000000 --- a/apps/web/src/shared/extensions/formatPlaceholder.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { syntaxTree } from "@codemirror/language" -import { RangeSetBuilder, type Extension } from "@codemirror/state" -import { Decoration, type DecorationSet, type EditorView, ViewPlugin } from "@codemirror/view" -import { FORMAT_CLASS, type FormatFamily, formatSpans } from "../formatPlaceholder" - -/** 编辑器里给字符串中的 %d 这类占位符上色,正则见 shared/formatPlaceholder.ts。只扫看得见的部分 */ -export function formatPlaceholders(family: FormatFamily): Extension { - const mark = Decoration.mark({ class: FORMAT_CLASS[family] }) - - function build(view: EditorView): DecorationSet { - const builder = new RangeSetBuilder() - let last = 0 - for (const { from, to } of view.visibleRanges) { - syntaxTree(view.state).iterate({ - from, - to, - enter: (node) => { - if (node.name !== "String" && node.name !== "FormatString") return - // 同一个字符串跨两段可见区(中间折叠了)时只加一次,builder 要求从前往后 - if (node.from >= last) { - const text = view.state.doc.sliceString(node.from, node.to) - for (const s of formatSpans(text, family)) { - builder.add(node.from + s.from, node.from + s.to, mark) - } - last = node.to - } - return false - }, - }) - } - return builder.finish() - } - - return ViewPlugin.define( - (view) => ({ - decorations: build(view), - update(u) { - if (u.docChanged || u.viewportChanged || syntaxTree(u.startState) !== syntaxTree(u.state)) { - this.decorations = build(u.view) - } - }, - }), - { decorations: (v) => v.decorations }, - ) -} diff --git a/apps/web/src/shared/extensions/language.ts b/apps/web/src/shared/extensions/language.ts index a503c44..b0b669c 100644 --- a/apps/web/src/shared/extensions/language.ts +++ b/apps/web/src/shared/extensions/language.ts @@ -1,25 +1,11 @@ import { cpp } from "@codemirror/lang-cpp" -import { python, pythonLanguage } from "@codemirror/lang-python" +import { python } from "@codemirror/lang-python" import { sql, SQLite } from "@codemirror/lang-sql" import { LanguageSupport } from "@codemirror/language" import type { Extension } from "@codemirror/state" -import { styleTags, tags as t } from "@lezer/highlight" import type { LANGUAGE } from "utils/types" -import { formatPlaceholders } from "./formatPlaceholder" - -/** - * Python 的解析器在上色上补几处,向 VS Code 看齐:`from` / `as` / `with` 和 `if`、`import` 一样算管流程 - * 的词(lezer 把 `from` 和 `def` 归成一类);f-string 的 `{ }` 和 `:.2f` 一样是格式占位符的颜色。 - * 静态代码(shared/highlight.ts)也用这一份,编辑器和题面的颜色才一样。 - */ -export const ojPythonLanguage = pythonLanguage.configure({ - props: [ - styleTags({ - "from as with": t.controlKeyword, - "FormatReplacement/{ FormatReplacement/} FormatSpec/:": t.modifier, - }), - ], -}) +import { codeMarks } from "./codeMarks" +import { ojPythonLanguage } from "./pythonLanguage" /** * 语言对应的高亮扩展。学生端和教师端共用同一份(两边都是题目页上的 SyncCodeEditor) —— @@ -30,7 +16,7 @@ export const ojPythonLanguage = pythonLanguage.configure({ export function languageExtension(language: LANGUAGE): Extension { if (language === "SQL") return sql({ dialect: SQLite, upperCaseKeywords: true }) if (language === "Python") { - return [new LanguageSupport(ojPythonLanguage, python().support), formatPlaceholders("python")] + return [new LanguageSupport(ojPythonLanguage, python().support), codeMarks("python")] } - return [cpp(), formatPlaceholders("c")] + return [cpp(), codeMarks("c")] } diff --git a/apps/web/src/shared/extensions/pythonLanguage.ts b/apps/web/src/shared/extensions/pythonLanguage.ts new file mode 100644 index 0000000..1b470a4 --- /dev/null +++ b/apps/web/src/shared/extensions/pythonLanguage.ts @@ -0,0 +1,23 @@ +import { pythonLanguage } from "@codemirror/lang-python" +import { styleTags, tags as t } from "@lezer/highlight" + +/** + * Python 的解析器在上色上补几处,向 VS Code 看齐。编辑器(extensions/language.ts)和 + * 静态代码(shared/highlight.ts)都用这一份,两边的颜色才一样。 + * + * - `from` / `as` / `with` 和 `if`、`import` 一样算管流程的词(lezer 把 `from` 和 `def` 归成一类) + * - f-string 的 `{ }` 和 `:.2f` 一样是格式占位符的颜色 + * - `print` 一律是函数:lezer 把不带括号的 `print`(Python 2 的写法)当关键字,`print"yes"`、 + * 全角括号的 `print(c)` 就变成关键字色,和旁边的 `print(...)` 不一样 + * + * 单独放一个文件是为了不绕圈:language.ts → codeMarks.ts → highlight.ts 要用到它。 + */ +export const ojPythonLanguage = pythonLanguage.configure({ + props: [ + styleTags({ + "from as with": t.controlKeyword, + "FormatReplacement/{ FormatReplacement/} FormatSpec/:": t.modifier, + print: t.function(t.variableName), + }), + ], +}) diff --git a/apps/web/src/shared/formatPlaceholder.ts b/apps/web/src/shared/formatPlaceholder.ts deleted file mode 100644 index 7a6acf6..0000000 --- a/apps/web/src/shared/formatPlaceholder.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * 字符串里的格式占位符:printf 的 %d、%.2f、%%,Python 的 "%d 个" % n。 - * lezer 把整个字符串当一个词,不分出占位符,所以另外扫一遍。静态代码在 shared/highlight.ts 里拆, - * 编辑器在 shared/extensions/formatPlaceholder.ts 里挂装饰,两边用这一份正则。 - * - * 正则和颜色照 VS Code 的语法文件:C 是 constant.other.placeholder(变量色), - * Python 是 constant.character.format.placeholder(关键字色),所以两种语言类名不同。 - */ -export type FormatFamily = "c" | "python" - -const PATTERNS: Record = { - c: /%(\d+\$)?[#0\- +']*(-?\d+|\*)?(\.(-?\d+|\*)?)?(hh|h|ll|l|j|t|z|L)?[diouxXeEfFgGaAcspn%]/g, - python: /%(\([\w\s]*\))?[-+#0 ]*(\d+|\*)?(\.(\d+|\*))?[hlL]?[diouxXeEfFgGcrsa%]/g, -} - -export const FORMAT_CLASS: Record = { - c: "tok-format", - python: "tok-format-py", -} - -/** 字符串里每个占位符的位置(相对 text 开头) */ -export function formatSpans(text: string, family: FormatFamily): { from: number; to: number }[] { - return [...text.matchAll(PATTERNS[family])].map((m) => ({ - from: m.index, - to: m.index + m[0].length, - })) -} diff --git a/apps/web/src/shared/highlight.css b/apps/web/src/shared/highlight.css index 4052f58..b1aedd0 100644 --- a/apps/web/src/shared/highlight.css +++ b/apps/web/src/shared/highlight.css @@ -14,6 +14,7 @@ --code-type: #267f99; --code-comment: #008000; --code-invalid: #cd3131; + --code-invalid-bg: rgba(205, 49, 49, 0.12); --code-heading: #800000; --code-raw: #800000; --code-bold: #000080; @@ -31,6 +32,7 @@ html.dark { --code-type: #4ec9b0; --code-comment: #6a9955; --code-invalid: #f44747; + --code-invalid-bg: rgba(244, 71, 71, 0.18); --code-heading: #569cd6; --code-raw: #ce9178; --code-bold: #569cd6; @@ -67,18 +69,28 @@ html.dark { color: var(--code-invalid); } -/* 字符串里的 %d,见 shared/formatPlaceholder.ts。静态代码是同一个 span 上的两个类名, - 编辑器里是两层 span、谁包谁不一定,所以三种写法都要 */ +/* shared/codeMarks.ts 补的几种标记。静态代码是同一个 span 上的两个类名,编辑器里是两层 span、 + 谁包谁不一定,所以内外两种写法都要,并且要写在上面那些 tok-* 后面 */ .tok-format, -.tok-format .tok-string, -.tok-string .tok-format { +.tok-format * { color: var(--code-variable); } .tok-format-py, -.tok-format-py .tok-string, -.tok-string .tok-format-py { +.tok-format-py * { color: var(--code-keyword); } +.tok-builtin-type, +.tok-builtin-type * { + color: var(--code-type); +} +.tok-fullwidth, +.tok-fullwidth * { + color: var(--code-invalid); + background-color: var(--code-invalid-bg); + text-decoration: wavy underline var(--code-invalid); + text-decoration-skip-ink: none; + text-underline-offset: 3px; +} /* 以下只有 Markdown 编辑器用得到 */ .tok-heading { diff --git a/apps/web/src/shared/highlight.ts b/apps/web/src/shared/highlight.ts index 4649dbc..4664602 100644 --- a/apps/web/src/shared/highlight.ts +++ b/apps/web/src/shared/highlight.ts @@ -12,8 +12,8 @@ import { cppLanguage } from "@codemirror/lang-cpp" import { SQLite } from "@codemirror/lang-sql" import { highlightCode, tagHighlighter, tags as t } from "@lezer/highlight" import type { ConfigProviderProps } from "naive-ui" -import { ojPythonLanguage } from "./extensions/language" -import { FORMAT_CLASS, type FormatFamily, formatSpans } from "./formatPlaceholder" +import { type CodeFamily, fakeStrings, splitMarks } from "./codeMarks" +import { ojPythonLanguage } from "./extensions/pythonLanguage" /** 词的种类 → 类名。编辑器在 shared/themes/codeTheme.ts 里挂的也是它 */ export const codeHighlighter = tagHighlighter([ @@ -55,7 +55,7 @@ function parserFor(lang?: string): Parser | null { return PARSERS[lang?.toLowerCase() ?? ""] ?? null } -function formatFamilyFor(lang?: string): FormatFamily | null { +function familyFor(lang?: string): CodeFamily | null { const parser = parserFor(lang) if (parser === cppLanguage.parser) return "c" if (parser === ojPythonLanguage.parser) return "python" @@ -82,19 +82,22 @@ function walk( put(code, "") return } - // 字符串里的 %d 这类占位符再拆出来,见 shared/formatPlaceholder.ts - const family = formatFamilyFor(lang) + // 再补 %d、全角标点、Python 内置类型这几种标记,见 shared/codeMarks.ts + const tree = parser.parse(code) + const family = familyFor(lang) + const fake = family ? fakeStrings(tree, (from, to) => code.slice(from, to)) : [] + let pos = 0 const emit = (text: string, classes: string) => { - if (!family || !classes.includes("tok-string")) return put(text, classes) - let at = 0 - for (const s of formatSpans(text, family)) { - if (s.from > at) put(text.slice(at, s.from), classes) - put(text.slice(s.from, s.to), `${classes} ${FORMAT_CLASS[family]}`) - at = s.to - } - if (at < text.length) put(text.slice(at), classes) + if (family) { + const marks = fake.some((r) => pos >= r.from && pos < r.to) ? "" : classes + splitMarks(text, classes, marks, family, code[pos - 1] ?? "", put) + } else put(text, classes) + pos += text.length } - highlightCode(code, parser.parse(code), codeHighlighter, emit, () => put("\n", "")) + highlightCode(code, tree, codeHighlighter, emit, () => { + put("\n", "") + pos++ + }) } /** 逐行高亮,返回每行的 HTML,每行的标签自己闭合 */