Repository: eslint/markdown
Pull request #324: fix: provide a language-specific definition for `RuleModule`
Author: login: fasttime
Labels: name: bug
name: accepted
Created: 2025-03-14T11:42:05Z | Merged: 2025-03-26T14:36:18Z

The `RuleModule` type exported by this package is currently the same as ESLint's `Rule.RuleModule`, meaning that it contains definitions that are specific for the JavaScript language, and so it isn't useful for typing Markdown-specific rules.

This pull request updates the exported `RuleModule` type, making it specific for the Markdown language.

fixes: #323 


Diff:
diff --git a/package.json b/package.json
index 5a55250a0..f4c899c2a 100644
--- a/package.json
+++ b/package.json
@@ -66,7 +66,7 @@
     "c8": "^10.1.2",
     "chai": "^5.1.1",
     "dedent": "^1.5.3",
-    "eslint": "^9.15.0",
+    "eslint": "^9.23.0",
     "eslint-config-eslint": "^11.0.0",
     "eslint-plugin-eslint-plugin": "^6.3.2",
     "globals": "^15.1.0",
diff --git a/src/index.js b/src/index.js
index 993bd8e5d..709082766 100644
--- a/src/index.js
+++ b/src/index.js
@@ -19,7 +19,8 @@ import rules from "./build/rules.js";
 /** @typedef {import("eslint").Linter.RulesRecord} RulesRecord*/
 /** @typedef {import("eslint").Linter.Config} Config*/
 /** @typedef {import("eslint").ESLint.Plugin} Plugin */
-/** @typedef {import("eslint").Rule.RuleModule} RuleModule */
+/** @typedef {import("./types.ts").MarkdownRuleDefinition} RuleModule */
+/** @typedef {import("./types.ts").MarkdownRuleVisitor} MarkdownRuleVisitor */
 /** @typedef {import("@eslint/core").Language} Language */
 
 //-----------------------------------------------------------------------------
diff --git a/src/language/markdown-source-code.js b/src/language/markdown-source-code.js
index 87322d70d..30b99ffd9 100644
--- a/src/language/markdown-source-code.js
+++ b/src/language/markdown-source-code.js
@@ -32,6 +32,7 @@ import { findOffsets } from "../util.js";
 /** @typedef {import("@eslint/core").SourceRange} SourceRange */
 /** @typedef {import("@eslint/core").FileProblem} FileProblem */
 /** @typedef {import("@eslint/core").DirectiveType} DirectiveType */
+/** @typedef {import("../types.ts").IMarkdownSourceCode} IMarkdownSourceCode */
 
 //-----------------------------------------------------------------------------
 // Helpers
