tiredize 0.1.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.
- tiredize/__init__.py +0 -0
- tiredize/__main__.py +19 -0
- tiredize/cli.py +238 -0
- tiredize/core_types.py +21 -0
- tiredize/linter/__init__.py +0 -0
- tiredize/linter/engine.py +73 -0
- tiredize/linter/rules/__init__.py +107 -0
- tiredize/linter/rules/_elements.py +32 -0
- tiredize/linter/rules/elements.py +71 -0
- tiredize/linter/rules/line_length.py +131 -0
- tiredize/linter/rules/links.py +240 -0
- tiredize/linter/rules/tabs.py +73 -0
- tiredize/linter/rules/trailing_whitespace.py +71 -0
- tiredize/linter/rules/unicode.py +110 -0
- tiredize/linter/utils.py +263 -0
- tiredize/markdown/__init__.py +0 -0
- tiredize/markdown/types/__init__.py +0 -0
- tiredize/markdown/types/code.py +107 -0
- tiredize/markdown/types/document.py +88 -0
- tiredize/markdown/types/frontmatter.py +67 -0
- tiredize/markdown/types/header.py +122 -0
- tiredize/markdown/types/image.py +67 -0
- tiredize/markdown/types/link.py +544 -0
- tiredize/markdown/types/list.py +19 -0
- tiredize/markdown/types/quoteblock.py +79 -0
- tiredize/markdown/types/reference.py +170 -0
- tiredize/markdown/types/schema.py +195 -0
- tiredize/markdown/types/section.py +179 -0
- tiredize/markdown/types/table.py +102 -0
- tiredize/markdown/utils.py +29 -0
- tiredize/validators/__init__.py +0 -0
- tiredize/validators/frontmatter_schema.py +458 -0
- tiredize/validators/markdown_schema.py +490 -0
- tiredize-0.1.0.dist-info/METADATA +1226 -0
- tiredize-0.1.0.dist-info/RECORD +38 -0
- tiredize-0.1.0.dist-info/WHEEL +4 -0
- tiredize-0.1.0.dist-info/entry_points.txt +2 -0
- tiredize-0.1.0.dist-info/licenses/LICENSE +674 -0
tiredize/__init__.py
ADDED
|
File without changes
|
tiredize/__main__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Entry point for `python -m tiredize`.
|
|
2
|
+
|
|
3
|
+
The console script generated from `[project.scripts]` wraps
|
|
4
|
+
`tiredize.cli:main` in `sys.exit(...)`; module execution has no such
|
|
5
|
+
wrapper, so the exit status has to be raised here. Without it every
|
|
6
|
+
`-m` invocation exits `0` and the tool cannot gate CI or a pre-commit
|
|
7
|
+
hook.
|
|
8
|
+
"""
|
|
9
|
+
# Standard library
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
# Local
|
|
13
|
+
from tiredize.cli import main
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# `raise SystemExit(...)` matches the idiom at the bottom of
|
|
17
|
+
# `tiredize/cli.py`. A usage error raised by argparse inside `main()`
|
|
18
|
+
# is itself a SystemExit and unwinds through this statement untouched.
|
|
19
|
+
raise SystemExit(main())
|
tiredize/cli.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# Standard library
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from importlib.metadata import PackageNotFoundError
|
|
4
|
+
from importlib.metadata import version as metadata_version
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
import argparse
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
# Third-party
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
# Local
|
|
14
|
+
from tiredize.core_types import RuleNotFoundError
|
|
15
|
+
from tiredize.core_types import RuleResult
|
|
16
|
+
from tiredize.linter.engine import run_linter
|
|
17
|
+
from tiredize.markdown.types.document import Document
|
|
18
|
+
from tiredize.markdown.types.schema import load_schema
|
|
19
|
+
from tiredize.validators.frontmatter_schema import load_frontmatter_schema
|
|
20
|
+
from tiredize.validators.frontmatter_schema import validate \
|
|
21
|
+
as validate_frontmatter
|
|
22
|
+
from tiredize.validators.markdown_schema import AmbiguityError
|
|
23
|
+
from tiredize.validators.markdown_schema import validate
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _build_arg_parser() -> argparse.ArgumentParser:
|
|
27
|
+
parser = argparse.ArgumentParser(
|
|
28
|
+
prog="tiredize",
|
|
29
|
+
description="Validate markdown documents against user-defined "
|
|
30
|
+
"linting rules and schemas."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--rules",
|
|
35
|
+
dest="rules_path",
|
|
36
|
+
help="YAML configuration file defining linter rules.",
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--markdown-schema",
|
|
40
|
+
dest="markdown_schema_path",
|
|
41
|
+
help="YAML configuration file defining markdown schema.",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--frontmatter-schema",
|
|
45
|
+
dest="frontmatter_schema_path",
|
|
46
|
+
help="YAML configuration file defining frontmatter schema.",
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--version",
|
|
50
|
+
action="version",
|
|
51
|
+
version=f"%(prog)s {_installed_version()}",
|
|
52
|
+
help="Print the installed tiredize version and exit.",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"paths",
|
|
56
|
+
nargs="*",
|
|
57
|
+
help="Markdown files to validate.",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return parser
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _installed_version() -> str:
|
|
64
|
+
"""
|
|
65
|
+
Return the version of the installed tiredize distribution.
|
|
66
|
+
|
|
67
|
+
The version lives only in the distribution metadata, which
|
|
68
|
+
hatch-vcs derives from git tags at build time; no source file
|
|
69
|
+
holds it. When tiredize is imported from a source tree that was
|
|
70
|
+
never installed there is no metadata, and "unknown" is returned
|
|
71
|
+
so that normal runs still work and `--version` makes no number
|
|
72
|
+
up.
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
return metadata_version("tiredize")
|
|
76
|
+
except PackageNotFoundError:
|
|
77
|
+
return "unknown"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _load_yaml(path: Path) -> dict[str, Any]:
|
|
81
|
+
with path.open("r", encoding="utf-8") as f:
|
|
82
|
+
data = yaml.safe_load(f)
|
|
83
|
+
if data is None:
|
|
84
|
+
return {}
|
|
85
|
+
if not isinstance(data, dict):
|
|
86
|
+
raise ValueError(
|
|
87
|
+
f"Expected YAML mapping in {path}, "
|
|
88
|
+
f"got {type(data).__name__}."
|
|
89
|
+
)
|
|
90
|
+
return data
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _run_rules(
|
|
94
|
+
doc: Document,
|
|
95
|
+
rules_config_path: Path,
|
|
96
|
+
) -> list[RuleResult]:
|
|
97
|
+
raw_config = _load_yaml(rules_config_path)
|
|
98
|
+
|
|
99
|
+
results: list[RuleResult] = run_linter(
|
|
100
|
+
document=doc,
|
|
101
|
+
rule_configs=raw_config
|
|
102
|
+
)
|
|
103
|
+
return results
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _run_markdown_schema(doc: Document, schema_path: Path) -> list[RuleResult]:
|
|
107
|
+
schema = load_schema(schema_path.read_text(encoding="utf-8"))
|
|
108
|
+
return validate(doc, schema)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _run_frontmatter_schema(
|
|
112
|
+
doc: Document,
|
|
113
|
+
schema_path: Path) -> list[RuleResult]:
|
|
114
|
+
schema = load_frontmatter_schema(
|
|
115
|
+
schema_path.read_text(encoding="utf-8")
|
|
116
|
+
)
|
|
117
|
+
return validate_frontmatter(doc, schema)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def main(argv: list[str] | None = None) -> int:
|
|
121
|
+
"""
|
|
122
|
+
Validate every requested path and return the process exit status.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
0 - every path was loaded and produced no findings.
|
|
126
|
+
1 - at least one path produced findings, or a runtime error
|
|
127
|
+
occurred.
|
|
128
|
+
2 - usage error: no paths, or no configuration flag.
|
|
129
|
+
|
|
130
|
+
Findings do not stop the run: every path is processed, every
|
|
131
|
+
finding is reported, and the status is 1 at the end. Runtime
|
|
132
|
+
errors do stop it: the first one prints to stderr and the status
|
|
133
|
+
is returned immediately, leaving later paths unprocessed.
|
|
134
|
+
|
|
135
|
+
Argparse's own errors raise SystemExit(2) from inside this
|
|
136
|
+
function rather than returning.
|
|
137
|
+
"""
|
|
138
|
+
parser = _build_arg_parser()
|
|
139
|
+
args = parser.parse_args(argv)
|
|
140
|
+
|
|
141
|
+
if not args.paths or not (
|
|
142
|
+
args.rules_path or args.markdown_schema_path or
|
|
143
|
+
args.frontmatter_schema_path):
|
|
144
|
+
parser.print_usage(sys.stderr)
|
|
145
|
+
print(
|
|
146
|
+
"error: at least one of --rules, --markdown-schema, or "
|
|
147
|
+
"--frontmatter-schema must be provided, and at least one path.",
|
|
148
|
+
file=sys.stderr,
|
|
149
|
+
)
|
|
150
|
+
return 2
|
|
151
|
+
|
|
152
|
+
exit_code = 0
|
|
153
|
+
|
|
154
|
+
for path_str in args.paths:
|
|
155
|
+
doc = Document()
|
|
156
|
+
try:
|
|
157
|
+
doc.load(path=Path(path_str))
|
|
158
|
+
except FileNotFoundError as exc:
|
|
159
|
+
# A runtime error aborts the run. Findings continue to the
|
|
160
|
+
# next path; errors do not, so any remaining paths are
|
|
161
|
+
# left unprocessed.
|
|
162
|
+
print(
|
|
163
|
+
f"error: {exc}",
|
|
164
|
+
file=sys.stderr,
|
|
165
|
+
)
|
|
166
|
+
return 1
|
|
167
|
+
|
|
168
|
+
all_results: list[RuleResult] = []
|
|
169
|
+
|
|
170
|
+
if args.rules_path:
|
|
171
|
+
try:
|
|
172
|
+
all_results.extend(
|
|
173
|
+
_run_rules(
|
|
174
|
+
doc, Path(args.rules_path)
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
except (
|
|
178
|
+
RuleNotFoundError,
|
|
179
|
+
FileNotFoundError,
|
|
180
|
+
ValueError,
|
|
181
|
+
yaml.YAMLError,
|
|
182
|
+
) as exc:
|
|
183
|
+
print(
|
|
184
|
+
f"error: {exc}",
|
|
185
|
+
file=sys.stderr,
|
|
186
|
+
)
|
|
187
|
+
return 1
|
|
188
|
+
|
|
189
|
+
if args.markdown_schema_path:
|
|
190
|
+
try:
|
|
191
|
+
all_results.extend(
|
|
192
|
+
_run_markdown_schema(
|
|
193
|
+
doc, Path(args.markdown_schema_path)
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
except (
|
|
197
|
+
ValueError,
|
|
198
|
+
AmbiguityError,
|
|
199
|
+
FileNotFoundError,
|
|
200
|
+
yaml.YAMLError,
|
|
201
|
+
) as exc:
|
|
202
|
+
print(
|
|
203
|
+
f"error: {exc}",
|
|
204
|
+
file=sys.stderr,
|
|
205
|
+
)
|
|
206
|
+
return 1
|
|
207
|
+
|
|
208
|
+
if args.frontmatter_schema_path:
|
|
209
|
+
try:
|
|
210
|
+
all_results.extend(
|
|
211
|
+
_run_frontmatter_schema(
|
|
212
|
+
doc, Path(args.frontmatter_schema_path)
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
except (
|
|
216
|
+
ValueError,
|
|
217
|
+
FileNotFoundError,
|
|
218
|
+
yaml.YAMLError,
|
|
219
|
+
) as exc:
|
|
220
|
+
print(
|
|
221
|
+
f"error: {exc}",
|
|
222
|
+
file=sys.stderr,
|
|
223
|
+
)
|
|
224
|
+
return 1
|
|
225
|
+
|
|
226
|
+
for res in all_results:
|
|
227
|
+
pos = res.position
|
|
228
|
+
line, col = doc.line_col(pos.offset)
|
|
229
|
+
print(f"{doc.path}:{line}:{col}: [{res.rule_id}] {res.message}")
|
|
230
|
+
if all_results:
|
|
231
|
+
exit_code = 1
|
|
232
|
+
else:
|
|
233
|
+
print(f"{doc.path}: no issues found.")
|
|
234
|
+
return exit_code
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
if __name__ == "__main__":
|
|
238
|
+
raise SystemExit(main())
|
tiredize/core_types.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Standard library
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RuleNotFoundError(Exception):
|
|
7
|
+
"""Raised when a rule ID does not match any discovered rule."""
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Position:
|
|
13
|
+
offset: int
|
|
14
|
+
length: int
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=False)
|
|
18
|
+
class RuleResult:
|
|
19
|
+
position: Position
|
|
20
|
+
rule_id: str | None
|
|
21
|
+
message: str
|
|
File without changes
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Standard library
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from dataclasses import replace
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
# Local
|
|
7
|
+
from tiredize.core_types import RuleNotFoundError
|
|
8
|
+
from tiredize.core_types import RuleResult
|
|
9
|
+
from tiredize.linter.rules import Rule
|
|
10
|
+
from tiredize.linter.rules import discover_rules
|
|
11
|
+
from tiredize.markdown.types.document import Document
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _select_rules(
|
|
15
|
+
rules: dict[str, Rule],
|
|
16
|
+
rule_configs: dict[str, dict[str, Any]] | None,
|
|
17
|
+
) -> dict[str, dict[str, Rule | dict[str, Any]]]:
|
|
18
|
+
"""
|
|
19
|
+
Filter rules by id.
|
|
20
|
+
|
|
21
|
+
If rule_configs is None, no rules are returned.
|
|
22
|
+
"""
|
|
23
|
+
if rule_configs is None:
|
|
24
|
+
return dict()
|
|
25
|
+
|
|
26
|
+
enabled_set: dict[str, dict[str, dict[str, Any] | Rule]] = dict()
|
|
27
|
+
for rule_id in rule_configs.keys():
|
|
28
|
+
if rule_id not in rules:
|
|
29
|
+
raise RuleNotFoundError(f"Unknown rule id: {rule_id}")
|
|
30
|
+
if rule_id not in enabled_set:
|
|
31
|
+
enabled_set[rule_id] = dict()
|
|
32
|
+
rule_config = rule_configs[rule_id]
|
|
33
|
+
enabled_set[rule_id]["rule"] = rules[rule_id]
|
|
34
|
+
enabled_set[rule_id]["config"] = rule_config
|
|
35
|
+
|
|
36
|
+
return enabled_set
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run_linter(
|
|
40
|
+
document: Document,
|
|
41
|
+
rule_configs: dict[str, dict[str, Any]] | None = None
|
|
42
|
+
) -> list[RuleResult]:
|
|
43
|
+
"""
|
|
44
|
+
Run lint rules against a document and return normalized results.
|
|
45
|
+
|
|
46
|
+
- document: the parsed Document to lint.
|
|
47
|
+
- rule_configs: mapping of rule_id to configuration dictionary
|
|
48
|
+
|
|
49
|
+
Note: Rules without an entry are disabled.
|
|
50
|
+
"""
|
|
51
|
+
all_rules = discover_rules()
|
|
52
|
+
active_rules = _select_rules(all_rules, rule_configs)
|
|
53
|
+
|
|
54
|
+
all_results: list[RuleResult] = []
|
|
55
|
+
for rule_id in active_rules.keys():
|
|
56
|
+
rule_config = active_rules[rule_id]["config"]
|
|
57
|
+
if not isinstance(rule_config, dict):
|
|
58
|
+
raise ValueError(
|
|
59
|
+
f"Invalid configuration for rule {rule_id}: {rule_config}"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
rule = active_rules[rule_id]["rule"]
|
|
63
|
+
if not isinstance(rule, Rule):
|
|
64
|
+
raise ValueError(
|
|
65
|
+
f"Invalid rule object for {rule_id}: {rule}"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
raw_results = rule.func(document, rule_config)
|
|
69
|
+
for res in raw_results:
|
|
70
|
+
normalized = replace(res, rule_id=rule_id)
|
|
71
|
+
all_results.append(normalized)
|
|
72
|
+
|
|
73
|
+
return all_results
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Standard library
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
from typing import Callable
|
|
6
|
+
import importlib
|
|
7
|
+
import inspect
|
|
8
|
+
import pkgutil
|
|
9
|
+
|
|
10
|
+
# Local
|
|
11
|
+
from tiredize.core_types import RuleResult
|
|
12
|
+
from tiredize.markdown.types.document import Document
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
RuleFunc = Callable[[Document, dict[str, Any]], list[RuleResult]]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Rule:
|
|
20
|
+
"""
|
|
21
|
+
Class containing a single lint rule.
|
|
22
|
+
"""
|
|
23
|
+
id: str
|
|
24
|
+
func: RuleFunc
|
|
25
|
+
description: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _iter_rule_modules(package_name: str) -> list[str]:
|
|
29
|
+
"""
|
|
30
|
+
Return fully qualified module names for all rule modules.
|
|
31
|
+
|
|
32
|
+
A rule module is any non private Python module directly under this package.
|
|
33
|
+
"""
|
|
34
|
+
package = importlib.import_module(package_name)
|
|
35
|
+
if not hasattr(package, "__path__"):
|
|
36
|
+
# Not a package, nothing to iterate
|
|
37
|
+
return []
|
|
38
|
+
|
|
39
|
+
module_names: list[str] = []
|
|
40
|
+
|
|
41
|
+
prefix = package.__name__ + "."
|
|
42
|
+
for _, name, ispkg in pkgutil.iter_modules(package.__path__, prefix):
|
|
43
|
+
# Skip subpackages for now, only plain modules
|
|
44
|
+
if ispkg:
|
|
45
|
+
continue
|
|
46
|
+
|
|
47
|
+
short_name = name.rsplit(".", 1)[-1]
|
|
48
|
+
if short_name.startswith("_"):
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
module_names.append(name)
|
|
52
|
+
|
|
53
|
+
return module_names
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _is_rule_function(name: str, obj: Any) -> bool:
|
|
57
|
+
"""
|
|
58
|
+
A rule is any callable named 'validate'.
|
|
59
|
+
"""
|
|
60
|
+
return callable(obj) and name == "validate"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _rule_id(module_name: str, func_name: str) -> str:
|
|
64
|
+
"""
|
|
65
|
+
Compute the rule id from module and function names.
|
|
66
|
+
|
|
67
|
+
Example:
|
|
68
|
+
module_name = 'tiredize.linter.rules.whitespace'
|
|
69
|
+
func_name = 'validate_newline_at_eof'
|
|
70
|
+
result = 'whitespace.validate_newline_at_eof'
|
|
71
|
+
"""
|
|
72
|
+
short_module = module_name.rsplit(".", 1)[-1]
|
|
73
|
+
return f"{short_module}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def discover_rules(package: str | None = None) -> dict[str, Rule]:
|
|
77
|
+
"""
|
|
78
|
+
Import all rule modules and return a mapping of rule_id to definitions.
|
|
79
|
+
|
|
80
|
+
If package is None, rules are discovered under tiredize.linter.rules.
|
|
81
|
+
If package is provided, it must be the dotted name of a Python package
|
|
82
|
+
that contains rule modules.
|
|
83
|
+
"""
|
|
84
|
+
package_name = package or __name__
|
|
85
|
+
rules: dict[str, Rule] = {}
|
|
86
|
+
|
|
87
|
+
module_names: list[str] = _iter_rule_modules(package_name)
|
|
88
|
+
for module_name in module_names:
|
|
89
|
+
module = importlib.import_module(module_name)
|
|
90
|
+
|
|
91
|
+
for name, obj in inspect.getmembers(module):
|
|
92
|
+
if not _is_rule_function(name, obj):
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
func: RuleFunc = obj
|
|
96
|
+
rule_id = _rule_id(module_name, name)
|
|
97
|
+
description = inspect.getdoc(func)
|
|
98
|
+
if description:
|
|
99
|
+
description = description.strip()
|
|
100
|
+
|
|
101
|
+
rules[rule_id] = Rule(
|
|
102
|
+
id=rule_id,
|
|
103
|
+
func=func,
|
|
104
|
+
description=description,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return rules
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Standard library
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
_ELEMENT_LABELS = {
|
|
5
|
+
"autolink": "Autolink",
|
|
6
|
+
"autolink_extended": "Extended autolink",
|
|
7
|
+
"code_block": "Fenced code block",
|
|
8
|
+
"code_inline": "Inline code",
|
|
9
|
+
"header": "Header",
|
|
10
|
+
"image_inline": "Inline image",
|
|
11
|
+
"image_reference": "Reference-style image",
|
|
12
|
+
"link_inline": "Inline link",
|
|
13
|
+
"link_reference": "Reference-style link",
|
|
14
|
+
"quoteblock": "Blockquote",
|
|
15
|
+
"reference_definition": "Reference definition",
|
|
16
|
+
"table": "Table",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
_ELEMENT_MAP = {
|
|
20
|
+
"autolink": lambda s: s.autolinks,
|
|
21
|
+
"autolink_extended": lambda s: s.autolinks_extended,
|
|
22
|
+
"code_block": lambda s: s.code_block,
|
|
23
|
+
"code_inline": lambda s: s.code_inline,
|
|
24
|
+
"header": lambda s: [s.header] if s.header.position.length > 0 else [],
|
|
25
|
+
"image_inline": lambda s: s.images_inline,
|
|
26
|
+
"image_reference": lambda s: s.images_reference,
|
|
27
|
+
"link_inline": lambda s: s.links_inline,
|
|
28
|
+
"link_reference": lambda s: s.links_reference,
|
|
29
|
+
"quoteblock": lambda s: s.quoteblocks,
|
|
30
|
+
"reference_definition": lambda s: s.reference_definitions,
|
|
31
|
+
"table": lambda s: s.tables,
|
|
32
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Standard library
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
# Local
|
|
6
|
+
from tiredize.core_types import RuleResult
|
|
7
|
+
from tiredize.linter.rules._elements import _ELEMENT_LABELS
|
|
8
|
+
from tiredize.linter.rules._elements import _ELEMENT_MAP
|
|
9
|
+
from tiredize.linter.utils import get_config_list
|
|
10
|
+
from tiredize.linter.utils import validate_config
|
|
11
|
+
from tiredize.markdown.types.document import Document
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Configuration keys this rule accepts, and the subset it requires.
|
|
15
|
+
# `disallow` is required because the rule inspects nothing without
|
|
16
|
+
# it: enabling `elements` with no `disallow` would be a no-op.
|
|
17
|
+
_RULE_ID = "elements"
|
|
18
|
+
_ALLOWED_KEYS = {
|
|
19
|
+
"disallow": "list",
|
|
20
|
+
}
|
|
21
|
+
_REQUIRED_KEYS = ("disallow",)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def validate(
|
|
25
|
+
document: Document,
|
|
26
|
+
config: dict[str, Any],
|
|
27
|
+
) -> list[RuleResult]:
|
|
28
|
+
"""
|
|
29
|
+
Validate that the document does not contain disallowed element types.
|
|
30
|
+
|
|
31
|
+
Configuration:
|
|
32
|
+
disallow: list[str] - Element types that must not appear in the
|
|
33
|
+
document. Required; an empty list allows everything.
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
ValueError: the configuration names a key this rule does not
|
|
37
|
+
accept, gives an accepted key a wrong-typed value, omits
|
|
38
|
+
a required key, or names an unknown element in disallow.
|
|
39
|
+
"""
|
|
40
|
+
validate_config(config, _ALLOWED_KEYS, _REQUIRED_KEYS, _RULE_ID)
|
|
41
|
+
|
|
42
|
+
# validate_config has confirmed disallow is present and a list.
|
|
43
|
+
disallow = get_config_list(config, "disallow")
|
|
44
|
+
if not disallow:
|
|
45
|
+
return []
|
|
46
|
+
|
|
47
|
+
for name in disallow:
|
|
48
|
+
if not isinstance(name, str):
|
|
49
|
+
raise ValueError(
|
|
50
|
+
"disallow entries must be strings, got "
|
|
51
|
+
f"{type(name).__name__!r}: {name!r}"
|
|
52
|
+
)
|
|
53
|
+
if name not in _ELEMENT_MAP:
|
|
54
|
+
raise ValueError(f"Unknown element name in disallow: '{name}'")
|
|
55
|
+
|
|
56
|
+
results: list[RuleResult] = []
|
|
57
|
+
for section in document.sections:
|
|
58
|
+
for name in disallow:
|
|
59
|
+
label = _ELEMENT_LABELS[name]
|
|
60
|
+
for elem in _ELEMENT_MAP[name](section):
|
|
61
|
+
if elem.position.length == 0:
|
|
62
|
+
continue
|
|
63
|
+
results.append(
|
|
64
|
+
RuleResult(
|
|
65
|
+
message=f"{label} is not allowed.",
|
|
66
|
+
position=elem.position,
|
|
67
|
+
rule_id=None,
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
return results
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Standard library
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import bisect
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
# Local
|
|
7
|
+
from tiredize.core_types import Position
|
|
8
|
+
from tiredize.core_types import RuleResult
|
|
9
|
+
from tiredize.linter.rules._elements import _ELEMENT_MAP
|
|
10
|
+
from tiredize.linter.utils import get_config_int
|
|
11
|
+
from tiredize.linter.utils import get_config_list
|
|
12
|
+
from tiredize.linter.utils import validate_config
|
|
13
|
+
from tiredize.markdown.types.document import Document
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Configuration keys this rule accepts, and the subset it requires.
|
|
17
|
+
# `maximum_length` is required because the rule measures nothing
|
|
18
|
+
# without it; `exclude` has a working default of "exclude nothing".
|
|
19
|
+
_RULE_ID = "line_length"
|
|
20
|
+
_ALLOWED_KEYS = {
|
|
21
|
+
"exclude": "list",
|
|
22
|
+
"maximum_length": "int",
|
|
23
|
+
}
|
|
24
|
+
_REQUIRED_KEYS = ("maximum_length",)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _build_excluded_ranges(
|
|
28
|
+
document: Document,
|
|
29
|
+
exclude: list[str],
|
|
30
|
+
) -> list[tuple[int, int]]:
|
|
31
|
+
for name in exclude:
|
|
32
|
+
if not isinstance(name, str):
|
|
33
|
+
raise ValueError(
|
|
34
|
+
"exclude entries must be strings, got "
|
|
35
|
+
f"{type(name).__name__!r}: {name!r}"
|
|
36
|
+
)
|
|
37
|
+
if name not in _ELEMENT_MAP:
|
|
38
|
+
raise ValueError(f"Unknown element name in exclude: '{name}'")
|
|
39
|
+
|
|
40
|
+
raw: list[tuple[int, int]] = []
|
|
41
|
+
for section in document.sections:
|
|
42
|
+
for name in exclude:
|
|
43
|
+
for elem in _ELEMENT_MAP[name](section):
|
|
44
|
+
start = elem.position.offset
|
|
45
|
+
end = start + elem.position.length
|
|
46
|
+
if end > start:
|
|
47
|
+
raw.append((start, end))
|
|
48
|
+
|
|
49
|
+
if not raw:
|
|
50
|
+
return []
|
|
51
|
+
|
|
52
|
+
raw.sort()
|
|
53
|
+
merged: list[tuple[int, int]] = [raw[0]]
|
|
54
|
+
for start, end in raw[1:]:
|
|
55
|
+
if start <= merged[-1][1]:
|
|
56
|
+
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
|
|
57
|
+
else:
|
|
58
|
+
merged.append((start, end))
|
|
59
|
+
return merged
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def validate(
|
|
63
|
+
document: Document,
|
|
64
|
+
config: dict[str, Any],
|
|
65
|
+
) -> list[RuleResult]:
|
|
66
|
+
"""
|
|
67
|
+
Validate document meets line length requirements.
|
|
68
|
+
|
|
69
|
+
Configuration:
|
|
70
|
+
maximum_length: int - The maximum allowed line length.
|
|
71
|
+
Required.
|
|
72
|
+
exclude: list[str] - Element types whose lines are exempt from
|
|
73
|
+
the limit. Optional; defaults to no exemptions.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
ValueError: the configuration names a key this rule does not
|
|
77
|
+
accept, gives an accepted key a wrong-typed value, omits
|
|
78
|
+
a required key, or names an unknown element in exclude.
|
|
79
|
+
"""
|
|
80
|
+
validate_config(config, _ALLOWED_KEYS, _REQUIRED_KEYS, _RULE_ID)
|
|
81
|
+
|
|
82
|
+
# validate_config has confirmed maximum_length is present and an
|
|
83
|
+
# int, so the accessor cannot return None here.
|
|
84
|
+
maximum_length = get_config_int(config, "maximum_length")
|
|
85
|
+
|
|
86
|
+
exclude = get_config_list(config, "exclude") or []
|
|
87
|
+
excluded_ranges = _build_excluded_ranges(document, exclude)
|
|
88
|
+
exc_starts = [s for s, _ in excluded_ranges]
|
|
89
|
+
|
|
90
|
+
results: list[RuleResult] = []
|
|
91
|
+
text = document.string
|
|
92
|
+
cursor = 0
|
|
93
|
+
|
|
94
|
+
for line in text.splitlines(keepends=True):
|
|
95
|
+
line_end = cursor + len(line)
|
|
96
|
+
|
|
97
|
+
idx = bisect.bisect_right(exc_starts, cursor) - 1
|
|
98
|
+
line_excluded = (
|
|
99
|
+
idx >= 0 and excluded_ranges[idx][1] > cursor
|
|
100
|
+
) or (
|
|
101
|
+
idx + 1 < len(excluded_ranges)
|
|
102
|
+
and excluded_ranges[idx + 1][0] < line_end
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if not line_excluded:
|
|
106
|
+
line_text = line
|
|
107
|
+
if line.endswith("\n"):
|
|
108
|
+
line_text = line[:-1]
|
|
109
|
+
if line_text.endswith("\r"):
|
|
110
|
+
line_text = line_text[:-1]
|
|
111
|
+
|
|
112
|
+
line_length = len(line_text)
|
|
113
|
+
if line_length > maximum_length:
|
|
114
|
+
overflow = line_length - maximum_length
|
|
115
|
+
position = Position(
|
|
116
|
+
offset=cursor + maximum_length,
|
|
117
|
+
length=overflow,
|
|
118
|
+
)
|
|
119
|
+
results.append(
|
|
120
|
+
RuleResult(
|
|
121
|
+
message=(
|
|
122
|
+
f"Line exceeds maximum length of {maximum_length} "
|
|
123
|
+
f"({line_length} found)."
|
|
124
|
+
),
|
|
125
|
+
position=position,
|
|
126
|
+
rule_id=None,
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
cursor += len(line)
|
|
131
|
+
return results
|