Skip to content

Commit c7a4d64

Browse files
authored
fix: false negatives and positives in no-reversed-media-syntax (#473)
* fix: `Heading`/`TableCell` false negatives in no-reversed-media-syntax * wip: TDD * wip: code refactor * wip * wip: add more test cases * wip: add wrong case * wip: handle HTML nodes * wip: cleanup * wip * wip
1 parent a9675aa commit c7a4d64

File tree

3 files changed

+206
-82
lines changed

3 files changed

+206
-82
lines changed

docs/rules/no-reversed-media-syntax.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ Examples of **incorrect** code for this rule:
1818
(ESLint)[https://eslint.org/]
1919

2020
!(A beautiful sunset)[sunset.png]
21+
22+
# (ESLint)[https://eslint.org/]
23+
24+
# !(A beautiful sunset)[sunset.png]
25+
26+
| ESLint | Sunset |
27+
| ----------------------------- | --------------------------------- |
28+
| (ESLint)[https://eslint.org/] | !(A beautiful sunset)[sunset.png] |
2129
```
2230

2331
Examples of **correct** code for this rule:
@@ -28,6 +36,14 @@ Examples of **correct** code for this rule:
2836
[ESLint](https://eslint.org/)
2937

3038
![A beautiful sunset](sunset.png)
39+
40+
# [ESLint](https://eslint.org/)
41+
42+
# ![A beautiful sunset](sunset.png)
43+
44+
| ESLint | Sunset |
45+
| ----------------------------- | --------------------------------- |
46+
| [ESLint](https://eslint.org/) | ![A beautiful sunset](sunset.png) |
3147
```
3248

3349
## When Not To Use It

src/rules/no-reversed-media-syntax.js

Lines changed: 99 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { findOffsets } from "../util.js";
1414
//-----------------------------------------------------------------------------
1515

1616
/**
17-
* @import { Node, Paragraph } from "mdast";
17+
* @import { Node, Heading, Paragraph, TableCell } from "mdast";
1818
* @import { MarkdownRuleDefinition } from "../types.js";
1919
* @typedef {"reversedSyntax"} NoReversedMediaSyntaxMessageIds
2020
* @typedef {[]} NoReversedMediaSyntaxOptions
@@ -25,39 +25,40 @@ import { findOffsets } from "../util.js";
2525
// Helpers
2626
//-----------------------------------------------------------------------------
2727

28-
/** Matches reversed link/image syntax like (text)[url], ignoring escaped characters like \(text\)[url]. */
28+
/** Matches reversed link/image syntax like `(text)[url]`, ignoring escaped characters like `\(text\)[url]`. */
2929
const reversedPattern =
3030
/(?<!\\)\(((?:\\.|[^()\\]|\([\s\S]*\))*)\)\[((?:\\.|[^\]\\\n])*)\](?!\()/gu;
3131

3232
/**
33-
* Checks if a match is within any of the code spans
33+
* Checks if a match is within any skip range
3434
* @param {number} matchIndex The index of the match
35-
* @param {Array<{startOffset: number, endOffset: number}>} codeSpans Array of code span positions
36-
* @returns {boolean} True if the match is within a code span
35+
* @param {Array<{startOffset: number, endOffset: number}>} skipRanges The skip ranges
36+
* @returns {boolean} True if the match is within a skip range
3737
*/
38-
function isInCodeSpan(matchIndex, codeSpans) {
39-
return codeSpans.some(
40-
span => matchIndex >= span.startOffset && matchIndex < span.endOffset,
38+
function isInSkipRange(matchIndex, skipRanges) {
39+
return skipRanges.some(
40+
range =>
41+
range.startOffset <= matchIndex && matchIndex < range.endOffset,
4142
);
4243
}
4344

4445
/**
45-
* Finds all code spans in the paragraph node by traversing its children
46-
* @param {Paragraph} node The paragraph node to search
47-
* @returns {Array<{startOffset: number, endOffset: number}>} Array of code span positions
46+
* Finds ranges of inline code and HTML nodes within a given node
47+
* @param {Heading | Paragraph | TableCell} node The node to search
48+
* @returns {Array<{startOffset: number, endOffset: number}>} Array of objects containing start and end offsets
4849
*/
49-
function findCodeSpans(node) {
50+
function findSkipRanges(node) {
5051
/** @type {Array<{startOffset: number, endOffset: number}>} */
51-
const codeSpans = [];
52+
const skipRanges = [];
5253

5354
/**
54-
* Recursively traverses the AST to find inline code nodes
55+
* Recursively traverses the AST to find inline code and HTML nodes
5556
* @param {Node} currentNode The current node being traversed
5657
* @returns {void}
5758
*/
5859
function traverse(currentNode) {
59-
if (currentNode.type === "inlineCode") {
60-
codeSpans.push({
60+
if (currentNode.type === "inlineCode" || currentNode.type === "html") {
61+
skipRanges.push({
6162
startOffset: currentNode.position.start.offset,
6263
endOffset: currentNode.position.end.offset,
6364
});
@@ -70,7 +71,7 @@ function findCodeSpans(node) {
7071
}
7172

7273
traverse(node);
73-
return codeSpans;
74+
return skipRanges;
7475
}
7576

7677
//-----------------------------------------------------------------------------
@@ -97,73 +98,89 @@ export default {
9798
},
9899

99100
create(context) {
100-
return {
101-
paragraph(node) {
102-
const text = context.sourceCode.getText(node);
103-
const codeSpans = findCodeSpans(node);
104-
let match;
105-
106-
while ((match = reversedPattern.exec(text)) !== null) {
107-
const [reversedSyntax, label, url] = match;
108-
const matchIndex = match.index;
109-
const matchLength = reversedSyntax.length;
110-
111-
if (
112-
isInCodeSpan(
113-
matchIndex + node.position.start.offset,
114-
codeSpans,
115-
)
116-
) {
117-
continue;
118-
}
119-
120-
const {
121-
lineOffset: startLineOffset,
122-
columnOffset: startColumnOffset,
123-
} = findOffsets(text, matchIndex);
124-
const {
125-
lineOffset: endLineOffset,
126-
columnOffset: endColumnOffset,
127-
} = findOffsets(text, matchIndex + matchLength);
128-
129-
const baseColumn = 1;
130-
const nodeStartLine = node.position.start.line;
131-
const nodeStartColumn = node.position.start.column;
132-
const startLine = nodeStartLine + startLineOffset;
133-
const endLine = nodeStartLine + endLineOffset;
134-
const startColumn =
135-
(startLine === nodeStartLine
136-
? nodeStartColumn
137-
: baseColumn) + startColumnOffset;
138-
const endColumn =
139-
(endLine === nodeStartLine
140-
? nodeStartColumn
141-
: baseColumn) + endColumnOffset;
142-
143-
context.report({
144-
loc: {
145-
start: {
146-
line: startLine,
147-
column: startColumn,
148-
},
149-
end: {
150-
line: endLine,
151-
column: endColumn,
152-
},
101+
/**
102+
* Finds reversed link/image syntax in a node.
103+
* @param {Heading | Paragraph | TableCell} node The node to check.
104+
* @returns {void} Reports any reversed syntax found.
105+
*/
106+
function findReversedMediaSyntax(node) {
107+
const text = context.sourceCode.getText(node);
108+
const skipRanges = findSkipRanges(node);
109+
let match;
110+
111+
while ((match = reversedPattern.exec(text)) !== null) {
112+
const [reversedSyntax, label, url] = match;
113+
const matchIndex = match.index;
114+
const matchLength = reversedSyntax.length;
115+
116+
if (
117+
isInSkipRange(
118+
matchIndex + node.position.start.offset,
119+
skipRanges,
120+
)
121+
) {
122+
continue;
123+
}
124+
125+
const {
126+
lineOffset: startLineOffset,
127+
columnOffset: startColumnOffset,
128+
} = findOffsets(text, matchIndex);
129+
const {
130+
lineOffset: endLineOffset,
131+
columnOffset: endColumnOffset,
132+
} = findOffsets(text, matchIndex + matchLength);
133+
134+
const baseColumn = 1;
135+
const nodeStartLine = node.position.start.line;
136+
const nodeStartColumn = node.position.start.column;
137+
const startLine = nodeStartLine + startLineOffset;
138+
const endLine = nodeStartLine + endLineOffset;
139+
const startColumn =
140+
(startLine === nodeStartLine
141+
? nodeStartColumn
142+
: baseColumn) + startColumnOffset;
143+
const endColumn =
144+
(endLine === nodeStartLine ? nodeStartColumn : baseColumn) +
145+
endColumnOffset;
146+
147+
context.report({
148+
loc: {
149+
start: {
150+
line: startLine,
151+
column: startColumn,
153152
},
154-
messageId: "reversedSyntax",
155-
fix(fixer) {
156-
const startOffset =
157-
node.position.start.offset + matchIndex;
158-
const endOffset = startOffset + matchLength;
159-
160-
return fixer.replaceTextRange(
161-
[startOffset, endOffset],
162-
`[${label}](${url})`,
163-
);
153+
end: {
154+
line: endLine,
155+
column: endColumn,
164156
},
165-
});
166-
}
157+
},
158+
messageId: "reversedSyntax",
159+
fix(fixer) {
160+
const startOffset =
161+
node.position.start.offset + matchIndex;
162+
const endOffset = startOffset + matchLength;
163+
164+
return fixer.replaceTextRange(
165+
[startOffset, endOffset],
166+
`[${label}](${url})`,
167+
);
168+
},
169+
});
170+
}
171+
}
172+
173+
return {
174+
heading(node) {
175+
findReversedMediaSyntax(node);
176+
},
177+
178+
paragraph(node) {
179+
findReversedMediaSyntax(node);
180+
},
181+
182+
tableCell(node) {
183+
findReversedMediaSyntax(node);
167184
},
168185
};
169186
},

tests/rules/no-reversed-media-syntax.test.js

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,19 @@ ruleTester.run("no-reversed-media-syntax", rule, {
5555
"text [foo](bar)[foo](bar) text",
5656
"text [foo](bar)[foo](bar)[foo](bar) text",
5757
"text (text `func()[index]`) text",
58+
'hi <span class="foo(bar)[baz]">hi</span>',
59+
// Heading
60+
"# [ESLint](https://eslint.org/)",
61+
"# ![A beautiful sunset](sunset.png)",
62+
// TableCell
63+
{
64+
code: dedent`
65+
| ESLint | Sunset |
66+
| ----------------------------- | --------------------------------- |
67+
| [ESLint](https://eslint.org/) | ![A beautiful sunset](sunset.png) |
68+
`,
69+
language: "markdown/gfm",
70+
},
5871
],
5972
invalid: [
6073
{
@@ -419,5 +432,83 @@ ruleTester.run("no-reversed-media-syntax", rule, {
419432
},
420433
],
421434
},
435+
// Heading
436+
{
437+
code: "# (ESLint)[https://eslint.org/]",
438+
output: "# [ESLint](https://eslint.org/)",
439+
errors: [
440+
{
441+
messageId: "reversedSyntax",
442+
line: 1,
443+
column: 3,
444+
endLine: 1,
445+
endColumn: 32,
446+
},
447+
],
448+
},
449+
{
450+
code: "# !(A beautiful sunset)[sunset.png]",
451+
output: "# ![A beautiful sunset](sunset.png)",
452+
errors: [
453+
{
454+
messageId: "reversedSyntax",
455+
line: 1,
456+
column: 4,
457+
endLine: 1,
458+
endColumn: 36,
459+
},
460+
],
461+
},
462+
{
463+
code: dedent`
464+
Setext Heading
465+
(ESLint)[https://eslint.org/]
466+
===
467+
`,
468+
output: dedent`
469+
Setext Heading
470+
[ESLint](https://eslint.org/)
471+
===
472+
`,
473+
errors: [
474+
{
475+
messageId: "reversedSyntax",
476+
line: 2,
477+
column: 3,
478+
endLine: 2,
479+
endColumn: 32,
480+
},
481+
],
482+
},
483+
// TableCell
484+
{
485+
code: dedent`
486+
| ESLint | Sunset |
487+
| ----------------------------- | --------------------------------- |
488+
| (ESLint)[https://eslint.org/] | !(A beautiful sunset)[sunset.png] |
489+
`,
490+
output: dedent`
491+
| ESLint | Sunset |
492+
| ----------------------------- | --------------------------------- |
493+
| [ESLint](https://eslint.org/) | ![A beautiful sunset](sunset.png) |
494+
`,
495+
language: "markdown/gfm",
496+
errors: [
497+
{
498+
messageId: "reversedSyntax",
499+
line: 3,
500+
column: 3,
501+
endLine: 3,
502+
endColumn: 32,
503+
},
504+
{
505+
messageId: "reversedSyntax",
506+
line: 3,
507+
column: 36,
508+
endLine: 3,
509+
endColumn: 68,
510+
},
511+
],
512+
},
422513
],
423514
});

0 commit comments

Comments
 (0)