From e00257459947fb880190fffb0ffc7d5ac3aa78dd Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 18 Jul 2026 04:53:42 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20=E6=9C=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E5=85=83=E6=95=B0=E6=8D=AE=E6=A0=87=E7=AD=BE=E5=9C=A8=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E4=B8=AD=E6=98=BE=E7=A4=BA=E8=AD=A6=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @tag 若不在已知元数据提示列表中,现在会在编辑器中以警告标记显示(沿用悬浮提示的 undefinedPrompt 文案),提示用户该标签暂未被 ScriptCat 支持。 Co-Authored-By: Claude Sonnet 5 --- src/pkg/utils/monaco-editor/index.ts | 34 ++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/pkg/utils/monaco-editor/index.ts b/src/pkg/utils/monaco-editor/index.ts index 2f8ea81e6..6468426ec 100644 --- a/src/pkg/utils/monaco-editor/index.ts +++ b/src/pkg/utils/monaco-editor/index.ts @@ -118,6 +118,7 @@ const scriptcatRemoveConnectWildcardRuleId = "scriptcat/remove-connect-wildcard" const scriptcatReplaceMatchTldWildcardRuleId = "scriptcat/replace-match-tld-wildcard-with-include"; const scriptcatReplaceIncludeWithMatchRuleId = "scriptcat/replace-include-with-match"; const scriptcatGrantNoneConflictRuleId = "scriptcat/grant-none-conflict"; +const scriptcatUndefinedMetadataTagRuleId = "scriptcat/undefined-metadata-tag"; const quickfixKind = "quickfix"; const noop = () => {}; const metaLinePattern = /\/\/[ \t]*@(\S+)[ \t]*(.*)$/; @@ -755,6 +756,38 @@ const contentChangeCanAffectMetadataMarkers = (model: editor.ITextModel, event: return false; }; +const getUndefinedMetadataTagMarkers = ( + model: editor.ITextModel, + blocks: MetadataAlignmentBlock[] +): editor.IMarkerData[] => { + const markers: editor.IMarkerData[] = []; + + for (const block of blocks) { + for (let lineNumber = block.startLineNumber; lineNumber <= block.endLineNumber; lineNumber += 1) { + const lineText = model.getLineContent(lineNumber); + const metadataMatch = metadataHoverPattern.exec(lineText); + if (!metadataMatch) continue; + + const [, prefix, tag] = metadataMatch; + const normalizedTag = tag.toLowerCase() as keyof EditorLangEntryPrompt; + if (promptByMetadataTag[normalizedTag]) continue; + + markers.push({ + severity: MarkerSeverity.Warning, + message: currentEditorLang.undefinedPrompt, + source: scriptcatMarkerOwner, + code: scriptcatUndefinedMetadataTagRuleId, + startLineNumber: lineNumber, + startColumn: prefix.length + 1, + endLineNumber: lineNumber, + endColumn: prefix.length + tag.length + 1, + }); + } + } + + return markers; +}; + const updateScriptcatMetadataMarkers = (model: editor.ITextModel) => { if (model.getLanguageId() !== "javascript") { editor.setModelMarkers(model, scriptcatMarkerOwner, []); @@ -764,6 +797,7 @@ const updateScriptcatMetadataMarkers = (model: editor.ITextModel) => { const metadataBlocks = getMetadataAlignmentBlocks(model); const markers: editor.IMarkerData[] = []; markers.push(...getGrantNoneConflictMarkers(metadataBlocks)); + markers.push(...getUndefinedMetadataTagMarkers(model, metadataBlocks)); for (const block of metadataBlocks) { if (isMetadataAlignmentBlockAligned(block)) continue; From 5603b7c9c51c98520efb2a451cfa3abc61d7fbf1 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:46:27 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=E6=9C=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E5=85=83=E6=95=B0=E6=8D=AE=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E8=AF=AF=E6=8A=A5=E3=80=81=E5=8C=BA=E5=9D=97=E6=89=AB=E6=8F=8F?= =?UTF-8?q?=E8=8C=83=E5=9B=B4=E4=B8=8E=20marker=20=E8=BF=87=E6=9C=9F?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 本地化标签(name:/description:)不再被误报为未定义标签 - 区块扫描改为与运行时 parseMetadata 一致:仅识别首个成对闭合的 UserScript 区块 - 编辑注释行首字符后不再遗留过期 marker - 将纯逻辑抽取到 metadata.ts 并补充单元测试 Co-Authored-By: Claude Sonnet 5 --- src/pkg/utils/monaco-editor/index.ts | 190 ++++--------------- src/pkg/utils/monaco-editor/metadata.test.ts | 152 +++++++++++++++ src/pkg/utils/monaco-editor/metadata.ts | 180 ++++++++++++++++++ 3 files changed, 373 insertions(+), 149 deletions(-) create mode 100644 src/pkg/utils/monaco-editor/metadata.test.ts create mode 100644 src/pkg/utils/monaco-editor/metadata.ts diff --git a/src/pkg/utils/monaco-editor/index.ts b/src/pkg/utils/monaco-editor/index.ts index 6468426ec..90ce5a151 100644 --- a/src/pkg/utils/monaco-editor/index.ts +++ b/src/pkg/utils/monaco-editor/index.ts @@ -6,6 +6,18 @@ import type { EditorLangCode, EditorLangEntry } from "./langs"; import { asEditorLangEntry, editorLangs } from "./langs"; import { deferred } from "../utils"; import { type EslintFix, getModelEslintFixKey } from "./eslintFixCache"; +import { + contentChangeCanAffectMetadataMarkers, + getMetadataAlignmentBlocks, + getMetadataAlignmentTargetColumn, + getUndefinedMetadataTagMatches, + isMetadataAlignmentBlockAligned, + metadataHoverPattern, + resolveMetadataTagBase, + type MetadataAlignmentBlock, + type MetadataAlignmentLine, + type MetadataBlockRange, +} from "./metadata"; interface ILinterWorker extends Worker { myLinterHook: EventEmitter; @@ -30,22 +42,6 @@ type MetadataLineFix = { type TextEdit = languages.IWorkspaceTextEdit["textEdit"]; -type MetadataAlignmentLine = { - lineNumber: number; - lineText: string; - prefix: string; - tag: string; - spacing: string; - value: string; - valueColumn: number; -}; - -type MetadataAlignmentBlock = { - startLineNumber: number; - endLineNumber: number; - lines: MetadataAlignmentLine[]; -}; - type MetadataAlignmentFix = { range: TextEdit["range"]; text: string; @@ -63,12 +59,14 @@ const configuredLanguagePromise = systemConfig.getLanguage(); let currentEditorLang: EditorLangEntry; type EditorLangEntryPrompt = typeof currentEditorLang.prompt; let promptByMetadataTag: EditorLangEntryPrompt; +let knownMetadataTagSet: ReadonlySet; const loadEditorLangEntry = (languageCode: EditorLangCode) => { currentEditorLang = asEditorLangEntry(languageCode); promptByMetadataTag = Object.fromEntries( Object.entries(currentEditorLang.prompt).map(([metadataTag, prompt]) => [metadataTag.toLowerCase(), prompt]) ) as typeof currentEditorLang.prompt; + knownMetadataTagSet = new Set(Object.keys(promptByMetadataTag)); }; loadEditorLangEntry("en-US"); @@ -121,13 +119,7 @@ const scriptcatGrantNoneConflictRuleId = "scriptcat/grant-none-conflict"; const scriptcatUndefinedMetadataTagRuleId = "scriptcat/undefined-metadata-tag"; const quickfixKind = "quickfix"; const noop = () => {}; -const metaLinePattern = /\/\/[ \t]*@(\S+)[ \t]*(.*)$/; -const metadataHoverPattern = /^(\s*\/\/[ \t]*@)(\S+)([ \t]*)(.*)$/; const metadataFixPattern = /^(\s*\/\/[ \t]*@)(connect|match|include)([ \t]+)(\S+)(.*)$/i; -const metadataAlignmentPattern = /^(\s*\/\/[ \t]*@)(\S+)([ \t]+)(.*)$/; -const metadataLineStartPattern = /^\s*\/\/[ \t]*@/; -const userscriptHeaderPattern = /^\s*\/\/[ \t]*==UserScript==[ \t]*$/; -const userscriptEndPattern = /^\s*\/\/[ \t]*==\/UserScript==[ \t]*$/; const matchMetadataPattern = /^(\*|[-a-z]+|http\*):\/\/([^/]+)(\/.*)?$/i; const noUndefMessagePattern = /^[^']*'([^']+)'[^']*$/; @@ -438,72 +430,6 @@ const getMetadataLineActions = ( ); }; -const getMetadataAlignmentLine = (lineNumber: number, lineText: string): MetadataAlignmentLine | null => { - const match = metadataAlignmentPattern.exec(lineText); - if (!match) return null; - - const [, prefix, tag, spacing, value] = match; - return { - lineNumber, - lineText, - prefix, - tag, - spacing, - value, - valueColumn: prefix.length + tag.length + spacing.length, - }; -}; - -const getMetadataAlignmentBlocks = (model: editor.ITextModel): MetadataAlignmentBlock[] => { - const blocks: MetadataAlignmentBlock[] = []; - const lineCount = model.getLineCount(); - let currentBlock: MetadataAlignmentBlock | null = null; - - const finishBlock = (endLineNumber: number) => { - if (!currentBlock) return; - currentBlock.endLineNumber = endLineNumber; - blocks.push(currentBlock); - currentBlock = null; - }; - - for (let lineNumber = 1; lineNumber <= lineCount; lineNumber += 1) { - const lineText = model.getLineContent(lineNumber); - - if (userscriptHeaderPattern.test(lineText)) { - finishBlock(lineNumber - 1); - currentBlock = { - startLineNumber: lineNumber, - endLineNumber: lineNumber, - lines: [], - }; - continue; - } - - if (!currentBlock) continue; - - const alignmentLine = getMetadataAlignmentLine(lineNumber, lineText); - if (alignmentLine) { - currentBlock.lines.push(alignmentLine); - } - - if (userscriptEndPattern.test(lineText)) { - finishBlock(lineNumber); - } - } - - finishBlock(lineCount); - return blocks; -}; - -const getMetadataAlignmentTargetColumn = (lines: MetadataAlignmentLine[]) => - Math.max(...lines.map((line) => line.prefix.length + line.tag.length + 1)); - -const isMetadataAlignmentBlockAligned = (block: MetadataAlignmentBlock) => { - if (block.lines.length < 2) return true; - const firstValueColumn = block.lines[0].valueColumn; - return block.lines.every((line) => line.valueColumn === firstValueColumn); -}; - const getMetadataAlignmentFix = (model: editor.ITextModel, block: MetadataAlignmentBlock): MetadataAlignmentFix => { const targetColumn = getMetadataAlignmentTargetColumn(block.lines); const lineFixes = new Map( @@ -726,75 +652,40 @@ const getMarkerCodeActions = ( return actions; }; -const lineCanAffectMetadataMarkers = (lineText: string) => - metadataLineStartPattern.test(lineText) || - userscriptHeaderPattern.test(lineText) || - userscriptEndPattern.test(lineText); - -const commentEditCanAffectMetadataMarkers = (lineText: string, change: editor.IModelContentChange) => - /^\s*\/\//.test(lineText) || (change.rangeLength > 0 && change.range.startColumn <= 12); - -const contentChangeCanAffectMetadataMarkers = (model: editor.ITextModel, event: editor.IModelContentChangedEvent) => { - if (event.isFlush || event.isEolChange) return true; - - for (const change of event.changes) { - if ( - change.range.startLineNumber !== change.range.endLineNumber || - change.text.includes("\n") || - change.text.includes("@") || - change.text.includes("UserScript") - ) { - return true; - } - - const lineText = model.getLineContent(change.range.startLineNumber); - if (lineCanAffectMetadataMarkers(lineText) || commentEditCanAffectMetadataMarkers(lineText, change)) { - return true; - } - } - - return false; -}; +// 记录每个 model 上一次识别到的 metadata 区块行范围,供 contentChangeCanAffectMetadataMarkers +// 判断编辑是否落在区块内;WeakMap 随 model 被丢弃自动释放,无需手动清理。 +const metadataBlockRangeByModel = new WeakMap(); const getUndefinedMetadataTagMarkers = ( model: editor.ITextModel, blocks: MetadataAlignmentBlock[] -): editor.IMarkerData[] => { - const markers: editor.IMarkerData[] = []; - - for (const block of blocks) { - for (let lineNumber = block.startLineNumber; lineNumber <= block.endLineNumber; lineNumber += 1) { - const lineText = model.getLineContent(lineNumber); - const metadataMatch = metadataHoverPattern.exec(lineText); - if (!metadataMatch) continue; - - const [, prefix, tag] = metadataMatch; - const normalizedTag = tag.toLowerCase() as keyof EditorLangEntryPrompt; - if (promptByMetadataTag[normalizedTag]) continue; - - markers.push({ - severity: MarkerSeverity.Warning, - message: currentEditorLang.undefinedPrompt, - source: scriptcatMarkerOwner, - code: scriptcatUndefinedMetadataTagRuleId, - startLineNumber: lineNumber, - startColumn: prefix.length + 1, - endLineNumber: lineNumber, - endColumn: prefix.length + tag.length + 1, - }); - } - } - - return markers; -}; +): editor.IMarkerData[] => + getUndefinedMetadataTagMatches(model, blocks, knownMetadataTagSet).map((match) => ({ + severity: MarkerSeverity.Warning, + message: currentEditorLang.undefinedPrompt, + source: scriptcatMarkerOwner, + code: scriptcatUndefinedMetadataTagRuleId, + startLineNumber: match.lineNumber, + startColumn: match.startColumn, + endLineNumber: match.lineNumber, + endColumn: match.endColumn, + })); const updateScriptcatMetadataMarkers = (model: editor.ITextModel) => { if (model.getLanguageId() !== "javascript") { editor.setModelMarkers(model, scriptcatMarkerOwner, []); + metadataBlockRangeByModel.set(model, null); return; } const metadataBlocks = getMetadataAlignmentBlocks(model); + metadataBlockRangeByModel.set( + model, + metadataBlocks[0] + ? { startLineNumber: metadataBlocks[0].startLineNumber, endLineNumber: metadataBlocks[0].endLineNumber } + : null + ); + const markers: editor.IMarkerData[] = []; markers.push(...getGrantNoneConflictMarkers(metadataBlocks)); markers.push(...getUndefinedMetadataTagMarkers(model, metadataBlocks)); @@ -839,7 +730,7 @@ const registerScriptcatMetadataMarkerProvider = () => { updateScriptcatMetadataMarkers(model); model.onDidChangeContent((event) => { if (model.getLanguageId() !== "javascript") return; - if (contentChangeCanAffectMetadataMarkers(model, event)) { + if (contentChangeCanAffectMetadataMarkers(model, event, metadataBlockRangeByModel.get(model) ?? null)) { updateScriptcatMetadataMarkers(model); } }); @@ -910,14 +801,15 @@ export function registerEditor() { }; } - const metadataCommentMatch = metaLinePattern.exec(lineText); + const metadataCommentMatch = metadataHoverPattern.exec(lineText); if (metadataCommentMatch) { - const metadataTag = metadataCommentMatch[1].toLowerCase() as keyof EditorLangEntryPrompt; + const [, , metadataTag] = metadataCommentMatch; + const baseTag = resolveMetadataTagBase(metadataTag) as keyof EditorLangEntryPrompt; return { contents: [ { - value: promptByMetadataTag[metadataTag] || currentEditorLang.undefinedPrompt, + value: promptByMetadataTag[baseTag] || currentEditorLang.undefinedPrompt, supportHtml: true, }, ], diff --git a/src/pkg/utils/monaco-editor/metadata.test.ts b/src/pkg/utils/monaco-editor/metadata.test.ts new file mode 100644 index 000000000..0d49b13d2 --- /dev/null +++ b/src/pkg/utils/monaco-editor/metadata.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; +import type { editor } from "monaco-editor"; +import { + contentChangeCanAffectMetadataMarkers, + getMetadataAlignmentBlocks, + getUndefinedMetadataTagMatches, + isKnownMetadataTag, + resolveMetadataTagBase, +} from "./metadata"; + +// 与 utils.test.ts 相同风格的简单 Monaco Editor 模型 mock +const createMockModel = (lines: string[]): editor.ITextModel => { + return { + getLineCount: () => lines.length, + getLineContent: (lineNumber: number) => lines[lineNumber - 1] || "", + } as editor.ITextModel; +}; + +const knownTags = new Set(["name", "description", "namespace", "grant", "match", "run-at", "noframes"]); + +describe("getMetadataAlignmentBlocks", () => { + it("应该识别成对闭合的 UserScript 区块", () => { + const model = createMockModel([ + "// ==UserScript==", + "// @name Test", + "// @version 1.0", + "// ==/UserScript==", + "const x = 1;", + ]); + const blocks = getMetadataAlignmentBlocks(model); + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ startLineNumber: 1, endLineNumber: 4 }); + expect(blocks[0].lines.map((line) => line.tag)).toEqual(["name", "version"]); + }); + + it("未闭合的区块不应产出任何区块(与运行时 parseMetadata 保持一致)", () => { + const model = createMockModel(["// ==UserScript==", "// @name Test", "// @version 1.0", "const x = 1;"]); + expect(getMetadataAlignmentBlocks(model)).toEqual([]); + }); + + it("只应扫描第一个成对闭合的区块,忽略其后的第二个区块", () => { + const model = createMockModel([ + "// ==UserScript==", + "// @name First", + "// ==/UserScript==", + "// ==UserScript==", + "// @name Second", + "// ==/UserScript==", + ]); + const blocks = getMetadataAlignmentBlocks(model); + expect(blocks).toHaveLength(1); + expect(blocks[0].lines[0].value).toBe("First"); + }); + + it("闭合标记之前不构成区块的行不应被当成 metadata", () => { + const model = createMockModel(["// @name Ignored", "// ==UserScript==", "// @name Test", "// ==/UserScript=="]); + const blocks = getMetadataAlignmentBlocks(model); + expect(blocks).toHaveLength(1); + expect(blocks[0].lines.map((line) => line.value)).toEqual(["Test"]); + }); +}); + +describe("resolveMetadataTagBase / isKnownMetadataTag", () => { + it("本地化标签(name:)应解析为对应的基础标签", () => { + expect(resolveMetadataTagBase("name:zh-CN")).toBe("name"); + expect(resolveMetadataTagBase("description:en")).toBe("description"); + }); + + it("非本地化标签保持原样(小写化)", () => { + expect(resolveMetadataTagBase("Grant")).toBe("grant"); + expect(resolveMetadataTagBase("run-at")).toBe("run-at"); + }); + + it("已知的本地化标签应视为已定义", () => { + expect(isKnownMetadataTag("name:zh-CN", knownTags)).toBe(true); + expect(isKnownMetadataTag("description:ja", knownTags)).toBe(true); + }); + + it("已知的普通/连字符/大小写标签应视为已定义", () => { + expect(isKnownMetadataTag("GRANT", knownTags)).toBe(true); + expect(isKnownMetadataTag("run-at", knownTags)).toBe(true); + }); + + it("未知标签(包括未知标签的本地化变体)应视为未定义", () => { + expect(isKnownMetadataTag("unknownTag", knownTags)).toBe(false); + expect(isKnownMetadataTag("foo:zh-CN", knownTags)).toBe(false); + }); +}); + +describe("getUndefinedMetadataTagMatches", () => { + it("应该跳过本地化标签,只标记真正未定义的标签", () => { + const model = createMockModel([ + "// ==UserScript==", + "// @name:zh-CN 脚本名称", + "// @unknownTag foo", + "// ==/UserScript==", + ]); + const blocks = getMetadataAlignmentBlocks(model); + const matches = getUndefinedMetadataTagMatches(model, blocks, knownTags); + expect(matches).toHaveLength(1); + expect(matches[0].lineNumber).toBe(3); + }); + + it("应返回精确的列范围", () => { + const model = createMockModel(["// ==UserScript==", "// @unknownTag foo", "// ==/UserScript=="]); + const blocks = getMetadataAlignmentBlocks(model); + const matches = getUndefinedMetadataTagMatches(model, blocks, knownTags); + expect(matches).toEqual([{ lineNumber: 2, startColumn: 5, endColumn: 15 }]); + }); +}); + +const createChangeEvent = ( + changes: Array<{ startLineNumber: number; endLineNumber: number; text: string; rangeLength: number }> +): editor.IModelContentChangedEvent => + ({ + isFlush: false, + isEolChange: false, + changes: changes.map((change) => ({ + range: { + startLineNumber: change.startLineNumber, + endLineNumber: change.endLineNumber, + startColumn: 1, + endColumn: 1, + }, + rangeLength: change.rangeLength, + text: change.text, + })), + }) as unknown as editor.IModelContentChangedEvent; + +describe("contentChangeCanAffectMetadataMarkers", () => { + it("在已知区块行首插入字符(导致该行不再形似注释)时仍应触发重新计算", () => { + // 编辑后行内容为 "x// @foo bar"(已不再匹配注释/metadata 正则), + // 但该行位于上一次识别到的区块范围内,因此应视为可能影响 markers。 + const model = createMockModel(["// ==UserScript==", "x// @foo bar", "// ==/UserScript=="]); + const event = createChangeEvent([{ startLineNumber: 2, endLineNumber: 2, text: "x", rangeLength: 0 }]); + const knownBlockRange = { startLineNumber: 1, endLineNumber: 3 }; + expect(contentChangeCanAffectMetadataMarkers(model, event, knownBlockRange)).toBe(true); + }); + + it("区块外、且编辑后文本也不像注释的普通代码编辑不应触发重新计算", () => { + const model = createMockModel(["// ==UserScript==", "// ==/UserScript==", "xconst y = 1;"]); + const event = createChangeEvent([{ startLineNumber: 3, endLineNumber: 3, text: "x", rangeLength: 0 }]); + const knownBlockRange = { startLineNumber: 1, endLineNumber: 2 }; + expect(contentChangeCanAffectMetadataMarkers(model, event, knownBlockRange)).toBe(false); + }); + + it("没有已知区块时,仍应依赖原有的行文本启发式判断", () => { + const model = createMockModel(["// @name Test"]); + const event = createChangeEvent([{ startLineNumber: 1, endLineNumber: 1, text: "x", rangeLength: 0 }]); + expect(contentChangeCanAffectMetadataMarkers(model, event, null)).toBe(true); + }); +}); diff --git a/src/pkg/utils/monaco-editor/metadata.ts b/src/pkg/utils/monaco-editor/metadata.ts new file mode 100644 index 000000000..0a248232e --- /dev/null +++ b/src/pkg/utils/monaco-editor/metadata.ts @@ -0,0 +1,180 @@ +import type { editor } from "monaco-editor"; + +export type MetadataAlignmentLine = { + lineNumber: number; + lineText: string; + prefix: string; + tag: string; + spacing: string; + value: string; + valueColumn: number; +}; + +export type MetadataAlignmentBlock = { + startLineNumber: number; + endLineNumber: number; + lines: MetadataAlignmentLine[]; +}; + +export type MetadataBlockRange = { + startLineNumber: number; + endLineNumber: number; +}; + +export type UndefinedMetadataTagMatch = { + lineNumber: number; + startColumn: number; + endColumn: number; +}; + +export const metadataHoverPattern = /^(\s*\/\/[ \t]*@)(\S+)([ \t]*)(.*)$/; +export const metadataLineStartPattern = /^\s*\/\/[ \t]*@/; +export const userscriptHeaderPattern = /^\s*\/\/[ \t]*==UserScript==[ \t]*$/; +export const userscriptEndPattern = /^\s*\/\/[ \t]*==\/UserScript==[ \t]*$/; +const metadataAlignmentPattern = /^(\s*\/\/[ \t]*@)(\S+)([ \t]+)(.*)$/; +// ScriptCat 运行时消费 `name:`/`description:` (src/locales/locales.ts) 等本地化标签, +// 它们是合法标签而非拼写错误,需与其余标签区分开来单独判断。 +const localeSuffixedMetadataTagPattern = /^(name|description):(.+)$/; + +export const getMetadataAlignmentLine = (lineNumber: number, lineText: string): MetadataAlignmentLine | null => { + const match = metadataAlignmentPattern.exec(lineText); + if (!match) return null; + + const [, prefix, tag, spacing, value] = match; + return { + lineNumber, + lineText, + prefix, + tag, + spacing, + value, + valueColumn: prefix.length + tag.length + spacing.length, + }; +}; + +/** + * 只识别第一个成对闭合的 ==UserScript==/==/UserScript== 区块,与运行时 parseMetadata 的 + * HEADER_BLOCK 正则语义保持一致(src/pkg/utils/script.ts):未闭合的区块不会产出任何区块, + * 闭合区块之外/之后的额外区块也不会被扫描。 + */ +export const getMetadataAlignmentBlocks = (model: editor.ITextModel): MetadataAlignmentBlock[] => { + const lineCount = model.getLineCount(); + let currentBlock: MetadataAlignmentBlock | null = null; + + for (let lineNumber = 1; lineNumber <= lineCount; lineNumber += 1) { + const lineText = model.getLineContent(lineNumber); + + if (currentBlock === null) { + if (userscriptHeaderPattern.test(lineText)) { + currentBlock = { startLineNumber: lineNumber, endLineNumber: lineNumber, lines: [] }; + } + continue; + } + + if (userscriptEndPattern.test(lineText)) { + currentBlock.endLineNumber = lineNumber; + return [currentBlock]; + } + + const alignmentLine = getMetadataAlignmentLine(lineNumber, lineText); + if (alignmentLine) { + currentBlock.lines.push(alignmentLine); + } + } + + return []; +}; + +export const getMetadataAlignmentTargetColumn = (lines: MetadataAlignmentLine[]) => + Math.max(...lines.map((line) => line.prefix.length + line.tag.length + 1)); + +export const isMetadataAlignmentBlockAligned = (block: MetadataAlignmentBlock) => { + if (block.lines.length < 2) return true; + const firstValueColumn = block.lines[0].valueColumn; + return block.lines.every((line) => line.valueColumn === firstValueColumn); +}; + +export const resolveMetadataTagBase = (tag: string): string => { + const normalizedTag = tag.toLowerCase(); + const localeMatch = localeSuffixedMetadataTagPattern.exec(normalizedTag); + return localeMatch ? localeMatch[1] : normalizedTag; +}; + +export const isKnownMetadataTag = (tag: string, knownTags: ReadonlySet): boolean => + knownTags.has(resolveMetadataTagBase(tag)); + +export const getUndefinedMetadataTagMatches = ( + model: editor.ITextModel, + blocks: MetadataAlignmentBlock[], + knownTags: ReadonlySet +): UndefinedMetadataTagMatch[] => { + const matches: UndefinedMetadataTagMatch[] = []; + + for (const block of blocks) { + for (let lineNumber = block.startLineNumber; lineNumber <= block.endLineNumber; lineNumber += 1) { + const lineText = model.getLineContent(lineNumber); + const metadataMatch = metadataHoverPattern.exec(lineText); + if (!metadataMatch) continue; + + const [, prefix, tag] = metadataMatch; + if (isKnownMetadataTag(tag, knownTags)) continue; + + matches.push({ + lineNumber, + startColumn: prefix.length + 1, + endColumn: prefix.length + tag.length + 1, + }); + } + } + + return matches; +}; + +const lineCanAffectMetadataMarkers = (lineText: string) => + metadataLineStartPattern.test(lineText) || + userscriptHeaderPattern.test(lineText) || + userscriptEndPattern.test(lineText); + +const commentEditCanAffectMetadataMarkers = (lineText: string, change: editor.IModelContentChange) => + /^\s*\/\//.test(lineText) || (change.rangeLength > 0 && change.range.startColumn <= 12); + +const changeIntersectsBlockRange = (change: editor.IModelContentChange, blockRange: MetadataBlockRange | null) => + blockRange !== null && + change.range.startLineNumber >= blockRange.startLineNumber && + change.range.startLineNumber <= blockRange.endLineNumber; + +/** + * 判断本次内容变更是否可能影响 ScriptCat metadata 诊断。除了基于变更后文本形态的启发式判断外, + * 还会检查变更行是否落在“上一次识别到的 metadata 区块”范围内——这样即使一次编辑让某一行从 + * metadata 注释变为非注释(例如在 `// @foo` 行首插入字符),只要该行原本在区块内就一定会触发 + * 重新计算,不依赖正则去猜编辑前后的文本形态,从而避免遗留过期 marker。 + */ +export const contentChangeCanAffectMetadataMarkers = ( + model: editor.ITextModel, + event: editor.IModelContentChangedEvent, + knownBlockRange: MetadataBlockRange | null +): boolean => { + if (event.isFlush || event.isEolChange) return true; + + for (const change of event.changes) { + if ( + change.range.startLineNumber !== change.range.endLineNumber || + change.text.includes("\n") || + change.text.includes("@") || + change.text.includes("UserScript") + ) { + return true; + } + + if (changeIntersectsBlockRange(change, knownBlockRange)) { + return true; + } + + const lineText = model.getLineContent(change.range.startLineNumber); + if (lineCanAffectMetadataMarkers(lineText) || commentEditCanAffectMetadataMarkers(lineText, change)) { + return true; + } + } + + return false; +};