Skip to content

Commit

Permalink
feat: add tags implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
alextekartik committed Oct 14, 2024
1 parent bef4e09 commit ad28379
Show file tree
Hide file tree
Showing 5 changed files with 357 additions and 0 deletions.
1 change: 1 addition & 0 deletions lib/list_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export 'src/list_utils_impl.dart'
TekartikCommonIterableExtension,
TekartikCommonIterableIterableExtension,
TekartikCommonListExtension,
TekartikCommonListListExtension,
TekartikCommonListOrNullExtension;

/// @Deprecated('User iterable extension TekartikIterableExt.firstOrNull')
Expand Down
6 changes: 6 additions & 0 deletions lib/src/list_utils_impl.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ extension TekartikCommonListExtension<T> on List<T> {
}
}

/// Common list list extension.
extension TekartikCommonListListExtension<T> on List<List<T>> {
/// [[1], [2, 3]].flatten() => [1, 2, 3]
List<T> flatten() => listFlatten<T>(this);
}

/// Common list or null extension.
extension TekartikCommonListOrNullExtension<T> on List<T>? {
/// If empty return null
Expand Down
274 changes: 274 additions & 0 deletions lib/src/tags_impl.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
import 'package:meta/meta.dart';
import 'package:tekartik_common_utils/list_utils.dart';
import 'package:tekartik_common_utils/string_utils.dart';

/// Tags are a list or trimmed string.
abstract class Tags {
/// Tags from a list of strings.
factory Tags.fromList({List<String>? tags}) {
return _Tags(tags);
}

/// Tags from a string (tags are comma separated).
factory Tags.fromText(String? text) {
if (text == null) {
return _Tags(null);
} else {
return _Tags(text.split(',').map((String tag) => tag.trim()).toList());
}
}

/// Tags as a list of strings.
List<String> toList();
}

/// Tags extension.
extension TagsExt on Tags {
/// Tags as a string (tags are comma separated).
String toText() {
return toList().join(',');
}

/// Tags as a string (tags are comma separated) or null if empty.
String? toTextOrNull() => toText().nonEmpty();

/// Tags as a list of strings or null if empty.
List<String>? toListOrNull() => toList().nonEmpty();

/// Check if the tags contain a given tag.
bool has(String tag) {
return toList().contains(tag);
}

/// Add a tag if not already present, return true if added.
bool add(String tag) {
if (has(tag)) {
return false;
}
toList().add(tag);
return true;
}

/// Remove a tag if present, return true if removed.
bool remove(String tag) {
return toList().remove(tag);
}
}

class _Tags implements Tags {
final List<String> tags;

_Tags(List<String>? tags) : tags = List.of(tags ?? <String>[]);

@override
List<String> toList() => tags;

@override
String toString() => 'Tags(${toText()})';
}

/// Tags condition (tag1 && tag2 && !tag3 || tag4 && (tag1 && tag4).
abstract class TagsCondition {
/// Parse a tags condition.
/// Don't assume precedence, use parenthesis.
factory TagsCondition(String expression) {
return _parseTagsCondition(expression.trim());
}

/// Check if the tags match the condition.
bool check(Tags tags);

/// Text representation of the condition.
String toText();

String _toInnerText();
}

/// A condition is either single or multi
@visibleForTesting
abstract class TagsConditionSingle implements TagsCondition {}

/// A condition is either single or multi
@visibleForTesting
abstract interface class TagsConditionMulti implements TagsCondition {}

class _TagsConditionTag implements TagsConditionSingle {
final String tag;

_TagsConditionTag(this.tag);

@override
bool check(Tags tags) {
return tags.has(tag);
}

@override
String toText() => tag;

@override
String _toInnerText() => tag;
}

mixin _TagsConditionMixin implements TagsCondition {
@override
String toString() => 'Condition(${toText()})';
}

abstract class _TagsConditionSingleBase
with _TagsConditionMixin
implements TagsConditionSingle {
final TagsCondition condition;

_TagsConditionSingleBase(this.condition);

@override
String toText() => _toInnerText();

@override
String _toInnerText() => condition._toInnerText();
}

abstract class _TagsConditionMultiBase
with _TagsConditionMixin
implements TagsConditionMulti {
List<TagsCondition> conditions;

_TagsConditionMultiBase(this.conditions);

List<String> conditionTexts() =>
conditions.map((condition) => condition._toInnerText()).toList();

@override
String _toInnerText() => '(${toText()})';
}

class _TagsConditionAny extends _TagsConditionMultiBase
implements _TagsConditionOr {
_TagsConditionAny(super.conditions);

@override
bool check(Tags tags) {
for (var condition in conditions.toList()) {
if (condition.check(tags)) {
return true;
}
}
return false;
}

@override
String toText() => conditionTexts().join(' || ');
}

class _TagsConditionAll extends _TagsConditionMultiBase
implements _TagsConditionAnd {
_TagsConditionAll(super.conditions);

@override
bool check(Tags tags) {
for (var condition in conditions.toList()) {
if (!condition.check(tags)) {
return false;
}
}
return true;
}

@override
String toText() {
var conditionTexts = this.conditionTexts();
return conditionTexts.join(' && ');
}
}

abstract class _TagsConditionOr implements TagsConditionMulti {
factory _TagsConditionOr(TagsCondition condition1, TagsCondition condition2) {
return _TagsConditionAny([condition1, condition2]);
}
}

abstract class _TagsConditionAnd implements TagsConditionMulti {
factory _TagsConditionAnd(
TagsCondition condition1, TagsCondition condition2) {
return _TagsConditionAll([condition1, condition2]);
}
}

class _TagsConditionNot extends _TagsConditionSingleBase {
_TagsConditionNot(super.condition);

@override
bool check(Tags tags) {
return !condition.check(tags);
}

@override
String _toInnerText() => '!${condition._toInnerText()}';
}

const _or = '||';
const _and = '&&';
var _allOperators = [_or, _and];

/// Assumed trimmed
TagsCondition _parseTagsCondition(String expression) {
var parts = expression.splitFirst(' ');
var token = parts.first;
var firstChar = token[0];

var not = firstChar == '!';

TagsCondition wrapCondition(TagsCondition condition) {
if (not) {
return _TagsConditionNot(condition);
}
return condition;
}

if (not) {
token = token.substring(1);
}
if (token.isEmpty) {
throw ArgumentError('Missing tag or expression after ! in "$expression"');
}
late TagsCondition firstCondition;
late String afterFirstCondition;
if (firstChar == '(') {
var endIndex = expression.lastIndexOf(')');
if (endIndex == -1) {
throw ArgumentError('Missing matching ) in "$expression"');
}
firstCondition = wrapCondition(
_parseTagsCondition(expression.substring(1, endIndex).trim()));
afterFirstCondition = expression.substring(endIndex + 1).trim();
} else if (_allOperators.contains(token)) {
throw ArgumentError('Unexpected operator "$token" found in "$expression"');
} else {
firstCondition = _TagsConditionTag(token);
if (parts.length == 1) {
return wrapCondition(firstCondition);
}
afterFirstCondition = parts.last.trim();
}

if (afterFirstCondition.isEmpty) {
return wrapCondition(firstCondition);
}

/// expect condition
parts = afterFirstCondition.splitFirst(' ');
var operator = parts.first;
var secondCondition = parts.last.trim();

if (!_allOperators.contains(operator)) {
throw ArgumentError('Missing operator in "$expression"');
}
var subExpression = _parseTagsCondition(secondCondition);
if (operator == _or) {
return wrapCondition(_TagsConditionOr(firstCondition, subExpression));
} else if (operator == _and) {
return wrapCondition(_TagsConditionAnd(firstCondition, subExpression));
} else {
throw ArgumentError('Missing operators token "$operator" in "$expression"');
}
}
1 change: 1 addition & 0 deletions lib/tags.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export 'src/tags_impl.dart' show Tags, TagsExt, TagsCondition;
75 changes: 75 additions & 0 deletions test/tags_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import 'package:tekartik_common_utils/src/tags_impl.dart'
show TagsConditionSingle, TagsConditionMulti;
import 'package:tekartik_common_utils/tags.dart';
import 'package:test/test.dart' hide Tags;

Tags _t(String text) => Tags.fromText(text);
TagsCondition _c(String expression) => TagsCondition(expression);

Future<void> main() async {
group('tags', () {
test('simple', () {
var tags = _t('test');
expect(_c('test').check(tags), isTrue);
expect(_c('test1').check(tags), isFalse);
});
test('and', () {
var tags = _t('test1,test2');
expect(_c('test1 && test2').check(tags), isTrue);
expect(_c('test1 && test3').check(tags), isFalse);
expect(_c('test2 && test3').check(tags), isFalse);
});
test('or', () {
var tags = _t('test1,test2');
expect(_c('test1 || test2').check(tags), isTrue);
expect(_c('test1 || test3').check(tags), isTrue);
expect(_c('test2 || test3').check(tags), isTrue);
expect(_c('test3 || test4').check(tags), isFalse);
});
test('parenthesis', () {
var tags = _t('test1');
expect(_c('test3 || (test1 || test2)').check(tags), isTrue);
expect(_c('test3 || (test1 && test2)').check(tags), isFalse);
tags = _t('test1, test2');
expect(_c('test3 || (test1 || test2)').check(tags), isTrue);
expect(_c('test1 && (test2 || test3)').check(tags), isTrue);
});
test('conditions', () {
expect(_c('test1'), isA<TagsConditionSingle>());
expect(_c('!test1'), isA<TagsConditionSingle>());
expect(_c('(test1)'), isA<TagsConditionSingle>());
expect(_c('!(test1)'), isA<TagsConditionSingle>());

expect(_c('test1 || test2'), isA<TagsConditionMulti>());
expect(_c('test1 && test2'), isA<TagsConditionMulti>());
expect(_c('(test1 && test2)'), isA<TagsConditionMulti>());
expect(_c('!(test1 && test2)'), isA<TagsConditionSingle>());

void roundTrip(String expression) {
expect(_c(expression).toText(), expression);
}

for (var expression in [
'test1',
'!test1',
'test1 || test2',
'test1 && test2',
'test1 && (test2 || test3)',
'(test2 || test3) && test1',
]) {
roundTrip(expression);
}
});

test('quick', () {
var tags = _t('test1, test2');
var condition = _c('test1 && (test2 || test3)');
// ignore: avoid_print
print(tags);
// ignore: avoid_print
print(condition);
// ignore: avoid_print
print(condition.check(tags));
}, skip: true);
});
}

0 comments on commit ad28379

Please sign in to comment.