Skip to content

Commit 6396d51

Browse files
authored
Support region extraction in {@example} directive (#4253)
1 parent a8cca82 commit 6396d51

4 files changed

Lines changed: 438 additions & 21 deletions

File tree

lib/src/model/documentation_comment.dart

Lines changed: 144 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -339,14 +339,26 @@ mixin DocumentationComment implements Warnable, SourceCode {
339339
///
340340
/// Syntax:
341341
///
342-
/// &#123;@example <path> [lang=LANGUAGE] [indent=keep|strip]&#125;
342+
/// &#123;@example <path>[#<region>] [lang=LANGUAGE] [indent=keep|strip]&#125;
343343
///
344344
/// The `path` is resolved relative to the current file's path, using the
345345
/// package's root as the root path;
346346
/// see [resolveExamplePath] for details.
347347
///
348348
/// The file content is processed as follows:
349349
/// - Line endings are normalized to `\n`.
350+
/// - If a `#<region>` is specified in the path, only the content within that
351+
/// region is extracted. A region is defined by `#region <region>` and
352+
/// `#endregion`. Lines containing region markers are stripped.
353+
/// - Lines containing a `#hide` marker are entirely omitted from the
354+
/// extracted output. This is useful for hiding setup, or assertions, or
355+
/// other code that is necessary for the example to compile but
356+
/// irrelevant to the documentation (e.g., `exit(0); // #hide`).
357+
/// - Region extraction uses a best effort approach: if there are unmatched
358+
/// closing tags or unclosed regions, the code is extracted based on the
359+
/// stack ordering and emit a warning for the structural error.
360+
/// - If no region is specified, the entire file is included and no markers
361+
/// are stripped.
350362
/// - If `indent` is `strip` (the default), common indentation is removed from
351363
/// all lines. Whitespace-only lines are ignored for calculation and
352364
/// normalized to empty lines.
@@ -361,6 +373,7 @@ mixin DocumentationComment implements Warnable, SourceCode {
361373
/// Example:
362374
///
363375
/// &#123;@example /examples/my_example.dart&#125;
376+
/// &#123;@example /examples/my_example.dart#my_region&#125;
364377
/// &#123;@example ../subdir/utils.dart lang=dart indent=strip&#125;
365378
/// &#123;@example ../subdir/utils.dart indent=keep&#125;
366379
String _injectExamples(String rawDocs) {
@@ -389,8 +402,15 @@ mixin DocumentationComment implements Warnable, SourceCode {
389402
'Ignoring extra arguments: ${args.rest.skip(1).join(" ")}');
390403
}
391404

392-
var filepath = args.rest.first;
393-
// TODO(zarah): Add support for regions (#region, #endregion).
405+
var rawPath = args.rest.first;
406+
var parsedUri = Uri.tryParse(rawPath);
407+
if (parsedUri == null) {
408+
warn(PackageWarning.invalidParameter,
409+
message: 'Invalid path for @example directive ($rawPath)');
410+
return '';
411+
}
412+
var targetRegion = parsedUri.hasFragment ? parsedUri.fragment : null;
413+
var filepath = parsedUri.removeFragment().toString();
394414

395415
var resolvedPath = resolveExamplePath(
396416
filepath,
@@ -418,14 +438,39 @@ mixin DocumentationComment implements Warnable, SourceCode {
418438
// Normalize line endings to \n.
419439
content = content.replaceAll(_lineEndingRegExp, '\n');
420440

441+
var lines = content.split('\n');
442+
443+
// Extract the region and filter markers before touching the indentation
444+
if (targetRegion != null && targetRegion.isNotEmpty) {
445+
var extractedLines = _extractRegion(
446+
lines,
447+
targetRegion,
448+
filepath: filepath,
449+
warn: warn,
450+
);
451+
452+
if (extractedLines == null) {
453+
warn(PackageWarning.missingExampleRegion,
454+
message: '$filepath#$targetRegion');
455+
return '';
456+
}
457+
lines = extractedLines;
458+
}
459+
460+
if (lines.isEmpty) {
461+
return '```$language\n```';
462+
}
463+
464+
content = lines.join('\n');
465+
421466
if (indent == 'strip') {
422467
content = _stripIndentation(content, warn);
423468
}
424469

425470
// Ensure the content ends with exactly one newline before the closing fence.
426471
var trailing = content.endsWith('\n') ? '' : '\n';
427472

428-
return '\n```$language\n$content$trailing```\n';
473+
return '```$language\n$content$trailing```';
429474
});
430475
}
431476

@@ -1146,4 +1191,99 @@ mixin DocumentationComment implements Warnable, SourceCode {
11461191

11471192
static final _canonicalForRegExp =
11481193
RegExp(r'[ ]*{@canonicalFor\s([^}]+)}[ ]*\n?');
1194+
1195+
/// Extracts the lines for the specified [targetRegion], retaining only the
1196+
/// content within the matching `#region` and `#endregion` boundaries.
1197+
///
1198+
/// Any lines containing `#region`, `#endregion`, or `#hide` markers are
1199+
/// stripped and completely omitted from the result.
1200+
///
1201+
/// The markers do not need to be within a code comment. Any line
1202+
/// containing `#region <name>`, `#endregion`, or `#hide` will be matched
1203+
/// and stripped.
1204+
///
1205+
/// Validates that all regions are properly opened and closed.
1206+
///
1207+
/// Uses a best effort stack-based approach: if there is a structural error
1208+
/// (like an extra `#endregion` or an unclosed region), it emits a warning
1209+
/// but still attempts to extract the code based on the innermost active
1210+
/// region.
1211+
///
1212+
/// Returns `null` if the target region was never found.
1213+
static List<String>? _extractRegion(
1214+
List<String> lines,
1215+
String targetRegion, {
1216+
required String filepath,
1217+
required void Function(PackageWarning, {String? message}) warn,
1218+
}) {
1219+
// Matches `#region` followed by spaces/tabs (no newlines), and captures
1220+
// the name (any characters up to the next whitespace or newline).
1221+
final regionStartPattern = RegExp(
1222+
r'#region[ \t]+(\S+)',
1223+
caseSensitive: false,
1224+
);
1225+
1226+
// Matches `#endregion` anywhere in the line. Any name after it is ignored.
1227+
final regionEndPattern = RegExp(
1228+
r'#endregion\b',
1229+
caseSensitive: false,
1230+
);
1231+
1232+
// Matches `#hide` anywhere in the line.
1233+
final hidePattern = RegExp(
1234+
r'#hide\b',
1235+
caseSensitive: false,
1236+
);
1237+
1238+
final result = <String>[];
1239+
final regionStack = <String>[];
1240+
var regionFound = false;
1241+
1242+
for (var i = 0; i < lines.length; i++) {
1243+
final line = lines[i];
1244+
final lineNumber = i + 1;
1245+
1246+
final startMatch = regionStartPattern.firstMatch(line);
1247+
if (startMatch != null) {
1248+
final regionName = startMatch.group(1)!;
1249+
regionStack.add(regionName);
1250+
1251+
if (regionName == targetRegion) {
1252+
regionFound = true;
1253+
}
1254+
continue;
1255+
}
1256+
1257+
if (regionEndPattern.hasMatch(line)) {
1258+
if (regionStack.isEmpty) {
1259+
warn(
1260+
PackageWarning.invalidParameter,
1261+
message:
1262+
'Found #endregion without a matching #region in $filepath at line $lineNumber.',
1263+
);
1264+
} else {
1265+
regionStack.removeLast();
1266+
}
1267+
continue;
1268+
}
1269+
1270+
if (hidePattern.hasMatch(line)) {
1271+
continue;
1272+
}
1273+
1274+
if (regionStack.contains(targetRegion)) {
1275+
result.add(line);
1276+
}
1277+
}
1278+
1279+
if (regionStack.isNotEmpty) {
1280+
warn(
1281+
PackageWarning.invalidParameter,
1282+
message:
1283+
'Unclosed #region(s) found in $filepath: ${regionStack.join(', ')}',
1284+
);
1285+
}
1286+
1287+
return regionFound ? result : null;
1288+
}
11491289
}

lib/src/model/package_graph.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,7 @@ class PackageGraph with Referable {
477477
PackageWarning.internalError ||
478478
PackageWarning.missingFromSearchIndex ||
479479
PackageWarning.missingExampleFile ||
480+
PackageWarning.missingExampleRegion ||
480481
PackageWarning.typeAsHtml ||
481482
PackageWarning.invalidParameter ||
482483
PackageWarning.toolError ||

lib/src/warnings.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,13 @@ enum PackageWarning implements Comparable<PackageWarning> {
270270
'An example file referenced by a dartdoc directive was not found',
271271
),
272272

273+
missingExampleRegion(
274+
'missing-example-region',
275+
'example region not found: {0}',
276+
shortHelp:
277+
'An example region referenced by a dartdoc directive was not found',
278+
),
279+
273280
// The message for this warning can contain many punctuation and other
274281
// symbols, so bracket with a triple quote for defense.
275282
typeAsHtml(

0 commit comments

Comments
 (0)