@@ -135,6 +136,7 @@ function extractInlineConfigCommentsFromHTML(node) {
 
 /**
  * Markdown Source Code Object
+ * @implements {IMarkdownSourceCode}
  */
 export class MarkdownSourceCode extends TextSourceCodeBase {
 	/**
diff --git a/src/rules/fenced-code-language.js b/src/rules/fenced-code-language.js
index 367768c16..d69da2547 100644
--- a/src/rules/fenced-code-language.js
+++ b/src/rules/fenced-code-language.js
@@ -7,13 +7,16 @@
 // Type Definitions
 //-----------------------------------------------------------------------------
 
-/** @typedef {import("eslint").Rule.RuleModule} RuleModule */
+/**
+ * @typedef {import("../types.ts").MarkdownRuleDefinition<{ RuleOptions: [{ required?: string[]; }]; }>}
+ * FencedCodeLanguageRuleDefinition
+ */
 
 //-----------------------------------------------------------------------------
 // Rule Definition
 //-----------------------------------------------------------------------------
 
-/** @type {RuleModule} */
+/** @type {FencedCodeLanguageRuleDefinition} */
 export default {
 	meta: {
 		type: "problem",
diff --git a/src/rules/heading-increment.js b/src/rules/heading-increment.js
index 312e77c35..f4a18dffe 100644
--- a/src/rules/heading-increment.js
+++ b/src/rules/heading-increment.js
@@ -7,13 +7,16 @@
 // Type Definitions
 //-----------------------------------------------------------------------------
 
-/** @typedef {import("eslint").Rule.RuleModule} RuleModule */
+/**
+ * @typedef {import("../types.ts").MarkdownRuleDefinition<{ RuleOptions: []; }>}
+ * HeadingIncrementRuleDefinition
+ */
 
 //-----------------------------------------------------------------------------
 // Rule Definition
 //-----------------------------------------------------------------------------
 
-/** @type {RuleModule} */
+/** @type {HeadingIncrementRuleDefinition} */
 export default {
 	meta: {
 		type: "problem",
@@ -40,7 +43,7 @@ export default {
 						messageId: "skippedHeading",
 						data: {
 							fromLevel: lastHeadingDepth.toString(),
-							toLevel: node.depth,
+							toLevel: node.depth.toString(),
 						},
 					});
 				}
diff --git a/src/rules/no-duplicate-headings.js b/src/rules/no-duplicate-headings.js
index 13ed3d71d..1d9dcf5de 100644
--- a/src/rules/no-duplicate-headings.js
+++ b/src/rules/no-duplicate-headings.js
@@ -7,13 +7,16 @@
 // Type Definitions
 //-----------------------------------------------------------------------------
 
-/** @typedef {import("eslint").Rule.RuleModule} RuleModule */
+/**
+ * @typedef {import("../types.ts").MarkdownRuleDefinition<{ RuleOptions: []; }>}
+ * NoDuplicateHeadingsRuleDefinition
+ */
 
 //-----------------------------------------------------------------------------
 // Rule Definition
 //-----------------------------------------------------------------------------
 
-/** @type {RuleModule} */
+/** @type {NoDuplicateHeadingsRuleDefinition} */
 export default {
 	meta: {
 		type: "problem",
diff --git a/src/rules/no-empty-links.js b/src/rules/no-empty-links.js
index ca39f0761..df50b4748 100644
--- a/src/rules/no-empty-links.js
+++ b/src/rules/no-empty-links.js
@@ -6,13 +6,16 @@
 // Type Definitions
 //-----------------------------------------------------------------------------
 
-/** @typedef {import("eslint").Rule.RuleModule} RuleModule */
+/**
+ * @typedef {import("../types.ts").MarkdownRuleDefinition<{ RuleOptions: []; }>}
+ * NoEmptyLinksRuleDefinition
+ */
 
 //-----------------------------------------------------------------------------
 // Rule Definition
 //-----------------------------------------------------------------------------
 
-/** @type {RuleModule} */
+/** @type {NoEmptyLinksRuleDefinition} */
 export default {
 	meta: {
 		type: "problem",
diff --git a/src/rules/no-html.js b/src/rules/no-html.js
index 4237d125e..6d3b42790 100644
--- a/src/rules/no-html.js
+++ b/src/rules/no-html.js
@@ -13,7 +13,10 @@ import { findOffsets } from "../util.js";
 // Type Definitions
 //-----------------------------------------------------------------------------
 
-/** @typedef {import("eslint").Rule.RuleModule} RuleModule */
+/**
+ * @typedef {import("../types.ts").MarkdownRuleDefinition<{ RuleOptions: [{ allowed?: string[]; }]; }>}
+ * NoHtmlRuleDefinition
+ */
 
 //-----------------------------------------------------------------------------
 // Helpers
@@ -25,7 +28,7 @@ const htmlTagPattern = /<([a-z0-9]+(?:-[a-z0-9]+)*)/giu;
 // Rule Definition
 //-----------------------------------------------------------------------------
 
-/** @type {RuleModule} */
+/** @type {NoHtmlRuleDefinition} */
 export default {
 	meta: {
 		type: "problem",
diff --git a/src/rules/no-invalid-label-refs.js b/src/rules/no-invalid-label-refs.js
index 2e44fb928..e1e1fbecc 100644
--- a/src/rules/no-invalid-label-refs.js
+++ b/src/rules/no-invalid-label-refs.js
@@ -15,7 +15,10 @@ import { findOffsets, illegalShorthandTailPattern } from "../util.js";
 
 /** @typedef {import("unist").Position} Position */
 /** @typedef {import("mdast").Text} TextNode */
-/** @typedef {import("eslint").Rule.RuleModule} RuleModule */
+/**
+ * @typedef {import("../types.ts").MarkdownRuleDefinition<{ RuleOptions: []; }>}
+ * NoInvalidLabelRuleDefinition
+ */
 
 //-----------------------------------------------------------------------------
 // Helpers
@@ -120,7 +123,7 @@ function findInvalidLabelReferences(node, docText) {
 // Rule Definition
 //-----------------------------------------------------------------------------
 
-/** @type {RuleModule} */
+/** @type {NoInvalidLabelRuleDefinition} */
 export default {
 	meta: {
 		type: "problem",
diff --git a/src/rules/no-missing-label-refs.js b/src/rules/no-missing-label-refs.js
index 5d365bb12..81407f175 100644
--- a/src/rules/no-missing-label-refs.js
+++ b/src/rules/no-missing-label-refs.js
@@ -15,7 +15,10 @@ import { findOffsets, illegalShorthandTailPattern } from "../util.js";
 
 /** @typedef {import("unist").Position} Position */
 /** @typedef {import("mdast").Text} TextNode */
-/** @typedef {import("eslint").Rule.RuleModule} RuleModule */
+/**
+ * @typedef {import("../types.ts").MarkdownRuleDefinition<{ RuleOptions: []; }>}
+ * NoMissingLabelRuleDefinition
+ */
 
 //-----------------------------------------------------------------------------
 // Helpers
@@ -101,7 +104,7 @@ function findMissingReferences(node, nodeText) {
 // Rule Definition
 //-----------------------------------------------------------------------------
 
-/** @type {RuleModule} */
+/** @type {NoMissingLabelRuleDefinition} */
 export default {
 	meta: {
 		type: "problem",
diff --git a/src/types.ts b/src/types.ts
index 02742ab3f..2ad36cc1c 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,5 +1,39 @@
-import type { Node } from "mdast";
+//------------------------------------------------------------------------------
+// Imports
+//------------------------------------------------------------------------------
+
+import type {
+	Code,
+	Heading,
+	Html,
+	Link,
+	Node,
+	Parent,
+	Root,
+	Text,
+} from "mdast";
 import type { Linter } from "eslint";
+import type {
+	RuleDefinition,
+	RuleVisitor,
+	SourceLocation,
+	TextSourceCode,
+} from "@eslint/core";
+
+//------------------------------------------------------------------------------
+// Helpers
+//------------------------------------------------------------------------------
+
+/** Adds matching `:exit` selectors for all properties of a `RuleVisitor`. */
+type WithExit<RuleVisitorType extends RuleVisitor> = {
+	[Key in keyof RuleVisitorType as
+		| Key
+		| `${Key & string}:exit`]: RuleVisitorType[Key];
+};
+
+//------------------------------------------------------------------------------
+// Exports
+//------------------------------------------------------------------------------
 
 export interface RangeMap {
 	indent: number;
@@ -20,3 +54,63 @@ export interface Block extends Node, BlockBase {
 export type Message = Linter.LintMessage;
 
 export type RuleType = "problem" | "suggestion" | "layout";
+
+/**
+ * The `SourceCode` interface for Markdown files.
+ */
+export interface IMarkdownSourceCode
+	extends TextSourceCode<{
+		LangOptions: {};
+		RootNode: Root;
+		SyntaxElementWithLoc: Node;
+		ConfigNode: { value: string; position: SourceLocation };
+	}> {
+	/**
+	 * Gets the entire source text split into an array of lines.
+	 * @returns The source text as an array of lines.
+	 */
+	get lines(): Array<string>;
+
+	/**
+	 * Gets the source code for the given node.
+	 * @param node The AST node to get the text for.
+	 * @param beforeCount The number of characters before the node to retrieve.
+	 * @param afterCount The number of characters after the node to retrieve.
+	 * @returns The text representing the AST node.
+	 */
+	getText(node?: Node, beforeCount?: number, afterCount?: number): string;
+}
+
+export interface MarkdownRuleVisitor
+	extends RuleVisitor,
+		WithExit<{
+			root?(node: Root): void;
+			code?(node: Code, parent?: Parent): void;
+			heading?(node: Heading, parent?: Parent): void;
+			html?(node: Html, parent?: Parent): void;
+			link?(node: Link, parent?: Parent): void;
+			text?(node: Text, parent?: Parent): void;
+		}> {}
+
+export type MarkdownRuleDefinitionTypeOptions = {
+	RuleOptions: unknown[];
+	MessageIds: string;
+	ExtRuleDocs: Record<string, unknown>;
+};
+
+export type MarkdownRuleDefinition<
+	Options extends Partial<MarkdownRuleDefinitionTypeOptions> = {},
+> = RuleDefinition<
+	// Language specific type options (non-configurable)
+	{
+		LangOptions: {};
+		Code: IMarkdownSourceCode;
+		Visitor: MarkdownRuleVisitor;
+		Node: Node;
+	} & Required<
+		// Rule specific type options (custom)
+		Options &
+			// Rule specific type options (defaults)
+			Omit<MarkdownRuleDefinitionTypeOptions, keyof Options>
+	>
+>;
diff --git a/tests/types/types.test.ts b/tests/types/types.test.ts
index 39db95298..da4f719c3 100644
--- a/tests/types/types.test.ts
+++ b/tests/types/types.test.ts
@@ -1,4 +1,13 @@
-import markdown from "@eslint/markdown";
+import markdown, {
+	IMarkdownSourceCode,
+	MarkdownNode,
+	MarkdownRuleVisitor,
+	ParentNode,
+	RootNode,
+	SourceLocation,
+	TextNode,
+	type RuleModule,
+} from "@eslint/markdown";
 import { ESLint, Linter } from "eslint";
 
 markdown satisfies ESLint.Plugin;
@@ -35,3 +44,41 @@ typeof processorPlugins satisfies {};
 	// Check that all recommended rule names match the names of existing rules in this plugin.
 	null as AssertAllNamesIn<RecommendedRuleName, RuleName>;
 }
+
+(): RuleModule => ({
+	create({ sourceCode }): MarkdownRuleVisitor {
+		sourceCode satisfies IMarkdownSourceCode;
+
+		sourceCode.ast satisfies RootNode;
+		sourceCode.lines satisfies string[];
+
+		return {
+			// Root selector
+			root(node) {
+				node satisfies RootNode;
+			},
+
+			// Known node selector, sourceCode methods used in visitor
+			text(node) {
+				node satisfies TextNode;
+				sourceCode.getText(node) satisfies string;
+				sourceCode.getLoc(node) satisfies SourceLocation;
+			},
+
+			// Known node selector with parent
+			link(node, parent) {
+				node satisfies MarkdownNode;
+				parent satisfies ParentNode | undefined;
+			},
+
+			// Known node selector with ":exit"
+			"html:exit"(node, parent) {
+				node satisfies MarkdownNode;
+				parent satisfies ParentNode | undefined;
+			},
+
+			// Unknown selectors allowed
+			"heading[depth=1]"(node: MarkdownNode, parent?: ParentNode) {},
+		};
+	},
+});
diff --git a/tools/dedupe-types.js b/tools/dedupe-types.js
index ce8b16d1f..b4a4adabc 100644
--- a/tools/dedupe-types.js
+++ b/tools/dedupe-types.js
@@ -39,5 +39,10 @@ files.forEach(filePath => {
 		return true;
 	});
 
-	fs.writeFileSync(filePath, remainingLines.join("\n"), "utf8");
+	// replace references to ../types.ts with ./types.ts
+	const text = remainingLines
+		.join("\n")
+		.replace(/\.\.\/types\.ts/gu, "./types.ts");
+
+	fs.writeFileSync(filePath, text, "utf8");
 });


Reviews:
user: login: fasttime
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T11:56:20Z
user: login: fasttime
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T12:14:49Z
user: login: lumirlumir
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T14:25:53Z
user: login: nzakas
body: Thanks for taking a look at this. It was in the back of my mind that we needed types for this plugin.

Left a few comments.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T14:29:14Z
user: login: fasttime
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-16T17:07:53Z
user: login: fasttime
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-16T17:12:17Z
user: login: fasttime
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-16T17:28:39Z
user: login: fasttime
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-16T17:59:02Z
user: login: nzakas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T14:53:06Z
user: login: fasttime
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T16:48:39Z
user: login: fasttime
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T08:09:30Z
user: login: nzakas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T14:33:50Z
user: login: fasttime
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-22T08:05:07Z
user: login: nzakas
body: LGTM. Thanks.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-26T14:35:48Z

Review comments:
diff_hunk: @@ -19,7 +19,8 @@ import rules from "./build/rules.js";
 /** @typedef {import("eslint").Linter.RulesRecord} RulesRecord*/
 /** @typedef {import("eslint").Linter.Config} Config*/
 /** @typedef {import("eslint").ESLint.Plugin} Plugin */
-/** @typedef {import("eslint").Rule.RuleModule} RuleModule */
+/** @typedef {import("./types.ts").MarkdownRuleDefinition} RuleModule */
path: src/index.js
user: login: fasttime
body: `RuleModule` is now an alias for the new `MarkdownRuleDefinition` type with the default generic parameters. It's still there for compatibility but I think it's now confusing (it's no longer identical to ESLint's `RuleModule`). So I'm not sure if we should also export `MarkdownRuleDefinition` as a generic type.
created_at: 2025-03-14T11:56:20Z
author_association: MEMBER
path: tests/types/core-patch.d.ts
user: login: fasttime
body: This is required for `RuleContext` to be the same type as in ESLint. See https://github.com/eslint/eslint/blob/v9.22.0/lib/types/index.d.ts#L44-L64.

It took me a while to understand that the `markdown` plugin object would not satisfy `ESLint.Plugin` because this patch was missing: https://github.com/eslint/markdown/blob/f88ed35493d32f37eec2904c316e550c2f19411a/tests/types/types.test.ts#L8
created_at: 2025-03-14T12:14:49Z
author_association: MEMBER
diff_hunk: @@ -19,7 +19,8 @@ import rules from "./build/rules.js";
 /** @typedef {import("eslint").Linter.RulesRecord} RulesRecord*/
 /** @typedef {import("eslint").Linter.Config} Config*/
 /** @typedef {import("eslint").ESLint.Plugin} Plugin */
-/** @typedef {import("eslint").Rule.RuleModule} RuleModule */
+/** @typedef {import("./types.ts").MarkdownRuleDefinition} RuleModule */
path: src/index.js
user: login: nzakas
body: Yeah, I'd say just use `MarkdownRuleDefinition`. People can always switch to ESLint's version of `RuleModule` if they really need it.
created_at: 2025-03-14T14:21:17Z
author_association: MEMBER
diff_hunk: @@ -20,3 +25,41 @@ export interface Block extends Node, BlockBase {
 export type Message = Linter.LintMessage;
 
 export type RuleType = "problem" | "suggestion" | "layout";
+
+/**
+ * The `SourceCode` interface for Markdown files.
+ */
+export interface IMarkdownSourceCode extends TextSourceCode {
+	/**
+	 * Gets the entire source text split into an array of lines.
+	 * @returns {Array<string>} The source text as an array of lines.
+	 * @public
+	 */
+	get lines(): Array<string>;
+
+	/**
+	 * Gets the source code for the given node.
+	 * @param {object} [node] The AST node to get the text for.
+	 * @param {number} [beforeCount] The number of characters before the node to retrieve.
+	 * @param {number} [afterCount] The number of characters after the node to retrieve.
+	 * @returns {string} The text representing the AST node.
+	 * @public
+	 */
+	getText(node?: object, beforeCount?: number, afterCount?: number): string;
+}
+
+export type MarkdownRuleVisitor = Record<
+	string,
+	((node: Node) => void) | undefined
+>;
path: src/types.ts
user: login: nzakas
body: Can we add some of the methods with node names like we do for the JSON plugin?
https://github.com/eslint/json/blob/10882ffe9c39cdd866be51801f9950f4a010cd87/src/types.ts#L58-L84
created_at: 2025-03-14T14:24:40Z
author_association: MEMBER
diff_hunk: @@ -20,3 +25,41 @@ export interface Block extends Node, BlockBase {
 export type Message = Linter.LintMessage;
 
 export type RuleType = "problem" | "suggestion" | "layout";
+
+/**
+ * The `SourceCode` interface for Markdown files.
+ */
+export interface IMarkdownSourceCode extends TextSourceCode {
+	/**
+	 * Gets the entire source text split into an array of lines.
+	 * @returns {Array<string>} The source text as an array of lines.
+	 * @public
+	 */
+	get lines(): Array<string>;
+
+	/**
+	 * Gets the source code for the given node.
+	 * @param {object} [node] The AST node to get the text for.
+	 * @param {number} [beforeCount] The number of characters before the node to retrieve.
+	 * @param {number} [afterCount] The number of characters after the node to retrieve.
+	 * @returns {string} The text representing the AST node.
+	 * @public
+	 */
+	getText(node?: object, beforeCount?: number, afterCount?: number): string;
+}
path: src/types.ts
user: login: lumirlumir
body: Thanks you @fasttime 

https://github.com/eslint/eslint/blob/main/lib/types/index.d.ts#L185-L284

I believe the `IMarkdownSourceCode` interface should follow the same format as the `SourceCode` class defined in the link above.

<details>
<summary>details</summary>

```js
// `types/index.d.ts` of ESLint

export class SourceCode implements TextSourceCode<{
    LangOptions: Linter.LanguageOptions;
    RootNode: AST.Program;
    SyntaxElementWithLoc: AST.Token | ESTree.Node;
    ConfigNode: ESTree.Comment;
}> {
    text: string;
    ast: AST.Program;
    lines: string[];
    hasBOM: boolean;
    parserServices: SourceCode.ParserServices;
    scopeManager: Scope.ScopeManager;
    visitorKeys: SourceCode.VisitorKeys;

    constructor(text: string, ast: AST.Program);
    constructor(config: SourceCode.Config);

    static splitLines(text: string): string[];

    getLoc(syntaxElement: AST.Token | ESTree.Node): ESTree.SourceLocation;
    getRange(syntaxElement: AST.Token | ESTree.Node): SourceRange;

    getText(node?: ESTree.Node, beforeCount?: number, afterCount?: number): string;

    getLines(): string[];

    getAllComments(): ESTree.Comment[];

    getAncestors(node: ESTree.Node): ESTree.Node[];

    getDeclaredVariables(node: ESTree.Node): Scope.Variable[];

    getJSDocComment(node: ESTree.Node): ESTree.Comment | null;

    getNodeByRangeIndex(index: number): ESTree.Node | null;

    isSpaceBetweenTokens(first: AST.Token, second: AST.Token): boolean;

    getLocFromIndex(index: number): ESTree.Position;

    getIndexFromLoc(location: ESTree.Position): number;

    // Inherited methods from TokenStore
    // ---------------------------------

    getTokenByRangeStart(offset: number, options?: { includeComments: false }): AST.Token | null;
    getTokenByRangeStart(offset: number, options: { includeComments: boolean }): AST.Token | ESTree.Comment | null;

    getFirstToken: SourceCode.UnaryNodeCursorWithSkipOptions;

    getFirstTokens: SourceCode.UnaryNodeCursorWithCountOptions;

    getLastToken: SourceCode.UnaryNodeCursorWithSkipOptions;

    getLastTokens: SourceCode.UnaryNodeCursorWithCountOptions;

    getTokenBefore: SourceCode.UnaryCursorWithSkipOptions;

    getTokensBefore: SourceCode.UnaryCursorWithCountOptions;

    getTokenAfter: SourceCode.UnaryCursorWithSkipOptions;

    getTokensAfter: SourceCode.UnaryCursorWithCountOptions;

    getFirstTokenBetween: SourceCode.BinaryCursorWithSkipOptions;

    getFirstTokensBetween: SourceCode.BinaryCursorWithCountOptions;

    getLastTokenBetween: SourceCode.BinaryCursorWithSkipOptions;

    getLastTokensBetween: SourceCode.BinaryCursorWithCountOptions;

    getTokensBetween: SourceCode.BinaryCursorWithCountOptions;

    getTokens:
        & ((node: ESTree.Node, beforeCount?: number, afterCount?: number) => AST.Token[])
        & SourceCode.UnaryNodeCursorWithCountOptions;

    commentsExistBetween(
        left: ESTree.Node | AST.Token | ESTree.Comment,
        right: ESTree.Node | AST.Token | ESTree.Comment,
    ): boolean;

    getCommentsBefore(nodeOrToken: ESTree.Node | AST.Token): ESTree.Comment[];

    getCommentsAfter(nodeOrToken: ESTree.Node | AST.Token): ESTree.Comment[];

    getCommentsInside(node: ESTree.Node): ESTree.Comment[];

    getScope(node: ESTree.Node): Scope.Scope;

    isSpaceBetween(
        first: ESTree.Node | AST.Token,
        second: ESTree.Node | AST.Token,
    ): boolean;

    markVariableAsUsed(name: string, refNode?: ESTree.Node): boolean;

    traverse(): Iterable<TraversalStep>;
}
```

</details>

Since `context.sourceCode` in `@eslint/markdown` lacks some methods that exist in the `SourceCode` class mentioned above, the detailed implementation of `IMarkdownSourceCode` should differ from ESLint's original `SourceCode` class.

For example,

- Methods not implemented:
    - `sourceCode.getLocFromIndex()`
    - `sourceCode.getIndexFromLoc()`
    - (There may be more missing methods.)

- Methods implemented:
    - `sourceCode.getText()`
    - `sourceCode.getRange()`
    - (There may be additional implemented methods.)

---

In conclusion, we need to identify which methods and properties have been correctly implemented.

And also, I believe the `node` type should be from `mdast`, rather than a general `object` type.

---

My opinion may not be entirely correct, so please let me know if further clarification is needed!


created_at: 2025-03-14T14:25:53Z
author_association: MEMBER
path: tests/types/core-patch.d.ts
user: login: nzakas
body: I'm not sure I understand this. When used with ESLint, won't ESLint's definition for `RuleContext` already be used?

Note: we don't do this for the JSON plugin.
created_at: 2025-03-14T14:28:41Z
author_association: MEMBER
diff_hunk: @@ -19,7 +19,8 @@ import rules from "./build/rules.js";
 /** @typedef {import("eslint").Linter.RulesRecord} RulesRecord*/
 /** @typedef {import("eslint").Linter.Config} Config*/
 /** @typedef {import("eslint").ESLint.Plugin} Plugin */
-/** @typedef {import("eslint").Rule.RuleModule} RuleModule */
+/** @typedef {import("./types.ts").MarkdownRuleDefinition} RuleModule */
path: src/index.js
user: login: fasttime
body: I've added an export for `MarkdownRuleDefinition` as a generic type in 896a1949a16731abfa165e315ab5b19ce1b8772b. Now this can be used to define a custom Markdown rule with `RuleOptions`, `MessageIds` and `ExtRuleDocs`, all optional properties.
created_at: 2025-03-16T17:07:53Z
author_association: MEMBER
diff_hunk: @@ -20,3 +25,41 @@ export interface Block extends Node, BlockBase {
 export type Message = Linter.LintMessage;
 
 export type RuleType = "problem" | "suggestion" | "layout";
+
+/**
+ * The `SourceCode` interface for Markdown files.
+ */
+export interface IMarkdownSourceCode extends TextSourceCode {
+	/**
+	 * Gets the entire source text split into an array of lines.
+	 * @returns {Array<string>} The source text as an array of lines.
+	 * @public
+	 */
+	get lines(): Array<string>;
+
+	/**
+	 * Gets the source code for the given node.
+	 * @param {object} [node] The AST node to get the text for.
+	 * @param {number} [beforeCount] The number of characters before the node to retrieve.
+	 * @param {number} [afterCount] The number of characters after the node to retrieve.
+	 * @returns {string} The text representing the AST node.
+	 * @public
+	 */
+	getText(node?: object, beforeCount?: number, afterCount?: number): string;
+}
+
+export type MarkdownRuleVisitor = Record<
+	string,
+	((node: Node) => void) | undefined
+>;
path: src/types.ts
user: login: fasttime
body: Done in 4e326af917f7421935b54bc3fe34e89fc87b24aa. I've added listeners for all the node types used in our rules plus `Root`. Please, check if there is anything else that should be added.
created_at: 2025-03-16T17:12:17Z
author_association: MEMBER
path: tests/types/core-patch.d.ts
user: login: fasttime
body: > I'm not sure I understand this. When used with ESLint, won't ESLint's definition for `RuleContext` already be used?

It looks like the additional definitions for `RuleContext` are only applied globally when they are located in an ESM module. For example, if I change the file extension to CommonJS (`core-patch.d.ts` -> `core-patch.d.cts`), the type test fails again. I suppose this has to do with the fact that CommonJS files have a root scope that is not the global scope, and so the additions in the ESLint types only affect the `RuleContext` used in that file.

> Note: we don't do this for the JSON plugin.

In the JSON plugin, the type test that checks if `ESLint.Plugin` is satisfied is currently disabled: https://github.com/eslint/json/blob/json-v0.11.0/tests/types/types.test.ts. I think we could re-enable it if we patch the `RuleContext` also there.


created_at: 2025-03-16T17:28:39Z
author_association: MEMBER
diff_hunk: @@ -20,3 +25,41 @@ export interface Block extends Node, BlockBase {
 export type Message = Linter.LintMessage;
 
 export type RuleType = "problem" | "suggestion" | "layout";
+
+/**
+ * The `SourceCode` interface for Markdown files.
+ */
+export interface IMarkdownSourceCode extends TextSourceCode {
+	/**
+	 * Gets the entire source text split into an array of lines.
+	 * @returns {Array<string>} The source text as an array of lines.
+	 * @public
+	 */
+	get lines(): Array<string>;
+
+	/**
+	 * Gets the source code for the given node.
+	 * @param {object} [node] The AST node to get the text for.
+	 * @param {number} [beforeCount] The number of characters before the node to retrieve.
+	 * @param {number} [afterCount] The number of characters after the node to retrieve.
+	 * @returns {string} The text representing the AST node.
+	 * @public
+	 */
+	getText(node?: object, beforeCount?: number, afterCount?: number): string;
+}
path: src/types.ts
user: login: fasttime
body: Actually I was looking at `IJSONSourceCode` as declared in `@eslint/json` (see https://github.com/eslint/json/blob/json-v0.11.0/src/types.ts#L89-L118) as a model for `IMarkdownSourceCode`. ESLint types contain legacy code so they're not always the best example. But you are perfectly right that the type of nodes should be `Node` from `mdast`, rather than a generic `object`. That should be fixed in c015363ecd64193aef59f1623d525b5ea4564580. Please, have a look.

> In conclusion, we need to identify which methods and properties have been correctly implemented.

You can check [`src/language/markdown-source-code.js`](https://github.com/eslint/markdown/blob/main/src/language/markdown-source-code.js) for the actual implementation. Note that part of the logic is inherited from classes in another package.
created_at: 2025-03-16T17:59:02Z
author_association: MEMBER
path: tests/types/core-patch.d.ts
user: login: nzakas
body: I'm not liking the idea or propagating JS-specific changes to all of the language plugins that we're just going to have to remove later.

Is there something we can do in the main ESLint repo to make the definition of `Plugin` not require `RuleContext` to have these extra methods?
created_at: 2025-03-17T14:53:05Z
author_association: MEMBER
path: tests/types/core-patch.d.ts
user: login: fasttime
body: I think we could add the deprecated methods directly to `Rule.RuleContext` and update `Rule.RuleModule` so that the `create` method accepts an argument of type `Rule.RuleContext` (as opposed to `RuleContext` from core types). I can look into how to get this to work.
created_at: 2025-03-17T16:48:39Z
author_association: MEMBER
path: tests/types/core-patch.d.ts
user: login: fasttime
body: Here is a pull request that should fix the issue in the main repo: eslint/eslint#19531.
created_at: 2025-03-18T08:09:30Z
author_association: MEMBER
path: tests/types/core-patch.d.ts
user: login: nzakas
body: Nice!
created_at: 2025-03-18T14:33:50Z
author_association: MEMBER
diff_hunk: @@ -19,7 +19,8 @@ import rules from "./build/rules.js";
 /** @typedef {import("eslint").Linter.RulesRecord} RulesRecord*/
 /** @typedef {import("eslint").Linter.Config} Config*/
 /** @typedef {import("eslint").ESLint.Plugin} Plugin */
-/** @typedef {import("eslint").Rule.RuleModule} RuleModule */
+/** @typedef {import("./types.ts").MarkdownRuleDefinition} RuleModule */
path: src/index.js
user: login: fasttime
body: Note: we need to discuss the implementation details for `MarkdownRuleDefinition` along with `JSRuleDefinition` in eslint/eslint#19521.
created_at: 2025-03-22T08:05:06Z
author_association: MEMBER

PR comments:
user: login: nzakas
created_at: 2025-03-25T14:15:44Z
body: @fasttime this is still listed as a draft. Are you ready for review?
author_association: MEMBER
user: login: fasttime
created_at: 2025-03-26T09:11:44Z
body: Yes, this is ready for review now. I removed the `MarkdownRuleDefinition` export for the moment because I think we should discuss in eslint/eslint#19521 how to provide consistent implementations for different languages before exposing that type. I will re-add it in a new PR when we have a decision.
author_association: MEMBER