From 11e992651b29b69227f5715d55ba15d2874c9e24 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:35:46 +0530 Subject: [PATCH 1/2] perf(editor): index language modes instead of sorting on every lookup getModeForPath copied and sorted all registered modes per file, and the comparator recomputed specificity plus indexOf. File trees and icon plugins hit this for every entry. Rebuild a specificity index only when modes are added or removed, look up exact names / filename regexes / longest extension, and cache by basename. Matching still goes through supportsFile, with a scan fallback, so plugin register/unregister and later-wins ties stay the same. Keep resolving file-tree icons at render time so plugins that replace helpers.getIconForFile (for example Material Icons) still apply. --- src/cm/modelist.ts | 212 ++++++++++++++++++++--- src/components/fileTree/index.js | 1 + tests/unit/modelist.test.js | 283 +++++++++++++++++++++++++++++++ 3 files changed, 469 insertions(+), 27 deletions(-) create mode 100644 tests/unit/modelist.test.js diff --git a/src/cm/modelist.ts b/src/cm/modelist.ts index 8d5e782f04..fc276847ed 100644 --- a/src/cm/modelist.ts +++ b/src/cm/modelist.ts @@ -14,6 +14,23 @@ 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; +} + +let modeIndex: ModeIndex | null = null; +const resolvedByFileName = new Map(); + function normalizeModeKey(value: string): string { return String(value ?? "") .trim() @@ -34,6 +51,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 +97,7 @@ export function addMode( } }); modes.push(mode); + invalidateModeIndex(); } /** @@ -84,36 +115,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); + if (modeIndexInList >= 0) { + modes.splice(modeIndexInList, 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; - } - } - return mode; + invalidateModeIndex(); } /** @@ -157,6 +165,156 @@ 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 namedChecks: NamedCheck[] = []; + const extensions = new Map(); + + 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 }; + return modeIndex; +} + +function findModeByExtension( + fileNameLower: string, + extensions: Map, +): Mode | undefined { + let dot = fileNameLower.indexOf("."); + while (dot >= 0 && dot < fileNameLower.length - 1) { + const mode = extensions.get(fileNameLower.slice(dot + 1)); + if (mode) return mode; + dot = fileNameLower.indexOf(".", dot + 1); + } + return undefined; +} + +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); + 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..30549bc9c3 --- /dev/null +++ b/tests/unit/modelist.test.js @@ -0,0 +1,283 @@ +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"); + }); +}); + +describe("getModeForPath speed", () => { + it("is much faster than copying and sorting modes per lookup", () => { + const files = []; + for (let i = 0; i < 400; 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"); + } + + legacyGetModeForPath(files[0]); + getModeForPath(`cold-index-${Date.now()}.js`); + + const legacyStart = performance.now(); + for (const file of files) { + legacyGetModeForPath(file); + } + const legacyMs = performance.now() - legacyStart; + + const indexedStart = performance.now(); + for (const file of files) { + getModeForPath(file); + } + const indexedMs = performance.now() - indexedStart; + + const warmStart = performance.now(); + for (const file of files) { + getModeForPath(file); + } + const warmMs = performance.now() - warmStart; + + console.log( + `[modelist] ${files.length} lookups — legacy ${legacyMs.toFixed(2)}ms, indexed ${indexedMs.toFixed(2)}ms, cached ${warmMs.toFixed(2)}ms, speedup ${(legacyMs / Math.max(indexedMs, 0.001)).toFixed(1)}x`, + ); + + expect(indexedMs).toBeLessThan(legacyMs); + if (legacyMs >= 8) { + expect(indexedMs * 5).toBeLessThan(legacyMs); + } + expect(warmMs).toBeLessThanOrEqual(indexedMs + 1); + }); +}); From 8be0d23a190db1e04f53d6bff60370e1a4aa7aa1 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:47:45 +0530 Subject: [PATCH 2/2] fix --- src/cm/modelist.ts | 27 +++++++++++++++--- tests/unit/modelist.test.js | 57 +++++++++++++++++-------------------- 2 files changed, 49 insertions(+), 35 deletions(-) diff --git a/src/cm/modelist.ts b/src/cm/modelist.ts index fc276847ed..64038b7101 100644 --- a/src/cm/modelist.ts +++ b/src/cm/modelist.ts @@ -26,6 +26,7 @@ interface ModeIndex { sorted: Mode[]; namedChecks: NamedCheck[]; extensions: Map; + rankByMode: Map; } let modeIndex: ModeIndex | null = null; @@ -216,9 +217,14 @@ function getModeIndex(): ModeIndex { }); 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("|")) { @@ -245,21 +251,30 @@ function getModeIndex(): ModeIndex { } } - modeIndex = { sorted, namedChecks, extensions }; + 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) return mode; + if (mode) { + const rank = rankByMode.get(mode) ?? Number.POSITIVE_INFINITY; + if (rank < bestRank) { + best = mode; + bestRank = rank; + } + } dot = fileNameLower.indexOf(".", dot + 1); } - return undefined; + return best; } function resolveModeForPath(fileName: string): Mode { @@ -286,7 +301,11 @@ function resolveModeForPath(fileName: string): Mode { } } - const byExtension = findModeByExtension(fileNameLower, index.extensions); + const byExtension = findModeByExtension( + fileNameLower, + index.extensions, + index.rankByMode, + ); if (byExtension?.supportsFile?.(fileName)) return byExtension; for (const mode of index.sorted) { diff --git a/tests/unit/modelist.test.js b/tests/unit/modelist.test.js index 30549bc9c3..beab912787 100644 --- a/tests/unit/modelist.test.js +++ b/tests/unit/modelist.test.js @@ -231,12 +231,22 @@ describe("getModeForPath", () => { expect(getModeForPath("demo.acodepluginmode").name).toBe("text"); }); -}); -describe("getModeForPath speed", () => { - it("is much faster than copying and sorting modes per lookup", () => { + 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 < 400; i++) { + 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`); @@ -247,37 +257,22 @@ describe("getModeForPath speed", () => { files.push("Dockerfile"); files.push("nginx.conf"); files.push("CMakeLists.txt"); + files.push("types.d.ts"); } - legacyGetModeForPath(files[0]); - getModeForPath(`cold-index-${Date.now()}.js`); - - const legacyStart = performance.now(); - for (const file of files) { - legacyGetModeForPath(file); - } - const legacyMs = performance.now() - legacyStart; - - const indexedStart = performance.now(); - for (const file of files) { - getModeForPath(file); - } - const indexedMs = performance.now() - indexedStart; - - const warmStart = performance.now(); + const mismatches = []; for (const file of files) { - getModeForPath(file); + const next = getModeForPath(file); + const legacy = legacyGetModeForPath(file); + if (next !== legacy) { + mismatches.push({ + file, + next: next?.name, + legacy: legacy?.name, + }); + } } - const warmMs = performance.now() - warmStart; - - console.log( - `[modelist] ${files.length} lookups — legacy ${legacyMs.toFixed(2)}ms, indexed ${indexedMs.toFixed(2)}ms, cached ${warmMs.toFixed(2)}ms, speedup ${(legacyMs / Math.max(indexedMs, 0.001)).toFixed(1)}x`, - ); - expect(indexedMs).toBeLessThan(legacyMs); - if (legacyMs >= 8) { - expect(indexedMs * 5).toBeLessThan(legacyMs); - } - expect(warmMs).toBeLessThanOrEqual(indexedMs + 1); + expect(mismatches).toEqual([]); }); });