Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## Unreleased

### Bug Fixes

- fix: do not flag `{key="value"}` inside inline code spans as needing whitespace removal. Backtick-wrapped attribute syntax in prose (for example, when documenting Pandoc attribute rules) is now correctly recognised as code and skipped by the inline attribute diagnostics.
- fix: do not extract `{...}` patterns from YAML front matter as Pandoc attribute blocks. Curly braces inside literal block scalars (for example, Typst code under `include-before-body`) no longer trigger false-positive inline attribute diagnostics.

## 3.0.0 (2026-04-29)

### New Features
Expand Down
52 changes: 39 additions & 13 deletions src/providers/inlineAttributeDiagnosticsProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ import {
} from "./elementAttributeCompletionProvider";
import { collectShortcodeSchemas, resolveShortcodeAttribute } from "./shortcodeCompletionProvider";
import { getErrorMessage } from "@quarto-wizard/core";
import { getCodeBlockRanges, isInCodeBlockRange, type TextRange } from "../utils/yamlPosition";
import {
getCodeBlockRanges,
getInlineCodeSpanRanges,
getYamlFrontMatterRange,
isInCodeBlockRange,
type TextRange,
} from "../utils/yamlPosition";
import { logMessage } from "../utils/log";
import { debounce } from "../utils/debounce";

Expand Down Expand Up @@ -249,30 +255,50 @@ interface BlockMatch {
}

