Skip to content

Commit 694ba6b

Browse files
committed
feat: ✨ Add StringSelection class and handy extension
fix: 🐛 Update leak tracker dependencies test: 🧪 Add tests for StringSelection
1 parent 9c774c7 commit 694ba6b

3 files changed

Lines changed: 217 additions & 11 deletions

File tree

lib/src/string.dart

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,115 @@
66
// Copyright (c) 2023 ModestNerds, Co
77
//
88

9+
/// Represents a selection within a string, defined by start and end markers.
10+
///
11+
/// This class allows you to perform operations on a substring that is
12+
/// identified by string markers rather than integer indices.
13+
class StringSelection {
14+
/// Creates a [StringSelection] with the given source string and selection bounds.
15+
const StringSelection({
16+
required this.source,
17+
required this.startIndex,
18+
required this.endIndex,
19+
});
20+
21+
/// The original source string.
22+
final String source;
23+
24+
/// The starting index of the selection (inclusive).
25+
final int startIndex;
26+
27+
/// The ending index of the selection (exclusive).
28+
final int endIndex;
29+
30+
/// Returns the selected substring.
31+
///
32+
/// Example:
33+
/// ```dart
34+
/// final selection = 'Hello <b>World</b>'.select(start: '<b>', end: '</b>');
35+
/// print(selection?.selected); // Output: <b>World</b>
36+
/// ```
37+
String get selected => source.substring(startIndex, endIndex);
38+
39+
/// Returns the content between the start and end markers (excluding the markers).
40+
///
41+
/// Example:
42+
/// ```dart
43+
/// final selection = 'Hello <b>World</b>'.select(start: '<b>', end: '</b>');
44+
/// print(selection?.content); // Output: World
45+
/// ```
46+
String content(String start, String end) {
47+
return source.substring(startIndex + start.length, endIndex - end.length);
48+
}
49+
50+
/// Returns a new string with the selected portion removed.
51+
///
52+
/// Example:
53+
/// ```dart
54+
/// final result = 'Hello <script>alert(1)</script> World'
55+
/// .select(start: '<script', end: '</script>')
56+
/// ?.remove();
57+
/// print(result); // Output: Hello World
58+
/// ```
59+
String remove() {
60+
return source.substring(0, startIndex) + source.substring(endIndex);
61+
}
62+
63+
/// Returns a new string with the selected portion replaced by [replacement].
64+
///
65+
/// Example:
66+
/// ```dart
67+
/// final result = 'Hello <b>World</b>'
68+
/// .select(start: '<b>', end: '</b>')
69+
/// ?.replace('Universe');
70+
/// print(result); // Output: Hello Universe
71+
/// ```
72+
String replace(String replacement) {
73+
return source.substring(0, startIndex) +
74+
replacement +
75+
source.substring(endIndex);
76+
}
77+
78+
/// Returns a new string with all occurrences of the selection removed.
79+
///
80+
/// Example:
81+
/// ```dart
82+
/// final result = 'A<x>B<x>C'.select(start: '<x>', end: '<x>')?.removeAll();
83+
/// ```
84+
String removeAll(String start, String end) {
85+
String result = source;
86+
while (true) {
87+
final selection = result.select(start: start, end: end);
88+
if (selection == null) break;
89+
result = selection.remove();
90+
}
91+
return result;
92+
}
93+
94+
/// Returns a new string with all occurrences of the selection replaced.
95+
///
96+
/// Example:
97+
/// ```dart
98+
/// final result = 'A<x>1</x>B<x>2</x>C'
99+
/// .select(start: '<x>', end: '</x>')
100+
/// ?.replaceAll(start: '<x>', end: '</x>', replacement: '[]');
101+
/// print(result); // Output: A[]B[]C
102+
/// ```
103+
String replaceAll({
104+
required String start,
105+
required String end,
106+
required String replacement,
107+
}) {
108+
String result = source;
109+
while (true) {
110+
final selection = result.select(start: start, end: end);
111+
if (selection == null) break;
112+
result = selection.replace(replacement);
113+
}
114+
return result;
115+
}
116+
}
117+
9118
extension HandyStringExtension on String {
10119
/// Converts this string (assumed to be a country code) to a country flag emoji.
11120
///
@@ -166,4 +275,42 @@ extension HandyStringExtension on String {
166275
inputCharacters.sort();
167276
return characters.join().matches(inputCharacters.join());
168277
}
278+
279+
/// Selects a portion of this string between [start] and [end] markers.
280+
///
281+
/// Returns a [StringSelection] that allows you to perform operations
282+
/// like [StringSelection.remove], [StringSelection.replace], etc.
283+
///
284+
/// Returns `null` if the [start] or [end] markers are not found, or if
285+
/// [end] appears before [start].
286+
///
287+
/// Example:
288+
/// ```dart
289+
/// final input = 'Check - Check In: Maintenance Due : ZAR 1374.94.'
290+
/// '<script type="text/javascript">viewGLAccounts(0,2722896);</script>'
291+
/// ' Check In:';
292+
///
293+
/// final result = input.select(start: '<script', end: '</script>')?.remove();
294+
/// print(result);
295+
/// // Output: Check - Check In: Maintenance Due : ZAR 1374.94. Check In:
296+
/// ```
297+
///
298+
/// Parameters:
299+
/// - [start]: The string marking the beginning of the selection.
300+
/// - [end]: The string marking the end of the selection.
301+
///
302+
/// Returns a [StringSelection] or `null` if markers are not found.
303+
StringSelection? select({required String start, required String end}) {
304+
final startIndex = indexOf(start);
305+
if (startIndex == -1) return null;
306+
307+
final endIndex = indexOf(end, startIndex + start.length);
308+
if (endIndex == -1) return null;
309+
310+
return StringSelection(
311+
source: this,
312+
startIndex: startIndex,
313+
endIndex: endIndex + end.length,
314+
);
315+
}
169316
}

