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.
Files changed (38) hide show
  1. tiredize/__init__.py +0 -0
  2. tiredize/__main__.py +19 -0
  3. tiredize/cli.py +238 -0
  4. tiredize/core_types.py +21 -0
  5. tiredize/linter/__init__.py +0 -0
  6. tiredize/linter/engine.py +73 -0
  7. tiredize/linter/rules/__init__.py +107 -0
  8. tiredize/linter/rules/_elements.py +32 -0
  9. tiredize/linter/rules/elements.py +71 -0
  10. tiredize/linter/rules/line_length.py +131 -0
  11. tiredize/linter/rules/links.py +240 -0
  12. tiredize/linter/rules/tabs.py +73 -0
  13. tiredize/linter/rules/trailing_whitespace.py +71 -0
  14. tiredize/linter/rules/unicode.py +110 -0
  15. tiredize/linter/utils.py +263 -0
  16. tiredize/markdown/__init__.py +0 -0
  17. tiredize/markdown/types/__init__.py +0 -0
  18. tiredize/markdown/types/code.py +107 -0
  19. tiredize/markdown/types/document.py +88 -0
  20. tiredize/markdown/types/frontmatter.py +67 -0
  21. tiredize/markdown/types/header.py +122 -0
  22. tiredize/markdown/types/image.py +67 -0
  23. tiredize/markdown/types/link.py +544 -0
  24. tiredize/markdown/types/list.py +19 -0
  25. tiredize/markdown/types/quoteblock.py +79 -0
  26. tiredize/markdown/types/reference.py +170 -0
  27. tiredize/markdown/types/schema.py +195 -0
  28. tiredize/markdown/types/section.py +179 -0
  29. tiredize/markdown/types/table.py +102 -0
  30. tiredize/markdown/utils.py +29 -0
  31. tiredize/validators/__init__.py +0 -0
  32. tiredize/validators/frontmatter_schema.py +458 -0
  33. tiredize/validators/markdown_schema.py +490 -0
  34. tiredize-0.1.0.dist-info/METADATA +1226 -0
  35. tiredize-0.1.0.dist-info/RECORD +38 -0
  36. tiredize-0.1.0.dist-info/WHEEL +4 -0
  37. tiredize-0.1.0.dist-info/entry_points.txt +2 -0
  38. 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