diff --git a/README.md b/README.md index be427a85..223df6ad 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ export default defineConfig([ | [`no-missing-label-refs`](./docs/rules/no-missing-label-refs.md) | Disallow missing label references | yes | | [`no-multiple-h1`](./docs/rules/no-multiple-h1.md) | Disallow multiple H1 headings in the same document | yes | | [`require-alt-text`](./docs/rules/require-alt-text.md) | Require alternative text for images | yes | +| [`table-column-count`](./docs/rules/table-column-count.md) | Disallow data rows in a GitHub Flavored Markdown table from having more cells than the header row | yes | **Note:** This plugin does not provide formatting rules. We recommend using a source code formatter such as [Prettier](https://prettier.io) for that purpose. diff --git a/docs/rules/table-column-count.md b/docs/rules/table-column-count.md new file mode 100644 index 00000000..5bed7aa8 --- /dev/null +++ b/docs/rules/table-column-count.md @@ -0,0 +1,67 @@ +# table-column-count + +Disallow data rows in a GitHub Flavored Markdown table from having more cells than the header row. + +## Background + +In GitHub Flavored Markdown [tables](https://github.github.com/gfm/#tables-extension-), rows should maintain a consistent number of cells. While variations are sometimes tolerated, data rows having *more* cells than the header can lead to lost data or rendering issues. This rule prevents data rows from exceeding the header's column count. + +## Rule Details + +> [!IMPORTANT] +> +> This rule relies on the `table` AST node, typically available when using a GFM-compatible parser (e.g., `language: "markdown/gfm"`). + +This rule is triggered if a data row in a GFM table contains more cells than the header row. It does not flag rows with fewer cells than the header. + +Examples of **incorrect** code for this rule: + +```markdown + + +| Head1 | Head2 | +| ----- | ----- | +| R1C1 | R1C2 | R2C3 | + +| A | +| - | +| 1 | 2 | +``` + +Examples of **correct** code for this rule: + +```markdown + + + +| Header | Header | +| ------ | ------ | +| Cell | Cell | +| Cell | Cell | + + + +| Header | Header | Header | +| ------ | ------ | ------ | +| Cell | Cell | | + + + +| Col A | Col B | Col C | +| ----- | ----- | ----- | +| 1 | | 3 | +| 4 | 5 | + + +| Single Header | +| ------------- | +| Single Cell | +``` + +## When Not To Use It + +If you intentionally create Markdown tables where data rows are expected to contain more cells than the header, and you have a specific (perhaps non-standard) processing or rendering pipeline that handles this scenario correctly, you might choose to disable this rule. However, adhering to this rule is recommended for typical GFM rendering and data consistency. + +## Prior Art + +* [MD056 - table-column-count](https://github.com/DavidAnson/markdownlint/blob/main/doc/Rules.md#md056---table-column-count) diff --git a/src/rules/table-column-count.js b/src/rules/table-column-count.js new file mode 100644 index 00000000..e21e0cb5 --- /dev/null +++ b/src/rules/table-column-count.js @@ -0,0 +1,73 @@ +/** + * @fileoverview Rule to disallow data rows in a GitHub Flavored Markdown table from having more cells than the header row + * @author Sweta Tanwar (@SwetaTanwar) + */ + +//----------------------------------------------------------------------------- +// Type Definitions +//----------------------------------------------------------------------------- + +/** + * @typedef {import("../types.ts").MarkdownRuleDefinition<{ RuleOptions: []; }>} TableColumnCountRuleDefinition + */ + +//----------------------------------------------------------------------------- +// Rule Definition +//----------------------------------------------------------------------------- + +/** @type {TableColumnCountRuleDefinition} */ +export default { + meta: { + type: "problem", + + docs: { + recommended: true, + description: + "Disallow data rows in a GitHub Flavored Markdown table from having more cells than the header row", + url: "https://github.com/eslint/markdown/blob/main/docs/rules/table-column-count.md", + }, + + messages: { + inconsistentColumnCount: + "Table column count mismatch (Expected: {{expectedCells}}, Actual: {{actualCells}}), extra data starting here will be ignored.", + }, + }, + + create(context) { + return { + table(node) { + if (node.children.length < 1) { + return; + } + + const headerRow = node.children[0]; + const expectedCellsLength = headerRow.children.length; + + for (let i = 1; i < node.children.length; i++) { + const currentRow = node.children[i]; + const actualCellsLength = currentRow.children.length; + + if (actualCellsLength > expectedCellsLength) { + const firstExtraCellNode = + currentRow.children[expectedCellsLength]; + + const lastActualCellNode = + currentRow.children[actualCellsLength - 1]; + + context.report({ + loc: { + start: firstExtraCellNode.position.start, + end: lastActualCellNode.position.end, + }, + messageId: "inconsistentColumnCount", + data: { + actualCells: String(actualCellsLength), + expectedCells: String(expectedCellsLength), + }, + }); + } + } + }, + }; + }, +}; diff --git a/tests/rules/table-column-count.test.js b/tests/rules/table-column-count.test.js new file mode 100644 index 00000000..48702275 --- /dev/null +++ b/tests/rules/table-column-count.test.js @@ -0,0 +1,257 @@ +/** + * @fileoverview Tests for table-column-count rule. + * @author Sweta Tanwar (@SwetaTanwar) + */ + +//------------------------------------------------------------------------------ +// Imports +//------------------------------------------------------------------------------ + +import rule from "../../src/rules/table-column-count.js"; +import markdown from "../../src/index.js"; +import { RuleTester } from "eslint"; +import dedent from "dedent"; + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + plugins: { + markdown, + }, + language: "markdown/gfm", +}); + +ruleTester.run("table-column-count", rule, { + valid: [ + dedent` + | Header | Header | + | ------ | ------ | + | Cell | Cell | + | Cell | Cell | + `, + dedent` + | Header | Header | Header | + | ------ | ------ | ------ | + | Cell | Cell | + | Cell | | + `, + dedent` + | A | B | + |---|---| + | | | + | C | | + `, + `Just some text. | not a table |`, + dedent` + | Header | Header | + | ------ | ------ | ----- | + | Cell | Cell | + `, + dedent` + | Header | Header | + | ------ | ------ | + `, + dedent` + Some text before. + + | H1 | H2 | + |----|----| + | D1 | D2 | + + Some text after. + `, + dedent` + | Valid | Table | + | ----- | ----- | + | Row | Here | + `, + dedent` + | abc | defghi | + :-: | -----------: + bar | baz + `, + dedent` + | f|oo | + | ------ | + | b \`|\` az | + | b **|** im | + `, + dedent` + | abc | def | + | --- | --- | + | bar | baz | + > bar + `, + dedent` + | abc | def | + | --- | --- | + `, + ], + + invalid: [ + { + code: dedent` + | Head1 | Head2 | + | ----- | ----- | + | R1C1 | R1C2 | R2C3 | + `, + errors: [ + { + messageId: "inconsistentColumnCount", + data: { actualCells: "3", expectedCells: "2" }, + line: 3, + column: 17, + endLine: 3, + endColumn: 26, + }, + ], + }, + { + code: dedent` + | Head1 | Head2 | + | ----- | ----- | + | R1C1 | R1C2 | R2C3 | R3C4 | + `, + errors: [ + { + messageId: "inconsistentColumnCount", + data: { actualCells: "4", expectedCells: "2" }, + line: 3, + column: 17, + endLine: 3, + endColumn: 33, + }, + ], + }, + { + code: dedent` + | A | + | - | + | 1 | 2 | + `, + errors: [ + { + messageId: "inconsistentColumnCount", + data: { actualCells: "2", expectedCells: "1" }, + line: 3, + column: 5, + endLine: 3, + endColumn: 10, + }, + ], + }, + { + code: dedent` + Some introductory text. + + | Header1 | Header2 | + | ------- | ------- | + | Data1 | Data2 | Data3 | + | D4 | D5 | + + Some concluding text. + `, + errors: [ + { + messageId: "inconsistentColumnCount", + data: { actualCells: "3", expectedCells: "2" }, + line: 5, + column: 21, + endLine: 5, + endColumn: 30, + }, + ], + }, + { + code: dedent` + | abc | defghi | + :-: | -----------: + bar | baz + bar | baz | bad + `, + errors: [ + { + messageId: "inconsistentColumnCount", + data: { actualCells: "3", expectedCells: "2" }, + line: 4, + column: 11, + endLine: 4, + endColumn: 16, + }, + ], + }, + { + code: dedent` + | abc | def | + | --- | --- | + | bar | baz | Extra | + > This is a blockquote after + `, + errors: [ + { + messageId: "inconsistentColumnCount", + data: { actualCells: "3", expectedCells: "2" }, + line: 3, + column: 13, + endLine: 3, + endColumn: 22, + }, + ], + }, + { + code: dedent` + | abc | def | + | --- | --- | + | bar | baz | Extra1 | + | bar | baz | Extra2 | + `, + errors: [ + { + messageId: "inconsistentColumnCount", + data: { actualCells: "3", expectedCells: "2" }, + line: 3, + column: 13, + endLine: 3, + endColumn: 23, + }, + { + messageId: "inconsistentColumnCount", + data: { actualCells: "3", expectedCells: "2" }, + line: 4, + column: 13, + endLine: 4, + endColumn: 23, + }, + ], + }, + { + code: dedent` + | abc | def | + | --- | --- | + | bar | baz | Extra1 | + | bar | baz | + | bar | baz | Extra2 | + `, + errors: [ + { + messageId: "inconsistentColumnCount", + data: { actualCells: "3", expectedCells: "2" }, + line: 3, + column: 13, + endLine: 3, + endColumn: 23, + }, + { + messageId: "inconsistentColumnCount", + data: { actualCells: "3", expectedCells: "2" }, + line: 5, + column: 13, + endLine: 5, + endColumn: 23, + }, + ], + }, + ], +});