docbridge 0.11.0 → 0.12.0

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 (43) hide show
  1. package/CHANGELOG.md +56 -1
  2. package/README.md +16 -9
  3. package/dist/bin/darwin-arm64/SHA256SUMS +3 -3
  4. package/dist/bin/darwin-arm64/docbridge-go-scanner +0 -0
  5. package/dist/bin/darwin-arm64/docbridge-swift-scanner +0 -0
  6. package/dist/bin/darwin-arm64/docbridge_dart_scanner +0 -0
  7. package/dist/bin/linux-x64/SHA256SUMS +2 -2
  8. package/dist/bin/linux-x64/docbridge-go-scanner +0 -0
  9. package/dist/bin/linux-x64/docbridge-swift-scanner +1 -1
  10. package/dist/index.js +1761 -678
  11. package/dist/workers/java/docbridge-java-scanner.jar +0 -0
  12. package/dist/workers/python/docbridge_python_scanner/__init__.py +6 -0
  13. package/dist/workers/python/docbridge_python_scanner/comments.py +177 -0
  14. package/dist/workers/python/docbridge_python_scanner/declarations.py +318 -0
  15. package/dist/workers/python/docbridge_python_scanner/links.py +62 -0
  16. package/dist/workers/python/docbridge_python_scanner/positions.py +75 -0
  17. package/dist/workers/python/docbridge_python_scanner/protocol.py +105 -0
  18. package/dist/workers/python/docbridge_python_scanner/scanner.py +214 -0
  19. package/dist/workers/python/docbridge_python_scanner.py +16 -0
  20. package/dist/workers/ruby/bin/docbridge-ruby-scanner +10 -0
  21. package/dist/workers/ruby/lib/docbridge_ruby_scanner/cli.rb +30 -0
  22. package/dist/workers/ruby/lib/docbridge_ruby_scanner/comments.rb +55 -0
  23. package/dist/workers/ruby/lib/docbridge_ruby_scanner/compat.rb +39 -0
  24. package/dist/workers/ruby/lib/docbridge_ruby_scanner/declarations.rb +230 -0
  25. package/dist/workers/ruby/lib/docbridge_ruby_scanner/links.rb +38 -0
  26. package/dist/workers/ruby/lib/docbridge_ruby_scanner/positions.rb +50 -0
  27. package/dist/workers/ruby/lib/docbridge_ruby_scanner/probe.rb +28 -0
  28. package/dist/workers/ruby/lib/docbridge_ruby_scanner/protocol.rb +161 -0
  29. package/dist/workers/ruby/lib/docbridge_ruby_scanner.rb +21 -0
  30. package/docs/README.md +10 -3
  31. package/docs/user/automation.md +8 -4
  32. package/docs/user/commands.md +11 -3
  33. package/docs/user/configuration.md +104 -17
  34. package/docs/user/getting-started.md +1 -1
  35. package/docs/user/linking.md +79 -10
  36. package/docs/user/troubleshooting.md +22 -9
  37. package/package.json +1 -1
  38. package/schemas/common-output.schema.json +11 -1
  39. package/schemas/context-output.schema.json +1 -1
  40. package/schemas/docbridge.schema.json +95 -2
  41. package/schemas/graph-output.schema.json +1 -1
  42. package/schemas/scanner-worker.schema.json +1 -1
  43. package/templates/skills/docbridge/SKILL.md +12 -2
