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))