1import { Editor } from "@tiptap/core";
2import { EditorState, type Plugin, type Transaction } from "@tiptap/pm/state";
3import type { DecorationSet, EditorView } from "@tiptap/pm/view";
4import StarterKit from "@tiptap/starter-kit";
5import { afterEach, describe, expect, it, onTestFinished, vi } from "vite-plus/test";
6
7import { ComposerCodeBlockExtension } from "~/composer-rich-text-doc";
8import type { DiffThemeName } from "~/lib/diffRendering";
9import { getSyntaxHighlighterPromise } from "~/lib/syntaxHighlighting";
10
11import { composerCodeBlockHighlight, tokenizeBlock } from "./composerCodeBlockHighlight";
12
13vi.mock("~/lib/syntaxHighlighting", () => ({ getSyntaxHighlighterPromise: vi.fn() }));
14
15const THEME = "pierre-dark" as DiffThemeName;
16
17function fakeHighlighter(colorFor: (content: string) => string | undefined) {
18 return {
19 codeToTokens(code: string) {
20 return {
21 tokens: code.split(/\r?\n/).map((line) =>
22 line
23 .split(/(\s+)/)
24 .filter((part) => part.length > 0)
25 .map((content) => ({ content, color: colorFor(content) })),
26 ),
27 };
28 },
29 } as unknown as Parameters<typeof tokenizeBlock>[0];
30}
31
32describe("composer code block highlighting", () => {
33 it("accounts for both characters of a CRLF line break", () => {
34 const highlighter = fakeHighlighter((content) => (content === "two" ? "#00ff00" : undefined));
35
36 const decorations = tokenizeBlock(highlighter, "one\r\ntwo", "ts", THEME);
37
38 expect(decorations).toEqual([{ from: 5, to: 8, color: "#00ff00" }]);
39 });
40});
41
42describe("highlighting edited fences", () => {
43 afterEach(() => {
44 vi.useRealTimers();
45 vi.unstubAllGlobals();
46 });
47
48 async function mountFence(text: string, resolveTheme = () => THEME, language = "ts") {
49 vi.useFakeTimers();
50 const highlighter = fakeHighlighter((content) =>
51 content.startsWith("x") ? "#ff0000" : undefined,
52 );
53 const tokenize = vi.spyOn(highlighter, "codeToTokens");
54 vi.mocked(getSyntaxHighlighterPromise).mockResolvedValue(highlighter);
55 const editor = new Editor({
56 extensions: [
57 StarterKit.configure({ codeBlock: false, trailingNode: false }),
58 ComposerCodeBlockExtension,
59 composerCodeBlockHighlight({ resolveTheme }),
60 ],
61 content: {
62 type: "doc",
63 content: [{ type: "codeBlock", attrs: { language }, content: [{ type: "text", text }] }],
64 },
65 });
66 vi.stubGlobal("document", { documentElement: {} });
67 vi.stubGlobal(
68 "MutationObserver",
69 class {
70 observe() {}
71 disconnect() {}
72 },
73 );
74 const plugin = editor.extensionManager.plugins.find((candidate) =>
75 (candidate as unknown as { key: string }).key.startsWith("composerCodeBlockHighlight"),
76 ) as Plugin<DecorationSet>;
77 // A headless editor holds no plugins in its state, so give this one its own.
78 let state = EditorState.create({ doc: editor.state.doc, plugins: [plugin] });
79 // Like a real view, every dispatch updates the plugin's view.
80 let pluginView: ReturnType<NonNullable<typeof plugin.spec.view>> | undefined;
81 const view = {
82 get state() {
83 return state;
84 },
85 dispatch: (transaction: Transaction) => {
86 const previous = state;
87 state = state.apply(transaction);
88 pluginView?.update?.(view, previous);
89 },
90 } as unknown as EditorView;
91 pluginView = plugin.spec.view!(view);
92 onTestFinished(() => {
93 pluginView?.destroy?.();
94 editor.destroy();
95 });
96 await vi.advanceTimersByTimeAsync(0);
97 return {
98 tokenize,
99 ranges: () =>
100 plugin
101 .getState(state)!
102 .find()
103 .map(({ from, to }) => [from, to]),
104 edit: async (change: (transaction: Transaction) => Transaction) => {
105 view.dispatch(change(state.tr));
106 await vi.advanceTimersByTimeAsync(0);
107 },
108 colored: () =>
109 plugin
110 .getState(state)!
111 .find()
112 .map((decoration) => state.doc.textBetween(decoration.from, decoration.to)),
113 };
114 }
115
116 it("clips a split token to its fences without coloring the paragraph between them", async () => {
117 const fence = await mountFence("x".repeat(6_000));
118
119 await fence.edit((tr) =>
120 tr
121 .split(3_001)
122 .insert(
123 3_002,
124 tr.doc.type.schema.node("paragraph", null, [tr.doc.type.schema.text("plain")]),
125 ),
126 );
127
128 expect(fence.tokenize).toHaveBeenCalledTimes(1);
129 expect(fence.ranges()).toEqual([
130 [1, 3_001],
131 [3_010, 6_010],
132 ]);
133 expect(fence.colored()).toEqual(["x".repeat(3_000), "x".repeat(3_000)]);
134 });
135
136 it("highlights a gitignore fence with the ini grammar, as chat does", async () => {
137 await mountFence("*.log\n!keep.log", () => THEME, 'gitignore title=".gitignore"');
138
139 expect(vi.mocked(getSyntaxHighlighterPromise)).toHaveBeenLastCalledWith("ini");
140 });
141});