@@ -0,0 +1,6 @@
1
+ """DocBridge Python scanner worker.
2
+
3
+ Implements schemaVersion 1 of the DocBridge scanner worker protocol on top of
4
+ the standard library's ``ast`` and ``tokenize`` modules. The package has no
5
+ third-party dependency and runs on CPython 3.10 or newer.
6
+ """
@@ -0,0 +1,177 @@
1
+ """Annotation sources: leading ``#`` comment blocks and docstrings.
2
+
3
+ Both sources are read from the original source text through ``tokenize``
4
+ positions and ``ast`` node offsets, never from ``ast.get_docstring()``, so
5
+ every ``@doc`` target keeps the position it has in the file.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import bisect
12
+ import io
13
+ import re
14
+ import tokenize
15
+ from dataclasses import dataclass
16
+
17
+ from .links import DocTarget, find_doc_matches
18
+ from .positions import LineTable
19
+
20
+ STRING_PREFIX_PATTERN = re.compile(r"[a-zA-Z]*")
21
+ QUOTES = ('"""', "'''", '"', "'")
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class CommentLine:
26
+ """A comment that is the only content of its line."""
27
+
28
+ row: int
29
+ col: int
30
+ text: str
31
+
32
+
33
+ class TokenIndex:
34
+ """Tokens of one successfully parsed file, addressable by position."""
35
+
36
+ def __init__(self, tokens: list[tokenize.TokenInfo]) -> None:
37
+ self.tokens = tokens
38
+ self._starts = [token.start for token in tokens]
39
+ self.own_line_comments: dict[int, CommentLine] = {}
40
+ for index, token in enumerate(tokens):
41
+ if token.type != tokenize.COMMENT:
42
+ continue
43
+ follower = tokens[index + 1] if index + 1 < len(tokens) else None
44
+ if follower is not None and follower.type == tokenize.NL:
45
+ row, col = token.start
46
+ self.own_line_comments[row] = CommentLine(row, col, token.string[1:])
47
+
48
+ @classmethod
49
+ def from_text(cls, parse_text: str) -> TokenIndex:
50
+ return cls(list(tokenize.generate_tokens(io.StringIO(parse_text).readline)))
51
+
52
+ def index_at(self, row: int, col: int) -> int:
53
+ """Return the index of the first token starting at or after ``(row, col)``."""
54
+ return bisect.bisect_left(self._starts, (row, col))
55
+
56
+ def token_at(self, row: int, col: int) -> tokenize.TokenInfo | None:
57
+ index = self.index_at(row, col)
58
+ if index < len(self.tokens) and self.tokens[index].start == (row, col):
59
+ return self.tokens[index]
60
+ return None
61
+
62
+ def leading_comment_block(self, row_above: int, col: int) -> list[CommentLine]:
63
+ """Return the contiguous comment-only lines ending at ``row_above``.
64
+
65
+ Every line of the block starts its comment at ``col``; a blank line, a
66
+ code line, or a comment at another column ends the block.
67
+ """
68
+ block: list[CommentLine] = []
69
+ row = row_above
70
+ while row >= 1:
71
+ comment = self.own_line_comments.get(row)
72
+ if comment is None or comment.col != col:
73
+ break
74
+ block.append(comment)
75
+ row -= 1
76
+ block.reverse()
77
+ return block
78
+
79
+
80
+ def comment_block_targets(block: list[CommentLine], table: LineTable) -> list[DocTarget]:
81
+ """Find ``@doc`` targets in each comment line's text after ``#``."""
82
+ targets: list[DocTarget] = []
83
+ for comment in block:
84
+ body_col = comment.col + 1
85
+ for match in find_doc_matches(comment.text):
86
+ targets.append(
87
+ DocTarget(
88
+ target=match.target,
89
+ start=table.position_from_chars(comment.row, body_col + match.start),
90
+ end=table.position_from_chars(comment.row, body_col + match.end),
91
+ )
92
+ )
93
+ return targets
94
+
95
+
96
+ def docstring_node(body: list[ast.stmt]) -> ast.Expr | None:
97
+ """Return the docstring statement of a body, when its first statement is one."""
98
+ if not body:
99
+ return None
100
+ first = body[0]
101
+ if (
102
+ isinstance(first, ast.Expr)
103
+ and isinstance(first.value, ast.Constant)
104
+ and isinstance(first.value.value, str)
105
+ ):
106
+ return first
107
+ return None
108
+
109
+
110
+ def _source_text(table: LineTable, start: tuple[int, int], end: tuple[int, int]) -> str:
111
+ """Return the parse text between two (row, code point col) positions."""
112
+ start_row, start_col = start
113
+ end_row, end_col = end
114
+ if start_row == end_row:
115
+ return table.line_text(start_row)[start_col:end_col]
116
+ pieces = [table.line_text(start_row)[start_col:]]
117
+ pieces.extend(table.line_text(row) for row in range(start_row + 1, end_row))
118
+ pieces.append(table.line_text(end_row)[:end_col])
119
+ return "\n".join(pieces)
120
+
121
+
122
+ def _string_body_span(text: str) -> tuple[int, int]:
123
+ """Return the offsets of a string literal's content, excluding its delimiters."""
124
+ prefix_match = STRING_PREFIX_PATTERN.match(text)
125
+ prefix_length = prefix_match.end() if prefix_match else 0
126
+ rest = text[prefix_length:]
127
+ for quote in QUOTES:
128
+ if rest.startswith(quote) and rest.endswith(quote) and len(rest) >= 2 * len(quote):
129
+ return prefix_length + len(quote), len(text) - len(quote)
130
+ return prefix_length, len(text)
131
+
132
+
133
+ def docstring_targets(node: ast.Expr, table: LineTable, tokens: TokenIndex) -> list[DocTarget]:
134
+ """Find ``@doc`` targets in each literal of a docstring, delimiters excluded.
135
+
136
+ A docstring may be parenthesized or implicitly concatenated, so its span
137
+ holds several ``STRING`` tokens between brackets and comments. Each
138
+ literal's body, without its prefix and quotes, is searched on its own, so
139
+ a target never absorbs a closing quote or bracket or continues into the
140
+ next literal. A ``str`` constant never contains an f-string, so every
141
+ literal is a plain ``STRING`` token.
142
+ """
143
+ start_row = node.lineno
144
+ start_col = table.chars_from_bytes(start_row, node.col_offset)
145
+ end_row = node.end_lineno if node.end_lineno is not None else start_row
146
+ end_col = table.chars_from_bytes(
147
+ end_row, node.end_col_offset if node.end_col_offset is not None else 0
148
+ )
149
+ targets: list[DocTarget] = []
150
+ for token in tokens.tokens[tokens.index_at(start_row, start_col) :]:
151
+ if token.start >= (end_row, end_col):
152
+ break
153
+ if token.type == tokenize.STRING:
154
+ targets.extend(_literal_targets(token, table))
155
+ return targets
156
+
157
+
158
+ def _literal_targets(token: tokenize.TokenInfo, table: LineTable) -> list[DocTarget]:
159
+ """Find ``@doc`` targets in one string literal's body, at their source positions."""
160
+ start_row, start_col = token.start
161
+ text = _source_text(table, token.start, token.end)
162
+ body_start, body_end = _string_body_span(text)
163
+
164
+ targets: list[DocTarget] = []
165
+ for match in find_doc_matches(text[body_start:body_end]):
166
+ offset = body_start + match.start
167
+ line_break = text.rfind("\n", 0, offset)
168
+ row = start_row + text.count("\n", 0, offset)
169
+ col = offset - (line_break + 1) if line_break >= 0 else start_col + offset
170
+ targets.append(
171
+ DocTarget(
172
+ target=match.target,
173
+ start=table.position_from_chars(row, col),
174
+ end=table.position_from_chars(row, col + len(match.target)),
175
+ )
176
+ )
177
+ return targets
@@ -0,0 +1,318 @@
1
+ """Declaration walk: supported definitions, grouping, visibility, and ranges.
2
+
3
+ The walk visits ``def``, ``async def``, and ``class`` at module level and in
4
+ class bodies, recursively for classes, descending into ``if``, ``try``,
5
+ ``with``, ``for``, ``while``, and ``match`` blocks at those levels and never
6
+ into function bodies.
7
+
8
+ Each endpoint collects its declarations in source order. A declaration is a
9
+ single definition or a group, and a group is either a property chain (a
10
+ function decorated ``property``, ``cached_property``, or ``@<name>.getter``,
11
+ ``.setter``, or ``.deleter`` with its own name, followed by functions of that
12
+ name decorated with such an accessor) or a run of consecutive ``overload``
13
+ stubs plus at most one implementation, the first following function of that
14
+ name not decorated ``overload``. A same-name definition that a group does not
15
+ accept closes it and starts the next declaration of the endpoint, which
16
+ repeats it.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import ast
22
+ import enum
23
+ import tokenize
24
+ from dataclasses import dataclass
25
+
26
+ from .comments import (
27
+ TokenIndex,
28
+ comment_block_targets,
29
+ docstring_node,
30
+ docstring_targets,
31
+ )
32
+ from .links import DocTarget
33
+ from .positions import LineTable
34
+
35
+ PROPERTY_DECORATORS = frozenset({"property", "cached_property"})
36
+ ACCESSOR_ATTRIBUTES = frozenset({"getter", "setter", "deleter"})
37
+ OVERLOAD_DECORATORS = frozenset({"overload"})
38
+ OPENING_BRACKETS = frozenset({"(", "[", "{"})
39
+ CLOSING_BRACKETS = frozenset({")", "]", "}"})
40
+
41
+ DefinitionNode = ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef
42
+
43
+
44
+ @dataclass
45
+ class Member:
46
+ """One ``def`` / ``async def`` / ``class`` node contributing to an endpoint."""
47
+
48
+ name_range: dict[str, dict[str, int]]
49
+ declaration_range: dict[str, dict[str, int]]
50
+ signature_range: dict[str, dict[str, int]]
51
+ targets: list[DocTarget]
52
+
53
+
54
+ class GroupState(enum.Enum):
55
+ """Whether a declaration still accepts later same-name definitions."""
56
+
57
+ CLOSED = enum.auto()
58
+ PROPERTY_CHAIN = enum.auto()
59
+ OVERLOAD_STUBS = enum.auto()
60
+
61
+
62
+ @dataclass
63
+ class Declaration:
64
+ """One declaration of an endpoint: a single definition or a group."""
65
+
66
+ members: list[Member]
67
+ state: GroupState
68
+
69
+ @classmethod
70
+ def start(cls, member: Member, node: DefinitionNode) -> Declaration:
71
+ """Start a declaration; a property function or overload stub opens a group."""
72
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
73
+ return cls([member], GroupState.CLOSED)
74
+ if has_named_decorator(node, OVERLOAD_DECORATORS):
75
+ return cls([member], GroupState.OVERLOAD_STUBS)
76
+ if has_named_decorator(node, PROPERTY_DECORATORS) or is_property_accessor(node):
77
+ return cls([member], GroupState.PROPERTY_CHAIN)
78
+ return cls([member], GroupState.CLOSED)
79
+
80
+ @property
81
+ def targets(self) -> list[DocTarget]:
82
+ return [target for member in self.members for target in member.targets]
83
+
84
+ @property
85
+ def first_annotated_member(self) -> Member:
86
+ return next(member for member in self.members if member.targets)
87
+
88
+ def accepts(self, node: DefinitionNode) -> bool:
89
+ """Whether ``node``, a later definition of the same name, joins this group."""
90
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
91
+ return False
92
+ if self.state is GroupState.PROPERTY_CHAIN:
93
+ return is_property_accessor(node)
94
+ return self.state is GroupState.OVERLOAD_STUBS
95
+
96
+ def join(self, member: Member, node: DefinitionNode) -> None:
97
+ """Add an accepted definition; an implementation closes an overload group."""
98
+ self.members.append(member)
99
+ if self.state is GroupState.OVERLOAD_STUBS and not has_named_decorator(
100
+ node, OVERLOAD_DECORATORS
101
+ ):
102
+ self.state = GroupState.CLOSED
103
+
104
+
105
+ @dataclass
106
+ class DeclarationEntry:
107
+ """A supported endpoint: its first member owns the ranges."""
108
+
109
+ symbol_name: str
110
+ canonical_id: str
111
+ private: bool
112
+ declarations: list[Declaration]
113
+
114
+ @property
115
+ def members(self) -> list[Member]:
116
+ return [member for declaration in self.declarations for member in declaration.members]
117
+
118
+ def add(self, member: Member, node: DefinitionNode) -> None:
119
+ """Join the open group that accepts ``node``, else start a new declaration."""
120
+ last = self.declarations[-1] if self.declarations else None
121
+ if last is not None and last.accepts(node):
122
+ last.join(member, node)
123
+ else:
124
+ self.declarations.append(Declaration.start(member, node))
125
+
126
+
127
+ @dataclass
128
+ class UnsupportedEntry:
129
+ """A statement that is not a supported declaration but carries ``@doc``."""
130
+
131
+ range: dict[str, dict[str, int]]
132
+ targets: list[DocTarget]
133
+
134
+
135
+ Entry = DeclarationEntry | UnsupportedEntry
136
+
137
+
138
+ def is_dunder(name: str) -> bool:
139
+ return len(name) > 4 and name.startswith("__") and name.endswith("__")
140
+
141
+
142
+ def is_private_name(name: str) -> bool:
143
+ return name.startswith("_") and not is_dunder(name)
144
+
145
+
146
+ def decorator_name(decorator: ast.expr) -> str | None:
147
+ """The last segment of a ``Name`` or ``Attribute`` decorator; ``Call`` has none."""
148
+ if isinstance(decorator, ast.Name):
149
+ return decorator.id
150
+ if isinstance(decorator, ast.Attribute):
151
+ return decorator.attr
152
+ return None
153
+
154
+
155
+ def has_named_decorator(node: DefinitionNode, names: frozenset[str]) -> bool:
156
+ return any(decorator_name(decorator) in names for decorator in node.decorator_list)
157
+
158
+
159
+ def is_property_accessor(node: DefinitionNode) -> bool:
160
+ """Decorated ``@<name>.getter``, ``.setter``, or ``.deleter`` with its own name."""
161
+ return any(
162
+ isinstance(decorator, ast.Attribute)
163
+ and decorator.attr in ACCESSOR_ATTRIBUTES
164
+ and isinstance(decorator.value, ast.Name)
165
+ and decorator.value.id == node.name
166
+ for decorator in node.decorator_list
167
+ )
168
+
169
+
170
+ class DeclarationCollector:
171
+ """Collects entries in source order for one parsed file.
172
+
173
+ Endpoints are kept per file, keyed by canonical ID, so a class declared
174
+ twice (an ``if``/``else`` pair, say) contributes the members of both
175
+ bodies to the same member endpoints.
176
+ """
177
+
178
+ def __init__(self, table: LineTable, tokens: TokenIndex) -> None:
179
+ self.table = table
180
+ self.tokens = tokens
181
+ self.entries: list[Entry] = []
182
+ self.endpoints: dict[str, DeclarationEntry] = {}
183
+
184
+ def collect(self, module: ast.Module) -> list[Entry]:
185
+ self._module_docstring(module)
186
+ self._walk(module.body, prefix="", private=False)
187
+ return self.entries
188
+
189
+ def _module_docstring(self, module: ast.Module) -> None:
190
+ docstring = docstring_node(module.body)
191
+ if docstring is None:
192
+ return
193
+ targets = docstring_targets(docstring, self.table, self.tokens)
194
+ if targets:
195
+ self.entries.append(UnsupportedEntry(self._node_range(docstring), targets))
196
+
197
+ def _walk(self, body: list[ast.stmt], prefix: str, private: bool) -> None:
198
+ for statement in body:
199
+ if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
200
+ self._definition(statement, prefix, private)
201
+ continue
202
+ self._unsupported_statement(statement)
203
+ for block in self._nested_blocks(statement):
204
+ self._walk(block, prefix, private)
205
+
206
+ @staticmethod
207
+ def _nested_blocks(statement: ast.stmt) -> list[list[ast.stmt]]:
208
+ if isinstance(statement, (ast.If, ast.For, ast.AsyncFor, ast.While)):
209
+ return [statement.body, statement.orelse]
210
+ if isinstance(statement, (ast.With, ast.AsyncWith)):
211
+ return [statement.body]
212
+ if isinstance(statement, ast.Try) or (
213
+ hasattr(ast, "TryStar") and isinstance(statement, ast.TryStar)
214
+ ):
215
+ handlers = [handler.body for handler in statement.handlers]
216
+ return [statement.body, *handlers, statement.orelse, statement.finalbody]
217
+ if isinstance(statement, ast.Match):
218
+ return [case.body for case in statement.cases]
219
+ return []
220
+
221
+ def _unsupported_statement(self, statement: ast.stmt) -> None:
222
+ """Report a leading ``@doc`` comment on a statement that is no declaration."""
223
+ column = self.table.chars_from_bytes(statement.lineno, statement.col_offset)
224
+ block = self.tokens.leading_comment_block(statement.lineno - 1, column)
225
+ targets = comment_block_targets(block, self.table)
226
+ if not targets:
227
+ return
228
+ token = self.tokens.token_at(statement.lineno, column)
229
+ end = token.end if token is not None else (statement.lineno, column)
230
+ self.entries.append(
231
+ UnsupportedEntry(
232
+ {
233
+ "start": self.table.position_from_chars(statement.lineno, column),
234
+ "end": self.table.position_from_chars(*end),
235
+ },
236
+ targets,
237
+ )
238
+ )
239
+
240
+ def _definition(self, node: DefinitionNode, prefix: str, private: bool) -> None:
241
+ canonical_id = prefix + node.name
242
+ entry = self.endpoints.get(canonical_id)
243
+ if entry is None:
244
+ entry = DeclarationEntry(
245
+ symbol_name=node.name,
246
+ canonical_id=canonical_id,
247
+ private=private or is_private_name(node.name),
248
+ declarations=[],
249
+ )
250
+ self.endpoints[canonical_id] = entry
251
+ self.entries.append(entry)
252
+ entry.add(self._member(node), node)
253
+
254
+ if isinstance(node, ast.ClassDef):
255
+ self._walk(
256
+ node.body,
257
+ prefix=canonical_id + ".",
258
+ private=private or is_private_name(node.name),
259
+ )
260
+
261
+ def _member(self, node: DefinitionNode) -> Member:
262
+ keyword_col = self.table.chars_from_bytes(node.lineno, node.col_offset)
263
+ first_line = node.decorator_list[0].lineno if node.decorator_list else node.lineno
264
+ block = self.tokens.leading_comment_block(first_line - 1, keyword_col)
265
+ targets = comment_block_targets(block, self.table)
266
+ docstring = docstring_node(node.body)
267
+ if docstring is not None:
268
+ targets.extend(docstring_targets(docstring, self.table, self.tokens))
269
+
270
+ start_row = block[0].row if block else first_line
271
+ start = self.table.position_from_chars(start_row, keyword_col)
272
+ name_token, signature_end = self._header_tokens(node, keyword_col)
273
+ end_row = node.end_lineno if node.end_lineno is not None else node.lineno
274
+ end_col = node.end_col_offset if node.end_col_offset is not None else 0
275
+
276
+ return Member(
277
+ name_range={
278
+ "start": self.table.position_from_chars(*name_token.start),
279
+ "end": self.table.position_from_chars(*name_token.end),
280
+ },
281
+ declaration_range={
282
+ "start": start,
283
+ "end": self.table.position_from_bytes(end_row, end_col),
284
+ },
285
+ signature_range={"start": start, "end": self.table.position_from_chars(*signature_end)},
286
+ targets=targets,
287
+ )
288
+
289
+ def _header_tokens(
290
+ self, node: DefinitionNode, keyword_col: int
291
+ ) -> tuple[tokenize.TokenInfo, tuple[int, int]]:
292
+ """Return the name token and the end of the ``:`` that closes the header."""
293
+ keyword = "class" if isinstance(node, ast.ClassDef) else "def"
294
+ tokens = self.tokens.tokens
295
+ index = self.tokens.index_at(node.lineno, keyword_col)
296
+ while tokens[index].type != tokenize.NAME or tokens[index].string != keyword:
297
+ index += 1
298
+ name_token = tokens[index + 1]
299
+
300
+ depth = 0
301
+ for token in tokens[index + 2 :]:
302
+ if token.type != tokenize.OP:
303
+ continue
304
+ if token.string in OPENING_BRACKETS:
305
+ depth += 1
306
+ elif token.string in CLOSING_BRACKETS:
307
+ depth -= 1
308
+ elif token.string == ":" and depth == 0:
309
+ return name_token, token.end
310
+ return name_token, name_token.end
311
+
312
+ def _node_range(self, node: ast.stmt) -> dict[str, dict[str, int]]:
313
+ end_row = node.end_lineno if node.end_lineno is not None else node.lineno
314
+ end_col = node.end_col_offset if node.end_col_offset is not None else 0
315
+ return {
316
+ "start": self.table.position_from_bytes(node.lineno, node.col_offset),
317
+ "end": self.table.position_from_bytes(end_row, end_col),
318
+ }
@@ -0,0 +1,62 @@
1
+ """``@doc`` target extraction and link-target validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ # Whitespace means the ASCII set: space, tab, LF, CR, FF, and VT. A Unicode
9
+ # space such as U+00A0 neither separates a target nor ends one.
10
+ DOC_TARGET_PATTERN = re.compile(r"@doc\s+(\S+)", re.ASCII)
11
+ WHITESPACE_PATTERN = re.compile(r"\s", re.ASCII)
12
+
13
+ INVALID_LINK_TARGET_MESSAGE = (
14
+ "Link target must be a project-root-relative file path and fragment in file#fragment form."
15
+ )
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class DocTarget:
20
+ """One ``@doc`` target with the 1-based UTF-16 range of its text."""
21
+
22
+ target: str
23
+ start: dict[str, int]
24
+ end: dict[str, int]
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class DocMatch:
29
+ """A target and its code point offsets, relative to the searched text."""
30
+
31
+ target: str
32
+ start: int
33
+ end: int
34
+
35
+
36
+ def find_doc_matches(text: str) -> list[DocMatch]:
37
+ """Match ``@doc\\s+(\\S+)`` over ``text`` and return each target's offsets."""
38
+ return [
39
+ DocMatch(target=match.group(1), start=match.start(1), end=match.end(1))
40
+ for match in DOC_TARGET_PATTERN.finditer(text)
41
+ ]
42
+
43
+
44
+ def _has_whitespace(value: str) -> bool:
45
+ return WHITESPACE_PATTERN.search(value) is not None
46
+
47
+
48
+ def is_valid_link_target(target: str, source_file_path: str) -> bool:
49
+ """Mirror the core's ``parseLinkTarget`` rules for a ``file#fragment`` target."""
50
+ parts = target.split("#")
51
+ if len(parts) != 2:
52
+ return False
53
+ file_path, fragment = parts
54
+ if not file_path or not fragment:
55
+ return False
56
+ if file_path.startswith(("/", "./", "../")):
57
+ return False
58
+ if "\\" in file_path or _has_whitespace(file_path) or _has_whitespace(fragment):
59
+ return False
60
+ if ".." in file_path.split("/"):
61
+ return False
62
+ return file_path != source_file_path
@@ -0,0 +1,75 @@
1
+ """Position conversion: three column systems into 1-based UTF-16 columns.
2
+
3
+ CPython reports columns in three ways that must be kept apart:
4
+
5
+ - ``ast`` ``col_offset`` / ``end_col_offset`` are 0-based UTF-8 byte offsets
6
+ into the line;
7
+ - ``tokenize`` columns are 0-based Unicode code point indexes;
8
+ - ``SyntaxError.offset`` is 1-based in code points.
9
+
10
+ DocBridge positions are 1-based lines and 1-based UTF-16 code unit columns,
11
+ with end-exclusive ranges. Lines split on ``\\n`` only, so a CRLF file keeps
12
+ its ``\\r`` inside the line, after every token. A UTF-8 BOM is removed before
13
+ parsing, because CPython rejects it inside a ``str``, and is counted back in as
14
+ the first character of line 1.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ BOM = ""
20
+
21
+
22
+ def utf16_length(text: str) -> int:
23
+ """Return the number of UTF-16 code units needed to encode ``text``."""
24
+ return len(text) + sum(1 for character in text if ord(character) >= 0x10000)
25
+
26
+
27
+ class LineTable:
28
+ """Line-based column conversion for one file's content."""
29
+
30
+ def __init__(self, content: str) -> None:
31
+ self.has_bom = content.startswith(BOM)
32
+ self.parse_text = content[len(BOM) :] if self.has_bom else content
33
+ self.lines = self.parse_text.split("\n")
34
+
35
+ def line_text(self, line: int) -> str:
36
+ """Return the parse text of a 1-based line, or ``""`` outside the file."""
37
+ if 1 <= line <= len(self.lines):
38
+ return self.lines[line - 1]
39
+ return ""
40
+
41
+ def column_from_chars(self, line: int, chars: int) -> int:
42
+ """Convert a 0-based code point index (``tokenize``) into a column."""
43
+ text = self.line_text(line)
44
+ chars = max(0, min(chars, len(text)))
45
+ bom_units = 1 if self.has_bom and line == 1 else 0
46
+ return utf16_length(text[:chars]) + 1 + bom_units
47
+
48
+ def column_from_bytes(self, line: int, byte_offset: int) -> int:
49
+ """Convert a 0-based UTF-8 byte offset (``ast``) into a column."""
50
+ encoded = self.line_text(line).encode("utf-8")
51
+ prefix = encoded[: max(0, byte_offset)].decode("utf-8", errors="ignore")
52
+ return self.column_from_chars(line, len(prefix))
53
+
54
+ def column_from_offset(self, line: int, offset: int) -> int:
55
+ """Convert a 1-based code point offset (``SyntaxError``) into a column."""
56
+ return self.column_from_chars(line, offset - 1)
57
+
58
+ def chars_from_bytes(self, line: int, byte_offset: int) -> int:
59
+ """Convert a 0-based UTF-8 byte offset into a 0-based code point index."""
60
+ encoded = self.line_text(line).encode("utf-8")
61
+ return len(encoded[: max(0, byte_offset)].decode("utf-8", errors="ignore"))
62
+
63
+ def position_from_chars(self, line: int, chars: int) -> dict[str, int]:
64
+ return {"line": line, "column": self.column_from_chars(line, chars)}
65
+
66
+ def position_from_bytes(self, line: int, byte_offset: int) -> dict[str, int]:
67
+ return {"line": line, "column": self.column_from_bytes(line, byte_offset)}
68
+
69
+
70
+ def make_range(start: dict[str, int], end: dict[str, int]) -> dict[str, dict[str, int]]:
71
+ return {"start": start, "end": end}
72
+
73
+
74
+ def make_location(file_path: str, position: dict[str, int]) -> dict[str, object]:
75
+ return {"filePath": file_path, "line": position["line"], "column": position["column"]}