diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..effe43c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/README.md b/README.md new file mode 100644 index 0000000..80aee76 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# list_or + +Ever worked with an API that usually returns a list of items, but sometimes decides to return a single item just to keep you on your toes? + +`list_or` is a lightweight Dart utility that fixes this exact headache. It takes an arbitrary dynamic value, whether it's a single object, an array, a `Set`, or even `null`, and normalizes it into a standard, predictable `List`. + +No more writing boilerplate type-checks for every flexible JSON payload. + +## Features + +* **Normalizes inputs:** Wraps single values into a list, or passes through existing iterables. +* **Fully mutable:** Implements `ListMixin`, meaning you can `.add()`, `.map()`, `.remove()`, and iterate over it just like any other Dart list. +* **Type-safe:** Strictly enforces your generic type `T`. If you expect a `String` and get an `int`, it throws immediately. +* **Smart null handling:** Explicitly supports `null` values only if you define the type as nullable (e.g., `ListOr`). +* **Clean stringification:** If the list only has one item, `toString()` prints just that item instead of adding `[ ]` brackets. + +## Getting Started + +Add the package to your `pubspec.yaml`: + +```bash +dart pub add list_or + +``` + +Or, if you're using Flutter: + +```bash +flutter pub add list_or + +``` + +Import it in your file: + +```dart +import 'package:list_or/list_or.dart'; + +``` + +## Usage + +The most common use case is parsing unpredictable JSON. + +Imagine an API endpoint where the `tags` field might be a single string or an array of strings depending on how many tags the user added. + +```dart +import 'package:list_or/list_or.dart'; + +void main() { + // Scenario A: The API returns a single string + final Map json1 = {'id': 1, 'tags': 'flutter'}; + + // Scenario B: The API returns an array of strings + final Map json2 = {'id': 2, 'tags': ['dart', 'backend']}; + + // ListOr normalizes both seamlessly. + final tags1 = ListOr(json1['tags']); + final tags2 = ListOr(json2['tags']); + + print(tags1); // Output: flutter + print(tags2); // Output: [dart, backend] + + // You can interact with it exactly like a normal list + tags1.add('mobile'); + print(tags1.length); // Output: 2 + print(tags1.contains('mobile')); // Output: true +} + +``` + +### Handling Nulls + +If your payload might intentionally contain nulls, just make your generic type +nullable: + +```dart +// This works perfectly +final nullableList = ListOr(null); +print(nullableList.length); // Output: 1 + +// This will throw an ArgumentError to keep your types safe +final strictList = ListOr(null); + +``` + +## Additional Information + +* **Found a bug?** Open an issue on the issue tracker. +* **Want to contribute?** Pull requests are welcome. Please ensure that all existing tests pass and add new tests for any new functionality. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..dee8927 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,30 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment the following section to specify additional rules. + +# linter: +# rules: +# - camel_case_types + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/example/list_or_example.dart b/example/list_or_example.dart new file mode 100644 index 0000000..8f8040d --- /dev/null +++ b/example/list_or_example.dart @@ -0,0 +1,56 @@ +import 'package:list_or/list_or.dart'; + +void main() { + print('--- Example 1: Wrapping a Single Value ---'); + // A single value is converted into a list containing just that value. + final singleItem = ListOr('Hello World'); + + print('Length: ${singleItem.length}'); // Output: 1 + print('First item: ${singleItem[0]}'); // Output: Hello World + + // Notice the custom toString():it prints just the string, not ['Hello World'] + print('String representation: $singleItem'); // Output: Hello World + + print('\n--- Example 2: Wrapping an Iterable ---'); + // A standard List or Set is normalized into a modifiable ListOr. + final multipleItems = ListOr(['Apple', 'Banana']); + + // Because it mixes in ListMixin, you can use all standard list methods. + multipleItems.add('Cherry'); + + print('Length: ${multipleItems.length}'); // Output: 3 + + // When the length is not exactly 1, toString() acts like a normal List. + print( + 'String representation: $multipleItems', + ); // Output: [Apple, Banana, Cherry] + + print('\n--- Example 3: Parsing Flexible JSON Data ---'); + // Imagine an API that irregularly returns 'tags' as either a String or a List. + final Map jsonResponse1 = {'id': 1, 'tags': 'flutter'}; + final Map jsonResponse2 = { + 'id': 2, + 'tags': ['dart', 'backend'], + }; + + // ListOr cleanly normalizes both scenarios without complex type checking. + final tags1 = ListOr(jsonResponse1['tags']); + final tags2 = ListOr(jsonResponse2['tags']); + + print( + 'Tags 1 contains "flutter": ${tags1.contains('flutter')}', + ); // Output: true + print('Tags 2 length: ${tags2.length}'); // Output: 2 + + print('\n--- Example 4: Handling Nulls with Nullable Types ---'); + // If your data might explicitly be null and you want to keep that null + // as a valid single element, you must use a nullable generic type. + final nullableList = ListOr(null); + + print('Nullable list length: ${nullableList.length}'); // Output: 1 + print('First item: ${nullableList.first}'); // Output: null + + // Note: Trying to do the following would throw an ArgumentError, + // keeping your types safe: + // final strictList = ListOr(null); // THROWS! +} diff --git a/lib/list_or.dart b/lib/list_or.dart new file mode 100644 index 0000000..3edafd5 --- /dev/null +++ b/lib/list_or.dart @@ -0,0 +1,118 @@ +/// A utility library for handling flexible data structures. +/// +/// This library provides the `ListOr` class, which normalizes single values or +/// iterables into a unified `List` interface. It is particularly useful for +/// parsing JSON or dynamic data where a field might interchangeably be provided +/// as a single element or a collection of elements. +library; + +import 'dart:collection'; + +/// A list implementation that normalizes a single value or an iterable into a +/// [List]. +/// +/// This class mixes in [ListMixin] to provide a complete list API backed by an +/// internal list. It is particularly useful when parsing flexible data +/// structures (such as JSON) where a field might be provided as either a single +/// element or a collection of elements. +class ListOr extends Object with ListMixin { + /// The underlying list storing the elements. + final List _items; + + /// Creates a [ListOr] instance from an arbitrary [value]. + /// + /// The factory normalizes the [value] based on its type: + /// * If [value] is an [Iterable] (e.g., `List` or `Set`), it creates a new + /// modifiable list and casts the elements to [T]. + /// * If [value] is exactly of type [T] (including `null` if [T] is nullable), + /// it wraps the value in a single-element list. + /// + /// Throws an [ArgumentError] if [value] is `null` but the generic type [T] is + /// non-nullable. + /// Throws an [ArgumentError] if the [value] is neither of type [T] nor an + /// [Iterable] compatible with [T]. + factory ListOr(Object? value) { + if (value is Iterable) { + return ListOr._(List.from(value)); + } + + if (value is T) { + return ListOr._([value]); + } + + if (value == null) { + throw ArgumentError( + 'The type $T does not accept null values. ' + 'Pass a value of type $T or use ListOr<$T?> instead.', + ); + } + + throw ArgumentError( + 'Value must be of type $T or Iterable containing $T. Got ${value.runtimeType}', + ); + } + + /// Internal constructor to initialize the backing list. + ListOr._(this._items); + + /// Returns the number of elements in the list. + @override + int get length => _items.length; + + /// Updates the length of the list. + @override + set length(int newLength) { + _items.length = newLength; + } + + /// Returns the element at the given [index]. + @override + T operator [](int index) => _items[index]; + + /// Sets the element at the given [index] to [value]. + @override + void operator []=(int index, T value) { + _items[index] = value; + } + + /// Adds [element] to the end of this list. + @override + void add(T element) => _items.add(element); + + /// Appends all objects of [iterable] to the end of this list. + @override + void addAll(Iterable iterable) => _items.addAll(iterable); + + /// Inserts [element] at position [index] in this list. + @override + void insert(int index, T element) => _items.insert(index, element); + + /// Removes the object at position [index] from this list. + /// + /// Returns the removed element. + @override + T removeAt(int index) => _items.removeAt(index); + + /// Removes the first occurrence of [element] from this list. + /// + /// Returns `true` if [element] was in the list, `false` otherwise. + @override + bool remove(Object? element) => _items.remove(element); + + /// Removes all objects from this list; the length of the list becomes zero. + @override + void clear() => _items.clear(); + + /// Returns a string representation of this object. + /// + /// If the list contains exactly one element, this returns the string + /// representation of that single element. Otherwise, it returns the standard + /// bracketed list representation (e.g., `[a, b]` or `[]`). + @override + String toString() { + if (_items.length == 1) { + return _items.firstOrNull.toString(); + } + return _items.toString(); + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..13b47d5 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,12 @@ +name: list_or +description: A Dart utility that seamlessly normalizes single values and iterables into a unified, type-safe List. +version: 1.0.0 +repository: https://git.hadak.org/dart/list_or +issue_tracker: https://git.hadak.org/dart/list_or/issues + +environment: + sdk: ^3.0.0 + +dev_dependencies: + lints: ^6.0.0 + test: ^1.25.6 diff --git a/test/list_or_test.dart b/test/list_or_test.dart new file mode 100644 index 0000000..9d43683 --- /dev/null +++ b/test/list_or_test.dart @@ -0,0 +1,138 @@ +import 'package:list_or/list_or.dart'; +import 'package:test/test.dart'; + +void main() { + group('ListOr Initialization', () { + test('creates from a single value', () { + final list = ListOr('hello'); + + expect(list.length, equals(1)); + expect(list.first, equals('hello')); + expect(list, equals(['hello'])); + }); + + test('creates from a List', () { + final list = ListOr([1, 2, 3]); + + expect(list.length, equals(3)); + expect(list, equals([1, 2, 3])); + }); + + test('creates from a Set (Iterable)', () { + final list = ListOr({4, 5, 6}); + + expect(list.length, equals(3)); + expect(list, equals([4, 5, 6])); + }); + + test('handles null correctly when T is nullable', () { + final list = ListOr(null); + + expect(list.length, equals(1)); + expect(list.first, isNull); + expect(list, equals([null])); + }); + + test('throws ArgumentError when null is passed but T is non-nullable', () { + expect( + () => ListOr(null), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('does not accept null values'), + ), + ), + ); + }); + + test('throws ArgumentError on incompatible types', () { + expect( + () => ListOr(123), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Value must be of type'), + ), + ), + ); + }); + }); + + group('ListOr Mutations (Bug Fix Verification)', () { + test('can add to a single-initialized list without throwing', () { + final list = ListOr('Apple'); + list.add('Banana'); // This would throw prior to the explicit override fix + + expect(list, equals(['Apple', 'Banana'])); + }); + + test('can addAll from another iterable', () { + final list = ListOr(1); + list.addAll([2, 3, 4]); + + expect(list, equals([1, 2, 3, 4])); + }); + + test('can insert at a specific index', () { + final list = ListOr(['A', 'C']); + list.insert(1, 'B'); + + expect(list, equals(['A', 'B', 'C'])); + }); + + test('can update elements using index operator', () { + final list = ListOr([10, 20, 30]); + list[1] = 99; + + expect(list, equals([10, 99, 30])); + }); + + test('can remove a specific element', () { + final list = ListOr(['A', 'B', 'C']); + final result = list.remove('B'); + + expect(result, isTrue); + expect(list, equals(['A', 'C'])); + }); + + test('can remove at a specific index', () { + final list = ListOr([1, 2, 3]); + final removed = list.removeAt(0); + + expect(removed, equals(1)); + expect(list, equals([2, 3])); + }); + + test('can clear the list entirely', () { + final list = ListOr(['A', 'B']); + list.clear(); + + expect(list, isEmpty); + expect(list.length, equals(0)); + }); + }); + + group('ListOr toString formatting', () { + test('formats a single element as just the element string', () { + final list = ListOr('hello'); + expect(list.toString(), equals('hello')); + }); + + test('formats multiple elements with standard list brackets', () { + final list = ListOr([1, 2, 3]); + expect(list.toString(), equals('[1, 2, 3]')); + }); + + test('formats empty list with standard empty brackets', () { + final list = ListOr([]); + expect(list.toString(), equals('[]')); + }); + + test('formats single null element correctly', () { + final list = ListOr(null); + expect(list.toString(), equals('null')); + }); + }); +}