From 9618b3e83f688aef2111d852ae183a1137aebc1f Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Wed, 26 Aug 2026 15:02:18 +0000 Subject: [PATCH] fix: stop single-line string rules at line ends --- src/grammars/assembly.js | 4 +- src/grammars/c.js | 6 +-- src/grammars/csharp.js | 6 +-- src/grammars/css.js | 6 +-- src/grammars/dart.js | 4 +- src/grammars/graphql.js | 2 +- src/grammars/java.js | 4 +- src/grammars/kotlin.js | 4 +- src/grammars/lua.js | 4 +- src/grammars/swift.js | 2 +- src/grammars/toml.js | 2 +- src/grammars/yaml.js | 4 +- test/highlight.spec.pw.js | 102 ++++++++++++++++++++++++++++++++++++++ 13 files changed, 127 insertions(+), 23 deletions(-) diff --git a/src/grammars/assembly.js b/src/grammars/assembly.js index b11f88c..736dc33 100644 --- a/src/grammars/assembly.js +++ b/src/grammars/assembly.js @@ -22,8 +22,8 @@ export default { }, strings: { patterns: [ - { match: "'(?:\\\\.|[^'\\\\])*'", name: "string.quoted.single" }, - { match: "\"(?:\\\\.|[^\"\\\\])*\"", name: "string.quoted.double" } + { match: "'(?:\\\\.|[^'\\\\\\r\\n])*'", name: "string.quoted.single" }, + { match: "\"(?:\\\\.|[^\"\\\\\\r\\n])*\"", name: "string.quoted.double" } ] }, "label-declaration": { diff --git a/src/grammars/c.js b/src/grammars/c.js index ddcb8a1..8b6d105 100644 --- a/src/grammars/c.js +++ b/src/grammars/c.js @@ -28,7 +28,7 @@ export default { name: "keyword.control.directive.include", patterns: [ { match: "<[^>]*>", name: "string.quoted.other.lt-gt" }, - { match: "\"(?:\\\\.|[^\"\\\\])*\"", name: "string.quoted.double" } + { match: "\"(?:\\\\.|[^\"\\\\\\r\\n])*\"", name: "string.quoted.double" } ] }, { @@ -39,8 +39,8 @@ export default { }, strings: { patterns: [ - { match: "'(?:\\\\.|[^'\\\\])'", name: "string.quoted.char" }, - { match: "\"(?:\\\\.|[^\"\\\\])*\"", name: "string.quoted.double" } + { match: "'(?:\\\\.|[^'\\\\\\r\\n])'", name: "string.quoted.char" }, + { match: "\"(?:\\\\.|[^\"\\\\\\r\\n])*\"", name: "string.quoted.double" } ] }, "storage-types": { diff --git a/src/grammars/csharp.js b/src/grammars/csharp.js index ab7161b..8e6fde6 100644 --- a/src/grammars/csharp.js +++ b/src/grammars/csharp.js @@ -26,9 +26,9 @@ export default { { match: "@\"(?:\"\"|[^\"])*\"", name: "string.quoted.double.verbatim" }, { match: "\\$@\"(?:\"\"|\\{\\{|\\}\\}|[^\"])*\"", name: "string.quoted.double.interpolated" }, { match: "@\\$\"(?:\"\"|\\{\\{|\\}\\}|[^\"])*\"", name: "string.quoted.double.interpolated" }, - { match: "\\$\"(?:\\\\.|\\{\\{|\\}\\}|[^\"\\\\])*\"", name: "string.quoted.double.interpolated" }, - { match: "\"(?:\\\\.|[^\"\\\\])*\"", name: "string.quoted.double" }, - { match: "'(?:\\\\.|[^'\\\\])'", name: "string.quoted.char" } + { match: "\\$\"(?:\\\\.|\\{\\{|\\}\\}|[^\"\\\\\\r\\n])*\"", name: "string.quoted.double.interpolated" }, + { match: "\"(?:\\\\.|[^\"\\\\\\r\\n])*\"", name: "string.quoted.double" }, + { match: "'(?:\\\\.|[^'\\\\\\r\\n])'", name: "string.quoted.char" } ] }, attributes: { match: "\\[[A-Za-z_][\\w.]*\\]", name: "entity.name.decorator" }, diff --git a/src/grammars/css.js b/src/grammars/css.js index f8aee14..3728059 100644 --- a/src/grammars/css.js +++ b/src/grammars/css.js @@ -11,7 +11,7 @@ export default { ], repository: { comments: { begin: "/\\*", end: "\\*/", name: "comment.block" }, - strings: { match: "(['\"])(?:\\\\.|(?!\\1)[^\\\\])*\\1", name: "string.quoted" }, + strings: { match: "(['\"])(?:\\\\.|(?!\\1)[^\\\\\\r\\n])*\\1", name: "string.quoted" }, keyframes: { begin: "(@(?:-\\w+-)?keyframes)\\s+([a-zA-Z_-][\\w-]*)\\s*\\{", end: "\\}", @@ -22,7 +22,7 @@ export default { patterns: [{ include: "$self" }] }, "at-rule-block": { - begin: "(@(?:container|document|font-feature-values|layer|media|scope|starting-style|supports)\\b)[^{;]*\\{", + begin: "(@(?:container|counter-style|document|font-face|font-feature-values|font-palette-values|layer|media|page|position-try|scope|starting-style|supports|view-transition)\\b)[^{;]*\\{", end: "\\}", beginCaptures: { 1: { name: "keyword.control.at-rule" } }, patterns: [{ include: "$self" }] @@ -38,7 +38,7 @@ export default { ] }, "rule-set": { - begin: "([^\\s@{};<][^@{};<;]*?)\\s*\\{", + begin: "([^\\s@{};<][^@{};<]*?)\\s*\\{", end: "\\}", beginCaptures: { 1: { name: "entity.name.selector" } }, patterns: [ diff --git a/src/grammars/dart.js b/src/grammars/dart.js index 9df7bbf..c9fdf8f 100644 --- a/src/grammars/dart.js +++ b/src/grammars/dart.js @@ -25,8 +25,8 @@ export default { patterns: [ { begin: "r?\"\"\"", end: "\"\"\"", name: "string.quoted.triple.double" }, { begin: "r?'''", end: "'''", name: "string.quoted.triple.single" }, - { match: "r?\"(?:\\\\.|[^\"\\\\])*\"", name: "string.quoted.double" }, - { match: "r?'(?:\\\\.|[^'\\\\])*'", name: "string.quoted.single" } + { match: "r?\"(?:\\\\.|[^\"\\\\\\r\\n])*\"", name: "string.quoted.double" }, + { match: "r?'(?:\\\\.|[^'\\\\\\r\\n])*'", name: "string.quoted.single" } ] }, annotations: { match: "@[A-Za-z_][\\w]*", name: "entity.name.decorator" }, diff --git a/src/grammars/graphql.js b/src/grammars/graphql.js index a022442..5dd74cd 100644 --- a/src/grammars/graphql.js +++ b/src/grammars/graphql.js @@ -19,7 +19,7 @@ export default { strings: { patterns: [ { begin: "\"\"\"", end: "\"\"\"", name: "string.quoted.triple" }, - { match: "\"(?:\\\\.|[^\"\\\\])*\"", name: "string.quoted.double" } + { match: "\"(?:\\\\.|[^\"\\\\\\r\\n])*\"", name: "string.quoted.double" } ] }, "type-declaration": { diff --git a/src/grammars/java.js b/src/grammars/java.js index edc4302..94d55c3 100644 --- a/src/grammars/java.js +++ b/src/grammars/java.js @@ -23,8 +23,8 @@ export default { strings: { patterns: [ { begin: "\"\"\"", end: "\"\"\"", name: "string.quoted.triple" }, - { match: "\"(?:\\\\.|[^\"\\\\])*\"", name: "string.quoted.double" }, - { match: "'(?:\\\\.|[^'\\\\])'", name: "string.quoted.char" } + { match: "\"(?:\\\\.|[^\"\\\\\\r\\n])*\"", name: "string.quoted.double" }, + { match: "'(?:\\\\.|[^'\\\\\\r\\n])'", name: "string.quoted.char" } ] }, annotations: { match: "@[A-Za-z_][\\w.]*", name: "entity.name.decorator" }, diff --git a/src/grammars/kotlin.js b/src/grammars/kotlin.js index cb2f2d7..5c9fd81 100644 --- a/src/grammars/kotlin.js +++ b/src/grammars/kotlin.js @@ -23,8 +23,8 @@ export default { strings: { patterns: [ { begin: "\"\"\"", end: "\"\"\"", name: "string.quoted.triple" }, - { match: "\"(?:\\\\.|[^\"\\\\])*\"", name: "string.quoted.double" }, - { match: "'(?:\\\\.|[^'\\\\])'", name: "string.quoted.char" } + { match: "\"(?:\\\\.|[^\"\\\\\\r\\n])*\"", name: "string.quoted.double" }, + { match: "'(?:\\\\.|[^'\\\\\\r\\n])'", name: "string.quoted.char" } ] }, annotations: { match: "@[A-Za-z_][\\w.]*", name: "entity.name.decorator" }, diff --git a/src/grammars/lua.js b/src/grammars/lua.js index 185c254..311c8a4 100644 --- a/src/grammars/lua.js +++ b/src/grammars/lua.js @@ -21,8 +21,8 @@ export default { strings: { patterns: [ { begin: "\\[(=*)\\[", end: "\\]\\1\\]", name: "string.quoted.other.multiline" }, - { match: "\"(?:\\\\.|[^\"\\\\])*\"", name: "string.quoted.double" }, - { match: "'(?:\\\\.|[^'\\\\])*'", name: "string.quoted.single" } + { match: "\"(?:\\\\.|[^\"\\\\\\r\\n])*\"", name: "string.quoted.double" }, + { match: "'(?:\\\\.|[^'\\\\\\r\\n])*'", name: "string.quoted.single" } ] }, "function-declaration": { diff --git a/src/grammars/swift.js b/src/grammars/swift.js index e6122c3..0845b30 100644 --- a/src/grammars/swift.js +++ b/src/grammars/swift.js @@ -23,7 +23,7 @@ export default { strings: { patterns: [ { begin: "\"\"\"", end: "\"\"\"", name: "string.quoted.triple" }, - { match: "\"(?:\\\\.|[^\"\\\\])*\"", name: "string.quoted.double" } + { match: "\"(?:\\\\.|[^\"\\\\\\r\\n])*\"", name: "string.quoted.double" } ] }, attributes: { match: "@[A-Za-z_][\\w]*", name: "entity.name.decorator" }, diff --git a/src/grammars/toml.js b/src/grammars/toml.js index aad9bec..15c0066 100644 --- a/src/grammars/toml.js +++ b/src/grammars/toml.js @@ -25,7 +25,7 @@ export default { patterns: [ { begin: "\"\"\"", end: "\"\"\"", name: "string.quoted.triple.double" }, { begin: "'''", end: "'''", name: "string.quoted.triple.single" }, - { match: "\"(?:\\\\.|[^\"\\\\])*\"", name: "string.quoted.double" }, + { match: "\"(?:\\\\.|[^\"\\\\\\r\\n])*\"", name: "string.quoted.double" }, { match: "'[^'\\r\\n]*'", name: "string.quoted.single" } ] }, diff --git a/src/grammars/yaml.js b/src/grammars/yaml.js index de3a5ad..360d210 100644 --- a/src/grammars/yaml.js +++ b/src/grammars/yaml.js @@ -6,7 +6,9 @@ export default { { match: "^(?:---|\\.\\.\\.)\\s*$|^%YAML\\b.*$", name: "keyword.control.document" }, { match: "^\\s*(?:-\\s+)?([^#\\s][^\\r\\n:#]*?)(?=\\s*:)", captures: { 1: { name: "entity.name.key" } } }, { match: "[&*][a-zA-Z_][\\w-]*|![^\\s]+", name: "entity.name.anchor" }, - { match: "(['\"])(?:\\\\.|(?!\\1)[^\\\\])*\\1", name: "string.quoted" }, + // Quoted scalars may fold over lines, but only onto more-indented ones, so + // a stray apostrophe in a plain scalar cannot swallow the rest of the file. + { match: "(['\"])(?:\\\\.|(?!\\1)[^\\\\\\r\\n]|\\r?\\n[ \\t]+)*\\1", name: "string.quoted" }, { match: "(?<=:\\s)[|>][-+]?\\s*$", name: "keyword.control.block-scalar" }, { match: "\\b(?:true|false|yes|no|on|off)\\b", name: "constant.language.boolean" }, { match: "\\bnull\\b|~", name: "constant.language" }, diff --git a/test/highlight.spec.pw.js b/test/highlight.spec.pw.js index 1d13b82..30b2f41 100644 --- a/test/highlight.spec.pw.js +++ b/test/highlight.spec.pw.js @@ -24,6 +24,33 @@ const readHighlights = page => }; }); +/** + * Highlight a single injected code block and read back its ranges by category. + */ +const highlightSnippet = (page, language, code) => + page.evaluate(async ({ language, code }) => { + const root = document.createElement("div"); + const block = document.createElement("code"); + const pre = document.createElement("pre"); + block.className = `language-${language}`; + block.textContent = code; + pre.append(block); + root.append(pre); + document.body.append(root); + + const { highlightAll } = await import("/docs/microlighter/index.js"); + await highlightAll({ root }); + + const ranges = {}; + for (const [category, highlight] of CSS.highlights) { + for (const range of highlight) { + if (range.startContainer.parentElement !== block) continue; + (ranges[category] ??= []).push(range.toString()); + } + } + return ranges; + }, { language, code }); + test.describe("MicroLighter demo site (docs/index.html)", () => { test("registers highlight ranges across every code block", async ({ page }) => { await page.goto(HOMEPAGE, { waitUntil: "networkidle" }); @@ -231,6 +258,81 @@ test.describe("MicroLighter demo site (docs/index.html)", () => { expect(sampleLanguages).toEqual(["html", "markdown", "python", "sql", "cpp", "tsx"]); }); + test("stops unterminated quotes from swallowing later lines", async ({ page }) => { + await page.goto(HOMEPAGE, { waitUntil: "networkidle" }); + + const css = await highlightSnippet(page, "css", [ + "a { font-family: Don't; }", + 'b { content: "kept"; }', + "c { font-family: Won't; }" + ].join("\n")); + const yaml = await highlightSnippet(page, "yaml", [ + "first: don't", + 'second: "kept"', + "third: won't" + ].join("\n")); + + expect(css.string).toEqual(['"kept"']); + expect(yaml.string).toEqual(['"kept"']); + }); + + test("terminates single-line string literals at the end of the line", async ({ page }) => { + await page.goto(HOMEPAGE, { waitUntil: "networkidle" }); + + // Languages whose grammars only allow multi-line strings through a + // dedicated triple-quoted or long-bracket rule. + const languages = [ + "assembly", "c", "csharp", "css", "dart", "graphql", + "java", "kotlin", "lua", "swift", "toml" + ]; + const unexpected = {}; + + for (const language of languages) { + const ranges = await highlightSnippet(page, language, 'first = \'x\nsecond = "kept"\nthird = \'y\'\n'); + const strings = ranges.string ?? []; + if (!strings.includes('"kept"') || strings.some(value => value.includes("\n"))) { + unexpected[language] = strings; + } + } + + expect(unexpected).toEqual({}); + }); + + test("keeps highlighting yaml quoted scalars folded over several lines", async ({ page }) => { + await page.goto(HOMEPAGE, { waitUntil: "networkidle" }); + + const ranges = await highlightSnippet(page, "yaml", 'key: "folded\n continuation"\nnext: 1\n'); + + expect(ranges.string).toEqual(['"folded\n continuation"']); + }); + + test("categorizes block at-rules as at-rule keywords, not selectors", async ({ page }) => { + await page.goto(HOMEPAGE, { waitUntil: "networkidle" }); + + const ranges = await highlightSnippet(page, "css", [ + '@font-face { font-family: "Example"; }', + "@page { margin: 1cm; }", + "@counter-style thumbs { system: cyclic; }", + "@view-transition { navigation: auto; }" + ].join("\n")); + + expect(ranges["at-rule"]).toEqual([ + "@font-face", + "@page", + "@counter-style", + "@view-transition" + ]); + expect(ranges.selector ?? []).toEqual([]); + }); + + test("scopes multi-line selectors without their trailing whitespace", async ({ page }) => { + await page.goto(HOMEPAGE, { waitUntil: "networkidle" }); + + const ranges = await highlightSnippet(page, "css", "a,\nb\n{\n color: red;\n}\n"); + + expect(ranges.selector).toEqual(["a,\nb"]); + }); + test("loads without runtime errors", async ({ page }) => { const errors = []; page.on("pageerror", error => errors.push(String(error)));