v1.0.0 - Initial release #1
@@ -0,0 +1,287 @@
|
||||
name: CI and Release
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
format:
|
||||
name: format
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Dart
|
||||
uses: dart-lang/setup-dart@v1
|
||||
- name: Install dependencies
|
||||
run: dart pub get
|
||||
- name: Check formatting
|
||||
run: dart format --output=none --set-exit-if-changed .
|
||||
|
||||
analyze:
|
||||
name: analyze
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Dart
|
||||
uses: dart-lang/setup-dart@v1
|
||||
- name: Install dependencies
|
||||
run: dart pub get
|
||||
- name: Run analyzer
|
||||
run: dart analyze --fatal-infos
|
||||
|
||||
test:
|
||||
name: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Dart
|
||||
uses: dart-lang/setup-dart@v1
|
||||
- name: Install dependencies
|
||||
run: dart pub get
|
||||
- name: Run tests
|
||||
run: dart test
|
||||
|
||||
pana:
|
||||
name: pana
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Dart
|
||||
uses: dart-lang/setup-dart@v1
|
||||
- name: Install dependencies
|
||||
run: dart pub get
|
||||
- name: Run pana and enforce full score
|
||||
run: |
|
||||
set -euo pipefail
|
||||
dart pub global activate pana
|
||||
export PATH="$PATH:$HOME/.pub-cache/bin"
|
||||
|
||||
pana . | tee /tmp/pana.log
|
||||
|
||||
SCORE_LINE="$(grep -Eo 'Points: [0-9]+/[0-9]+' /tmp/pana.log | tail -n1 || true)"
|
||||
if [ -z "$SCORE_LINE" ]; then
|
||||
echo "Could not parse pana score output."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCORE="${SCORE_LINE#Points: }"
|
||||
GOT="${SCORE%/*}"
|
||||
MAX="${SCORE#*/}"
|
||||
|
||||
if [ "$GOT" -ne "$MAX" ]; then
|
||||
echo "Pana score must be full. Got $SCORE."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Pana full score confirmed: $SCORE"
|
||||
|
||||
version_and_changelog:
|
||||
name: version-and-changelog
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Validate release metadata for PRs into main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ "${GITHUB_EVENT_NAME}" != "pull_request" ]; then
|
||||
echo "Not a pull_request event; skipping version/changelog gate."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "${GITHUB_BASE_REF:-}" ]; then
|
||||
echo "GITHUB_BASE_REF is not set for pull_request event."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch origin "${GITHUB_BASE_REF}" --depth=1
|
||||
|
||||
PR_VERSION="$(sed -nE 's/^version:\s*([^[:space:]]+)\s*$/\1/p' pubspec.yaml | head -n1)"
|
||||
BASE_HAS_PUBSPEC="false"
|
||||
BASE_VERSION=""
|
||||
|
||||
if git cat-file -e "origin/${GITHUB_BASE_REF}:pubspec.yaml" 2>/dev/null; then
|
||||
BASE_HAS_PUBSPEC="true"
|
||||
BASE_VERSION="$(git show "origin/${GITHUB_BASE_REF}:pubspec.yaml" | sed -nE 's/^version:\s*([^[:space:]]+)\s*$/\1/p' | head -n1)"
|
||||
fi
|
||||
|
||||
if [ -z "$PR_VERSION" ]; then
|
||||
echo "Unable to read version from PR pubspec.yaml."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$BASE_HAS_PUBSPEC" = "true" ]; then
|
||||
if [ -z "$BASE_VERSION" ]; then
|
||||
echo "Unable to read version from base branch pubspec.yaml."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
parse_semver_core() {
|
||||
printf '%s' "$1" | sed -E 's/^([0-9]+\.[0-9]+\.[0-9]+).*/\1/'
|
||||
}
|
||||
|
||||
BASE_CORE="$(parse_semver_core "$BASE_VERSION")"
|
||||
PR_CORE="$(parse_semver_core "$PR_VERSION")"
|
||||
|
||||
if ! printf '%s' "$BASE_CORE" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "Base version is not valid semver: $BASE_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! printf '%s' "$PR_CORE" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "PR version is not valid semver: $PR_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BASE_MAJOR="${BASE_CORE%%.*}"
|
||||
BASE_REST="${BASE_CORE#*.}"
|
||||
BASE_MINOR="${BASE_REST%%.*}"
|
||||
BASE_PATCH="${BASE_REST#*.}"
|
||||
|
||||
PR_MAJOR="${PR_CORE%%.*}"
|
||||
PR_REST="${PR_CORE#*.}"
|
||||
PR_MINOR="${PR_REST%%.*}"
|
||||
PR_PATCH="${PR_REST#*.}"
|
||||
|
||||
if [ "$PR_MAJOR" -lt "$BASE_MAJOR" ]; then
|
||||
echo "pubspec.yaml version must be greater than base version ($BASE_VERSION -> $PR_VERSION)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$PR_MAJOR" -eq "$BASE_MAJOR" ] && [ "$PR_MINOR" -lt "$BASE_MINOR" ]; then
|
||||
echo "pubspec.yaml version must be greater than base version ($BASE_VERSION -> $PR_VERSION)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$PR_MAJOR" -eq "$BASE_MAJOR" ] && [ "$PR_MINOR" -eq "$BASE_MINOR" ] && [ "$PR_PATCH" -le "$BASE_PATCH" ]; then
|
||||
echo "pubspec.yaml version must be at least 0.0.1 greater than base version ($BASE_VERSION -> $PR_VERSION)."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Base branch has no pubspec.yaml; treating this as first release."
|
||||
fi
|
||||
|
||||
if git diff --quiet "origin/${GITHUB_BASE_REF}...HEAD" -- CHANGELOG.md; then
|
||||
echo "CHANGELOG.md must be updated in the PR."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "## $PR_VERSION" CHANGELOG.md && ! grep -Fq "## [$PR_VERSION]" CHANGELOG.md; then
|
||||
echo "CHANGELOG.md must include a heading for version $PR_VERSION."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$BASE_HAS_PUBSPEC" = "true" ]; then
|
||||
echo "Version/changelog gate passed: $BASE_VERSION -> $PR_VERSION"
|
||||
else
|
||||
echo "Version/changelog gate passed for first release: $PR_VERSION"
|
||||
fi
|
||||
|
||||
publish:
|
||||
name: publish
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- format
|
||||
- analyze
|
||||
- test
|
||||
- pana
|
||||
- version_and_changelog
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Dart
|
||||
uses: dart-lang/setup-dart@v1
|
||||
- name: Publish package and create Gitea release on push to main
|
||||
env:
|
||||
PUB_CREDENTIALS_JSON: ${{ secrets.PUB_CREDENTIALS_JSON }}
|
||||
RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
FALLBACK_RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ "${GITHUB_EVENT_NAME}" != "push" ] || [ "${GITHUB_REF}" != "refs/heads/main" ]; then
|
||||
echo "Not a push to main; skipping publish."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PACKAGE_NAME="$(sed -nE 's/^name:\s*([^[:space:]]+)\s*$/\1/p' pubspec.yaml | head -n1)"
|
||||
PACKAGE_VERSION="$(sed -nE 's/^version:\s*([^[:space:]]+)\s*$/\1/p' pubspec.yaml | head -n1)"
|
||||
|
||||
if [ -z "$PACKAGE_NAME" ] || [ -z "$PACKAGE_VERSION" ]; then
|
||||
echo "Failed to parse package name/version from pubspec.yaml."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if curl -fsSL "https://pub.dev/api/packages/${PACKAGE_NAME}" | grep -q "\"version\":\"${PACKAGE_VERSION}\""; then
|
||||
echo "${PACKAGE_NAME} ${PACKAGE_VERSION} is already published; skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "${PUB_CREDENTIALS_JSON:-}" ]; then
|
||||
echo "Missing required secret PUB_CREDENTIALS_JSON."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$HOME/.pub-cache"
|
||||
printf '%s' "$PUB_CREDENTIALS_JSON" > "$HOME/.pub-cache/credentials.json"
|
||||
chmod 600 "$HOME/.pub-cache/credentials.json"
|
||||
|
||||
dart pub get
|
||||
dart pub publish --dry-run
|
||||
dart pub publish --force
|
||||
|
||||
TOKEN="${RELEASE_TOKEN:-}"
|
||||
if [ -z "$TOKEN" ]; then
|
||||
TOKEN="${FALLBACK_RELEASE_TOKEN:-}"
|
||||
fi
|
||||
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo "Missing release token. Provide default workflow token or RELEASE_TOKEN."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVER_URL="${GITHUB_SERVER_URL:-${GITEA_SERVER_URL:-}}"
|
||||
REPO_PATH="${GITHUB_REPOSITORY:-${GITEA_REPOSITORY:-}}"
|
||||
|
||||
if [ -z "$SERVER_URL" ] || [ -z "$REPO_PATH" ]; then
|
||||
echo "Missing repository context required for Gitea API release creation."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG="v${PACKAGE_VERSION}"
|
||||
RELEASE_BY_TAG_URL="${SERVER_URL}/api/v1/repos/${REPO_PATH}/releases/tags/${TAG}"
|
||||
|
||||
if curl -fsS -H "Authorization: token ${TOKEN}" "$RELEASE_BY_TAG_URL" >/dev/null; then
|
||||
echo "Release ${TAG} already exists; skipping release creation."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
CREATE_RELEASE_URL="${SERVER_URL}/api/v1/repos/${REPO_PATH}/releases"
|
||||
RELEASE_PAYLOAD="{\"tag_name\":\"${TAG}\",\"target\":\"${GITHUB_SHA}\",\"target_commitish\":\"${GITHUB_SHA}\",\"title\":\"${TAG}\",\"name\":\"${TAG}\",\"note\":\"Release ${TAG}\",\"body\":\"Release ${TAG}\",\"draft\":false,\"prerelease\":false}"
|
||||
|
||||
HTTP_CODE="$(curl -sS -o /tmp/gitea-release-response.json -w '%{http_code}' \
|
||||
-X POST "$CREATE_RELEASE_URL" \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$RELEASE_PAYLOAD")"
|
||||
|
||||
if [ "$HTTP_CODE" = "201" ]; then
|
||||
echo "Created Gitea release for ${TAG}."
|
||||
elif [ "$HTTP_CODE" = "409" ]; then
|
||||
echo "Release or tag ${TAG} already exists; treating as success."
|
||||
else
|
||||
echo "Failed to create Gitea release. HTTP ${HTTP_CODE}."
|
||||
cat /tmp/gitea-release-response.json
|
||||
exit 1
|
||||
fi
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
printf '%s\n' 'Running pre-commit checks: dart format + dart analyze'
|
||||
|
||||
dart format --output=none --set-exit-if-changed .
|
||||
dart analyze --fatal-infos
|
||||
|
||||
printf '%s\n' 'Pre-commit checks passed.'
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
printf '%s\n' 'Running pre-push checks: dart format + dart test'
|
||||
|
||||
dart format --output=none --set-exit-if-changed .
|
||||
dart test
|
||||
|
||||
printf '%s\n' 'Pre-push checks passed.'
|
||||
@@ -0,0 +1,3 @@
|
||||
## 1.0.0
|
||||
|
||||
- Initial version.
|
||||
@@ -0,0 +1,28 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2026, Hans Kokx
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -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<T>`.
|
||||
|
||||
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<String?>`).
|
||||
* **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<String, dynamic> json1 = {'id': 1, 'tags': 'flutter'};
|
||||
|
||||
// Scenario B: The API returns an array of strings
|
||||
final Map<String, dynamic> json2 = {'id': 2, 'tags': ['dart', 'backend']};
|
||||
|
||||
// ListOr normalizes both seamlessly.
|
||||
final tags1 = ListOr<String>(json1['tags']);
|
||||
final tags2 = ListOr<String>(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<int?>(null);
|
||||
print(nullableList.length); // Output: 1
|
||||
|
||||
// This will throw an ArgumentError to keep your types safe
|
||||
final strictList = ListOr<int>(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.
|
||||
@@ -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
|
||||
@@ -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<String>('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<String>(['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<String, dynamic> jsonResponse1 = {'id': 1, 'tags': 'flutter'};
|
||||
final Map<String, dynamic> jsonResponse2 = {
|
||||
'id': 2,
|
||||
'tags': ['dart', 'backend'],
|
||||
};
|
||||
|
||||
// ListOr cleanly normalizes both scenarios without complex type checking.
|
||||
final tags1 = ListOr<String>(jsonResponse1['tags']);
|
||||
final tags2 = ListOr<String>(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<int?>(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<int>(null); // THROWS!
|
||||
}
|
||||
@@ -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<T>].
|
||||
///
|
||||
/// 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<T> extends Object with ListMixin<T> {
|
||||
/// The underlying list storing the elements.
|
||||
final List<T> _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<T>.from(value));
|
||||
}
|
||||
|
||||
if (value is T) {
|
||||
return ListOr._(<T>[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<T> 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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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<String>('hello');
|
||||
|
||||
expect(list.length, equals(1));
|
||||
expect(list.first, equals('hello'));
|
||||
expect(list, equals(['hello']));
|
||||
});
|
||||
|
||||
test('creates from a List', () {
|
||||
final list = ListOr<int>([1, 2, 3]);
|
||||
|
||||
expect(list.length, equals(3));
|
||||
expect(list, equals([1, 2, 3]));
|
||||
});
|
||||
|
||||
test('creates from a Set (Iterable)', () {
|
||||
final list = ListOr<int>({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<int?>(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<int>(null),
|
||||
throwsA(
|
||||
isA<ArgumentError>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('does not accept null values'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws ArgumentError on incompatible types', () {
|
||||
expect(
|
||||
() => ListOr<String>(123),
|
||||
throwsA(
|
||||
isA<ArgumentError>().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<String>('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<int>(1);
|
||||
list.addAll([2, 3, 4]);
|
||||
|
||||
expect(list, equals([1, 2, 3, 4]));
|
||||
});
|
||||
|
||||
test('can insert at a specific index', () {
|
||||
final list = ListOr<String>(['A', 'C']);
|
||||
list.insert(1, 'B');
|
||||
|
||||
expect(list, equals(['A', 'B', 'C']));
|
||||
});
|
||||
|
||||
test('can update elements using index operator', () {
|
||||
final list = ListOr<int>([10, 20, 30]);
|
||||
list[1] = 99;
|
||||
|
||||
expect(list, equals([10, 99, 30]));
|
||||
});
|
||||
|
||||
test('can remove a specific element', () {
|
||||
final list = ListOr<String>(['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<int>([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<String>(['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<String>('hello');
|
||||
expect(list.toString(), equals('hello'));
|
||||
});
|
||||
|
||||
test('formats multiple elements with standard list brackets', () {
|
||||
final list = ListOr<int>([1, 2, 3]);
|
||||
expect(list.toString(), equals('[1, 2, 3]'));
|
||||
});
|
||||
|
||||
test('formats empty list with standard empty brackets', () {
|
||||
final list = ListOr<String>(<String>[]);
|
||||
expect(list.toString(), equals('[]'));
|
||||
});
|
||||
|
||||
test('formats single null element correctly', () {
|
||||
final list = ListOr<String?>(null);
|
||||
expect(list.toString(), equals('null'));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user