From 21bcee09189b8847247160e27d7e6d6b20ffe8c2 Mon Sep 17 00:00:00 2001 From: Rodrigo Moraes Date: Sat, 22 Aug 2026 19:06:07 -0300 Subject: [PATCH 1/2] feat(dart): extract dartdoc instead of discarding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extract_dart() strips comments before any pass runs, and `///` is just a special case of `//`, so every doc comment in a Dart corpus is deleted before extraction starts — 14,358 doc blocks in Flutter's own lib/src/material alone, 97% of which carry a real summary sentence. Recover the blocks from the raw source (the existing passes keep running on the comment-free text) and turn them into graph signal: - `doc`: the bounded lead paragraph, on every symbol declared in the file and on the file node itself (a block above `library;`). Directives, inline HTML, and `[ref]` brackets are stripped so it reads as prose. - `See also:` entries -> `references` / context=dartdoc_see_also. - `{@tool}` sample paths -> `references` / context=dartdoc_sample, the runnable file that shows how to use the symbol. - `{@template id}` -> `defines` a doc-fragment node, `{@macro id}` -> `references` it, so dartdoc's transclusion is traversable across files and packages. Inline `[Foo]` mentions in prose are parsed but deliberately not emitted: on src/material they add ~6.6k edges (+16%) that mostly restate relations the AST passes already found. Doc-derived edges use the generic `references` relation and a `dartdoc_*` context, so a doc-stated relation stays distinguishable from one proven by code (#2270) and never downgrades a specific relation on the same pair. Measured on flutter/lib/src/material (182 files): 12,548 nodes gain a summary, +6% nodes, +6% edges, extraction 3.05s -> 3.17s. Co-Authored-By: Claude Opus 5 --- graphify/extractors/dart.py | 221 +++++++++++++++++++++++++++++++++++- tests/test_dart.py | 179 +++++++++++++++++++++++++++++ 2 files changed, 396 insertions(+), 4 deletions(-) diff --git a/graphify/extractors/dart.py b/graphify/extractors/dart.py index acbe195836..e3773796b6 100644 --- a/graphify/extractors/dart.py +++ b/graphify/extractors/dart.py @@ -1,12 +1,163 @@ """Dart extractor. Moved verbatim from graphify/extract.py.""" from __future__ import annotations +import html import re from pathlib import Path from graphify.extractors.base import _file_stem, _make_id +# ── Dartdoc (`///`) ─────────────────────────────────────────────────────────── +# The comment-stripping pass in extract_dart() deletes every `//`-prefixed line +# before a single symbol is extracted, and `///` is just a special case of `//`. +# For a Dart corpus that throws away the richest human-authored statement of what +# an API is for and how it is used — 14.4k doc blocks in Flutter's own +# `src/material` alone. These helpers recover it from the RAW source, before the +# stripping runs, so the existing passes keep operating on comment-free text. + +_DARTDOC_LINE = re.compile(r"^[ \t]*///[ \t]?(.*)$") + +# Type-shaped declarations a doc block can precede. Mirrors the class pattern used +# by section 1 so both agree on what counts as a declaration. +_DARTDOC_TYPE_DECL = re.compile( + r"^\s*(?:(?:abstract|sealed|base|interface|final|mixin)\s+)*" + r"(?:class|mixin|enum|extension\s+type|extension|typedef)\s+(\w+)" +) +# Everything else (methods, fields, constructors, top-level functions/variables): +# the declared name is the last identifier before the first `(`, `=`, `;` or `{`. +_DARTDOC_MEMBER_DECL = re.compile(r"(\w+)\s*[(=;{]") +_DARTDOC_LIBRARY_DECL = re.compile(r"^\s*library\b") + +# Line-level dartdoc directives (`@docImport 'x.dart';`, `@nodoc`) carry no prose +# and must never leak into a summary. +_DARTDOC_LINE_DIRECTIVE = re.compile(r"^\s*@\w+") +# Inline directives: {@template id}, {@macro id}, {@tool dartpad}, {@youtube ...}. +_DARTDOC_INLINE_DIRECTIVE = re.compile(r"\{@[^}]*\}") +_DARTDOC_TEMPLATE = re.compile(r"\{@template\s+([^\s}]+)\}") +_DARTDOC_MACRO = re.compile(r"\{@macro\s+([^\s}]+)\}") +# The runnable example a {@tool} block points at, e.g. +# `** See code in examples/api/lib/material/scaffold/scaffold.0.dart **`. +_DARTDOC_SAMPLE = re.compile(r"\*\*\s*See code in (\S+)\s*\*\*") +# The curated cross-reference list dartdoc convention puts at the end of a block. +_DARTDOC_SEE_ALSO = re.compile(r"^See also:\s*$", re.MULTILINE) +_DARTDOC_SEE_ALSO_ENTRY = re.compile(r"^\s*\*\s+\[([A-Za-z_]\w*)", re.MULTILINE) +# Dartdoc renders inline HTML, and generated files lean on it — every one of the +# ~8.8k icon constants in Flutter's icons.dart is documented as an tag. Strip +# the markup so a summary reads as prose. The tag-name charset excludes ":" so +# dartdoc's autolinks survive. +_DARTDOC_HTML_TAG = re.compile(r"\n]*)?>") +# `[Foo]` / `[Foo.bar]` inside prose is a dartdoc reference, not a markdown link. +_DARTDOC_REF = re.compile(r"\[([^\]\n]+)\]") + +# A summary is one bounded sentence-ish lead paragraph, not the whole block; the +# graph carries navigation signal, not documentation. Matches the 200-300 char +# budget floated in docs/node-summaries-rfc.md. +_DARTDOC_SUMMARY_MAX = 280 + +# Scalar/collection builtins are never worth a `references` edge — same list the +# type-lookup pass in section 7 filters on. +_DARTDOC_REF_NOISE = frozenset({ + "String", "int", "double", "bool", "num", "dynamic", "Object", "void", + "List", "Map", "Set", "Iterable", "Future", "Stream", "Function", "Record", + "Null", "Never", +}) + + +def _parse_dartdoc(body: str) -> dict: + """Split one raw dartdoc block into the parts worth putting in a graph. + + ``summary`` is the lead paragraph with directives removed and `[refs]` + unwrapped; ``see_also`` is the curated cross-reference list; ``samples`` are + the runnable example files a ``{@tool}`` block points at; ``templates`` and + ``macros`` are the two halves of dartdoc's transclusion (`{@template id}` + declares a reusable fragment, `{@macro id}` pastes it in — 714 macros + resolving to 224 templates across Flutter's `src/material`, many of them + living in another package entirely). + """ + see_also: list[str] = [] + prose_source = body + marker = _DARTDOC_SEE_ALSO.search(body) + if marker: + for name in _DARTDOC_SEE_ALSO_ENTRY.findall(body[marker.end():]): + if name not in see_also: + see_also.append(name) + prose_source = body[:marker.start()] + + prose_lines = [ + line for line in prose_source.splitlines() + if not _DARTDOC_LINE_DIRECTIVE.match(line) + ] + prose = "\n".join(prose_lines) + prose = _DARTDOC_SAMPLE.sub("", prose) + prose = _DARTDOC_INLINE_DIRECTIVE.sub("", prose) + prose = _DARTDOC_HTML_TAG.sub("", prose) + prose = html.unescape(prose) + prose = _DARTDOC_REF.sub(r"\1", prose) + + paragraph = next((p for p in re.split(r"\n\s*\n", prose) if p.strip()), "") + summary = " ".join(paragraph.split()) + if len(summary) > _DARTDOC_SUMMARY_MAX: + summary = summary[:_DARTDOC_SUMMARY_MAX].rsplit(" ", 1)[0] + "\u2026" + + return { + "summary": summary, + "see_also": see_also, + "samples": _DARTDOC_SAMPLE.findall(body), + "templates": _DARTDOC_TEMPLATE.findall(body), + "macros": _DARTDOC_MACRO.findall(body), + } + + +def _collect_dartdoc(src: str) -> tuple[dict, dict]: + """Map documented declarations in ``src`` to their parsed dartdoc. + + Returns ``(library_doc, by_name)``. A block sitting above ``library;`` + documents the file itself; every other block documents the next declaration, + skipping the blank lines and annotations dartdoc allows in between. + + Keying ``by_name`` on the bare declared name matches how this extractor + already identifies nodes (``_make_id(stem, name)``), so a name declared twice + in one file — ``build`` in two widget classes — resolves to one node either + way and the first doc block wins. + """ + library_doc: dict = {} + by_name: dict[str, dict] = {} + lines = src.splitlines() + i = 0 + total = len(lines) + while i < total: + match = _DARTDOC_LINE.match(lines[i]) + if match is None: + i += 1 + continue + block: list[str] = [] + while i < total: + match = _DARTDOC_LINE.match(lines[i]) + if match is None: + break + block.append(match.group(1)) + i += 1 + # Walk to the declaration the block documents, past blank lines and + # annotations (`@immutable`, `@Deprecated('...')`). + j = i + while j < total and (not lines[j].strip() or lines[j].lstrip().startswith("@")): + j += 1 + if j >= total: + continue + declaration = lines[j] + parsed = _parse_dartdoc("\n".join(block)) + if _DARTDOC_LIBRARY_DECL.match(declaration): + if not library_doc: + library_doc = parsed + continue + name_match = _DARTDOC_TYPE_DECL.match(declaration) or _DARTDOC_MEMBER_DECL.search(declaration) + if name_match is None: + continue + by_name.setdefault(name_match.group(1), parsed) + return library_doc, by_name + + def extract_dart(path: Path) -> dict: """Extract classes, mixins, functions, imports, generic calls, and annotations from a .dart file using regex.""" try: @@ -30,6 +181,10 @@ def _comment_replace(match: re.Match) -> str: return token src_clean = comment_string_pattern.sub(_comment_replace, src) + # Recover the dartdoc the stripping above just deleted. Read from `src`, + # not `src_clean`, and keep every later pass on the comment-free text. + library_doc, dartdoc_by_name = _collect_dartdoc(src) + stem = _file_stem(path) file_nid = _make_id(str(path)) @@ -50,15 +205,26 @@ def _comment_replace(match: re.Match) -> str: nodes = [] if not is_part: - nodes.append({"id": file_nid, "label": path.name, "file_type": "code", - "source_file": str(path), "source_location": None}) + file_node = {"id": file_nid, "label": path.name, "file_type": "code", + "source_file": str(path), "source_location": None} + if library_doc.get("summary"): + file_node["doc"] = library_doc["summary"] + nodes.append(file_node) edges = [] defined: set[str] = set() def add_node(nid: str, label: str, ftype: str = "code", source_file: str | None = str(path)) -> None: if nid not in defined: - nodes.append({"id": nid, "label": label, "file_type": ftype, - "source_file": source_file, "source_location": None}) + node = {"id": nid, "label": label, "file_type": ftype, + "source_file": source_file, "source_location": None} + # Only a symbol DECLARED here can carry this file's dartdoc. Nodes + # minted for referenced external types pass source_file=None, and a + # name collision with a local symbol must not hand them its doc. + if source_file is not None: + summary = dartdoc_by_name.get(label, {}).get("summary") + if summary: + node["doc"] = summary + nodes.append(node) defined.add(nid) def add_edge(src_id: str, tgt_id: str, relation: str, weight: float = 1.0, context: str | None = None) -> None: @@ -525,4 +691,51 @@ def _find_matching_brace(text: str, start_pos: int) -> int: add_node(target_nid, clean_name, source_file=None) add_edge(file_nid, target_nid, "references", context="type_lookup") + # 8. Dartdoc cross-references + # Only the parts a human curated as a relation become edges. `See also:` is an + # explicit "these belong together" list; a {@tool} block names the runnable file + # that shows how to use the symbol; {@template}/{@macro} is dartdoc's own + # transclusion, so linking them makes reused documentation traversable across + # files and packages. Inline `[Foo]` mentions in prose are deliberately NOT + # emitted: on Flutter's src/material they add ~6.6k edges (+16%) that mostly + # restate relations the AST passes above already found. + # Every edge is tagged `context="dartdoc_*"` so a doc-stated relation stays + # distinguishable from one proven by code (#2270). + def add_dartdoc_edges(owner_nid: str, doc: dict) -> None: + for name in doc["see_also"]: + if name in _DARTDOC_REF_NOISE: + continue + if name[:1].isupper(): + target_nid = _make_id(name) # a type: resolve globally + else: + target_nid = _make_id(stem, name) # a top-level function declared here + if target_nid not in defined: + continue # a bare member ref resolves to nothing + if target_nid == owner_nid: + continue + add_node(target_nid, name, source_file=None) + add_edge(owner_nid, target_nid, "references", context="dartdoc_see_also") + + for sample in doc["samples"]: + sample_nid = _make_id(sample) + add_node(sample_nid, sample, source_file=None) + add_edge(owner_nid, sample_nid, "references", context="dartdoc_sample") + + for template_id in doc["templates"]: + template_nid = _make_id("dartdoc", template_id) + add_node(template_nid, f"{{@template {template_id}}}", ftype="concept") + add_edge(owner_nid, template_nid, "defines", context="dartdoc_template") + + for macro_id in doc["macros"]: + template_nid = _make_id("dartdoc", macro_id) + add_node(template_nid, f"{{@template {macro_id}}}", ftype="concept", source_file=None) + add_edge(owner_nid, template_nid, "references", context="dartdoc_macro") + + if not is_part and library_doc: + add_dartdoc_edges(file_nid, library_doc) + for owner_name, doc in dartdoc_by_name.items(): + owner_nid = _make_id(stem, owner_name) + if owner_nid in defined: + add_dartdoc_edges(owner_nid, doc) + return {"nodes": nodes, "edges": edges} diff --git a/tests/test_dart.py b/tests/test_dart.py index 094a6f5e32..662f26217f 100644 --- a/tests/test_dart.py +++ b/tests/test_dart.py @@ -5,6 +5,7 @@ from pathlib import Path from graphify.extract import extract_dart, _make_id, _file_stem +from graphify.extractors.dart import _parse_dartdoc class TestDart(unittest.TestCase): @@ -636,5 +637,183 @@ class ChildClass extends Bloc, State> {} self.assertEqual(nav_edge["target"], "route_home_id_123_type_auth") + def test_dartdoc_extraction(self): + """Dartdoc (///) survives comment stripping: summaries land on nodes, and + See also / {@tool} / {@template}-{@macro} become edges.""" + code_content = textwrap.dedent(""" + /// Material Design button collection. + /// + /// Second paragraph is not part of the summary. + library; + + import 'package:flutter/material.dart'; + + /// {@template app.buttons.onPressed} + /// Called when the button is tapped. + /// {@end-template} + const double kButtonHeight = 48.0; + + /// A Material Design floating action button. + /// + /// A circular icon button that hovers over content to promote a primary + /// action, most commonly used in the [Scaffold.floatingActionButton] field. + /// + /// {@macro app.buttons.onPressed} + /// + /// {@tool dartpad} + /// This example shows a [MyFab] in its usual position. + /// + /// ** See code in examples/api/lib/material/my_fab/my_fab.0.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [Scaffold], in which floating action buttons typically live. + /// * [showDialog], the dialog helper declared below. + /// * [onPressed], a member reference that must not become a node. + /// * + class MyFab extends StatelessWidget { + /// Creates a circular floating action button. + const MyFab({super.key}); + } + + /// Shows a Material dialog. + Future showDialog() async {} + + class Undocumented extends StatelessWidget {} + """) + + file_path = self.temp_path / "my_fab.dart" + file_path.write_text(code_content, encoding="utf-8") + + result = extract_dart(file_path) + nodes = result["nodes"] + edges = result["edges"] + by_label = {n["label"]: n for n in nodes} + + # A. Library-level dartdoc lands on the file node, first paragraph only. + self.assertEqual(by_label["my_fab.dart"]["doc"], "Material Design button collection.") + + # B. Class summary is the first paragraph, with [refs] unwrapped. + fab = by_label["MyFab"] + self.assertEqual(fab["doc"], "A Material Design floating action button.") + + # C. Top-level function and variable docs are attached too. + self.assertEqual(by_label["showDialog"]["doc"], "Shows a Material dialog.") + self.assertIn("doc", by_label["kButtonHeight"]) + + # D. An undocumented declaration carries no doc key at all. + self.assertNotIn("doc", by_label["Undocumented"]) + + # E. See also: entries become references edges, tagged as dartdoc. + see_also = { + e["target"] + for e in edges + if e["source"] == fab["id"] and e.get("context") == "dartdoc_see_also" + } + self.assertIn(_make_id("Scaffold"), see_also) + # A lowercase entry resolves only when this file declares it. + self.assertIn(_make_id(_file_stem(file_path), "showDialog"), see_also) + # A bare member reference resolves to nothing and must be dropped. + self.assertNotIn(_make_id("onPressed"), see_also) + self.assertNotIn(_make_id(_file_stem(file_path), "onPressed"), see_also) + + # F. {@tool} sample paths become edges to the example file. + sample = next( + (e for e in edges if e.get("context") == "dartdoc_sample"), None + ) + self.assertIsNotNone(sample) + self.assertEqual(sample["source"], fab["id"]) + self.assertEqual( + sample["target"], _make_id("examples/api/lib/material/my_fab/my_fab.0.dart") + ) + + # G. {@template} is defined once and {@macro} references the same node, + # so doc reuse is traversable across files. + template_nid = _make_id("dartdoc", "app.buttons.onPressed") + template_def = next( + ( + e + for e in edges + if e["target"] == template_nid and e.get("context") == "dartdoc_template" + ), + None, + ) + self.assertIsNotNone(template_def) + self.assertEqual(template_def["relation"], "defines") + + macro_ref = next( + ( + e + for e in edges + if e["target"] == template_nid and e.get("context") == "dartdoc_macro" + ), + None, + ) + self.assertIsNotNone(macro_ref) + self.assertEqual(macro_ref["source"], fab["id"]) + self.assertEqual(macro_ref["relation"], "references") + + def test_dartdoc_does_not_leak_into_external_nodes(self): + """A referenced external type that shares a name with a documented local + symbol must not inherit the local doc.""" + code_content = textwrap.dedent(""" + /// The local repository implementation. + class Repository {} + + void lookup() { + final other = locator(); + } + """) + file_path = self.temp_path / "repo.dart" + file_path.write_text(code_content, encoding="utf-8") + + result = extract_dart(file_path) + session = next(n for n in result["nodes"] if n["id"] == _make_id("Session")) + self.assertIsNone(session["source_file"]) + self.assertNotIn("doc", session) + + def test_dartdoc_ignores_directive_only_blocks(self): + """A doc block that holds only @docImport lines has no prose, so it must + not produce a bogus summary.""" + code_content = textwrap.dedent(""" + /// @docImport 'elevated_button.dart'; + /// @docImport 'ink_well.dart'; + library; + + class Plain {} + """) + file_path = self.temp_path / "plain.dart" + file_path.write_text(code_content, encoding="utf-8") + + result = extract_dart(file_path) + file_node = next(n for n in result["nodes"] if n["label"] == "plain.dart") + self.assertNotIn("doc", file_node) + + + + def test_dartdoc_summary_is_prose_not_markup(self): + """Dartdoc renders inline HTML (all ~8.8k icon constants in Flutter's + icons.dart are documented as an tag), but a summary must read as prose. + Autolinks are not tags and must survive.""" + icon_doc = _parse_dartdoc( + 'help_outline ' + "— material icon named \"help outline\"." + ) + self.assertEqual( + icon_doc["summary"], 'help_outline \u2014 material icon named "help outline".' + ) + + link_doc = _parse_dartdoc("See for details.") + self.assertEqual(link_doc["summary"], "See for details.") + + def test_dartdoc_summary_is_bounded(self): + """A summary is navigation signal, not the documentation itself.""" + long_doc = _parse_dartdoc(" ".join(["word"] * 400)) + self.assertLessEqual(len(long_doc["summary"]), 281) + self.assertTrue(long_doc["summary"].endswith("\u2026")) + + + if __name__ == "__main__": unittest.main() From fd3d39c5918edc9808c85033d2b77c0a647ad415 Mon Sep 17 00:00:00 2001 From: Rodrigo Moraes Date: Sat, 22 Aug 2026 19:33:31 -0300 Subject: [PATCH 2/2] feat(dart): keep the whole doc and bind it to the exact declaration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps in the first pass. The doc text was the lead paragraph, truncated at 280 chars. The cap was almost never the problem — it hit 16 of 13,869 blocks on src/material — but keeping only the lead paragraph dropped half the prose (0.85 MB of 1.81 MB). `doc` is now the block's full text, every paragraph joined by blank lines. Splitting it across nodes was the alternative and is worse here: the median block is a single paragraph, so it would have minted ~14k nodes to hold one paragraph each. A consumer that wants the summary takes doc.split("\n\n")[0], which is dartdoc's own convention. Binding was by bare declared name, so a constructor's doc collapsed onto its class (`MyFab(` and `class MyFab` share a name) and a documented constructor parameter was dropped entirely — `_DARTDOC_MEMBER_DECL` never matched `this.color,`. A block now attaches at the granularity of what it sits above: library; -> the file class/mixin/enum/... -> that type constructor -> that constructor (a new node, `contains` from the class; `Foo()` keeps a label distinct from `Foo`, and `Foo._()` gets an ID that does not normalize onto the class or the file, #2738) constructor parameter -> the field `this.x` forwards to, or the parameter itself (`references` from the ctor) anything else -> that member Enclosing type comes from brace depth and the parameter list from paren depth, both counted with strings and trailing comments blanked, so a widget constructor CALL inside a build method is not read as a declaration. Blocks also skip plain `//` comments between the doc and the declaration, not just blanks and annotations. Constructor and parameter nodes are minted only where a doc block points at one, keeping this proportional to the documentation rather than to every constructor in the corpus. add_node now fills in a doc on a node an earlier pass already created under a differently-normalized label. IDs strip leading underscores, so a private field `_field` and a parameter `field` are one node; without this the second label's doc was silently dropped (hit in cloud_firestore's filters.dart). flutter/lib/src/material: 25,207 nodes (13,042 with a doc, 1.67 MB of text), 44,393 edges, 3.47s. lib/src/widgets: 737 documented constructors. Co-Authored-By: Claude Opus 5 --- graphify/extractors/dart.py | 284 +++++++++++++++++++++++++++++------- tests/test_dart.py | 205 ++++++++++++++++++++++---- 2 files changed, 409 insertions(+), 80 deletions(-) diff --git a/graphify/extractors/dart.py b/graphify/extractors/dart.py index e3773796b6..b7eb733010 100644 --- a/graphify/extractors/dart.py +++ b/graphify/extractors/dart.py @@ -24,13 +24,31 @@ r"^\s*(?:(?:abstract|sealed|base|interface|final|mixin)\s+)*" r"(?:class|mixin|enum|extension\s+type|extension|typedef)\s+(\w+)" ) -# Everything else (methods, fields, constructors, top-level functions/variables): -# the declared name is the last identifier before the first `(`, `=`, `;` or `{`. +# A constructor is the one declaration whose name IS the enclosing type, with no +# return type in front: `Foo(`, `const Foo(`, `factory Foo.fromJson(`, `Foo._(`. +# The `type == enclosing type` check at the call site is what keeps a widget +# constructor CALL inside a build method (`Padding(`) from matching. +_DARTDOC_CONSTRUCTOR_DECL = re.compile( + r"^\s*(?:(?:const|factory|external)\s+)*" + r"(?P[A-Z]\w*)(?:\.(?P\w+))?\s*\(" +) +# Inside a constructor's parameter list, `this.x` / `super.x` forwards to a field +# that already has its own node, so a doc above it documents that field. +_DARTDOC_FORWARDED_PARAM = re.compile(r"^\s*(?:required\s+)?(?:this|super)\.(\w+)") +# Any other parameter: the last identifier before the end of the declaration, +# once a default value has been cut off (a default can itself contain commas and +# identifiers — `= EdgeInsets.only(left: 1, right: 2)` — and the parameter name is +# always to its left). The trailing class covers `field, {`, where the brace opens +# the named-parameter group. Only consulted when the line is known to sit inside a +# parameter list. +_DARTDOC_PARAM_NAME = re.compile(r"(\w+)\s*[,;)}\]{\s]*$") +# Everything else (methods, fields, getters, top-level functions/variables): the +# declared name is the last identifier before the first `(`, `=`, `;` or `{`. _DARTDOC_MEMBER_DECL = re.compile(r"(\w+)\s*[(=;{]") _DARTDOC_LIBRARY_DECL = re.compile(r"^\s*library\b") # Line-level dartdoc directives (`@docImport 'x.dart';`, `@nodoc`) carry no prose -# and must never leak into a summary. +# and must never leak into the doc text. _DARTDOC_LINE_DIRECTIVE = re.compile(r"^\s*@\w+") # Inline directives: {@template id}, {@macro id}, {@tool dartpad}, {@youtube ...}. _DARTDOC_INLINE_DIRECTIVE = re.compile(r"\{@[^}]*\}") @@ -44,16 +62,24 @@ _DARTDOC_SEE_ALSO_ENTRY = re.compile(r"^\s*\*\s+\[([A-Za-z_]\w*)", re.MULTILINE) # Dartdoc renders inline HTML, and generated files lean on it — every one of the # ~8.8k icon constants in Flutter's icons.dart is documented as an tag. Strip -# the markup so a summary reads as prose. The tag-name charset excludes ":" so +# the markup so the text reads as prose. The tag-name charset excludes ":" so # dartdoc's autolinks survive. _DARTDOC_HTML_TAG = re.compile(r"\n]*)?>") # `[Foo]` / `[Foo.bar]` inside prose is a dartdoc reference, not a markdown link. _DARTDOC_REF = re.compile(r"\[([^\]\n]+)\]") - -# A summary is one bounded sentence-ish lead paragraph, not the whole block; the -# graph carries navigation signal, not documentation. Matches the 200-300 char -# budget floated in docs/node-summaries-rfc.md. -_DARTDOC_SUMMARY_MAX = 280 +# A control character in a node attribute breaks the HTML export (#2897). +_DARTDOC_CONTROL = re.compile(r"[\x00-\x08\x0b-\x1f\x7f]") + +# Blanked before counting brackets for structural context: string literals and +# trailing `//` comments, whose brackets are not code. Doc lines start with `///` +# so they are already excluded. +_DARTDOC_LINE_NOISE = re.compile( + r"'''[\s\S]*?'''" + r'|"""[\s\S]*?"""' + r"|'(?:\\.|[^'\\])*'" + r'|"(?:\\.|[^"\\])*"' + r"|//.*$" +) # Scalar/collection builtins are never worth a `references` edge — same list the # type-lookup pass in section 7 filters on. @@ -67,13 +93,17 @@ def _parse_dartdoc(body: str) -> dict: """Split one raw dartdoc block into the parts worth putting in a graph. - ``summary`` is the lead paragraph with directives removed and `[refs]` - unwrapped; ``see_also`` is the curated cross-reference list; ``samples`` are - the runnable example files a ``{@tool}`` block points at; ``templates`` and - ``macros`` are the two halves of dartdoc's transclusion (`{@template id}` - declares a reusable fragment, `{@macro id}` pastes it in — 714 macros - resolving to 224 templates across Flutter's `src/material`, many of them - living in another package entirely). + ``doc`` is the block's full prose — every paragraph, joined by blank lines, + with directives removed, inline HTML stripped and `[refs]` unwrapped. It is + deliberately NOT truncated: a consumer that wants the one-line summary takes + ``doc.split("\\n\\n")[0]``, which is dartdoc's own convention for the first + paragraph, and one that wants the whole explanation now has it. + + ``see_also`` is the curated cross-reference list; ``samples`` are the runnable + example files a ``{@tool}`` block points at; ``templates`` and ``macros`` are + the two halves of dartdoc's transclusion (`{@template id}` declares a reusable + fragment, `{@macro id}` pastes it in — 714 macros resolving to 224 templates + across Flutter's `src/material`, many living in another package entirely). """ see_also: list[str] = [] prose_source = body @@ -94,14 +124,12 @@ def _parse_dartdoc(body: str) -> dict: prose = _DARTDOC_HTML_TAG.sub("", prose) prose = html.unescape(prose) prose = _DARTDOC_REF.sub(r"\1", prose) + prose = _DARTDOC_CONTROL.sub("", prose) - paragraph = next((p for p in re.split(r"\n\s*\n", prose) if p.strip()), "") - summary = " ".join(paragraph.split()) - if len(summary) > _DARTDOC_SUMMARY_MAX: - summary = summary[:_DARTDOC_SUMMARY_MAX].rsplit(" ", 1)[0] + "\u2026" + paragraphs = [" ".join(p.split()) for p in re.split(r"\n\s*\n", prose) if p.strip()] return { - "summary": summary, + "doc": "\n\n".join(paragraphs), "see_also": see_also, "samples": _DARTDOC_SAMPLE.findall(body), "templates": _DARTDOC_TEMPLATE.findall(body), @@ -109,26 +137,99 @@ def _parse_dartdoc(body: str) -> dict: } -def _collect_dartdoc(src: str) -> tuple[dict, dict]: - """Map documented declarations in ``src`` to their parsed dartdoc. +def _dartdoc_constructor_label(type_name: str, ctor_name: str | None) -> str: + """Node label for a constructor. The trailing `()` is what keeps an unnamed + constructor's label from colliding with its class's.""" + return f"{type_name}.{ctor_name}()" if ctor_name else f"{type_name}()" + - Returns ``(library_doc, by_name)``. A block sitting above ``library;`` - documents the file itself; every other block documents the next declaration, - skipping the blank lines and annotations dartdoc allows in between. +def _dartdoc_constructor_key(type_name: str, ctor_name: str | None) -> str: + """ID fragment for a constructor. A name made only of underscores normalizes + to nothing, which would collapse `Foo._()` onto the class node — or, for the + bare `_`, onto the FILE node (#2738).""" + if not ctor_name: + return f"{type_name}.new" + return f"{type_name}.{ctor_name}" if ctor_name.strip("_") else f"{type_name}.private" - Keying ``by_name`` on the bare declared name matches how this extractor - already identifies nodes (``_make_id(stem, name)``), so a name declared twice - in one file — ``build`` in two widget classes — resolves to one node either - way and the first doc block wins. + +def _dartdoc_structure(lines: list[str]) -> tuple[list, list]: + """For every line, the type that encloses it and the constructor whose + parameter list it sits in. + + Brace depth resolves the enclosing type, paren depth resolves the parameter + list, both counted on lines with strings and trailing comments blanked out. + """ + enclosing: list[str | None] = [] + param_of: list[tuple[str, str, str | None] | None] = [] + stack: list[tuple[int, str]] = [] + depth = 0 + current_ctor: tuple[str, str, str | None] | None = None + paren_depth = 0 + + for raw in lines: + line = _DARTDOC_LINE_NOISE.sub("", raw) + enclosing.append(stack[-1][1] if stack else None) + param_of.append(current_ctor) + + type_match = _DARTDOC_TYPE_DECL.match(line) + if type_match: + stack.append((depth, type_match.group(1))) + elif current_ctor is None and stack: + ctor_match = _DARTDOC_CONSTRUCTOR_DECL.match(line) + if ctor_match and ctor_match.group("type") == stack[-1][1]: + current_ctor = ( + _dartdoc_constructor_label( + ctor_match.group("type"), ctor_match.group("name") + ), + ctor_match.group("type"), + ctor_match.group("name"), + ) + paren_depth = 0 + + if current_ctor is not None: + paren_depth += line.count("(") - line.count(")") + if paren_depth <= 0: + current_ctor = None + paren_depth = 0 + + depth += line.count("{") - line.count("}") + while stack and depth <= stack[-1][0]: + stack.pop() + + return enclosing, param_of + + +def _collect_dartdoc(src: str) -> tuple[dict, dict, dict, list]: + """Bind every dartdoc block to the declaration it documents. + + A block attaches to the first thing below it that is not more documentation, + skipping the blank lines, annotations and plain `//` comments Dart allows in + between — and it attaches at that declaration's own granularity: + + - above ``library;`` -> the file + - above a class/mixin/enum/... -> that type + - above a constructor -> that constructor + - above a constructor parameter -> the field it forwards to (``this.x``), or + the parameter itself + - anything else -> that member (field, method, getter, ...) + + Returns ``(library_doc, by_label, constructors, parameters)``. ``by_label`` is + keyed by the LABEL the node will carry, so ``add_node`` picks a doc up without + knowing which pass created the node. A label declared twice in one file — + ``build`` in two widget classes — already resolves to a single node, so the + first block wins, matching the ID collision that already exists. """ - library_doc: dict = {} - by_name: dict[str, dict] = {} lines = src.splitlines() - i = 0 + enclosing, param_of = _dartdoc_structure(lines) + library_doc: dict = {} + by_label: dict[str, dict] = {} + constructors: dict[str, tuple[str, str | None]] = {} + parameters: list[tuple[str, str]] = [] total = len(lines) + i = 0 + while i < total: - match = _DARTDOC_LINE.match(lines[i]) - if match is None: + if _DARTDOC_LINE.match(lines[i]) is None: i += 1 continue block: list[str] = [] @@ -138,24 +239,58 @@ def _collect_dartdoc(src: str) -> tuple[dict, dict]: break block.append(match.group(1)) i += 1 - # Walk to the declaration the block documents, past blank lines and - # annotations (`@immutable`, `@Deprecated('...')`). j = i - while j < total and (not lines[j].strip() or lines[j].lstrip().startswith("@")): + while j < total: + stripped = lines[j].strip() + if stripped and not stripped.startswith("@") and not stripped.startswith("//"): + break j += 1 if j >= total: continue + declaration = lines[j] parsed = _parse_dartdoc("\n".join(block)) + if _DARTDOC_LIBRARY_DECL.match(declaration): if not library_doc: library_doc = parsed continue - name_match = _DARTDOC_TYPE_DECL.match(declaration) or _DARTDOC_MEMBER_DECL.search(declaration) - if name_match is None: + + owner_ctor = param_of[j] + if owner_ctor is not None: + forwarded = _DARTDOC_FORWARDED_PARAM.match(declaration) + name_match = forwarded or _DARTDOC_PARAM_NAME.search( + declaration.split("=", 1)[0] + ) + if name_match is None: + continue + ctor_label, ctor_type, ctor_name = owner_ctor + constructors.setdefault(ctor_label, (ctor_type, ctor_name)) + parameters.append((ctor_label, name_match.group(1))) + by_label.setdefault(name_match.group(1), parsed) + continue + + type_match = _DARTDOC_TYPE_DECL.match(declaration) + if type_match: + by_label.setdefault(type_match.group(1), parsed) continue - by_name.setdefault(name_match.group(1), parsed) - return library_doc, by_name + + ctor_match = _DARTDOC_CONSTRUCTOR_DECL.match(declaration) + if ctor_match and ctor_match.group("type") == enclosing[j]: + label = _dartdoc_constructor_label( + ctor_match.group("type"), ctor_match.group("name") + ) + constructors.setdefault( + label, (ctor_match.group("type"), ctor_match.group("name")) + ) + by_label.setdefault(label, parsed) + continue + + member_match = _DARTDOC_MEMBER_DECL.search(declaration) + if member_match is not None: + by_label.setdefault(member_match.group(1), parsed) + + return library_doc, by_label, constructors, parameters def extract_dart(path: Path) -> dict: @@ -183,7 +318,8 @@ def _comment_replace(match: re.Match) -> str: # Recover the dartdoc the stripping above just deleted. Read from `src`, # not `src_clean`, and keep every later pass on the comment-free text. - library_doc, dartdoc_by_name = _collect_dartdoc(src) + (library_doc, dartdoc_by_label, + dartdoc_constructors, dartdoc_parameters) = _collect_dartdoc(src) stem = _file_stem(path) file_nid = _make_id(str(path)) @@ -207,25 +343,31 @@ def _comment_replace(match: re.Match) -> str: if not is_part: file_node = {"id": file_nid, "label": path.name, "file_type": "code", "source_file": str(path), "source_location": None} - if library_doc.get("summary"): - file_node["doc"] = library_doc["summary"] + if library_doc.get("doc"): + file_node["doc"] = library_doc["doc"] nodes.append(file_node) edges = [] defined: set[str] = set() + node_by_id: dict[str, dict] = {} def add_node(nid: str, label: str, ftype: str = "code", source_file: str | None = str(path)) -> None: + # Only a symbol DECLARED here can carry this file's dartdoc. Nodes minted + # for referenced external types pass source_file=None, and a name collision + # with a local symbol must not hand them its doc. + doc = dartdoc_by_label.get(label, {}).get("doc") if source_file is not None else None if nid not in defined: node = {"id": nid, "label": label, "file_type": ftype, "source_file": source_file, "source_location": None} - # Only a symbol DECLARED here can carry this file's dartdoc. Nodes - # minted for referenced external types pass source_file=None, and a - # name collision with a local symbol must not hand them its doc. - if source_file is not None: - summary = dartdoc_by_name.get(label, {}).get("summary") - if summary: - node["doc"] = summary + if doc: + node["doc"] = doc nodes.append(node) + node_by_id[nid] = node defined.add(nid) + elif doc: + # An earlier pass already created this node under a different label + # that carried no doc. IDs are normalized, so `_field` and `field` + # are one node; without this the doc for the second label is dropped. + node_by_id[nid].setdefault("doc", doc) def add_edge(src_id: str, tgt_id: str, relation: str, weight: float = 1.0, context: str | None = None) -> None: edge = {"source": src_id, "target": tgt_id, "relation": relation, @@ -691,7 +833,39 @@ def _find_matching_brace(text: str, start_pos: int) -> int: add_node(target_nid, clean_name, source_file=None) add_edge(file_nid, target_nid, "references", context="type_lookup") - # 8. Dartdoc cross-references + # 8. Dartdoc-declared API surface + # A documented constructor and its documented parameters are API surface the + # author chose to describe, but no earlier pass mints a node for either: the + # method pass skips any name starting uppercase (so every constructor), and + # parameter lists are never walked. Create them here — only when a doc block + # points at them, so this stays proportional to the documentation rather than + # minting a node for every constructor in the corpus. + dartdoc_nid_by_label: dict[str, str] = {} + for ctor_label, (ctor_type, ctor_name) in dartdoc_constructors.items(): + type_nid = _make_id(stem, ctor_type) + if type_nid not in defined: + continue + ctor_nid = _make_id(stem, _dartdoc_constructor_key(ctor_type, ctor_name)) + if ctor_nid in (type_nid, file_nid): + continue + add_node(ctor_nid, ctor_label) + add_edge(type_nid, ctor_nid, "contains", context="dartdoc_constructor") + dartdoc_nid_by_label[ctor_label] = ctor_nid + + for ctor_label, param_label in dartdoc_parameters: + ctor_nid = dartdoc_nid_by_label.get(ctor_label) + if ctor_nid is None: + continue + param_nid = _make_id(stem, param_label) + if param_nid in (ctor_nid, file_nid): + continue + # A `this.x` parameter forwards to a field that already has a node, so + # this reuses it rather than declaring a second owner for the same prop — + # hence `references`, not `contains`. + add_node(param_nid, param_label) + add_edge(ctor_nid, param_nid, "references", context="dartdoc_parameter") + + # 9. Dartdoc cross-references # Only the parts a human curated as a relation become edges. `See also:` is an # explicit "these belong together" list; a {@tool} block names the runnable file # that shows how to use the symbol; {@template}/{@macro} is dartdoc's own @@ -733,8 +907,8 @@ def add_dartdoc_edges(owner_nid: str, doc: dict) -> None: if not is_part and library_doc: add_dartdoc_edges(file_nid, library_doc) - for owner_name, doc in dartdoc_by_name.items(): - owner_nid = _make_id(stem, owner_name) + for owner_label, doc in dartdoc_by_label.items(): + owner_nid = dartdoc_nid_by_label.get(owner_label) or _make_id(stem, owner_label) if owner_nid in defined: add_dartdoc_edges(owner_nid, doc) diff --git a/tests/test_dart.py b/tests/test_dart.py index 662f26217f..b2d4c5aa79 100644 --- a/tests/test_dart.py +++ b/tests/test_dart.py @@ -638,12 +638,10 @@ class ChildClass extends Bloc, State> {} def test_dartdoc_extraction(self): - """Dartdoc (///) survives comment stripping: summaries land on nodes, and + """Dartdoc (///) survives comment stripping: full prose lands on nodes, and See also / {@tool} / {@template}-{@macro} become edges.""" code_content = textwrap.dedent(""" /// Material Design button collection. - /// - /// Second paragraph is not part of the summary. library; import 'package:flutter/material.dart'; @@ -673,7 +671,6 @@ def test_dartdoc_extraction(self): /// * [onPressed], a member reference that must not become a node. /// * class MyFab extends StatelessWidget { - /// Creates a circular floating action button. const MyFab({super.key}); } @@ -691,12 +688,19 @@ class Undocumented extends StatelessWidget {} edges = result["edges"] by_label = {n["label"]: n for n in nodes} - # A. Library-level dartdoc lands on the file node, first paragraph only. + # A. Library-level dartdoc lands on the file node. self.assertEqual(by_label["my_fab.dart"]["doc"], "Material Design button collection.") - # B. Class summary is the first paragraph, with [refs] unwrapped. + # B. The class keeps its WHOLE prose, paragraphs and all, with [refs] + # unwrapped and directives removed. fab = by_label["MyFab"] - self.assertEqual(fab["doc"], "A Material Design floating action button.") + self.assertEqual( + fab["doc"], + "A Material Design floating action button.\n\n" + "A circular icon button that hovers over content to promote a primary " + "action, most commonly used in the Scaffold.floatingActionButton field.\n\n" + "This example shows a MyFab in its usual position.", + ) # C. Top-level function and variable docs are attached too. self.assertEqual(by_label["showDialog"]["doc"], "Shows a Material dialog.") @@ -719,9 +723,7 @@ class Undocumented extends StatelessWidget {} self.assertNotIn(_make_id(_file_stem(file_path), "onPressed"), see_also) # F. {@tool} sample paths become edges to the example file. - sample = next( - (e for e in edges if e.get("context") == "dartdoc_sample"), None - ) + sample = next((e for e in edges if e.get("context") == "dartdoc_sample"), None) self.assertIsNotNone(sample) self.assertEqual(sample["source"], fab["id"]) self.assertEqual( @@ -754,6 +756,159 @@ class Undocumented extends StatelessWidget {} self.assertEqual(macro_ref["source"], fab["id"]) self.assertEqual(macro_ref["relation"], "references") + def test_dartdoc_binds_to_the_declaration_below_it(self): + """A block attaches at the granularity of what it sits above: class, + constructor, constructor parameter, field, method.""" + code_content = textwrap.dedent(""" + /// A tappable card. + class Card extends StatelessWidget { + /// Creates a card. + const Card({ + super.key, + /// The surface color. + this.color, + /// Called on tap. + required this.onTap, + /// How far the content is inset. + double padding = 8.0, + }); + + /// Creates a card from JSON. + factory Card.fromJson(Map json) => const Card(); + + /// Internal, test-only. + const Card._(); + + /// The resolved surface color. + final Color? color; + + /// Builds the card. + Widget build(BuildContext context) => const Placeholder(); + } + """) + file_path = self.temp_path / "card.dart" + file_path.write_text(code_content, encoding="utf-8") + + result = extract_dart(file_path) + stem = _file_stem(file_path) + by_label = {n["label"]: n for n in result["nodes"]} + by_id = {n["id"]: n for n in result["nodes"]} + + # The class doc stays on the class, not on its constructor. + self.assertEqual(by_label["Card"]["doc"], "A tappable card.") + + # Each constructor is its own node, and the unnamed one does not collide + # with the class. + unnamed = by_id[_make_id(stem, "Card.new")] + self.assertEqual(unnamed["label"], "Card()") + self.assertEqual(unnamed["doc"], "Creates a card.") + self.assertNotEqual(unnamed["id"], by_label["Card"]["id"]) + + named = by_id[_make_id(stem, "Card.fromJson")] + self.assertEqual(named["doc"], "Creates a card from JSON.") + + # `Card._()` normalizes to nothing and would otherwise collapse onto the + # class node, or onto the file node (#2738). + private = by_id[_make_id(stem, "Card.private")] + self.assertEqual(private["doc"], "Internal, test-only.") + + # A constructor is contained by its class. + contains = { + e["target"] + for e in result["edges"] + if e["source"] == by_label["Card"]["id"] + and e.get("context") == "dartdoc_constructor" + } + self.assertEqual( + contains, + {unnamed["id"], named["id"], private["id"]}, + ) + + # A `this.x` parameter documents the field it forwards to, and the field's + # own node is the one that carries it. + color = by_id[_make_id(stem, "color")] + self.assertEqual(color["doc"], "The surface color.") + self.assertEqual(by_id[_make_id(stem, "onTap")]["doc"], "Called on tap.") + # A plain parameter with no matching field is bound too. + self.assertEqual( + by_id[_make_id(stem, "padding")]["doc"], "How far the content is inset." + ) + # An undocumented parameter (super.key) is not pulled in. + self.assertNotIn(_make_id(stem, "key"), by_id) + + params = { + e["target"] + for e in result["edges"] + if e["source"] == unnamed["id"] and e.get("context") == "dartdoc_parameter" + } + self.assertEqual( + params, + {_make_id(stem, "color"), _make_id(stem, "onTap"), _make_id(stem, "padding")}, + ) + + # A method doc still binds to the method. + self.assertEqual(by_id[_make_id(stem, "build")]["doc"], "Builds the card.") + + def test_dartdoc_skips_intervening_annotations_and_comments(self): + """The block documents the first line below it that is not itself + documentation.""" + code_content = textwrap.dedent(""" + /// The answer. + // a plain implementation note + @Deprecated('use answer2') + const int answer = 42; + """) + file_path = self.temp_path / "answer.dart" + file_path.write_text(code_content, encoding="utf-8") + + result = extract_dart(file_path) + by_label = {n["label"]: n for n in result["nodes"]} + self.assertEqual(by_label["answer"]["doc"], "The answer.") + + def test_dartdoc_constructor_call_is_not_mistaken_for_a_declaration(self): + """`Padding(` inside a build method is a call, not a constructor: only a + name matching the ENCLOSING type declares one.""" + code_content = textwrap.dedent(""" + class Page extends StatelessWidget { + Widget build(BuildContext context) { + /// not a declaration + return Padding(padding: EdgeInsets.zero); + } + } + """) + file_path = self.temp_path / "page.dart" + file_path.write_text(code_content, encoding="utf-8") + + result = extract_dart(file_path) + stem = _file_stem(file_path) + ids = {n["id"] for n in result["nodes"]} + self.assertNotIn(_make_id(stem, "Padding.new"), ids) + + def test_dartdoc_survives_a_normalized_id_collision(self): + """Node IDs strip leading underscores, so a private field and a parameter + named after it are one node. Whichever pass gets there first names the + node; the doc must still land on it.""" + code_content = textwrap.dedent(""" + class Filter { + final Object _field; + + /// Builds a filter. + Filter( + /// The field to filter on. + Object field, + ); + } + """) + file_path = self.temp_path / "filter.dart" + file_path.write_text(code_content, encoding="utf-8") + + result = extract_dart(file_path) + stem = _file_stem(file_path) + self.assertEqual(_make_id(stem, "_field"), _make_id(stem, "field")) + node = next(n for n in result["nodes"] if n["id"] == _make_id(stem, "field")) + self.assertEqual(node["doc"], "The field to filter on.") + + def test_dartdoc_does_not_leak_into_external_nodes(self): """A referenced external type that shares a name with a documented local symbol must not inherit the local doc.""" @@ -775,7 +930,7 @@ class Repository {} def test_dartdoc_ignores_directive_only_blocks(self): """A doc block that holds only @docImport lines has no prose, so it must - not produce a bogus summary.""" + not produce a bogus doc.""" code_content = textwrap.dedent(""" /// @docImport 'elevated_button.dart'; /// @docImport 'ink_well.dart'; @@ -790,29 +945,29 @@ class Plain {} file_node = next(n for n in result["nodes"] if n["label"] == "plain.dart") self.assertNotIn("doc", file_node) - - - def test_dartdoc_summary_is_prose_not_markup(self): + def test_dartdoc_is_prose_not_markup(self): """Dartdoc renders inline HTML (all ~8.8k icon constants in Flutter's - icons.dart are documented as an tag), but a summary must read as prose. + icons.dart are documented as an tag), but the text must read as prose. Autolinks are not tags and must survive.""" icon_doc = _parse_dartdoc( 'help_outline ' - "— material icon named \"help outline\"." + '— material icon named "help outline".' ) self.assertEqual( - icon_doc["summary"], 'help_outline \u2014 material icon named "help outline".' + icon_doc["doc"], 'help_outline \u2014 material icon named "help outline".' ) link_doc = _parse_dartdoc("See for details.") - self.assertEqual(link_doc["summary"], "See for details.") - - def test_dartdoc_summary_is_bounded(self): - """A summary is navigation signal, not the documentation itself.""" - long_doc = _parse_dartdoc(" ".join(["word"] * 400)) - self.assertLessEqual(len(long_doc["summary"]), 281) - self.assertTrue(long_doc["summary"].endswith("\u2026")) - + self.assertEqual(link_doc["doc"], "See for details.") + + def test_dartdoc_is_not_truncated(self): + """Long dartdoc keeps every paragraph — consumers that want the one-line + summary take the first one, which is dartdoc's own convention.""" + body = "\n\n".join([" ".join(["word"] * 200)] * 4) + parsed = _parse_dartdoc(body) + self.assertEqual(len(parsed["doc"].split("\n\n")), 4) + self.assertGreater(len(parsed["doc"]), 3000) + self.assertFalse(parsed["doc"].endswith("\u2026")) if __name__ == "__main__":