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
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Worker protocol: one JSON request on stdin, one JSON response on stdout.
|
|
2
|
+
|
|
3
|
+
``--probe`` prints one JSON line describing the runtime and exits 0 whether or
|
|
4
|
+
not the runtime is usable, so this module stays importable on older Pythons:
|
|
5
|
+
it uses no 3.10-only syntax and imports the scanner only for a scan.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import platform
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
SCHEMA_VERSION = 1
|
|
15
|
+
LANGUAGE = "python"
|
|
16
|
+
RUNTIME_FLOOR = (3, 10)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ProtocolError(Exception):
|
|
20
|
+
"""The request cannot be scanned; the worker exits non-zero."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def probe() -> dict[str, object]:
|
|
24
|
+
"""Describe the interpreter: CPython identity and the 3.10 floor."""
|
|
25
|
+
implementation = platform.python_implementation()
|
|
26
|
+
version = platform.python_version()
|
|
27
|
+
if implementation != "CPython":
|
|
28
|
+
return {"ok": False, "reason": f"expected CPython, found {implementation} {version}"}
|
|
29
|
+
if sys.version_info < RUNTIME_FLOOR:
|
|
30
|
+
floor = ".".join(str(part) for part in RUNTIME_FLOOR)
|
|
31
|
+
return {"ok": False, "reason": f"CPython {version} is below the {floor} floor"}
|
|
32
|
+
return {"ok": True, "runtime": "cpython", "version": version}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_request(raw: bytes) -> dict[str, object]:
|
|
36
|
+
"""Decode and validate the request shape the scanner relies on."""
|
|
37
|
+
try:
|
|
38
|
+
request = json.loads(raw.decode("utf-8"))
|
|
39
|
+
except (UnicodeDecodeError, ValueError) as error:
|
|
40
|
+
raise ProtocolError(f"request is not valid JSON: {error}") from error
|
|
41
|
+
if not isinstance(request, dict):
|
|
42
|
+
raise ProtocolError("request must be a JSON object")
|
|
43
|
+
if request.get("schemaVersion") != SCHEMA_VERSION:
|
|
44
|
+
raise ProtocolError(
|
|
45
|
+
f"unsupported schemaVersion {request.get('schemaVersion')!r}; expected {SCHEMA_VERSION}"
|
|
46
|
+
)
|
|
47
|
+
files = request.get("files")
|
|
48
|
+
if not isinstance(files, list):
|
|
49
|
+
raise ProtocolError("request.files must be an array")
|
|
50
|
+
for index, file in enumerate(files):
|
|
51
|
+
if (
|
|
52
|
+
not isinstance(file, dict)
|
|
53
|
+
or not isinstance(file.get("filePath"), str)
|
|
54
|
+
or not isinstance(file.get("content"), str)
|
|
55
|
+
):
|
|
56
|
+
raise ProtocolError(f"request.files[{index}] must hold filePath and content strings")
|
|
57
|
+
options = request.get("options")
|
|
58
|
+
if options is not None and not isinstance(options, dict):
|
|
59
|
+
raise ProtocolError("request.options must be an object")
|
|
60
|
+
visibility = (options or {}).get("visibility")
|
|
61
|
+
if visibility is not None and (
|
|
62
|
+
not isinstance(visibility, list) or not all(isinstance(v, str) for v in visibility)
|
|
63
|
+
):
|
|
64
|
+
raise ProtocolError("request.options.visibility must be an array of strings")
|
|
65
|
+
return request
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def scan_request(request: dict[str, object]) -> dict[str, object]:
|
|
69
|
+
"""Scan every requested file in request order."""
|
|
70
|
+
from .scanner import scan_file
|
|
71
|
+
|
|
72
|
+
options = request.get("options") or {}
|
|
73
|
+
visibility = options.get("visibility") if isinstance(options, dict) else None
|
|
74
|
+
files = request.get("files")
|
|
75
|
+
scanned = [
|
|
76
|
+
scan_file(file["filePath"], file["content"], visibility)
|
|
77
|
+
for file in (files if isinstance(files, list) else [])
|
|
78
|
+
]
|
|
79
|
+
return {
|
|
80
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
81
|
+
"requestId": request.get("requestId"),
|
|
82
|
+
"language": LANGUAGE,
|
|
83
|
+
"files": scanned,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _write_json_line(payload: dict[str, object]) -> None:
|
|
88
|
+
sys.stdout.buffer.write(json.dumps(payload, ensure_ascii=False).encode("utf-8") + b"\n")
|
|
89
|
+
sys.stdout.buffer.flush()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def main(argv: list[str]) -> int:
|
|
93
|
+
if argv == ["--probe"]:
|
|
94
|
+
_write_json_line(probe())
|
|
95
|
+
return 0
|
|
96
|
+
if argv:
|
|
97
|
+
sys.stderr.write("docbridge-python-scanner: usage: [--probe] < request.json\n")
|
|
98
|
+
return 2
|
|
99
|
+
try:
|
|
100
|
+
request = parse_request(sys.stdin.buffer.read())
|
|
101
|
+
except ProtocolError as error:
|
|
102
|
+
sys.stderr.write(f"docbridge-python-scanner: {error}\n")
|
|
103
|
+
return 1
|
|
104
|
+
_write_json_line(scan_request(request))
|
|
105
|
+
return 0
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Per-file scanning: parse, collect declarations, and build the response file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import tokenize
|
|
7
|
+
|
|
8
|
+
from .comments import TokenIndex
|
|
9
|
+
from .declarations import DeclarationCollector, DeclarationEntry, Entry, Member, UnsupportedEntry
|
|
10
|
+
from .links import INVALID_LINK_TARGET_MESSAGE, DocTarget, is_valid_link_target
|
|
11
|
+
from .positions import LineTable, make_location
|
|
12
|
+
|
|
13
|
+
LANGUAGE = "python"
|
|
14
|
+
DEFAULT_VISIBILITY = ("public",)
|
|
15
|
+
|
|
16
|
+
ResponseFile = dict[str, object]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _empty_response(file_path: str) -> ResponseFile:
|
|
20
|
+
return {
|
|
21
|
+
"filePath": file_path,
|
|
22
|
+
"symbols": [],
|
|
23
|
+
"undocumentedSymbols": [],
|
|
24
|
+
"links": [],
|
|
25
|
+
"diagnostics": [],
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _sentence(message: str) -> str:
|
|
30
|
+
return message if message.endswith(".") else message + "."
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _parse_error(file_path: str, message: str, position: dict[str, int]) -> ResponseFile:
|
|
34
|
+
response = _empty_response(file_path)
|
|
35
|
+
response["diagnostics"] = [
|
|
36
|
+
{
|
|
37
|
+
"severity": "error",
|
|
38
|
+
"code": "code_parse_error",
|
|
39
|
+
"target": file_path,
|
|
40
|
+
"language": LANGUAGE,
|
|
41
|
+
"message": "Python parse error: " + _sentence(message),
|
|
42
|
+
"location": make_location(file_path, position),
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
return response
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _syntax_error_position(error: SyntaxError, table: LineTable) -> dict[str, int]:
|
|
49
|
+
if error.lineno is None or error.offset is None:
|
|
50
|
+
return {"line": 1, "column": 1}
|
|
51
|
+
return {"line": error.lineno, "column": table.column_from_offset(error.lineno, error.offset)}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _tokenize_error_position(error: tokenize.TokenError, table: LineTable) -> dict[str, int]:
|
|
55
|
+
if len(error.args) >= 2 and isinstance(error.args[1], tuple) and len(error.args[1]) == 2:
|
|
56
|
+
row, col = error.args[1]
|
|
57
|
+
return table.position_from_chars(row, col)
|
|
58
|
+
return {"line": 1, "column": 1}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def scan_file(file_path: str, content: str, visibility: list[str] | None) -> ResponseFile:
|
|
62
|
+
"""Scan one file. ``None`` visibility means the default ``["public"]``."""
|
|
63
|
+
table = LineTable(content)
|
|
64
|
+
try:
|
|
65
|
+
module = ast.parse(table.parse_text, file_path, type_comments=False)
|
|
66
|
+
except SyntaxError as error:
|
|
67
|
+
return _parse_error(
|
|
68
|
+
file_path, error.msg or str(error), _syntax_error_position(error, table)
|
|
69
|
+
)
|
|
70
|
+
except (ValueError, RecursionError) as error:
|
|
71
|
+
return _parse_error(file_path, str(error), {"line": 1, "column": 1})
|
|
72
|
+
try:
|
|
73
|
+
tokens = TokenIndex.from_text(table.parse_text)
|
|
74
|
+
except tokenize.TokenError as error:
|
|
75
|
+
message = error.args[0] if error.args else str(error)
|
|
76
|
+
return _parse_error(file_path, str(message), _tokenize_error_position(error, table))
|
|
77
|
+
except SyntaxError as error:
|
|
78
|
+
return _parse_error(
|
|
79
|
+
file_path, error.msg or str(error), _syntax_error_position(error, table)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
entries = DeclarationCollector(table, tokens).collect(module)
|
|
83
|
+
visible = set(DEFAULT_VISIBILITY if visibility is None else visibility)
|
|
84
|
+
return ResponseBuilder(file_path, visible).build(entries)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ResponseBuilder:
|
|
88
|
+
"""Turns collected entries into the worker's per-file response."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, file_path: str, visible: set[str]) -> None:
|
|
91
|
+
self.file_path = file_path
|
|
92
|
+
self.visible = visible
|
|
93
|
+
self.response = _empty_response(file_path)
|
|
94
|
+
self.symbols: list[dict[str, object]] = []
|
|
95
|
+
self.undocumented: list[dict[str, object]] = []
|
|
96
|
+
self.links: list[dict[str, object]] = []
|
|
97
|
+
self.diagnostics: list[dict[str, object]] = []
|
|
98
|
+
|
|
99
|
+
def build(self, entries: list[Entry]) -> ResponseFile:
|
|
100
|
+
for entry in entries:
|
|
101
|
+
if isinstance(entry, UnsupportedEntry):
|
|
102
|
+
self._unsupported(entry.range)
|
|
103
|
+
else:
|
|
104
|
+
self._declaration(entry)
|
|
105
|
+
self.response["symbols"] = self.symbols
|
|
106
|
+
self.response["undocumentedSymbols"] = self.undocumented
|
|
107
|
+
self.response["links"] = self.links
|
|
108
|
+
self.response["diagnostics"] = self.diagnostics
|
|
109
|
+
return self.response
|
|
110
|
+
|
|
111
|
+
def _unsupported(self, name_range: dict[str, dict[str, int]]) -> None:
|
|
112
|
+
self.diagnostics.append(
|
|
113
|
+
{
|
|
114
|
+
"severity": "warning",
|
|
115
|
+
"code": "unsupported_declaration",
|
|
116
|
+
"target": self.file_path,
|
|
117
|
+
"language": LANGUAGE,
|
|
118
|
+
"message": "Python declaration annotated with @doc is not supported.",
|
|
119
|
+
"location": make_location(self.file_path, name_range["start"]),
|
|
120
|
+
"range": name_range,
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def _declaration(self, entry: DeclarationEntry) -> None:
|
|
125
|
+
visibility_class = "private" if entry.private else "public"
|
|
126
|
+
if visibility_class not in self.visible:
|
|
127
|
+
for member in entry.members:
|
|
128
|
+
if member.targets:
|
|
129
|
+
self._unsupported(member.name_range)
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
endpoint = f"{self.file_path}#{entry.canonical_id}"
|
|
133
|
+
symbol = self._symbol(entry, entry.members[0], endpoint)
|
|
134
|
+
# A group's members document the endpoint together. The first annotated
|
|
135
|
+
# declaration owns the links; the endpoint is reported once, at the
|
|
136
|
+
# first annotated repeat, and later repeats are dropped silently.
|
|
137
|
+
annotated = [declaration for declaration in entry.declarations if declaration.targets]
|
|
138
|
+
if not annotated:
|
|
139
|
+
self.undocumented.append(symbol)
|
|
140
|
+
return
|
|
141
|
+
self.symbols.append(symbol)
|
|
142
|
+
self._links(endpoint, annotated[0].targets)
|
|
143
|
+
if len(annotated) > 1:
|
|
144
|
+
self._duplicate_code_symbol(endpoint, annotated[1].first_annotated_member)
|
|
145
|
+
|
|
146
|
+
def _symbol(self, entry: DeclarationEntry, member: Member, endpoint: str) -> dict[str, object]:
|
|
147
|
+
return {
|
|
148
|
+
"kind": "code",
|
|
149
|
+
"language": LANGUAGE,
|
|
150
|
+
"filePath": self.file_path,
|
|
151
|
+
"symbolName": entry.symbol_name,
|
|
152
|
+
"canonicalId": entry.canonical_id,
|
|
153
|
+
"endpoint": endpoint,
|
|
154
|
+
"location": make_location(self.file_path, member.name_range["start"]),
|
|
155
|
+
"nameRange": member.name_range,
|
|
156
|
+
"declarationRange": member.declaration_range,
|
|
157
|
+
"signatureRange": member.signature_range,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
def _duplicate_code_symbol(self, endpoint: str, member: Member) -> None:
|
|
161
|
+
self.diagnostics.append(
|
|
162
|
+
{
|
|
163
|
+
"severity": "error",
|
|
164
|
+
"code": "duplicate_code_symbol",
|
|
165
|
+
"target": endpoint,
|
|
166
|
+
"language": LANGUAGE,
|
|
167
|
+
"message": "Duplicate Python code symbol endpoint: " + endpoint,
|
|
168
|
+
"location": make_location(self.file_path, member.name_range["start"]),
|
|
169
|
+
"range": member.name_range,
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def _links(self, endpoint: str, targets: list[DocTarget]) -> None:
|
|
174
|
+
seen: set[str] = set()
|
|
175
|
+
for target in targets:
|
|
176
|
+
location = make_location(self.file_path, target.start)
|
|
177
|
+
target_range = {"start": target.start, "end": target.end}
|
|
178
|
+
if not is_valid_link_target(target.target, self.file_path):
|
|
179
|
+
self.diagnostics.append(
|
|
180
|
+
{
|
|
181
|
+
"severity": "error",
|
|
182
|
+
"code": "invalid_link_target",
|
|
183
|
+
"target": target.target,
|
|
184
|
+
"language": LANGUAGE,
|
|
185
|
+
"source": endpoint,
|
|
186
|
+
"message": INVALID_LINK_TARGET_MESSAGE,
|
|
187
|
+
"location": location,
|
|
188
|
+
"range": target_range,
|
|
189
|
+
}
|
|
190
|
+
)
|
|
191
|
+
continue
|
|
192
|
+
if target.target in seen:
|
|
193
|
+
self.diagnostics.append(
|
|
194
|
+
{
|
|
195
|
+
"severity": "warning",
|
|
196
|
+
"code": "duplicate_link",
|
|
197
|
+
"target": target.target,
|
|
198
|
+
"language": LANGUAGE,
|
|
199
|
+
"source": endpoint,
|
|
200
|
+
"message": f"Duplicate @doc link from {endpoint} to {target.target}.",
|
|
201
|
+
"location": location,
|
|
202
|
+
"range": target_range,
|
|
203
|
+
}
|
|
204
|
+
)
|
|
205
|
+
continue
|
|
206
|
+
seen.add(target.target)
|
|
207
|
+
self.links.append(
|
|
208
|
+
{
|
|
209
|
+
"source": endpoint,
|
|
210
|
+
"target": target.target,
|
|
211
|
+
"location": location,
|
|
212
|
+
"targetRange": target_range,
|
|
213
|
+
}
|
|
214
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""DocBridge Python scanner worker entrypoint.
|
|
2
|
+
|
|
3
|
+
The core runs this file as ``python3 -I -S <absolute path>``. Isolated mode
|
|
4
|
+
drops the script directory from ``sys.path``, so the directory is inserted
|
|
5
|
+
explicitly before the package import; nothing else is ever added.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
12
|
+
|
|
13
|
+
from docbridge_python_scanner.protocol import main
|
|
14
|
+
|
|
15
|
+
if __name__ == "__main__":
|
|
16
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# DocBridge Ruby scanner worker entrypoint. The core always starts it as
|
|
4
|
+
# `ruby --disable=gems,did_you_mean,error_highlight -W0 <this file>`, so it
|
|
5
|
+
# adds its own lib/ to the load path instead of relying on RubyGems.
|
|
6
|
+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
|
7
|
+
|
|
8
|
+
require "docbridge_ruby_scanner/cli"
|
|
9
|
+
|
|
10
|
+
exit(DocbridgeRubyScanner::CLI.run(ARGV, $stdin, $stdout, $stderr))
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "docbridge_ruby_scanner/probe"
|
|
5
|
+
|
|
6
|
+
module DocbridgeRubyScanner
|
|
7
|
+
# The executable's behaviour: `--probe` prints one JSON line and exits 0;
|
|
8
|
+
# otherwise one request is read from stdin and one response is written to
|
|
9
|
+
# stdout. Stderr carries only error text.
|
|
10
|
+
module CLI
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def run(argv, stdin, stdout, stderr)
|
|
14
|
+
stdout.binmode
|
|
15
|
+
if argv.include?("--probe")
|
|
16
|
+
stdout.write("#{JSON.generate(Probe.result)}\n")
|
|
17
|
+
return 0
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
require "docbridge_ruby_scanner/protocol"
|
|
21
|
+
stdin.binmode
|
|
22
|
+
request = JSON.parse(stdin.read.force_encoding(Encoding::UTF_8))
|
|
23
|
+
stdout.write("#{JSON.generate(Protocol.scan_request(request))}\n")
|
|
24
|
+
0
|
|
25
|
+
rescue JSON::ParserError, KeyError, TypeError, NoMethodError => error
|
|
26
|
+
stderr.write("docbridge-ruby-scanner: invalid request: #{error.message}\n")
|
|
27
|
+
1
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "docbridge_ruby_scanner/compat"
|
|
4
|
+
require "docbridge_ruby_scanner/links"
|
|
5
|
+
|
|
6
|
+
module DocbridgeRubyScanner
|
|
7
|
+
# The contiguous run of full-line `#` comments that ends on the line directly
|
|
8
|
+
# above a declaration. Trailing comments (code before the `#` on the same
|
|
9
|
+
# line), `=begin`/`=end` blocks, and blank lines break the run.
|
|
10
|
+
CommentBlock = Struct.new(:start_offset, :targets)
|
|
11
|
+
|
|
12
|
+
class CommentIndex
|
|
13
|
+
def initialize(parse_result, converter)
|
|
14
|
+
@converter = converter
|
|
15
|
+
@by_line = {}
|
|
16
|
+
content = converter.content
|
|
17
|
+
parse_result.comments.each do |comment|
|
|
18
|
+
next unless Compat.inline_comment?(comment, content)
|
|
19
|
+
|
|
20
|
+
location = comment.location
|
|
21
|
+
next unless full_line?(location.start_offset)
|
|
22
|
+
|
|
23
|
+
@by_line[converter.position(location.start_offset)[:line]] = location
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The comment block attached to a declaration that starts at
|
|
28
|
+
# `start_offset`, or nil when the line above it is not a comment.
|
|
29
|
+
def block_above(start_offset)
|
|
30
|
+
line = @converter.position(start_offset)[:line] - 1
|
|
31
|
+
return nil unless @by_line.key?(line)
|
|
32
|
+
|
|
33
|
+
line -= 1 while @by_line.key?(line - 1)
|
|
34
|
+
lines = []
|
|
35
|
+
while (location = @by_line[line])
|
|
36
|
+
lines << location
|
|
37
|
+
line += 1
|
|
38
|
+
end
|
|
39
|
+
targets = lines.flat_map do |location|
|
|
40
|
+
body_start = location.start_offset + 1
|
|
41
|
+
Links.doc_targets(@converter.slice(body_start, location.end_offset), body_start)
|
|
42
|
+
end
|
|
43
|
+
CommentBlock.new(lines.first.start_offset, targets)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def full_line?(offset)
|
|
49
|
+
content = @converter.content
|
|
50
|
+
line_start = content.byterindex("\n", offset)
|
|
51
|
+
line_start = line_start.nil? ? 0 : line_start + 1
|
|
52
|
+
@converter.slice(line_start, offset).match?(/\A[ \t]*\z/)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module DocbridgeRubyScanner
|
|
6
|
+
# Every Prism accessor whose shape differs between the Prism bundled with
|
|
7
|
+
# CRuby 3.3 (0.19) and the 1.x series bundled with 3.4 and later lives here,
|
|
8
|
+
# so the rest of the worker reads one stable API.
|
|
9
|
+
#
|
|
10
|
+
# Prism 0.19 exposes the last segment of a constant path as
|
|
11
|
+
# `ConstantPathNode#child` (a ConstantReadNode); 0.28 replaced it with
|
|
12
|
+
# `#name` and `#name_loc` and 1.x deprecates `#child`.
|
|
13
|
+
module Compat
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# The name of the last segment of a constant path, or nil when the parser
|
|
17
|
+
# recovered from a missing segment.
|
|
18
|
+
def constant_path_name(node)
|
|
19
|
+
if node.respond_to?(:name_loc)
|
|
20
|
+
node.name
|
|
21
|
+
else
|
|
22
|
+
child = node.child
|
|
23
|
+
child.respond_to?(:name) ? child.name : nil
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The location of the last segment of a constant path.
|
|
28
|
+
def constant_path_name_loc(node)
|
|
29
|
+
node.respond_to?(:name_loc) ? node.name_loc : node.child.location
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# True for a `#` comment; `=begin`/`=end` blocks and `__END__` data are
|
|
33
|
+
# not. The class exists in both versions, but checking the source byte is
|
|
34
|
+
# independent of the comment class hierarchy.
|
|
35
|
+
def inline_comment?(comment, content)
|
|
36
|
+
content.byteslice(comment.location.start_offset, 1) == "#"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|