Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 46 additions & 120 deletions src/pkg/utils/monaco-editor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>;
Expand All @@ -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;
Expand All @@ -63,12 +59,14 @@ const configuredLanguagePromise = systemConfig.getLanguage();
let currentEditorLang: EditorLangEntry;
type EditorLangEntryPrompt = typeof currentEditorLang.prompt;
let promptByMetadataTag: EditorLangEntryPrompt;
let knownMetadataTagSet: ReadonlySet<string>;

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");
Expand Down Expand Up @@ -118,15 +116,10 @@ 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]*(.*)$/;
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 = /^[^']*'([^']+)'[^']*$/;

Expand Down Expand Up @@ -437,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(
Expand Down Expand Up @@ -725,45 +652,43 @@ 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;
}
}
// 记录每个 model 上一次识别到的 metadata 区块行范围,供 contentChangeCanAffectMetadataMarkers
// 判断编辑是否落在区块内;WeakMap 随 model 被丢弃自动释放,无需手动清理。
const metadataBlockRangeByModel = new WeakMap<editor.ITextModel, MetadataBlockRange | null>();

return false;
};
const getUndefinedMetadataTagMarkers = (
model: editor.ITextModel,
blocks: MetadataAlignmentBlock[]
): 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));

for (const block of metadataBlocks) {
if (isMetadataAlignmentBlockAligned(block)) continue;
Expand Down Expand Up @@ -805,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);
}
});
Expand Down Expand Up @@ -876,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,
},
],
Expand Down
152 changes: 152 additions & 0 deletions src/pkg/utils/monaco-editor/metadata.test.ts
Original file line number Diff line number Diff line change
@@ -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:<locale>)应解析为对应的基础标签", () => {
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);
});
});
Loading
Loading