From 875e049dcd347d2798cf8e7a3639b4efa236b5af Mon Sep 17 00:00:00 2001 From: Muu Date: Thu, 5 Nov 2026 23:38:03 +0800 Subject: [PATCH] fix(editor): align Python identifier colors and unused-parameter styling (#1572) Python defaults diverged in several places: built-ins used the predefined magenta, __init__/__doc__ used function/self colours, instance fields the property colour, f-strings and byte strings the generic string colour, and the PEP 695 type keyword was uncoloured. Python now has its own tokens for built-ins, predefined definitions/usages, f-strings and byte strings; instance fields follow identifiers; type is a keyword. Unused parameters (not reported by Pyrefly) are greyed by a scope-aware local check that skips self/cls/_ names and signatures dictated elsewhere: stubs, @abstractmethod/@overload/@override, dunder methods and methods of classes with bases. Co-authored-by: muutot <92625353+muutot@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- docs/plugins/themes.md | 9 +- e2e/pythonHighlight.fixture.ts | 9 ++ e2e/pythonHighlight.spec.ts | 12 ++- .../themes/harbor-night.scheme.json | 7 +- src/editor/lsp/pythonHighlight.test.ts | 65 +++++++++++++ src/editor/lsp/pythonHighlight.ts | 91 ++++++++++++++++++- src/editor/lsp/semantic.ts | 2 + src/editor/lsp/theme.ts | 11 ++- src/platform/colorScheme.test.ts | 7 ++ src/platform/colorScheme.ts | 3 +- src/platform/icls.test.ts | 8 ++ src/platform/icls.ts | 1 + src/platform/locales/text/zh.json | 1 + src/styles/tokens.css | 28 +++++- 14 files changed, 239 insertions(+), 15 deletions(-) diff --git a/docs/plugins/themes.md b/docs/plugins/themes.md index 760a7c3d..db1c9806 100644 --- a/docs/plugins/themes.md +++ b/docs/plugins/themes.md @@ -324,7 +324,7 @@ a token. -235 themeable tokens: 121 in the UI theme file, 114 in the editor colour scheme file. +240 themeable tokens: 121 in the UI theme file, 119 in the editor colour scheme file. ### Surfaces (UI theme) @@ -436,11 +436,16 @@ a token. | `--syn-constant` | `#c77dbb` | `#871094` | | `--syn-python-local-variable` | `var(--syn-variable)` | `var(--syn-variable)` | | `--syn-python-global-variable` | `var(--syn-global)` | `var(--syn-global)` | -| `--syn-python-instance-attribute` | `var(--syn-property)` | `var(--syn-property)` | +| `--syn-python-instance-attribute` | `var(--syn-variable)` | `var(--syn-variable)` | | `--syn-python-class-attribute` | `var(--syn-property)` | `var(--syn-property)` | | `--syn-python-constant` | `var(--syn-constant)` | `var(--syn-constant)` | | `--syn-python-property` | `var(--syn-property)` | `var(--syn-property)` | | `--syn-python-type-parameter` | `var(--syn-type)` | `var(--syn-type)` | +| `--syn-python-builtin` | `var(--syn-builtin)` | `var(--syn-builtin)` | +| `--syn-python-predefined-definition` | `var(--syn-predefined)` | `var(--syn-predefined)` | +| `--syn-python-predefined-usage` | `var(--syn-predefined)` | `var(--syn-predefined)` | +| `--syn-python-fstring` | `#a5c261` | `var(--syn-string)` | +| `--syn-python-bytes` | `#a5c261` | `var(--syn-string)` | | `--syn-builtin` | `#8888c6` | `#000080` | | `--syn-self` | `#94558d` | `#94558d` | | `--syn-keyword-arg` | `#aa4926` | `#660099` | diff --git a/e2e/pythonHighlight.fixture.ts b/e2e/pythonHighlight.fixture.ts index e42a08e4..71ad99bd 100644 --- a/e2e/pythonHighlight.fixture.ts +++ b/e2e/pythonHighlight.fixture.ts @@ -26,6 +26,9 @@ const attributes = { "PY.SELF_PARAMETER": { FOREGROUND: "aa7788" }, "PY.BUILTIN_NAME": { FOREGROUND: "bb8899" }, "PY.KEYWORD_ARGUMENT": { FOREGROUND: "cc99aa" }, + "PY.STRING.F": { FOREGROUND: "aa8811" }, + "PY.PREDEFINED_DEFINITION": { FOREGROUND: "bb9922" }, + "PY.PREDEFINED_USAGE": { FOREGROUND: "ccaa33" }, OPENIDEA_pyLocalVariable: { FOREGROUND: "aa1101" }, OPENIDEA_pyGlobalVariable: { FOREGROUND: "aa2202" }, OPENIDEA_pyInstanceAttribute: { FOREGROUND: "aa3303" }, @@ -52,6 +55,8 @@ new EditorView({ parent: document.body, state: EditorState.create({ doc: `MAX_SIZE: Final = 10 +type Alias = list[int] +fstr = f"value={1}" name = "global" T = TypeVar("T") @@ -69,9 +74,13 @@ class Foo[T]: self.instance_attr = value print(local, MAX_SIZE, name, T) self.run(whatever=1) # Comment + print(self.__doc__) @property def size(self) -> int: return self.instance_attr + +def unused(param): + return 1 `, extensions: [ support!, diff --git a/e2e/pythonHighlight.spec.ts b/e2e/pythonHighlight.spec.ts index f3e5b084..aa70a0f4 100644 --- a/e2e/pythonHighlight.spec.ts +++ b/e2e/pythonHighlight.spec.ts @@ -7,8 +7,11 @@ test("Python syntax supplements retain PyCharm colours without a language server await expect(page.locator(".cm-content")).toBeVisible(); await page.screenshot({ path: info.outputPath("before.png") }); await page.goto("/e2e/pythonHighlight.html"); - await expect(page.locator(".cm-sem-python-definition")).toHaveText("outer"); - await expect(page.locator(".cm-sem-python-definition")).toHaveCSS("color", "rgb(34, 68, 102)"); + await expect(page.locator(".cm-sem-python-definition").filter({ hasText: /^outer$/ })).toHaveText("outer"); + await expect(page.locator(".cm-sem-python-definition").filter({ hasText: /^outer$/ })).toHaveCSS( + "color", + "rgb(34, 68, 102)", + ); await expect(page.locator(".cm-sem-python-nested")).toHaveCSS("color", "rgb(51, 85, 119)"); await expect(page.locator(".cm-sem-python-docstring")).toHaveCSS("color", "rgb(102, 136, 170)"); await expect(page.locator(".cm-sem-python-docstring")).toHaveCSS("text-decoration-line", "none"); @@ -46,6 +49,11 @@ test("Python syntax supplements retain PyCharm colours without a language server await expect(page.locator(".cm-sem-python-constant").first()).toHaveCSS("color", "rgb(170, 85, 5)"); await expect(page.locator(".cm-sem-python-property").first()).toHaveCSS("color", "rgb(170, 102, 6)"); await expect(page.locator(".cm-sem-python-type-parameter").first()).toHaveCSS("color", "rgb(170, 119, 7)"); + await expect(page.locator(".cm-sem-python-fstring").first()).toHaveCSS("color", "rgb(170, 136, 17)"); + await expect(page.locator(".cm-sem-python-predefined-definition")).toHaveCSS("color", "rgb(187, 153, 34)"); + await expect(page.locator(".cm-sem-python-predefined-usage")).toHaveCSS("color", "rgb(204, 170, 51)"); + await expect(page.locator(".cm-sem-keyword").filter({ hasText: /^type$/ })).toHaveCSS("color", "rgb(17, 34, 51)"); + await expect(page.locator(".cm-sem-unused")).toHaveCSS("color", "rgb(111, 115, 122)"); expect(errors).toEqual([]); await page.screenshot({ path: info.outputPath("python-highlighting.png") }); }); diff --git a/plugins/official/harbor-night/themes/harbor-night.scheme.json b/plugins/official/harbor-night/themes/harbor-night.scheme.json index a6f7dc8e..416058d9 100644 --- a/plugins/official/harbor-night/themes/harbor-night.scheme.json +++ b/plugins/official/harbor-night/themes/harbor-night.scheme.json @@ -24,11 +24,16 @@ "--syn-constant": "#e58fa3", "--syn-python-local-variable": "#d9dfe8", "--syn-python-global-variable": "#d9dfe8", - "--syn-python-instance-attribute": "#b9a3f0", + "--syn-python-instance-attribute": "#d9dfe8", "--syn-python-class-attribute": "#b9a3f0", "--syn-python-constant": "#e58fa3", "--syn-python-property": "#b9a3f0", "--syn-python-type-parameter": "#8fd4c9", + "--syn-python-builtin": "#6fc3df", + "--syn-python-predefined-definition": "#c4a0f2", + "--syn-python-predefined-usage": "#c4a0f2", + "--syn-python-fstring": "#9ccc7a", + "--syn-python-bytes": "#9ccc7a", "--syn-builtin": "#6fc3df", "--syn-self": "#d6849c", "--syn-keyword-arg": "#c4a0f2", diff --git a/src/editor/lsp/pythonHighlight.test.ts b/src/editor/lsp/pythonHighlight.test.ts index 81ba2d62..a47830b7 100644 --- a/src/editor/lsp/pythonHighlight.test.ts +++ b/src/editor/lsp/pythonHighlight.test.ts @@ -50,6 +50,71 @@ class Foo: ); }); + it("marks unused parameters and highlights type and f-string keywords", async () => { + const state = await parse(`type Alias = list[int] +s = f"value={1}" +def used(x): + return x +def unused(param): + return 1 +def skipped(self, _hidden): + return 1 +`); + const tree = syntaxTree(state); + const kinds = pythonSymbolKinds(tree, state.doc); + const kindOf = (name: string) => + [...kinds] + .filter(([range]) => { + const [from, to] = range.split(":").map(Number); + return state.doc.sliceString(from!, to!) === name; + }) + .map(([, kind]) => kind); + expect(kindOf("param")).toEqual(["unusedParameter"]); + expect(kindOf("x")).toEqual(["parameter", "parameter"]); + expect(kindOf("self")).toEqual(["parameter"]); + expect(kindOf("_hidden")).toEqual(["parameter"]); + + // Signatures dictated elsewhere: stubs, abstract / overloaded methods, dunders, overrides. + const stubs = await parse(`from abc import abstractmethod +class Plain: + def method(self, unused_a): + return 1 + def stub(self, stub_a): + """Doc.""" + ... + def todo(self, todo_a): + raise NotImplementedError + def nothing(self, pass_a): pass + @abstractmethod + def abstract(self, abstract_a): + return 1 + def __exit__(self, exc_type, exc, tb): + return None +class Child(Plain): + def method(self, override_a): + return 2 +`); + const stubKinds = pythonSymbolKinds(syntaxTree(stubs), stubs.doc); + const stubKindOf = (name: string) => + [...stubKinds] + .filter(([range]) => { + const [from, to] = range.split(":").map(Number); + return stubs.doc.sliceString(from!, to!) === name; + }) + .map(([, kind]) => kind); + expect(stubKindOf("unused_a")).toEqual(["unusedParameter"]); + for (const name of ["stub_a", "todo_a", "pass_a", "abstract_a", "exc_type", "tb", "override_a"]) + expect(stubKindOf(name), name).toEqual(["parameter"]); + + const ranges = pythonSyntaxRanges(tree, state.doc, kinds); + const styles = ranges.map((r) => [state.doc.sliceString(r.from, r.to), r.className]); + expect(styles).toContainEqual(["param", "cm-sem-parameter cm-sem-unused"]); + expect(styles).toContainEqual(["type", "cm-sem-keyword"]); + const fstring = ranges.filter((r) => r.className === "cm-sem-python-fstring"); + expect(fstring.length).toBeGreaterThan(0); + expect(fstring.some((r) => state.doc.sliceString(r.from, r.to).includes("value="))).toBe(true); + }); + it("lets server classification override the local builtin guess", async () => { const state = await parse("print(1)"); const ranges = semanticRanges( diff --git a/src/editor/lsp/pythonHighlight.ts b/src/editor/lsp/pythonHighlight.ts index a57c0e58..1892c10c 100644 --- a/src/editor/lsp/pythonHighlight.ts +++ b/src/editor/lsp/pythonHighlight.ts @@ -19,6 +19,7 @@ export type PythonSymbolKind = | "constant" | "property" | "typeParameter" + | "unusedParameter" | "builtin"; const PYTHON_CLASSES: Record = { @@ -32,6 +33,7 @@ const PYTHON_CLASSES: Record = { constant: "cm-sem-constant cm-sem-python-constant", property: "cm-sem-property cm-sem-python-property", typeParameter: "cm-sem-typeParameter cm-sem-python-type-parameter", + unusedParameter: "cm-sem-parameter cm-sem-unused", builtin: "cm-sem-builtin cm-sem-python-builtin", }; @@ -91,6 +93,7 @@ const KIND_PRIORITY: Record = { globalVariable: 70, typeParameter: 90, parameter: 80, + unusedParameter: 80, property: 80, constant: 70, classAttribute: 60, @@ -106,6 +109,33 @@ function scope(node: SyntaxNode | null): SyntaxNode | null { return null; } +const STUB_STATEMENTS = new Set(["PassStatement", "RaiseStatement"]); + +/** + * Whether the parameters `fn` doesn't read count as unused. Not for stubs (a body of only a + * docstring, `...`, `pass` or `raise`), `@abstractmethod` / `@overload` / `@override`, dunder + * methods or methods of a class with bases: those signatures are dictated elsewhere. + */ +function checksUnusedParameters(fn: SyntaxNode, doc: Text): boolean { + if (fn.name !== "FunctionDefinition") return true; + const name = directName(fn); + if (name && /^__\w+__$/.test(doc.sliceString(name.from, name.to))) return false; + if (fn.parent?.name === "DecoratedStatement") + for (let d = fn.parent.firstChild; d; d = d.nextSibling) + if (d.name === "Decorator" && /\b(?:abstractmethod|overload|override)\b/.test(doc.sliceString(d.from, d.to))) + return false; + const owner = scope(fn.parent); + if (owner?.name === "ClassDefinition" && owner.getChild("ArgList")) return false; + const body = fn.getChild("Body"); + for (let st = body?.firstChild; st; st = st.nextSibling) { + if (st.name === ":" || st.name === "Comment" || STUB_STATEMENTS.has(st.name)) continue; + const only = st.name === "ExpressionStatement" ? st.firstChild : null; + if (only && !only.nextSibling && (only.name === "String" || only.name === "Ellipsis")) continue; + return true; + } + return false; +} + function enclosingClass(node: SyntaxNode | null): SyntaxNode | null { for (; node; node = node.parent) if (node.name === "ClassDefinition") return node; return null; @@ -340,6 +370,24 @@ export function pythonSymbolKinds(tree: Tree, doc: Text): Map(); + tree.iterate({ + enter(cursor) { + const node = cursor.node; + if (node.name !== "VariableName" || hasAncestor(node, "ParamList")) return; + const name = text(node); + let crossedFunction = false; + for (let current = scope(node); current; current = scope(current.parent)) { + if (FUNCTION_SCOPES.has(current.name)) crossedFunction = true; + const kind = bindings.get(key(current))?.get(name); + if (!kind) continue; + if (crossedFunction && current.name === "ClassDefinition" && kind !== "typeParameter") continue; + if (kind === "parameter") usedParameters.add(`${key(current)}:${name}`); + break; + } + }, + }); + const resolve = ( node: SyntaxNode, name: string, @@ -394,6 +442,18 @@ export function pythonSymbolKinds(tree: Tree, doc: Text): Map { const name = text(node); if (node.name === "PropertyName") return memberKind(node, name); + if (node.parent?.name === "ParamList") { + const owner = scope(node); + if ( + owner && + name !== "self" && + name !== "cls" && + !name.startsWith("_") && + !usedParameters.has(`${key(owner)}:${name}`) && + checksUnusedParameters(owner, doc) + ) + return "unusedParameter"; + } const start = node.parent?.name === "FunctionDefinition" ? scope(node.parent.parent) : scope(node); const resolved = resolve(node, name, start); if (resolved) return resolved; @@ -423,8 +483,34 @@ export function pythonSyntaxRanges( tree.iterate({ enter(cursor) { const node = cursor.node; - if (!["VariableName", "PropertyName", "String", "Escape", "(", ")", "[", "]", "{", "}"].includes(node.name)) + if ( + ![ + "VariableName", + "PropertyName", + "String", + "FormatString", + "type", + "Escape", + "(", + ")", + "[", + "]", + "{", + "}", + ].includes(node.name) + ) return; + if (node.name === "FormatString") { + const className = "cm-sem-python-fstring"; + let from = node.from; + for (let child = node.firstChild; child; child = child.nextSibling) { + if (child.name !== "FormatReplacement") continue; + if (child.from > from) ranges.push({ from, to: child.from, className, source: "pythonSyntax" }); + from = child.to; + } + if (from < node.to) ranges.push({ from, to: node.to, className, source: "pythonSyntax" }); + return; + } const text = doc.sliceString(node.from, node.to); const symbol = symbols.get(`${node.from}:${node.to}`); let style: string | undefined; @@ -433,7 +519,7 @@ export function pythonSyntaxRanges( else if (["[", "]"].includes(node.name)) style = "python-brackets"; else if (["{", "}"].includes(node.name)) style = "python-braces"; else if (node.name === "VariableName") { - if (symbol === "typeParameter") className = pythonSymbolClasses(symbol); + if (symbol === "typeParameter" || symbol === "unusedParameter") className = pythonSymbolClasses(symbol); else if (node.parent?.name === "FunctionDefinition") { if (symbol === "property") className = pythonSymbolClasses(symbol); else @@ -467,6 +553,7 @@ export function pythonSyntaxRanges( if (docstring && !/^[bf]/i.test(text)) style = "python-docstring"; else if (/^[rub]*b[rub]*['"]/i.test(text)) style = "python-bytes"; } else if (node.name === "Escape") style = "python-escape"; + else if (node.name === "type" && node.parent?.name === "TypeDefinition") style = "keyword"; if (style) className = `cm-sem-${style}`; if (className) ranges.push({ from: node.from, to: node.to, className, source: "pythonSyntax" }); if (style === "python-docstring") return false; diff --git a/src/editor/lsp/semantic.ts b/src/editor/lsp/semantic.ts index 3d847381..e19050ee 100644 --- a/src/editor/lsp/semantic.ts +++ b/src/editor/lsp/semantic.ts @@ -97,6 +97,7 @@ const PYTHON_VARIABLE_KINDS = new Set([ "constant", "property", "typeParameter", + "unusedParameter", ]); const PYTHON_SYMBOL_BASES = new Set([ "cm-sem-variable", @@ -105,6 +106,7 @@ const PYTHON_SYMBOL_BASES = new Set([ "cm-sem-constant", "cm-sem-type", "cm-sem-typeParameter", + "cm-sem-parameter", "cm-sem-builtin", ]); diff --git a/src/editor/lsp/theme.ts b/src/editor/lsp/theme.ts index 79350190..a6e10175 100644 --- a/src/editor/lsp/theme.ts +++ b/src/editor/lsp/theme.ts @@ -365,10 +365,14 @@ export const lspTheme = EditorView.theme({ ".cm-sem-python-braces": pythonStyle("python-braces", "punct"), ".cm-sem-python-definition": pythonStyle("function", "function"), ".cm-sem-python-nested": pythonStyle("python-nested-function", "function"), - ".cm-sem-python-predefined-definition": pythonStyle("python-predefined-definition", "function"), - ".cm-sem-python-predefined-usage": pythonStyle("python-predefined-usage", "self"), + ".cm-sem-python-predefined-definition": pythonStyle("python-predefined-definition", "predefined"), + ".cm-sem-python-predefined-usage": pythonStyle("python-predefined-usage", "predefined"), ".cm-sem-python-docstring, .cm-sem-python-docstring *": pythonStyle("docstring", "string"), ".cm-sem-python-bytes": pythonStyle("python-bytes", "string"), + ".cm-sem-python-fstring, .cm-sem-python-fstring .tok-string, .cm-sem-python-fstring .tok-string2": pythonStyle( + "python-fstring", + "string", + ), ".cm-sem-python-escape": pythonStyle("escape", "escape"), ".cm-sem-python-format": pythonStyle("python-format", "escape"), ".cm-sem-python-keyword-arg": pythonStyle("keyword-arg", "param"), @@ -385,12 +389,13 @@ export const lspTheme = EditorView.theme({ ".cm-sem-variable": { color: "var(--syn-variable)" }, ".cm-sem-property": { color: "var(--syn-property)" }, ".cm-sem-parameter": { color: "var(--syn-param, var(--syn-variable))" }, + ".cm-sem-parameter.cm-sem-unused": { color: "var(--fg-unused, var(--fg-faint))" }, ".cm-sem-self": { color: "var(--syn-self, var(--syn-keyword))" }, ".cm-sem-keyword": { color: "var(--syn-keyword)", fontWeight: "var(--syn-keyword-bold, inherit)" }, // Before the builtin rule: library globals (`print`, `string`) keep the predefined colour. ".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-builtin.cm-sem-python-builtin": pythonStyle("predefined", "builtin"), + ".cm-sem-builtin.cm-sem-python-builtin": pythonStyle("python-builtin", "builtin"), ".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))", diff --git a/src/platform/colorScheme.test.ts b/src/platform/colorScheme.test.ts index b3766fe3..267b9bad 100644 --- a/src/platform/colorScheme.test.ts +++ b/src/platform/colorScheme.test.ts @@ -12,6 +12,13 @@ describe("color schemes", () => { expect(schemeTokens("light").get("--syn-keyword")).toBe("#0033b3"); expect(schemeTokens("dark").get("--syn-keyword")).toBe("#cf8e6d"); expect(schemeTokens("light").get("--bg-editor")).not.toMatch(/var\(/); + const dark = schemeTokens("dark"); + expect(dark.get("--syn-python-builtin")).toBe("#8888c6"); + expect(dark.get("--syn-python-predefined-definition")).toBe("#b200b2"); + expect(dark.get("--syn-python-predefined-usage")).toBe("#b200b2"); + expect(dark.get("--syn-python-instance-attribute")).toBe("#bcbec4"); + expect(dark.get("--syn-python-fstring")).toBe("#a5c261"); + expect(dark.get("--syn-python-bytes")).toBe("#a5c261"); }); it("gives PHP variables and function calls their own colours, as PhpStorm (#1532)", () => { diff --git a/src/platform/colorScheme.ts b/src/platform/colorScheme.ts index ef2e9534..d201cb35 100644 --- a/src/platform/colorScheme.ts +++ b/src/platform/colorScheme.ts @@ -142,6 +142,7 @@ const BASE_ATTRIBUTES: readonly AttributeDef[] = [ syn("pyBraces", "Braces", ["Python"], "python-braces"), syn("pyNestedFunction", "Nested function definition", ["Python"], "python-nested-function"), syn("pyBytes", "Byte string", ["Python"], "python-bytes"), + syn("pyFString", "F-string", ["Python"], "python-fstring"), syn("pyPredefinedDefinition", "Predefined function definition", ["Python"], "python-predefined-definition"), syn("pyPredefinedUsage", "Predefined name usage", ["Python"], "python-predefined-usage"), syn("pyFormat", "Format specifier", ["Python"], "python-format"), @@ -156,7 +157,7 @@ const BASE_ATTRIBUTES: readonly AttributeDef[] = [ }, syn("pyKeywordArg", "Keyword argument", ["Python"], "keyword-arg"), syn("pyDocstring", "Docstring", ["Python"], "docstring"), - syn("pyBuiltin", "Built-in name", ["Python"], "predefined"), + syn("pyBuiltin", "Built-in name", ["Python"], "python-builtin"), syn("pyDecorator", "Decorator", ["Python"], "annotation"), syn("pyLocalVariable", "Local variable", ["Python"], "python-local-variable"), syn("pyGlobalVariable", "Global variable", ["Python"], "python-global-variable"), diff --git a/src/platform/icls.test.ts b/src/platform/icls.test.ts index 018a3003..705242c1 100644 --- a/src/platform/icls.test.ts +++ b/src/platform/icls.test.ts @@ -122,6 +122,14 @@ describe(".icls schemes", () => { for (const [name, value] of names) expect(attrs[name]?.foreground).toBe(`#${value}`); }); + it("imports the Python f-string colour", () => { + const xml = + ''; + const { id, customs } = importScheme({}, parseIcls(xml).scheme); + expect(parseIcls(exportIcls(id, customs)).scheme.attributes.pyFString?.foreground).toBe("#808080"); + }); + it("imports the global variable colour", () => { const xml = '