pubspec.lock

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -63,26 +63,26 @@ packages:
6363
dependency: transitive
6464
description:
6565
name: leak_tracker
66-
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
66+
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
6767
url: "https://pub.dev"
6868
source: hosted
69-
version: "10.0.9"
69+
version: "11.0.2"
7070
leak_tracker_flutter_testing:
7171
dependency: transitive
7272
description:
7373
name: leak_tracker_flutter_testing
74-
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
74+
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
7575
url: "https://pub.dev"
7676
source: hosted
77-
version: "3.0.9"
77+
version: "3.0.10"
7878
leak_tracker_testing:
7979
dependency: transitive
8080
description:
8181
name: leak_tracker_testing
82-
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
82+
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
8383
url: "https://pub.dev"
8484
source: hosted
85-
version: "3.0.1"
85+
version: "3.0.2"
8686
matcher:
8787
dependency: transitive
8888
description:
@@ -164,18 +164,18 @@ packages:
164164
dependency: transitive
165165
description:
166166
name: test_api
167-
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
167+
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
168168
url: "https://pub.dev"
169169
source: hosted
170-
version: "0.7.4"
170+
version: "0.7.6"
171171
vector_math:
172172
dependency: transitive
173173
description:
174174
name: vector_math
175-
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
175+
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
176176
url: "https://pub.dev"
177177
source: hosted
178-
version: "2.1.4"
178+
version: "2.2.0"
179179
very_good_analysis:
180180
dependency: "direct dev"
181181
description:
@@ -193,5 +193,5 @@ packages:
193193
source: hosted
194194
version: "15.0.0"
195195
sdks:
196-
dart: ">=3.7.0-0 <4.0.0"
196+
dart: ">=3.8.0-0 <4.0.0"
197197
flutter: ">=3.18.0-18.0.pre.54"

test/src/string_test.dart

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,63 @@ void main() {
6161
expect('HANDY EXTENSIONS'.isAllCaps, true);
6262
});
6363
});
64+
65+
group('select', () {
66+
test('removes script tags from string', () {
67+
const input = 'Check - Check In: Maintenance Due : ZAR 1374.94.'
68+
'<script type="text/javascript">viewGLAccounts(0,2722896);</script>'
69+
' Check In:';
70+
final result = input.select(start: '<script', end: '</script>')?.remove();
71+
expect(
72+
result, 'Check - Check In: Maintenance Due : ZAR 1374.94. Check In:');
73+
});
74+
75+
test('returns null when start marker not found', () {
76+
const input = 'Hello World';
77+
final result = input.select(start: '<script', end: '</script>');
78+
expect(result, isNull);
79+
});
80+
81+
test('returns null when end marker not found', () {
82+
const input = 'Hello <script>World';
83+
final result = input.select(start: '<script>', end: '</script>');
84+
expect(result, isNull);
85+
});
86+
87+
test('selected returns the full selection including markers', () {
88+
const input = 'Hello <b>World</b> Goodbye';
89+
final selection = input.select(start: '<b>', end: '</b>');
90+
expect(selection?.selected, '<b>World</b>');
91+
});
92+
93+
test('content returns text between markers', () {
94+
const input = 'Hello <b>World</b> Goodbye';
95+
final selection = input.select(start: '<b>', end: '</b>');
96+
expect(selection?.content('<b>', '</b>'), 'World');
97+
});
98+
99+
test('replace substitutes selection with new text', () {
100+
const input = 'Hello <b>World</b> Goodbye';
101+
final result =
102+
input.select(start: '<b>', end: '</b>')?.replace('Universe');
103+
expect(result, 'Hello Universe Goodbye');
104+
});
105+
106+
test('removeAll removes all occurrences', () {
107+
const input = 'A<x>1</x>B<x>2</x>C';
108+
final result =
109+
input.select(start: '<x>', end: '</x>')?.removeAll('<x>', '</x>');
110+
expect(result, 'ABC');
111+
});
112+
113+
test('replaceAll replaces all occurrences', () {
114+
const input = 'A<x>1</x>B<x>2</x>C';
115+
final result = input.select(start: '<x>', end: '</x>')?.replaceAll(
116+
start: '<x>',
117+
end: '</x>',
118+
replacement: '[]',
119+
);
120+
expect(result, 'A[]B[]C');
121+
});
122+
});
64123
}

0 commit comments

Comments
 (0)