diff --git a/graphify/extractors/dart.py b/graphify/extractors/dart.py index acbe19583..b7eb73301 100644 --- a/graphify/extractors/dart.py +++ b/graphify/extractors/dart.py @@ -1,12 +1,298 @@ """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+)" +) +# 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 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"\{@[^}]*\}") +_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 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 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. +_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. + + ``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 + 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) + prose = _DARTDOC_CONTROL.sub("", prose) + + paragraphs = [" ".join(p.split()) for p in re.split(r"\n\s*\n", prose) if p.strip()] + + return { + "doc": "\n\n".join(paragraphs), + "see_also": see_also, + "samples": _DARTDOC_SAMPLE.findall(body), + "templates": _DARTDOC_TEMPLATE.findall(body), + "macros": _DARTDOC_MACRO.findall(body), + } + + +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}()" + + +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" + + +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. + """ + lines = src.splitlines() + 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: + if _DARTDOC_LINE.match(lines[i]) 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 + j = i + 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 + + 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 + + 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: """Extract classes, mixins, functions, imports, generic calls, and annotations from a .dart file using regex.""" try: @@ -30,6 +316,11 @@ 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_label, + dartdoc_constructors, dartdoc_parameters) = _collect_dartdoc(src) + stem = _file_stem(path) file_nid = _make_id(str(path)) @@ -50,16 +341,33 @@ 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("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: - 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} + 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, @@ -525,4 +833,83 @@ 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-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 + # 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_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) + return {"nodes": nodes, "edges": edges} diff --git a/tests/test_dart.py b/tests/test_dart.py index 094a6f5e3..b2d4c5aa7 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,338 @@ 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: full prose lands on nodes, and + See also / {@tool} / {@template}-{@macro} become edges.""" + code_content = textwrap.dedent(""" + /// Material Design button collection. + 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 { + 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. + self.assertEqual(by_label["my_fab.dart"]["doc"], "Material Design button collection.") + + # 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.\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.") + 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_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.""" + 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 doc.""" + 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_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 the text 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["doc"], 'help_outline \u2014 material icon named "help outline".' + ) + + link_doc = _parse_dartdoc("See for details.") + 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__": unittest.main()