diff --git a/src/cm/modelist.ts b/src/cm/modelist.ts index 64038b7101..bebd133793 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 { @@ -352,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; @@ -360,6 +371,7 @@ export class Mode { aliases: string[]; extRe: RegExp | null; filenameMatchers: RegExp[]; + formatAs: string | null; languageExtension: LanguageExtensionProvider | null; constructor( @@ -377,10 +389,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..e727b447b6 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, + getFormatAsMode, 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 = getFormatAsMode(modeName); + 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..e95014c0cf 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 { getFormatAsMode } 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 = getFormatAsMode(modeName); + 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..b7d97aed44 100644 --- a/src/settings/formatterSettings.js +++ b/src/settings/formatterSettings.js @@ -1,4 +1,4 @@ -import { getModes } from "cm/modelist"; +import { getFormatAsMode, getMode, getModes } from "cm/modelist"; import settingsPage from "components/settingsPage"; import appSettings from "lib/settings"; @@ -21,8 +21,20 @@ export default function formatterSettings(languageName) { 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(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; + }; return { key: name, @@ -34,9 +46,13 @@ export default function formatterSettings(languageName) { if (formatter) { return formatter.name; } - return strings.none; + return (baseMode && inheritedText()) || strings.none; + }, + // Read when the picker opens, so the inherited label stays current + get select() { + if (baseMode) options[0] = [null, inheritedText() || strings.none]; + return options; }, - select: options, chevron: true, category: languagesLabel, }; diff --git a/tests/unit/modelist.test.js b/tests/unit/modelist.test.js index ada2cb3821..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") { @@ -235,6 +241,43 @@ 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("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), + ).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");