blob: fb4da64732477e35ef6ad83c673146f8e80c22ed [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 'package:collection/collection.dart';
import 'package:meta/meta.dart';
/// A stored transformation that can transform from
/// a collection of strings, usually lines,
/// to a transformed version using [transform].
sealed class Transform {
/// Transform the provided [lines] with the transform
/// defined by this type.
@useResult
Iterable<String> transform(Iterable<String> lines);
}
/// A base class for transforms based off a singular [Pattern].
abstract final class PatternTransform extends Transform {
/// The pattern to apply during the transform.
final Pattern pattern;
/// The super constructor for transforms that apply a singular pattern.
PatternTransform(this.pattern);
}
/// A transform to remove the lines that don't contain the specified [pattern].
final class RetainTransform extends PatternTransform {
/// Create a [RetainTransform] that removes the lines that
/// don't contain the specified [pattern].
RetainTransform(super.pattern);
@override
Iterable<String> transform(Iterable<String> lines) {
return lines.where((line) => line.contains(pattern));
}
}
/// A transform to remove the lines that contain the specified [pattern].
final class RemoveTransform extends PatternTransform {
/// Create a [RemoveTransform] that removes the lines that
/// do contain the specified [pattern].
RemoveTransform(super.pattern);
@override
Iterable<String> transform(Iterable<String> lines) {
return lines.whereNot((line) => line.contains(pattern));
}
}
/// A transform to keep the lines after and including the first one
/// that contains the specified [pattern].
final class FromTransform extends PatternTransform {
/// Create a [FromTransform] that keeps the lines after and including
/// the first one that contains the specified [pattern].
FromTransform(super.pattern);
@override
Iterable<String> transform(Iterable<String> lines) {
return lines.skipWhile((line) => !line.contains(pattern));
}
}
/// A transform to keep only the lines before and including the first one
/// that contains the specified [pattern].
final class ToTransform extends PatternTransform {
/// Create a [ToTransform] that keeps the lines before and including
/// the first one that contains the specified [pattern].
ToTransform(super.pattern);
@override
Iterable<String> transform(Iterable<String> lines) {
final newLines = <String>[];
for (final line in lines) {
newLines.add(line);
if (line.contains(pattern)) {
break;
}
}
return newLines;
}
}
/// A base class for transforms based off a singular integer [count].
abstract final class AmountTransform extends Transform {
/// The count to use during the transform.
final int count;
/// The super constructor for transforms that utilize a singular [count].
AmountTransform(this.count);
}
/// A transform to skip the first [count] lines if positive or
/// the last [count] lines if negative.
final class SkipTransform extends AmountTransform {
/// Create a [SkipTransform] that skips the first [count] lines if
/// positive or the last [count] lines if negative.
SkipTransform(super.count);
@override
Iterable<String> transform(Iterable<String> lines) {
if (count.isNegative) {
return lines.take(lines.length + count);
} else {
return lines.skip(count);
}
}
}
/// A transform to keep the first [count] lines if positive or
/// the last [count] lines if negative.
final class TakeTransform extends AmountTransform {
/// Create a [TakeTransform] that keeps the first [count] lines if
/// positive or the last [count] lines if negative.
TakeTransform(super.count);
@override
Iterable<String> transform(Iterable<String> lines) {
if (count.isNegative) {
return lines.skip(lines.length + count);
} else {
return lines.take(count);
}
}
}
/// A transform to indent each line with [count] whitespace.
final class IndentTransform extends AmountTransform {
/// Create a [IndentTransform] that indents each line with [count] whitespace.
IndentTransform(super.count) : assert(count >= 0);
@override
Iterable<String> transform(Iterable<String> lines) {
if (count == 0) return lines;
final indentString = ' ' * count;
return lines.map((line) => '$indentString$line');
}
}
/// Convert the [replaceInstructions] string that can include multiple
/// regular expression-based replacements to multiple
/// [ReplaceTransform] instances representing their transformation.
///
/// Errors in parsing the [replaceInstructions] are reported to the
/// calling function through the [reportError] callback.
@useResult
Iterable<ReplaceTransform> stringToReplaceTransforms(
String replaceInstructions,
Never Function(String) reportError,
) {
final parts = replaceInstructions
.replaceAll(r'\/', _placeholderString)
.split('/')
.map((part) => part.replaceAll(_placeholderString, '/'))
.toList(growable: false);
final length = parts.length;
if (length < 4 || length % 3 != 1) {
reportError('Each replace transform must have 3 parts.');
}
final start = parts[0];
if (start.isNotEmpty) {
reportError('A replace transform must start with a forward slash (`/`).');
}
final transforms = <ReplaceTransform>[];
for (var index = 1; index < length; index += 3) {
final end = parts[index + 2];
if (!_endReplacePattern.hasMatch(end)) {
reportError('A replace transform must end with `g;`.');
}
final originalPattern = parts[index];
final replaceWith = parts[index + 1];
final encodedReplaceWith = _encodeSlashChar(replaceWith);
if (!encodedReplaceWith.contains(_matchDollarNumRE)) {
transforms.add(
SimpleReplaceTransform(
RegExp(originalPattern, multiLine: true),
encodedReplaceWith,
),
);
} else {
transforms.add(
BackReferenceReplaceTransform(
RegExp(originalPattern, multiLine: true),
encodedReplaceWith,
),
);
}
}
return transforms;
}
/// A base class for replacement transforms that convert
/// text from one form to another, based on a provided [from] pattern
/// and [to] string.
sealed class ReplaceTransform extends Transform {
/// The pattern to match text to consider for replacement.
final Pattern from;
/// The string to replace the text matching the [from] pattern with.
final String to;
/// The super constructor for transforms that apply a replacement.
ReplaceTransform(this.from, this.to);
/// Transform the provided [lines] with the transform
/// defined by this type.
///
/// Note that, compared to other [Transform] types,
/// [ReplaceTransform] subtypes tend to operator on combined lines
/// rather than each line individually.
@override
Iterable<String> transform(Iterable<String> lines);
}
/// A transform to replace each instance of the
/// [from] pattern with the [to] string.
final class SimpleReplaceTransform extends ReplaceTransform {
/// Create a [SimpleReplaceTransform] that replaces each instance of the
/// [from] pattern with the [to] string in the combined text.
SimpleReplaceTransform(super.from, super.to);
@override
Iterable<String> transform(Iterable<String> lines) {
return lines.join('\n').replaceAll(from, to).split('\n');
}
}
/// A transform to replace each instance of the
/// [from] pattern with the [to] string while
/// allowing backreferences to captured groups in the [to] string.
final class BackReferenceReplaceTransform extends ReplaceTransform {
/// Create a [BackReferenceReplaceTransform] that replaces each instance of
/// the [from] pattern with the [to] string in the combined text,
/// while allowing backreferences to captured groups in the [to] string.
BackReferenceReplaceTransform(super.from, super.to);
@override
Iterable<String> transform(Iterable<String> lines) {
return lines
.join('\n')
.replaceAllMapped(
from,
(match) => to.replaceAllMapped(_matchDollarNumRE, (replaceMatch) {
// The following works to match JS `replace` semantics.
// $$ becomes $ in a replacement string.
final dollarSignCount = replaceMatch[1]!.length;
// The escaped dollar sign characters present (if any).
final escapedDollarSigns = r'$' * (dollarSignCount ~/ 2);
// Potentially a reference to a captured group,
// otherwise the content after the escaped dollar signs.
final potentialGroupReference = replaceMatch[2];
if (potentialGroupReference == null ||
potentialGroupReference.isEmpty) {
return escapedDollarSigns;
}
if (dollarSignCount.isEven) {
return '$escapedDollarSigns$potentialGroupReference';
}
// $& references the entire matched substring.
if (potentialGroupReference == '&') {
return '$escapedDollarSigns${match[0]}';
}
final groupNumber = int.tryParse(potentialGroupReference);
if (groupNumber == null || groupNumber > match.groupCount) {
// If there is no corresponding capture group,
// just output the reference itself.
return '$escapedDollarSigns\$$potentialGroupReference';
}
return '$escapedDollarSigns${match[groupNumber]}';
}),
)
.split('\n');
}
}
const String _placeholderString = '\u{0}';
final RegExp _matchDollarNumRE = RegExp(r'(\$+)(&|\d*)');
final RegExp _slashHexCharRE = RegExp(r'\\x(..)');
final RegExp _slashLetterRE = RegExp(r'\\([\\nt])');
/// Encode special characters: '\t', `\n`, and `\xHH` where `HH` are hex digits.
String _encodeSlashChar(String s) => s
.replaceAllMapped(_slashLetterRE, (match) => _slashCharToChar(match[1]))
.replaceAllMapped(
_slashHexCharRE,
// At this point, escaped `\` is encoded as [_placeholderString].
(match) => _hexToChar(match[1], errorValue: '\\x${match[1]}'),
)
.replaceAll(_placeholderString, '\\'); // Recover `\` characters.
String _hexToChar(String? hexDigits, {required String errorValue}) {
final charCode = int.tryParse(hexDigits ?? '', radix: 16);
return charCode == null ? errorValue : String.fromCharCode(charCode);
}
String _slashCharToChar(String? char) => switch (char) {
'n' => '\n',
't' => '\t',
'\\' => _placeholderString,
_ => '\\$char',
};
final RegExp _endReplacePattern = RegExp(r'^g;?\s*$');