diff --git a/pkg/dev_compiler/test/nullable_inference_test.dart b/pkg/dev_compiler/test/nullable_inference_test.dart index f09eef1efb63..f94a1ff7facc 100644 --- a/pkg/dev_compiler/test/nullable_inference_test.dart +++ b/pkg/dev_compiler/test/nullable_inference_test.dart @@ -243,6 +243,7 @@ void main() { s.replaceRange(1, 2, s); s.split(s); s.splitMapJoin(s, onMatch: (_) => s, onNonMatch: (_) => s); + s.splitMap(s, onMatch: (_) => s, onNonMatch: (_) => s); s.startsWith(s); s.substring(1); s.toLowerCase(); diff --git a/pkg/linter/lib/src/rules/null_closures.dart b/pkg/linter/lib/src/rules/null_closures.dart index 053b5653352e..88d9f8fcedac 100644 --- a/pkg/linter/lib/src/rules/null_closures.dart +++ b/pkg/linter/lib/src/rules/null_closures.dart @@ -91,6 +91,10 @@ final Map> NonNullableFunction('dart.core', 'String', 'splitMapJoin', named: ['onMatch', 'onNonMatch']), }, + 'splitMap': { + NonNullableFunction('dart.core', 'Iterable', 'splitMap', + named: ['onMatch', 'onNonMatch']), + }, 'takeWhile': { NonNullableFunction('dart.core', 'Iterable', 'takeWhile', positional: [0]), }, diff --git a/pkg/linter/messages.yaml b/pkg/linter/messages.yaml index 8438573f14e2..a49b3863fdaf 100644 --- a/pkg/linter/messages.yaml +++ b/pkg/linter/messages.yaml @@ -7037,6 +7037,7 @@ LintCode: * `String.replaceAllMapped` at the 1st positional parameter * `String.replaceFirstMapped` at the 1st positional parameter * `String.splitMapJoin` at the named parameters `onMatch` and `onNonMatch` + * `String.splitMap` at the named parameter `onMatch` and `onNonMatch` **BAD:** ```dart diff --git a/pkg/linter/tool/machine/rules.json b/pkg/linter/tool/machine/rules.json index f6c99ca30d57..469f2f07b6db 100644 --- a/pkg/linter/tool/machine/rules.json +++ b/pkg/linter/tool/machine/rules.json @@ -1532,7 +1532,7 @@ "incompatible": [], "sets": [], "fixStatus": "hasFix", - "details": "**DON'T** pass `null` as an argument where a closure is expected.\n\nOften a closure that is passed to a method will only be called conditionally,\nso that tests and \"happy path\" production calls do not reveal that `null` will\nresult in an exception being thrown.\n\nThis rule only catches null literals being passed where closures are expected\nin the following locations:\n\n#### Constructors\n\n* From `dart:async`\n * `Future` at the 0th positional parameter\n * `Future.microtask` at the 0th positional parameter\n * `Future.sync` at the 0th positional parameter\n * `Timer` at the 0th positional parameter\n * `Timer.periodic` at the 1st positional parameter\n* From `dart:core`\n * `List.generate` at the 1st positional parameter\n\n#### Static functions\n\n* From `dart:async`\n * `scheduleMicrotask` at the 0th positional parameter\n * `Future.doWhile` at the 0th positional parameter\n * `Future.forEach` at the 0th positional parameter\n * `Future.wait` at the named parameter `cleanup`\n * `Timer.run` at the 0th positional parameter\n\n#### Instance methods\n\n* From `dart:async`\n * `Future.then` at the 0th positional parameter\n * `Future.complete` at the 0th positional parameter\n* From `dart:collection`\n * `Queue.removeWhere` at the 0th positional parameter\n * `Queue.retain\n * `Iterable.firstWhere` at the 0th positional parameter, and the named\n parameter `orElse`\n * `Iterable.forEach` at the 0th positional parameter\n * `Iterable.fold` at the 1st positional parameter\n * `Iterable.lastWhere` at the 0th positional parameter, and the named\n parameter `orElse`\n * `Iterable.map` at the 0th positional parameter\n * `Iterable.reduce` at the 0th positional parameter\n * `Iterable.singleWhere` at the 0th positional parameter, and the named\n parameter `orElse`\n * `Iterable.skipWhile` at the 0th positional parameter\n * `Iterable.takeWhile` at the 0th positional parameter\n * `Iterable.where` at the 0th positional parameter\n * `List.removeWhere` at the 0th positional parameter\n * `List.retainWhere` at the 0th positional parameter\n * `String.replaceAllMapped` at the 1st positional parameter\n * `String.replaceFirstMapped` at the 1st positional parameter\n * `String.splitMapJoin` at the named parameters `onMatch` and `onNonMatch`\n\n**BAD:**\n```dart\n[1, 3, 5].firstWhere((e) => e.isOdd, orElse: null);\n```\n\n**GOOD:**\n```dart\n[1, 3, 5].firstWhere((e) => e.isOdd, orElse: () => null);\n```", + "details": "**DON'T** pass `null` as an argument where a closure is expected.\n\nOften a closure that is passed to a method will only be called conditionally,\nso that tests and \"happy path\" production calls do not reveal that `null` will\nresult in an exception being thrown.\n\nThis rule only catches null literals being passed where closures are expected\nin the following locations:\n\n#### Constructors\n\n* From `dart:async`\n * `Future` at the 0th positional parameter\n * `Future.microtask` at the 0th positional parameter\n * `Future.sync` at the 0th positional parameter\n * `Timer` at the 0th positional parameter\n * `Timer.periodic` at the 1st positional parameter\n* From `dart:core`\n * `List.generate` at the 1st positional parameter\n\n#### Static functions\n\n* From `dart:async`\n * `scheduleMicrotask` at the 0th positional parameter\n * `Future.doWhile` at the 0th positional parameter\n * `Future.forEach` at the 0th positional parameter\n * `Future.wait` at the named parameter `cleanup`\n * `Timer.run` at the 0th positional parameter\n\n#### Instance methods\n\n* From `dart:async`\n * `Future.then` at the 0th positional parameter\n * `Future.complete` at the 0th positional parameter\n* From `dart:collection`\n * `Queue.removeWhere` at the 0th positional parameter\n * `Queue.retain\n * `Iterable.firstWhere` at the 0th positional parameter, and the named\n parameter `orElse`\n * `Iterable.forEach` at the 0th positional parameter\n * `Iterable.fold` at the 1st positional parameter\n * `Iterable.lastWhere` at the 0th positional parameter, and the named\n parameter `orElse`\n * `Iterable.map` at the 0th positional parameter\n * `Iterable.reduce` at the 0th positional parameter\n * `Iterable.singleWhere` at the 0th positional parameter, and the named\n parameter `orElse`\n * `Iterable.skipWhile` at the 0th positional parameter\n * `Iterable.takeWhile` at the 0th positional parameter\n * `Iterable.where` at the 0th positional parameter\n * `List.removeWhere` at the 0th positional parameter\n * `List.retainWhere` at the 0th positional parameter\n * `String.replaceAllMapped` at the 1st positional parameter\n * `String.replaceFirstMapped` at the 1st positional parameter\n * `String.splitMapJoin` at the named parameters `onMatch` and `onNonMatch` * `String.splitMap` at the named parameters `onMatch` and `onNonMatch`\n\n**BAD:**\n```dart\n[1, 3, 5].firstWhere((e) => e.isOdd, orElse: null);\n```\n\n**GOOD:**\n```dart\n[1, 3, 5].firstWhere((e) => e.isOdd, orElse: () => null);\n```", "sinceDartSdk": "2.0" }, { diff --git a/sdk/lib/_internal/js_dev_runtime/private/js_string.dart b/sdk/lib/_internal/js_dev_runtime/private/js_string.dart index ac49213b9884..2c9410e26c41 100644 --- a/sdk/lib/_internal/js_dev_runtime/private/js_string.dart +++ b/sdk/lib/_internal/js_dev_runtime/private/js_string.dart @@ -83,6 +83,104 @@ final class JSString extends Interceptor return stringReplaceAllFuncUnchecked(this, from, onMatch, onNonMatch); } + @notNull + Iterable splitMap( + Pattern pattern, { + T Function(Match match)? onMatch, + T Function(String nonMatch)? onNonMatch, + }) { + return stringSplitMapUnchecked(this, pattern, onMatch, onNonMatch); + } + + Iterable stringSplitMapUnchecked(String receiver, Pattern pattern, + T Function(Match)? onMatch, T Function(String)? onNonMatch) { + onMatch ??= (match) => match[0]! as T; + onNonMatch ??= (string) => string as T; + if (pattern is String) { + return stringSplitStringMapUnchecked( + receiver, pattern, onMatch, onNonMatch); + } + if (pattern is JSSyntaxRegExp) { + return stringSplitJSRegExpMapUnchecked(receiver, pattern, onMatch, onNonMatch); + } + return stringSplitGeneralMapUnchecked(receiver, pattern, onMatch, onNonMatch); + } + + Iterable stringSplitStringMapUnchecked(String receiver, String pattern, + T Function(Match) onMatch, T Function(String) onNonMatch) { + if (pattern.isEmpty) { + return stringSplitEmptyMapUnchecked(receiver, onMatch, onNonMatch); + } + List result = []; + int startIndex = 0; + int patternLength = pattern.length; + while (true) { + int position = stringIndexOfStringUnchecked(receiver, pattern, startIndex); + if (position == -1) { + break; + } + result.add(onNonMatch(receiver.substring(startIndex, position))); + result.add(onMatch(StringMatch(position, receiver, pattern))); + startIndex = position + patternLength; + } + result.add(onNonMatch(receiver.substring(startIndex))); + return result; + } + + Iterable stringSplitEmptyMapUnchecked( + String receiver, T Function(Match) onMatch, T Function(String) onNonMatch) { + List result = []; + int length = receiver.length; + int i = 0; + result.add(onNonMatch("")); + while (i < length) { + result.add(onMatch(StringMatch(i, receiver, ""))); + // Special case to avoid splitting a surrogate pair. + int code = receiver.codeUnitAt(i); + if ((code & ~0x3FF) == 0xD800 && length > i + 1) { + // Leading surrogate; + code = receiver.codeUnitAt(i + 1); + if ((code & ~0x3FF) == 0xDC00) { + // Matching trailing surrogate. + result.add(onNonMatch(receiver.substring(i, i + 2))); + i += 2; + continue; + } + } + result.add(onNonMatch(receiver[i])); + i++; + } + result.add(onMatch(StringMatch(i, receiver, ""))); + result.add(onNonMatch("")); + return result; + } + + Iterable stringSplitJSRegExpMapUnchecked(String receiver, + JSSyntaxRegExp pattern, T Function(Match) onMatch, T Function(String) onNonMatch) { + List result = []; + int startIndex = 0; + for (Match match in pattern.allMatches(receiver)) { + result.add(onNonMatch(receiver.substring(startIndex, match.start))); + result.add(onMatch(match)); + startIndex = match.end; + } + result.add(onNonMatch(receiver.substring(startIndex))); + return result; + } + + Iterable stringSplitGeneralMapUnchecked(String receiver, Pattern pattern, + T Function(Match) onMatch, T Function(String) onNonMatch) { + List result = []; + int startIndex = 0; + for (Match match in pattern.allMatches(receiver)) { + result.add(onNonMatch(receiver.substring(startIndex, match.start))); + result.add(onMatch(match)); + startIndex = match.end; + } + result.add(onNonMatch(receiver.substring(startIndex))); + return result; + } + @notNull String replaceFirst( Pattern from, diff --git a/sdk/lib/_internal/js_runtime/lib/interceptors.dart b/sdk/lib/_internal/js_runtime/lib/interceptors.dart index 71fa15160258..23edf7d26932 100644 --- a/sdk/lib/_internal/js_runtime/lib/interceptors.dart +++ b/sdk/lib/_internal/js_runtime/lib/interceptors.dart @@ -42,6 +42,7 @@ import 'dart:_js_helper' stringIndexOfStringUnchecked, stringLastIndexOfUnchecked, stringReplaceAllFuncUnchecked, + stringSplitMapUnchecked, stringReplaceAllUnchecked, stringReplaceFirstUnchecked, stringReplaceFirstMappedUnchecked, diff --git a/sdk/lib/_internal/js_runtime/lib/js_string.dart b/sdk/lib/_internal/js_runtime/lib/js_string.dart index 2f0a683c5930..aec01552886a 100644 --- a/sdk/lib/_internal/js_runtime/lib/js_string.dart +++ b/sdk/lib/_internal/js_runtime/lib/js_string.dart @@ -72,6 +72,11 @@ final class JSString extends Interceptor return stringReplaceAllFuncUnchecked(this, from, onMatch, onNonMatch); } + Iterable splitMap(Pattern pattern, + {T Function(Match match)? onMatch, T Function(String nonMatch)? onNonMatch}) { + return stringSplitMapUnchecked(this, pattern, onMatch, onNonMatch); + } + String replaceFirst(Pattern from, String to, [int startIndex = 0]) { checkString(to); checkInt(startIndex); @@ -79,6 +84,94 @@ final class JSString extends Interceptor return stringReplaceFirstUnchecked(this, from, to, startIndex); } + Iterable stringSplitMapUnchecked(String receiver, Pattern pattern, + T Function(Match)? onMatch, T Function(String)? onNonMatch) { + onMatch ??= (match) => match[0]! as T; + onNonMatch ??= (string) => string as T; + if (pattern is String) { + return stringSplitStringMapUnchecked(receiver, pattern, onMatch, onNonMatch); + } + if (pattern is JSSyntaxRegExp) { + return stringSplitJSRegExpMapUnchecked(receiver, pattern, onMatch, onNonMatch); + } + return stringSplitGeneralMapUnchecked(receiver, pattern, onMatch, onNonMatch); + } + + Iterable stringSplitStringMapUnchecked(String receiver, String pattern, + T Function(Match) onMatch, T Function(String) onNonMatch) { + if (pattern.isEmpty) { + return stringSplitEmptyMapUnchecked(receiver, onMatch, onNonMatch); + } + List result = []; + int startIndex = 0; + int patternLength = pattern.length; + while (true) { + int position = stringIndexOfStringUnchecked(receiver, pattern, startIndex); + if (position == -1) { + break; + } + result.add(onNonMatch(receiver.substring(startIndex, position))); + result.add(onMatch(StringMatch(position, receiver, pattern))); + startIndex = position + patternLength; + } + result.add(onNonMatch(receiver.substring(startIndex))); + return result; + } + + Iterable stringSplitEmptyMapUnchecked( + String receiver, T Function(Match) onMatch, T Function(String) onNonMatch) { + List result = []; + int length = receiver.length; + int i = 0; + result.add(onNonMatch("")); + while (i < length) { + result.add(onMatch(StringMatch(i, receiver, ""))); + // Special case to avoid splitting a surrogate pair. + int code = receiver.codeUnitAt(i); + if ((code & ~0x3FF) == 0xD800 && length > i + 1) { + // Leading surrogate; + code = receiver.codeUnitAt(i + 1); + if ((code & ~0x3FF) == 0xDC00) { + // Matching trailing surrogate. + result.add(onNonMatch(receiver.substring(i, i + 2))); + i += 2; + continue; + } + } + result.add(onNonMatch(receiver[i])); + i++; + } + result.add(onMatch(StringMatch(i, receiver, ""))); + result.add(onNonMatch("")); + return result; + } + + Iterable stringSplitJSRegExpMapUnchecked(String receiver, + JSSyntaxRegExp pattern, T Function(Match) onMatch, T Function(String) onNonMatch) { + List result = []; + int startIndex = 0; + for (Match match in pattern.allMatches(receiver)) { + result.add(onNonMatch(receiver.substring(startIndex, match.start))); + result.add(onMatch(match)); + startIndex = match.end; + } + result.add(onNonMatch(receiver.substring(startIndex))); + return result; + } + + Iterable stringSplitGeneralMapUnchecked(String receiver, Pattern pattern, + T Function(Match) onMatch, T Function(String) onNonMatch) { + List result = []; + int startIndex = 0; + for (Match match in pattern.allMatches(receiver)) { + result.add(onNonMatch(receiver.substring(startIndex, match.start))); + result.add(onMatch(match)); + startIndex = match.end; + } + result.add(onNonMatch(receiver.substring(startIndex))); + return result; + } + String replaceFirstMapped(Pattern from, String replace(Match match), [int startIndex = 0]) { checkNull(replace); diff --git a/sdk/lib/_internal/js_runtime/lib/string_helper.dart b/sdk/lib/_internal/js_runtime/lib/string_helper.dart index 41fa7bbc60f8..8ad6d384b30e 100644 --- a/sdk/lib/_internal/js_runtime/lib/string_helper.dart +++ b/sdk/lib/_internal/js_runtime/lib/string_helper.dart @@ -264,6 +264,94 @@ String stringReplaceAllFuncUnchecked(String receiver, Pattern pattern, return buffer.toString(); } +Iterable stringSplitMapUnchecked(String receiver, Pattern pattern, + T Function(Match)? onMatch, T Function(String)? onNonMatch) { + onMatch ??= (match) => match[0]! as T; + onNonMatch ??= (string) => string as T; + if (pattern is String) { + return stringSplitStringMapUnchecked(receiver, pattern, onMatch, onNonMatch); + } + if (pattern is JSSyntaxRegExp) { + return stringSplitJSRegExpMapUnchecked(receiver, pattern, onMatch, onNonMatch); + } + return stringSplitGeneralMapUnchecked(receiver, pattern, onMatch, onNonMatch); +} + +Iterable stringSplitStringMapUnchecked(String receiver, String pattern, + T Function(Match) onMatch, T Function(String) onNonMatch) { + if (pattern.isEmpty) { + return stringSplitEmptyMapUnchecked(receiver, onMatch, onNonMatch); + } + List result = []; + int startIndex = 0; + int patternLength = pattern.length; + while (true) { + int position = stringIndexOfStringUnchecked(receiver, pattern, startIndex); + if (position == -1) { + break; + } + result.add(onNonMatch(receiver.substring(startIndex, position))); + result.add(onMatch(StringMatch(position, receiver, pattern))); + startIndex = position + patternLength; + } + result.add(onNonMatch(receiver.substring(startIndex))); + return result; +} + +Iterable stringSplitEmptyMapUnchecked( + String receiver, T Function(Match) onMatch, T Function(String) onNonMatch) { + List result = []; + int length = receiver.length; + int i = 0; + result.add(onNonMatch("")); + while (i < length) { + result.add(onMatch(StringMatch(i, receiver, ""))); + // Special case to avoid splitting a surrogate pair. + int code = receiver.codeUnitAt(i); + if ((code & ~0x3FF) == 0xD800 && length > i + 1) { + // Leading surrogate; + code = receiver.codeUnitAt(i + 1); + if ((code & ~0x3FF) == 0xDC00) { + // Matching trailing surrogate. + result.add(onNonMatch(receiver.substring(i, i + 2))); + i += 2; + continue; + } + } + result.add(onNonMatch(receiver[i])); + i++; + } + result.add(onMatch(StringMatch(i, receiver, ""))); + result.add(onNonMatch("")); + return result; +} + +Iterable stringSplitJSRegExpMapUnchecked(String receiver, + JSSyntaxRegExp pattern, T Function(Match) onMatch, T Function(String) onNonMatch) { + List result = []; + int startIndex = 0; + for (Match match in pattern.allMatches(receiver)) { + result.add(onNonMatch(receiver.substring(startIndex, match.start))); + result.add(onMatch(match)); + startIndex = match.end; + } + result.add(onNonMatch(receiver.substring(startIndex))); + return result; +} + +Iterable stringSplitGeneralMapUnchecked(String receiver, Pattern pattern, + T Function(Match) onMatch, T Function(String) onNonMatch) { + List result = []; + int startIndex = 0; + for (Match match in pattern.allMatches(receiver)) { + result.add(onNonMatch(receiver.substring(startIndex, match.start))); + result.add(onMatch(match)); + startIndex = match.end; + } + result.add(onNonMatch(receiver.substring(startIndex))); + return result; +} + String stringReplaceAllEmptyFuncUnchecked(String receiver, String Function(Match) onMatch, String Function(String) onNonMatch) { // Pattern is the empty string. diff --git a/sdk/lib/_internal/vm/lib/string_patch.dart b/sdk/lib/_internal/vm/lib/string_patch.dart index 7c72c64650a1..7bc734a2e470 100644 --- a/sdk/lib/_internal/vm/lib/string_patch.dart +++ b/sdk/lib/_internal/vm/lib/string_patch.dart @@ -886,6 +886,20 @@ abstract final class _StringBase implements String { return buffer.toString(); } + Iterable splitMap( + Pattern pattern, { + T onMatch(Match match)?, + T onNonMatch(String nonMatch)?, + }) { + if (pattern == null) { + throw new ArgumentError.notNull("pattern"); + } + onMatch ??= _matchString as T Function(Match); + onNonMatch ??= _stringIdentity as T Function(String); + + return _StringSplitIterable(this, pattern, onMatch, onNonMatch); + } + // Convert single object to string. @pragma("vm:entry-point", "call") static String _interpolateSingle(Object? o) { @@ -1031,6 +1045,77 @@ abstract final class _StringBase implements String { external static String _concatRangeNative(List strings, int start, int end); } +final class _StringSplitIterable extends Iterable { + final String _input; + final Pattern _pattern; + final T Function(Match match) _onMatch; + final T Function(String nonMatch) _onNonMatch; + + _StringSplitIterable( + this._input, + this._pattern, + this._onMatch, + this._onNonMatch, + ); + + Iterator get iterator => + _StringSplitIterator(_input, _pattern, _onMatch, _onNonMatch); +} + +final class _StringSplitIterator implements Iterator { + final String _input; + final Pattern _pattern; + final T Function(Match match) _onMatch; + final T Function(String nonMatch) _onNonMatch; + int _index = 0; + int _matchStart = 0; + T? _current; + bool _isDone = false; + + _StringSplitIterator( + this._input, + this._pattern, + this._onMatch, + this._onNonMatch, + ); + + bool moveNext() { + if (_isDone) { + return false; + } + if (_current != null) { + _current = null; + return true; + } + if (_index == _input.length) { + _isDone = true; + return false; + } + Iterator matches = _pattern.allMatches(_input, _index).iterator; + if (!matches.moveNext()) { + _current = _onNonMatch(_input.substring(_index)); + _index = _input.length; + return true; + } + Match match = matches.current; + if (match.start == _index) { + _current = _onMatch(match); + _index = match.end; + return true; + } + _current = _onNonMatch(_input.substring(_index, match.start)); + _index = match.start; + return true; + } + + T get current { + if (_current == null) { + throw new StateError("No element"); + } + return _current!; + } +} + /// Product of two positive integers, clamped to the maximum int value on /// overflow or non-positive inputs. int _clampedPositiveProduct(int a, int b) { diff --git a/sdk/lib/_internal/wasm/lib/js_string.dart b/sdk/lib/_internal/wasm/lib/js_string.dart index ea32379b3583..8ec70f78cf54 100644 --- a/sdk/lib/_internal/wasm/lib/js_string.dart +++ b/sdk/lib/_internal/wasm/lib/js_string.dart @@ -246,6 +246,67 @@ final class JSStringImpl implements String, StringUncheckedOperationsBase { return buffer.toString(); } + @override + Iterable splitMap( + Pattern pattern, { + T Function(Match match)? onMatch, + T Function(String nonMatch)? onNonMatch, + }) { + onMatch ??= _stringIdentity as T Function(Match); + onNonMatch ??= _stringIdentity as T Function(String); + + final result = []; + if (pattern is String) { + final patternLength = pattern.length; + if (patternLength == 0) { + // Pattern is the empty string. + int i = 0; + result.add(onNonMatch("")); + final length = this.length; + while (i < length) { + result.add(onMatch!(StringMatch(i, this, ""))); + // Special case to avoid splitting a surrogate pair. + int code = codeUnitAt(i); + if ((code & ~0x3FF) == 0xD800 && length > i + 1) { + // Leading surrogate; + code = codeUnitAt(i + 1); + if ((code & ~0x3FF) == 0xDC00) { + // Matching trailing surrogate. + result.add(onNonMatch(substring(i, i + 2))); + i += 2; + continue; + } + } + result.add(onNonMatch(this[i])); + i++; + } + result.add(onMatch!(StringMatch(i, this, ""))); + return result; + } + int startIndex = 0; + final length = this.length; + while (startIndex < length) { + int position = indexOf(pattern, startIndex); + if (position == -1) { + break; + } + result.add(onNonMatch(substring(startIndex, position))); + result.add(onMatch!(StringMatch(position, this, pattern))); + startIndex = position + patternLength; + } + result.add(onNonMatch(substring(startIndex))); + return result; + } + int startIndex = 0; + for (Match match in pattern.allMatches(this)) { + result.add(onNonMatch(substring(startIndex, match.start))); + result.add(onMatch!(match)); + startIndex = match.end; + } + result.add(onNonMatch(substring(startIndex))); + return result; + } + String _replaceRange(int start, int end, String replacement) { String prefix = substring(0, start); String suffix = substring(end); diff --git a/sdk/lib/_internal/wasm/lib/string.dart b/sdk/lib/_internal/wasm/lib/string.dart index 14f40bd417b3..72af1ec73437 100644 --- a/sdk/lib/_internal/wasm/lib/string.dart +++ b/sdk/lib/_internal/wasm/lib/string.dart @@ -1015,6 +1015,31 @@ abstract final class StringBase extends WasmStringBase return buffer.toString(); } + Iterable splitMap( + Pattern pattern, { + T Function(Match match)? onMatch, + T Function(String nonMatch)? onNonMatch, + }){ + List result = []; + int startIndex = 0; + + onMatch ??= (Match match) => unsafeCast(match[0]); + onNonMatch ??= (String nonMatch) => unsafeCast(nonMatch); + + for (Match match in pattern.allMatches(this)) { + if (startIndex != match.start) { + result.add(onNonMatch(this.substring(startIndex, match.start))); + } + result.add(onMatch(match)); + startIndex = match.end; + } + + if (startIndex != this.length) { + result.add(onNonMatch(this.substring(startIndex))); + } + return result; + } + // Used in string interpolation expressions where ownership of array is passed // to this function. // diff --git a/sdk/lib/core/string.dart b/sdk/lib/core/string.dart index 68a445fdc5c8..826e118f13d0 100644 --- a/sdk/lib/core/string.dart +++ b/sdk/lib/core/string.dart @@ -707,6 +707,44 @@ abstract final class String implements Comparable, Pattern { String Function(String)? onNonMatch, }); + /// Splits the string and returns an iterable of generic type. + /// + /// The [pattern] is used to split the string + /// into parts and separating matches. + /// Each match of [Pattern.allMatches] of [pattern] on this string is + /// used as a match, and the substrings between the end of one match + /// (or the start of the string) and the start of the next match (or the + /// end of the string) is treated as a non-matched part. + /// (There is no omission of leading or trailing empty matchs, like + /// in [split], all matches and parts between the are included.) + /// + /// Each match is converted to a value of type [T] by calling [onMatch]. If [onMatch] + /// is omitted, the matched substring is used. + /// + /// Each non-matched part is converted to a value of type [T] by a call to [onNonMatch]. + /// If [onNonMatch] is omitted, the non-matching substring itself is used. + /// + /// The resulting iterable is lazy, and will only convert the parts as they are + /// read. The conversion is done in order, so the first part of the iterable + /// will be the first part of the string, and so on. + /// + /// ```dart + /// final result = 'Here is a [link](https://dart.dev) to the dart website' + /// .splitMap( + /// RegExp( r'https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)'), + /// onMatch: (m) => TextSpan( + /// text: m[0]!, + /// style: TextStyle(color: Colors.blue), + /// ), + /// onNonMatch: (n) => TextSpan(text: n), + /// ); + /// ``` + Iterable splitMap( + Pattern pattern, { + T Function(Match match)? onMatch, + T Function(String nonMatch)? onNonMatch, + }); + /// An unmodifiable list of the UTF-16 code units of this string. List get codeUnits; diff --git a/tests/corelib/string_replace_all_common.dart b/tests/corelib/string_replace_all_common.dart index 38071c3c85f8..6d0bbfaee5ee 100644 --- a/tests/corelib/string_replace_all_common.dart +++ b/tests/corelib/string_replace_all_common.dart @@ -8,6 +8,7 @@ testAll(Pattern Function(Pattern) wrap) { testReplaceAll(wrap); testReplaceAllMapped(wrap); testSplitMapJoin(wrap); + testSplitMap(wrap); } testReplaceAll(Pattern Function(Pattern) wrap) { @@ -166,3 +167,74 @@ testSplitMapJoin(Pattern Function(Pattern) wrap) { "abcabdae".splitMapJoin(wrap("a"), onNonMatch: rest), ); } + +testSplitMap(Pattern Function(Pattern) wrap) { + String mark(Match m) => "[${m[0]}]"; + String rest(String s) => "<${s}>"; + + Expect.listEquals( + ["", "[b]", "", "[b]", ""], + "abcabdae".splitMap(wrap("b"), onMatch: mark, onNonMatch: rest), + ); + + // Test with the replaced string at the beginning. + Expect.listEquals( + ["<>", "[a]", "", "[a]", "", "[a]", ""], + "abcabdae".splitMap(wrap("a"), onMatch: mark, onNonMatch: rest), + ); + + // Test with the replaced string at the end. + Expect.listEquals( + ["", "[e]", "<>"], + "abcabdae".splitMap(wrap("e"), onMatch: mark, onNonMatch: rest), + ); + + // Test when there are no occurrence of the string to replace. + Expect.listEquals( + [""], + "abcabdae".splitMap(wrap("f"), onMatch: mark, onNonMatch: rest), + ); + + // Test when the string to change is the empty string. + Expect.listEquals( + ["<>"], + "".splitMap(wrap("from"), onMatch: mark, onNonMatch: rest), + ); + + // Test when the string to change is a substring of the string to + // replace. + Expect.listEquals( + [""], + "fro".splitMap(wrap("from"), onMatch: mark, onNonMatch: rest), + ); + + // Test when matches are adjacent + Expect.listEquals( + ["<>", "[from]", "<>", "[from]", "<>"], + "fromfrom".splitMap(wrap("from"), onMatch: mark, onNonMatch: rest), + ); + + // Test changing the empty string. + Expect.listEquals( + ["<>"], + "".splitMap(wrap(""), onMatch: mark, onNonMatch: rest), + ); + + // Test replacing the empty string. + Expect.listEquals( + ["<>", "[A]", "<>", "[B]", "<>", "[C]", "<>"], + "ABC".splitMap(wrap(""), onMatch: mark, onNonMatch: rest), + ); + + // Test with only onMatch. + Expect.listEquals( + ["[a]", "bc", "[a]", "bd", "[a]", "e"], + "abcabdae".splitMap(wrap("a"), onMatch: mark), + ); + + // Test with only onNonMatch + Expect.listEquals( + ["", "a", "bc", "a", "bd", "a", "e"], + "abcabdae".splitMap(wrap("a"), onNonMatch: rest), + ); +} diff --git a/tests/lib/js/static_interop_test/js_string_test.dart b/tests/lib/js/static_interop_test/js_string_test.dart index 38424f3cc24c..b088ec42eaa1 100644 --- a/tests/lib/js/static_interop_test/js_string_test.dart +++ b/tests/lib/js/static_interop_test/js_string_test.dart @@ -768,6 +768,29 @@ void testSplitMapJoin(TestMode mode) { '<>aaa', r('abcabdae').splitMapJoin(a('a'), onNonMatch: rest)); } +void testSplitMap(TestMode mode) { + String r(String s) => getStr(s, Position.jsStringImplReceiver, mode); + String a(String s) => getStr(s, Position.jsStringImplArgument, mode); + String mark(Match m) => a('[${m[0]}]'); + String rest(String s) => a('<${s}>'); + + Expect.listEquals(['a', '[b]', 'ca', '[b]', 'dae'], + r('abcabdae').splitMap(a('b'), onMatch: mark, onNonMatch: rest)); + Expect.listEquals(['abcabdae'], + r('abcabdae').splitMap(a('f'), onMatch: mark, onNonMatch: rest)); + Expect.listEquals( + [''], r('').splitMap(a('from'), onMatch: mark, onNonMatch: rest)); + Expect.listEquals(['', '[', ']', '', '[', ']', '', '[', ']', '', ''], + r('').splitMap(a(''), onMatch: mark, onNonMatch: rest)); + Expect.listEquals(['', '[', ']', '', '[', ']', '', '[', ']', '', ''], + r('ABC').splitMap(a(''), onMatch: mark, onNonMatch: rest)); + Expect.listEquals(['', '[a]', 'bc', '[a]', 'bd', '[a]', 'e'], + r('abcabdae').splitMap(a('a'), onMatch: mark)); + Expect.listEquals( + ['', '<>', 'a', '<', 'bc', '>', 'a', '<', 'bd', '>', 'a', '<', 'e', '>'], + r('abcabdae').splitMap(a('a'), onNonMatch: rest)); +} + void main() { for (final mode in [ TestMode.jsStringImplReceiver, @@ -793,6 +816,7 @@ void main() { testSplitUserPattern(mode); testReplace(mode); testSplitMapJoin(mode); + testSplitMap(mode); } testOutOfRange();