mdlint 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mdlint/__init__.py +1 -0
- mdlint/cli.py +370 -0
- mdlint/config.py +223 -0
- mdlint/document.py +128 -0
- mdlint/linter.py +375 -0
- mdlint/output/__init__.py +1 -0
- mdlint/output/json.py +45 -0
- mdlint/output/terminal.py +119 -0
- mdlint/rules/__init__.py +118 -0
- mdlint/rules/base.py +73 -0
- mdlint/rules/md001.py +171 -0
- mdlint/rules/md003.py +223 -0
- mdlint/rules/md004.py +214 -0
- mdlint/rules/md005.py +246 -0
- mdlint/rules/md007.py +214 -0
- mdlint/rules/md009.py +125 -0
- mdlint/rules/md010.py +116 -0
- mdlint/rules/md011.py +136 -0
- mdlint/rules/md012.py +147 -0
- mdlint/rules/md013.py +279 -0
- mdlint/rules/md014.py +144 -0
- mdlint/rules/md018.py +112 -0
- mdlint/rules/md019.py +107 -0
- mdlint/rules/md020.py +139 -0
- mdlint/rules/md021.py +142 -0
- mdlint/rules/md022.py +223 -0
- mdlint/rules/md023.py +138 -0
- mdlint/rules/md024.py +145 -0
- mdlint/rules/md025.py +98 -0
- mdlint/rules/md026.py +147 -0
- mdlint/rules/md027.py +93 -0
- mdlint/rules/md028.py +149 -0
- mdlint/rules/md029.py +285 -0
- mdlint/rules/md030.py +224 -0
- mdlint/rules/md031.py +156 -0
- mdlint/rules/md032.py +164 -0
- mdlint/rules/md033.py +173 -0
- mdlint/rules/md034.py +157 -0
- mdlint/rules/md035.py +132 -0
- mdlint/rules/md036.py +168 -0
- mdlint/rules/md037.py +230 -0
- mdlint/rules/md038.py +249 -0
- mdlint/rules/md039.py +202 -0
- mdlint/rules/md040.py +141 -0
- mdlint/rules/md041.py +164 -0
- mdlint/rules/md042.py +149 -0
- mdlint/rules/md043.py +196 -0
- mdlint/rules/md044.py +237 -0
- mdlint/rules/md045.py +187 -0
- mdlint/rules/md046.py +177 -0
- mdlint/rules/md047.py +73 -0
- mdlint/rules/md048.py +173 -0
- mdlint/rules/md049.py +275 -0
- mdlint/rules/md050.py +265 -0
- mdlint/rules/md051.py +354 -0
- mdlint/rules/md052.py +166 -0
- mdlint/rules/md053.py +190 -0
- mdlint/rules/md054.py +266 -0
- mdlint/rules/md055.py +273 -0
- mdlint/rules/md056.py +132 -0
- mdlint/rules/md058.py +133 -0
- mdlint/rules/md059.py +217 -0
- mdlint/rules/md060.py +626 -0
- mdlint/suppression.py +103 -0
- mdlint/violation.py +29 -0
- mdlint-0.2.0.dist-info/METADATA +234 -0
- mdlint-0.2.0.dist-info/RECORD +69 -0
- mdlint-0.2.0.dist-info/WHEEL +4 -0
- mdlint-0.2.0.dist-info/entry_points.txt +3 -0
mdlint/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
mdlint/cli.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Literal, get_args, get_origin
|
|
5
|
+
|
|
6
|
+
import rich_click as click
|
|
7
|
+
from rich.console import Console, Group
|
|
8
|
+
from rich.markdown import Markdown
|
|
9
|
+
from rich.padding import Padding
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.syntax import Syntax
|
|
12
|
+
|
|
13
|
+
from mdlint import __version__
|
|
14
|
+
from mdlint.config import apply_cli_overrides, load_config
|
|
15
|
+
from mdlint.linter import Linter, LintResult, discover_files
|
|
16
|
+
from mdlint.output.json import format_json
|
|
17
|
+
from mdlint.output.terminal import print_results
|
|
18
|
+
from mdlint.rules import RULE_REGISTRY
|
|
19
|
+
|
|
20
|
+
# Width for rule documentation output and help output
|
|
21
|
+
RULE_DOC_WIDTH = 120
|
|
22
|
+
HELP_CONFIG_WIDTH = 120
|
|
23
|
+
RULE_DOC_INDENT = 2
|
|
24
|
+
|
|
25
|
+
CONTEXT_SETTINGS = {
|
|
26
|
+
"help_option_names": ["-h", "--help"],
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@click.group(context_settings=CONTEXT_SETTINGS)
|
|
31
|
+
@click.rich_config(
|
|
32
|
+
help_config=click.RichHelpConfiguration(text_markup="markdown", width=HELP_CONFIG_WIDTH)
|
|
33
|
+
)
|
|
34
|
+
@click.version_option(version=__version__, prog_name="mdlint")
|
|
35
|
+
def cli() -> None:
|
|
36
|
+
"""mdlint - A Python Markdown linter."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@cli.command()
|
|
40
|
+
@click.rich_config(
|
|
41
|
+
help_config=click.RichHelpConfiguration(
|
|
42
|
+
option_groups={
|
|
43
|
+
"mdlint check": [
|
|
44
|
+
{
|
|
45
|
+
"name": "Rule selection",
|
|
46
|
+
"options": ["--select", "--ignore", "--extend-select", "--extend-ignore"],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"name": "File selection",
|
|
50
|
+
"options": ["--exclude", "--extend-exclude", "--no-ignore"],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "Output options",
|
|
54
|
+
"options": ["--format", "--fix", "--verbose", "--show-files", "--show-context"],
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
width=HELP_CONFIG_WIDTH,
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
@click.argument("paths", nargs=-1, type=click.Path())
|
|
62
|
+
@click.option(
|
|
63
|
+
"--format",
|
|
64
|
+
"-f",
|
|
65
|
+
"output_format",
|
|
66
|
+
type=click.Choice(["terminal", "json"]),
|
|
67
|
+
default="terminal",
|
|
68
|
+
help="Output format.",
|
|
69
|
+
)
|
|
70
|
+
@click.option(
|
|
71
|
+
"--config",
|
|
72
|
+
"-c",
|
|
73
|
+
"config_path",
|
|
74
|
+
type=str,
|
|
75
|
+
default=None,
|
|
76
|
+
help="Path to config file or inline TOML (e.g., MD003.style='atx').",
|
|
77
|
+
)
|
|
78
|
+
@click.option(
|
|
79
|
+
"--select",
|
|
80
|
+
"-s",
|
|
81
|
+
multiple=True,
|
|
82
|
+
help="Rules to run. Replaces any select list in config (repeatable).",
|
|
83
|
+
)
|
|
84
|
+
@click.option(
|
|
85
|
+
"--ignore",
|
|
86
|
+
"-i",
|
|
87
|
+
multiple=True,
|
|
88
|
+
help="Rules to skip. Replaces any ignore list in config (repeatable).",
|
|
89
|
+
)
|
|
90
|
+
@click.option(
|
|
91
|
+
"--extend-select",
|
|
92
|
+
"-S",
|
|
93
|
+
multiple=True,
|
|
94
|
+
help="Rules to add on top of the select list (repeatable).",
|
|
95
|
+
)
|
|
96
|
+
@click.option(
|
|
97
|
+
"--extend-ignore",
|
|
98
|
+
"-I",
|
|
99
|
+
multiple=True,
|
|
100
|
+
help="Rules to add on top of the ignore list (repeatable).",
|
|
101
|
+
)
|
|
102
|
+
@click.option(
|
|
103
|
+
"--exclude",
|
|
104
|
+
multiple=True,
|
|
105
|
+
help="Exclude files/directories from analysis. Replaces config exclude list (repeatable).",
|
|
106
|
+
)
|
|
107
|
+
@click.option(
|
|
108
|
+
"--extend-exclude",
|
|
109
|
+
multiple=True,
|
|
110
|
+
help="Exclude files/directories on top of the config exclude list (repeatable).",
|
|
111
|
+
)
|
|
112
|
+
@click.option(
|
|
113
|
+
"--no-ignore",
|
|
114
|
+
is_flag=True,
|
|
115
|
+
default=False,
|
|
116
|
+
help="Disable .gitignore pattern matching.",
|
|
117
|
+
)
|
|
118
|
+
@click.option(
|
|
119
|
+
"--verbose",
|
|
120
|
+
"-v",
|
|
121
|
+
is_flag=True,
|
|
122
|
+
default=False,
|
|
123
|
+
help="Show files being processed.",
|
|
124
|
+
)
|
|
125
|
+
@click.option(
|
|
126
|
+
"--show-files",
|
|
127
|
+
is_flag=True,
|
|
128
|
+
default=False,
|
|
129
|
+
help="List files that would be linted and exit.",
|
|
130
|
+
)
|
|
131
|
+
@click.option(
|
|
132
|
+
"--show-context",
|
|
133
|
+
is_flag=True,
|
|
134
|
+
default=False,
|
|
135
|
+
help="Show surrounding lines for each violation.",
|
|
136
|
+
)
|
|
137
|
+
@click.option(
|
|
138
|
+
"--fix",
|
|
139
|
+
is_flag=True,
|
|
140
|
+
default=False,
|
|
141
|
+
help="Auto-fix violations where possible.",
|
|
142
|
+
)
|
|
143
|
+
def check(
|
|
144
|
+
paths: tuple[str, ...],
|
|
145
|
+
output_format: str,
|
|
146
|
+
config_path: str | None,
|
|
147
|
+
select: tuple[str, ...],
|
|
148
|
+
ignore: tuple[str, ...],
|
|
149
|
+
extend_select: tuple[str, ...],
|
|
150
|
+
extend_ignore: tuple[str, ...],
|
|
151
|
+
exclude: tuple[str, ...],
|
|
152
|
+
extend_exclude: tuple[str, ...],
|
|
153
|
+
no_ignore: bool,
|
|
154
|
+
verbose: bool,
|
|
155
|
+
show_files: bool,
|
|
156
|
+
show_context: bool,
|
|
157
|
+
fix: bool,
|
|
158
|
+
) -> None:
|
|
159
|
+
"""Lint Markdown files for style violations.
|
|
160
|
+
|
|
161
|
+
PATHS can be files or directories. Use '-' for stdin. If no paths provided, lints current
|
|
162
|
+
directory.
|
|
163
|
+
"""
|
|
164
|
+
# Validate CLI options - check for unknown rules
|
|
165
|
+
all_rules = set(select) | set(ignore) | set(extend_select) | set(extend_ignore)
|
|
166
|
+
unknown = all_rules - set(RULE_REGISTRY.keys())
|
|
167
|
+
if unknown:
|
|
168
|
+
rules = ", ".join(sorted(unknown))
|
|
169
|
+
click.echo(f"Error: Unknown rule(s): {rules}", err=True)
|
|
170
|
+
sys.exit(2)
|
|
171
|
+
|
|
172
|
+
# Load configuration
|
|
173
|
+
config = load_config(config_path=config_path)
|
|
174
|
+
config = apply_cli_overrides(
|
|
175
|
+
config,
|
|
176
|
+
select=select,
|
|
177
|
+
ignore=ignore,
|
|
178
|
+
extend_select=extend_select,
|
|
179
|
+
extend_ignore=extend_ignore,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
if verbose and config.source:
|
|
183
|
+
click.secho(f"Using config: {config.source}", dim=True, err=True)
|
|
184
|
+
|
|
185
|
+
linter = Linter(rule_configs=config.rules, enabled_rules=config.enabled_rules)
|
|
186
|
+
if verbose:
|
|
187
|
+
click.secho(
|
|
188
|
+
f"Linting with these rules enabled: {', '.join(sorted(config.enabled_rules))}",
|
|
189
|
+
dim=True,
|
|
190
|
+
err=True,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Handle stdin
|
|
194
|
+
if len(paths) == 1 and paths[0] == "-":
|
|
195
|
+
content = sys.stdin.read()
|
|
196
|
+
if fix:
|
|
197
|
+
file_result, fixed_content = linter.fix_stdin(content)
|
|
198
|
+
click.echo(fixed_content, nl=False)
|
|
199
|
+
sys.exit(1 if file_result.violations else 0)
|
|
200
|
+
file_result = linter.lint_stdin(content)
|
|
201
|
+
result = LintResult(files=[file_result])
|
|
202
|
+
else:
|
|
203
|
+
# Default to current directory if no paths
|
|
204
|
+
if not paths:
|
|
205
|
+
paths = (".",)
|
|
206
|
+
|
|
207
|
+
path_list = [Path(p) for p in paths]
|
|
208
|
+
for path in path_list:
|
|
209
|
+
if not path.exists():
|
|
210
|
+
click.echo(f"Error: no such file or directory: {path}", err=True)
|
|
211
|
+
sys.exit(2)
|
|
212
|
+
|
|
213
|
+
if exclude:
|
|
214
|
+
exclude_list: list[str] | None = list(exclude)
|
|
215
|
+
else:
|
|
216
|
+
exclude_list = config.exclude.copy() or None
|
|
217
|
+
if extend_exclude:
|
|
218
|
+
if exclude_list is None:
|
|
219
|
+
exclude_list = []
|
|
220
|
+
exclude_list.extend(extend_exclude)
|
|
221
|
+
|
|
222
|
+
if show_files:
|
|
223
|
+
files = discover_files(
|
|
224
|
+
path_list, respect_gitignore=not no_ignore, exclude_patterns=exclude_list
|
|
225
|
+
)
|
|
226
|
+
for file in files:
|
|
227
|
+
click.echo(file)
|
|
228
|
+
sys.exit(0)
|
|
229
|
+
|
|
230
|
+
if verbose:
|
|
231
|
+
click.secho(f"Scanning {len(path_list)} path(s)...", dim=True, err=True)
|
|
232
|
+
|
|
233
|
+
if fix:
|
|
234
|
+
result = linter.fix_paths(
|
|
235
|
+
path_list, respect_gitignore=not no_ignore, exclude_patterns=exclude_list
|
|
236
|
+
)
|
|
237
|
+
else:
|
|
238
|
+
result = linter.lint_paths(
|
|
239
|
+
path_list, respect_gitignore=not no_ignore, exclude_patterns=exclude_list
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
if verbose:
|
|
243
|
+
click.secho(f"Found {result.files_checked} file(s)", dim=True, err=True)
|
|
244
|
+
|
|
245
|
+
# Output results
|
|
246
|
+
if output_format == "json":
|
|
247
|
+
click.echo(format_json(result))
|
|
248
|
+
else:
|
|
249
|
+
print_results(result, show_context=show_context)
|
|
250
|
+
|
|
251
|
+
sys.exit(result.exit_code)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@cli.command()
|
|
255
|
+
@click.argument("rule_id", required=False)
|
|
256
|
+
@click.option(
|
|
257
|
+
"--show-examples",
|
|
258
|
+
"-e",
|
|
259
|
+
is_flag=True,
|
|
260
|
+
default=False,
|
|
261
|
+
help="Show valid/invalid examples for the rule.",
|
|
262
|
+
)
|
|
263
|
+
def rule(rule_id: str | None, show_examples: bool) -> None:
|
|
264
|
+
"""Display rule documentation.
|
|
265
|
+
|
|
266
|
+
If RULE_ID is provided, shows detailed documentation for that rule.
|
|
267
|
+
Otherwise, lists all available rules.
|
|
268
|
+
"""
|
|
269
|
+
console = Console(width=RULE_DOC_WIDTH)
|
|
270
|
+
|
|
271
|
+
if rule_id is None:
|
|
272
|
+
# List all rules
|
|
273
|
+
console.print("[bold]Available rules:[/bold]\n")
|
|
274
|
+
for rid, rule_class in sorted(RULE_REGISTRY.items()):
|
|
275
|
+
console.print(f" [yellow]{rid}[/yellow] {rule_class.name:20} {rule_class.summary}")
|
|
276
|
+
raise SystemExit(0) # ty doesn't recognize sys.exit(0) as terminating function
|
|
277
|
+
|
|
278
|
+
# Resolve partial rule ID to full ID
|
|
279
|
+
# Accept numeric input (e.g., "1" -> "MD001") or MD-prefixed (case-insensitive)
|
|
280
|
+
if rule_id.isdigit():
|
|
281
|
+
resolved_id = f"MD{int(rule_id):03d}"
|
|
282
|
+
elif rule_id.upper().startswith("MD"):
|
|
283
|
+
resolved_id = rule_id.upper()
|
|
284
|
+
else:
|
|
285
|
+
click.echo(f"Error: Invalid rule ID '{rule_id}'", err=True)
|
|
286
|
+
sys.exit(2)
|
|
287
|
+
|
|
288
|
+
if resolved_id not in RULE_REGISTRY:
|
|
289
|
+
click.echo(f"Error: Unknown rule '{resolved_id}'", err=True)
|
|
290
|
+
sys.exit(2)
|
|
291
|
+
|
|
292
|
+
rule_class = RULE_REGISTRY[resolved_id]
|
|
293
|
+
console.print(f"[bold yellow]{rule_class.id}[/bold yellow]: {rule_class.name}\n")
|
|
294
|
+
console.print(f" {rule_class.summary}\n")
|
|
295
|
+
|
|
296
|
+
if rule_class.description:
|
|
297
|
+
console.print(Padding("[bold]What it does:[/bold]", (0, 0, 0, RULE_DOC_INDENT)))
|
|
298
|
+
console.print(Padding(Markdown(rule_class.description), (0, 0, 1, RULE_DOC_INDENT)))
|
|
299
|
+
|
|
300
|
+
if rule_class.rationale:
|
|
301
|
+
console.print(Padding("[bold]Why is this bad?[/bold]", (0, 0, 0, RULE_DOC_INDENT)))
|
|
302
|
+
console.print(Padding(Markdown(rule_class.rationale), (0, 0, 1, RULE_DOC_INDENT)))
|
|
303
|
+
|
|
304
|
+
config_class = rule_class.config_class
|
|
305
|
+
config_fields = list(dataclasses.fields(config_class))
|
|
306
|
+
if config_fields:
|
|
307
|
+
console.print(" [bold]Configuration:[/bold]")
|
|
308
|
+
for f in config_fields:
|
|
309
|
+
default = f.default if f.default is not dataclasses.MISSING else None
|
|
310
|
+
# Extract metadata
|
|
311
|
+
metadata = f.metadata if f.metadata else {}
|
|
312
|
+
description = metadata.get("description", "")
|
|
313
|
+
option_descriptions = metadata.get("option_descriptions", {})
|
|
314
|
+
# Extract options from Literal types
|
|
315
|
+
field_type = f.type
|
|
316
|
+
options = None
|
|
317
|
+
if get_origin(field_type) is Literal:
|
|
318
|
+
options = get_args(field_type)
|
|
319
|
+
if description:
|
|
320
|
+
console.print(Padding(f"[cyan]{f.name}[/cyan]: {description}", (0, 0, 0, 4)))
|
|
321
|
+
else:
|
|
322
|
+
console.print(Padding(f"[cyan]{f.name}[/cyan]", (0, 0, 0, 4)))
|
|
323
|
+
console.print(Padding(f"default: {default}", (0, 0, 0, 6)))
|
|
324
|
+
if options:
|
|
325
|
+
if option_descriptions:
|
|
326
|
+
console.print(" options:")
|
|
327
|
+
# Calculate max width for alignment
|
|
328
|
+
max_width = max(len(str(o)) for o in options)
|
|
329
|
+
for opt in options:
|
|
330
|
+
opt_desc = option_descriptions.get(opt, "")
|
|
331
|
+
console.print(f" {opt:<{max_width}} - {opt_desc}")
|
|
332
|
+
else:
|
|
333
|
+
options_str = " | ".join(str(o) for o in options)
|
|
334
|
+
console.print(f" options: {options_str}")
|
|
335
|
+
console.print()
|
|
336
|
+
|
|
337
|
+
if not show_examples:
|
|
338
|
+
return
|
|
339
|
+
|
|
340
|
+
panel_width = RULE_DOC_WIDTH - RULE_DOC_INDENT
|
|
341
|
+
panels = []
|
|
342
|
+
|
|
343
|
+
if rule_class.example_invalid:
|
|
344
|
+
syntax = Syntax(rule_class.example_invalid.rstrip(), "markdown", theme="ansi_dark")
|
|
345
|
+
panel = Panel(
|
|
346
|
+
syntax,
|
|
347
|
+
title="[red]Invalid[/red]",
|
|
348
|
+
title_align="left",
|
|
349
|
+
border_style="red",
|
|
350
|
+
width=panel_width,
|
|
351
|
+
)
|
|
352
|
+
panels.append(panel)
|
|
353
|
+
|
|
354
|
+
if rule_class.example_valid:
|
|
355
|
+
syntax = Syntax(rule_class.example_valid.rstrip(), "markdown", theme="ansi_dark")
|
|
356
|
+
panel = Panel(
|
|
357
|
+
syntax,
|
|
358
|
+
title="[green]Valid[/green]",
|
|
359
|
+
title_align="left",
|
|
360
|
+
border_style="green",
|
|
361
|
+
width=panel_width,
|
|
362
|
+
)
|
|
363
|
+
panels.append(panel)
|
|
364
|
+
|
|
365
|
+
if panels:
|
|
366
|
+
console.print(Padding(Group(*panels), (0, 0, 0, RULE_DOC_INDENT)))
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
if __name__ == "__main__":
|
|
370
|
+
cli()
|
mdlint/config.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
if sys.version_info >= (3, 11):
|
|
6
|
+
import tomllib
|
|
7
|
+
else:
|
|
8
|
+
import tomli as tomllib # type: ignore[import-not-found]
|
|
9
|
+
|
|
10
|
+
from mdlint.rules import RULE_REGISTRY
|
|
11
|
+
from mdlint.rules.base import RuleConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Configuration:
|
|
16
|
+
"""Represents the merged configuration from all sources.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
rules: Rule ID -> config mapping.
|
|
20
|
+
select: Rules to enable (baseline). Empty means all rules.
|
|
21
|
+
ignore: Rules to disable (subtracted from select).
|
|
22
|
+
source: Path to config file used, or None for defaults.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
rules: dict[str, RuleConfig] = field(default_factory=dict)
|
|
26
|
+
select: list[str] = field(default_factory=list)
|
|
27
|
+
ignore: list[str] = field(default_factory=list)
|
|
28
|
+
exclude: list[str] = field(default_factory=list)
|
|
29
|
+
source: str | None = None
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def enabled_rules(self) -> set[str]:
|
|
33
|
+
"""Compute the set of enabled rule IDs.
|
|
34
|
+
|
|
35
|
+
select establishes the baseline (all rules if empty),
|
|
36
|
+
then ignore subtracts from it.
|
|
37
|
+
"""
|
|
38
|
+
# Baseline: select list or all registered rules
|
|
39
|
+
if self.select:
|
|
40
|
+
baseline = set(self.select)
|
|
41
|
+
else:
|
|
42
|
+
baseline = set(RULE_REGISTRY.keys())
|
|
43
|
+
|
|
44
|
+
# Subtract ignored rules
|
|
45
|
+
return baseline - set(self.ignore)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def find_config_file(start_dir: Path | None = None) -> Path | None:
|
|
49
|
+
"""Search upward for configuration file.
|
|
50
|
+
|
|
51
|
+
Searches for .mdlint.toml first, then pyproject.toml.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
start_dir: Directory to start searching from. Defaults to cwd.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Path to config file, or None if not found.
|
|
58
|
+
"""
|
|
59
|
+
current = (start_dir or Path.cwd()).resolve()
|
|
60
|
+
while current != current.parent:
|
|
61
|
+
# Check for .mdlint.toml first
|
|
62
|
+
mdlint_config = current / ".mdlint.toml"
|
|
63
|
+
if mdlint_config.is_file():
|
|
64
|
+
return mdlint_config
|
|
65
|
+
|
|
66
|
+
# Check for pyproject.toml with [tool.mdlint] section
|
|
67
|
+
pyproject = current / "pyproject.toml"
|
|
68
|
+
if pyproject.is_file():
|
|
69
|
+
try:
|
|
70
|
+
with pyproject.open("rb") as f:
|
|
71
|
+
data = tomllib.load(f)
|
|
72
|
+
if "tool" in data and "mdlint" in data["tool"]:
|
|
73
|
+
return pyproject
|
|
74
|
+
except (tomllib.TOMLDecodeError, OSError):
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
current = current.parent
|
|
78
|
+
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def parse_toml_file(path: Path) -> dict:
|
|
83
|
+
"""Parse TOML file and extract mdlint configuration.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
path: Path to TOML file.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Configuration dictionary.
|
|
90
|
+
"""
|
|
91
|
+
with path.open("rb") as f:
|
|
92
|
+
data = tomllib.load(f)
|
|
93
|
+
|
|
94
|
+
# Handle pyproject.toml format
|
|
95
|
+
if path.name == "pyproject.toml":
|
|
96
|
+
return data.get("tool", {}).get("mdlint", {})
|
|
97
|
+
|
|
98
|
+
return data
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def parse_inline_config(config_str: str) -> dict:
|
|
102
|
+
"""Parse inline TOML configuration string.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
config_str: Inline TOML string (e.g., "MD003.style='atx'").
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Configuration dictionary.
|
|
109
|
+
"""
|
|
110
|
+
# Top-level config keys that should not be wrapped in [rules]
|
|
111
|
+
top_level_keys = {"select", "ignore", "exclude"}
|
|
112
|
+
|
|
113
|
+
# Handle simple key=value format
|
|
114
|
+
if "=" in config_str and not config_str.strip().startswith("["):
|
|
115
|
+
# Convert MD003.style='atx' to [rules.MD003]\nstyle = 'atx'
|
|
116
|
+
key_path, value = config_str.split("=", 1)
|
|
117
|
+
key_path = key_path.strip()
|
|
118
|
+
value = value.strip()
|
|
119
|
+
|
|
120
|
+
if key_path in top_level_keys:
|
|
121
|
+
# Keep as top-level config
|
|
122
|
+
config_str = f"{key_path} = {value}"
|
|
123
|
+
elif "." in key_path:
|
|
124
|
+
rule_id, param = key_path.rsplit(".", 1)
|
|
125
|
+
config_str = f"[rules.{rule_id}]\n{param} = {value}"
|
|
126
|
+
else:
|
|
127
|
+
config_str = f"[rules]\n{key_path} = {value}"
|
|
128
|
+
|
|
129
|
+
return tomllib.loads(config_str)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def build_rule_configs(config_data: dict) -> dict[str, RuleConfig]:
|
|
133
|
+
"""Build rule configurations from parsed config data.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
config_data: Parsed configuration dictionary.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Mapping of rule ID to RuleConfig.
|
|
140
|
+
"""
|
|
141
|
+
rules_data = config_data.get("rules", {})
|
|
142
|
+
return {
|
|
143
|
+
rule_id: rule_class.config_class(**rules_data.get(rule_id, {}))
|
|
144
|
+
for rule_id, rule_class in RULE_REGISTRY.items()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def load_config(config_path: str | None = None) -> Configuration:
|
|
149
|
+
"""Load configuration from file or defaults.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
config_path: Explicit config path or inline TOML.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Merged Configuration object.
|
|
156
|
+
"""
|
|
157
|
+
config_data: dict = {}
|
|
158
|
+
source: str | None = None
|
|
159
|
+
|
|
160
|
+
if config_path:
|
|
161
|
+
path = Path(config_path)
|
|
162
|
+
if path.is_file():
|
|
163
|
+
config_data = parse_toml_file(path)
|
|
164
|
+
source = str(path)
|
|
165
|
+
else:
|
|
166
|
+
# Treat as inline TOML
|
|
167
|
+
config_data = parse_inline_config(config_path)
|
|
168
|
+
source = "<inline>"
|
|
169
|
+
else:
|
|
170
|
+
# Search for config file
|
|
171
|
+
found_path = find_config_file()
|
|
172
|
+
if found_path:
|
|
173
|
+
config_data = parse_toml_file(found_path)
|
|
174
|
+
source = str(found_path)
|
|
175
|
+
|
|
176
|
+
rule_configs = build_rule_configs(config_data)
|
|
177
|
+
|
|
178
|
+
# Extract select/ignore/exclude lists
|
|
179
|
+
select = config_data.get("select", [])
|
|
180
|
+
ignore = config_data.get("ignore", [])
|
|
181
|
+
exclude = config_data.get("exclude", [])
|
|
182
|
+
|
|
183
|
+
return Configuration(
|
|
184
|
+
rules=rule_configs,
|
|
185
|
+
select=select,
|
|
186
|
+
ignore=ignore,
|
|
187
|
+
exclude=exclude,
|
|
188
|
+
source=source,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def apply_cli_overrides(
|
|
193
|
+
config: Configuration,
|
|
194
|
+
select: tuple[str, ...] | None = None,
|
|
195
|
+
ignore: tuple[str, ...] | None = None,
|
|
196
|
+
extend_select: tuple[str, ...] | None = None,
|
|
197
|
+
extend_ignore: tuple[str, ...] | None = None,
|
|
198
|
+
) -> Configuration:
|
|
199
|
+
"""Apply CLI rule selection overrides.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
config: Base configuration.
|
|
203
|
+
select: Replaces the entire select baseline.
|
|
204
|
+
ignore: Replaces the entire ignore list.
|
|
205
|
+
extend_select: Adds to the existing select list.
|
|
206
|
+
extend_ignore: Adds to the existing ignore list.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
Modified Configuration.
|
|
210
|
+
"""
|
|
211
|
+
if select:
|
|
212
|
+
config.select = list(select)
|
|
213
|
+
|
|
214
|
+
if ignore:
|
|
215
|
+
config.ignore = list(ignore)
|
|
216
|
+
|
|
217
|
+
if extend_select:
|
|
218
|
+
config.select.extend(extend_select)
|
|
219
|
+
|
|
220
|
+
if extend_ignore:
|
|
221
|
+
config.ignore.extend(extend_ignore)
|
|
222
|
+
|
|
223
|
+
return config
|