doc-code 0.1.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.
- doc_code/__init__.py +3 -0
- doc_code/ai.py +206 -0
- doc_code/cli.py +593 -0
- doc_code/config.py +362 -0
- doc_code/editor.py +496 -0
- doc_code/errors.py +21 -0
- doc_code/git.py +74 -0
- doc_code/py.typed +1 -0
- doc_code/scope.py +105 -0
- doc_code/symbols.py +594 -0
- doc_code-0.1.0.dist-info/METADATA +138 -0
- doc_code-0.1.0.dist-info/RECORD +16 -0
- doc_code-0.1.0.dist-info/WHEEL +5 -0
- doc_code-0.1.0.dist-info/entry_points.txt +2 -0
- doc_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- doc_code-0.1.0.dist-info/top_level.txt +1 -0
doc_code/symbols.py
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
"""Language-aware source symbol discovery and deterministic documentation rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import textwrap
|
|
7
|
+
from collections import Counter, defaultdict
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from dataclasses import dataclass, field, replace
|
|
10
|
+
|
|
11
|
+
from tree_sitter import Language, Node, Parser
|
|
12
|
+
from tree_sitter_javascript import language as javascript_language
|
|
13
|
+
from tree_sitter_typescript import language_tsx, language_typescript
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Symbol:
|
|
18
|
+
"""Store immutable metadata for a source symbol."""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
kind: str
|
|
22
|
+
line: int
|
|
23
|
+
end_line: int
|
|
24
|
+
indent: str
|
|
25
|
+
args: tuple[str, ...] = ()
|
|
26
|
+
has_doc: bool = False
|
|
27
|
+
doc_start: int | None = None
|
|
28
|
+
doc_end: int | None = None
|
|
29
|
+
body_line: int | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Documentation:
|
|
34
|
+
"""Generated documentation for one symbol and its named arguments."""
|
|
35
|
+
|
|
36
|
+
description: str
|
|
37
|
+
arguments: Mapping[str, str] = field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _unique_symbol_names(symbols: list[Symbol]) -> list[Symbol]:
|
|
41
|
+
"""Disambiguate valid redefinitions without changing already unique public names."""
|
|
42
|
+
totals = Counter(symbol.name for symbol in symbols)
|
|
43
|
+
occurrences: defaultdict[tuple[str, int], int] = defaultdict(int)
|
|
44
|
+
normalized: list[Symbol] = []
|
|
45
|
+
for symbol in symbols:
|
|
46
|
+
if totals[symbol.name] == 1:
|
|
47
|
+
normalized.append(symbol)
|
|
48
|
+
continue
|
|
49
|
+
location = (symbol.name, symbol.line)
|
|
50
|
+
occurrences[location] += 1
|
|
51
|
+
normalized.append(
|
|
52
|
+
replace(symbol, name=f"{symbol.name}@L{symbol.line}:{occurrences[location]}")
|
|
53
|
+
)
|
|
54
|
+
return normalized
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _statement_start_line(node: ast.stmt) -> int:
|
|
58
|
+
"""Return the first source line belonging to a statement, including decorators."""
|
|
59
|
+
decorators = (
|
|
60
|
+
node.decorator_list
|
|
61
|
+
if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef))
|
|
62
|
+
else ()
|
|
63
|
+
)
|
|
64
|
+
return min((node.lineno, *(decorator.lineno for decorator in decorators)))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _docstring_expression(node: ast.stmt | None) -> ast.Expr | None:
|
|
68
|
+
"""Return a statement when it is a string literal suitable for a docstring."""
|
|
69
|
+
if (
|
|
70
|
+
isinstance(node, ast.Expr)
|
|
71
|
+
and isinstance(node.value, ast.Constant)
|
|
72
|
+
and isinstance(node.value.value, str)
|
|
73
|
+
):
|
|
74
|
+
return node
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _python_arguments(arguments: ast.arguments) -> tuple[str, ...]:
|
|
79
|
+
"""Return every named function parameter except conventional instance parameters."""
|
|
80
|
+
positional = (*arguments.posonlyargs, *arguments.args)
|
|
81
|
+
named = [argument.arg for argument in positional if argument.arg not in {"self", "cls"}]
|
|
82
|
+
if arguments.vararg:
|
|
83
|
+
named.append(arguments.vararg.arg)
|
|
84
|
+
named.extend(argument.arg for argument in arguments.kwonlyargs)
|
|
85
|
+
if arguments.kwarg:
|
|
86
|
+
named.append(arguments.kwarg.arg)
|
|
87
|
+
return tuple(named)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def python_symbols(content: str, filename: str = "<unknown>") -> list[Symbol]:
|
|
91
|
+
"""Discover module, class, and function symbols in Python source."""
|
|
92
|
+
tree = ast.parse(content, filename=filename)
|
|
93
|
+
lines = content.splitlines()
|
|
94
|
+
found: list[Symbol] = []
|
|
95
|
+
module_doc = _docstring_expression(tree.body[0] if tree.body else None)
|
|
96
|
+
found.append(
|
|
97
|
+
Symbol(
|
|
98
|
+
"module",
|
|
99
|
+
"module",
|
|
100
|
+
0,
|
|
101
|
+
0,
|
|
102
|
+
"",
|
|
103
|
+
(),
|
|
104
|
+
module_doc is not None,
|
|
105
|
+
module_doc.lineno if module_doc else None,
|
|
106
|
+
module_doc.end_lineno if module_doc else None,
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def visit(nodes: list[ast.stmt], prefix: str = "") -> None:
|
|
111
|
+
"""Visit nested Python declarations recursively."""
|
|
112
|
+
for node in nodes:
|
|
113
|
+
if not isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
114
|
+
continue
|
|
115
|
+
name = f"{prefix}.{node.name}" if prefix else node.name
|
|
116
|
+
first = node.body[0] if node.body else None
|
|
117
|
+
docstring = _docstring_expression(first)
|
|
118
|
+
args = (
|
|
119
|
+
_python_arguments(node.args)
|
|
120
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
121
|
+
else ()
|
|
122
|
+
)
|
|
123
|
+
found.append(
|
|
124
|
+
Symbol(
|
|
125
|
+
name,
|
|
126
|
+
"class" if isinstance(node, ast.ClassDef) else "function",
|
|
127
|
+
node.lineno,
|
|
128
|
+
node.end_lineno or node.lineno,
|
|
129
|
+
lines[node.lineno - 1][
|
|
130
|
+
: len(lines[node.lineno - 1]) - len(lines[node.lineno - 1].lstrip())
|
|
131
|
+
],
|
|
132
|
+
args,
|
|
133
|
+
docstring is not None,
|
|
134
|
+
docstring.lineno if docstring else None,
|
|
135
|
+
docstring.end_lineno if docstring else None,
|
|
136
|
+
_statement_start_line(first) if first else None,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
visit(node.body, name)
|
|
140
|
+
|
|
141
|
+
visit(tree.body)
|
|
142
|
+
return _unique_symbol_names(found)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
_JAVASCRIPT = Language(javascript_language())
|
|
146
|
+
_TYPESCRIPT = Language(language_typescript())
|
|
147
|
+
_TSX = Language(language_tsx())
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _node_text(node: Node | None, source: bytes) -> str:
|
|
151
|
+
"""Return the UTF-8 source slice represented by an AST node."""
|
|
152
|
+
return source[node.start_byte : node.end_byte].decode() if node else ""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _parameter_names(node: Node | None, source: bytes) -> tuple[str, ...]:
|
|
156
|
+
"""Extract named parameter bindings from a JavaScript or TypeScript AST node."""
|
|
157
|
+
if node is None:
|
|
158
|
+
return ()
|
|
159
|
+
if node.type in {"identifier", "shorthand_property_identifier_pattern"}:
|
|
160
|
+
return (_node_text(node, source),)
|
|
161
|
+
if node.type == "assignment_pattern":
|
|
162
|
+
return _parameter_names(node.child_by_field_name("left"), source)
|
|
163
|
+
if node.type in {"required_parameter", "optional_parameter"}:
|
|
164
|
+
return _parameter_names(node.child_by_field_name("pattern"), source)
|
|
165
|
+
if node.type == "rest_pattern":
|
|
166
|
+
return tuple(name for child in node.children for name in _parameter_names(child, source))
|
|
167
|
+
return tuple(name for child in node.named_children for name in _parameter_names(child, source))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _javascript_symbol(
|
|
171
|
+
node: Node, name: str, kind: str, parameters: Node | None, source: bytes, lines: list[str]
|
|
172
|
+
) -> Symbol:
|
|
173
|
+
"""Build a symbol from a declaration AST node and its optional JSDoc block."""
|
|
174
|
+
line = node.start_point.row + 1
|
|
175
|
+
doc_start: int | None = None
|
|
176
|
+
doc_end: int | None = None
|
|
177
|
+
if line > 1 and lines[line - 2].strip().endswith("*/"):
|
|
178
|
+
for index in range(line - 2, -1, -1):
|
|
179
|
+
if lines[index].strip().startswith("/**"):
|
|
180
|
+
doc_start, doc_end = index + 1, line - 1
|
|
181
|
+
break
|
|
182
|
+
source_line = lines[line - 1]
|
|
183
|
+
indent = source_line[: len(source_line) - len(source_line.lstrip())]
|
|
184
|
+
return Symbol(
|
|
185
|
+
name,
|
|
186
|
+
kind,
|
|
187
|
+
line,
|
|
188
|
+
node.end_point.row + 1,
|
|
189
|
+
indent,
|
|
190
|
+
_parameter_names(parameters, source),
|
|
191
|
+
doc_start is not None,
|
|
192
|
+
doc_start,
|
|
193
|
+
doc_end,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _raise_javascript_syntax_error(root: Node, content: str, filename: str) -> None:
|
|
198
|
+
"""Raise a Python-style syntax error for the first malformed AST node."""
|
|
199
|
+
if not root.has_error:
|
|
200
|
+
return
|
|
201
|
+
malformed: list[Node] = []
|
|
202
|
+
pending = [root]
|
|
203
|
+
while pending:
|
|
204
|
+
node = pending.pop()
|
|
205
|
+
if node.is_error or node.is_missing:
|
|
206
|
+
malformed.append(node)
|
|
207
|
+
pending.extend(child for child in node.children if child.has_error)
|
|
208
|
+
target = min(malformed or [root], key=lambda node: node.start_byte)
|
|
209
|
+
row, column = target.start_point
|
|
210
|
+
lines = content.splitlines()
|
|
211
|
+
source_line = lines[row] if row < len(lines) else ""
|
|
212
|
+
raise SyntaxError(
|
|
213
|
+
"invalid JavaScript/TypeScript syntax",
|
|
214
|
+
(filename, row + 1, column + 1, source_line + "\n"),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class _JavaScriptSymbolVisitor:
|
|
219
|
+
"""Collect documentable declarations from a tree-sitter syntax tree."""
|
|
220
|
+
|
|
221
|
+
def __init__(self, source: bytes, lines: list[str]) -> None:
|
|
222
|
+
"""Initialize the visitor with shared source context."""
|
|
223
|
+
self.source = source
|
|
224
|
+
self.lines = lines
|
|
225
|
+
self.found: list[Symbol] = []
|
|
226
|
+
|
|
227
|
+
@staticmethod
|
|
228
|
+
def _qualified(prefix: str, name: str) -> str:
|
|
229
|
+
"""Return a name qualified by its enclosing declaration."""
|
|
230
|
+
return f"{prefix}.{name}" if prefix else name
|
|
231
|
+
|
|
232
|
+
def _append(self, node: Node, name: str, kind: str, parameters: Node | None = None) -> None:
|
|
233
|
+
"""Append one symbol using this visitor's shared source context."""
|
|
234
|
+
self.found.append(_javascript_symbol(node, name, kind, parameters, self.source, self.lines))
|
|
235
|
+
|
|
236
|
+
def visit(self, node: Node, prefix: str = "") -> None:
|
|
237
|
+
"""Visit a syntax node and its relevant descendants."""
|
|
238
|
+
declaration = node
|
|
239
|
+
if node.type == "export_statement":
|
|
240
|
+
declaration = node.child_by_field_name("declaration") or node
|
|
241
|
+
if declaration.type == "class_declaration":
|
|
242
|
+
self._visit_class(declaration, prefix)
|
|
243
|
+
return
|
|
244
|
+
if declaration.type in {"function_declaration", "generator_function_declaration"}:
|
|
245
|
+
self._visit_function(declaration, prefix)
|
|
246
|
+
return
|
|
247
|
+
if declaration.type == "lexical_declaration":
|
|
248
|
+
self._visit_lexical(declaration, prefix)
|
|
249
|
+
return
|
|
250
|
+
for child in node.named_children:
|
|
251
|
+
self.visit(child, prefix)
|
|
252
|
+
|
|
253
|
+
def _visit_class(self, node: Node, prefix: str) -> None:
|
|
254
|
+
"""Record a class and visit its members."""
|
|
255
|
+
raw_name = _node_text(node.child_by_field_name("name"), self.source)
|
|
256
|
+
name = self._qualified(prefix, raw_name)
|
|
257
|
+
self._append(node, name, "class")
|
|
258
|
+
body = node.child_by_field_name("body")
|
|
259
|
+
if not body:
|
|
260
|
+
return
|
|
261
|
+
for child in body.named_children:
|
|
262
|
+
self._visit_class_member(child, name)
|
|
263
|
+
|
|
264
|
+
def _visit_class_member(self, node: Node, class_name: str) -> None:
|
|
265
|
+
"""Record methods and arrow fields, or recurse into other members."""
|
|
266
|
+
if node.type == "method_definition":
|
|
267
|
+
self._visit_method(node, class_name)
|
|
268
|
+
elif node.type == "public_field_definition":
|
|
269
|
+
self._visit_public_field(node, class_name)
|
|
270
|
+
else:
|
|
271
|
+
self.visit(node, class_name)
|
|
272
|
+
|
|
273
|
+
def _visit_method(self, node: Node, class_name: str) -> None:
|
|
274
|
+
"""Record a class method and visit declarations in its body."""
|
|
275
|
+
method_name = _node_text(node.child_by_field_name("name"), self.source)
|
|
276
|
+
name = f"{class_name}.{method_name}"
|
|
277
|
+
self._append(node, name, "function", node.child_by_field_name("parameters"))
|
|
278
|
+
for descendant in node.named_children:
|
|
279
|
+
self.visit(descendant, name)
|
|
280
|
+
|
|
281
|
+
def _visit_public_field(self, node: Node, class_name: str) -> None:
|
|
282
|
+
"""Record a public class field when its value is an arrow function."""
|
|
283
|
+
value = node.child_by_field_name("value")
|
|
284
|
+
if not value or value.type != "arrow_function":
|
|
285
|
+
return
|
|
286
|
+
field_name = _node_text(node.child_by_field_name("name"), self.source)
|
|
287
|
+
name = f"{class_name}.{field_name}"
|
|
288
|
+
parameters = value.child_by_field_name("parameters") or value.child_by_field_name(
|
|
289
|
+
"parameter"
|
|
290
|
+
)
|
|
291
|
+
self._append(node, name, "function", parameters)
|
|
292
|
+
for descendant in value.named_children:
|
|
293
|
+
self.visit(descendant, name)
|
|
294
|
+
|
|
295
|
+
def _visit_function(self, node: Node, prefix: str) -> None:
|
|
296
|
+
"""Record a function declaration and visit its body."""
|
|
297
|
+
raw_name = _node_text(node.child_by_field_name("name"), self.source)
|
|
298
|
+
name = self._qualified(prefix, raw_name)
|
|
299
|
+
self._append(node, name, "function", node.child_by_field_name("parameters"))
|
|
300
|
+
body = node.child_by_field_name("body")
|
|
301
|
+
if body:
|
|
302
|
+
self.visit(body, name)
|
|
303
|
+
|
|
304
|
+
def _visit_lexical(self, node: Node, prefix: str) -> None:
|
|
305
|
+
"""Record arrow functions declared with ``let`` or ``const``."""
|
|
306
|
+
for declarator in node.named_children:
|
|
307
|
+
self._visit_declarator(declarator, prefix)
|
|
308
|
+
|
|
309
|
+
def _visit_declarator(self, node: Node, prefix: str) -> None:
|
|
310
|
+
"""Record one arrow-function variable declarator."""
|
|
311
|
+
value = node.child_by_field_name("value")
|
|
312
|
+
if node.type != "variable_declarator" or not value or value.type != "arrow_function":
|
|
313
|
+
return
|
|
314
|
+
raw_name = _node_text(node.child_by_field_name("name"), self.source)
|
|
315
|
+
name = self._qualified(prefix, raw_name)
|
|
316
|
+
parameters = value.child_by_field_name("parameters") or value.child_by_field_name(
|
|
317
|
+
"parameter"
|
|
318
|
+
)
|
|
319
|
+
self._append(node, name, "function", parameters)
|
|
320
|
+
for descendant in value.named_children:
|
|
321
|
+
self.visit(descendant, name)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def javascript_symbols(
|
|
325
|
+
content: str, suffix: str = ".js", filename: str = "<unknown>"
|
|
326
|
+
) -> list[Symbol]:
|
|
327
|
+
"""Discover JavaScript and TypeScript declarations through their concrete syntax tree."""
|
|
328
|
+
source = content.encode()
|
|
329
|
+
language = _TSX if suffix == ".tsx" else _TYPESCRIPT if suffix == ".ts" else _JAVASCRIPT
|
|
330
|
+
tree = Parser(language).parse(source)
|
|
331
|
+
_raise_javascript_syntax_error(tree.root_node, content, filename)
|
|
332
|
+
visitor = _JavaScriptSymbolVisitor(source, content.splitlines())
|
|
333
|
+
visitor.visit(tree.root_node)
|
|
334
|
+
return _unique_symbol_names(visitor.found)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def discover(content: str, suffix: str, filename: str = "<unknown>") -> list[Symbol]:
|
|
338
|
+
"""Select Python or JavaScript-family symbol discovery by suffix."""
|
|
339
|
+
return (
|
|
340
|
+
python_symbols(content, filename)
|
|
341
|
+
if suffix == ".py"
|
|
342
|
+
else javascript_symbols(content, suffix, filename)
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def eligible(symbol: Symbol, coverage: str) -> bool:
|
|
347
|
+
"""Return whether a symbol is eligible under the coverage policy."""
|
|
348
|
+
if coverage == "all":
|
|
349
|
+
return True
|
|
350
|
+
if coverage == "minimal":
|
|
351
|
+
return (
|
|
352
|
+
not symbol.has_doc
|
|
353
|
+
and (
|
|
354
|
+
symbol.kind == "module"
|
|
355
|
+
or ("." not in symbol.name and not symbol.name.startswith("_"))
|
|
356
|
+
)
|
|
357
|
+
)
|
|
358
|
+
return not symbol.has_doc
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def needs_documentation(symbol: Symbol, coverage: str) -> bool:
|
|
362
|
+
"""Return whether a symbol is eligible under the selected coverage policy."""
|
|
363
|
+
return eligible(symbol, coverage)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def source_for_symbol(
|
|
367
|
+
content: str, symbol: Symbol, suffix: str, filename: str = "<unknown>"
|
|
368
|
+
) -> str:
|
|
369
|
+
"""Return the smallest self-contained source region available for one symbol.
|
|
370
|
+
|
|
371
|
+
Python and JavaScript-family symbols use parser-derived ranges. Decorated Python declarations
|
|
372
|
+
include their decorators. Module documentation remains file-scoped.
|
|
373
|
+
"""
|
|
374
|
+
if symbol.kind == "module":
|
|
375
|
+
return _module_outline(content, suffix, filename)
|
|
376
|
+
lines = content.splitlines(keepends=True)
|
|
377
|
+
start = symbol.line - 1
|
|
378
|
+
if suffix == ".py":
|
|
379
|
+
while start > 0 and lines[start - 1].lstrip().startswith("@"):
|
|
380
|
+
start -= 1
|
|
381
|
+
return "".join(lines[start : symbol.end_line])
|
|
382
|
+
|
|
383
|
+
return "".join(lines[start : symbol.end_line])
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _module_outline(content: str, suffix: str, filename: str = "<unknown>") -> str:
|
|
387
|
+
"""Create a compact module overview without including implementation bodies."""
|
|
388
|
+
if suffix == ".py":
|
|
389
|
+
return _python_module_outline(content, filename)
|
|
390
|
+
return _javascript_module_outline(content, suffix)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _python_module_outline(content: str, filename: str = "<unknown>") -> str:
|
|
394
|
+
"""Return Python module metadata, imports, constants, and public signatures."""
|
|
395
|
+
tree = ast.parse(content, filename=filename)
|
|
396
|
+
lines = content.splitlines(keepends=True)
|
|
397
|
+
outline = ["MODULE OUTLINE:"]
|
|
398
|
+
first = tree.body[0] if tree.body else None
|
|
399
|
+
if (
|
|
400
|
+
isinstance(first, ast.Expr)
|
|
401
|
+
and isinstance(first.value, ast.Constant)
|
|
402
|
+
and isinstance(first.value.value, str)
|
|
403
|
+
):
|
|
404
|
+
outline.append("MODULE DOCSTRING:")
|
|
405
|
+
outline.append("".join(lines[first.lineno - 1 : first.end_lineno]).rstrip())
|
|
406
|
+
for node in tree.body:
|
|
407
|
+
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
408
|
+
outline.append("".join(lines[node.lineno - 1 : node.end_lineno]).rstrip())
|
|
409
|
+
elif isinstance(node, (ast.Assign, ast.AnnAssign)):
|
|
410
|
+
names = _public_assignment_names(node)
|
|
411
|
+
if names:
|
|
412
|
+
outline.append(f"CONSTANTS: {', '.join(names)}")
|
|
413
|
+
elif isinstance(
|
|
414
|
+
node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)
|
|
415
|
+
) and not node.name.startswith("_"):
|
|
416
|
+
outline.append(_python_definition_header(lines, node))
|
|
417
|
+
return "\n\n".join(part for part in outline if part)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _public_assignment_names(node: ast.Assign | ast.AnnAssign) -> list[str]:
|
|
421
|
+
"""Return public top-level names without serializing potentially large values."""
|
|
422
|
+
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
|
423
|
+
return [
|
|
424
|
+
target.id
|
|
425
|
+
for target in targets
|
|
426
|
+
if isinstance(target, ast.Name) and not target.id.startswith("_")
|
|
427
|
+
]
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _python_definition_header(
|
|
431
|
+
lines: list[str], node: ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef
|
|
432
|
+
) -> str:
|
|
433
|
+
"""Return decorators and the signature, stopping before the first body statement."""
|
|
434
|
+
start = node.lineno - 1
|
|
435
|
+
while start > 0 and lines[start - 1].lstrip().startswith("@"):
|
|
436
|
+
start -= 1
|
|
437
|
+
first_body_line = node.body[0].lineno - 1 if node.body else (node.end_lineno or node.lineno)
|
|
438
|
+
end = max(start + 1, first_body_line)
|
|
439
|
+
return "".join(lines[start:end]).rstrip()
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _javascript_module_outline(content: str, suffix: str = ".js") -> str:
|
|
443
|
+
"""Return imports and definition lines for JavaScript and TypeScript modules."""
|
|
444
|
+
lines = content.splitlines(keepends=True)
|
|
445
|
+
outline = ["MODULE OUTLINE:"]
|
|
446
|
+
index = 0
|
|
447
|
+
while index < len(lines):
|
|
448
|
+
stripped = lines[index].lstrip()
|
|
449
|
+
if stripped.startswith(("import ", "export {", "export *")):
|
|
450
|
+
statement = [lines[index]]
|
|
451
|
+
while not statement[-1].rstrip().endswith(";") and index + 1 < len(lines):
|
|
452
|
+
index += 1
|
|
453
|
+
statement.append(lines[index])
|
|
454
|
+
outline.append("".join(statement).rstrip())
|
|
455
|
+
index += 1
|
|
456
|
+
for symbol in javascript_symbols(content, suffix):
|
|
457
|
+
if not symbol.name.startswith("_"):
|
|
458
|
+
outline.append(lines[symbol.line - 1].rstrip())
|
|
459
|
+
return "\n\n".join(part for part in outline if part)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def render(
|
|
463
|
+
symbol: Symbol,
|
|
464
|
+
documentation: str | Documentation,
|
|
465
|
+
suffix: str,
|
|
466
|
+
python_format: str,
|
|
467
|
+
line_length: int = 100,
|
|
468
|
+
indentation: str = "",
|
|
469
|
+
) -> str:
|
|
470
|
+
"""Render a PEP 257 docstring or JSDoc block for a source symbol."""
|
|
471
|
+
if isinstance(documentation, Documentation):
|
|
472
|
+
description = documentation.description
|
|
473
|
+
argument_docs = documentation.arguments
|
|
474
|
+
else:
|
|
475
|
+
description = documentation
|
|
476
|
+
argument_docs = {}
|
|
477
|
+
description = " ".join(description.split()).strip() or f"Describe {symbol.name}."
|
|
478
|
+
description = description[:1].upper() + description[1:]
|
|
479
|
+
if not description.endswith("."):
|
|
480
|
+
description = description.rstrip("!?;:") + "."
|
|
481
|
+
if suffix != ".py":
|
|
482
|
+
rows = ["/**"]
|
|
483
|
+
rows.extend(_javascript_doc_lines(description, line_length, indentation))
|
|
484
|
+
for arg in symbol.args:
|
|
485
|
+
rows.extend(
|
|
486
|
+
_javascript_doc_lines(
|
|
487
|
+
f"@param {{any}} {arg} {_argument_description(arg, argument_docs)}",
|
|
488
|
+
line_length,
|
|
489
|
+
indentation,
|
|
490
|
+
)
|
|
491
|
+
)
|
|
492
|
+
rows.append(" */")
|
|
493
|
+
return "\n".join(rows)
|
|
494
|
+
if symbol.kind in {"module", "class"} or not symbol.args:
|
|
495
|
+
return _python_docstring(description, line_length, indentation)
|
|
496
|
+
if python_format == "numpy":
|
|
497
|
+
body = [description, "", "Parameters", "----------"]
|
|
498
|
+
body.extend(
|
|
499
|
+
f"{arg} : Any\n {_argument_description(arg, argument_docs)}" for arg in symbol.args
|
|
500
|
+
)
|
|
501
|
+
elif python_format == "sphinx":
|
|
502
|
+
body = [description, ""] + [
|
|
503
|
+
f":param {arg}: {_argument_description(arg, argument_docs)}" for arg in symbol.args
|
|
504
|
+
]
|
|
505
|
+
else:
|
|
506
|
+
body = [description, "", "Args:"] + [
|
|
507
|
+
f" {arg}: {_argument_description(arg, argument_docs)}" for arg in symbol.args
|
|
508
|
+
]
|
|
509
|
+
return _python_docstring("\n".join(body), line_length, indentation)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _python_docstring(content: str, line_length: int, indentation: str) -> str:
|
|
513
|
+
"""Render generated text using the one-line and multi-line forms from PEP 257."""
|
|
514
|
+
escaped = content.replace("\\", "\\\\").replace('"""', '\\"""')
|
|
515
|
+
single_line_width = max(line_length - len(indentation) - 6, 20)
|
|
516
|
+
if "\n" not in escaped and len(escaped) <= single_line_width:
|
|
517
|
+
return f'"""{escaped}"""'
|
|
518
|
+
|
|
519
|
+
source_rows = escaped.splitlines()
|
|
520
|
+
first_line_width = max(line_length - len(indentation) - 3, 20)
|
|
521
|
+
summary, detail = _split_docstring_summary(source_rows[0], first_line_width)
|
|
522
|
+
logical_rows = [summary]
|
|
523
|
+
if detail:
|
|
524
|
+
logical_rows.extend(("", detail))
|
|
525
|
+
logical_rows.extend(source_rows[1:])
|
|
526
|
+
|
|
527
|
+
rows: list[str] = []
|
|
528
|
+
for index, row in enumerate(logical_rows):
|
|
529
|
+
if not row:
|
|
530
|
+
rows.append("")
|
|
531
|
+
continue
|
|
532
|
+
available = line_length - len(indentation) - (3 if index == 0 else 0)
|
|
533
|
+
rows.extend(_wrap_documentation_line(row, max(available, 20)))
|
|
534
|
+
return '"""' + "\n".join(rows) + '\n\n"""'
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _split_docstring_summary(content: str, width: int) -> tuple[str, str | None]:
|
|
538
|
+
"""Split an overlong summary into a PEP 257 summary and detailed description."""
|
|
539
|
+
if len(content) <= width:
|
|
540
|
+
return content, None
|
|
541
|
+
split_at = _summary_split_index(content, width)
|
|
542
|
+
summary = content[:split_at].rstrip(" ,;:")
|
|
543
|
+
detail = content[split_at:].lstrip(" ,;:")
|
|
544
|
+
if not summary.endswith((".", "?", "!")):
|
|
545
|
+
summary = summary.rstrip("?!") + "."
|
|
546
|
+
if detail:
|
|
547
|
+
detail = detail[:1].upper() + detail[1:]
|
|
548
|
+
return summary, detail or None
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _summary_split_index(content: str, width: int) -> int:
|
|
552
|
+
"""Choose a readable boundary that leaves room for summary punctuation."""
|
|
553
|
+
usable_width = max(width - 1, 10)
|
|
554
|
+
minimum = min(max(usable_width // 3, 12), usable_width)
|
|
555
|
+
for index, character in enumerate(content[:usable_width]):
|
|
556
|
+
if index >= minimum and character in ".!?" and content[index + 1 : index + 2] == " ":
|
|
557
|
+
return index + 1
|
|
558
|
+
clause = max(content.rfind(mark, minimum, usable_width) for mark in (",", ";", ":"))
|
|
559
|
+
if clause >= minimum:
|
|
560
|
+
return clause
|
|
561
|
+
for conjunction in (" and ", " or ", " that ", " which ", " while ", " e ", " que "):
|
|
562
|
+
boundary = content.rfind(conjunction, minimum, usable_width)
|
|
563
|
+
if boundary >= minimum:
|
|
564
|
+
return boundary + 1
|
|
565
|
+
boundary = content.rfind(" ", minimum, usable_width)
|
|
566
|
+
return boundary if boundary >= minimum else usable_width
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _argument_description(argument: str, descriptions: Mapping[str, str]) -> str:
|
|
570
|
+
"""Return a normalized argument description with a safe compatibility fallback."""
|
|
571
|
+
description = " ".join(descriptions.get(argument, "").split()).strip()
|
|
572
|
+
if not description:
|
|
573
|
+
return f"Description of {argument}."
|
|
574
|
+
return description if description.endswith(".") else description.rstrip("!?;:") + "."
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _javascript_doc_lines(content: str, line_length: int, indentation: str) -> list[str]:
|
|
578
|
+
"""Wrap one JSDoc content line while accounting for its leading ` * ` marker."""
|
|
579
|
+
width = max(line_length - len(indentation) - 3, 20)
|
|
580
|
+
return [f" * {row}" if row else " *" for row in _wrap_documentation_line(content, width)]
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _wrap_documentation_line(content: str, width: int) -> list[str]:
|
|
584
|
+
"""Wrap documentation text while retaining its semantic indentation."""
|
|
585
|
+
leading = content[: len(content) - len(content.lstrip())]
|
|
586
|
+
text = content.lstrip()
|
|
587
|
+
return textwrap.wrap(
|
|
588
|
+
text,
|
|
589
|
+
width=width,
|
|
590
|
+
initial_indent=leading,
|
|
591
|
+
subsequent_indent=leading,
|
|
592
|
+
break_long_words=True,
|
|
593
|
+
break_on_hyphens=False,
|
|
594
|
+
) or [leading]
|