diff --git a/web/app/components/editor/blocks/code-block/languages.test.ts b/web/app/components/editor/blocks/code-block/languages.test.ts index b78b8bb7..a616682f 100644 --- a/web/app/components/editor/blocks/code-block/languages.test.ts +++ b/web/app/components/editor/blocks/code-block/languages.test.ts @@ -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["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 +} diff --git a/web/app/components/editor/blocks/code-block/languages.ts b/web/app/components/editor/blocks/code-block/languages.ts index 66c01dc0..c59788ef 100644 --- a/web/app/components/editor/blocks/code-block/languages.ts +++ b/web/app/components/editor/blocks/code-block/languages.ts @@ -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 = { @@ -42,9 +42,25 @@ const languageNames: Record = { 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 + 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([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 + } +}