Skip to content
Draft
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
},
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.3",
"@eslint-markdown/parser": "^0.0.0-canary.3",
"@eslint/js": "^10.0.1",
"@eslint/json": "^2.0.1",
"@types/mdast": "^4.0.4",
Expand Down
57 changes: 44 additions & 13 deletions src/language/markdown-language.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,9 @@ import { math } from "micromark-extension-math";
* @import { Language, File, ParseResult, OkParseResult } from "@eslint/core";
* @import { Root } from "mdast";
* @import { Options } from "mdast-util-from-markdown";
* @import { MarkdownLanguageOptions, MarkdownLanguageContext } from "../types.js";
* @import { MarkdownLanguageOptions, MarkdownLanguageContext, MarkdownParserMode } from "../types.js";
* @typedef {Options['extensions']} Extensions
* @typedef {Options['mdastExtensions']} MdastExtensions
* @typedef {"commonmark"|"gfm"} ParserMode
*/

//-----------------------------------------------------------------------------
Expand All @@ -55,7 +54,7 @@ const jsonFrontmatterConfig = {

/**
* Create parser options based on `mode` and `languageOptions`.
* @param {ParserMode} mode The markdown parser mode.
* @param {MarkdownParserMode} mode The markdown parser mode.
* @param {MarkdownLanguageOptions} languageOptions Language options.
* @returns {{extensions: Extensions, mdastExtensions: MdastExtensions}} Parser options for micromark and mdast.
*/
Expand Down Expand Up @@ -145,18 +144,29 @@ export class MarkdownLanguage {
defaultLanguageOptions = {
frontmatter: false,
math: false,
parser: {
meta: {
name: "mdast-util-from-markdown",
},
parse(text, { mode, ...languageOptions }) {
return fromMarkdown(
text,
createParserOptions(mode, languageOptions),
);
},
},
};

/**
* The Markdown parser mode.
* @type {ParserMode}
* @type {MarkdownParserMode}
*/
#mode = "commonmark";

/**
* Creates a new instance.
* @param {Object} options The options to use for this instance.
* @param {ParserMode} [options.mode] The Markdown parser mode to use.
* @param {Object} [options] The options to use for this instance.
* @param {MarkdownParserMode} [options.mode] The Markdown parser mode to use.
*/
constructor({ mode } = {}) {
if (mode) {
Expand All @@ -166,7 +176,7 @@ export class MarkdownLanguage {

/**
* Validates the language options.
* @param {MarkdownLanguageOptions} languageOptions The language options to validate.
* @param {MarkdownLanguageOptions} [languageOptions] The language options to validate.
* @returns {void}
* @throws {Error} When the language options are invalid.
*/
Expand Down Expand Up @@ -197,30 +207,51 @@ export class MarkdownLanguage {
`Invalid language option value \`${mathOption}\` for math. Expected a boolean.`,
);
}

// `parser` option validation
const parserOption = languageOptions?.parser;

if (parserOption !== undefined) {
if (typeof parserOption !== "object" || parserOption === null) {
throw new Error(
`Invalid language option value \`${String(parserOption)}\` for parser. Expected a non-null object.`,
);
}

if (typeof parserOption.parse !== "function") {
throw new Error(
"Invalid language option `parser`. Expected an object with a `parse` method.",
);
}
}
}

/**
* Parses the given file into an AST.
* @param {File} file The virtual file to parse.
* @param {MarkdownLanguageContext} context The options to use for parsing.
* @param {MarkdownLanguageContext} [context] The options to use for parsing.
* @returns {ParseResult<Root>} The result of parsing.
*/
parse(file, context) {
// Note: BOM already removed
const text = /** @type {string} */ (file.body);

const {
parser = this.defaultLanguageOptions.parser,
...restLanguageOptions
} = context?.languageOptions ?? {};

/*
* Check for parsing errors first. If there's a parsing error, nothing
* else can happen. However, a parsing error does not throw an error
* from this method - it's just considered a fatal error message, a
* problem that ESLint identified just like any other.
*/
try {
const options = createParserOptions(
this.#mode,
context?.languageOptions,
);
const root = fromMarkdown(text, options);
const root = parser.parse(text, {
...restLanguageOptions,
mode: this.#mode,
});

return {
ok: true,
Expand Down
52 changes: 52 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import type { InlineMath, Math } from "mdast-util-math";
import type {
LanguageContext,
LanguageOptions,
ObjectMetaProperties,
RuleVisitor,
} from "@eslint/core";
import type {
Expand Down Expand Up @@ -133,6 +134,51 @@ declare module "mdast" {
// Exports: Language and Source Code
//------------------------------------------------------------------------------

/**
* @deprecated Use `MarkdownParserMode` instead.
*/
export type ParserMode = MarkdownParserMode;

/**
* The mode of the Markdown parser to use.
* @default "commonmark"
*/
export type MarkdownParserMode = "commonmark" | "gfm";

/**
* A parser that converts Markdown source text into an
* [mdast](https://github.com/syntax-tree/mdast#readme) syntax tree.
*/
export type MarkdownParser = ObjectMetaProperties & {
/**
* Parses Markdown source text into an
* [mdast](https://github.com/syntax-tree/mdast#readme) syntax tree.
* @param text The Markdown source text to parse.
* @param options The parser-specific options.
* @returns The root of the mdast syntax tree.
*/
parse(
text: string,
options: MarkdownLanguageOptions & {
/**
* The mode of the Markdown parser to use.
* @default "commonmark"
*/
mode: MarkdownParserMode;

/*
* Don't use `Omit<MarkdownLanguageOptions, "parser">` here.
* Because `MarkdownLanguageOptions` has a string index signature,
* `Omit` would lose its named option properties.
*/
/**
* Parsers cannot override the `parser` used by the language.
*/
parser?: never;
},
): Root;
};

/**
* Language options provided for Markdown files.
*/
Expand All @@ -148,6 +194,12 @@ export interface MarkdownLanguageOptions extends LanguageOptions {
* @default false
*/
math?: boolean;

/**
* An object with a `parse()` method and optional metadata properties.
* If not configured, the default ESLint Markdown parser (`mdast-util-from-markdown`) will be used.
*/
parser?: MarkdownParser;
}

/**
Expand Down
21 changes: 21 additions & 0 deletions tests/fixtures/parser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import markdown from "../../src/index.js";

export default [
{
files: ["**/*.md"],
plugins: { markdown },
language: "markdown/commonmark",
languageOptions: {
parser: {
parse() {
throw new Error(
"The configured Markdown parser was called.",
);
},
},
},
rules: {
"markdown/heading-increment": "error",
},
},
];
36 changes: 36 additions & 0 deletions tests/language/markdown-language.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,22 @@ describe("MarkdownLanguage", () => {
language.validateLanguageOptions({ math: false });
});
});

it("should throw the expected error when `parser` is a symbol", () => {
const language = new MarkdownLanguage();

assert.throws(
() => {
language.validateLanguageOptions({
parser: Symbol("parser"),
});
},
{
message:
"Invalid language option value `Symbol(parser)` for parser. Expected a non-null object.",
},
);
});
});

describe("parse()", () => {
Expand Down Expand Up @@ -402,6 +418,26 @@ describe("MarkdownLanguage", () => {
assert.strictEqual(result.ast.children[1].children[0].type, "text");
assert.strictEqual(result.ast.children[2].type, "math");
});

it("should not allow language options to override commonmark mode", () => {
const language = new MarkdownLanguage({
mode: "commonmark",
});
const result = language.parse(
{
body: "| Column 1 | Column 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |",
path: "test.md",
},
{
languageOptions: {
mode: "gfm",
},
},
);

assert.strictEqual(result.ok, true);
assert.strictEqual(result.ast.children[0].type, "paragraph");
});
});

describe("createSourceCode()", () => {
Expand Down
69 changes: 69 additions & 0 deletions tests/plugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
//-----------------------------------------------------------------------------

import assert from "node:assert";
import { spawnSync } from "node:child_process";
import path from "node:path";
import fs from "node:fs";
import { fileURLToPath, pathToFileURL } from "node:url";
Expand Down Expand Up @@ -1228,6 +1229,25 @@ describe("FlatESLint", () => {
.map(([name]) => `markdown/${name}`);
assert.deepStrictEqual(actualRuleIds, expectedRuleIds);
});

it("should serialize the default Markdown parser", async () => {
const markdownESLint = new ESLint({
overrideConfigFile: true,
overrideConfig: {
files: ["**/*.md"],
plugins: { markdown: plugin },
language: "markdown/commonmark",
},
});
const config =
await markdownESLint.calculateConfigForFile("test.md");
const serializedConfig = JSON.parse(JSON.stringify(config));

assert.strictEqual(
serializedConfig.languageOptions.parser,
"mdast-util-from-markdown",
);
});
});

describe("plugin", () => {
Expand Down Expand Up @@ -2440,4 +2460,53 @@ describe("FlatESLint", () => {
}
});
});

describe("CLI", () => {
it("should override the Markdown parser with `--parser @eslint-markdown/parser`", () => {
const eslintPath = fileURLToPath(
new URL(
"bin/eslint.js",
import.meta.resolve("eslint/package.json"),
),
);
const args = [
eslintPath,
"--config=parser.js",
"--stdin",
"--stdin-filename=test.md",
"--format=json",
];
const options = {
cwd: path.resolve(__dirname, "fixtures"),
input: "# Hello\n\n### Skipped level\n",
encoding: "utf8",
};

const withoutParser = spawnSync(process.execPath, args, options);

assert.strictEqual(withoutParser.status, 1);
const [configuredResult] = JSON.parse(withoutParser.stdout);

assert.strictEqual(configuredResult.messages.length, 1);
assert.strictEqual(
configuredResult.messages[0].message,
"Parsing error: The configured Markdown parser was called.",
);

const withParser = spawnSync(
process.execPath,
[...args, "--parser", "@eslint-markdown/parser"],
options,
);

assert.strictEqual(withParser.status, 1);
const [overriddenResult] = JSON.parse(withParser.stdout);

assert.strictEqual(overriddenResult.messages.length, 1);
assert.strictEqual(
overriddenResult.messages[0].ruleId,
"markdown/heading-increment",
);
});
});
});
Loading