From d9cb4d5feaa8d433fa912e9d536bafb9b22e1b82 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sun, 29 Mar 2026 10:43:53 +0100 Subject: [PATCH] fix: restore syntax highlighting inside Obsidian callouts (#205) * fix: restore syntax highlighting inside Obsidian callouts The callout wrapper adds extra indentation/prefix characters that shifted the document offsets, causing the editor extension to miss SQLSeal codeblocks. Updated range detection to account for callout context so highlighting works correctly inside collapsed and expanded callouts. * chore: refactoring code to reuse the same function in tests and app --- .changeset/gentle-ducks-peel.md | 5 + .../editorExtension/codeBlockExtraction.ts | 78 +++++++++++ .../editorExtension/syntaxHighlight.test.ts | 121 ++++++++++++++++++ .../editorExtension/syntaxHighlight.ts | 36 +----- 4 files changed, 211 insertions(+), 29 deletions(-) create mode 100644 .changeset/gentle-ducks-peel.md create mode 100644 src/modules/syntaxHighlight/editorExtension/codeBlockExtraction.ts create mode 100644 src/modules/syntaxHighlight/editorExtension/syntaxHighlight.test.ts diff --git a/.changeset/gentle-ducks-peel.md b/.changeset/gentle-ducks-peel.md new file mode 100644 index 0000000..119d1e1 --- /dev/null +++ b/.changeset/gentle-ducks-peel.md @@ -0,0 +1,5 @@ +--- +"sqlseal": patch +--- + +fixed syntax highlighting in callouts diff --git a/src/modules/syntaxHighlight/editorExtension/codeBlockExtraction.ts b/src/modules/syntaxHighlight/editorExtension/codeBlockExtraction.ts new file mode 100644 index 0000000..1c0a536 --- /dev/null +++ b/src/modules/syntaxHighlight/editorExtension/codeBlockExtraction.ts @@ -0,0 +1,78 @@ +export interface CodeBlockMatch { + startIndex: number + content: string + /** Characters stripped from the start of each content line (e.g. "> " in callouts) */ + linePrefix?: string +} + +/** + * Extract sqlseal code blocks from markdown text, including those inside callouts. + * + * This function handles both regular code blocks and those inside Obsidian callouts + * (which are prefixed with "> " on each line). For callout blocks, the prefix is + * stripped from content lines so the SQL parser can process them cleanly. + * + * @param text - The raw markdown text to search + * @returns Array of code block matches with their content and position information + */ +export function extractCodeBlocks(text: string): CodeBlockMatch[] { + // Parsing — match code blocks with optional callout prefix (e.g. "> ") on fence lines. + // The prefix is captured so positions can be mapped back to the raw document. + const codeBlockRegex = /^([ \t]*(?:> )*)```(sqlseal)\n([\s\S]*?)^[ \t]*(?:> )*```/gm + let match + const results: CodeBlockMatch[] = [] + + while ((match = codeBlockRegex.exec(text)) !== null) { + const fencePrefix = match[1] || '' + const langTag = match[2] + const rawContent = match[3] + const blockStart = match.index + const langTagEnd = blockStart + fencePrefix.length + 3 + langTag.length // prefix + ``` + langTag + const contentStart = langTagEnd + 1 // +1 for the newline after the opening fence + + if (!fencePrefix) { + results.push({ content: rawContent, startIndex: contentStart }) + } else { + // Strip the callout prefix from each content line so the grammar can parse it cleanly. + const strippedLines = rawContent.split('\n').map(line => + line.startsWith(fencePrefix) ? line.slice(fencePrefix.length) : line + ) + results.push({ + content: strippedLines.join('\n'), + startIndex: contentStart, + linePrefix: fencePrefix + }) + } + } + + return results +} + +/** + * Map a position in stripped content back to the raw document position. + * + * For callout blocks, each line has a prefix (e.g. "> ") that was removed before + * parsing. This function restores that offset: for a position on line N, it adds + * (N+1) * prefixLen to account for all the stripped prefixes up to that point. + * + * Formula: rawPos = startIndex + strippedPos + (lineNum + 1) * prefixLen + * where lineNum = count of \n characters in content before posInContent + * + * @param content - The stripped content (after prefix removal) + * @param linePrefix - The prefix that was stripped (e.g. "> "), empty string if none + * @param startIndex - Starting position of the content in the raw document + * @param posInContent - Position within the stripped content + * @returns Position in the raw document + */ +export function toDocPos( + content: string, + linePrefix: string, + startIndex: number, + posInContent: number +): number { + if (!linePrefix) return startIndex + posInContent + + const prefixLen = linePrefix.length + const lineCount = (content.slice(0, posInContent).match(/\n/g) || []).length + return startIndex + posInContent + (lineCount + 1) * prefixLen +} diff --git a/src/modules/syntaxHighlight/editorExtension/syntaxHighlight.test.ts b/src/modules/syntaxHighlight/editorExtension/syntaxHighlight.test.ts new file mode 100644 index 0000000..e6f4d0a --- /dev/null +++ b/src/modules/syntaxHighlight/editorExtension/syntaxHighlight.test.ts @@ -0,0 +1,121 @@ +/** + * Tests for the callout syntax-highlighting fix (main-ow0). + * + * SQLSealViewPlugin is not imported here because it pulls in Obsidian/CodeMirror + * globals that don't exist in Node. Instead, we test the pure logic functions + * that were extracted for testability: + * + * 1. extractCodeBlocks() - The regex that finds sqlseal blocks (including inside callouts) + * and the prefix-stripping that cleans "> " from each content line + * 2. toDocPos() - The formula that maps stripped positions back to raw doc positions + */ + +import { extractCodeBlocks, toDocPos } from './codeBlockExtraction'; + +// -------------------------------------------------------------------------- +// Tests +// -------------------------------------------------------------------------- + +describe('callout code block detection (getCodeBlocks logic)', () => { + describe('regular (non-callout) blocks', () => { + it('finds a simple sqlseal block', () => { + const text = '```sqlseal\nSELECT * FROM files\n```\n' + const blocks = extractCodeBlocks(text) + expect(blocks).toHaveLength(1) + expect(blocks[0].content).toBe('SELECT * FROM files\n') + expect(blocks[0].linePrefix).toBeUndefined() + }) + + it('sets startIndex to char 11 ("```sqlseal\\n" = 11 chars)', () => { + const text = '```sqlseal\nSELECT 1\n```\n' + expect(extractCodeBlocks(text)[0].startIndex).toBe(11) + }) + + it('returns empty array when there are no sqlseal blocks', () => { + expect(extractCodeBlocks('no blocks here')).toHaveLength(0) + expect(extractCodeBlocks('```sql\nSELECT 1\n```')).toHaveLength(0) + }) + + it('finds multiple blocks', () => { + const text = '```sqlseal\nSELECT 1\n```\n\ntext\n\n```sqlseal\nSELECT 2\n```\n' + expect(extractCodeBlocks(text)).toHaveLength(2) + }) + }) + + describe('callout blocks (bug: were invisible to the highlighter)', () => { + it('finds a sqlseal block inside a callout', () => { + // Before fix: regex /```(sqlseal)\n([\s\S]*?)```/g did not match + // when opening fence was preceded by "> " + const text = '> ```sqlseal\n> SELECT * FROM files\n> ```\n' + const blocks = extractCodeBlocks(text) + expect(blocks).toHaveLength(1) + }) + + it('strips "> " prefix from each content line', () => { + const text = '> ```sqlseal\n> SELECT * FROM files\n> ```\n' + const blocks = extractCodeBlocks(text) + // Grammar must receive clean SQL, not "> SELECT * FROM files" + expect(blocks[0].content).toContain('SELECT * FROM files') + expect(blocks[0].content).not.toContain('> ') + }) + + it('sets linePrefix to "> "', () => { + const text = '> ```sqlseal\n> SELECT 1\n> ```\n' + expect(extractCodeBlocks(text)[0].linePrefix).toBe('> ') + }) + + it('sets startIndex to first raw char of content (after "> ```sqlseal\\n")', () => { + // "> "(2) + "```"(3) + "sqlseal"(7) + "\n"(1) = 13 + const text = '> ```sqlseal\n> SELECT 1\n> ```\n' + expect(extractCodeBlocks(text)[0].startIndex).toBe(13) + }) + + it('correctly strips multi-line callout content', () => { + const text = '> ```sqlseal\n> LINE 1\n> LINE 2\n> ```\n' + const blocks = extractCodeBlocks(text) + expect(blocks[0].content).toBe('LINE 1\nLINE 2\n') + }) + }) +}) + +describe('toDocPos() — maps stripped positions to raw document positions', () => { + /** + * For a callout block, each line in the raw document has a "> " prefix (2 chars) + * that was stripped before grammar parsing. When applying decorations we must add + * those chars back. + * + * Formula: rawPos = startIndex + strippedPos + (lineNum + 1) * prefixLen + * where lineNum = count of \n characters in strippedContent before strippedPos + */ + + it('maps position 0 on line 0 (S of SELECT)', () => { + // "> ```sqlseal\n" = 13 chars → contentStart = 13 + // raw line: "> SELECT * FROM files\n" → S is at raw[15] = 13 + 2 + // formula: 13 + 0 + (0+1)*2 = 15 + expect(toDocPos('SELECT * FROM files\n', '> ', 13, 0)).toBe(15) + }) + + it('maps end of SELECT keyword (pos 6) on line 0', () => { + // formula: 13 + 6 + (0+1)*2 = 21 + expect(toDocPos('SELECT * FROM files\n', '> ', 13, 6)).toBe(21) + }) + + it('maps start of second line (after first \\n)', () => { + // strippedContent = "LINE1\nLINE2\n", pos=6 = L of LINE2, lineNum=1 + // formula: 13 + 6 + (1+1)*2 = 23 + expect(toDocPos('LINE1\nLINE2\n', '> ', 13, 6)).toBe(23) + }) + + it('maps the \\n at end of line 0', () => { + // "SELECT\n", pos=6 = \n, lineNum=0 + // formula: 13 + 6 + (0+1)*2 = 21 + // raw line "> SELECT\n" has \n at index 13+2+6 = 21 ✓ + expect(toDocPos('SELECT\n', '> ', 13, 6)).toBe(21) + }) + + it('uses prefix length correctly for longer prefixes', () => { + // nested callout prefix "> > " (4 chars) + // pos=0, lineNum=0: 13 + 0 + (0+1)*4 = 17 + expect(toDocPos('SELECT 1\n', '> > ', 13, 0)).toBe(17) + }) +}) diff --git a/src/modules/syntaxHighlight/editorExtension/syntaxHighlight.ts b/src/modules/syntaxHighlight/editorExtension/syntaxHighlight.ts index a03b6b0..e9dc542 100644 --- a/src/modules/syntaxHighlight/editorExtension/syntaxHighlight.ts +++ b/src/modules/syntaxHighlight/editorExtension/syntaxHighlight.ts @@ -16,11 +16,7 @@ import { Decorator, highlighterOperation } from '../grammar/highlighterOperation import { FilePathWidget } from './widgets/FilePathWidget'; import { RendererRegistry } from '../../editor/renderer/rendererRegistry'; import { SQLSealLangDefinition } from '../../editor/parser'; - -interface CodeBlockMatch { - startIndex: number, - content: string -} +import { extractCodeBlocks, toDocPos, CodeBlockMatch } from './codeBlockExtraction'; const markDecorations = { blockFlag: Decoration.mark({ class: 'cm-sqlseal-block-flag' }), @@ -83,21 +79,7 @@ export class SQLSealViewPlugin implements PluginValue { }] } - // Parsing - const codeBlockRegex = /```(sqlseal)\n([\s\S]*?)```/g; - let match; - let results: CodeBlockMatch[] = [] - while ((match = codeBlockRegex.exec(text)) !== null) { - const blockStart = match.index; - const langTagEnd = blockStart + match[1].length + 3; - const sqlContent = match[2]; - const contentStart = langTagEnd + 1; - results.push({ - content: sqlContent, - startIndex: contentStart - }) - } - return results + return extractCodeBlocks(text) } decorateFilename(dec: Decorator, { content, startIndex }: CodeBlockMatch) { @@ -123,9 +105,10 @@ export class SQLSealViewPlugin implements PluginValue { } privateDecorateCodeblock(codeblockMatch: CodeBlockMatch): Array> { - const { content, startIndex } = codeblockMatch + const { content, startIndex, linePrefix } = codeblockMatch const decorations = this.parseWithGrammar(content); - return (decorations || []).flatMap(dec => { + + return (decorations || []).flatMap(dec => { switch (dec.type) { case 'filename': return this.decorateFilename(dec, codeblockMatch) @@ -133,8 +116,8 @@ export class SQLSealViewPlugin implements PluginValue { const decoration = markDecorations[dec.type as keyof typeof markDecorations]; if (decoration) { return decoration.range( - startIndex + dec.start, - startIndex + dec.end + toDocPos(content, linePrefix || '', startIndex, dec.start), + toDocPos(content, linePrefix || '', startIndex, dec.end) ) } else { return [] @@ -144,11 +127,6 @@ export class SQLSealViewPlugin implements PluginValue { } private buildDecorations(view: EditorView): DecorationSet { - const builder: Array> = []; - // const text = view.state.doc.toString(); - // const codeBlockRegex = /```(sqlseal)\n([\s\S]*?)```/g; - // let match; - const results = this.getCodeBlocks(view) const decorators = results.flatMap(r => this.privateDecorateCodeblock(r))