Skip to content

Commit

Permalink
Add SIL index
Browse files Browse the repository at this point in the history
  • Loading branch information
Pante committed Sep 26, 2023
1 parent e644306 commit 7c4e370
Show file tree
Hide file tree
Showing 6 changed files with 231 additions and 5 deletions.
4 changes: 3 additions & 1 deletion sugar/dart_test.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
tags:
flaky:
retry: 3
retry: 3
property:
skip: false
16 changes: 12 additions & 4 deletions sugar/lib/src/core/strings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -482,6 +488,8 @@ extension Strings on String {
///
/// 'a' >= 'A'; // true
/// 'A' >= 'a'; // false
///
/// 'z11' >= 'z2'; // false
/// ```
bool operator >= (String other) => compareTo(other) >= 0;

Expand Down
11 changes: 11 additions & 0 deletions sugar/lib/src/crdt/sil.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
abstract class Sil<E> with Iterable<E> {

final Map<String, E> _map;
final List<E> _list;


}

void a() {
f = [].indexed;
}
105 changes: 105 additions & 0 deletions sugar/lib/src/crdt/sil_index.dart
Original file line number Diff line number Diff line change
@@ -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;
}
8 changes: 8 additions & 0 deletions sugar/test/src/core/strings_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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('<=', () {
Expand All @@ -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('>', () {
Expand All @@ -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('>=', () {
Expand All @@ -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));
});

}
92 changes: 92 additions & 0 deletions sugar/test/src/crdt/sil_index_test.dart
Original file line number Diff line number Diff line change
@@ -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<ArgumentError>(
(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<ArgumentError>(
(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<ArgumentError>(
(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']);
}
}

0 comments on commit 7c4e370

Please sign in to comment.