diff --git a/sugar/dart_test.yaml b/sugar/dart_test.yaml index c59ee5cd..0eeb7631 100644 --- a/sugar/dart_test.yaml +++ b/sugar/dart_test.yaml @@ -1,3 +1,5 @@ tags: flaky: - retry: 3 \ No newline at end of file + retry: 3 + property: + skip: false \ No newline at end of file diff --git a/sugar/lib/src/core/strings.dart b/sugar/lib/src/core/strings.dart index b2f8d5e6..a308c607 100644 --- a/sugar/lib/src/core/strings.dart +++ b/sugar/lib/src/core/strings.dart @@ -409,7 +409,7 @@ extension Strings on String { @useResult bool get isNotBlank => trim().isNotEmpty; - /// Returns whether this string is lexicographically less than [other]. + /// Returns whether this string is alphabetically less than [other]. /// /// If one string is a prefix of the other, then the shorter string is less than the longer string. This function does /// not check for Unicode equivalence and is case sensitive. @@ -425,10 +425,12 @@ extension Strings on String { /// /// 'A' < 'a'; // true /// 'a' < 'A'; // false + /// + /// 'z11' < 'z2'; // true /// ``` bool operator < (String other) => compareTo(other) < 0; - /// Returns whether this string is lexicographically less than or equal to [other]. + /// Returns whether this string is alphabetically less than or equal to [other]. /// /// If one string is a prefix of the other, then the shorter string is less than the longer string. This function does /// not check for Unicode equivalence and is case sensitive. @@ -444,10 +446,12 @@ extension Strings on String { /// /// 'A' <= 'a'; // true /// 'a' <= 'A'; // false + /// + /// 'z11' <= 'z2'; // true /// ``` bool operator <= (String other) => compareTo(other) <= 0; - /// Returns whether this string is lexicographically greater than [other]. + /// Returns whether this string is alphabetically greater than [other]. /// /// If one string is a prefix of the other, then the larger string is greater than the shorter string. This function /// does not check for Unicode equivalence and is case sensitive. @@ -463,10 +467,12 @@ extension Strings on String { /// /// 'a' > 'A'; // true /// 'A' > 'a'; // false + /// + /// 'z11' > 'z2'; // false /// ``` bool operator > (String other) => compareTo(other) > 0; - /// Returns whether this string is lexicographically greater than or equal to [other]. + /// Returns whether this string is alphabetically greater than or equal to [other]. /// /// If one string is a prefix of the other, then the larger string is greater than the shorter string. This function /// does not check for Unicode equivalence and is case sensitive. @@ -482,6 +488,8 @@ extension Strings on String { /// /// 'a' >= 'A'; // true /// 'A' >= 'a'; // false + /// + /// 'z11' >= 'z2'; // false /// ``` bool operator >= (String other) => compareTo(other) >= 0; diff --git a/sugar/lib/src/crdt/sil.dart b/sugar/lib/src/crdt/sil.dart new file mode 100644 index 00000000..ceea3567 --- /dev/null +++ b/sugar/lib/src/crdt/sil.dart @@ -0,0 +1,11 @@ +abstract class Sil with Iterable { + + final Map _map; + final List _list; + + +} + +void a() { + f = [].indexed; +} diff --git a/sugar/lib/src/crdt/sil_index.dart b/sugar/lib/src/crdt/sil_index.dart new file mode 100644 index 00000000..ee8d3d87 --- /dev/null +++ b/sugar/lib/src/crdt/sil_index.dart @@ -0,0 +1,105 @@ +import 'dart:math'; + +import 'package:sugar/sugar.dart'; + +/// Provides low-level functions for manipulating indexes in a String Indexed List (SIL). +/// +/// Users should generally prefer the higher-level [SIL] instead. +/// +/// ## Description +/// SIL indexes are strings compared alphabetically to determine order. For example, 'a' is ordered before 'b' since +/// `'a' < 'b'`. Each character in a SIL index is one of the allowed 64 characters, `+, -, [0-9], [A-Z] and [a-z]`. +/// +/// If two indexes contain different number of characters, the shorter index will be implicitly suffixed with `+`s +/// (the first allowed character) until its length is equal to the longer index. For example, when comparing `a` and +/// `a+a`, `a` will be implicated suffixed as `a++`. +/// +/// This guarantees that an element can always be inserted by suffixing its index with an allowed character. For example, +/// `aa` can be inserted between `a` and `b`. +/// +/// It is still possible for two equivalent indexes without any empty space in-between to be generated concurrently. It +/// is impossible for the functions in [SilIndex] to prevent that. Such situations should be handled during merging instead. +/// +/// ## The `strict` flag +/// In the original closed-source implementation, the allowed character set contained `/` instead of `-`. To maintain +/// backwards-compatibility, most functions accept a `strict` flag which disables index format validation. +/// +/// External users are discouraged from enabling the `lenient` flag. +extension SilIndex on Never { + + /// The minimum character. + static const min = '+'; + /// The maximum character. + static const max = 'z'; + /// The allow character set in a SIL index. + static const ascii = [ + // The original implementation used / instead of -, however this made working with URLs/escaping troublesome. + 43, 45, // +, - + 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, // 0 - 9 + 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, // A - Z + 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, // a - z + ]; + + static final Random _random = Random(); + static final RegExp _format = RegExp(r'(\+|-|[0-9]|[A-Z]|[a-z])+'); + static final RegExp _trailing = RegExp(r'(\+)+$'); + + /// Generates a new SIL index between the given [min], inclusive, and [max], exclusive. + /// + /// ## The `strict` flag + /// In the original closed-source implementation, the allowed character set contained `/` instead of `-`. To maintain + /// backwards-compatibility, [between] ] accept a [strict] flag which disables index format validation. + /// + /// ## Contract + /// An [ArgumentError] is thrown if + /// * [max] <= [min] + /// * [strict] is true and either [min] or [max] is not a valid SIL index + @Possible({ArgumentError}) + static String between({String min = min, String max = max, bool strict = true}) { + _validate(min, max, strict: strict); + + final index = StringBuffer(); + for (var i = 0; ; i++) { + final first = ascii.indexOf(min.charCodeAt(i, ascii.first)); + final last = ascii.indexOf(max.charCodeAt(i, ascii.last)); + + if (last - first == 0) { + index.writeCharCode(ascii[first]); + continue; + } + + final between = _random.nextBoundedInt(first, (first < last ? last : ascii.length)); + index.writeCharCode(ascii[between]); + + // This detects cases where between is '+' and first is empty as empty characters in the minimum boundary are treated + // as implicit `+`s. + if (between - first != 0) { + return _stripTrailing(index.toString()); + } + } + } + + static void _validate(String min, String max, {required bool strict}) { + if (strict && !min.matches(_format)) { + throw ArgumentError('SIL index, "$min", is invalid. Should be in the format: ([0-9]|[A-Z]|[a-z]|\\+|-)+'); + } + + if (strict && !max.matches(_format)) { + throw ArgumentError('SIL index, "$max", is invalid. Should be in the format: ([0-9]|[A-Z]|[a-z]|\\+|-)+'); + } + + if ((max.replaceAll(_trailing, '')) <= min.replaceAll(_trailing, '') ) { + throw ArgumentError('Minimum SIL index, "$min", is greater than or equal to the maximum SIL index, "$max". Minimum should be less than maximum.'); + } + } + + static String _stripTrailing(String index) { + assert(!index.endsWith('+'), 'SIL index, "$index", contains trailing "+"s.'); + return index.endsWith('+') ? index.replaceAll(_trailing, '') : index; + } + +} + +extension on String { + int charCodeAt(int index, int defaultValue) => index < length ? codeUnitAt(index) : defaultValue; +} diff --git a/sugar/test/src/core/strings_test.dart b/sugar/test/src/core/strings_test.dart index 76524ace..48b1ce62 100644 --- a/sugar/test/src/core/strings_test.dart +++ b/sugar/test/src/core/strings_test.dart @@ -254,6 +254,8 @@ void main() { test('a < A', () => expect('a' < 'A', false)); test('A < a', () => expect('A' < 'a', true)); + + test('z11 < z2', () => expect('z11' < 'z2', true)); }); group('<=', () { @@ -270,6 +272,8 @@ void main() { test('a <= A', () => expect('a' <= 'A', false)); test('A <= a', () => expect('A' <= 'a', true)); + + test('z11 <= z2', () => expect('z11' <= 'z2', true)); }); group('>', () { @@ -286,6 +290,8 @@ void main() { test('a > A', () => expect('a' > 'A', true)); test('A > a', () => expect('A' > 'a', false)); + + test('z11 > z2', () => expect('z11' > 'z2', false)); }); group('>=', () { @@ -302,6 +308,8 @@ void main() { test('a >= A', () => expect('a' >= 'A', true)); test('A >= a', () => expect('A' >= 'a', false)); + + test('z11 >= z2', () => expect('z11' >= 'z2', false)); }); } diff --git a/sugar/test/src/crdt/sil_index_test.dart b/sugar/test/src/crdt/sil_index_test.dart new file mode 100644 index 00000000..952c913f --- /dev/null +++ b/sugar/test/src/crdt/sil_index_test.dart @@ -0,0 +1,92 @@ +import 'dart:math'; + +import 'package:sugar/src/crdt/sil_index.dart'; +import 'package:sugar/sugar.dart'; +import 'package:test/test.dart'; + +final _random = Random(); +final _index = StringBuffer(); + +Iterable<(String, String)> get boundaries sync* { + const iterations = 2000; // Tweak this to adjust the number of tests + for (var i = 0; i < iterations; i++) { + var min = generate(); + var max = generate(); + while (min.isEmpty || max.isEmpty || min >= max) { + min = generate(); + max = generate(); + } + + yield (min, max); + } +} + +String generate() { + final length = _random.nextInt(8) + 1; + for (var i = 0; i < length; i++) { + _index.writeCharCode(SilIndex.ascii[_random.nextInt(SilIndex.ascii.length)]); + } + + final index = _index.toString().replaceAll(RegExp(r'(\+)+$'), ''); + _index.clear(); + + return index; +} + + +void main() { + group('preconditions', () { + for (final (min, max) in [ + ('b', 'a'), + ('a', 'a'), + ('a++++', 'a++'), + ('a++', 'a+++++++'), + ('a', 'a++++'), + ('a++++++', 'a'), + ]) { + test('start index >= end index', () => expect( + () => SilIndex.between(min: min, max: max), + throwsA(predicate( + (e) => e.message == 'Minimum SIL index, "$min", is greater than or equal to the maximum SIL index, "$max". Minimum should be less than maximum.' + )), + )); + } + + for (final argument in ['1241=', '20"385r2', '漢字']) { + test('invalid format', () => expect( + () => SilIndex.between(min: argument), + throwsA(predicate( + (e) => e.message == 'SIL index, "$argument", is invalid. Should be in the format: ([0-9]|[A-Z]|[a-z]|\\+|-)+' + )), + )); + + test('invalid format', () => expect( + () => SilIndex.between(max: argument), + throwsA(predicate( + (e) => e.message == 'SIL index, "$argument", is invalid. Should be in the format: ([0-9]|[A-Z]|[a-z]|\\+|-)+' + )), + )); + } + }); + + test('wrap around', () { + final value = SilIndex.between(min: '+yzz', max: '-'); + expect(value > '+yzz', true); + expect(value < '-', true); + }); + + test('boundary', () { + final value = SilIndex.between(min: '+zzz', max: '-'); + expect(value.compareTo('+zzz'), 1); + expect(value.compareTo('/'), -1); + }); + + for (final (min, max) in boundaries) { + test('insert between $min and $max', () { + final value = SilIndex.between(min: min, max: max); + expect(value.endsWith('+'), false); + expect(value.compareTo(min), 1, reason: '$value <= $min'); + expect(value.compareTo(max), -1, reason: '$value >= $max'); + }, tags: ['property']); + } +}