improve syntax highlighting

This commit is contained in:
swithek 2026-10-11 22:11:53 +02:00
parent 8a639da0c4
commit cb7435db2c
2 changed files with 139 additions and 2 deletions

View File

@ -1,4 +1,4 @@
import { common } from "lowlight"
import { common, createLowlight } from "lowlight"
import { describe, it } from "vitest"
import {
defaultExtendedCodeBlockLanguage,
@ -19,6 +19,52 @@ describe("extendedCodeBlockLanguages", () => {
})
})
describe("function calls", () => {
const extended = createLowlight(extendedCodeBlockLanguages)
const stock = createLowlight(common)
it.for([
{ key: "csharp", code: "Console.WriteLine(Foo(1));" },
{ key: "go", code: "fmt.Println(foo(1))" },
{ key: "java", code: "System.out.println(foo(1));" },
{ key: "kotlin", code: "a.println(foo(1))" },
{ key: "python", code: "os.path.join(foo(1))" },
{ key: "ruby", code: "a.puts(foo(1))" },
{ key: "swift", code: "a.write(foo(1))" },
])("marks $key calls, nested ones included", ({ key, code }, { expect }) => {
expect(callNames(extended.highlight(key, code))).toHaveLength(2)
expect(callNames(stock.highlight(key, code))).toEqual([])
})
it.for([
{ name: "a keyword before a parenthesis", code: "if (a) { }" },
{ name: "a type conversion", code: "x := string(a)" },
{ name: "a built-in", code: "n := len(a)" },
{ name: "a comment", code: "// foo(1)" },
{ name: "a string", code: 's := "foo(1)"' },
])("leaves $name alone", ({ code }, { expect }) => {
expect(callNames(extended.highlight("go", code))).toEqual([])
})
it.for([
{ key: "python", code: "def run(a=make(1)): pass", expected: ["make"] },
{ key: "swift", code: 'let s = "\\(fmt(a))"', expected: ["fmt"] },
])(
"marks a $key call inside a nested mode",
({ key, code, expected }, { expect }) => {
expect(callNames(extended.highlight(key, code))).toEqual(expected)
},
)
it("keeps the relevance score of the stock grammar", ({ expect }) => {
const code = 'func main() {\n\tfmt.Println(foo("a"))\n}'
expect(extended.highlight("go", code).data?.relevance).toBe(
stock.highlight("go", code).data?.relevance,
)
})
})
describe("extendedCodeBlockLanguageOptions", () => {
it("lists every registered grammar", ({ expect }) => {
expect(Object.keys(extendedCodeBlockLanguageOptions).toSorted()).toEqual(
@ -62,3 +108,25 @@ describe("defaultExtendedCodeBlockLanguage", () => {
).toBeTypeOf("function")
})
})
type Root = ReturnType<ReturnType<typeof createLowlight>["highlight"]>
function callNames(node: Root | Root["children"][number]): string[] {
if (!("children" in node)) {
return []
}
const nested = node.children.flatMap(callNames)
if (node.type !== "element") {
return nested
}
const classes = node.properties.className
if (!Array.isArray(classes) || !classes.includes("invoke__")) {
return nested
}
const text = node.children[0]
return text?.type === "text" ? [text.value] : nested
}

View File

@ -1,5 +1,5 @@
import { common } from "lowlight"
import type { LanguageFn } from "highlight.js"
import type { LanguageFn, Mode } from "highlight.js"
import curl from "highlightjs-curl"
const languageNames: Record<string, string> = {
@ -42,9 +42,25 @@ const languageNames: Record<string, string> = {
xml: "XML / HTML",
yaml: "YAML",
}
// grammars that leave function calls unmarked
const callLanguages = [
"csharp",
"go",
"java",
"kotlin",
"python",
"ruby",
"swift",
]
export const extendedCodeBlockLanguages = { ...common }
extendedCodeBlockLanguages.curl = curl as LanguageFn
for (const [key, grammar] of Object.entries(common)) {
if (callLanguages.includes(key)) {
extendedCodeBlockLanguages[key] = withFunctionCalls(grammar)
}
}
export const extendedCodeBlockLanguageOptions = Object.fromEntries(
Object.keys(extendedCodeBlockLanguages)
.sort((a, b) => (a < b ? -1 : 1))
@ -53,3 +69,56 @@ export const extendedCodeBlockLanguageOptions = Object.fromEntries(
}),
)
export const defaultExtendedCodeBlockLanguage = "plaintext"
function withFunctionCalls(grammar: LanguageFn): LanguageFn {
return (hljs) => {
const language = grammar(hljs)
// each of these grammars groups its keywords by scope
const groups = language.keywords as Record<string, string | string[]>
const keywords = new Set(
Object.entries(groups)
.filter(([scope]) => !scope.startsWith("$"))
.flatMap(([, names]) =>
typeof names === "string" ? names.split(/\s+/) : names,
)
// a name may carry a relevance suffix: "name|0"
.map((name) => name.replace(/\|.*/, "")),
)
const call: Mode = {
scope: "title.function.invoke",
match: /\b[A-Za-z_]\w*(?=\s*\()/,
// language detection compares relevance scores. A call must not
// add to them.
relevance: 0,
// a mode match wins over a keyword, so `if (` would turn into
// a call
"on:begin": (match, response) => {
if (keywords.has(match[0])) {
response.ignoreMatch()
}
},
}
const seen = new Set<Mode>([call])
// nested modes do not inherit the top level rules. Modes with keywords
// hold code, such as parameters and string interpolation.
const visit = (mode: Mode | "self" | undefined | null) => {
if (!mode || mode === "self" || seen.has(mode)) {
return
}
seen.add(mode)
mode.contains?.forEach(visit)
mode.variants?.forEach(visit)
visit(mode.starts)
if ((mode === language || mode.keywords) && mode.contains) {
mode.contains.push(call)
}
}
visit(language)
return language
}
}