blob: 8566c92d56922249b7dd459007c40957d28319ed [file] [edit]
// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math show min;
import 'package:meta/meta.dart';
import 'package:path/path.dart' as path;
import 'package:source_span/source_span.dart';
/// A tool to extract declared docregions from specified source files.
///
/// Caches all docregions parsed from files, so consider
/// creating a new one for each file you're injecting in to.
final class ExcerptExtractor {
/// A cache of file paths to to the regions found within them.
///
/// If the value a pile path points to is `null`,
/// the file couldn't be found or read.
final Map<String, Map<String, Region>?> _regionCacheByPath = {};
/// Extract the region with the specified [regionName] from
/// the file located at the specified [filePath].
///
/// If a file does not exist at that location or the [regionName]
/// does not exist either, a `ExtractException` will be thrown.
@useResult
Future<Region> extractRegion(String filePath, String regionName) async {
final canonicalPath = path.canonicalize(filePath);
if (!_regionCacheByPath.containsKey(canonicalPath)) {
_regionCacheByPath[canonicalPath] = await _extractAllRegions(
canonicalPath,
);
}
final regions = _regionCacheByPath[canonicalPath];
if (regions == null) {
throw ExtractException('No file exists at $filePath.');
}
final region = regions[regionName];
if (region == null) {
throw ExtractException(
'The region "$regionName" does not exist in the file at $filePath.',
);
}
return region;
}
/// Extract all declared docregions directly from the provided string
/// [content].
///
/// The [filePath] is used to determine the language dialect for parsing
/// comments and docregion tags, and is also associated with the returned
/// regions for error reporting.
///
/// Caches the parsed regions internally by their canonicalized [filePath].
@useResult
Map<String, Region> extractRegionsFromContent(
String content,
String filePath,
) {
final canonicalPath = path.canonicalize(filePath);
final regions = _extractAllRegionsFromContent(content, canonicalPath);
_regionCacheByPath[canonicalPath] = regions;
return regions;
}
/// Synchronously extract the region with the specified [regionName] from
/// the file located at the specified [filePath].
///
/// If a file does not exist at that location or the [regionName]
/// does not exist either, a [ExtractException] will be thrown.
@useResult
Region extractRegionSync(String filePath, String regionName) {
final canonicalPath = path.canonicalize(filePath);
if (!_regionCacheByPath.containsKey(canonicalPath)) {
_regionCacheByPath[canonicalPath] = _extractAllRegionsSync(canonicalPath);
}
final regions = _regionCacheByPath[canonicalPath];
if (regions == null) {
throw ExtractException('No file exists at $filePath.');
}
final region = regions[regionName];
if (region == null) {
throw ExtractException(
'The region "$regionName" does not exist in the file at $filePath.',
);
}
return region;
}
@useResult
Future<Map<String, Region>?> _extractAllRegions(String path) async {
final file = File(path);
if (!(await file.exists())) {
return null;
}
final content = await file.readAsString();
return _extractAllRegionsFromContent(content, path);
}
@useResult
Map<String, Region>? _extractAllRegionsSync(String path) {
final file = File(path);
if (!file.existsSync()) {
return null;
}
final content = file.readAsStringSync();
return _extractAllRegionsFromContent(content, path);
}
Map<String, Region> _extractAllRegionsFromContent(
String content,
String path,
) {
final sourceFile = SourceFile.fromString(content, url: Uri.file(path));
final lines = const LineSplitter().convert(content);
final dialect = _LanguageDialect.fromPath(path);
var state = _ScannerState.code;
String? currentStringDelimiter;
(String, String)? currentBlockCommentDelimiter;
final regionContent = <String, Region>{_entireFileRegionName: Region._()};
final currentRegions = <String>{_entireFileRegionName};
for (var lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
final line = lines[lineIndex];
final trimmedLine = line.trimLeft();
final indent = line.length - trimmedLine.length;
final lineStart = sourceFile.getOffset(lineIndex);
final lineEnd = lineStart + line.length;
final lineSpan = sourceFile.span(lineStart, lineEnd);
final (
nextState,
nextStringDelim,
nextBlockCommentDelim,
isDirective,
match,
) = _scanLine(
line,
dialect,
state,
currentStringDelimiter,
currentBlockCommentDelimiter,
);
state = nextState;
currentStringDelimiter = nextStringDelim;
currentBlockCommentDelimiter = nextBlockCommentDelim;
if (isDirective && match != null) {
final isEnd = match.namedGroup('end') != null;
final rawRegionNames = match.namedGroup('regions');
if (rawRegionNames == null) {
throw ExtractException(
'A docregion comment must specify at least one region!',
lineSpan,
);
}
final regionNames = dialect.cleanRegions(rawRegionNames).split(',');
for (final rawRegionName in regionNames) {
final regionName = rawRegionName.trim();
if (regionName.isEmpty) {
throw ExtractException(
'docregion comment tried to use an empty region name.',
lineSpan,
);
}
if (isEnd) {
final removed = currentRegions.remove(regionName);
if (!removed) {
throw ExtractException(
'enddocregion tried to close the '
"unopened '$regionName' region!",
lineSpan,
);
}
} else {
if (regionContent[regionName] case final region?) {
// If the region already exists, add a plaster line.
region._addPlaster(indent);
} else {
regionContent[regionName] = Region._();
}
currentRegions.add(regionName);
}
}
} else {
// Just a normal line.
for (final region in currentRegions) {
regionContent[region]!._addLine(line, indent);
}
}
}
currentRegions.remove(_entireFileRegionName);
if (currentRegions.isNotEmpty) {
throw ExtractException(
'Regions $currentRegions were not closed.',
sourceFile.span(content.length),
);
}
return regionContent;
}
}
(
_ScannerState nextState,
String? nextStringDelimiter,
(String, String)? nextBlockCommentDelimiter,
bool isDirective,
RegExpMatch? match,
)
_scanLine(
String line,
_LanguageDialect dialect,
_ScannerState state,
String? currentStringDelimiter,
(String, String)? currentBlockCommentDelimiter,
) {
if (state == _ScannerState.lineComment) {
state = _ScannerState.code;
}
final match = _docRegionDirective.firstMatch(line);
var isDirective = false;
var i = 0;
final len = line.length;
while (i < len) {
if (match != null && i == match.start) {
if (state == _ScannerState.lineComment ||
state == _ScannerState.blockComment) {
isDirective = true;
}
}
if (state == _ScannerState.code) {
(String, String)? matchedBlock;
for (final block in dialect.blockComments) {
if (line.startsWith(block.$1, i)) {
matchedBlock = block;
break;
}
}
if (matchedBlock != null) {
state = _ScannerState.blockComment;
currentBlockCommentDelimiter = matchedBlock;
i += matchedBlock.$1.length;
continue;
}
String? matchedLineComment;
for (final prefix in dialect.lineComments) {
if (line.startsWith(prefix, i)) {
matchedLineComment = prefix;
break;
}
}
if (matchedLineComment != null) {
state = _ScannerState.lineComment;
if (match != null && match.start >= i) {
isDirective = true;
}
break;
}
String? matchedStringDelim;
for (final delim in dialect.stringDelimiters) {
if (line.startsWith(delim, i)) {
matchedStringDelim = delim;
break;
}
}
if (matchedStringDelim != null) {
state = _ScannerState.string;
currentStringDelimiter = matchedStringDelim;
i += matchedStringDelim.length;
continue;
}
i++;
} else if (state == _ScannerState.string) {
if (line.startsWith(r'\', i) && i + 1 < len) {
i += 2;
continue;
}
if (line.startsWith(currentStringDelimiter!, i)) {
state = _ScannerState.code;
i += currentStringDelimiter.length;
currentStringDelimiter = null;
} else {
i++;
}
} else if (state == _ScannerState.blockComment) {
final endToken = currentBlockCommentDelimiter!.$2;
if (line.startsWith(endToken, i)) {
state = _ScannerState.code;
i += endToken.length;
currentBlockCommentDelimiter = null;
} else {
i++;
}
}
}
return (
state,
currentStringDelimiter,
currentBlockCommentDelimiter,
isDirective,
match,
);
}
/// The contents of a docregion found in a file.
final class Region {
/// The untransformed text lines or plaster lines of the docregion.
final List<_RegionLine> _lines = [];
/// The minimum indent seen in this region.
int? _minIndent;
/// Creates a [Region] with the specified indentation,
/// usually from the docregion comment.
Region._();
/// Adds the specified [line] with the specified [indent]
/// to the contents of the region.
void _addLine(String line, int indent) {
_lines.add(_StringLine(line));
// Ignore the indent of blank lines.
if (line.trim().isNotEmpty) {
final currentMin = _minIndent;
_minIndent = currentMin == null ? indent : math.min(currentMin, indent);
}
}
/// Adds a line where a plaster could be inserted
/// as well as the [directiveIndent] of the directive adding it.
///
/// This is usually when a docregion is closed and opened again.
void _addPlaster(int directiveIndent) {
_lines.add(_PlasterLine(directiveIndent));
}
/// Builds a list of strings from the region,
/// replacing lines marked as plasters with the
/// specified [plaster] content,
/// and applying the minimum indentation to each line.
///
/// If [plaster] is `null` or `'none'`,
/// the plaster lines are not included at all.
@useResult
Iterable<String> linesWithPlaster(final String? plaster) {
final updatedLines = <String>[];
final includePlaster = plaster != null && plaster != 'none';
final minIndent = _minIndent ?? 0;
for (final line in _lines) {
switch (line) {
case _PlasterLine(:final directiveIndent):
if (includePlaster) {
final minimizedDirectiveIndent = directiveIndent - minIndent;
updatedLines.add('${' ' * minimizedDirectiveIndent}$plaster');
}
case _StringLine(:final line):
if (minIndent == 0 || line.length < minIndent) {
updatedLines.add(line);
} else {
updatedLines.add(line.substring(minIndent));
}
}
}
return updatedLines;
}
}
sealed class _RegionLine {}
final class _StringLine extends _RegionLine {
final String line;
_StringLine(this.line);
}
final class _PlasterLine extends _RegionLine {
final int directiveIndent;
_PlasterLine(this.directiveIndent);
}
/// An exception thrown when a [ExcerptExtractor]
/// failed to extract a region.
final class ExtractException extends SourceSpanException {
/// Create a [ExtractException] with the specified [message] and [span].
ExtractException(super.message, [super.span]);
}
const String _entireFileRegionName = '';
final RegExp _docRegionDirective = RegExp(
r'#(?<end>end)?docregion\s+(?<regions>[a-zA-Z0-9,-\s]+)',
);
enum _ScannerState { code, string, lineComment, blockComment }
enum _LanguageDialect {
cStyle(
extensions: {
'dart',
'js',
'ts',
'go',
'java',
'kt',
'swift',
'c',
'cpp',
'cs',
},
lineComments: ['//'],
blockComments: [('/*', '*/')],
stringDelimiters: ["'''", '"""', "'", '"'],
),
pythonStyle(
extensions: {'py'},
lineComments: ['#'],
blockComments: [],
stringDelimiters: ["'''", '"""', "'", '"'],
),
hashStyle(
extensions: {'yaml', 'yml'},
lineComments: ['#'],
blockComments: [],
stringDelimiters: ["'", '"'],
),
htmlStyle(
extensions: {'html', 'xml'},
lineComments: [],
blockComments: [('<!--', '-->')],
stringDelimiters: ["'", '"'],
),
cssStyle(
extensions: {'css'},
lineComments: [],
blockComments: [('/*', '*/')],
stringDelimiters: [],
),
fallback(
extensions: {},
lineComments: ['//', '#'],
blockComments: [('/*', '*/'), ('<!--', '-->')],
stringDelimiters: ["'''", '"""', "'", '"'],
);
final Set<String> extensions;
final List<String> lineComments;
final List<(String, String)> blockComments;
final List<String> stringDelimiters;
const _LanguageDialect({
required this.extensions,
required this.lineComments,
required this.blockComments,
required this.stringDelimiters,
});
String cleanRegions(String raw) {
switch (this) {
case _LanguageDialect.htmlStyle:
// HTML block comments end in '-->'. Since dashes are allowed inside
// region names, the greedy regex matching group catches the trailing
// dashes from the comment closing delimiter (e.g. 'html-region --').
// We strip them off to recover the actual region name.
var cleaned = raw.trimRight();
while (cleaned.endsWith('-')) {
cleaned = cleaned.substring(0, cleaned.length - 1).trimRight();
}
return cleaned;
default:
return raw.trimRight();
}
}
factory _LanguageDialect.fromPath(String filePath) {
final ext = path.extension(filePath).replaceFirst('.', '').toLowerCase();
for (final dialect in _LanguageDialect.values) {
if (dialect.extensions.contains(ext)) {
return dialect;
}
}
return fallback;
}
}