Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/a2ui_core/lib/a2ui_core.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/// Core A2UI protocol implementation for Dart.
library a2ui_core;

export 'src/common/errors.dart';
export 'src/common/cancellation.dart';
export 'src/common/reactivity.dart';
export 'src/common/data_path.dart';
export 'src/protocol/catalog.dart';
export 'src/protocol/messages.dart';
export 'src/protocol/common.dart';
export 'src/protocol/common_schemas.dart';
export 'src/protocol/minimal_catalog.dart';
export 'src/state/data_model.dart';
export 'src/state/component_model.dart';
export 'src/state/surface_model.dart';
export 'src/processing/processor.dart';
export 'src/processing/expressions.dart';
export 'src/processing/basic_functions.dart';
export 'src/rendering/contexts.dart';
export 'src/rendering/binder.dart';
53 changes: 53 additions & 0 deletions packages/a2ui_core/lib/src/common/cancellation.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/// A signal that can be used to cancel an operation.
class CancellationSignal {
bool _isCancelled = false;

final _listeners = <void Function()>[];

/// Whether the operation has been cancelled.
bool get isCancelled => _isCancelled;

/// Cancels the operation.
void cancel() {
if (_isCancelled) return;
_isCancelled = true;
for (final void Function() listener in _listeners) {
listener();
}
}
Comment on lines +11 to +17
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The cancel method has a potential for ConcurrentModificationError if a listener tries to modify the listeners list while it's being iterated over. It's safer to iterate over a copy of the list.

Also, once a signal is cancelled, the listeners are no longer needed and can be cleared to free memory.

For managing listeners, consider using a Set instead of a List for _listeners to automatically prevent duplicate registrations. This is a common practice for listener patterns in Dart.

Suggested change
void cancel() {
if (_isCancelled) return;
_isCancelled = true;
for (final void Function() listener in _listeners) {
listener();
}
}
void cancel() {
if (_isCancelled) return;
_isCancelled = true;
final listenersToCall = _listeners.toList();
_listeners.clear();
for (final listener in listenersToCall) {
listener();
}
}


/// Adds a listener to be notified when the operation is cancelled.
void addListener(void Function() listener) {
if (_isCancelled) {
listener();
} else {
_listeners.add(listener);
}
}

/// Removes a listener.
void removeListener(void Function() listener) {
_listeners.remove(listener);
}

/// Throws a [CancellationException] if the operation has been cancelled.
void throwIfCancelled() {
if (_isCancelled) {
throw const CancellationException();
}
}
}

