From 8dedf5e73b86d6a8eeee95dfffb044558cd435f2 Mon Sep 17 00:00:00 2001 From: Mariano Dupont Date: Thu, 5 Nov 2026 01:59:38 -0300 Subject: [PATCH] fix(themes): preserve language-specific ICLS styles (#1457) Importing a PhpStorm .icls applied Java/Kotlin keys and PHP-only colours to every language, so PHP variables, strings and punctuation came out wrong. Java, Kotlin and PHP attributes are now kept per language (Color Scheme lists them under their own nodes, and editors scope them by data-language), explicit attribute inheritance is resolved, an explicit TextAttributes value resets omitted bold/italic, the light/dark parent follows the scheme's background, and PHP classes, method declarations, fields, brackets and separators get their own tags. Selecting a language attribute in Settings previews that language's colours. Co-authored-by: marianomd <274989+marianomd@users.noreply.github.com> --- e2e/icls-php.spec.ts | 77 ++++++++++++++++++ src/editor/core/cm/highlight.ts | 20 +++-- src/editor/core/cm/languages.test.ts | 21 +++++ src/editor/core/cm/languages.ts | 17 +++- src/editor/lsp/theme.ts | 8 ++ .../settings/pages/editor/colorScheme.tsx | 15 +++- .../colorSchemes/monokai-php.icls | 24 ++++++ src/platform/colorScheme.ts | 38 ++++++++- src/platform/icls.test.ts | 47 +++++++++-- src/platform/icls.ts | 80 +++++++++++++++---- src/platform/locales/text/es.json | 4 + src/platform/locales/text/zh.json | 4 + 12 files changed, 318 insertions(+), 37 deletions(-) create mode 100644 e2e/icls-php.spec.ts create mode 100644 src/platform/__fixtures__/colorSchemes/monokai-php.icls diff --git a/e2e/icls-php.spec.ts b/e2e/icls-php.spec.ts new file mode 100644 index 00000000..6a199e61 --- /dev/null +++ b/e2e/icls-php.spec.ts @@ -0,0 +1,77 @@ +import { readFileSync } from "node:fs"; +import { expect, test } from "./fixtures"; + +const xml = readFileSync( + process.env.OPENIDEA_ICLS_FIXTURE ?? "src/platform/__fixtures__/colorSchemes/monokai-php.icls", + "utf8", +); + +test("imported PHP colours stay separate from Java and preserve regular text on a dark background", async ({ + workbench: page, +}, info) => { + await page.evaluate(async (xml) => { + const iclsPath = "/src/platform/icls.ts"; + const projectPath = "/src/platform/project.ts"; + const backendPath = "/src/editor/core/backend.ts"; + const contributionsPath = "/src/workbench/contributions.ts"; + const { parseIcls } = await import(iclsPath); + const { useProject } = await import(projectPath); + const { docBackend } = await import(backendPath); + const { editorService } = await import(contributionsPath); + const previous = useProject.getState().settings ?? {}; + useProject.setState({ + settings: { + ...previous, + editor: { + ...previous.editor, + colorScheme: "custom-php", + customSchemes: { "custom-php": parseIcls(xml).scheme }, + semanticHighlighting: false, + }, + }, + }); + const backend = docBackend(); + const open = backend.open.bind(backend); + backend.open = async (...args: Parameters) => { + const result = await open(...args); + return args[0].endsWith(".php") ? { ...result, language: "php", uiMode: "PHP" } : result; + }; + backend.files.set( + "/dev/acme-api/palette.php", + "name == 10;\n }\n}\n", + ); + await editorService().openFile("/dev/acme-api/palette.php"); + }, xml); + const php = page.locator('.cm-editor[data-language="PHP"]'); + await expect(php).toBeVisible(); + await expect(php).toHaveCSS("background-color", "rgb(0, 0, 0)"); + const token = (text: string) => + php + .locator(".cm-content span") + .filter({ hasText: text }) + .filter({ hasNot: php.locator("span span") }); + await expect(token("$name").first()).toHaveCSS("color", "rgb(72, 216, 238)"); + await expect(token("'Palette'")).toHaveCSS("color", "rgb(255, 231, 146)"); + await expect(token("'Palette'")).toHaveCSS("font-weight", "400"); + await expect(token("class").first()).toHaveCSS("color", "rgb(249, 38, 114)"); + await expect(token("Plain PHP documentation")).toHaveCSS("font-style", "normal"); + await expect(token("BasePalette")).toHaveCSS("color", "rgb(174, 129, 255)"); + await expect(token(";").first()).toHaveCSS("color", "rgb(255, 255, 255)"); + await expect(token("example")).toHaveCSS("color", "rgb(141, 225, 45)"); + await expect(token("$name").first()).toHaveCSS("font-weight", "700"); + await page.evaluate(async () => { + const backendPath = "/src/editor/core/backend.ts"; + const contributionsPath = "/src/workbench/contributions.ts"; + const { docBackend } = await import(backendPath); + const { editorService } = await import(contributionsPath); + docBackend().files.set("/dev/acme-api/Palette.java", 'class Palette { String name = "Palette"; }'); + await editorService().openFile("/dev/acme-api/Palette.java"); + }); + const java = page.locator('.cm-editor[data-language="Java"]'); + await expect(java).toBeVisible(); + await expect(java.locator(".cm-content span").filter({ hasText: '"Palette"' })).toHaveCSS("font-weight", "700"); + await page.getByRole("tab", { name: "palette.php", exact: true }).click(); + await expect(php).toBeVisible(); + await expect(token("'Palette'")).toHaveCSS("font-weight", "400"); + await php.screenshot({ path: info.outputPath("php-import.png"), animations: "disabled" }); +}); diff --git a/src/editor/core/cm/highlight.ts b/src/editor/core/cm/highlight.ts index d46306da..4518a087 100644 --- a/src/editor/core/cm/highlight.ts +++ b/src/editor/core/cm/highlight.ts @@ -9,17 +9,19 @@ import { tags as t, type Tag } from "@lezer/highlight"; function token( tag: Tag | Tag[], name: string, - defaults: { italic?: string; bold?: string; effect?: string } = {}, + defaults: { italic?: string; bold?: string; effect?: string; color?: string } = {}, ): TagStyle { + const variable = (suffix: string, fallback: string) => + `var(--syn-${name}${suffix}, ${defaults.color ? `var(--syn-${defaults.color}${suffix}, ${fallback})` : fallback})`; return { tag, - color: `var(--syn-${name})`, - fontStyle: `var(--syn-${name}-italic, ${defaults.italic ?? "inherit"})`, - fontWeight: `var(--syn-${name}-bold, ${defaults.bold ?? "inherit"})`, - backgroundColor: `var(--syn-${name}-bg, transparent)`, - textDecoration: `var(--syn-${name}-effect, ${defaults.effect ?? "none"})`, + color: defaults.color ? variable("", "inherit") : `var(--syn-${name})`, + fontStyle: variable("-italic", defaults.italic ?? "inherit"), + fontWeight: variable("-bold", defaults.bold ?? "inherit"), + backgroundColor: variable("-bg", "transparent"), + textDecoration: variable("-effect", defaults.effect ?? "none"), // WebKit takes a decoration with a style or colour only through the prefixed property. - WebkitTextDecoration: `var(--syn-${name}-effect, ${defaults.effect ?? "none"})`, + WebkitTextDecoration: variable("-effect", defaults.effect ?? "none"), }; } @@ -71,10 +73,12 @@ export const openideaHighlightStyle = HighlightStyle.define([ token(t.special(t.brace), "decorator"), token([t.tagName, t.angleBracket], "tag"), token([t.punctuation, t.bracket, t.separator, t.operator, t.derefOperator], "punct"), + token(t.bracket, "bracket", { color: "punct" }), + token(t.separator, "separator", { color: "punct" }), token(t.invalid, "invalid"), // Markup (Markdown, docs) token(t.heading, "markup-heading", { bold: "var(--fw-code-bold, 700)" }), - token(t.processingInstruction, "keyword"), + token(t.processingInstruction, "processing-instruction", { color: "keyword" }), { tag: t.emphasis, fontStyle: "italic" }, { tag: t.strong, fontWeight: "var(--fw-code-bold, 700)" }, { tag: t.strikethrough, textDecoration: "line-through" }, diff --git a/src/editor/core/cm/languages.test.ts b/src/editor/core/cm/languages.test.ts index 72d674ea..c6e71b62 100644 --- a/src/editor/core/cm/languages.test.ts +++ b/src/editor/core/cm/languages.test.ts @@ -3,6 +3,27 @@ import { highlightTree, tagHighlighter, tags as t } from "@lezer/highlight"; import { findLanguageDescription, loadSupport } from "./languages"; describe("language modes", () => { + it("distinguishes PHP classes, method declarations and fields for imported schemes", async () => { + const support = await loadSupport(findLanguageDescription("PHP", "/tmp/palette.php")!); + const source = " = []; + highlightTree( + support!.language.parser.parse(source), + tagHighlighter([ + { tag: t.className, class: "class" }, + { tag: t.function(t.definition(t.variableName)), class: "function" }, + { tag: t.definition(t.propertyName), class: "field" }, + ]), + (from, to, style) => found.push([source.slice(from, to), style]), + ); + expect(found).toEqual([ + ["Palette", "class"], + ["BasePalette", "class"], + ["$name", "field"], + ["example", "function"], + ]); + }); + it("recognizes AsciiDoc files and loads their lightweight syntax mode", async () => { for (const path of ["/docs/guide.adoc", "/docs/guide.asciidoc"]) { const description = findLanguageDescription("AsciiDoc", path); diff --git a/src/editor/core/cm/languages.ts b/src/editor/core/cm/languages.ts index ddff2757..2c61f901 100644 --- a/src/editor/core/cm/languages.ts +++ b/src/editor/core/cm/languages.ts @@ -5,6 +5,7 @@ * and indentation fall back to the backend definition when the mode lacks them. */ import { EditorState, Prec, type Extension } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; import { LRLanguage, LanguageDescription, @@ -246,6 +247,12 @@ const EXTRA_TAGS: Record[0]> = { JSX: JS_TAGS, TSX: JS_TAGS, Java: JAVA_TAGS, + PHP: { + "; ,": t.separator, + "ClassDeclaration/Name BaseClause/Name InterfaceClause/Name": t.className, + "MethodDeclaration/Name FunctionDefinition/Name": t.function(t.definition(t.variableName)), + "PropertyDeclaration/VariableDeclarator/VariableName": t.definition(t.propertyName), + }, CSS: CSS_TAGS, SCSS: CSS_TAGS, Sass: CSS_TAGS, @@ -255,8 +262,14 @@ const EXTRA_TAGS: Record[0]> = { function patchSupport(desc: LanguageDescription, support: LanguageSupport): LanguageSupport { const extra = EXTRA_TAGS[desc.name]; - if (!extra || !(support.language instanceof LRLanguage)) return support; - const patched = new LanguageSupport(support.language.configure({ props: [styleTags(extra)] }), support.support); + const language = + extra && support.language instanceof LRLanguage + ? support.language.configure({ props: [styleTags(extra)] }) + : support.language; + const patched = new LanguageSupport(language, [ + support.support, + EditorView.editorAttributes.of({ "data-language": desc.name }), + ]); // `loadedSupport` reads `desc.support` synchronously; keep it on the patched instance. (desc as { support: LanguageSupport | undefined }).support = patched; return patched; diff --git a/src/editor/lsp/theme.ts b/src/editor/lsp/theme.ts index aff186e7..8ea732ac 100644 --- a/src/editor/lsp/theme.ts +++ b/src/editor/lsp/theme.ts @@ -365,6 +365,14 @@ export const lspTheme = EditorView.theme({ ".cm-sem-global:not(.cm-sem-function)": { color: "var(--syn-global, inherit)" }, ".cm-sem-builtin:not(.cm-sem-function)": { color: "var(--syn-builtin, inherit)" }, ".cm-sem-static": { fontStyle: "var(--syn-static-italic, italic)" }, + ".cm-sem-property.cm-sem-static, .cm-sem-property.cm-sem-static *": { + color: "var(--syn-static-property, var(--syn-property))", + fontStyle: "var(--syn-static-property-italic, var(--syn-static-italic, italic))", + fontWeight: "var(--syn-static-property-bold, var(--syn-property-bold, inherit))", + backgroundColor: "var(--syn-static-property-bg, transparent)", + textDecoration: "var(--syn-static-property-effect, none)", + WebkitTextDecoration: "var(--syn-static-property-effect, none)", + }, ".cm-sem-python-static": { fontStyle: "var(--syn-python-static-italic, normal)" }, ".cm-sem-mutable": { textDecoration: "underline", diff --git a/src/features/settings/pages/editor/colorScheme.tsx b/src/features/settings/pages/editor/colorScheme.tsx index 16024a1e..6831c494 100644 --- a/src/features/settings/pages/editor/colorScheme.tsx +++ b/src/features/settings/pages/editor/colorScheme.tsx @@ -55,11 +55,15 @@ function buildTree(): Node[] { const TREE = buildTree(); -/** CSS custom properties of a scheme plus pending overrides (for the preview). */ +/** + * CSS custom properties of a scheme plus pending overrides (for the preview); with + * `language`, that language's own colours on top, as its editors show them. + */ function previewVars( id: string, o: Record | undefined, customs: CustomColorSchemes, + language?: string, ): CSSProperties { const vars: Record = {}; const tokens = resolvedSchemeTokens(id, customs); @@ -74,6 +78,10 @@ function previewVars( vars[k] = v; vars["--bg-editor"] = tokens.get("--bg-editor") ?? tokens.get("--bg-island") ?? ""; for (const [key, value] of overrideDeclarations(o)) vars[key] = value; + if (language) { + for (const [key, value] of overrideDeclarations(customs[id]?.attributes, language)) vars[key] = value; + for (const [key, value] of overrideDeclarations(o, language)) vars[key] = value; + } return vars as CSSProperties; } @@ -276,9 +284,10 @@ function ColorSchemePage() { const id = effectiveScheme(scheme, undefined, customs); const mine = overrides?.[id]; const attr = ATTRIBUTES.find((a) => a.id === selected); + const language = attr?.language; const vars = useMemo( - () => ({ ...previewVars(id, mine, customs), ...fontWeightVars(weight, boldFontWeight) }) as CSSProperties, - [id, mine, customs, weight, boldFontWeight], + () => ({ ...previewVars(id, mine, customs, language), ...fontWeightVars(weight, boldFontWeight) }) as CSSProperties, + [id, mine, customs, language, weight, boldFontWeight], ); const update = (attrId: string, v: TextAttributesOverride | undefined) => { const next: Overrides = { ...(overrides ?? {}) }; diff --git a/src/platform/__fixtures__/colorSchemes/monokai-php.icls b/src/platform/__fixtures__/colorSchemes/monokai-php.icls new file mode 100644 index 00000000..03d90ddd --- /dev/null +++ b/src/platform/__fixtures__/colorSchemes/monokai-php.icls @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/platform/colorScheme.ts b/src/platform/colorScheme.ts index 93564613..4e0dbc5e 100644 --- a/src/platform/colorScheme.ts +++ b/src/platform/colorScheme.ts @@ -32,6 +32,8 @@ export interface AttributeDef { /** A semantic modifier whose only configurable attribute is italic. */ italicOnly?: boolean; defaultItalic?: boolean; + /** CodeMirror language name; unset attributes are language defaults. */ + language?: string; } const syn = (id: string, name: string, path: string[], token: string): AttributeDef => ({ @@ -45,7 +47,7 @@ const syn = (id: string, name: string, path: string[], token: string): Attribute const LD = "Language Defaults"; /** IDEA-style attribute tree: General, Language Defaults, then languages. */ -export const ATTRIBUTES: readonly AttributeDef[] = [ +const BASE_ATTRIBUTES: readonly AttributeDef[] = [ { id: "text", name: "Default text", path: ["General", "Text"], fg: "--fg-editor", bg: "--bg-editor" }, { id: "caret", name: "Caret", path: ["General", "Editor"], fg: "--caret" }, { id: "caretRow", name: "Caret row", path: ["General", "Editor"], bg: "--bg-current-line" }, @@ -152,6 +154,26 @@ export const ATTRIBUTES: readonly AttributeDef[] = [ syn("regexp", "Regular expression", ["RegExp"], "regexp"), ]; +const SCHEME_LANGUAGES = { java: "Java", kotlin: "Kotlin", php: "PHP" } as const; + +export const ATTRIBUTES: readonly AttributeDef[] = [ + ...BASE_ATTRIBUTES, + ...Object.entries(SCHEME_LANGUAGES).flatMap(([id, language]) => + BASE_ATTRIBUTES.filter((a) => a.path[0] === LD && !/^(semantic|bracket)\d$/.test(a.id)).map((a) => ({ + ...a, + id: `${id}.${a.id}`, + path: [language, ...a.path.slice(1)], + language, + })), + ), + ...[ + ["brackets", "Brackets", "bracket"], + ["separator", "Comma and semicolon", "separator"], + ["scriptTag", "PHP tags", "processing-instruction"], + ["staticField", "Static field", "static-property"], + ].map(([id, name, token]) => ({ ...syn(`php.${id}`, name!, ["PHP"], token!), language: "PHP" })), +]; + // --- scheme base values ------------------------------------------------------ /** Variables a scheme sets: every attribute variable plus the editor tokens derived from them. */ @@ -304,10 +326,12 @@ const EFFECT_CSS: Record string> = { /** Declarations for the overrides of one scheme. */ export function overrideDeclarations( overrides: Record | undefined, + language?: string, ): Array<[string, string]> { const out: Array<[string, string]> = []; if (!overrides) return out; for (const attr of ATTRIBUTES) { + if (attr.language !== language) continue; const o = overrides[attr.id]; if (!o) continue; if (isHexColor(o.foreground) && attr.fg) out.push([attr.fg, o.foreground]); @@ -402,5 +426,15 @@ export function schemeCss( const body = decls .filter(([k, v]) => /^--[\w-]+$/.test(k) && SAFE.test(v)) .map(([k, v]) => ` ${k}: ${v} !important;`); - return body.length ? `:root {\n${body.join("\n")}\n}\n` : ""; + let css = body.length ? `:root {\n${body.join("\n")}\n}\n` : ""; + for (const language of Object.values(SCHEME_LANGUAGES)) { + const languageBody = [ + ...overrideDeclarations(valid[id]?.attributes, language), + ...overrideDeclarations(overrides?.[id], language), + ] + .filter(([k, v]) => /^--[\w-]+$/.test(k) && SAFE.test(v)) + .map(([k, v]) => ` ${k}: ${v} !important;`); + if (languageBody.length) css += `.cm-editor[data-language="${language}"] {\n${languageBody.join("\n")}\n}\n`; + } + return css; } diff --git a/src/platform/icls.test.ts b/src/platform/icls.test.ts index 9e914a56..4f72ec1d 100644 --- a/src/platform/icls.test.ts +++ b/src/platform/icls.test.ts @@ -12,12 +12,48 @@ const fixture = (name: string) => readFileSync(`src/platform/__fixtures__/colorS beforeAll(() => _setSchemeSource(readFileSync("src/styles/tokens.css", "utf8"))); describe(".icls schemes", () => { + it("resolves explicit attribute inheritance independent of XML order and stops cycles", () => { + const { scheme } = parseIcls(` + + `); + expect(scheme.attributes["php.parameter"]).toEqual({ foreground: "#48d8ee", bold: false, italic: false }); + expect(scheme.attributes["php.identifier"]).toEqual(scheme.attributes["php.parameter"]); + expect(scheme.attributes["php.string"]).toBeUndefined(); + }); + + it("preserves PHP colours and regular styles separately from Java and language defaults", () => { + const { scheme } = parseIcls(fixture("monokai-php")); + expect(scheme.parent).toBe("dark"); + expect(scheme.attributes["php.identifier"]).toEqual({ foreground: "#48d8ee", bold: false, italic: false }); + expect(scheme.attributes["php.keyword"]).toEqual({ foreground: "#f92672", bold: false, italic: false }); + expect(scheme.attributes["php.string"]).toEqual({ foreground: "#ffe792", bold: false, italic: false }); + expect(scheme.attributes["php.docComment"]).toEqual({ foreground: "#75715e", bold: false, italic: false }); + expect(scheme.attributes["java.string"]).toEqual({ foreground: "#ffe792", bold: true, italic: false }); + expect(scheme.attributes.keyword?.foreground).toBe("#41abff"); + const customs = { "custom-php": scheme }; + const css = schemeCss("custom-php", undefined, null, customs); + expect(css).toContain('.cm-editor[data-language="PHP"]'); + expect(css).toContain("--syn-variable: #48d8ee !important"); + const roundTrip = parseIcls(exportIcls("custom-php", customs)).scheme; + expect(roundTrip.attributes["php.string"]).toEqual(scheme.attributes["php.string"]); + expect(roundTrip.attributes["java.string"]).toEqual(scheme.attributes["java.string"]); + }); + it("maps defaults, Java/Kotlin, editor surfaces, search and diagnostics with counts", () => { const { scheme, applied, skipped } = parseIcls(fixture("forest")); expect(scheme.name).toBe("Forest & Moss"); expect(scheme.parent).toBe("dark"); - expect(scheme.attributes.keyword).toEqual({ foreground: "#99dd77", bold: true, italic: false }); - expect(scheme.attributes.functionDeclaration).toEqual({ foreground: "#e0c080", bold: false, italic: true }); + expect(scheme.attributes.keyword).toEqual({ foreground: "#aadd88", bold: false, italic: false }); + expect(scheme.attributes["java.keyword"]).toEqual({ foreground: "#99dd77", bold: true, italic: false }); + expect(scheme.attributes["kotlin.functionDeclaration"]).toEqual({ + foreground: "#e0c080", + bold: false, + italic: true, + }); expect(scheme.attributes.text).toEqual({ foreground: "#d8ebd8", background: "#18281e" }); expect(scheme.attributes.caret?.foreground).toBe("#f0e080"); expect(scheme.attributes.selection?.background).toBe("#406040"); @@ -53,8 +89,8 @@ describe(".icls schemes", () => { }); it.each([ - ["Darcula", "ffffff", "dark"], - ["Default", "000000", "light"], + ["Darcula", "ffffff", "light"], + ["Default", "000000", "dark"], ["Other", "101820", "dark"], ["Other", "fefefe", "light"], ["", "0", "dark"], @@ -111,7 +147,8 @@ describe(".icls schemes", () => { root.dataset.theme = theme; const css = schemeCss(id, overrides, root, customs); expect(css).toContain("--bg-editor: #18281e !important"); - expect(css.lastIndexOf("--syn-keyword: #abcdef")).toBeGreaterThan(css.indexOf("--syn-keyword: #99dd77")); + expect(css.indexOf("--syn-keyword: #abcdef")).toBeGreaterThan(css.indexOf("--syn-keyword: #aadd88")); + expect(css).toContain('.cm-editor[data-language="Java"]'); expect(effectiveScheme("theme-default", root, customs)).toBe(theme); const deleted = deleteScheme(customs, overrides, id, id); expect(deleted).toEqual({ customs: {}, overrides: {}, selected: "theme-default" }); diff --git a/src/platform/icls.ts b/src/platform/icls.ts index 7062162e..1e17f8ba 100644 --- a/src/platform/icls.ts +++ b/src/platform/icls.ts @@ -96,17 +96,40 @@ const LANGUAGE_KEYS: Record = { PARENTHS: "operator", }; for (const [name, id] of Object.entries(LANGUAGE_KEYS)) { - ATTRIBUTE_NAMES[`JAVA_${name}`] = id; - ATTRIBUTE_NAMES[`KOTLIN_${name}`] = id; + ATTRIBUTE_NAMES[`JAVA_${name}`] = `java.${id}`; + ATTRIBUTE_NAMES[`KOTLIN_${name}`] = `kotlin.${id}`; ATTRIBUTE_NAMES[name] = id; } Object.assign(ATTRIBUTE_NAMES, { - KOTLIN_MUTABLE_VARIABLE: "identifier", - KOTLIN_PACKAGE_PROPERTY: "field", - KOTLIN_SYNTHETIC_EXTENSION_PROPERTY: "field", - KOTLIN_EXTENSION_FUNCTION_CALL: "functionCall", - KOTLIN_NAMED_ARGUMENT: "parameter", - KOTLIN_OBJECT: "className", + KOTLIN_MUTABLE_VARIABLE: "kotlin.identifier", + KOTLIN_PACKAGE_PROPERTY: "kotlin.field", + KOTLIN_SYNTHETIC_EXTENSION_PROPERTY: "kotlin.field", + KOTLIN_EXTENSION_FUNCTION_CALL: "kotlin.functionCall", + KOTLIN_NAMED_ARGUMENT: "kotlin.parameter", + KOTLIN_OBJECT: "kotlin.className", + CLASS_NAME_ATTRIBUTES: "className", + PHP_KEYWORD: "php.keyword", + PHP_VAR: "php.identifier", + PHP_PARAMETER: "php.parameter", + PHP_CLASS: "php.className", + PHP_CONSTANT: "php.constant", + PHP_INSTANCE_FIELD: "php.field", + PHP_STATIC_FIELD: "php.staticField", + PHP_FUNCTION: "php.functionDeclaration", + PHP_FUNCTION_CALL: "php.functionCall", + PHP_INSTANCE_METHOD: "php.functionCall", + "PHP_PREDEFINED SYMBOL": "php.predefined", + PHP_NUMBER: "php.number", + PHP_STRING: "php.string", + PHP_ESCAPE_SEQUENCE: "php.escape", + PHP_COMMENT: "php.lineComment", + PHP_DOC_COMMENT_ID: "php.docComment", + PHP_DOC_TAG: "php.metadata", + PHP_OPERATION_SIGN: "php.operator", + PHP_BRACKETS: "php.brackets", + PHP_COMMA: "php.separator", + PHP_SEMICOLON: "php.separator", + PHP_TAG: "php.scriptTag", }); const COLOR_NAMES: Record = { @@ -147,6 +170,22 @@ export function parseIcls(xml: string): { scheme: CustomColorScheme; applied: nu ) throw new Error("Invalid .icls color scheme"); const attributes: Record = {}; + const entries = new Map( + children(root, "attributes").flatMap((section) => + children(section, "option").map((entry) => [entry.getAttribute("name"), entry] as const), + ), + ); + const inheritedValue = (entry: Element): Element | undefined => { + const seen = new Set(); + let current: Element | undefined = entry; + while (current && !seen.has(current)) { + seen.add(current); + const value = children(current, "value")[0]; + if (value) return value; + current = entries.get(current.getAttribute("baseAttributes")); + } + return undefined; + }; let applied = 0; let skipped = 0; const merge = (id: string, value: TextAttributesOverride) => { @@ -171,11 +210,14 @@ export function parseIcls(xml: string): { scheme: CustomColorScheme; applied: nu skipped++; continue; } - const value = children(entry, "value")[0]; + const value = inheritedValue(entry); if (!value) { skipped++; continue; } + const attr = ATTRIBUTES.find((a) => a.id === id)!; + // An explicit TextAttributes value defaults to Font.PLAIN, not the last alias's style. + if (attr.token) merge(id, { bold: false, italic: false }); for (const option of children(value, "option")) { const name = option.getAttribute("name"); const raw = option.getAttribute("value"); @@ -188,7 +230,6 @@ export function parseIcls(xml: string): { scheme: CustomColorScheme; applied: nu : name === "EFFECT_COLOR" ? "effectColor" : undefined; - const attr = ATTRIBUTES.find((a) => a.id === id)!; const supported = field === "foreground" ? !!attr.fg @@ -212,13 +253,17 @@ export function parseIcls(xml: string): { scheme: CustomColorScheme; applied: nu const bg = attributes.text?.background; const rgb = bg ? [1, 3, 5].map((i) => parseInt(bg.slice(i, i + 2), 16)) : [255, 255, 255]; const dark = rgb[0]! * 0.299 + rgb[1]! * 0.587 + rgb[2]! * 0.114 < 128; - const parent = /darcula|dark/i.test(parentName) - ? "dark" - : /default|light/i.test(parentName) - ? "light" - : dark - ? "dark" - : "light"; + const parent = bg + ? dark + ? "dark" + : "light" + : /darcula|dark/i.test(parentName) + ? "dark" + : /default|light/i.test(parentName) + ? "light" + : dark + ? "dark" + : "light"; return { scheme: { name: root.getAttribute("name")!.trim(), parent, attributes }, applied, skipped }; } @@ -290,6 +335,7 @@ export function exportIcls( if (isHexColor(c)) colors.push(`