linnl 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.
- linnl/__init__.py +1 -0
- linnl/agents.py +60 -0
- linnl/cli.py +215 -0
- linnl/config.py +85 -0
- linnl/constants.py +5 -0
- linnl/document.py +138 -0
- linnl/judge.py +228 -0
- linnl/linter.py +299 -0
- linnl/logconfig.py +63 -0
- linnl/plugins.py +45 -0
- linnl/resources/config.toml +110 -0
- linnl/resources/templates/prompt.md.j2 +20 -0
- linnl/rules/__init__.py +24 -0
- linnl/rules/cliche_rules.py +485 -0
- linnl/rules/code_rules.py +101 -0
- linnl/rules/pattern_rules.py +46 -0
- linnl/rules/rules.py +245 -0
- linnl/rules/wikipedia_rules.py +159 -0
- linnl/violations.py +83 -0
- linnl-0.1.0.dist-info/METADATA +305 -0
- linnl-0.1.0.dist-info/RECORD +24 -0
- linnl-0.1.0.dist-info/WHEEL +4 -0
- linnl-0.1.0.dist-info/entry_points.txt +3 -0
- linnl-0.1.0.dist-info/licenses/LICENSE +21 -0
linnl/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Lint prose against style rules, with an LLM judging."""
|
linnl/agents.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Install linnl commands for supported coding agents."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Agent(StrEnum):
|
|
11
|
+
"""Select the model backend for model-judged rules."""
|
|
12
|
+
|
|
13
|
+
CLAUDE = "claude"
|
|
14
|
+
CODEX = "codex"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
DEFAULT_AGENT_MODELS: dict[Agent, str] = {
|
|
18
|
+
Agent.CLAUDE: "claude-opus-5",
|
|
19
|
+
Agent.CODEX: "gpt-5.6-luna",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
LINNL_COMMAND = """---
|
|
24
|
+
description: Check your most recent response with linnl.
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
Check your most recent assistant response with linnl.
|
|
28
|
+
|
|
29
|
+
Extract only the user-facing text from your immediately preceding response.
|
|
30
|
+
Do not include tool calls, this command, or your current reasoning. Pipe that
|
|
31
|
+
text to `linnl lint` through standard input and report the result.
|
|
32
|
+
|
|
33
|
+
If linnl reports violations, explain them and provide a corrected version. If it
|
|
34
|
+
reports no violations, say that the response is clean.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
AGENT_COMMAND_DIRECTORIES: dict[Agent, tuple[str, str]] = {
|
|
38
|
+
Agent.CLAUDE: (".claude", "commands"),
|
|
39
|
+
Agent.CODEX: (".codex", "prompts"),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def install_hook(agent: Agent, *, local: bool) -> Path:
|
|
44
|
+
"""Install the linnl command for an agent and return its path."""
|
|
45
|
+
agent_directory, command_directory = AGENT_COMMAND_DIRECTORIES[agent]
|
|
46
|
+
base_directory = Path.cwd() if local else Path.home()
|
|
47
|
+
command_path = base_directory / agent_directory / command_directory / "linnl.md"
|
|
48
|
+
command_path.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
|
|
50
|
+
if command_path.exists():
|
|
51
|
+
existing_command = command_path.read_text(encoding="utf-8")
|
|
52
|
+
if existing_command != LINNL_COMMAND:
|
|
53
|
+
logger.info("linnl command out of date, will update at %s", command_path)
|
|
54
|
+
else:
|
|
55
|
+
logger.info("linnl command already installed at %s", command_path)
|
|
56
|
+
return command_path
|
|
57
|
+
|
|
58
|
+
command_path.write_text(LINNL_COMMAND, encoding="utf-8")
|
|
59
|
+
logger.info("Installed linnl command at %s", command_path)
|
|
60
|
+
return command_path
|
linnl/cli.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Parse the command line and run the linter."""
|
|
2
|
+
|
|
3
|
+
import pathlib as pl
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from linnl.agents import Agent
|
|
10
|
+
from linnl.agents import install_hook as install_agent_hook
|
|
11
|
+
from linnl.config import SHIPPED_CONFIG_FILE, discover_config_file
|
|
12
|
+
from linnl.linter import Linter
|
|
13
|
+
from linnl.logconfig import (
|
|
14
|
+
DEFAULT_PRETTY,
|
|
15
|
+
DEFAULT_STRUCTURED,
|
|
16
|
+
DEFAULT_VERBOSE,
|
|
17
|
+
config_logging,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(
|
|
21
|
+
add_completion=True,
|
|
22
|
+
pretty_exceptions_show_locals=False,
|
|
23
|
+
pretty_exceptions_short=True,
|
|
24
|
+
pretty_exceptions_enable=False,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
PathsArgument = Annotated[
|
|
29
|
+
list[pl.Path] | None,
|
|
30
|
+
typer.Argument(
|
|
31
|
+
help="Files or directories to lint. Reads stdin when none are given."
|
|
32
|
+
),
|
|
33
|
+
]
|
|
34
|
+
ConfigOption = Annotated[
|
|
35
|
+
pl.Path | None,
|
|
36
|
+
typer.Option(
|
|
37
|
+
help="Config file. Discovered from the working directory when not given."
|
|
38
|
+
),
|
|
39
|
+
]
|
|
40
|
+
SelectOption = Annotated[
|
|
41
|
+
list[str] | None,
|
|
42
|
+
typer.Option(
|
|
43
|
+
help="Enable exactly these rules or prefixes, replacing the config's selection."
|
|
44
|
+
),
|
|
45
|
+
]
|
|
46
|
+
ExtendSelectOption = Annotated[
|
|
47
|
+
list[str] | None,
|
|
48
|
+
typer.Option(help="Enable these rules or prefixes on top of the selection."),
|
|
49
|
+
]
|
|
50
|
+
IgnoreOption = Annotated[
|
|
51
|
+
list[str] | None,
|
|
52
|
+
typer.Option(help="Disable these rules or prefixes."),
|
|
53
|
+
]
|
|
54
|
+
ModelOption = Annotated[
|
|
55
|
+
str | None, typer.Option(help="Model alias or id the judge calls.")
|
|
56
|
+
]
|
|
57
|
+
AgentOption = Annotated[
|
|
58
|
+
Agent | None, typer.Option(help="Model agent to use for model-judged rules.")
|
|
59
|
+
]
|
|
60
|
+
MaxConcurrencyOption = Annotated[
|
|
61
|
+
int | None,
|
|
62
|
+
typer.Option(min=1, help="How many documents are linted concurrently."),
|
|
63
|
+
]
|
|
64
|
+
IncludeExtensionOption = Annotated[
|
|
65
|
+
list[str] | None,
|
|
66
|
+
typer.Option(
|
|
67
|
+
"--include-extension",
|
|
68
|
+
help="Extensions linted when a directory is given, replacing the config's.",
|
|
69
|
+
),
|
|
70
|
+
]
|
|
71
|
+
IgnoreCodeBlocksOption = Annotated[
|
|
72
|
+
bool | None,
|
|
73
|
+
typer.Option(
|
|
74
|
+
"--ignore-code-blocks/--lint-code-blocks",
|
|
75
|
+
help="Ignore Markdown and reStructuredText code blocks.",
|
|
76
|
+
),
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.callback()
|
|
81
|
+
def main(
|
|
82
|
+
verbose: int = typer.Option(
|
|
83
|
+
DEFAULT_VERBOSE,
|
|
84
|
+
"--verbose",
|
|
85
|
+
"-v",
|
|
86
|
+
count=True,
|
|
87
|
+
help="Verbosity level. Pass more than once for more logging.",
|
|
88
|
+
),
|
|
89
|
+
pretty: bool = typer.Option(
|
|
90
|
+
DEFAULT_PRETTY,
|
|
91
|
+
"--pretty",
|
|
92
|
+
help="Pretty-print logs with colors.",
|
|
93
|
+
),
|
|
94
|
+
structured: bool = typer.Option(
|
|
95
|
+
DEFAULT_STRUCTURED,
|
|
96
|
+
"--structured/--unstructured",
|
|
97
|
+
"-s/-u",
|
|
98
|
+
help="Output structured (JSON) logs.",
|
|
99
|
+
),
|
|
100
|
+
) -> None:
|
|
101
|
+
"""linnl, a prose linter."""
|
|
102
|
+
config_logging(verbose=verbose, pretty=pretty, structured=structured)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.command()
|
|
106
|
+
def lint(
|
|
107
|
+
paths: PathsArgument = None,
|
|
108
|
+
config_file: ConfigOption = None,
|
|
109
|
+
select: SelectOption = None,
|
|
110
|
+
extend_select: ExtendSelectOption = None,
|
|
111
|
+
ignore: IgnoreOption = None,
|
|
112
|
+
model: ModelOption = None,
|
|
113
|
+
agent: AgentOption = None,
|
|
114
|
+
max_concurrency: MaxConcurrencyOption = None,
|
|
115
|
+
include_extensions: IncludeExtensionOption = None,
|
|
116
|
+
ignore_code_blocks: IgnoreCodeBlocksOption = None,
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Lint files and print the violations. Exits with 1 when any is reported."""
|
|
119
|
+
try:
|
|
120
|
+
if config_file is None:
|
|
121
|
+
config_file = discover_config_file(pl.Path.cwd())
|
|
122
|
+
|
|
123
|
+
linter = Linter.from_config(
|
|
124
|
+
config_file,
|
|
125
|
+
select=select,
|
|
126
|
+
extend_select=extend_select,
|
|
127
|
+
ignore=ignore,
|
|
128
|
+
model=model,
|
|
129
|
+
agent=agent,
|
|
130
|
+
max_concurrency=max_concurrency,
|
|
131
|
+
include_extensions=include_extensions,
|
|
132
|
+
ignore_code_blocks=ignore_code_blocks,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if paths is None:
|
|
136
|
+
if sys.stdin.isatty():
|
|
137
|
+
raise typer.BadParameter("no paths given and nothing piped to stdin")
|
|
138
|
+
|
|
139
|
+
violations = linter.lint_text(sys.stdin.read())
|
|
140
|
+
else:
|
|
141
|
+
violations = linter.lint_paths(paths)
|
|
142
|
+
except ExceptionGroup as error:
|
|
143
|
+
typer.echo(f"Error: {error.exceptions[0]}", err=True)
|
|
144
|
+
raise typer.Exit(code=2) from None
|
|
145
|
+
except (OSError, RuntimeError, TypeError, ValueError, typer.BadParameter) as error:
|
|
146
|
+
typer.echo(f"Error: {error}", err=True)
|
|
147
|
+
raise typer.Exit(code=2) from None
|
|
148
|
+
|
|
149
|
+
if len(violations) == 0:
|
|
150
|
+
print("No violations found.")
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
print(violations)
|
|
154
|
+
|
|
155
|
+
file_count = len(set(v.path for v in violations))
|
|
156
|
+
file_label = "file" if file_count == 1 else "files"
|
|
157
|
+
print(f"Found {len(violations)} violations across {file_count} {file_label}.")
|
|
158
|
+
|
|
159
|
+
raise typer.Exit(code=1)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@app.command(name="rules")
|
|
163
|
+
def list_rules(
|
|
164
|
+
config_file: ConfigOption = None,
|
|
165
|
+
select: SelectOption = None,
|
|
166
|
+
extend_select: ExtendSelectOption = None,
|
|
167
|
+
ignore: IgnoreOption = None,
|
|
168
|
+
) -> None:
|
|
169
|
+
"""Print the rule book with each rule's on/off state and who checks it."""
|
|
170
|
+
if config_file is None:
|
|
171
|
+
config_file = discover_config_file(pl.Path.cwd())
|
|
172
|
+
|
|
173
|
+
linter = Linter.from_config(
|
|
174
|
+
config_file,
|
|
175
|
+
select=select,
|
|
176
|
+
extend_select=extend_select,
|
|
177
|
+
ignore=ignore,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
print(linter.rules)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@app.command(name="config")
|
|
184
|
+
def print_shipped_config() -> None:
|
|
185
|
+
"""Print the shipped config, to copy and edit: linnl config > linnl.toml."""
|
|
186
|
+
print(SHIPPED_CONFIG_FILE.read_text(encoding="utf-8"), end="")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@app.command(name="plugins")
|
|
190
|
+
def list_plugins(config_file: ConfigOption = None) -> None:
|
|
191
|
+
"""List the rule packages enabled by the active configuration."""
|
|
192
|
+
if config_file is None:
|
|
193
|
+
config_file = discover_config_file(pl.Path.cwd())
|
|
194
|
+
|
|
195
|
+
linter = Linter.from_config(config_file)
|
|
196
|
+
|
|
197
|
+
if len(linter.config.plugins) == 0:
|
|
198
|
+
print("No linnl plugins enabled.")
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
print(linter.config.plugins)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@app.command()
|
|
205
|
+
def install_hook(
|
|
206
|
+
agent: Agent,
|
|
207
|
+
local: bool = typer.Option(
|
|
208
|
+
False,
|
|
209
|
+
"--local/--global",
|
|
210
|
+
help="Install in the current project or in your home directory.",
|
|
211
|
+
),
|
|
212
|
+
) -> None:
|
|
213
|
+
"""Install linnl as hook for your coding agent."""
|
|
214
|
+
command_path = install_agent_hook(agent, local=local)
|
|
215
|
+
typer.echo(f"Installed linnl command at {command_path}")
|
linnl/config.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Configuration for the linter."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import tomllib
|
|
6
|
+
from importlib import resources
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
LINNL_CONFIG_FILE_NAME = "linnl.toml"
|
|
13
|
+
PYPROJECT_FILE_NAME = "pyproject.toml"
|
|
14
|
+
SHIPPED_CONFIG_FILE = Path(
|
|
15
|
+
str(resources.files("linnl").joinpath("resources", "config.toml"))
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def locate_user_config_file() -> Path:
|
|
20
|
+
"""Return the user config path under $XDG_CONFIG_HOME, or ~/.config when unset."""
|
|
21
|
+
base = os.environ.get("XDG_CONFIG_HOME", "")
|
|
22
|
+
if base == "":
|
|
23
|
+
return Path.home() / ".config" / "linnl" / "config.toml"
|
|
24
|
+
|
|
25
|
+
return Path(base) / "linnl" / "config.toml"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def has_linnl_section(pyproject: Path) -> bool:
|
|
29
|
+
with pyproject.open("rb") as handle:
|
|
30
|
+
return "linnl" in tomllib.load(handle).get("tool", {})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def discover_config_file(start: Path) -> Path:
|
|
34
|
+
"""Find the config file to be used, starting at a given directory.
|
|
35
|
+
|
|
36
|
+
Walking upward, a pyproject.toml with a [tool.linnl] table wins over an
|
|
37
|
+
linnl.toml in the same directory. Below both comes the user config, and
|
|
38
|
+
below that the shipped config.
|
|
39
|
+
"""
|
|
40
|
+
for directory in (start, *start.parents):
|
|
41
|
+
pyproject = directory / PYPROJECT_FILE_NAME
|
|
42
|
+
linnl_config = directory / LINNL_CONFIG_FILE_NAME
|
|
43
|
+
pyproject_applies = pyproject.is_file() and has_linnl_section(pyproject)
|
|
44
|
+
|
|
45
|
+
if pyproject_applies and linnl_config.is_file():
|
|
46
|
+
logger.warning(
|
|
47
|
+
"%s: both %s and %s configure linnl, using %s",
|
|
48
|
+
directory,
|
|
49
|
+
pyproject.name,
|
|
50
|
+
linnl_config.name,
|
|
51
|
+
pyproject.name,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if pyproject_applies:
|
|
55
|
+
logger.info("Using pyproject.toml at %s", pyproject)
|
|
56
|
+
return pyproject
|
|
57
|
+
|
|
58
|
+
if linnl_config.is_file():
|
|
59
|
+
logger.info("Using linnl.toml at %s", linnl_config)
|
|
60
|
+
return linnl_config
|
|
61
|
+
|
|
62
|
+
user_file = locate_user_config_file()
|
|
63
|
+
if user_file.is_file():
|
|
64
|
+
logger.info("Using user config at %s", user_file)
|
|
65
|
+
return user_file
|
|
66
|
+
|
|
67
|
+
logger.info("Using default config at %s", SHIPPED_CONFIG_FILE)
|
|
68
|
+
return SHIPPED_CONFIG_FILE
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def read_config_file(path: Path) -> dict[str, Any]:
|
|
72
|
+
"""Parse the config file, unwrapping [tool.linnl] for pyproject.toml."""
|
|
73
|
+
with path.open("rb") as handle:
|
|
74
|
+
data = tomllib.load(handle)
|
|
75
|
+
|
|
76
|
+
logger.info("Settings loaded from %s", path)
|
|
77
|
+
|
|
78
|
+
if path.name != PYPROJECT_FILE_NAME:
|
|
79
|
+
return data
|
|
80
|
+
|
|
81
|
+
section = data.get("tool", {}).get("linnl")
|
|
82
|
+
if not isinstance(section, dict):
|
|
83
|
+
raise ValueError(f"{path}: no [tool.linnl] table")
|
|
84
|
+
|
|
85
|
+
return section
|
linnl/constants.py
ADDED
linnl/document.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from docutils import nodes
|
|
4
|
+
from docutils.core import publish_doctree
|
|
5
|
+
from markdown_it import MarkdownIt
|
|
6
|
+
|
|
7
|
+
from linnl.constants import DEFAULT_IGNORE_CODE_BLOCKS
|
|
8
|
+
|
|
9
|
+
MARKDOWN_PARSER = MarkdownIt("commonmark")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Document:
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
path: Path | None = None,
|
|
16
|
+
prose: str | None = None,
|
|
17
|
+
ignore_code_blocks: bool = DEFAULT_IGNORE_CODE_BLOCKS,
|
|
18
|
+
):
|
|
19
|
+
if prose is None:
|
|
20
|
+
if path is None:
|
|
21
|
+
raise ValueError("Either 'prose' or 'path' must be provided.")
|
|
22
|
+
|
|
23
|
+
prose = path.read_text(encoding="utf-8")
|
|
24
|
+
|
|
25
|
+
self.ignore_code_blocks = ignore_code_blocks
|
|
26
|
+
self._prose = prose
|
|
27
|
+
self.path = path
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def prose(self) -> str:
|
|
31
|
+
if self.ignore_code_blocks:
|
|
32
|
+
return self._omit_code_blocks()
|
|
33
|
+
return self._prose
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def lines(self) -> list[str]:
|
|
37
|
+
return self.prose.split("\n")
|
|
38
|
+
|
|
39
|
+
def _omit_code_blocks(self) -> str:
|
|
40
|
+
"""Omit code content while preserving source line positions."""
|
|
41
|
+
if self.path is None:
|
|
42
|
+
return self._prose
|
|
43
|
+
|
|
44
|
+
if self.path.suffix.lower() == ".md":
|
|
45
|
+
omitted_ranges = self._find_markdown_code_block_ranges()
|
|
46
|
+
elif self.path.suffix.lower() == ".rst":
|
|
47
|
+
omitted_ranges = self._find_rst_code_block_ranges()
|
|
48
|
+
else:
|
|
49
|
+
return self._prose
|
|
50
|
+
|
|
51
|
+
lines = self._prose.splitlines(keepends=True)
|
|
52
|
+
omitted_lines = [False] * len(lines)
|
|
53
|
+
|
|
54
|
+
for start_line, end_line in omitted_ranges:
|
|
55
|
+
for line_index in range(start_line, end_line):
|
|
56
|
+
omitted_lines[line_index] = True
|
|
57
|
+
|
|
58
|
+
return "".join(
|
|
59
|
+
self._extract_line_ending(line) if omitted else line
|
|
60
|
+
for line, omitted in zip(lines, omitted_lines, strict=True)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def _find_markdown_code_block_ranges(self) -> list[tuple[int, int]]:
|
|
64
|
+
"""Find source ranges for Markdown code blocks."""
|
|
65
|
+
ranges = []
|
|
66
|
+
|
|
67
|
+
for token in MARKDOWN_PARSER.parse(self._prose):
|
|
68
|
+
if token.type in {"code_block", "fence"} and token.map is not None:
|
|
69
|
+
ranges.append((token.map[0], token.map[1]))
|
|
70
|
+
|
|
71
|
+
return ranges
|
|
72
|
+
|
|
73
|
+
def _find_rst_code_block_ranges(self) -> list[tuple[int, int]]:
|
|
74
|
+
"""Find source ranges for reStructuredText literal blocks."""
|
|
75
|
+
tree = publish_doctree(
|
|
76
|
+
self._prose,
|
|
77
|
+
settings_overrides={
|
|
78
|
+
"file_insertion_enabled": False,
|
|
79
|
+
"halt_level": 6,
|
|
80
|
+
"raw_enabled": False,
|
|
81
|
+
"report_level": 5,
|
|
82
|
+
},
|
|
83
|
+
)
|
|
84
|
+
ranges = []
|
|
85
|
+
source_offset = 0
|
|
86
|
+
|
|
87
|
+
for block in tree.findall(nodes.literal_block):
|
|
88
|
+
if isinstance(
|
|
89
|
+
block.parent, nodes.system_message
|
|
90
|
+
) and not block.rawsource.lstrip().startswith(
|
|
91
|
+
(".. code::", ".. code-block::")
|
|
92
|
+
):
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
start_offset = self._find_source_offset_for_rst_block(
|
|
96
|
+
block.rawsource,
|
|
97
|
+
source_offset,
|
|
98
|
+
block.line,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
start_line = self._prose.count("\n", 0, start_offset)
|
|
102
|
+
end_line = start_line + len(block.rawsource.splitlines())
|
|
103
|
+
ranges.append((start_line, end_line))
|
|
104
|
+
source_offset = start_offset + len(block.rawsource)
|
|
105
|
+
|
|
106
|
+
return ranges
|
|
107
|
+
|
|
108
|
+
def _find_source_offset_for_rst_block(
|
|
109
|
+
self,
|
|
110
|
+
rawsource: str,
|
|
111
|
+
source_offset: int,
|
|
112
|
+
reported_line: int | None,
|
|
113
|
+
) -> int:
|
|
114
|
+
"""Find the source offset for a reStructuredText literal block."""
|
|
115
|
+
offset = self._prose.find(rawsource, source_offset)
|
|
116
|
+
|
|
117
|
+
if offset == -1:
|
|
118
|
+
raise ValueError("Could not locate a reStructuredText code block.")
|
|
119
|
+
|
|
120
|
+
if reported_line is None:
|
|
121
|
+
return offset
|
|
122
|
+
|
|
123
|
+
expected_offset = offset
|
|
124
|
+
|
|
125
|
+
while offset != -1:
|
|
126
|
+
line_number = self._prose.count("\n", 0, offset) + 1
|
|
127
|
+
|
|
128
|
+
if line_number > reported_line:
|
|
129
|
+
break
|
|
130
|
+
|
|
131
|
+
expected_offset = offset
|
|
132
|
+
offset = self._prose.find(rawsource, offset + 1)
|
|
133
|
+
|
|
134
|
+
return expected_offset
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def _extract_line_ending(line: str) -> str:
|
|
138
|
+
return line[len(line.rstrip("\r\n")) :]
|