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.
Files changed (99) hide show
  1. code_standards-7.0.0.dist-info/METADATA +53 -0
  2. code_standards-7.0.0.dist-info/RECORD +99 -0
  3. code_standards-7.0.0.dist-info/WHEEL +4 -0
  4. code_standards-7.0.0.dist-info/entry_points.txt +3 -0
  5. code_standards-7.0.0.dist-info/licenses/LICENSE +21 -0
  6. sarj_standards/__init__.py +30 -0
  7. sarj_standards/__main__.py +5 -0
  8. sarj_standards/_meta.py +22 -0
  9. sarj_standards/api.py +890 -0
  10. sarj_standards/cli/__init__.py +0 -0
  11. sarj_standards/cli/main.py +2466 -0
  12. sarj_standards/configs/cli-reference.v1.json +1 -0
  13. sarj_standards/configs/doctor.config.json +22 -0
  14. sarj_standards/configs/eslint.application.mjs +1366 -0
  15. sarj_standards/configs/eslint.peers.json +44 -0
  16. sarj_standards/configs/eslint.strict.mjs +1060 -0
  17. sarj_standards/configs/markdownlint.strict.yaml +12 -0
  18. sarj_standards/configs/pyright.strict.json +96 -0
  19. sarj_standards/configs/ruff.application.toml +363 -0
  20. sarj_standards/configs/ruff.strict.toml +338 -0
  21. sarj_standards/configs/rule-inventory.v1.json +1 -0
  22. sarj_standards/configs/rule-ledger.json +846 -0
  23. sarj_standards/configs/rule-warning-levels.v1.json +1 -0
  24. sarj_standards/configs/taplo.strict.toml +14 -0
  25. sarj_standards/configs/yamllint.strict.yaml +25 -0
  26. sarj_standards/libs/__init__.py +0 -0
  27. sarj_standards/libs/adoption/__init__.py +0 -0
  28. sarj_standards/libs/adoption/configs.py +36 -0
  29. sarj_standards/libs/adoption/doctor.py +1346 -0
  30. sarj_standards/libs/adoption/exclusions.py +66 -0
  31. sarj_standards/libs/adoption/hooks.py +423 -0
  32. sarj_standards/libs/adoption/launcher.py +240 -0
  33. sarj_standards/libs/adoption/lifecycle.py +493 -0
  34. sarj_standards/libs/adoption/manifest.py +550 -0
  35. sarj_standards/libs/adoption/packagemanager.py +285 -0
  36. sarj_standards/libs/adoption/retired_suppressions.py +371 -0
  37. sarj_standards/libs/adoption/scaffold.py +1660 -0
  38. sarj_standards/libs/adoption/service.py +441 -0
  39. sarj_standards/libs/adoption/transaction.py +274 -0
  40. sarj_standards/libs/adoption/upgrade.py +516 -0
  41. sarj_standards/libs/adoption/uvtool.py +62 -0
  42. sarj_standards/libs/catalogs/__init__.py +9 -0
  43. sarj_standards/libs/catalogs/slack_automations.py +627 -0
  44. sarj_standards/libs/corpus/__init__.py +25 -0
  45. sarj_standards/libs/corpus/manifest.py +211 -0
  46. sarj_standards/libs/corpus/snapshot.py +222 -0
  47. sarj_standards/libs/diagnostics/__init__.py +65 -0
  48. sarj_standards/libs/diagnostics/analysis.schema.json +161 -0
  49. sarj_standards/libs/diagnostics/baseline.py +131 -0
  50. sarj_standards/libs/diagnostics/models.py +574 -0
  51. sarj_standards/libs/diagnostics/serialize.py +290 -0
  52. sarj_standards/libs/diagnostics/source.py +172 -0
  53. sarj_standards/libs/filesystem.py +11 -0
  54. sarj_standards/libs/linting/__init__.py +0 -0
  55. sarj_standards/libs/linting/analysis.py +422 -0
  56. sarj_standards/libs/linting/external.py +1454 -0
  57. sarj_standards/libs/linting/library_policy.py +688 -0
  58. sarj_standards/libs/linting/policy.py +152 -0
  59. sarj_standards/libs/linting/runner.py +442 -0
  60. sarj_standards/libs/linting/textlint.py +1605 -0
  61. sarj_standards/libs/release/__init__.py +98 -0
  62. sarj_standards/libs/release/_values.py +24 -0
  63. sarj_standards/libs/release/artifacts.py +191 -0
  64. sarj_standards/libs/release/causality.py +80 -0
  65. sarj_standards/libs/release/changes.py +48 -0
  66. sarj_standards/libs/release/process.py +128 -0
  67. sarj_standards/libs/release/publish.py +85 -0
  68. sarj_standards/libs/release/registry.py +271 -0
  69. sarj_standards/libs/release/release_age.py +218 -0
  70. sarj_standards/libs/release/rollout.py +1163 -0
  71. sarj_standards/libs/release/tags.py +373 -0
  72. sarj_standards/libs/release/typescript.py +191 -0
  73. sarj_standards/libs/repository/__init__.py +0 -0
  74. sarj_standards/libs/repository/cli_reference_artifact.py +324 -0
  75. sarj_standards/libs/repository/comment_corpus.py +536 -0
  76. sarj_standards/libs/repository/config_generation.py +146 -0
  77. sarj_standards/libs/repository/docs.py +347 -0
  78. sarj_standards/libs/repository/hooks.py +118 -0
  79. sarj_standards/libs/repository/ledger.py +99 -0
  80. sarj_standards/libs/repository/repository.py +744 -0
  81. sarj_standards/libs/repository/rule_authoring.py +246 -0
  82. sarj_standards/libs/repository/rule_catalog_artifact.py +479 -0
  83. sarj_standards/libs/repository/rule_changes.py +318 -0
  84. sarj_standards/libs/repository/rule_inventory_artifact.py +142 -0
  85. sarj_standards/libs/repository/rule_lifecycle.py +167 -0
  86. sarj_standards/libs/repository/rule_maintenance.py +225 -0
  87. sarj_standards/libs/rules/__init__.py +74 -0
  88. sarj_standards/libs/rules/catalog.py +145 -0
  89. sarj_standards/libs/rules/contracts.py +382 -0
  90. sarj_standards/libs/rules/corpus_runner.py +365 -0
  91. sarj_standards/libs/rules/evaluation.py +177 -0
  92. sarj_standards/libs/setup/__init__.py +4 -0
  93. sarj_standards/libs/setup/repository.py +40 -0
  94. sarj_standards/py.typed +0 -0
  95. sarj_standards/schemas/__init__.py +4 -0
  96. sarj_standards/schemas/_paths.py +7 -0
  97. sarj_standards/schemas/rule-catalog.v1.json +1 -0
  98. sarj_standards/schemas/rule-catalog.v1.schema.json +112 -0
  99. 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
+ )
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+
6
+ if TYPE_CHECKING:
7
+ from pathlib import Path
8
+
9
+
10
+ def is_link_like(path: Path) -> bool:
11
+ return path.is_symlink() or path.is_junction()
File without changes