/// An exception thrown when an operation is cancelled.
class CancellationException implements Exception {
/// Creates a [CancellationException].
const CancellationException([this.message]);

/// A message describing the cancellation.
final String? message;

@override
String toString() => message == null
? 'CancellationException'
: 'CancellationException: $message';
}
83 changes: 83 additions & 0 deletions packages/a2ui_core/lib/src/common/data_path.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/// A class for handling JSON Pointer (RFC 6901) paths.
class DataPath {
final List<String> segments;

DataPath(this.segments);

/// Parses a JSON Pointer string into a [DataPath].
factory DataPath.parse(String path) {
if (path.isEmpty || path == '/') {
return DataPath([]);
}

String normalized = path;
if (path.startsWith('/')) {
normalized = path.substring(1);
}

if (normalized.endsWith('/')) {
normalized = normalized.substring(0, normalized.length - 1);
}

if (normalized.isEmpty) {
return DataPath([]);
}

final segments = normalized.split('/').map((s) {
return s.replaceAll('~1', '/').replaceAll('~0', '~');
}).toList();

return DataPath(segments);
}

/// The number of segments in the path.
int get length => segments.length;

/// Whether the path is empty (points to the root).
bool get isEmpty => segments.isEmpty;

/// Whether the path starts with a slash (is absolute).
bool get isAbsolute => true; // All parsed paths are treated as absolute for now in our context

/// Joins this path with another path or segment.
DataPath append(dynamic other) {
if (other is DataPath) {
return DataPath([...segments, ...other.segments]);
} else if (other is String) {
if (other.startsWith('/')) {
return DataPath.parse(other);
}
Comment on lines +47 to +49
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The behavior of append when given a String that starts with '/' is surprising. It replaces the current path instead of appending to it. This is more akin to a resolve operation and can lead to subtle bugs if the caller expects a consistent append behavior.

To make the append method more predictable, consider either always treating the string as a relative path to append (stripping the leading slash if present) or throwing an ArgumentError if an absolute path string is provided.

Suggested change
if (other.startsWith('/')) {
return DataPath.parse(other);
}
if (other.startsWith('/')) {
throw ArgumentError('Cannot append an absolute path string: $other');
}

return DataPath([...segments, ...DataPath.parse(other).segments]);
}
return DataPath([...segments, other.toString()]);
}

/// Returns the parent path.
DataPath? get parent {
if (segments.isEmpty) return null;
return DataPath(segments.sublist(0, segments.length - 1));
}

@override
String toString() {
if (segments.isEmpty) return '/';
return '/${segments.map((s) => s.replaceAll('~', '~0').replaceAll('/', '~1')).join('/')}';
}

@override
bool operator ==(Object other) =>
identical(this, other) ||
other is DataPath &&
segments.length == other.segments.length &&
_listEquals(segments, other.segments);

@override
int get hashCode => segments.join('/').hashCode;

static bool _listEquals(List<String> a, List<String> b) {
for (int i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
Comment on lines +68 to +82
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementations of operator== and hashCode have issues.

  1. The hashCode implementation segments.join('/').hashCode can cause collisions. For example, DataPath(['a', 'b']) and DataPath(['a/b']) would have the same hash code but are not equal. This violates the hashCode contract and can cause problems when DataPath objects are used in hash-based collections like Map keys or Set elements.
  2. The _listEquals method is a manual implementation of list comparison. Since you already have a dependency on the collection package, you can use ListEquality for a more robust and idiomatic solution for both equality and hash code generation.

Here is a suggested improvement using ListEquality which also allows removing the _listEquals method. You'll need to import package:collection/collection.dart.

Suggested change
bool operator ==(Object other) =>
identical(this, other) ||
other is DataPath &&
segments.length == other.segments.length &&
_listEquals(segments, other.segments);
@override
int get hashCode => segments.join('/').hashCode;
static bool _listEquals(List<String> a, List<String> b) {
for (int i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
bool operator ==(Object other) =>
identical(this, other) ||
other is DataPath &&
const ListEquality().equals(segments, other.segments);
@override
int get hashCode => const ListEquality().hash(segments);

}
38 changes: 38 additions & 0 deletions packages/a2ui_core/lib/src/common/errors.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/// Base class for all A2UI specific errors.
class A2uiError implements Exception {
final String message;
final String code;

A2uiError(this.message, [this.code = 'UNKNOWN_ERROR']);

@override
String toString() => '$runtimeType [$code]: $message';
}

/// Thrown when JSON validation fails or schemas are mismatched.
class A2uiValidationError extends A2uiError {
final dynamic details;

A2uiValidationError(String message, {this.details}) : super(message, 'VALIDATION_ERROR');
}

/// Thrown during DataModel mutations (invalid paths, type mismatches).
class A2uiDataError extends A2uiError {
final String? path;

A2uiDataError(String message, {this.path}) : super(message, 'DATA_ERROR');
}

/// Thrown during string interpolation and function evaluation.
class A2uiExpressionError extends A2uiError {
final String? expression;
final dynamic details;

A2uiExpressionError(String message, {this.expression, this.details})
: super(message, 'EXPRESSION_ERROR');
}

/// Thrown for structural issues in the UI tree (missing surfaces, duplicate components).
class A2uiStateError extends A2uiError {
A2uiStateError(String message) : super(message, 'STATE_ERROR');
}
165 changes: 165 additions & 0 deletions packages/a2ui_core/lib/src/common/reactivity.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import 'dart:async';

/// An interface for objects that maintain a list of listeners.
abstract interface class Listenable {
/// Adds a listener to be notified when the object changes.
void addListener(void Function() listener);

/// Removes a listener.
void removeListener(void Function() listener);
}

/// An interface for objects that hold a value and notify listeners when it changes.
abstract class ValueListenable<T> implements Listenable {
/// The current value.
T get value;
}

bool _inBatch = false;
final _pendingNotifiers = <ValueNotifier<dynamic>>{};

/// Executes [callback] and defers notifications until it completes.
void batch(void Function() callback) {
if (_inBatch) {
callback();
return;
}

_inBatch = true;
try {
callback();
} finally {
_inBatch = false;
final toNotify = _pendingNotifiers.toList();
_pendingNotifiers.clear();
for (final notifier in toNotify) {
notifier._notifyListeners();
}
}
}

/// A base class for objects that hold a value and notify listeners when it changes.
class ValueNotifier<T> implements ValueListenable<T> {
T _value;
final _listeners = <void Function()>{};

ValueNotifier(this._value);

@override
T get value {
_DependencyTracker.instance?._reportRead(this);
return _value;
}

set value(T newValue) {
if (_value == newValue) return;
_value = newValue;
notifyListeners();
}

@override
void addListener(void Function() listener) {
_listeners.add(listener);
}

@override
void removeListener(void Function() listener) {
_listeners.remove(listener);
}

/// Notifies all registered listeners.
void notifyListeners() {
if (_inBatch) {
_pendingNotifiers.add(this);
return;
}
_notifyListeners();
}

void _notifyListeners() {
for (final listener in List<void Function()>.from(_listeners)) {
if (_listeners.contains(listener)) {
listener();
}
}
}

/// Disposes of the notifier and its resources.
void dispose() {
_listeners.clear();
_pendingNotifiers.remove(this);
}
}

/// A derived notifier that automatically tracks and listens to other [ValueListenable]
/// dependencies, recalculating its value only when they change.
class ComputedNotifier<T> extends ValueNotifier<T> {
final T Function() _compute;
final Set<ValueListenable<dynamic>> _dependencies = {};

ComputedNotifier(this._compute) : super(_compute()) {
_updateDependencies();
}

void _updateDependencies() {
final tracker = _DependencyTracker();
final newValue = tracker.track(_compute);

final newDeps = tracker.dependencies;

// Unsubscribe from old dependencies no longer needed
for (final dep in _dependencies.difference(newDeps)) {
dep.removeListener(_onDependencyChanged);
}

// Subscribe to new dependencies
for (final dep in newDeps.difference(_dependencies)) {
dep.addListener(_onDependencyChanged);
}

_dependencies.clear();
_dependencies.addAll(newDeps);

super.value = newValue;
}

void _onDependencyChanged() {
_updateDependencies();
}

@override
T get value {
// If we have no listeners, we might want to re-evaluate on every read
// to ensure we're fresh, but usually, Computed is used with listeners.
// For now, let's just return the cached value and rely on dependencies.
return super.value;
}

@override
void dispose() {
for (final dep in _dependencies) {
dep.removeListener(_onDependencyChanged);
}
_dependencies.clear();
super.dispose();
}
}

class _DependencyTracker {
static _DependencyTracker? instance;
final Set<ValueListenable<dynamic>> dependencies = {};

T track<T>(T Function() callback) {
final previous = instance;
instance = this;
try {
return callback();
} finally {
instance = previous;
}
}

void _reportRead(ValueListenable<dynamic> listenable) {
dependencies.add(listenable);
}
}
Loading
Loading