diff --git a/src/cm/modelist.ts b/src/cm/modelist.ts index 8d5e782f04..64038b7101 100644 --- a/src/cm/modelist.ts +++ b/src/cm/modelist.ts @@ -14,6 +14,24 @@ export interface ModesByName { const modesByName: ModesByName = {}; const modes: Mode[] = []; +const FILE_NAME_CACHE_LIMIT = 2000; + +interface NamedCheck { + mode: Mode; + exactName?: string; + matcher?: RegExp; +} + +interface ModeIndex { + sorted: Mode[]; + namedChecks: NamedCheck[]; + extensions: Map; + rankByMode: Map; +} + +let modeIndex: ModeIndex | null = null; +const resolvedByFileName = new Map(); + function normalizeModeKey(value: string): string { return String(value ?? "") .trim() @@ -34,6 +52,19 @@ function escapeRegExp(value: string): string { return String(value ?? "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +function fileNameFromPath(path: string): string { + const value = String(path ?? ""); + const slash = value.lastIndexOf("/"); + const backslash = value.lastIndexOf("\\"); + const sep = slash > backslash ? slash : backslash; + return sep === -1 ? value : value.slice(sep + 1); +} + +function invalidateModeIndex(): void { + modeIndex = null; + resolvedByFileName.clear(); +} + /** * Initialize CodeMirror mode list functionality */ @@ -67,6 +98,7 @@ export function addMode( } }); modes.push(mode); + invalidateModeIndex(); } /** @@ -84,36 +116,13 @@ export function removeMode(name: string): void { } }); - const modeIndex = modes.findIndex( + const modeIndexInList = modes.findIndex( (registeredMode) => registeredMode === mode, ); - if (modeIndex >= 0) { - modes.splice(modeIndex, 1); - } -} - -/** - * Get mode for file path - */ -export function getModeForPath(path: string): Mode { - let mode = modesByName.text; - const fileName = path.split(/[/\\]/).pop() || ""; - - // Sort modes by specificity (descending) to check most specific first - const sortedModes = [...modes].sort((a, b) => { - const scoreDiff = getModeSpecificityScore(b) - getModeSpecificityScore(a); - if (scoreDiff !== 0) return scoreDiff; - // Tie-breaker: prefer modes registered later (plugins) over those registered earlier (core) - return modes.indexOf(b) - modes.indexOf(a); - }); - - for (const iMode of sortedModes) { - if (iMode.supportsFile?.(fileName)) { - mode = iMode; - break; - } + if (modeIndexInList >= 0) { + modes.splice(modeIndexInList, 1); } - return mode; + invalidateModeIndex(); } /** @@ -157,6 +166,174 @@ function getModeSpecificityScore(modeInstance: Mode): number { return maxScore; } +function exactNameFromRegex(matcher: RegExp): string | null { + const otherFlags = matcher.flags.replaceAll("i", ""); + if (otherFlags) return null; + + const source = matcher.source; + if ( + source.length < 2 || + source[0] !== "^" || + source[source.length - 1] !== "$" + ) { + return null; + } + + const inner = source.slice(1, -1); + let name = ""; + for (let i = 0; i < inner.length; i++) { + const ch = inner[i]; + if (ch === "\\") { + const next = inner[i + 1]; + if (!next) return null; + name += next; + i++; + continue; + } + if ("^$|.*+?()[]{}".includes(ch)) return null; + name += ch; + } + + return name ? name.toLowerCase() : null; +} + +function rememberFirst(map: Map, key: string, mode: Mode): void { + if (key && !map.has(key)) { + map.set(key, mode); + } +} + +function getModeIndex(): ModeIndex { + if (modeIndex) return modeIndex; + + const ranked = modes.map((mode, index) => ({ + mode, + score: getModeSpecificityScore(mode), + index, + })); + ranked.sort((a, b) => { + const scoreDiff = b.score - a.score; + return scoreDiff !== 0 ? scoreDiff : b.index - a.index; + }); + + const sorted = ranked.map((entry) => entry.mode); + const rankByMode = new Map(); + const namedChecks: NamedCheck[] = []; + const extensions = new Map(); + + for (let rank = 0; rank < sorted.length; rank++) { + rankByMode.set(sorted[rank], rank); + } + + for (const { mode } of ranked) { + if (mode.extensions) { + for (const raw of mode.extensions.split("|")) { + const pattern = raw.trim(); + if (!pattern) continue; + if (pattern.startsWith("^")) { + namedChecks.push({ + mode, + exactName: pattern.slice(1).toLowerCase(), + }); + } else { + rememberFirst(extensions, pattern.toLowerCase(), mode); + } + } + } + + for (const matcher of mode.filenameMatchers) { + const exactName = exactNameFromRegex(matcher); + if (exactName) { + namedChecks.push({ mode, exactName }); + continue; + } + namedChecks.push({ mode, matcher }); + } + } + + modeIndex = { sorted, namedChecks, extensions, rankByMode }; + return modeIndex; +} + +function findModeByExtension( + fileNameLower: string, + extensions: Map, + rankByMode: Map, +): Mode | undefined { + let best: Mode | undefined; + let bestRank = Number.POSITIVE_INFINITY; + let dot = fileNameLower.indexOf("."); + while (dot >= 0 && dot < fileNameLower.length - 1) { + const mode = extensions.get(fileNameLower.slice(dot + 1)); + if (mode) { + const rank = rankByMode.get(mode) ?? Number.POSITIVE_INFINITY; + if (rank < bestRank) { + best = mode; + bestRank = rank; + } + } + dot = fileNameLower.indexOf(".", dot + 1); + } + return best; +} + +function resolveModeForPath(fileName: string): Mode { + const fallback = modesByName.text; + const index = getModeIndex(); + const fileNameLower = fileName.toLowerCase(); + + for (const check of index.namedChecks) { + if (check.exactName) { + if ( + check.exactName === fileNameLower && + check.mode.supportsFile?.(fileName) + ) { + return check.mode; + } + continue; + } + + const matcher = check.matcher; + if (!matcher) continue; + matcher.lastIndex = 0; + if (matcher.test(fileName) && check.mode.supportsFile?.(fileName)) { + return check.mode; + } + } + + const byExtension = findModeByExtension( + fileNameLower, + index.extensions, + index.rankByMode, + ); + if (byExtension?.supportsFile?.(fileName)) return byExtension; + + for (const mode of index.sorted) { + if (mode.supportsFile?.(fileName)) return mode; + } + + return fallback; +} + +function cacheResolvedMode(fileNameLower: string, mode: Mode): Mode { + if (resolvedByFileName.size >= FILE_NAME_CACHE_LIMIT) { + resolvedByFileName.clear(); + } + resolvedByFileName.set(fileNameLower, mode); + return mode; +} + +/** + * Get mode for file path + */ +export function getModeForPath(path: string): Mode { + const fileName = fileNameFromPath(path); + const cached = resolvedByFileName.get(fileName); + if (cached) return cached; + + return cacheResolvedMode(fileName, resolveModeForPath(fileName)); +} + /** * Get all modes by name */ diff --git a/src/components/fileTree/index.js b/src/components/fileTree/index.js index 22176d7dfe..0250b1c019 100644 --- a/src/components/fileTree/index.js +++ b/src/components/fileTree/index.js @@ -259,6 +259,7 @@ export default class FileTree { * @returns {HTMLElement} */ createFileElement(name, url, recycledEl) { + // Resolve here so icon plugins that replace helpers.getIconForFile still apply. const iconClass = helpers.getIconForFile(name); // Try to recycle existing element diff --git a/tests/unit/modelist.test.js b/tests/unit/modelist.test.js new file mode 100644 index 0000000000..beab912787 --- /dev/null +++ b/tests/unit/modelist.test.js @@ -0,0 +1,278 @@ +import { afterEach, describe, expect, it } from "vitest"; +import "cm/supportedModes"; +import { + addMode, + getModeForPath, + getModes, + removeMode, +} from "cm/modelist"; + +function getModeSpecificityScore(modeInstance) { + if (modeInstance.name.toLowerCase() === "text") { + return 0; + } + + const extensionsStr = modeInstance.extensions; + let maxScore = 0; + + if (extensionsStr) { + const patterns = extensionsStr.split("|"); + for (const pattern of patterns) { + let currentScore = 0; + if (pattern.startsWith("^")) { + currentScore = 1000 + (pattern.length - 1); + } else { + currentScore = pattern.length; + } + if (currentScore > maxScore) { + maxScore = currentScore; + } + } + } + + for (const matcher of modeInstance.filenameMatchers) { + const score = 1000 + matcher.source.length; + if (score > maxScore) { + maxScore = score; + } + } + + return maxScore; +} + +function legacyGetModeForPath(path) { + const modes = getModes(); + let mode = modes.find((entry) => entry.name === "text"); + const fileName = path.split(/[/\\]/).pop() || ""; + + const sortedModes = [...modes].sort((a, b) => { + const scoreDiff = getModeSpecificityScore(b) - getModeSpecificityScore(a); + if (scoreDiff !== 0) return scoreDiff; + return modes.indexOf(b) - modes.indexOf(a); + }); + + for (const iMode of sortedModes) { + if (iMode.supportsFile?.(fileName)) { + mode = iMode; + break; + } + } + return mode; +} + +function collectParityPaths() { + const paths = new Set([ + "", + "README", + ".gitignore", + ".env", + "Dockerfile", + "dockerfile", + "MAKEFILE", + "Makefile", + "CMakeLists.txt", + "Gemfile", + "Rakefile", + "BUILD", + "BUCK", + "Jenkinsfile", + "nginx.conf", + "sites-enabled/nginx.proxy.conf", + "yarn.lock", + "Cargo.lock", + "poetry.lock", + "package.json", + "tsconfig.json", + "foo.text", + "notes.txt", + "file.d.ts", + "file.ts", + "app.test.js", + "app.js", + "APP.JS", + "src/components/Button.tsx", + "C:\\Users\\dev\\main.py", + "folder/sub/file.unknownext", + "example.html ", + ".bashrc", + ".prettierrc", + "bun.lock", + "file.astro", + "game.luau", + ]); + + for (const mode of getModes()) { + if (mode.extensions) { + for (const raw of mode.extensions.split("|")) { + const pattern = raw.trim(); + if (!pattern) continue; + if (pattern.startsWith("^")) { + paths.add(pattern.slice(1)); + paths.add(`/tmp/${pattern.slice(1)}`); + } else { + paths.add(`sample.${pattern}`); + paths.add(`nested/dir/sample.${pattern}`); + } + } + } + for (const matcher of mode.filenameMatchers) { + const source = matcher.source; + if ( + source.startsWith("^") && + source.endsWith("$") && + !/[|()[*+?]/.test(source.slice(1, -1).replace(/\\./g, "")) + ) { + const name = source + .slice(1, -1) + .replace(/\\(.)/g, "$1"); + if (name) paths.add(name); + } + } + } + + return [...paths]; +} + +const addedModes = []; + +afterEach(() => { + while (addedModes.length) { + removeMode(addedModes.pop()); + } +}); + +function registerTestMode(name, extensions, options) { + addMode(name, extensions, name, null, options); + addedModes.push(name); +} + +describe("getModeForPath", () => { + it("matches the previous sort-and-scan result for built-in modes", () => { + const paths = collectParityPaths(); + expect(paths.length).toBeGreaterThan(100); + + const mismatches = []; + for (const path of paths) { + const next = getModeForPath(path); + const legacy = legacyGetModeForPath(path); + if (next !== legacy) { + mismatches.push({ + path, + next: next?.name, + legacy: legacy?.name, + }); + } + } + + expect(mismatches).toEqual([]); + }); + + it("prefers later registrations when specificity is equal", () => { + registerTestMode("acodebench-first", "acodebench"); + registerTestMode("acodebench-second", "acodebench"); + + expect(getModeForPath("demo.acodebench").name).toBe("acodebench-second"); + + removeMode("acodebench-second"); + addedModes.pop(); + + expect(getModeForPath("demo.acodebench").name).toBe("acodebench-first"); + }); + + it("keeps anchored filenames ahead of generic extensions", () => { + expect(getModeForPath("Dockerfile").name).toBe("dockerfile"); + expect(getModeForPath("dockerfile").name).toBe( + legacyGetModeForPath("dockerfile").name, + ); + expect(getModeForPath("CMakeLists.txt").name).toBe("cmake"); + expect(getModeForPath("notes.txt").name).toBe("text"); + }); + + it("keeps longer extensions ahead of shorter suffixes", () => { + expect(getModeForPath("types.d.ts").name).toBe( + legacyGetModeForPath("types.d.ts").name, + ); + expect(getModeForPath("types.ts").name).toBe( + legacyGetModeForPath("types.ts").name, + ); + }); + + it("resolves regex filename matchers", () => { + expect(getModeForPath("nginx.conf").name).toBe( + legacyGetModeForPath("nginx.conf").name, + ); + expect(getModeForPath("BUILD").name).toBe( + legacyGetModeForPath("BUILD").name, + ); + }); + + it("keeps higher-specificity filename regexes ahead of exact names", () => { + registerTestMode("acode-exact-foo", "^acodepluginfile"); + registerTestMode("acode-regex-foo", "", { + filenameMatchers: [/^acodepluginfile.*/], + }); + + expect(getModeForPath("acodepluginfile").name).toBe( + legacyGetModeForPath("acodepluginfile").name, + ); + expect(getModeForPath("acodepluginfile").name).toBe("acode-regex-foo"); + }); + + it("invalidates plugin registrations immediately", () => { + registerTestMode("acodepluginmode", "acodepluginmode"); + + expect(getModeForPath("demo.acodepluginmode").name).toBe("acodepluginmode"); + expect(getModes().some((mode) => mode.name === "acodepluginmode")).toBe( + true, + ); + + removeMode("acodepluginmode"); + addedModes.pop(); + + expect(getModeForPath("demo.acodepluginmode").name).toBe("text"); + }); + + it("keeps mode-level ranking when a longer suffix is claimed by a lower-ranked mode", () => { + registerTestMode("acode-long-ts", "ts|verylongextension"); + registerTestMode("acode-dts", "d.ts"); + + expect(getModeForPath("types.d.ts").name).toBe( + legacyGetModeForPath("types.d.ts").name, + ); + expect(getModeForPath("types.d.ts").name).toBe("acode-long-ts"); + expect(getModeForPath("file.verylongextension").name).toBe("acode-long-ts"); + expect(getModeForPath("file.ts").name).toBe("acode-long-ts"); + }); + + it("matches the previous sort-and-scan result for a large mixed folder", () => { + const files = []; + for (let i = 0; i < 50; i++) { + files.push(`src/app${i}.js`); + files.push(`src/app${i}.ts`); + files.push(`src/app${i}.json`); + files.push(`src/app${i}.py`); + files.push(`src/app${i}.md`); + files.push(`src/app${i}.css`); + files.push(`src/app${i}.unknownext`); + files.push("Dockerfile"); + files.push("nginx.conf"); + files.push("CMakeLists.txt"); + files.push("types.d.ts"); + } + + const mismatches = []; + for (const file of files) { + const next = getModeForPath(file); + const legacy = legacyGetModeForPath(file); + if (next !== legacy) { + mismatches.push({ + file, + next: next?.name, + legacy: legacy?.name, + }); + } + } + + expect(mismatches).toEqual([]); + }); +});