docbridge 0.10.1 → 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.
- package/CHANGELOG.md +74 -1
- package/README.md +16 -9
- package/dist/bin/darwin-arm64/SHA256SUMS +3 -2
- package/dist/bin/darwin-arm64/docbridge-go-scanner +0 -0
- package/dist/bin/darwin-arm64/docbridge-swift-scanner +0 -0
- package/dist/bin/darwin-arm64/docbridge_dart_scanner +0 -0
- package/dist/bin/linux-x64/SHA256SUMS +3 -2
- package/dist/bin/linux-x64/docbridge-go-scanner +0 -0
- package/dist/bin/linux-x64/docbridge-swift-scanner +1 -1
- package/dist/bin/linux-x64/docbridge_dart_scanner +0 -0
- package/dist/index.js +1773 -668
- package/dist/workers/java/docbridge-java-scanner.jar +0 -0
- package/dist/workers/python/docbridge_python_scanner/__init__.py +6 -0
- package/dist/workers/python/docbridge_python_scanner/comments.py +177 -0
- package/dist/workers/python/docbridge_python_scanner/declarations.py +318 -0
- package/dist/workers/python/docbridge_python_scanner/links.py +62 -0
- package/dist/workers/python/docbridge_python_scanner/positions.py +75 -0
- package/dist/workers/python/docbridge_python_scanner/protocol.py +105 -0
- package/dist/workers/python/docbridge_python_scanner/scanner.py +214 -0
- package/dist/workers/python/docbridge_python_scanner.py +16 -0
- package/dist/workers/ruby/bin/docbridge-ruby-scanner +10 -0
- package/dist/workers/ruby/lib/docbridge_ruby_scanner/cli.rb +30 -0
- package/dist/workers/ruby/lib/docbridge_ruby_scanner/comments.rb +55 -0
- package/dist/workers/ruby/lib/docbridge_ruby_scanner/compat.rb +39 -0
- package/dist/workers/ruby/lib/docbridge_ruby_scanner/declarations.rb +230 -0
- package/dist/workers/ruby/lib/docbridge_ruby_scanner/links.rb +38 -0
- package/dist/workers/ruby/lib/docbridge_ruby_scanner/positions.rb +50 -0
- package/dist/workers/ruby/lib/docbridge_ruby_scanner/probe.rb +28 -0
- package/dist/workers/ruby/lib/docbridge_ruby_scanner/protocol.rb +161 -0
- package/dist/workers/ruby/lib/docbridge_ruby_scanner.rb +21 -0
- package/docs/README.md +10 -3
- package/docs/user/automation.md +8 -4
- package/docs/user/commands.md +11 -3
- package/docs/user/configuration.md +105 -13
- package/docs/user/getting-started.md +1 -1
- package/docs/user/linking.md +105 -10
- package/docs/user/troubleshooting.md +23 -10
- package/package.json +1 -1
- package/schemas/common-output.schema.json +11 -1
- package/schemas/context-output.schema.json +1 -1
- package/schemas/docbridge.schema.json +118 -2
- package/schemas/graph-output.schema.json +1 -1
- package/schemas/scanner-worker.schema.json +1 -1
- package/templates/skills/docbridge/SKILL.md +12 -2
|
Binary file
|
|
@@ -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"]}
|