code-standards 7.0.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.
- code_standards-7.0.0.dist-info/METADATA +53 -0
- code_standards-7.0.0.dist-info/RECORD +99 -0
- code_standards-7.0.0.dist-info/WHEEL +4 -0
- code_standards-7.0.0.dist-info/entry_points.txt +3 -0
- code_standards-7.0.0.dist-info/licenses/LICENSE +21 -0
- sarj_standards/__init__.py +30 -0
- sarj_standards/__main__.py +5 -0
- sarj_standards/_meta.py +22 -0
- sarj_standards/api.py +890 -0
- sarj_standards/cli/__init__.py +0 -0
- sarj_standards/cli/main.py +2466 -0
- sarj_standards/configs/cli-reference.v1.json +1 -0
- sarj_standards/configs/doctor.config.json +22 -0
- sarj_standards/configs/eslint.application.mjs +1366 -0
- sarj_standards/configs/eslint.peers.json +44 -0
- sarj_standards/configs/eslint.strict.mjs +1060 -0
- sarj_standards/configs/markdownlint.strict.yaml +12 -0
- sarj_standards/configs/pyright.strict.json +96 -0
- sarj_standards/configs/ruff.application.toml +363 -0
- sarj_standards/configs/ruff.strict.toml +338 -0
- sarj_standards/configs/rule-inventory.v1.json +1 -0
- sarj_standards/configs/rule-ledger.json +846 -0
- sarj_standards/configs/rule-warning-levels.v1.json +1 -0
- sarj_standards/configs/taplo.strict.toml +14 -0
- sarj_standards/configs/yamllint.strict.yaml +25 -0
- sarj_standards/libs/__init__.py +0 -0
- sarj_standards/libs/adoption/__init__.py +0 -0
- sarj_standards/libs/adoption/configs.py +36 -0
- sarj_standards/libs/adoption/doctor.py +1346 -0
- sarj_standards/libs/adoption/exclusions.py +66 -0
- sarj_standards/libs/adoption/hooks.py +423 -0
- sarj_standards/libs/adoption/launcher.py +240 -0
- sarj_standards/libs/adoption/lifecycle.py +493 -0
- sarj_standards/libs/adoption/manifest.py +550 -0
- sarj_standards/libs/adoption/packagemanager.py +285 -0
- sarj_standards/libs/adoption/retired_suppressions.py +371 -0
- sarj_standards/libs/adoption/scaffold.py +1660 -0
- sarj_standards/libs/adoption/service.py +441 -0
- sarj_standards/libs/adoption/transaction.py +274 -0
- sarj_standards/libs/adoption/upgrade.py +516 -0
- sarj_standards/libs/adoption/uvtool.py +62 -0
- sarj_standards/libs/catalogs/__init__.py +9 -0
- sarj_standards/libs/catalogs/slack_automations.py +627 -0
- sarj_standards/libs/corpus/__init__.py +25 -0
- sarj_standards/libs/corpus/manifest.py +211 -0
- sarj_standards/libs/corpus/snapshot.py +222 -0
- sarj_standards/libs/diagnostics/__init__.py +65 -0
- sarj_standards/libs/diagnostics/analysis.schema.json +161 -0
- sarj_standards/libs/diagnostics/baseline.py +131 -0
- sarj_standards/libs/diagnostics/models.py +574 -0
- sarj_standards/libs/diagnostics/serialize.py +290 -0
- sarj_standards/libs/diagnostics/source.py +172 -0
- sarj_standards/libs/filesystem.py +11 -0
- sarj_standards/libs/linting/__init__.py +0 -0
- sarj_standards/libs/linting/analysis.py +422 -0
- sarj_standards/libs/linting/external.py +1454 -0
- sarj_standards/libs/linting/library_policy.py +688 -0
- sarj_standards/libs/linting/policy.py +152 -0
- sarj_standards/libs/linting/runner.py +442 -0
- sarj_standards/libs/linting/textlint.py +1605 -0
- sarj_standards/libs/release/__init__.py +98 -0
- sarj_standards/libs/release/_values.py +24 -0
- sarj_standards/libs/release/artifacts.py +191 -0
- sarj_standards/libs/release/causality.py +80 -0
- sarj_standards/libs/release/changes.py +48 -0
- sarj_standards/libs/release/process.py +128 -0
- sarj_standards/libs/release/publish.py +85 -0
- sarj_standards/libs/release/registry.py +271 -0
- sarj_standards/libs/release/release_age.py +218 -0
- sarj_standards/libs/release/rollout.py +1163 -0
- sarj_standards/libs/release/tags.py +373 -0
- sarj_standards/libs/release/typescript.py +191 -0
- sarj_standards/libs/repository/__init__.py +0 -0
- sarj_standards/libs/repository/cli_reference_artifact.py +324 -0
- sarj_standards/libs/repository/comment_corpus.py +536 -0
- sarj_standards/libs/repository/config_generation.py +146 -0
- sarj_standards/libs/repository/docs.py +347 -0
- sarj_standards/libs/repository/hooks.py +118 -0
- sarj_standards/libs/repository/ledger.py +99 -0
- sarj_standards/libs/repository/repository.py +744 -0
- sarj_standards/libs/repository/rule_authoring.py +246 -0
- sarj_standards/libs/repository/rule_catalog_artifact.py +479 -0
- sarj_standards/libs/repository/rule_changes.py +318 -0
- sarj_standards/libs/repository/rule_inventory_artifact.py +142 -0
- sarj_standards/libs/repository/rule_lifecycle.py +167 -0
- sarj_standards/libs/repository/rule_maintenance.py +225 -0
- sarj_standards/libs/rules/__init__.py +74 -0
- sarj_standards/libs/rules/catalog.py +145 -0
- sarj_standards/libs/rules/contracts.py +382 -0
- sarj_standards/libs/rules/corpus_runner.py +365 -0
- sarj_standards/libs/rules/evaluation.py +177 -0
- sarj_standards/libs/setup/__init__.py +4 -0
- sarj_standards/libs/setup/repository.py +40 -0
- sarj_standards/py.typed +0 -0
- sarj_standards/schemas/__init__.py +4 -0
- sarj_standards/schemas/_paths.py +7 -0
- sarj_standards/schemas/rule-catalog.v1.json +1 -0
- sarj_standards/schemas/rule-catalog.v1.schema.json +112 -0
- sarj_standards/schemas/slack-automations.v1.schema.json +1751 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from heapq import nsmallest
|
|
4
|
+
import json
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
from .models import AnalysisReport, Diagnostic, Fix, FixSafety, Location, Severity, TextEdit
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_GITHUB_ANNOTATION_LIMIT = 10
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def to_json(report: AnalysisReport, *, indent: int | None = 2) -> str:
|
|
14
|
+
return json.dumps(report.as_dict(), indent=indent, sort_keys=True) + "\n"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def to_sarif(report: AnalysisReport) -> str:
|
|
18
|
+
rules = _sarif_rules(report)
|
|
19
|
+
payload: dict[str, object] = {
|
|
20
|
+
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
|
|
21
|
+
"version": "2.1.0",
|
|
22
|
+
"runs": [
|
|
23
|
+
{
|
|
24
|
+
"tool": {
|
|
25
|
+
"driver": {
|
|
26
|
+
"name": "code-standards",
|
|
27
|
+
"informationUri": "https://github.com/sarj-ai/code-standards",
|
|
28
|
+
"rules": rules,
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"results": [_sarif_result(item) for item in report.diagnostics],
|
|
32
|
+
"columnKind": "utf16CodeUnits",
|
|
33
|
+
"invocations": [
|
|
34
|
+
{
|
|
35
|
+
"executionSuccessful": report.completion.value == "complete",
|
|
36
|
+
"toolExecutionNotifications": [
|
|
37
|
+
{"descriptor": {"id": issue.kind}, "message": {"text": issue.message}, "level": "error"}
|
|
38
|
+
for issue in report.issues
|
|
39
|
+
]
|
|
40
|
+
+ [
|
|
41
|
+
{
|
|
42
|
+
"descriptor": {"id": "coverage-notice"},
|
|
43
|
+
"message": {"text": _coverage_line(item.source, item.reason, item.file_count)},
|
|
44
|
+
"level": "error" if item.blocking else "note",
|
|
45
|
+
"properties": {"disposition": item.disposition.value},
|
|
46
|
+
}
|
|
47
|
+
for item in report.coverage
|
|
48
|
+
],
|
|
49
|
+
}
|
|
50
|
+
],
|
|
51
|
+
}
|
|
52
|
+
],
|
|
53
|
+
}
|
|
54
|
+
return json.dumps(payload, indent=2, sort_keys=True) + "\n"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def to_text(report: AnalysisReport) -> str:
|
|
58
|
+
lines = [_text_diagnostic(item) for item in report.diagnostics]
|
|
59
|
+
lines.extend(f"{issue.source}: {issue.kind}: {issue.message}" for issue in report.issues)
|
|
60
|
+
lines.extend(_coverage_line(item.source, item.reason, item.file_count) for item in report.coverage)
|
|
61
|
+
lines.append(_summary(report))
|
|
62
|
+
return "\n".join(lines) + "\n"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def to_github(report: AnalysisReport, *, max_annotations_per_level: int = _GITHUB_ANNOTATION_LIMIT) -> str:
|
|
66
|
+
if not 0 <= max_annotations_per_level <= _GITHUB_ANNOTATION_LIMIT:
|
|
67
|
+
msg = "max_annotations_per_level must be between 0 and 10"
|
|
68
|
+
raise ValueError(msg)
|
|
69
|
+
issues = [
|
|
70
|
+
f"::error title={_github_property(f'{issue.source}/{issue.kind}')}::{_github_message(issue.message)}"
|
|
71
|
+
for issue in report.issues[:max_annotations_per_level]
|
|
72
|
+
]
|
|
73
|
+
counts = dict.fromkeys(Severity, 0)
|
|
74
|
+
for tool in report.tools:
|
|
75
|
+
for diagnostic in tool.diagnostics:
|
|
76
|
+
counts[diagnostic.severity] += 1
|
|
77
|
+
error_budget = max_annotations_per_level - len(issues)
|
|
78
|
+
selected = {
|
|
79
|
+
severity: nsmallest(
|
|
80
|
+
error_budget if severity is Severity.ERROR else max_annotations_per_level,
|
|
81
|
+
(diagnostic for tool in report.tools for diagnostic in tool.diagnostics if diagnostic.severity is severity),
|
|
82
|
+
key=_github_priority,
|
|
83
|
+
)
|
|
84
|
+
for severity in Severity
|
|
85
|
+
}
|
|
86
|
+
lines = [
|
|
87
|
+
*issues,
|
|
88
|
+
*(_github_diagnostic(item) for item in selected[Severity.ERROR]),
|
|
89
|
+
*(_github_diagnostic(item) for item in selected[Severity.WARNING]),
|
|
90
|
+
*(_github_diagnostic(item) for item in selected[Severity.INFO]),
|
|
91
|
+
]
|
|
92
|
+
omitted = (
|
|
93
|
+
max(0, len(report.issues) + counts[Severity.ERROR] - max_annotations_per_level)
|
|
94
|
+
+ max(0, counts[Severity.WARNING] - max_annotations_per_level)
|
|
95
|
+
+ max(0, counts[Severity.INFO] - max_annotations_per_level)
|
|
96
|
+
)
|
|
97
|
+
if omitted:
|
|
98
|
+
lines.append(
|
|
99
|
+
f"code-standards: {omitted} annotation(s) omitted by GitHub's per-level limits; "
|
|
100
|
+
"use JSON or SARIF for the complete report"
|
|
101
|
+
)
|
|
102
|
+
lines.extend(_coverage_line(item.source, item.reason, item.file_count) for item in report.coverage)
|
|
103
|
+
lines.append(_summary(report))
|
|
104
|
+
return "\n".join(lines) + "\n"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _github_priority(diagnostic: Diagnostic) -> tuple[object, ...]:
|
|
108
|
+
severity = {Severity.ERROR: 0, Severity.WARNING: 1, Severity.INFO: 2}[diagnostic.severity]
|
|
109
|
+
location = diagnostic.location
|
|
110
|
+
position = location.region.start if location.region is not None else location.position
|
|
111
|
+
return (
|
|
112
|
+
severity,
|
|
113
|
+
location.path,
|
|
114
|
+
-1 if position is None else position.line,
|
|
115
|
+
-1 if position is None else position.character,
|
|
116
|
+
diagnostic.source,
|
|
117
|
+
diagnostic.code,
|
|
118
|
+
diagnostic.message,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _text_diagnostic(diagnostic: Diagnostic) -> str:
|
|
123
|
+
position = (
|
|
124
|
+
diagnostic.location.region.start if diagnostic.location.region is not None else diagnostic.location.position
|
|
125
|
+
)
|
|
126
|
+
suffix = "" if position is None else f":{position.line + 1}:{position.character + 1}"
|
|
127
|
+
return (
|
|
128
|
+
f"{diagnostic.location.path}{suffix}: {diagnostic.severity.value} "
|
|
129
|
+
f"{diagnostic.code} {diagnostic.message} [{diagnostic.source}]"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _github_diagnostic(diagnostic: Diagnostic) -> str:
|
|
134
|
+
level = "notice" if diagnostic.severity is Severity.INFO else diagnostic.severity.value
|
|
135
|
+
properties: list[str] = []
|
|
136
|
+
position = (
|
|
137
|
+
diagnostic.location.region.start if diagnostic.location.region is not None else diagnostic.location.position
|
|
138
|
+
)
|
|
139
|
+
if position is not None:
|
|
140
|
+
properties.extend((f"file={_github_property(diagnostic.location.path)}", f"line={position.line + 1}"))
|
|
141
|
+
region = diagnostic.location.region
|
|
142
|
+
if region is None:
|
|
143
|
+
properties.append(f"col={position.character + 1}")
|
|
144
|
+
elif region.end.line == region.start.line:
|
|
145
|
+
properties.extend(
|
|
146
|
+
(
|
|
147
|
+
f"col={position.character + 1}",
|
|
148
|
+
f"endLine={region.end.line + 1}",
|
|
149
|
+
f"endColumn={region.end.character + 1}",
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
else:
|
|
153
|
+
properties.append(f"endLine={region.end.line + 1}")
|
|
154
|
+
properties.append(f"title={_github_property(f'{diagnostic.source}/{diagnostic.code}')}")
|
|
155
|
+
message = diagnostic.message if position is not None else f"{diagnostic.location.path}: {diagnostic.message}"
|
|
156
|
+
return f"::{level} {','.join(properties)}::{_github_message(message)}"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _github_message(value: str) -> str:
|
|
160
|
+
return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _github_property(value: str) -> str:
|
|
164
|
+
return _github_message(value).replace(":", "%3A").replace(",", "%2C")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _summary(report: AnalysisReport) -> str:
|
|
168
|
+
counts = dict.fromkeys(Severity, 0)
|
|
169
|
+
for tool in report.tools:
|
|
170
|
+
for diagnostic in tool.diagnostics:
|
|
171
|
+
counts[diagnostic.severity] += 1
|
|
172
|
+
return (
|
|
173
|
+
f"code-standards: {counts[Severity.ERROR]} error(s), {counts[Severity.WARNING]} warning(s), "
|
|
174
|
+
f"{counts[Severity.INFO]} notice(s), {len(report.issues)} execution issue(s)"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _coverage_line(source: str, reason: str, file_count: int) -> str:
|
|
179
|
+
return f"code-standards coverage: {source} did not analyze {file_count} selected file(s): {reason}"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _sarif_rules(report: AnalysisReport) -> list[dict[str, object]]:
|
|
183
|
+
by_code: dict[tuple[str, str], Diagnostic] = {}
|
|
184
|
+
for diagnostic in report.diagnostics:
|
|
185
|
+
by_code.setdefault((diagnostic.source, diagnostic.code), diagnostic)
|
|
186
|
+
return [
|
|
187
|
+
{
|
|
188
|
+
"id": _sarif_rule_id(item),
|
|
189
|
+
"name": item.rule_id or code,
|
|
190
|
+
"shortDescription": {"text": item.help or item.rule_id or code},
|
|
191
|
+
**({"helpUri": item.help_url} if item.help_url is not None else {}),
|
|
192
|
+
}
|
|
193
|
+
for (_source, code), item in sorted(by_code.items())
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _sarif_result(diagnostic: Diagnostic) -> dict[str, object]:
|
|
198
|
+
location = diagnostic.location
|
|
199
|
+
physical: dict[str, object] = {"artifactLocation": {"uri": quote(location.path, safe="/")}}
|
|
200
|
+
if location.region is not None:
|
|
201
|
+
physical["region"] = {
|
|
202
|
+
"startLine": location.region.start.line + 1,
|
|
203
|
+
"startColumn": location.region.start.character + 1,
|
|
204
|
+
"endLine": location.region.end.line + 1,
|
|
205
|
+
"endColumn": location.region.end.character + 1,
|
|
206
|
+
}
|
|
207
|
+
elif location.position is not None:
|
|
208
|
+
physical["region"] = {
|
|
209
|
+
"startLine": location.position.line + 1,
|
|
210
|
+
"startColumn": location.position.character + 1,
|
|
211
|
+
}
|
|
212
|
+
result: dict[str, object] = {
|
|
213
|
+
"ruleId": _sarif_rule_id(diagnostic),
|
|
214
|
+
"level": _sarif_level(diagnostic.severity),
|
|
215
|
+
"message": {"text": diagnostic.message},
|
|
216
|
+
"locations": [{"physicalLocation": physical}],
|
|
217
|
+
"properties": {
|
|
218
|
+
"source": diagnostic.source,
|
|
219
|
+
"code": diagnostic.code,
|
|
220
|
+
"repositoryRoot": ".",
|
|
221
|
+
**({"tags": list(diagnostic.tags)} if diagnostic.tags else {}),
|
|
222
|
+
**({"notes": list(diagnostic.notes)} if diagnostic.notes else {}),
|
|
223
|
+
},
|
|
224
|
+
}
|
|
225
|
+
if diagnostic.fingerprint is not None:
|
|
226
|
+
result["partialFingerprints"] = {"sarj/v1": diagnostic.fingerprint}
|
|
227
|
+
safe_fixes = tuple(fix for fix in diagnostic.fixes if fix.safety is FixSafety.SAFE)
|
|
228
|
+
if safe_fixes:
|
|
229
|
+
result["fixes"] = [_sarif_fix(fix) for fix in safe_fixes]
|
|
230
|
+
if diagnostic.related:
|
|
231
|
+
result["relatedLocations"] = [
|
|
232
|
+
{"message": {"text": item.label}, "physicalLocation": _sarif_physical(item.location)}
|
|
233
|
+
for item in diagnostic.related
|
|
234
|
+
]
|
|
235
|
+
return result
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _sarif_fix(fix: Fix) -> dict[str, object]:
|
|
239
|
+
grouped: dict[str, list[TextEdit]] = {}
|
|
240
|
+
for edit in fix.edits:
|
|
241
|
+
grouped.setdefault(edit.location.path, []).append(edit)
|
|
242
|
+
return {
|
|
243
|
+
"description": {"text": fix.title},
|
|
244
|
+
"artifactChanges": [
|
|
245
|
+
{
|
|
246
|
+
"artifactLocation": {"uri": quote(path, safe="/")},
|
|
247
|
+
"replacements": [
|
|
248
|
+
{
|
|
249
|
+
"deletedRegion": _sarif_region(edit.location),
|
|
250
|
+
"insertedContent": {"text": edit.replacement},
|
|
251
|
+
}
|
|
252
|
+
for edit in edits
|
|
253
|
+
],
|
|
254
|
+
}
|
|
255
|
+
for path, edits in sorted(grouped.items())
|
|
256
|
+
],
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _sarif_physical(location: Location) -> dict[str, object]:
|
|
261
|
+
physical: dict[str, object] = {"artifactLocation": {"uri": quote(location.path, safe="/")}}
|
|
262
|
+
region = _sarif_region(location)
|
|
263
|
+
if region:
|
|
264
|
+
physical["region"] = region
|
|
265
|
+
return physical
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _sarif_region(location: Location) -> dict[str, int]:
|
|
269
|
+
if location.region is not None:
|
|
270
|
+
return {
|
|
271
|
+
"startLine": location.region.start.line + 1,
|
|
272
|
+
"startColumn": location.region.start.character + 1,
|
|
273
|
+
"endLine": location.region.end.line + 1,
|
|
274
|
+
"endColumn": location.region.end.character + 1,
|
|
275
|
+
}
|
|
276
|
+
if location.position is not None:
|
|
277
|
+
return {"startLine": location.position.line + 1, "startColumn": location.position.character + 1}
|
|
278
|
+
return {}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _sarif_rule_id(diagnostic: Diagnostic) -> str:
|
|
282
|
+
return f"{diagnostic.source}/{diagnostic.code}"
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _sarif_level(severity: Severity) -> str:
|
|
286
|
+
if severity is Severity.ERROR:
|
|
287
|
+
return "error"
|
|
288
|
+
if severity is Severity.WARNING:
|
|
289
|
+
return "warning"
|
|
290
|
+
return "note"
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from array import array
|
|
4
|
+
from bisect import bisect_right
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import re
|
|
8
|
+
from typing import TYPE_CHECKING, NamedTuple
|
|
9
|
+
|
|
10
|
+
from .models import Position, Region
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from typing import Self
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _Utf16Index(NamedTuple):
|
|
18
|
+
starts: array[int]
|
|
19
|
+
ends: array[int]
|
|
20
|
+
unit_extras: array[int]
|
|
21
|
+
byte_extras: array[int]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class SourceDocument:
|
|
26
|
+
path: Path
|
|
27
|
+
text: str
|
|
28
|
+
_lines: tuple[str, ...] = field(init=False, repr=False)
|
|
29
|
+
_line_byte_offsets: tuple[int, ...] = field(init=False, repr=False)
|
|
30
|
+
_byte_length: int = field(init=False, repr=False)
|
|
31
|
+
_utf16_indexes: dict[int, _Utf16Index] = field(init=False, repr=False)
|
|
32
|
+
|
|
33
|
+
def __post_init__(self) -> None:
|
|
34
|
+
lines = tuple(re.findall(r".*?(?:\r\n|\r|\n)|.+\Z", self.text, flags=re.DOTALL)) or ("",)
|
|
35
|
+
if self.text.endswith(("\n", "\r")):
|
|
36
|
+
lines = (*lines, "")
|
|
37
|
+
self._lines = lines
|
|
38
|
+
offsets: list[int] = []
|
|
39
|
+
offset = 0
|
|
40
|
+
for line in self._lines:
|
|
41
|
+
offsets.append(offset)
|
|
42
|
+
offset += len(line.encode("utf-8", errors="surrogateescape"))
|
|
43
|
+
self._line_byte_offsets = tuple(offsets)
|
|
44
|
+
self._byte_length = offset
|
|
45
|
+
self._utf16_indexes = {}
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def read(cls, path: Path) -> Self:
|
|
49
|
+
return cls(path, path.read_bytes().decode("utf-8", errors="surrogateescape"))
|
|
50
|
+
|
|
51
|
+
def point(self, *, line: int, column: int) -> Position | None:
|
|
52
|
+
if line < 1 or column < 1 or line > len(self._lines):
|
|
53
|
+
return None
|
|
54
|
+
content = self._lines[line - 1].rstrip("\r\n")
|
|
55
|
+
codepoint_index = column - 1
|
|
56
|
+
if codepoint_index > len(content):
|
|
57
|
+
return None
|
|
58
|
+
prefix = content[:codepoint_index]
|
|
59
|
+
return Position(
|
|
60
|
+
line=line - 1,
|
|
61
|
+
character=len(prefix.encode("utf-16-le", errors="surrogatepass")) // 2,
|
|
62
|
+
byte_offset=(self._line_byte_offsets[line - 1] + len(prefix.encode("utf-8", errors="surrogateescape"))),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def utf16_point(self, *, line: int, character: int) -> Position | None:
|
|
66
|
+
if line < 0 or character < 0 or line >= len(self._lines):
|
|
67
|
+
return None
|
|
68
|
+
content = self._lines[line].rstrip("\r\n")
|
|
69
|
+
if character == 0:
|
|
70
|
+
return Position(line=line, character=0, byte_offset=self._line_byte_offsets[line])
|
|
71
|
+
if content.isascii():
|
|
72
|
+
if character > len(content):
|
|
73
|
+
return None
|
|
74
|
+
return Position(
|
|
75
|
+
line=line,
|
|
76
|
+
character=character,
|
|
77
|
+
byte_offset=self._line_byte_offsets[line] + character,
|
|
78
|
+
)
|
|
79
|
+
index = self._utf16_indexes.get(line)
|
|
80
|
+
if index is None:
|
|
81
|
+
index = self._build_utf16_index(content)
|
|
82
|
+
self._utf16_indexes[line] = index
|
|
83
|
+
starts, ends, unit_extras, byte_extras = index
|
|
84
|
+
event_index = bisect_right(ends, character)
|
|
85
|
+
if event_index < len(starts) and starts[event_index] < character < ends[event_index]:
|
|
86
|
+
return None
|
|
87
|
+
unit_extra = unit_extras[event_index - 1] if event_index else 0
|
|
88
|
+
byte_extra = byte_extras[event_index - 1] if event_index else 0
|
|
89
|
+
codepoint_index = character - unit_extra
|
|
90
|
+
if codepoint_index < 0 or codepoint_index > len(content):
|
|
91
|
+
return None
|
|
92
|
+
return Position(
|
|
93
|
+
line=line,
|
|
94
|
+
character=character,
|
|
95
|
+
byte_offset=self._line_byte_offsets[line] + codepoint_index + byte_extra,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
def _build_utf16_index(content: str) -> _Utf16Index:
|
|
100
|
+
starts = array("I")
|
|
101
|
+
ends = array("I")
|
|
102
|
+
unit_extras = array("I")
|
|
103
|
+
byte_extras = array("I")
|
|
104
|
+
utf16_offset = 0
|
|
105
|
+
byte_offset = 0
|
|
106
|
+
for codepoint_index, value in enumerate(content):
|
|
107
|
+
units = len(value.encode("utf-16-le", errors="surrogatepass")) // 2
|
|
108
|
+
byte_length = len(value.encode("utf-8", errors="surrogateescape"))
|
|
109
|
+
if units != 1 or byte_length != 1:
|
|
110
|
+
end = utf16_offset + units
|
|
111
|
+
starts.append(utf16_offset)
|
|
112
|
+
ends.append(end)
|
|
113
|
+
unit_extras.append(end - codepoint_index - 1)
|
|
114
|
+
byte_extras.append(byte_offset + byte_length - codepoint_index - 1)
|
|
115
|
+
utf16_offset += units
|
|
116
|
+
byte_offset += byte_length
|
|
117
|
+
return _Utf16Index(starts, ends, unit_extras, byte_extras)
|
|
118
|
+
|
|
119
|
+
def byte_point(self, *, line: int, column: int) -> Position | None:
|
|
120
|
+
if line < 1 or column < 1 or line > len(self._lines):
|
|
121
|
+
return None
|
|
122
|
+
content = self._lines[line - 1].rstrip("\r\n")
|
|
123
|
+
byte_column = column - 1
|
|
124
|
+
encoded = content.encode("utf-8", errors="surrogateescape")
|
|
125
|
+
if byte_column > len(encoded):
|
|
126
|
+
return None
|
|
127
|
+
try:
|
|
128
|
+
prefix = encoded[:byte_column].decode("utf-8", errors="surrogateescape")
|
|
129
|
+
except ValueError:
|
|
130
|
+
return None
|
|
131
|
+
if not content.startswith(prefix):
|
|
132
|
+
return None
|
|
133
|
+
return Position(
|
|
134
|
+
line=line - 1,
|
|
135
|
+
character=len(prefix.encode("utf-16-le", errors="surrogatepass")) // 2,
|
|
136
|
+
byte_offset=self._line_byte_offsets[line - 1] + byte_column,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def region(self, *, start_byte: int, end_byte: int) -> Region:
|
|
140
|
+
if start_byte < 0 or end_byte < start_byte or end_byte > self._byte_length:
|
|
141
|
+
msg = "source byte range is outside the document"
|
|
142
|
+
raise ValueError(msg)
|
|
143
|
+
return Region(self._position_at_byte(start_byte), self._position_at_byte(end_byte))
|
|
144
|
+
|
|
145
|
+
def _position_at_byte(self, offset: int) -> Position:
|
|
146
|
+
if offset < 0 or offset > self._byte_length:
|
|
147
|
+
msg = "source byte offset is outside the document"
|
|
148
|
+
raise ValueError(msg)
|
|
149
|
+
line = bisect_right(self._line_byte_offsets, offset) - 1
|
|
150
|
+
line_start = self._line_byte_offsets[line]
|
|
151
|
+
relative = offset - line_start
|
|
152
|
+
source_line = self._lines[line]
|
|
153
|
+
encoded_line = source_line.encode("utf-8", errors="surrogateescape")
|
|
154
|
+
if relative > 0 and relative < len(encoded_line) and encoded_line[relative - 1 : relative + 1] == b"\r\n":
|
|
155
|
+
msg = "source byte offset splits a CRLF line terminator"
|
|
156
|
+
raise ValueError(msg)
|
|
157
|
+
prefix = encoded_line[:relative]
|
|
158
|
+
try:
|
|
159
|
+
decoded = prefix.decode("utf-8", errors="surrogateescape")
|
|
160
|
+
except UnicodeDecodeError as exc:
|
|
161
|
+
msg = "source byte offset splits a UTF-8 code point"
|
|
162
|
+
raise ValueError(msg) from exc
|
|
163
|
+
if not source_line.startswith(decoded):
|
|
164
|
+
msg = "source byte offset splits a UTF-8 code point"
|
|
165
|
+
raise ValueError(msg)
|
|
166
|
+
local_newlines = decoded.count("\n")
|
|
167
|
+
current = decoded.rpartition("\n")[2]
|
|
168
|
+
return Position(
|
|
169
|
+
line=line + local_newlines,
|
|
170
|
+
character=len(current.encode("utf-16-le", errors="surrogatepass")) // 2,
|
|
171
|
+
byte_offset=offset,
|
|
172
|
+
)
|
|
File without changes
|