From c9b8902307605949613ebe65d4830a5cef69a0d9 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:06:54 +0530 Subject: [PATCH 1/3] feat(modes): let plugin modes reuse another mode's formatter Add a formatAs option to editorLanguages.register so a plugin mode (e.g. json-bedrock) formats with the formatter and Prettier parser of an existing mode (e.g. json) when the user hasn't picked one for it. editorLanguages.register now forwards its options to addMode, and Mode ignores malformed options instead of throwing. --- src/cm/modelist.ts | 18 +++++++++++++++--- src/lib/acode.js | 12 ++++++++---- src/lib/prettierFormatter.js | 5 ++++- src/settings/formatterSettings.js | 18 +++++++++++++++--- tests/unit/modelist.test.js | 24 ++++++++++++++++++++++++ 5 files changed, 66 insertions(+), 11 deletions(-) diff --git a/src/cm/modelist.ts b/src/cm/modelist.ts index 64038b7101..8f1927a0fc 100644 --- a/src/cm/modelist.ts +++ b/src/cm/modelist.ts @@ -5,6 +5,8 @@ export type LanguageExtensionProvider = () => Extension | Promise; export interface AddModeOptions { aliases?: string[]; filenameMatchers?: RegExp[]; + /** Mode whose formatter (and Prettier parser) this mode reuses. */ + formatAs?: string; } export interface ModesByName { @@ -360,6 +362,7 @@ export class Mode { aliases: string[]; extRe: RegExp | null; filenameMatchers: RegExp[]; + formatAs: string | null; languageExtension: LanguageExtensionProvider | null; constructor( @@ -377,10 +380,19 @@ export class Mode { this.mode = name; // CodeMirror uses different mode naming this.extensions = extensions; this.caption = caption || this.name.replace(/_/g, " "); - this.aliases = normalizeAliases(options.aliases, this.name); - this.filenameMatchers = Array.isArray(options.filenameMatchers) - ? options.filenameMatchers.filter((matcher) => matcher instanceof RegExp) + // Plugins may pass anything here; ignore what isn't the expected shape + const { aliases, filenameMatchers, formatAs } = + options && typeof options === "object" ? options : {}; + this.aliases = Array.isArray(aliases) + ? normalizeAliases(aliases, this.name) : []; + this.filenameMatchers = Array.isArray(filenameMatchers) + ? filenameMatchers.filter((matcher) => matcher instanceof RegExp) + : []; + this.formatAs = + typeof formatAs === "string" && normalizeModeKey(formatAs) + ? normalizeModeKey(formatAs) + : null; this.languageExtension = languageExtension; let re = ""; diff --git a/src/lib/acode.js b/src/lib/acode.js index 2870fc0a98..d79e17ec54 100644 --- a/src/lib/acode.js +++ b/src/lib/acode.js @@ -22,6 +22,7 @@ import lspClientManager from "cm/lsp/clientManager"; import { registerLspFormatter } from "cm/lsp/formatter"; import { addMode, + getMode, getModeForPath, getModes, getModesByName, @@ -275,9 +276,10 @@ class Acode { // Preferred CodeMirror language registration API for plugins const editorLanguages = { // name: string, extensions: string|Array, caption?: string, - // loader?: () => Extension | Promise - register: (name, extensions, caption, loader) => - addMode(name, extensions, caption, loader), + // loader?: () => Extension | Promise, + // options?: { aliases?, filenameMatchers?, formatAs? } + register: (name, extensions, caption, loader, options) => + addMode(name, extensions, caption, loader, options), unregister: (name) => removeMode(name), add: (name, extensions, caption, loader) => addMode(name, extensions, caption, loader), @@ -844,7 +846,9 @@ class Acode { } const modeName = resolvedMode || "text"; const formatterMap = appSettings.value.formatter || {}; - const formatterId = formatterMap[modeName]; + const formatAs = getMode(modeName)?.formatAs; + const formatterId = + formatterMap[modeName] ?? (formatAs && formatterMap[formatAs]); const formatter = this.#formatter.find(({ id }) => id === formatterId); if (!formatter) { diff --git a/src/lib/prettierFormatter.js b/src/lib/prettierFormatter.js index 7200e5646b..e09572cf10 100644 --- a/src/lib/prettierFormatter.js +++ b/src/lib/prettierFormatter.js @@ -1,6 +1,7 @@ import fsOperation from "fileSystem"; import { parse } from "acorn"; import { getDocText } from "cm/editorUtils"; +import { getMode } from "cm/modelist"; import toast from "components/toast"; import appSettings from "lib/settings"; import prettierPluginBabel from "prettier/plugins/babel"; @@ -77,7 +78,9 @@ export async function formatActiveFileWithPrettier() { if (!file || file.type !== "editor" || !editor) return false; const modeName = (file.currentMode || "text").toLowerCase(); - const parser = getParserForMode(modeName); + const formatAs = getMode(modeName)?.formatAs; + const parser = + getParserForMode(modeName) || (formatAs && getParserForMode(formatAs)); if (!parser) { toast("Prettier does not support this file type yet"); return false; diff --git a/src/settings/formatterSettings.js b/src/settings/formatterSettings.js index 604caec673..93cc71c78f 100644 --- a/src/settings/formatterSettings.js +++ b/src/settings/formatterSettings.js @@ -1,4 +1,4 @@ -import { getModes } from "cm/modelist"; +import { getMode, getModes } from "cm/modelist"; import settingsPage from "components/settingsPage"; import appSettings from "lib/settings"; @@ -15,13 +15,18 @@ export default function formatterSettings(languageName) { String(a.caption || a.name).localeCompare(String(b.caption || b.name)), ) .map((mode) => { - const { name, caption, extensions } = mode; + const { name, caption, extensions, formatAs } = mode; const formatterID = values.formatter[name] || null; // Only pass real extensions (skip anchored filename patterns like ^Dockerfile) const extList = String(extensions) .split("|") .filter((e) => e && !e.startsWith("^")); - const options = acode.getFormatterFor(extList); + // Also offer formatters of the mode this one formats as + const baseMode = getMode(formatAs); + const baseExts = String(baseMode?.extensions || "") + .split("|") + .filter((e) => e && !e.startsWith("^")); + const options = acode.getFormatterFor([...extList, ...baseExts]); const sampleExt = extList[0] || name; return { @@ -34,6 +39,13 @@ export default function formatterSettings(languageName) { if (formatter) { return formatter.name; } + // Unset: format() falls back to the base mode's formatter + const inherited = + baseMode && + formatters.find(({ id }) => id === values.formatter[formatAs]); + if (inherited) { + return `${inherited.name} (${baseMode.caption})`; + } return strings.none; }, select: options, diff --git a/tests/unit/modelist.test.js b/tests/unit/modelist.test.js index ada2cb3821..266975e93e 100644 --- a/tests/unit/modelist.test.js +++ b/tests/unit/modelist.test.js @@ -235,6 +235,30 @@ describe("getModeForPath", () => { expect(getModeForPath("acodepluginfile").name).toBe("acode-regex-foo"); }); + it("stores the normalized formatAs mode", () => { + registerTestMode("acode-json-like", "acodejsonlike", { + formatAs: " JSON ", + }); + registerTestMode("acode-plain", "acodeplain"); + + expect(getModeForPath("a.acodejsonlike").formatAs).toBe("json"); + expect(getModeForPath("a.acodeplain").formatAs).toBeNull(); + }); + + it("ignores malformed mode options from plugins", () => { + expect(() => + registerTestMode("acode-null-options", "acodenullopts", null), + ).not.toThrow(); + registerTestMode("acode-bad-options", "acodebadopts", { + aliases: "abc", + formatAs: 42, + }); + + const mode = getModeForPath("a.acodebadopts"); + expect(mode.aliases).toEqual([]); + expect(mode.formatAs).toBeNull(); + }); + it("invalidates plugin registrations immediately", () => { registerTestMode("acodepluginmode", "acodepluginmode"); From 8f6d968f7142f93418503c8483848cee6c719e9d Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:13:43 +0530 Subject: [PATCH 2/3] fix(modes): resolve formatAs aliases and label the inherited choice Resolve formatAs through the mode list so an alias (e.g. json5) finds the base mode's saved formatter and Prettier parser. For modes with formatAs, the picker's unset option now names the inherited formatter (e.g. "Prettier (JSON)") instead of "None". --- src/cm/modelist.ts | 9 +++++++++ src/lib/acode.js | 4 ++-- src/lib/prettierFormatter.js | 4 ++-- src/settings/formatterSettings.js | 23 ++++++++++++----------- tests/unit/modelist.test.js | 21 ++++++++++++++++++++- 5 files changed, 45 insertions(+), 16 deletions(-) diff --git a/src/cm/modelist.ts b/src/cm/modelist.ts index 8f1927a0fc..bebd133793 100644 --- a/src/cm/modelist.ts +++ b/src/cm/modelist.ts @@ -354,6 +354,15 @@ export function getMode(name: string): Mode | null { return modesByName[normalizeModeKey(name)] || null; } +/** + * Name of the mode that `name` formats as, with aliases resolved, or `null`. + */ +export function getFormatAsMode(name: string): string | null { + const formatAs = getMode(name)?.formatAs; + if (!formatAs) return null; + return getMode(formatAs)?.name ?? formatAs; +} + export class Mode { extensions: string; caption: string; diff --git a/src/lib/acode.js b/src/lib/acode.js index d79e17ec54..e727b447b6 100644 --- a/src/lib/acode.js +++ b/src/lib/acode.js @@ -22,7 +22,7 @@ import lspClientManager from "cm/lsp/clientManager"; import { registerLspFormatter } from "cm/lsp/formatter"; import { addMode, - getMode, + getFormatAsMode, getModeForPath, getModes, getModesByName, @@ -846,7 +846,7 @@ class Acode { } const modeName = resolvedMode || "text"; const formatterMap = appSettings.value.formatter || {}; - const formatAs = getMode(modeName)?.formatAs; + const formatAs = getFormatAsMode(modeName); const formatterId = formatterMap[modeName] ?? (formatAs && formatterMap[formatAs]); const formatter = this.#formatter.find(({ id }) => id === formatterId); diff --git a/src/lib/prettierFormatter.js b/src/lib/prettierFormatter.js index e09572cf10..e95014c0cf 100644 --- a/src/lib/prettierFormatter.js +++ b/src/lib/prettierFormatter.js @@ -1,7 +1,7 @@ import fsOperation from "fileSystem"; import { parse } from "acorn"; import { getDocText } from "cm/editorUtils"; -import { getMode } from "cm/modelist"; +import { getFormatAsMode } from "cm/modelist"; import toast from "components/toast"; import appSettings from "lib/settings"; import prettierPluginBabel from "prettier/plugins/babel"; @@ -78,7 +78,7 @@ export async function formatActiveFileWithPrettier() { if (!file || file.type !== "editor" || !editor) return false; const modeName = (file.currentMode || "text").toLowerCase(); - const formatAs = getMode(modeName)?.formatAs; + const formatAs = getFormatAsMode(modeName); const parser = getParserForMode(modeName) || (formatAs && getParserForMode(formatAs)); if (!parser) { diff --git a/src/settings/formatterSettings.js b/src/settings/formatterSettings.js index 93cc71c78f..6092d51f6d 100644 --- a/src/settings/formatterSettings.js +++ b/src/settings/formatterSettings.js @@ -1,4 +1,4 @@ -import { getMode, getModes } from "cm/modelist"; +import { getFormatAsMode, getMode, getModes } from "cm/modelist"; import settingsPage from "components/settingsPage"; import appSettings from "lib/settings"; @@ -15,19 +15,27 @@ export default function formatterSettings(languageName) { String(a.caption || a.name).localeCompare(String(b.caption || b.name)), ) .map((mode) => { - const { name, caption, extensions, formatAs } = mode; + const { name, caption, extensions } = mode; const formatterID = values.formatter[name] || null; // Only pass real extensions (skip anchored filename patterns like ^Dockerfile) const extList = String(extensions) .split("|") .filter((e) => e && !e.startsWith("^")); // Also offer formatters of the mode this one formats as - const baseMode = getMode(formatAs); + const baseMode = getMode(getFormatAsMode(name) || ""); const baseExts = String(baseMode?.extensions || "") .split("|") .filter((e) => e && !e.startsWith("^")); const options = acode.getFormatterFor([...extList, ...baseExts]); const sampleExt = extList[0] || name; + // Unset falls back to the base mode's formatter, so say which one + const inheritedText = () => { + const inherited = formatters.find( + ({ id }) => id === values.formatter[baseMode.name], + ); + return inherited ? `${inherited.name} (${baseMode.caption})` : null; + }; + if (baseMode) options[0] = [null, inheritedText() || strings.none]; return { key: name, @@ -39,14 +47,7 @@ export default function formatterSettings(languageName) { if (formatter) { return formatter.name; } - // Unset: format() falls back to the base mode's formatter - const inherited = - baseMode && - formatters.find(({ id }) => id === values.formatter[formatAs]); - if (inherited) { - return `${inherited.name} (${baseMode.caption})`; - } - return strings.none; + return (baseMode && inheritedText()) || strings.none; }, select: options, chevron: true, diff --git a/tests/unit/modelist.test.js b/tests/unit/modelist.test.js index 266975e93e..612dacfcbb 100644 --- a/tests/unit/modelist.test.js +++ b/tests/unit/modelist.test.js @@ -1,6 +1,12 @@ import { afterEach, describe, expect, it } from "vitest"; import "cm/supportedModes"; -import { addMode, getModeForPath, getModes, removeMode } from "cm/modelist"; +import { + addMode, + getFormatAsMode, + getModeForPath, + getModes, + removeMode, +} from "cm/modelist"; function getModeSpecificityScore(modeInstance) { if (modeInstance.name.toLowerCase() === "text") { @@ -245,6 +251,19 @@ describe("getModeForPath", () => { expect(getModeForPath("a.acodeplain").formatAs).toBeNull(); }); + it("resolves formatAs aliases to the base mode name", () => { + registerTestMode("acode-json5-like", "acodejson5like", { + formatAs: "json5", + }); + registerTestMode("acode-unknown-base", "acodeunknownbase", { + formatAs: "acode-missing", + }); + + expect(getFormatAsMode("acode-json5-like")).toBe("json"); + expect(getFormatAsMode("acode-unknown-base")).toBe("acode-missing"); + expect(getFormatAsMode("json")).toBeNull(); + }); + it("ignores malformed mode options from plugins", () => { expect(() => registerTestMode("acode-null-options", "acodenullopts", null), From f89d80b911d8f21332c2c659a1fe2ce8da02a46d Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:21:46 +0530 Subject: [PATCH 3/3] fix(settings): refresh inherited formatter label when the picker opens --- src/settings/formatterSettings.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/settings/formatterSettings.js b/src/settings/formatterSettings.js index 6092d51f6d..b7d97aed44 100644 --- a/src/settings/formatterSettings.js +++ b/src/settings/formatterSettings.js @@ -35,7 +35,6 @@ export default function formatterSettings(languageName) { ); return inherited ? `${inherited.name} (${baseMode.caption})` : null; }; - if (baseMode) options[0] = [null, inheritedText() || strings.none]; return { key: name, @@ -49,7 +48,11 @@ export default function formatterSettings(languageName) { } return (baseMode && inheritedText()) || strings.none; }, - select: options, + // Read when the picker opens, so the inherited label stays current + get select() { + if (baseMode) options[0] = [null, inheritedText() || strings.none]; + return options; + }, chevron: true, category: languagesLabel, };