mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 02:49:18 +02:00
improve syntax highlighting
This commit is contained in:
parent
8a639da0c4
commit
cb7435db2c
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user