/**
* Check whether a match range overlaps any code block range.
* Tests both endpoints and spanning (match starts before and ends inside a block).
* Check whether a match range overlaps any exclusion range.
*
* Fenced code blocks use full overlap (start, end, or spanning) because an
* attribute block can legitimately straddle a fence boundary only when it is
* not really an attribute.
*
* Inline code spans use a stricter rule: only exclude when the match start
* lies inside an inline span. This avoids false exclusion of fence-header
* attribute blocks like `` ```{.r code-summary="see `fn()`"} `` whose body
* contains quoted backticks that look like an inline code span.
*/
function overlapsCodeBlock(ranges: TextRange[], matchStart: number, matchEnd: number): boolean {
return (
isInCodeBlockRange(ranges, matchStart) ||
isInCodeBlockRange(ranges, matchEnd) ||
ranges.some((r) => matchStart < r.start && matchEnd >= r.start)
);
function overlapsExclusionRanges(
fencedRanges: TextRange[],
inlineRanges: TextRange[],
matchStart: number,
matchEnd: number,
): boolean {
if (
isInCodeBlockRange(fencedRanges, matchStart) ||
isInCodeBlockRange(fencedRanges, matchEnd) ||
fencedRanges.some((r) => matchStart < r.start && matchEnd >= r.start)
) {
return true;
}
return isInCodeBlockRange(inlineRanges, matchStart);
}

/**
* Extract all attribute blocks and shortcode blocks from document text,
* excluding matches that fall inside fenced code blocks.
* excluding matches that fall inside fenced code blocks, the YAML front
* matter, or inline code spans.
*/
export function extractBlocks(text: string, codeBlockRanges?: TextRange[]): BlockMatch[] {
const ranges = codeBlockRanges ?? getCodeBlockRanges(text);
const codeRanges = codeBlockRanges ?? getCodeBlockRanges(text);
const yamlRange = getYamlFrontMatterRange(text);
const fencedRanges = yamlRange ? [yamlRange, ...codeRanges] : codeRanges;
const inlineRanges = getInlineCodeSpanRanges(text, fencedRanges);
const blocks: BlockMatch[] = [];

for (const match of text.matchAll(ELEMENT_ATTRIBUTE_RE)) {
if (match.index === undefined) {
continue;
}
if (overlapsCodeBlock(ranges, match.index, match.index + match[0].length - 1)) {
if (overlapsExclusionRanges(fencedRanges, inlineRanges, match.index, match.index + match[0].length - 1)) {
continue;
}
// Content is everything between { and }.
Expand All @@ -285,7 +311,7 @@ export function extractBlocks(text: string, codeBlockRanges?: TextRange[]): Bloc
if (match.index === undefined) {
continue;
}
if (overlapsCodeBlock(ranges, match.index, match.index + match[0].length - 1)) {
if (overlapsExclusionRanges(fencedRanges, inlineRanges, match.index, match.index + match[0].length - 1)) {
continue;
}
// Content is everything between {{< and >}}.
Expand Down
109 changes: 109 additions & 0 deletions src/test/suite/inlineAttributeDiagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,115 @@ suite("Inline Attribute Diagnostics", () => {
});
});

suite("extractBlocks (inline code spans)", () => {
test("should not extract attribute block from inside a single-backtick span", () => {
const text = 'Pandoc syntax: `{key="value"}` produces an attribute.';
const blocks = extractBlocks(text);
assert.strictEqual(blocks.length, 0);
});

test("should not extract attribute block with spaces around = inside backticks", () => {
const text = 'But `{key = "value"}` does not.';
const blocks = extractBlocks(text);
assert.strictEqual(blocks.length, 0);
});

test("should not produce spaces-around-equals diagnostic for backtick-wrapped {key = value}", () => {
const text = 'see `{key = "value"}` here';
const blocks = extractBlocks(text);
for (const block of blocks) {
assert.strictEqual(findSpacesAroundEquals(block.content).length, 0);
}
});

test("should still extract a real {=html} attribute on inline code", () => {
// Pandoc raw inline: `code`{=html} — the {=html} attribute is OUTSIDE
// the inline code span and is a legitimate attribute block.
const text = "x `code`{=html} y";
const blocks = extractBlocks(text);
assert.strictEqual(blocks.length, 1);
assert.strictEqual(blocks[0].content, "=html");
});

test("should still extract a normal attribute outside any backticks", () => {
const text = '[span]{.cls key="value"} and `inline {a=b}`';
const blocks = extractBlocks(text);
assert.strictEqual(blocks.length, 1);
assert.strictEqual(blocks[0].content, '.cls key="value"');
});

test("should not extract attribute from inside a double-backtick span", () => {
const text = "before ``contains {key = val} here`` after";
const blocks = extractBlocks(text);
assert.strictEqual(blocks.length, 0);
});
});

suite("extractBlocks (YAML front matter)", () => {
test("should not extract a {...} block from a YAML literal block scalar", () => {
const text = [
"---",
"format: typst",
"include-before-body:",
" - text: |",
" #show raw.where(block: false): it => {",
" let text = it.text();",
" it",
" }",
"---",
"",
"body",
].join("\n");
const blocks = extractBlocks(text);
assert.strictEqual(blocks.length, 0);
});

test("should not produce spaces-around-equals findings inside YAML front matter", () => {
const text = [
"---",
"format: typst",
"include-before-body:",
" - text: |",
" it => {",
" let text = it.text();",
" }",
"---",
].join("\n");
const blocks = extractBlocks(text);
for (const block of blocks) {
assert.strictEqual(findSpacesAroundEquals(block.content).length, 0);
}
});

test("should not extract {a=b} inside a quoted YAML scalar value", () => {
const text = ["---", 'title: "literal {a = b} text"', "---", ""].join("\n");
const blocks = extractBlocks(text);
assert.strictEqual(blocks.length, 0);
});

test("should still extract attribute blocks after the closing front matter", () => {
const text = ["---", "title: Test", "---", "", '[span]{.cls key="value"}'].join("\n");
const blocks = extractBlocks(text);
assert.strictEqual(blocks.length, 1);
assert.strictEqual(blocks[0].content, '.cls key="value"');
});

test("should be unaffected when the document has no front matter", () => {
const text = '[span]{.cls key="value"}';
const blocks = extractBlocks(text);
assert.strictEqual(blocks.length, 1);
assert.strictEqual(blocks[0].content, '.cls key="value"');
});

test("should handle CRLF front matter correctly", () => {
const text = ["---", "format: typst", "include-before-body:", " - text: |", " it => { x }", "---", ""].join(
"\r\n",
);
const blocks = extractBlocks(text);
assert.strictEqual(blocks.length, 0);
});
});

suite("extractBareWords", () => {
test("should extract bare word from element content", () => {
const results = extractBareWords(".class myword");
Expand Down
124 changes: 124 additions & 0 deletions src/test/suite/yamlPosition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
getYamlIndentLevel,
getExistingKeysAtPath,
getCodeBlockRanges,
getInlineCodeSpanRanges,
getYamlFrontMatterRange,
isInCodeBlockRange,
hasUnquotedBacktick,
} from "../../utils/yamlPosition";
Expand Down Expand Up @@ -539,6 +541,128 @@ suite("YAML Position Utils Test Suite", () => {
});
});

suite("getInlineCodeSpanRanges", () => {
test("should detect a single-backtick inline code span", () => {
const text = "before `code` after";
const ranges = getInlineCodeSpanRanges(text, []);
assert.strictEqual(ranges.length, 1);
assert.strictEqual(text.slice(ranges[0].start, ranges[0].end), "`code`");
});

test("should cover a curly-brace attribute block inside backticks", () => {
const text = 'see `{key = "value"}` here';
const ranges = getInlineCodeSpanRanges(text, []);
assert.strictEqual(ranges.length, 1);
const braceStart = text.indexOf("{");
assert.ok(braceStart >= ranges[0].start && braceStart < ranges[0].end);
});

test("should require matching backtick run length", () => {
// Single backtick start cannot close on a double-backtick run.
const text = "a `one`` not closed";
const ranges = getInlineCodeSpanRanges(text, []);
assert.strictEqual(ranges.length, 0);
});

test("should allow single backticks inside a double-backtick span", () => {
const text = "a ``has ` inside`` end";
const ranges = getInlineCodeSpanRanges(text, []);
assert.strictEqual(ranges.length, 1);
assert.strictEqual(text.slice(ranges[0].start, ranges[0].end), "``has ` inside``");
});

test("should leave a {...} after the closing backticks outside the range", () => {
// Pandoc inline code with attribute: `code`{=html}
const text = "x `code`{=html} y";
const ranges = getInlineCodeSpanRanges(text, []);
assert.strictEqual(ranges.length, 1);
const attrStart = text.indexOf("{=html}");
assert.ok(attrStart >= ranges[0].end);
});

test("should return empty array for unclosed backtick run", () => {
const text = "no closer `here";
const ranges = getInlineCodeSpanRanges(text, []);
assert.strictEqual(ranges.length, 0);
});

test("should skip backticks inside fenced code block ranges", () => {
const text = "```\n`x`\n```\n`y`";
const fenced = getCodeBlockRanges(text);
const ranges = getInlineCodeSpanRanges(text, fenced);
assert.strictEqual(ranges.length, 1);
assert.strictEqual(text.slice(ranges[0].start, ranges[0].end), "`y`");
});

test("should return empty array for text with no backticks", () => {
assert.deepStrictEqual(getInlineCodeSpanRanges("plain text", []), []);
});

test("should detect multiple inline code spans", () => {
const text = "`a` and `b` and `c`";
const ranges = getInlineCodeSpanRanges(text, []);
assert.strictEqual(ranges.length, 3);
});
});

suite("getYamlFrontMatterRange", () => {
test("should cover the opening, body, and closing delimiter lines", () => {
const text = "---\ntitle: Test\n---\nbody\n";
const range = getYamlFrontMatterRange(text);
assert.ok(range);
assert.strictEqual(range!.start, 0);
assert.strictEqual(text.slice(range!.start, range!.end), "---\ntitle: Test\n---");
});

test("should return undefined when line 0 is not a fence", () => {
const text = "no front matter here\n---\nfoo\n---\n";
assert.strictEqual(getYamlFrontMatterRange(text), undefined);
});

test("should return undefined when there is no closing fence", () => {
const text = "---\ntitle: Test\nbody\n";
assert.strictEqual(getYamlFrontMatterRange(text), undefined);
});

test("should handle empty front matter", () => {
const text = "---\n---\nbody\n";
const range = getYamlFrontMatterRange(text);
assert.ok(range);
assert.strictEqual(text.slice(range!.start, range!.end), "---\n---");
});

test("should return undefined for empty text", () => {
assert.strictEqual(getYamlFrontMatterRange(""), undefined);
});

test("should handle CRLF line endings", () => {
const text = "---\r\ntitle: Test\r\n---\r\nbody\r\n";
const range = getYamlFrontMatterRange(text);
assert.ok(range);
assert.strictEqual(range!.start, 0);
assert.strictEqual(text.slice(range!.start, range!.end), "---\r\ntitle: Test\r\n---\r");
});

test("should cover multi-line block scalars within the front matter", () => {
const text = [
"---",
"format: typst",
"include-before-body:",
" - text: |",
" #show raw.where(block: false): it => {",
" let text = it.text();",
" it",
" }",
"---",
"body",
].join("\n");
const range = getYamlFrontMatterRange(text);
assert.ok(range);
const closingFenceOffset = text.lastIndexOf("---");
assert.ok(closingFenceOffset >= range!.start && closingFenceOffset < range!.end);
});
});

suite("hasUnquotedBacktick", () => {
test("should return false for empty string", () => {
assert.strictEqual(hasUnquotedBacktick(""), false);
Expand Down
Loading
Loading