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) {
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: [
64 { type: "codeBlock", attrs: { language: "ts" }, content: [{ type: "text", text }] },
65 ],
66 },
67 });
68 vi.stubGlobal("document", { documentElement: {} });
69 vi.stubGlobal(
70 "MutationObserver",
71 class {
72 observe() {}
73 disconnect() {}
74 },
75 );
76 const plugin = editor.extensionManager.plugins.find((candidate) =>
77 (candidate as unknown as { key: string }).key.startsWith("composerCodeBlockHighlight"),
78 ) as Plugin<DecorationSet>;
79 // A headless editor holds no plugins in its state, so give this one its own.
80 let state = EditorState.create({ doc: editor.state.doc, plugins: [plugin] });
81 // Like a real view, every dispatch updates the plugin's view.
82 let pluginView: ReturnType<NonNullable<typeof plugin.spec.view>> | undefined;
83 const view = {
84 get state() {
85 return state;
86 },
87 dispatch: (transaction: Transaction) => {
88 const previous = state;
89 state = state.apply(transaction);
90 pluginView?.update?.(view, previous);
91 },
92 } as unknown as EditorView;
93 pluginView = plugin.spec.view!(view);
94 onTestFinished(() => {
95 pluginView?.destroy?.();
96 editor.destroy();
97 });
98 await vi.advanceTimersByTimeAsync(0);
99 return {
100 tokenize,
101 ranges: () =>
102 plugin
103 .getState(state)!
104 .find()
105 .map(({ from, to }) => [from, to]),
106 edit: async (change: (transaction: Transaction) => Transaction) => {
107 view.dispatch(change(state.tr));
108 await vi.advanceTimersByTimeAsync(0);
109 },
110 colored: () =>
111 plugin
112 .getState(state)!
113 .find()
114 .map((decoration) => state.doc.textBetween(decoration.from, decoration.to)),
115 };
116 }
117
118 it("clips a split token to its fences without coloring the paragraph between them", async () => {
119 const fence = await mountFence("x".repeat(6_000));
120
121 await fence.edit((tr) =>
122 tr
123 .split(3_001)
124 .insert(
125 3_002,
126 tr.doc.type.schema.node("paragraph", null, [tr.doc.type.schema.text("plain")]),
127 ),
128 );
129
130 expect(fence.tokenize).toHaveBeenCalledTimes(1);
131 expect(fence.ranges()).toEqual([
132 [1, 3_001],
133 [3_010, 6_010],
134 ]);
135 expect(fence.colored()).toEqual(["x".repeat(3_000), "x".repeat(3_000)]);
136 });
137});