diff --git a/.gitignore b/.gitignore index 44fc9205..b4ca376f 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ coverage/ # Misc *.log .flutter-plugins-dependencies +dart-sdk/ +flutter/ +*.tar.xz diff --git a/packages/locorda/example/minimal/lib/consts.dart b/packages/locorda/example/minimal/lib/consts.dart new file mode 100644 index 00000000..51316f1f --- /dev/null +++ b/packages/locorda/example/minimal/lib/consts.dart @@ -0,0 +1 @@ +const appBaseUrl = 'https://locorda.dev/example/minimal'; diff --git a/packages/locorda/example/minimal/lib/init_locorda.g.dart b/packages/locorda/example/minimal/lib/init_locorda.g.dart index 3e4ddb97..045be1bb 100644 --- a/packages/locorda/example/minimal/lib/init_locorda.g.dart +++ b/packages/locorda/example/minimal/lib/init_locorda.g.dart @@ -1,20 +1,5 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: unused_import, depend_on_referenced_packages - -import 'package:locorda_flutter/locorda_flutter.dart'; -import 'worker_generated.g.dart' show generatedWorkerSetup; -import 'init_rdf_mapper.g.dart' show initRdfMapper; -import 'dart:async'; -import 'package:locorda_core/locorda_core.dart' as locorda_core; -import 'package:locorda_core/locorda_core.dart'; -import 'package:locorda_flutter_core/locorda_flutter_core.dart'; -import 'package:locorda_objects/locorda_objects.dart'; -import 'package:locorda_rdf_core/core.dart'; -import 'package:locorda_rdf_mapper/mapper.dart'; -import 'package:locorda_ui/locorda_ui.dart'; -import 'package:locorda_worker/worker_main.dart'; -import 'package:minimal_task_sync/task.dart' as task; -import 'package:minimal_task_sync/task.rdf_mapper.g.dart' as trmg; +// ignore_for_file: unused_import, depend_on_referenced_packages, unnecessary_import, implementation_imports /// Convenience wrapper for Locorda.create with auto-detected settings. /// @@ -22,30 +7,42 @@ import 'package:minimal_task_sync/task.rdf_mapper.g.dart' as trmg; /// - workerSetup: generatedWorkerSetup (from worker_generated.g.dart) /// - jsScript: 'worker_generated.dart.js' /// - mapperInitializer: Generated from initRdfMapper +/// - config: Generated from annotations via generateLocordaConfig() + +library; + +import 'dart:core'; +import 'init_rdf_mapper.g.dart' as mpr; +import 'locorda_config.g.dart' as cfg; +import 'package:locorda_flutter/locorda_flutter.dart'; +import 'package:locorda_flutter_core/src/integration.dart' as integration; +import 'package:locorda_rdf_core/core.dart' as core; +import 'package:locorda_rdf_core/src/graph/rdf_term.dart' as rdf_term; +import 'package:locorda_worker/src/main/main_handler.dart' as main_handler; +import 'package:locorda_worker/src/main/storage_main_handler.dart' as smh; +import 'worker_generated.g.dart' as wrk; + Future initLocorda({ void Function()? onWorkerSpawn, - required LocordaConfig config, - required StorageMainHandler storage, - List remotes = const [], - List plugins = const [], - IriTermFactory? iriTermFactory, - RdfCore? rdfCore, + required smh.StorageMainHandler storage, + List remotes = const [], + List plugins = const [], + rdf_term.IriTermFactory? iriTermFactory, + core.RdfCore? rdfCore, String? debugName, -}) async { - return Locorda.create( - workerSetup: generatedWorkerSetup, - jsScript: 'worker_generated.dart.js', - mapperInitializer: (context) => initRdfMapper( - rdfMapper: context.baseRdfMapper, - $resourceIriFactory: context.resourceIriFactory, - ), - onWorkerSpawn: onWorkerSpawn, - config: config, - storage: storage, - remotes: remotes, - plugins: plugins, - iriTermFactory: iriTermFactory, - rdfCore: rdfCore, - debugName: debugName, - ); -} +}) async => Locorda.create( + workerSetup: wrk.generatedWorkerSetup, + jsScript: 'worker_generated.dart.js', + mapperInitializer: (context) => mpr.initRdfMapper( + rdfMapper: context.baseRdfMapper, + $resourceIriFactory: context.resourceIriFactory, + ), + config: cfg.generateLocordaConfig(), + onWorkerSpawn: onWorkerSpawn, + storage: storage, + remotes: remotes, + plugins: plugins, + iriTermFactory: iriTermFactory, + rdfCore: rdfCore, + debugName: debugName, +); diff --git a/packages/locorda/example/minimal/lib/locorda_config.g.dart b/packages/locorda/example/minimal/lib/locorda_config.g.dart new file mode 100644 index 00000000..c003a353 --- /dev/null +++ b/packages/locorda/example/minimal/lib/locorda_config.g.dart @@ -0,0 +1,24 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: unused_import, depend_on_referenced_packages, unnecessary_import, implementation_imports + +/// Generated LocordaConfig from annotations. +/// +/// All crdtMapping IRIs are static, app-owned, absolute IRIs +/// fully determined at compile time from annotation values. +library; + +import 'dart:core'; +import 'package:locorda_objects/locorda_objects.dart'; +import 'package:minimal_task_sync/task.dart' as task; + +LocordaConfig generateLocordaConfig() => LocordaConfig( + resources: [ + ResourceConfig( + type: task.Task, + crdtMapping: Uri.parse( + 'https://locorda.dev/example/minimal/mappings/task-v1.ttl', + ), + indices: [FullIndex()], + ), + ], +); diff --git a/packages/locorda/example/minimal/lib/main.dart b/packages/locorda/example/minimal/lib/main.dart index 5d591f0d..b30e953d 100644 --- a/packages/locorda/example/minimal/lib/main.dart +++ b/packages/locorda/example/minimal/lib/main.dart @@ -14,35 +14,6 @@ void main() async { runApp(const MinimalTaskApp()); } -// #docregion locorda-setup -/// Initialize Locorda with worker architecture. -Future setupLocorda() async { - return initLocorda( - // Local Dir for testing/debugging (not for production!) - remotes: [ - await DirMainIntegration.create( - displayName: 'Local Directory (Testing)', - ), - ], - - // InMemoryStorage requires empty main handler - storage: InMemoryStorageMainHandler(), - - // Configure Task resource with CRDT mapping - config: LocordaConfig( - resources: [ - ResourceConfig( - type: Task, - crdtMapping: Uri.parse( - 'https://locorda.dev/example/minimal/mappings/task-v1.ttl'), - indices: [FullIndex()], // Simple: fetch all tasks - ), - ], - ), - ); -} -// #enddocregion locorda-setup - class MinimalTaskApp extends StatefulWidget { const MinimalTaskApp({super.key}); @@ -63,7 +34,22 @@ class _MinimalTaskAppState extends State { Future _initialize() async { try { - final locorda = await setupLocorda(); + // #docregion locorda-setup + /// Initialize Locorda with worker architecture. + + final locorda = await initLocorda( + // Local Dir for testing/debugging (not for production!) + remotes: [ + await DirMainIntegration.create( + displayName: 'Local Directory (Testing)'), + ], + + // InMemoryStorage for simplicity - data won't persist across app restarts + storage: InMemoryStorageMainHandler(), + ); + + // #enddocregion locorda-setup + final taskRepo = await TaskRepository.create(locorda.syncEngine); setState(() { @@ -79,17 +65,13 @@ class _MinimalTaskAppState extends State { Widget build(BuildContext context) { if (_errorMessage != null) { return MaterialApp( - home: Scaffold( - body: Center(child: Text(_errorMessage!)), - ), + home: Scaffold(body: Center(child: Text(_errorMessage!))), ); } if (_taskRepo == null) { return const MaterialApp( - home: Scaffold( - body: Center(child: CircularProgressIndicator()), - ), + home: Scaffold(body: Center(child: CircularProgressIndicator())), ); } @@ -166,10 +148,12 @@ class _TaskListScreenState extends State { onPressed: () { final title = _controller.text.trim(); if (title.isNotEmpty) { - widget.repository.save(Task( - id: 'task_${DateTime.now().millisecondsSinceEpoch}', - title: title, - )); + widget.repository.save( + Task( + id: 'task_${DateTime.now().millisecondsSinceEpoch}', + title: title, + ), + ); } _controller.clear(); Navigator.pop(context); @@ -187,4 +171,5 @@ class _TaskListScreenState extends State { super.dispose(); } } + // #enddocregion ui diff --git a/packages/locorda/example/minimal/lib/task.dart b/packages/locorda/example/minimal/lib/task.dart index 459e8f4e..ce9dce93 100644 --- a/packages/locorda/example/minimal/lib/task.dart +++ b/packages/locorda/example/minimal/lib/task.dart @@ -5,11 +5,13 @@ import 'package:locorda_annotations/locorda_annotations.dart'; import 'package:locorda_rdf_mapper_annotations/annotations.dart'; import 'package:locorda_rdf_core/core.dart'; import 'package:locorda_rdf_terms_schema/schema.dart'; +import 'package:minimal_task_sync/consts.dart' show appBaseUrl; // #docregion task-model /// A simple task with CRDT sync. @LcrdRootResource( - IriTerm('https://locorda.dev/example/minimal/Task'), + IriTerm('$appBaseUrl/vocabulary/task#Task'), + '$appBaseUrl/mappings/task-v1.ttl', ) class Task { /// Unique ID for this task @@ -22,7 +24,7 @@ class Task { final String title; /// Completion status - LWW (schema.org has no boolean completion property) - @RdfProperty(IriTerm('https://locorda.dev/example/minimal#completed')) + @RdfProperty(IriTerm('$appBaseUrl/vocabulary/task#completed')) @CrdtLwwRegister() final bool completed; @@ -45,4 +47,5 @@ class Task { createdAt: createdAt, ); } + // #enddocregion task-model diff --git a/packages/locorda/example/minimal/lib/task.rdf_mapper.g.dart b/packages/locorda/example/minimal/lib/task.rdf_mapper.g.dart index f7b9f131..182f16d5 100644 --- a/packages/locorda/example/minimal/lib/task.rdf_mapper.g.dart +++ b/packages/locorda/example/minimal/lib/task.rdf_mapper.g.dart @@ -29,7 +29,7 @@ class TaskMapper implements GlobalResourceMapper { @override IriTerm? get typeIri => - const IriTerm('https://locorda.dev/example/minimal/Task'); + const IriTerm('https://locorda.dev/example/minimal/vocabulary/task#Task'); @override task.Task fromRdfResource(IriTerm subject, DeserializationContext context) { @@ -39,7 +39,9 @@ class TaskMapper implements GlobalResourceMapper { final String title = reader.require(SchemaThing.name); final bool completed = reader.require( - const IriTerm('https://locorda.dev/example/minimal#completed'), + const IriTerm( + 'https://locorda.dev/example/minimal/vocabulary/task#completed', + ), ); final DateTime createdAt = reader.require(SchemaCreativeWork.dateCreated); @@ -63,7 +65,9 @@ class TaskMapper implements GlobalResourceMapper { .resourceBuilder(subject) .addValue(SchemaThing.name, resource.title) .addValue( - const IriTerm('https://locorda.dev/example/minimal#completed'), + const IriTerm( + 'https://locorda.dev/example/minimal/vocabulary/task#completed', + ), resource.completed, ) .addValue(SchemaCreativeWork.dateCreated, resource.createdAt) diff --git a/packages/locorda/example/minimal/lib/task_repository.dart b/packages/locorda/example/minimal/lib/task_repository.dart index ee60279c..7ec44ebd 100644 --- a/packages/locorda/example/minimal/lib/task_repository.dart +++ b/packages/locorda/example/minimal/lib/task_repository.dart @@ -18,7 +18,7 @@ class TaskRepository { /// Create and initialize repository with hydration. static Future create(ObjectSyncEngine syncEngine) async { final repo = TaskRepository._(syncEngine); - + // Setup hydration: remote changes → local storage repo._hydrationSubscription = await syncEngine.hydrateWithCallbacks( getCurrentCursor: () async => null, // Simple: no cursor persistence @@ -30,9 +30,10 @@ class TaskRepository { repo._tasks.remove(id); repo._notifyListeners(); }, - onCursorUpdate: (cursor) async {}, // Skip cursor persistence for minimal example + onCursorUpdate: + (cursor) async {}, // Skip cursor persistence for minimal example ); - + return repo; } @@ -40,8 +41,9 @@ class TaskRepository { Stream> watchAll() => _controller.stream; /// Get all tasks (snapshot) - List getAll() => _tasks.values.toList() - ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + List getAll() => + _tasks.values.toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); /// Save task (create or update) - triggers sync Future save(Task task) async { @@ -64,4 +66,5 @@ class TaskRepository { _controller.close(); } } + // #enddocregion repository diff --git a/packages/locorda/example/personal_notes_app/lib/consts.dart b/packages/locorda/example/personal_notes_app/lib/consts.dart new file mode 100644 index 00000000..e599eb3b --- /dev/null +++ b/packages/locorda/example/personal_notes_app/lib/consts.dart @@ -0,0 +1 @@ +const appBaseUrl = 'https://locorda.dev/example/personal_notes_app'; diff --git a/packages/locorda/example/personal_notes_app/lib/init_locorda.g.dart b/packages/locorda/example/personal_notes_app/lib/init_locorda.g.dart index 79daae22..6aef357c 100644 --- a/packages/locorda/example/personal_notes_app/lib/init_locorda.g.dart +++ b/packages/locorda/example/personal_notes_app/lib/init_locorda.g.dart @@ -1,30 +1,5 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: unused_import, depend_on_referenced_packages - -import 'package:locorda_flutter/locorda_flutter.dart'; -import 'worker_generated.g.dart' show generatedWorkerSetup; -import 'init_rdf_mapper.g.dart' show initRdfMapper; -import 'dart:async'; -import 'package:locorda_core/locorda_core.dart' as locorda_core; -import 'package:locorda_core/locorda_core.dart'; -import 'package:locorda_flutter_core/locorda_flutter_core.dart'; -import 'package:locorda_objects/locorda_objects.dart'; -import 'package:locorda_rdf_core/core.dart'; -import 'package:locorda_rdf_mapper/mapper.dart'; -import 'package:locorda_ui/locorda_ui.dart'; -import 'package:locorda_worker/worker_main.dart'; -import 'package:personal_notes_app/models/category.dart' as category; -import 'package:personal_notes_app/models/category.rdf_mapper.g.dart' as crmg; -import 'package:personal_notes_app/models/category_display_settings.dart' as cds; -import 'package:personal_notes_app/models/category_display_settings.rdf_mapper.g.dart' as cdsrmg; -import 'package:personal_notes_app/models/note.dart' as note; -import 'package:personal_notes_app/models/note.rdf_mapper.g.dart' as nrmg; -import 'package:personal_notes_app/models/note_group_key.dart' as ngk; -import 'package:personal_notes_app/models/note_group_key.rdf_mapper.g.dart' as ngkrmg; -import 'package:personal_notes_app/models/note_index_entry.dart' as nie; -import 'package:personal_notes_app/models/note_index_entry.rdf_mapper.g.dart' as niermg; -import 'package:personal_notes_app/models/weblink.dart' as weblink; -import 'package:personal_notes_app/models/weblink.rdf_mapper.g.dart' as wrmg; +// ignore_for_file: unused_import, depend_on_referenced_packages, unnecessary_import, implementation_imports /// Convenience wrapper for Locorda.create with auto-detected settings. /// @@ -32,32 +7,44 @@ import 'package:personal_notes_app/models/weblink.rdf_mapper.g.dart' as wrmg; /// - workerSetup: generatedWorkerSetup (from worker_generated.g.dart) /// - jsScript: 'worker_generated.dart.js' /// - mapperInitializer: Generated from initRdfMapper +/// - config: Generated from annotations via generateLocordaConfig() + +library; + +import 'dart:core'; +import 'init_rdf_mapper.g.dart' as mpr; +import 'locorda_config.g.dart' as cfg; +import 'package:locorda_flutter/locorda_flutter.dart'; +import 'package:locorda_flutter_core/src/integration.dart' as integration; +import 'package:locorda_rdf_core/core.dart' as core; +import 'package:locorda_rdf_core/src/graph/rdf_term.dart' as rdf_term; +import 'package:locorda_worker/src/main/main_handler.dart' as main_handler; +import 'package:locorda_worker/src/main/storage_main_handler.dart' as smh; +import 'worker_generated.g.dart' as wrk; + Future initLocorda({ void Function()? onWorkerSpawn, - required LocordaConfig config, - required StorageMainHandler storage, - List remotes = const [], - List plugins = const [], - IriTermFactory? iriTermFactory, - RdfCore? rdfCore, + required smh.StorageMainHandler storage, + List remotes = const [], + List plugins = const [], + rdf_term.IriTermFactory? iriTermFactory, + core.RdfCore? rdfCore, String? debugName, -}) async { - return Locorda.create( - workerSetup: generatedWorkerSetup, - jsScript: 'worker_generated.dart.js', - mapperInitializer: (context) => initRdfMapper( - rdfMapper: context.baseRdfMapper, - $indexItemIriFactory: context.indexItemIriFactory, - $resourceIriFactory: context.resourceIriFactory, - $resourceRefFactory: context.resourceRefFactory, - ), - onWorkerSpawn: onWorkerSpawn, - config: config, - storage: storage, - remotes: remotes, - plugins: plugins, - iriTermFactory: iriTermFactory, - rdfCore: rdfCore, - debugName: debugName, - ); -} +}) async => Locorda.create( + workerSetup: wrk.generatedWorkerSetup, + jsScript: 'worker_generated.dart.js', + mapperInitializer: (context) => mpr.initRdfMapper( + rdfMapper: context.baseRdfMapper, + $indexItemIriFactory: context.indexItemIriFactory, + $resourceIriFactory: context.resourceIriFactory, + $resourceRefFactory: context.resourceRefFactory, + ), + config: cfg.generateLocordaConfig(), + onWorkerSpawn: onWorkerSpawn, + storage: storage, + remotes: remotes, + plugins: plugins, + iriTermFactory: iriTermFactory, + rdfCore: rdfCore, + debugName: debugName, +); diff --git a/packages/locorda/example/personal_notes_app/lib/locorda_config.g.dart b/packages/locorda/example/personal_notes_app/lib/locorda_config.g.dart new file mode 100644 index 00000000..3c494305 --- /dev/null +++ b/packages/locorda/example/personal_notes_app/lib/locorda_config.g.dart @@ -0,0 +1,60 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: unused_import, depend_on_referenced_packages, unnecessary_import, implementation_imports + +/// Generated LocordaConfig from annotations. +/// +/// All crdtMapping IRIs are static, app-owned, absolute IRIs +/// fully determined at compile time from annotation values. +library; + +import 'dart:core'; +import 'package:locorda_core/locorda_core.dart'; +import 'package:locorda_objects/locorda_objects.dart'; +import 'package:locorda_rdf_core/src/graph/rdf_term.dart' as rdf_term; +import 'package:personal_notes_app/models/category.dart' as category; +import 'package:personal_notes_app/models/note.dart' as note; +import 'package:personal_notes_app/models/note_group_key.dart' as ngk; +import 'package:personal_notes_app/models/note_index_entry.dart' as nie; + +LocordaConfig generateLocordaConfig() => LocordaConfig( + resources: [ + ResourceConfig( + type: category.Category, + crdtMapping: Uri.parse( + 'https://locorda.dev/example/personal_notes_app/mappings/category-v1.ttl', + ), + indices: [FullIndex()], + ), + ResourceConfig( + type: note.Note, + crdtMapping: Uri.parse( + 'https://locorda.dev/example/personal_notes_app/mappings/note-v1.ttl', + ), + indices: [ + GroupIndex( + ngk.NoteGroupKey, + groupingProperties: [ + GroupingProperty( + const rdf_term.IriTerm('https://schema.org/dateCreated'), + transforms: [ + RegexTransform( + r'^([0-9]{4})-([0-9]{2})-([0-9]{2}).*', + r'${1}-${2}', + ), + ], + ), + ], + item: IndexItem(nie.NoteIndexEntry, { + const rdf_term.IriTerm('https://schema.org/name'), + const rdf_term.IriTerm('https://schema.org/dateCreated'), + const rdf_term.IriTerm('https://schema.org/dateModified'), + const rdf_term.IriTerm('https://schema.org/keywords'), + const rdf_term.IriTerm( + 'https://locorda.dev/example/personal_notes_app/vocabulary/personal-notes#belongsToCategory', + ), + }), + ), + ], + ), + ], +); diff --git a/packages/locorda/example/personal_notes_app/lib/main.dart b/packages/locorda/example/personal_notes_app/lib/main.dart index 4da17fa9..f2a7df0e 100644 --- a/packages/locorda/example/personal_notes_app/lib/main.dart +++ b/packages/locorda/example/personal_notes_app/lib/main.dart @@ -13,14 +13,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:locorda/locorda.dart'; import 'package:locorda_dir/locorda_dir.dart'; -import 'package:locorda_rdf_terms_schema/schema.dart'; import 'package:personal_notes_app/init_locorda.g.dart'; import 'package:personal_notes_app/locorda_worker.manifest.dart'; -import 'package:personal_notes_app/models/category.dart'; -import 'package:personal_notes_app/models/note.dart'; -import 'package:personal_notes_app/models/note_group_key.dart'; -import 'package:personal_notes_app/models/note_index_entry.dart'; -import 'package:personal_notes_app/vocabulary/personal_notes_vocab.dart'; import 'screens/notes_list_screen.dart'; import 'services/categories_service.dart'; @@ -28,8 +22,7 @@ import 'services/notes_service.dart'; import 'storage/database.dart' show AppDatabase; import 'storage/repositories.dart' show CategoryRepository, NoteRepository; import 'utils/logging_setup.dart'; - -const appBaseUrl = 'https://locorda.dev/example/personal_notes_app'; +import 'consts.dart' show appBaseUrl; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -84,41 +77,6 @@ Future initializeLocorda() async { driftWorker: Uri.parse('drift_worker.js'), ), ), - - /* resource-focused configuration */ - config: LocordaConfig( - resources: [ - // Configure Note resource with grouping index by category - ResourceConfig( - type: Note, - crdtMapping: Uri.parse('$appBaseUrl/mappings/note-v1.ttl'), - indices: [ - GroupIndex(NoteGroupKey, - item: IndexItem(NoteIndexEntry, { - SchemaNoteDigitalDocument.name, - SchemaNoteDigitalDocument.dateCreated, - SchemaNoteDigitalDocument.dateModified, - SchemaNoteDigitalDocument.keywords, - PersonalNotesVocab.belongsToCategory - }), - groupingProperties: [ - GroupingProperty(SchemaNoteDigitalDocument.dateCreated, - transforms: [ - RegexTransform(r'^([0-9]{4})-([0-9]{2})-([0-9]{2}).*', - r'${1}-${2}') - ]) - ]), - ], - ), - - // Configure Category resource with full index - ResourceConfig( - type: Category, - crdtMapping: Uri.parse('$appBaseUrl/mappings/category-v1.ttl'), - indices: [FullIndex(itemFetchPolicy: ItemFetchPolicy.prefetch)], - ), - ], - ), ); } diff --git a/packages/locorda/example/personal_notes_app/lib/models/category.dart b/packages/locorda/example/personal_notes_app/lib/models/category.dart index a995d950..5b6c0cc2 100644 --- a/packages/locorda/example/personal_notes_app/lib/models/category.dart +++ b/packages/locorda/example/personal_notes_app/lib/models/category.dart @@ -6,6 +6,7 @@ import 'package:locorda_rdf_mapper_annotations/annotations.dart'; import 'package:locorda_rdf_terms_schema/schema.dart'; import 'package:locorda_annotations/locorda_annotations.dart'; import '../vocabulary/personal_notes_vocab.dart'; +import '../consts.dart' show appBaseUrl; import 'category_display_settings.dart'; /// A category for organizing personal notes. @@ -17,7 +18,10 @@ import 'category_display_settings.dart'; /// - LWW-Register for name and description (last writer wins) /// - Immutable for creation date /// -@LcrdRootResource(PersonalNotesVocab.NotesCategory) +@LcrdRootResource( + PersonalNotesVocab.NotesCategory, + '$appBaseUrl/mappings/category-v1.ttl', +) class Category { /// Unique identifier for this category @RdfIriPart() diff --git a/packages/locorda/example/personal_notes_app/lib/models/category_display_settings.dart b/packages/locorda/example/personal_notes_app/lib/models/category_display_settings.dart index 277e4375..f0dd05a7 100644 --- a/packages/locorda/example/personal_notes_app/lib/models/category_display_settings.dart +++ b/packages/locorda/example/personal_notes_app/lib/models/category_display_settings.dart @@ -25,15 +25,9 @@ class CategoryDisplaySettings { @CrdtLwwRegister() final String? icon; - CategoryDisplaySettings({ - this.color, - this.icon, - }); + CategoryDisplaySettings({this.color, this.icon}); - CategoryDisplaySettings copyWith({ - String? color, - String? icon, - }) { + CategoryDisplaySettings copyWith({String? color, String? icon}) { return CategoryDisplaySettings( color: color ?? this.color, icon: icon ?? this.icon, diff --git a/packages/locorda/example/personal_notes_app/lib/models/comment.dart b/packages/locorda/example/personal_notes_app/lib/models/comment.dart index 70363c08..fa581a7b 100644 --- a/packages/locorda/example/personal_notes_app/lib/models/comment.dart +++ b/packages/locorda/example/personal_notes_app/lib/models/comment.dart @@ -14,10 +14,7 @@ import 'package:locorda_annotations/locorda_annotations.dart'; /// - Immutable for createdAt (creation timestamp) /// - LWW-Register for content (last writer wins) /// -@LcrdSubResource( - Schema.Comment, - SubIriStrategy("comment-{id}"), -) +@LcrdSubResource(Schema.Comment, SubIriStrategy("comment-{id}")) class Comment { /// Unique identifier for this comment (IRI fragment) @RdfIriPart() @@ -33,17 +30,10 @@ class Comment { @CrdtImmutable() final DateTime createdAt; - Comment({ - required this.id, - required this.content, - DateTime? createdAt, - }) : createdAt = createdAt ?? DateTime.now(); + Comment({required this.id, required this.content, DateTime? createdAt}) + : createdAt = createdAt ?? DateTime.now(); - Comment copyWith({ - String? id, - String? content, - DateTime? createdAt, - }) { + Comment copyWith({String? id, String? content, DateTime? createdAt}) { return Comment( id: id ?? this.id, content: content ?? this.content, diff --git a/packages/locorda/example/personal_notes_app/lib/models/note.dart b/packages/locorda/example/personal_notes_app/lib/models/note.dart index 7f9f9dc7..3aa17d65 100644 --- a/packages/locorda/example/personal_notes_app/lib/models/note.dart +++ b/packages/locorda/example/personal_notes_app/lib/models/note.dart @@ -8,14 +8,17 @@ import 'package:locorda_rdf_terms_schema/schema.dart'; import 'package:locorda_annotations/locorda_annotations.dart'; import '../vocabulary/personal_notes_vocab.dart'; import '../utils/optional.dart'; +import '../consts.dart' show appBaseUrl; import 'category.dart'; import 'weblink.dart'; import 'comment.dart'; class NoteCategoryProperty extends RdfProperty { const NoteCategoryProperty() - : super(PersonalNotesVocab.belongsToCategory, - iri: const LcrdRootResourceRef(Category)); + : super( + PersonalNotesVocab.belongsToCategory, + iri: const LcrdRootResourceRef(Category), + ); } /// A personal note with title, content, and tags. @@ -28,10 +31,11 @@ class NoteCategoryProperty extends RdfProperty { /// - OR-Set for tags (additions and removals merge) /// @LcrdRootResource( - PersonalNotesVocab.PersonalNote, - // by default, the fragment is "it", but we set it explicitly here - // to "note" instead for demonstration purposes - RootIriStrategy(RootIriConfig('note'))) + PersonalNotesVocab.PersonalNote, + '$appBaseUrl/mappings/note-v1.ttl', + iriStrategy: RootIriStrategy(RootIriConfig('note')), + fullIndex: LcrdFullIndex.disabled(), +) class Note { /// Unique identifier for this note @RdfIriPart() diff --git a/packages/locorda/example/personal_notes_app/lib/models/note_group_key.dart b/packages/locorda/example/personal_notes_app/lib/models/note_group_key.dart index 81612c85..25ba7f75 100644 --- a/packages/locorda/example/personal_notes_app/lib/models/note_group_key.dart +++ b/packages/locorda/example/personal_notes_app/lib/models/note_group_key.dart @@ -1,8 +1,22 @@ import 'package:locorda_rdf_mapper_annotations/annotations.dart'; import 'package:locorda_rdf_terms_schema/schema.dart'; import 'package:locorda_annotations/locorda_annotations.dart'; +import 'note.dart'; -@LcrdGroupKey() +@LcrdGroupKey( + Note, + groupingProperties: [ + LcrdGroupingProperty( + SchemaNoteDigitalDocument.dateCreated, + transforms: [ + LcrdRegexTransform( + r'^([0-9]{4})-([0-9]{2})-([0-9]{2}).*', + r'${1}-${2}', + ), + ], + ), + ], +) class NoteGroupKey { @RdfProperty(SchemaNoteDigitalDocument.dateCreated) final DateTime createdMonth; @@ -51,7 +65,7 @@ class NoteGroupKey { 'September', 'October', 'November', - 'December' + 'December', ]; if (month < 1 || month > 12) return formatted; diff --git a/packages/locorda/example/personal_notes_app/lib/models/note_index_entry.dart b/packages/locorda/example/personal_notes_app/lib/models/note_index_entry.dart index 95c53d00..24c6a119 100644 --- a/packages/locorda/example/personal_notes_app/lib/models/note_index_entry.dart +++ b/packages/locorda/example/personal_notes_app/lib/models/note_index_entry.dart @@ -7,6 +7,7 @@ library; import 'package:locorda_annotations/locorda_annotations.dart'; import 'package:personal_notes_app/models/note.dart'; +import 'package:personal_notes_app/models/note_group_key.dart'; import 'package:locorda_rdf_mapper_annotations/annotations.dart'; import 'package:locorda_rdf_terms_schema/schema.dart'; @@ -17,7 +18,7 @@ import 'package:locorda_rdf_terms_schema/schema.dart'; /// on-demand sync scenarios. /// /// No CRDT annotations needed for index entries, would be ignored anyways. -@LcrdIndexItem(IndexItemIriStrategy(Note)) +@LcrdIndexItem.groupIndex(NoteGroupKey, IndexItemIriStrategy(Note)) class NoteIndexEntry { /// Unique identifier for the note @RdfIriPart() diff --git a/packages/locorda/example/personal_notes_app/lib/models/weblink.dart b/packages/locorda/example/personal_notes_app/lib/models/weblink.dart index 0231c60e..e3ac3a23 100644 --- a/packages/locorda/example/personal_notes_app/lib/models/weblink.dart +++ b/packages/locorda/example/personal_notes_app/lib/models/weblink.dart @@ -15,9 +15,7 @@ import 'package:locorda_annotations/locorda_annotations.dart'; /// - Immutable for url (identifying property, cannot change) /// - LWW-Register for title and description (last writer wins) /// -@RdfLocalResource( - PersonalNotesVocab.Weblink, -) +@RdfLocalResource(PersonalNotesVocab.Weblink) class Weblink { /// The URL - this is the identifying property for this blank node @RdfProperty(Schema.url) @@ -35,17 +33,9 @@ class Weblink { @CrdtLwwRegister() final String? description; - Weblink({ - required this.url, - this.title, - this.description, - }); + Weblink({required this.url, this.title, this.description}); - Weblink copyWith({ - String? url, - String? title, - String? description, - }) { + Weblink copyWith({String? url, String? title, String? description}) { return Weblink( url: url ?? this.url, title: title ?? this.title, diff --git a/packages/locorda/example/personal_notes_app/lib/screens/note_editor_screen.dart b/packages/locorda/example/personal_notes_app/lib/screens/note_editor_screen.dart index 236fcfde..9f61a635 100644 --- a/packages/locorda/example/personal_notes_app/lib/screens/note_editor_screen.dart +++ b/packages/locorda/example/personal_notes_app/lib/screens/note_editor_screen.dart @@ -236,13 +236,14 @@ class _NoteEditorScreenState extends State { return const CircularProgressIndicator(); } final categories = snapshot.data ?? []; - final effectiveSelectedCategoryId = _selectedCategoryId != null && + final effectiveSelectedCategoryId = _selectedCategoryId != + null && categories.every( (category) => category.id != _selectedCategoryId) ? null : _selectedCategoryId; return DropdownButtonFormField( - value: effectiveSelectedCategoryId, + initialValue: effectiveSelectedCategoryId, decoration: const InputDecoration( labelText: 'Category', border: OutlineInputBorder(), diff --git a/packages/locorda/example/personal_notes_app/lib/vocabulary/personal_notes_vocab.dart b/packages/locorda/example/personal_notes_app/lib/vocabulary/personal_notes_vocab.dart index 535a6af9..5e9710d9 100644 --- a/packages/locorda/example/personal_notes_app/lib/vocabulary/personal_notes_vocab.dart +++ b/packages/locorda/example/personal_notes_app/lib/vocabulary/personal_notes_vocab.dart @@ -6,6 +6,7 @@ library; import 'package:locorda_rdf_core/core.dart'; +import '../consts.dart' show appBaseUrl; /// Constants for the Personal Notes vocabulary. /// @@ -13,8 +14,7 @@ import 'package:locorda_rdf_core/core.dart'; /// types for note organization that properly subclass Schema.org types. class PersonalNotesVocab { /// Base IRI for the Personal Notes vocabulary - static const baseIri = - 'https://locorda.dev/example/personal_notes_app/vocabulary/personal-notes#'; + static const baseIri = '$appBaseUrl/vocabulary/personal-notes#'; // Classes diff --git a/packages/locorda_annotations/lib/locorda_annotations.dart b/packages/locorda_annotations/lib/locorda_annotations.dart index 83eb6336..683928ce 100644 --- a/packages/locorda_annotations/lib/locorda_annotations.dart +++ b/packages/locorda_annotations/lib/locorda_annotations.dart @@ -13,6 +13,9 @@ export 'src/resource.dart' LcrdSubResource, LcrdGroupKey, LcrdIndexItem, + LcrdFullIndex, + LcrdGroupingProperty, + LcrdRegexTransform, RootIriStrategy, SubIriStrategy, IndexItemIriStrategy; diff --git a/packages/locorda_annotations/lib/src/resource.dart b/packages/locorda_annotations/lib/src/resource.dart index 62665273..d1e04710 100644 --- a/packages/locorda_annotations/lib/src/resource.dart +++ b/packages/locorda_annotations/lib/src/resource.dart @@ -12,22 +12,24 @@ const resourceIriVar = r'rootResourceIri'; class RootIriStrategy extends IriStrategy { const RootIriStrategy([RootIriConfig? config]) - : super.namedFactory( - resourceIriFactoryKey, - config ?? const RootIriConfig(), - // exposes the IRI of the Pod Resource as a potential provider to child resources - resourceIriVar); + : super.namedFactory( + resourceIriFactoryKey, + config ?? const RootIriConfig(), + // exposes the IRI of the Pod Resource as a potential provider to child resources + resourceIriVar, + ); } class SubIriStrategy extends IriStrategy { const SubIriStrategy(String fragmentTemplate) - : super.withFragment( - // references the parent resource IRI via the variable we expose in PodIriStrategy - // so that the subresource IRI can be constructed as {parentResourceIri}#fragment . - // Note: any fragment will be removed from the parent resource IRI automatically, - // so it is no problem at all if the parent resource IRI already has a fragment. - '{+$resourceIriVar}', - fragmentTemplate); + : super.withFragment( + // references the parent resource IRI via the variable we expose in PodIriStrategy + // so that the subresource IRI can be constructed as {parentResourceIri}#fragment . + // Note: any fragment will be removed from the parent resource IRI automatically, + // so it is no problem at all if the parent resource IRI already has a fragment. + '{+$resourceIriVar}', + fragmentTemplate, + ); } /// Annotation for RDF classes that represent resources stored in Solid Pods. @@ -61,7 +63,10 @@ class SubIriStrategy extends IriStrategy { /// ## Usage Example /// /// ```dart -/// @SolidPodResource() +/// @LcrdRootResource( +/// const IriTerm('https://example.org/Note'), +/// 'https://myapp.example.com/mappings/note-v1.ttl', +/// ) /// class Note extends RdfResource { /// @LwwRegister() /// late String title; @@ -78,7 +83,7 @@ class SubIriStrategy extends IriStrategy { /// /// ## CRDT Integration /// -/// Resources annotated with `@SolidPodResource()` automatically participate +/// Resources annotated with `@LcrdRootResource` automatically participate /// in CRDT-based conflict resolution when synchronized across multiple /// devices or users. Properties within the class should use appropriate /// CRDT annotations ([CrdtLwwRegister], [CrdtFwwRegister], [CrdtOrSet], [CrdtImmutable]) @@ -91,33 +96,43 @@ class SubIriStrategy extends IriStrategy { /// - CRDT merge logic for conflict resolution /// - Integration with Solid authentication and type indices /// - Serialization/deserialization methods for RDF storage +/// - LocordaConfig generation for automatic sync configuration /// /// See also: /// - [RdfGlobalResource] - The base annotation this extends /// - CRDT annotations: [CrdtLwwRegister], [CrdtFwwRegister], [CrdtOrSet], [CrdtImmutable] /// - [SyncEngine] - The main synchronization engine class LcrdRootResource extends RdfGlobalResource { - /// Creates a Solid Pod resource annotation. + /// Full absolute IRI identifying the CRDT mapping document. /// - /// This annotation inherits all functionality from [RdfGlobalResource] - /// and adds Solid-specific features for Pod-based resource management. + /// This is a static, app-owned IRI — fully known at compile time, + /// not dependent on any user or Pod URL. Use Dart const string + /// interpolation with a shared base constant to avoid repetition. /// - /// The [classIri] parameter defines the RDF type for this resource class. - /// The IRI strategy is configured globally when initializing the - /// [SyncEngine] system rather than per-annotation, providing consistent - /// IRI generation across all Solid Pod resources. + /// Example: `'$appBaseUrl/mappings/note-v1.ttl'` + /// where `const appBaseUrl = 'https://myapp.example.com';` + final String crdtMapping; + + /// Whether to auto-generate the CRDT mapping file from property annotations. /// - /// Example: - /// ```dart - /// @PodResource(const IriTerm('https://example.org/Note')) - /// class Note extends RdfResource { - /// @LwwRegister() - /// late String title; - /// } - /// ``` - const LcrdRootResource(IriTerm? classIri, - [RootIriStrategy iriStrategy = const RootIriStrategy()]) - : super(classIri, iriStrategy); + /// When `true` (default), the build system generates a `.ttl` file from + /// `@CrdtLwwRegister`, `@CrdtOrSet`, `@CrdtImmutable` annotations. + /// Set to `false` for manually authored mapping files. + final bool generateCrdtMapping; + + /// Configuration for the default FullIndex. + /// + /// Defaults to `LcrdFullIndex()` (enabled, localName='default', prefetch). + /// Use `LcrdFullIndex.disabled()` when only GroupIndex indices apply. + final LcrdFullIndex fullIndex; + + const LcrdRootResource( + IriTerm? classIri, + this.crdtMapping, { + RootIriStrategy iriStrategy = const RootIriStrategy(), + this.generateCrdtMapping = true, + this.fullIndex = const LcrdFullIndex(), + }) : super(classIri, iriStrategy); } class LcrdSubResource extends RdfGlobalResource { @@ -151,26 +166,105 @@ class LcrdSubResource extends RdfGlobalResource { /// } /// ``` const LcrdSubResource(IriTerm? classIri, SubIriStrategy iriStrategy) - : super(classIri, iriStrategy, registerGlobally: false); + : super(classIri, iriStrategy, registerGlobally: false); +} + +/// Configuration for the default FullIndex of a root resource. +/// +/// Controls whether a FullIndex is generated and its parameters. +/// Used as parameter in [LcrdRootResource.fullIndex]. +class LcrdFullIndex { + /// Whether FullIndex generation is enabled. + final bool isEnabled; + + /// Local name for the FullIndex (default: 'default'). + final String localName; + + /// Item fetch policy for the FullIndex. + final ItemFetchPolicy policy; + + /// Creates a FullIndex configuration with defaults. + const LcrdFullIndex({ + this.localName = 'default', + this.policy = ItemFetchPolicy.prefetch, + }) : isEnabled = true; + + /// Disables FullIndex generation for this resource. + /// Use when a resource only has GroupIndex indices. + const LcrdFullIndex.disabled() + : isEnabled = false, + localName = '', + policy = ItemFetchPolicy.prefetch; +} + +/// Defines a regex transformation applied to a grouping property value. +/// +/// Used within [LcrdGroupingProperty] to transform raw RDF values +/// (e.g., extracting year-month from a full date string). +class LcrdRegexTransform { + final String pattern; + final String replacement; + + const LcrdRegexTransform(this.pattern, this.replacement); +} + +/// Defines a property used for grouping in a GroupIndex, with optional transforms. +/// +/// The [property] IRI identifies which RDF predicate to group by. +/// Optional [transforms] apply regex transformations before grouping. +class LcrdGroupingProperty { + final IriTerm property; + final List transforms; + + const LcrdGroupingProperty(this.property, {this.transforms = const []}); } class IndexItemIriStrategy extends IriStrategy { const IndexItemIriStrategy(Type resourceType) - : super.namedFactory(indexItemIriFactoryKey, resourceType); + : super.namedFactory(indexItemIriFactoryKey, resourceType); } +/// Annotation for index item (entry) classes. +/// +/// Use [LcrdIndexItem.fullIndex] for FullIndex entries and +/// [LcrdIndexItem.groupIndex] for GroupIndex entries. class LcrdIndexItem extends RdfGlobalResource { - const LcrdIndexItem(IndexItemIriStrategy iriStrategy) - // create (and register) only a Deserializer, because the IndexItem classes - // are never serialized from dart to rdf - they are only deserialized. - : super.deserializeOnly( - // Save a bit of space and do not repeat the type of index entries over and over again - // Plus: since the IndexItem uses the same type as the root resource, we would - // risk messing up the rdf mapper if we used the same type here. - null, - iri: iriStrategy); + /// The GroupKey type this item belongs to, or `null` for FullIndex items. + final Type? groupKeyType; + + /// Creates a FullIndex item entry. + /// + /// The [iriStrategy] links back to the root resource type. + /// Due to Dart const-constructor limitations, `IndexItemIriStrategy` + /// must be passed as a parameter rather than constructed inline. + const LcrdIndexItem.fullIndex(IndexItemIriStrategy iriStrategy) + : groupKeyType = null, + super.deserializeOnly(null, iri: iriStrategy); + + /// Creates a GroupIndex item entry linked to a specific [groupKeyType]. + const LcrdIndexItem.groupIndex( + this.groupKeyType, + IndexItemIriStrategy iriStrategy, + ) : super.deserializeOnly(null, iri: iriStrategy); } +/// Annotation for GroupIndex key classes. +/// +/// Links a group key to its parent resource type and configures +/// the GroupIndex with an optional local name and grouping properties. class LcrdGroupKey extends RdfLocalResource { - const LcrdGroupKey(); + /// The resource type this group index is for. + final Type resourceType; + + /// Local name for this group index (default: 'default'). + final String? localName; + + /// Grouping property definitions with optional transforms. + final List groupingProperties; + + const LcrdGroupKey( + this.resourceType, { + this.localName, + this.groupingProperties = const [], + }); } diff --git a/packages/locorda_annotations/lib/src/resource_ref.dart b/packages/locorda_annotations/lib/src/resource_ref.dart index ac1ecf43..647ba72d 100644 --- a/packages/locorda_annotations/lib/src/resource_ref.dart +++ b/packages/locorda_annotations/lib/src/resource_ref.dart @@ -7,5 +7,5 @@ const resourceRefFactoryKey = r'$resourceRefFactory'; class LcrdRootResourceRef extends IriMapping { const LcrdRootResourceRef(Type cls) - : super.namedFactory(resourceRefFactoryKey, cls); + : super.namedFactory(resourceRefFactoryKey, cls); } diff --git a/packages/locorda_dev/build.yaml b/packages/locorda_dev/build.yaml index 99d3986e..8bd137dc 100644 --- a/packages/locorda_dev/build.yaml +++ b/packages/locorda_dev/build.yaml @@ -14,5 +14,6 @@ builders: - locorda_rdf_mapper_generator:source_builder - locorda_rdf_mapper_generator:init_file_builder - locorda_builder:worker_generator + - locorda_init_generator:locorda_config_generator - locorda_init_generator:init_locorda_generator - locorda_builder:web_worker diff --git a/packages/locorda_init_generator/build.yaml b/packages/locorda_init_generator/build.yaml index c30b5259..81a8f183 100644 --- a/packages/locorda_init_generator/build.yaml +++ b/packages/locorda_init_generator/build.yaml @@ -10,6 +10,19 @@ builders: required_inputs: - lib/worker_generated.g.dart # Optional: for worker detection - lib/init_rdf_mapper.g.dart # Optional: for mapper detection + - lib/locorda_config.g.dart # Optional: for config detection + defaults: + generate_for: + - pubspec.yaml + + locorda_config_generator: + import: "package:locorda_init_generator/builder.dart" + builder_factories: ["configBuilder"] + build_extensions: + pubspec.yaml: + - lib/locorda_config.g.dart + auto_apply: dependents + build_to: source defaults: generate_for: - pubspec.yaml diff --git a/packages/locorda_init_generator/lib/builder.dart b/packages/locorda_init_generator/lib/builder.dart index 66da1efe..2f7a05fc 100644 --- a/packages/locorda_init_generator/lib/builder.dart +++ b/packages/locorda_init_generator/lib/builder.dart @@ -5,3 +5,4 @@ library; export 'src/init_locorda_builder.dart'; +export 'src/config/config_builder.dart'; diff --git a/packages/locorda_init_generator/lib/src/code_generation/analyzer_utils.dart b/packages/locorda_init_generator/lib/src/code_generation/analyzer_utils.dart new file mode 100644 index 00000000..46d94958 --- /dev/null +++ b/packages/locorda_init_generator/lib/src/code_generation/analyzer_utils.dart @@ -0,0 +1,390 @@ +/// Analyzer utilities for converting DartType and DartObject to Code. +/// +/// Adapted from locorda_rdf_mapper_generator for use in locorda_init_generator. +library; + +import 'package:analyzer/dart/constant/value.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/nullability_suffix.dart'; +import 'package:analyzer/dart/element/type.dart'; + +import 'code.dart'; + +DartObject? getField(DartObject obj, String fieldName) { + final field = obj.getField(fieldName); + if (field != null && !field.isNull) { + return field; + } + final superInstance = obj.getField('(super)'); + if (superInstance == null) { + return null; + } + return getField(superInstance, fieldName); +} + +/// Converts a DartType to a Code instance with proper import tracking +Code typeToCode(DartType type, + {bool enforceNonNull = false, bool raw = false}) { + final typeAlias = type.alias; + if (typeAlias != null) { + final aliasElement = typeAlias.element; + var result = Code.type( + aliasElement.displayName, + importUri: _getImportUriForType(aliasElement), + ); + + if (!raw && typeAlias.typeArguments.isNotEmpty) { + result = result + + Code.genericParamsList( + typeAlias.typeArguments.map((arg) => typeToCode(arg)), + ); + } + + return _applyNullability( + result, + type.nullabilitySuffix, + enforceNonNull: enforceNonNull, + ); + } + + if (type is FunctionType) { + return _functionTypeToCode(type, enforceNonNull: enforceNonNull); + } + + if (type is RecordType) { + return _recordTypeToCode(type, enforceNonNull: enforceNonNull); + } + + // Handle generics recursively to preserve import information for type arguments + // Note: When raw=true, type arguments are omitted for raw type references + if (!raw && type is InterfaceType && type.typeArguments.isNotEmpty) { + final baseName = type.element.displayName; + final baseImportUri = _getImportUriForType(type.element); + + // Recursively convert type arguments + final typeArgCodes = type.typeArguments + .map((arg) => typeToCode(arg, enforceNonNull: false, raw: false)) + .toList(); + + // Build the complete generic type with Code.combine to preserve imports + final baseCode = Code.type(baseName, importUri: baseImportUri); + final genericParams = Code.genericParamsList(typeArgCodes); + + var result = Code.combine([baseCode, genericParams]); + + return _applyNullability( + result, + type.nullabilitySuffix, + enforceNonNull: enforceNonNull, + ); + } + + // Fallback for non-generic types or raw type references + var typeName = raw ? type.element?.displayName : null; + + typeName ??= type.getDisplayString(); + + // Normalize display name by removing trailing nullability marker. + // Nullability is applied centrally via _applyNullability to avoid `??`. + if (typeName.endsWith('?')) { + typeName = typeName.substring(0, typeName.length - 1); + } + final importUri = _getImportUriForType(type.element); + return _applyNullability( + Code.type(typeName, importUri: importUri), + type.nullabilitySuffix, + enforceNonNull: enforceNonNull, + ); +} + +Code _functionTypeToCode(FunctionType type, {required bool enforceNonNull}) { + final returnTypeCode = typeToCode(type.returnType); + + final params = []; + for (final parameter in type.formalParameters) { + final paramTypeCode = typeToCode(parameter.type); + final paramName = parameter.displayName; + final withName = paramName.isEmpty + ? paramTypeCode + : Code.combine([paramTypeCode, Code.literal(' $paramName')]); + + if (parameter.isNamed && parameter.isRequiredNamed) { + params.add(Code.literal('required ') + withName); + } else { + params.add(withName); + } + } + + final requiredPositionalCount = + type.formalParameters.where((param) => param.isRequiredPositional).length; + final optionalPositionalCount = + type.formalParameters.where((param) => param.isOptionalPositional).length; + final namedCount = + type.formalParameters.where((param) => param.isNamed).length; + + final requiredPositional = params.take(requiredPositionalCount).toList(); + final optionalPositional = params + .skip(requiredPositionalCount) + .take(optionalPositionalCount) + .toList(); + final named = + params.skip(requiredPositionalCount + optionalPositionalCount).toList(); + + final paramGroups = []; + paramGroups.addAll(requiredPositional); + if (optionalPositional.isNotEmpty) { + paramGroups.add( + Code.combine(optionalPositional, separator: ', ', pre: '[', post: ']'), + ); + } + if (named.isNotEmpty && namedCount > 0) { + paramGroups.add( + Code.combine(named, separator: ', ', pre: '{', post: '}'), + ); + } + + final typeFormals = type.typeParameters; + final typeFormalCode = typeFormals.isEmpty + ? Code.literal('') + : Code.genericParamsList( + typeFormals.map((typeFormal) { + final bound = typeFormal.bound; + if (bound == null) { + return Code.literal(typeFormal.displayName); + } + return Code.literal('${typeFormal.displayName} extends ') + + typeToCode(bound); + }), + ); + + var result = Code.combine([ + returnTypeCode, + Code.literal(' Function'), + typeFormalCode, + Code.combine(paramGroups, separator: ', ', pre: '(', post: ')'), + ]); + + return _applyNullability( + result, + type.nullabilitySuffix, + enforceNonNull: enforceNonNull, + ); +} + +Code _recordTypeToCode(RecordType type, {required bool enforceNonNull}) { + final positional = + type.positionalFields.map((field) => typeToCode(field.type)); + final named = type.namedFields.map( + (field) => + Code.combine([typeToCode(field.type), Code.literal(' ${field.name}')]), + ); + + final bodyParts = [...positional]; + if (named.isNotEmpty) { + bodyParts.add( + Code.combine(named, separator: ', ', pre: '{', post: '}'), + ); + } + + var result = Code.combine(bodyParts, separator: ', ', pre: '(', post: ')'); + + return _applyNullability( + result, + type.nullabilitySuffix, + enforceNonNull: enforceNonNull, + ); +} + +Code _applyNullability( + Code code, + NullabilitySuffix nullabilitySuffix, { + required bool enforceNonNull, +}) { + if (!enforceNonNull && nullabilitySuffix == NullabilitySuffix.question) { + return code + '?'; + } + return code; +} + +/// Converts a ClassElement to a Code instance +Code classToCode(ClassElement type) { + final typeName = type.displayName; + final importUri = _getImportUriForType(type); + return Code.type(typeName, importUri: importUri); +} + +/// Converts a EnumElement to a Code instance +Code enumToCode(EnumElement type) { + final typeName = type.displayName; + final importUri = _getImportUriForType(type); + return Code.type(typeName, importUri: importUri); +} + +/// Converts a DartObject to a Code instance with proper import tracking +/// +/// This function analyzes a compile-time constant value and generates the +/// corresponding Dart code along with any necessary import dependencies. +Code dartObjectToCode(DartObject? value) { + if (value == null || value.isNull) { + return Code.value('null'); + } + + if (value.type?.isDartCoreType == true) { + return typeToCode(value.toTypeValue()!); + } + + // Handle primitive types (no imports needed) + if (value.type?.isDartCoreBool == true) { + return Code.value(value.toBoolValue().toString()); + } + if (value.type?.isDartCoreInt == true) { + return Code.value(value.toIntValue().toString()); + } + if (value.type?.isDartCoreDouble == true) { + return Code.value(value.toDoubleValue().toString()); + } + if (value.type?.isDartCoreString == true) { + final str = value.toStringValue() ?? ''; + // Escape single quotes and wrap in single quotes + return Code.value("'${str.replaceAll("'", "\\'")}'"); + } + + final functionValue = value.toFunctionValue(); + if (functionValue != null) { + return _executableToCode(functionValue); + } + + // Handle enums - these need import tracking + final enumValue = value.getField('_name')?.toStringValue(); + if (enumValue != null && value.type?.element is EnumElement) { + final enumType = value.type!.getDisplayString(); + final importUri = _getImportUriForType(value.type!.element); + return Code.type('$enumType.$enumValue', importUri: importUri); + } + + // Handle lists + if (value.type?.isDartCoreList == true) { + final items = value.toListValue() ?? []; + final itemCodes = items.map((item) => dartObjectToCode(item)).toList(); + final combinedCode = Code.combine(itemCodes, separator: ', '); + return Code.combine([Code.value('['), combinedCode, Code.value(']')]); + } + + // Handle maps + if (value.type?.isDartCoreMap == true) { + final map = value.toMapValue() ?? {}; + final entryCodes = map.entries.map((entry) { + final keyCode = dartObjectToCode(entry.key); + final valueCode = dartObjectToCode(entry.value); + return Code.combine([keyCode, Code.value(': '), valueCode]); + }).toList(); + final combinedEntries = Code.combine(entryCodes, separator: ', '); + return Code.combine([Code.value('{'), combinedEntries, Code.value('}')]); + } + + // Handle sets + if (value.type?.isDartCoreSet == true) { + final set = value.toSetValue() ?? {}; + final itemCodes = set.map((item) => dartObjectToCode(item)).toList(); + final combinedItems = Code.combine(itemCodes, separator: ', '); + return Code.combine([Code.value('{'), combinedItems, Code.value('}')]); + } + + // Handle records + final recordValue = value.toRecordValue(); + if (recordValue != null) { + final positional = + recordValue.positional.map((item) => dartObjectToCode(item)).toList(); + final named = recordValue.named.entries + .map((entry) => + Code.value('${entry.key}: ') + dartObjectToCode(entry.value)) + .toList(); + + final parts = [...positional]; + if (named.isNotEmpty) { + parts.add(Code.combine(named, separator: ', ', pre: '{', post: '}')); + } + return Code.combine(parts, separator: ', ', pre: '(', post: ')'); + } + + // Handle const constructors + var typeElement = value.type?.element; + if (typeElement is ClassElement) { + for (final constructor in typeElement.constructors) { + final fields = constructor.formalParameters; + + if (constructor.isConst) { + final constructorName = constructor.displayName; + final positionalArgCodes = []; + final namedArgCodes = []; + + // Separate positional and named parameters + for (final field in fields) { + final fieldName = field.name; + if (fieldName == null) continue; + + final fieldValue = value.getField(fieldName); + if (fieldValue != null) { + final fieldCode = dartObjectToCode(fieldValue); + if (field.isNamed) { + // Named parameter: paramName: value + namedArgCodes + .add(Code.combine([Code.value('$fieldName: '), fieldCode])); + } else { + // Positional parameter: just the value + positionalArgCodes.add(fieldCode); + } + } + } + + // Combine positional and named arguments + final allArgCodes = []; + allArgCodes.addAll(positionalArgCodes); + allArgCodes.addAll(namedArgCodes); + + final importUri = _getImportUriForType(typeElement); + + return Code.combine([ + Code.literal('const '), + Code.type(constructorName, importUri: importUri), + Code.paramsList(allArgCodes), + ]); + } + } + } + + // Fallback to string representation if type is not recognized + return Code.value(value.toStringValue() ?? value.toString()); +} + +Code _executableToCode(ExecutableElement executable) { + final importUri = _getImportUriForType(executable); + + if (executable is ConstructorElement) { + final className = executable.enclosingElement.displayName; + final constructorName = executable.displayName; + final qualifiedName = + constructorName.isEmpty ? className : '$className.$constructorName'; + return Code.type(qualifiedName, importUri: importUri); + } + + final enclosingElement = executable.enclosingElement; + if (enclosingElement is InterfaceElement) { + return Code.type( + '${enclosingElement.displayName}.${executable.displayName}', + importUri: importUri, + ); + } + + return Code.type(executable.displayName, importUri: importUri); +} + +/// Determines the import URI for a given type element +String? _getImportUriForType(Element? element) { + if (element == null) return null; + + final source = element.library?.identifier; + if (source == null) return null; + + return source.toString(); +} diff --git a/packages/locorda_init_generator/lib/src/code_generation/code.dart b/packages/locorda_init_generator/lib/src/code_generation/code.dart new file mode 100644 index 00000000..c317f033 --- /dev/null +++ b/packages/locorda_init_generator/lib/src/code_generation/code.dart @@ -0,0 +1,440 @@ +/// Code generation utilities for managing imports and aliases. +/// +/// Adapted from locorda_rdf_mapper_generator for use in locorda_init_generator. +library; + +/// Represents generated code with its import dependencies and type aliases. +/// +/// This class manages code generation where types might come from different +/// packages/libraries and need to be properly imported and aliased in the +/// target file to avoid naming conflicts. +/// +/// ## Propagation and Resolution +/// Code objects are designed to be propagated as-is through the data layer +/// without being converted to strings until the final template rendering stage. +/// This ensures that: +/// 1. Import information is preserved throughout the processing pipeline +/// 2. Aliases are correctly resolved with the final import context +/// 3. Type references maintain their import dependencies +class Code { + static const String typeMarker = r'$Code$'; + static const String typeProperty = '__type__'; + + final String _code; + final Set _imports; // Import URIs only + + // Special markers to safely identify aliases in code - these are invalid Dart syntax + static const String _aliasStartMarker = '⟨@'; + static const String _aliasEndMarker = '@⟩'; + + const Code._(this._code, this._imports); + + Map toMap() { + return { + 'code': _code, + 'imports': _imports.toList(), + typeProperty: typeMarker, + }; + } + + static Code fromMap(Map map) { + assert(map[typeProperty] == typeMarker, 'Invalid map for Code: $map'); + return Code._( + map['code'] as String, + Set.from(map['imports'] as List), + ); + } + + /// Creates a Code instance with the given code string and no imports + const Code.literal(String code) : this._(code, const {}); + + /// Creates a Code instance for a simple value that doesn't require imports + const Code.value(String code) : this.literal(code); + + /// Creates a Code instance for a type reference that may require imports + factory Code.type(String typeName, {String? importUri}) { + if (importUri == null) { + // No import needed - this is a built-in type or already available + return Code.literal(typeName); + } + + return Code._('${_wrapImportUri(importUri)}$typeName', {importUri}); + } + + /// Combines multiple Code instances to a parameter list + factory Code.paramsList(Iterable params) => + Code.combine(params, separator: ', ', pre: '(', post: ')'); + + factory Code.genericParamsList(Iterable params) => + Code.combine(params, separator: ', ', pre: '<', post: '>'); + + factory Code.combine(Iterable codes, + {String separator = '', String? pre, String? post}) { + assert(pre == null && post == null || pre != null && post != null, + 'pre and post must be both provided or both null'); + if (codes.isEmpty && pre == null && post == null) return Code.literal(''); + if (codes.length == 1 && pre == null && post == null) return codes.first; + + final combinedImports = codes.expand((c) => c._imports).toSet(); + + // Build the combined code by joining each code's internal representation + String combinedCode = + '${pre ?? ''}${codes.map((c) => c._code).join(separator)}${post ?? ''}'; + + return Code._(combinedCode, combinedImports); + } + + Code operator +(Object other) => Code.combine( + [this, other is Code ? other : Code.literal(other.toString())]); + + /// The generated code string + String get code => resolveAliases().$1; + + /// All import dependencies required by this code + Set get imports => Set.unmodifiable(_imports); + + /// Checks if this code has any import dependencies + bool get hasImports => _imports.isNotEmpty; + + // The code without any import aliases, suitable for pure code generation or if + // you are just interested in the name of a type for example. + // To get the pure class name without imports, we resolve aliases + // and use the class name without any import prefixes. + String get codeWithoutAlias => resolveAliases( + knownImports: + Map.fromIterable(imports, key: (v) => v, value: (v) => '')).$1; + + /// Resolves alias markers in code to actual aliases + /// Returns a record with the resolved code and a map of import URIs to aliases + (String, Map) resolveAliases( + {Map knownImports = const {}, + Map broaderImports = const {}}) { + String resolvedCode = _code; + final importsWithAlias = {}; + + // Track which aliases are already used to avoid conflicts + final usedAliases = {}; + usedAliases.addAll(knownImports.values); + + for (final originalImportUri in _imports) { + final importUri = broaderImports[originalImportUri] ?? originalImportUri; + String alias; + + if (knownImports.containsKey(importUri)) { + // Use the known alias + alias = knownImports[importUri]!; + } else { + // Generate a new alias, ensuring it doesn't conflict + alias = _generateAliasFromUri(importUri); + if (alias.isNotEmpty) { + int counter = 2; + while (usedAliases.contains(alias)) { + alias = '${_generateAliasFromUri(importUri)}$counter'; + counter++; + } + } + usedAliases.add(alias); + } + + importsWithAlias[importUri] = alias; + final marker = _wrapImportUri(originalImportUri); + resolvedCode = + resolvedCode.replaceAll(marker, alias.isEmpty ? '' : '$alias.'); + } + + return (resolvedCode, importsWithAlias); + } + + /// Generates a default alias from an import URI + static String _generateAliasFromUri(String uri) { + if (uri.startsWith('package:') || + uri.startsWith('asset:') || + uri.startsWith('file:')) { + // Extract filename from URI: package:foo/bar/baz.dart -> baz, asset:foo/bar.dart -> bar + final prefixLength = uri.split(":")[0].length + 1; // +1 for the colon + final parts = uri.substring(prefixLength).split('/'); + if (parts.isNotEmpty) { + final lastPart = parts.last; + // Remove .dart extension if present + final aliasName = lastPart.endsWith('.dart') + ? lastPart.substring(0, lastPart.length - 5) + : lastPart; + return _sanitizeAlias(aliasName); + } + } else if (uri.startsWith('dart:')) { + if (uri == 'dart:core') { + // Special case for dart:core - no alias needed + return ''; + } + // dart:core -> core + return _sanitizeAlias(uri.substring(5)); + } + + // Fallback: use a generic alias + return 'lib${uri.hashCode.abs()}'; + } + + /// Wraps an import URI with special markers for safe replacement + static String _wrapImportUri(String importUri) { + return '$_aliasStartMarker$importUri$_aliasEndMarker'; + } + + /// Sanitizes an alias to ensure it's a valid Dart identifier + /// If the result is too long, shortens it by using first letters of underscore-separated parts + static String _sanitizeAlias(String input) { + final sanitized = input.replaceAll(RegExp(r'[^a-zA-Z0-9_]'), '_'); + + // If alias is reasonably short, return as is + if (sanitized.length <= 12) { + return sanitized; + } + + // For long aliases, use first letter of each underscore-separated part + final parts = sanitized.split('_').where((part) => part.isNotEmpty); + if (parts.length > 1) { + final abbreviated = parts.map((part) => part[0].toLowerCase()).join(); + return abbreviated.isNotEmpty ? abbreviated : sanitized; + } + + // For single long words without underscores, truncate to reasonable length + return sanitized.length > 12 ? sanitized.substring(0, 12) : sanitized; + } + + @override + String toString() => _code; + + @override + bool operator ==(Object other) => + identical(this, other) || other is Code && _code == other._code; + + @override + int get hashCode => _code.hashCode; + + Code _namedParam(Object name, Object value) { + return Code.combine([ + Code.literal('$name'), + Code.literal(': '), + _toCode(value), + ]); + } + + Code _toCode(Object args) => switch (args) { + Code c => c, + String s => Code.literal(s), + Map m => Code.combine( + m.entries.map((entry) => _namedParam(entry.key, entry.value)), + separator: ', ', + pre: '{', + post: '}', + ), + Set s => Code.combine( + s.map((item) => _toCode(item)), + separator: ', ', + pre: '{', + post: '}', + ), + Iterable i => Code.combine( + i.map((item) => _toCode(item)), + separator: ', ', + pre: '[', + post: ']', + ), + _ => Code.literal(args.toString()), + }; + + Iterable _toToplevelParams(Object args) sync* { + if (args is Map) { + // For top-level params, we want to yield each entry as a separate param (e.g. for named function arguments) + for (final entry in args.entries) { + yield _namedParam(entry.key, entry.value); + } + } else if (args is Iterable) { + // For top-level params, we want to yield each item directly (e.g. for function arguments) + for (final item in args) { + yield _toCode(item); + } + } else { + throw ArgumentError( + 'Top-level params must be a Map or an Iterable, was ${args.runtimeType}'); + } + } + + /// Generates a constructor invocation with optional positional and named arguments. + /// + /// Combines this Code (typically a type reference) with constructor arguments. + /// Supports four call patterns: + /// - No args: `SomeClass()` - use `.newInstance()` + /// - Positional only: `SomeClass(arg1, arg2)` - use `.newInstance([arg1, arg2])` + /// - Named only: `SomeClass(param: value)` - use `.newInstance({'param': value})` + /// - Mixed: `SomeClass(arg1, param: value)` - use `.newInstance([arg1], {'param': value})` + /// + /// Example usage (actual patterns from config_code_generator.dart): + /// ```dart + /// // No args: generateLocordaConfig() + /// Code.type('generateLocordaConfig', importUri: '...').newInstance() + /// + /// // Named args only: LocordaConfig(resources: [...]) + /// Code.type('LocordaConfig', importUri: pkg).newInstance({'resources': list}) + /// + /// // Positional + named: GroupIndex(NoteGroupKey, localName: 'byDate') + /// Code.type('GroupIndex', importUri: pkg).newInstance( + /// [groupKeyClass], + /// {'localName': Code.value("'byDate'")} + /// ) + /// + /// // Positional only: IndexItem(NoteIndexEntry, {propertySet}) + /// Code.type('IndexItem', importUri: pkg).newInstance([itemClass, propSet]) + /// ``` + Code newInstance([Object args = const [], Map namedArgs = const {}]) { + if (namedArgs.isEmpty) { + return this + Code.paramsList(_toToplevelParams(args)); + } + return this + + Code.paramsList( + [..._toToplevelParams(args), ..._toToplevelParams(namedArgs)]); + } + + /// Generates a method invocation on this Code object. + /// + /// Appends `.methodName(args)` or `.methodName(args, namedArgs)` to generate method calls. + /// Supports positional args (as List or Map) and optional named args (as Map). + /// + /// Example usage (actual patterns from code_generator.dart and config_code_generator.dart): + /// ```dart + /// // Locorda.create(config: configCode, storage: storageCode) + /// Code.type('Locorda', importUri: pkg).call('create', { + /// 'config': configCode, + /// 'storage': storageCode, + /// }) + /// + /// // Uri.parse('https://...') + /// core('Uri').call('parse', [ + /// Code.value("'https://example.com/mapping.ttl'"), + /// ]) + /// ``` + Code call(String methodName, + [Object args = const [], Map namedArgs = const {}]) { + if (namedArgs.isEmpty) { + return this + + Code.literal('.' + methodName) + + Code.paramsList(_toToplevelParams(args)); + } + return this + + Code.literal('.' + methodName) + + Code.paramsList( + [..._toToplevelParams(args), ..._toToplevelParams(namedArgs)]); + } + + /// Generates field/enum access on this Code object. + /// + /// Appends `.fieldName` for accessing static fields, enum values, + /// or object properties. + /// + /// Example usage (actual patterns from config_code_generator.dart): + /// ```dart + /// // ItemFetchPolicy.prefetch + /// Code.type('ItemFetchPolicy', importUri: locordaCorePkg).field('prefetch') + /// + /// // ItemFetchPolicy.onRequest + /// Code.type('ItemFetchPolicy', importUri: locordaCorePkg).field('onRequest') + /// ``` + Code field(String fieldName) => this + Code.literal('.' + fieldName); + + /// Generates a generic type with type parameters. + /// + /// Appends `` to create generic type references. + /// + /// Example usage (actual patterns from code_generator.dart): + /// ```dart + /// // Future + /// core('Future').withGenericParams([ + /// Code.type('Locorda', importUri: locordaPkg), + /// ]) + /// + /// // List + /// core('List').withGenericParams([ + /// Code.type('ResourceConfig', importUri: locordaObjectsPkg), + /// ]) + /// ``` + Code withGenericParams(List list) => + this + Code.genericParamsList(list); +} + +const importDartCore = 'dart:core'; + +class CodeResolver { + final Map _broaderImports = {}; + final Map _knownImports = {}; + final Map _usedImports = {}; + bool _finalized = false; + CodeResolver._( + {Map broaderImports = const {}, + Map knownImports = const {}}) { + _broaderImports.addAll(broaderImports); + _knownImports.addAll(knownImports); + } + Map get usedImports => Map.unmodifiable(_usedImports); + + static String toDartFileContent( + String header, Map importAliases, Code body, + {Map broaderImports = const {}}) { + CodeResolver codeResolver = CodeResolver._( + broaderImports: broaderImports, knownImports: importAliases); + StringBuffer buffer = StringBuffer(header); + buffer.writeln(); + final codeBody = codeResolver._writeCode(body); + codeResolver._writeImports(buffer); + buffer.write(codeBody); + return buffer.toString(); + } + + void _writeImports(StringBuffer buffer) { + _finalized = true; + // Add all imports with their aliases + final sortedImports = _usedImports.keys.toSet().toList()..sort(); + + for (final importUri in sortedImports) { + final alias = _usedImports[importUri]; + if (alias != null && alias.isNotEmpty) { + buffer.writeln("import '$importUri' as $alias;"); + } else { + buffer.writeln("import '$importUri';"); + } + } + buffer.writeln(); + } + + String _writeCode(Code code) { + if (_finalized) { + throw StateError( + 'Cannot resolve code after finalization. All imports should be resolved before finalization.'); + } + final (resolvedCode, moreImports) = code.resolveAliases( + knownImports: _knownImports, broaderImports: _broaderImports); + + // Update knownImports with new imports + _knownImports.addAll(moreImports); + _usedImports.addAll(moreImports); + return resolvedCode; + } +} + +/// Convenience function for creating Code instances for dart:core types. +/// +/// Shorthand for `Code.type(typeName, importUri: 'dart:core')`. +/// Use this for built-in Dart types like Future, List, Map, Uri, etc. +/// +/// Example usage: +/// ```dart +/// // Future +/// core('Future').withGenericParams([locordaType]) +/// +/// // List +/// core('List').withGenericParams([core('String')]) +/// +/// // Uri.parse(...) +/// core('Uri').call('parse', [urlString]) +/// ``` +Code core(String typeName) { + return Code.type(typeName, importUri: importDartCore); +} diff --git a/packages/locorda_init_generator/lib/src/code_generation/dart_formatter.dart b/packages/locorda_init_generator/lib/src/code_generation/dart_formatter.dart new file mode 100644 index 00000000..ed239fa4 --- /dev/null +++ b/packages/locorda_init_generator/lib/src/code_generation/dart_formatter.dart @@ -0,0 +1,58 @@ +import 'dart:io' show Platform; + +import 'package:dart_style/dart_style.dart'; +import 'package:logging/logging.dart'; +import 'package:pub_semver/pub_semver.dart'; + +final _log = Logger('DartFormatter'); + +/// Extracts the language version from the current Dart SDK version. +/// Returns the major.minor version (e.g., "3.6" from "3.6.0"). +Version _getCurrentLanguageVersion() { + // Platform.version format: "3.10.0 (stable) (Thu Nov 6 05:24:55 2025 -0800) on \"macos_arm64\"" + final versionString = Platform.version.split(' ').first; + final version = Version.parse(versionString); + // Use major.minor only for language version, ignore patch + return Version(version.major, version.minor, 0); +} + +/// Interface for formatting Dart code. +abstract class CodeFormatter { + /// Formats the given Dart code. + /// + /// Returns the formatted code on success, or the original unformatted code + /// if formatting fails. + String formatCode(String code); +} + +/// Implementation of CodeFormatter using dart_style. +class DartCodeFormatter implements CodeFormatter { + final DartFormatter _formatter; + + DartCodeFormatter({DartFormatter? formatter}) + : _formatter = formatter ?? + DartFormatter( + languageVersion: _getCurrentLanguageVersion(), + ); + + @override + String formatCode(String code) { + try { + return _formatter.format(code); + } catch (e, stackTrace) { + _log.warning( + 'Failed to format generated Dart code: $e', + e, + stackTrace, + ); + // Return unformatted code as fallback to avoid build failures + return code; + } + } +} + +/// No-op formatter for testing or when formatting is disabled. +class NoOpCodeFormatter implements CodeFormatter { + @override + String formatCode(String code) => code; +} diff --git a/packages/locorda_init_generator/lib/src/code_generator.dart b/packages/locorda_init_generator/lib/src/code_generator.dart index 97aa953a..17bcdbaf 100644 --- a/packages/locorda_init_generator/lib/src/code_generator.dart +++ b/packages/locorda_init_generator/lib/src/code_generator.dart @@ -1,166 +1,130 @@ +import 'code_generation/code.dart'; +import 'code_generation/dart_formatter.dart'; import 'parameter_info.dart'; +const _locordaFlutterImport = 'package:locorda_flutter/locorda_flutter.dart'; +const _workerGeneratedImport = 'worker_generated.g.dart'; +const _initRdfMapperImport = 'init_rdf_mapper.g.dart'; +const _locordaConfigImport = 'locorda_config.g.dart'; + +Code locordaFlutter(String name) => imported(name, _locordaFlutterImport); + +Code imported(String name, String importUri) => + Code.type(name, importUri: importUri); + /// Generates the initLocorda.g.dart file. class CodeGenerator { final bool hasGeneratedWorker; final bool hasInitMapper; + final bool hasGeneratedConfig; final List locordaParams; final List mapperParams; final Set detectedFrameworkParams; - final Set additionalImports; - const CodeGenerator({ + final CodeFormatter _formatter; + + CodeGenerator({ required this.hasGeneratedWorker, required this.hasInitMapper, + required this.hasGeneratedConfig, required this.locordaParams, required this.mapperParams, required this.detectedFrameworkParams, - required this.additionalImports, - }); + CodeFormatter? formatter, + }) : _formatter = formatter ?? DartCodeFormatter(); /// Generate the complete initLocorda.g.dart content. - String generate() { - final buffer = StringBuffer(); - - // Header - buffer.writeln('// GENERATED CODE - DO NOT MODIFY BY HAND'); - buffer.writeln('// ignore_for_file: unused_import, depend_on_referenced_packages'); - buffer.writeln(); - - // Imports - _writeImports(buffer); - buffer.writeln(); - - // Documentation - _writeDocumentation(buffer); - - // Function signature - _writeFunctionSignature(buffer); - - // Function body - _writeFunctionBody(buffer); - - return buffer.toString(); - } - - void _writeImports(StringBuffer buffer) { - buffer.writeln("import 'package:locorda_flutter/locorda_flutter.dart';"); - - if (hasGeneratedWorker) { - buffer.writeln("import 'worker_generated.g.dart' show generatedWorkerSetup;"); - } - - if (hasInitMapper) { - buffer.writeln("import 'init_rdf_mapper.g.dart' show initRdfMapper;"); - } - - final sortedImports = additionalImports.toList()..sort(); - for (final importLine in sortedImports) { - final normalized = importLine.trim(); - if (normalized.isEmpty) { - continue; - } - if (normalized == "import 'package:locorda_flutter/locorda_flutter.dart';") { - continue; - } - buffer.writeln(normalized.endsWith(';') ? normalized : '$normalized;'); - } - } + String generate() => _formatter.formatCode( + CodeResolver.toDartFileContent( + ''' +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: unused_import, depend_on_referenced_packages, unnecessary_import, implementation_imports - void _writeDocumentation(StringBuffer buffer) { - buffer.writeln('/// Convenience wrapper for Locorda.create with auto-detected settings.'); - buffer.writeln('///'); - buffer.writeln('/// Auto-configures:'); - - if (hasGeneratedWorker) { - buffer.writeln('/// - workerSetup: generatedWorkerSetup (from worker_generated.g.dart)'); - buffer.writeln("/// - jsScript: 'worker_generated.dart.js'"); - } - - if (hasInitMapper) { - buffer.writeln('/// - mapperInitializer: Generated from initRdfMapper'); - } - } +/// Convenience wrapper for Locorda.create with auto-detected settings. +/// +/// Auto-configures: +${[ + if (hasGeneratedWorker) ...[ + '/// - workerSetup: generatedWorkerSetup (from worker_generated.g.dart)', + "/// - jsScript: 'worker_generated.dart.js'" + ], + if (hasInitMapper) + '/// - mapperInitializer: Generated from initRdfMapper', + if (hasGeneratedConfig) + '/// - config: Generated from annotations via generateLocordaConfig()', + ].join('\n')} - void _writeFunctionSignature(StringBuffer buffer) { - buffer.writeln('Future initLocorda({'); - - // Add custom mapper params first - for (final param in mapperParams) { - _writeParameter(buffer, param); - } - - // Add Locorda.create params (filtered) - final filteredParams = _filterLocordaParams(); - for (final param in filteredParams) { - _writeParameter(buffer, param); - } - - buffer.writeln('}) async {'); - } +library; +''', + { + _locordaFlutterImport: '', + if (hasGeneratedWorker) _workerGeneratedImport: 'wrk', + if (hasInitMapper) _initRdfMapperImport: 'mpr', + if (hasGeneratedConfig) _locordaConfigImport: 'cfg', + }, + _newInitLocordaFunction(), + ), + ); - void _writeParameter(StringBuffer buffer, ParameterInfo param) { - final parts = []; - - if (param.isRequired) parts.add('required'); - parts.add(param.type); - parts.add(param.name); - - final line = ' ${parts.join(' ')}'; - if (param.defaultValue != null) { - buffer.writeln('$line = ${param.defaultValue},'); - } else { - buffer.writeln('$line,'); - } - } + Code _newInitLocordaFunction() => + core('Future').withGenericParams([locordaFlutter('Locorda')]) + + Code.combine( + pre: 'initLocorda ({', + [...mapperParams, ..._filterLocordaParams()].map(_parameterInfoToCode), + separator: ',', + post: '}) async => ', + ) + + locordaFlutter('Locorda').call('create', { + if (hasGeneratedWorker) ...{ + 'workerSetup': + imported('generatedWorkerSetup', _workerGeneratedImport), + 'jsScript': Code.value("'worker_generated.dart.js'"), + }, + if (hasInitMapper) + 'mapperInitializer': Code.literal('(context) => ') + + imported('initRdfMapper', _initRdfMapperImport).newInstance({ + 'rdfMapper': Code.value('context.baseRdfMapper'), + for (final frameworkParam in detectedFrameworkParams) + frameworkParam: + Code.value('context.${frameworkParam.substring(1)}'), + for (final param in mapperParams) + param.name: Code.value(param.name), + }), + if (hasGeneratedConfig) + 'config': imported('generateLocordaConfig', _locordaConfigImport) + .newInstance(), + for (final param in _filterLocordaParams()) + param.name: Code.value(param.name), + }) + + ';'; List _filterLocordaParams() { return locordaParams.where((param) { // Remove auto-configured params - if (hasGeneratedWorker && (param.name == 'workerSetup' || param.name == 'jsScript')) { + if (hasGeneratedWorker && + (param.name == 'workerSetup' || param.name == 'jsScript')) { return false; } if (hasInitMapper && param.name == 'mapperInitializer') { return false; } + if (hasGeneratedConfig && param.name == 'config') { + return false; + } return true; }).toList(); } - void _writeFunctionBody(StringBuffer buffer) { - buffer.writeln(' return Locorda.create('); - - // Auto-configured params - if (hasGeneratedWorker) { - buffer.writeln(' workerSetup: generatedWorkerSetup,'); - buffer.writeln(" jsScript: 'worker_generated.dart.js',"); - } - - if (hasInitMapper) { - buffer.writeln(' mapperInitializer: (context) => initRdfMapper('); - buffer.writeln(' rdfMapper: context.baseRdfMapper,'); - - // Framework params - for (final frameworkParam in detectedFrameworkParams) { - final contextParam = frameworkParam.substring(1); // Remove $ - buffer.writeln(' $frameworkParam: context.$contextParam,'); - } - - // Custom params - for (final param in mapperParams) { - buffer.writeln(' ${param.name}: ${param.name},'); - } - - buffer.writeln(' ),'); - } - - // Pass-through params - final filteredParams = _filterLocordaParams(); - for (final param in filteredParams) { - buffer.writeln(' ${param.name}: ${param.name},'); - } - - buffer.writeln(' );'); - buffer.writeln('}'); - } + Code _parameterInfoToCode(ParameterInfo param) => Code.combine( + [ + if (param.isRequired) Code.literal('required '), + param.type, + Code.literal(' '), + Code.literal(param.name), + if (param.defaultValue != null) ...[ + Code.literal(' = '), + param.defaultValue! + ], + ], + ); } diff --git a/packages/locorda_init_generator/lib/src/config/annotation_data.dart b/packages/locorda_init_generator/lib/src/config/annotation_data.dart new file mode 100644 index 00000000..9942f62f --- /dev/null +++ b/packages/locorda_init_generator/lib/src/config/annotation_data.dart @@ -0,0 +1,99 @@ +/// Data classes for extracted annotation information. +library; + +import '../code_generation/code.dart'; + +/// Immutable data extracted from @LcrdRootResource annotations. +class RootResourceData { + final Code className; + final Code? classIri; + + /// Code for the crdtMapping IRI with proper import tracking. + /// Preserves const interpolation and handles imports automatically. + final Code crdtMapping; + final bool generateCrdtMapping; + final FullIndexData fullIndex; + + const RootResourceData({ + required this.className, + required this.classIri, + required this.crdtMapping, + required this.generateCrdtMapping, + required this.fullIndex, + }); +} + +class FullIndexData { + final bool isEnabled; + final String localName; + final String policy; // 'prefetch' | 'onRequest' + + const FullIndexData({ + required this.isEnabled, + required this.localName, + required this.policy, + }); +} + +/// Immutable data extracted from @LcrdGroupKey annotations. +class GroupKeyData { + final Code className; + final Code resourceTypeName; + final String? localName; + final List groupingProperties; + + const GroupKeyData({ + required this.className, + required this.resourceTypeName, + required this.localName, + required this.groupingProperties, + }); +} + +class GroupingPropertyData { + final Code property; + final List transforms; + + const GroupingPropertyData({ + required this.property, + required this.transforms, + }); +} + +class RegexTransformData { + final String pattern; + final String replacement; + + const RegexTransformData({required this.pattern, required this.replacement}); +} + +/// Immutable data extracted from @LcrdIndexItem annotations. +class IndexItemData { + final Code? className; + + /// Resource type name from IndexItemIriStrategy + final Code resourceTypeName; + + /// GroupKey type name — null for FullIndex items + final Code? groupKeyTypeName; + + /// Set of property IRIs (extracted from @RdfProperty fields) + final List properties; + + const IndexItemData({ + required this.className, + required this.resourceTypeName, + required this.groupKeyTypeName, + required this.properties, + }); + + bool get isFullIndexItem => groupKeyTypeName == null; + bool get isGroupIndexItem => groupKeyTypeName != null; +} + +class IndexPropertyData { + /// The Code for the IRI term with proper import tracking + final Code code; + + const IndexPropertyData({required this.code}); +} diff --git a/packages/locorda_init_generator/lib/src/config/annotation_scanner.dart b/packages/locorda_init_generator/lib/src/config/annotation_scanner.dart new file mode 100644 index 00000000..668c4184 --- /dev/null +++ b/packages/locorda_init_generator/lib/src/config/annotation_scanner.dart @@ -0,0 +1,350 @@ +/// Scans Dart source files for Locorda config annotations. +library; + +import 'package:analyzer/dart/constant/value.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:logging/logging.dart'; + +import '../code_generation/analyzer_utils.dart'; +import '../code_generation/code.dart'; +import 'annotation_data.dart'; + +final _log = Logger('AnnotationScanner'); + +/// Scans Dart source files for Locorda config annotations. +/// +/// Uses the Element model (via `buildStep.resolver.libraryFor()`) for +/// annotation detection and extraction. All constant values are accessed +/// through `DartObject` methods, avoiding the need for AST parsing. +class AnnotationScanner { + /// Scans a resolved library for config-relevant annotations. + /// + /// [libraryElement] - resolved library from `buildStep.resolver` + /// [importUri] - package URI for generated imports + ScanResult scanLibrary( + LibraryElement libraryElement, + String importUri, + ) { + final rootResources = []; + final groupKeys = []; + final indexItems = []; + + // Scan all classes in the library + for (final element in libraryElement.classes) { + // Check for @LcrdRootResource + final rootResource = _scanRootResource( + element, + importUri, + ); + if (rootResource != null) { + rootResources.add(rootResource); + } + + // Check for @LcrdGroupKey + final groupKey = _scanGroupKey(element, importUri); + if (groupKey != null) { + groupKeys.add(groupKey); + } + + // Check for @LcrdIndexItem + final indexItem = _scanIndexItem(element, importUri); + if (indexItem != null) { + indexItems.add(indexItem); + } + } + + return ScanResult( + rootResources: rootResources, + groupKeys: groupKeys, + indexItems: indexItems, + ); + } + + RootResourceData? _scanRootResource( + ClassElement element, + String importUri, + ) { + // Find @LcrdRootResource annotation + for (final annotation in element.metadata.annotations) { + final annotationValue = annotation.computeConstantValue(); + if (annotationValue == null) continue; + + final annotationType = annotationValue.type?.element?.name; + if (annotationType != 'LcrdRootResource') continue; + + // Extract classIri (first argument) as Code + final classIriField = getField(annotationValue, 'classIri'); + Code? classIri; + if (classIriField != null && !classIriField.isNull) { + classIri = dartObjectToCode(classIriField); + } + + // Extract crdtMapping (second argument) as Code + final crdtMappingField = getField(annotationValue, 'crdtMapping'); + if (crdtMappingField == null) { + _log.warning( + 'Could not extract crdtMapping for ${element.displayName}'); + continue; + } + final crdtMapping = dartObjectToCode(crdtMappingField); + + // Extract generateCrdtMapping + final generateCrdtMapping = + getField(annotationValue, 'generateCrdtMapping')?.toBoolValue() ?? + true; + + // Extract fullIndex + final fullIndexField = getField(annotationValue, 'fullIndex'); + final fullIndexData = _extractFullIndexData(fullIndexField); + + return RootResourceData( + className: classToCode(element), + classIri: classIri, + crdtMapping: crdtMapping, + generateCrdtMapping: generateCrdtMapping, + fullIndex: fullIndexData, + ); + } + + return null; + } + + GroupKeyData? _scanGroupKey( + ClassElement element, + String importUri, + ) { + // Find @LcrdGroupKey annotation + for (final annotation in element.metadata.annotations) { + final annotationValue = annotation.computeConstantValue(); + if (annotationValue == null) continue; + + final annotationType = annotationValue.type?.element?.name; + if (annotationType != 'LcrdGroupKey') continue; + + // Extract resourceType + final resourceTypeField = getField(annotationValue, 'resourceType'); + final resourceTypeName = resourceTypeField?.toTypeValue()?.element; + + if (resourceTypeName is! ClassElement) { + _log.warning( + 'Could not extract resourceType for GroupKey ${element.displayName}'); + continue; + } + + // Extract localName + final localName = getField(annotationValue, 'localName')?.toStringValue(); + + // Extract groupingProperties + final groupingPropertiesField = + getField(annotationValue, 'groupingProperties'); + final groupingProperties = + _extractGroupingProperties(groupingPropertiesField); + + return GroupKeyData( + className: classToCode(element), + resourceTypeName: classToCode(resourceTypeName), + localName: localName, + groupingProperties: groupingProperties, + ); + } + + return null; + } + + IndexItemData? _scanIndexItem( + ClassElement element, + String importUri, + ) { + // Find @LcrdIndexItem annotation + for (final annotation in element.metadata.annotations) { + final annotationValue = annotation.computeConstantValue(); + if (annotationValue == null) continue; + + final annotationType = annotationValue.type?.element?.name; + if (annotationType != 'LcrdIndexItem') continue; + + // Extract groupKeyType (null for fullIndex) + final groupKeyTypeField = getField(annotationValue, 'groupKeyType'); + final groupKeyTypeName = + groupKeyTypeField?.toTypeValue()?.element as ClassElement?; + + // Extract resourceType from IndexItemIriStrategy + // The `iri` field in RdfGlobalResource contains the IndexItemIriStrategy + ClassElement? resourceTypeName; + final iriField = getField(annotationValue, 'iri'); + + if (iriField != null && !iriField.isNull) { + // The iriField is an IndexItemIriStrategy, which extends IriStrategy + // which extends BaseMapping. The resource Type is stored in + // BaseMapping._factoryConfigInstance field + final configField = getField(iriField, '_factoryConfigInstance'); + + if (configField != null) { + final typeValue = configField.toTypeValue(); + resourceTypeName = typeValue?.element as ClassElement?; + } + } + + if (resourceTypeName is! ClassElement) { + _log.warning( + 'Could not extract resourceType for IndexItem ${element.displayName}'); + continue; + } + + // Extract properties from fields + final properties = []; + for (final field in element.fields) { + for (final fieldAnnotation in field.metadata.annotations) { + final fieldAnnotationValue = fieldAnnotation.computeConstantValue(); + if (fieldAnnotationValue == null) continue; + + // Check if this is RdfProperty or a subclass + if (_matchesAnnotationInHierarchy( + fieldAnnotationValue.type, 'RdfProperty')) { + // Extract property IRI as Code with proper import tracking + final predicateField = getField(fieldAnnotationValue, 'predicate'); + if (predicateField != null) { + final propertyCode = dartObjectToCode(predicateField); + properties.add(IndexPropertyData(code: propertyCode)); + } + } + } + } + + // Warn if no properties found + if (properties.isEmpty) { + _log.warning( + 'IndexItem ${element.displayName} has no @RdfProperty fields - generating empty property set'); + } + + return IndexItemData( + className: classToCode(element), + resourceTypeName: classToCode(resourceTypeName), + groupKeyTypeName: + groupKeyTypeName == null ? null : classToCode(groupKeyTypeName), + properties: properties, + ); + } + + return null; + } + + /// Checks if the given type or any of its supertypes match the target annotation name. + /// Supports annotation subclassing by walking up the inheritance hierarchy. + bool _matchesAnnotationInHierarchy( + DartType? type, String targetAnnotationName) { + if (type == null) return false; + final visitedTypes = {}; + return _checkTypeHierarchy(type, targetAnnotationName, visitedTypes); + } + + bool _checkTypeHierarchy( + DartType type, String targetAnnotationName, Set visitedTypes) { + final typeName = type.element?.name; + if (typeName == null || visitedTypes.contains(typeName)) return false; + visitedTypes.add(typeName); + if (typeName == targetAnnotationName) return true; + if (type is InterfaceType) { + for (final supertype in type.allSupertypes) { + if (_checkTypeHierarchy( + supertype, targetAnnotationName, visitedTypes)) { + return true; + } + } + } + return false; + } + + FullIndexData _extractFullIndexData(DartObject? fullIndexField) { + if (fullIndexField == null) { + return const FullIndexData( + isEnabled: true, + localName: 'default', + policy: 'prefetch', + ); + } + + final isEnabled = + getField(fullIndexField, 'isEnabled')?.toBoolValue() ?? true; + final localName = + getField(fullIndexField, 'localName')?.toStringValue() ?? 'default'; + + // Extract policy enum value + final policyField = getField(fullIndexField, 'policy'); + String policyStr = 'prefetch'; + if (policyField != null) { + // The policy field is an ItemFetchPolicy enum, extract its name + final policyType = policyField.type; + if (policyType is InterfaceType) { + final policyTypeName = policyType.element.name; + if (policyTypeName == 'Prefetch') { + policyStr = 'prefetch'; + } else if (policyTypeName == 'OnRequest') { + policyStr = 'onRequest'; + } + } + } + + return FullIndexData( + isEnabled: isEnabled, + localName: localName, + policy: policyStr, + ); + } + + List _extractGroupingProperties( + DartObject? groupingPropertiesField) { + if (groupingPropertiesField == null) return []; + + final propertiesList = groupingPropertiesField.toListValue(); + if (propertiesList == null) return []; + + final result = []; + for (final propertyObj in propertiesList) { + final propertyField = getField(propertyObj, 'property'); + if (propertyField == null) continue; + + // Extract property as Code with proper import tracking + final property = dartObjectToCode(propertyField); + + // Extract transforms + final transformsField = getField(propertyObj, 'transforms'); + final transforms = []; + if (transformsField != null) { + final transformsList = transformsField.toListValue(); + if (transformsList != null) { + for (final transformObj in transformsList) { + final pattern = + getField(transformObj, 'pattern')?.toStringValue() ?? ''; + final replacement = + getField(transformObj, 'replacement')?.toStringValue() ?? ''; + transforms.add(RegexTransformData( + pattern: pattern, + replacement: replacement, + )); + } + } + } + + result.add(GroupingPropertyData( + property: property, + transforms: transforms, + )); + } + + return result; + } +} + +class ScanResult { + final List rootResources; + final List groupKeys; + final List indexItems; + + const ScanResult({ + required this.rootResources, + required this.groupKeys, + required this.indexItems, + }); +} diff --git a/packages/locorda_init_generator/lib/src/config/config_builder.dart b/packages/locorda_init_generator/lib/src/config/config_builder.dart new file mode 100644 index 00000000..6f4ddaa0 --- /dev/null +++ b/packages/locorda_init_generator/lib/src/config/config_builder.dart @@ -0,0 +1,91 @@ +/// Builder that generates lib/locorda_config.g.dart +library; + +import 'dart:async'; +import 'package:build/build.dart'; +import 'package:glob/glob.dart'; +import 'package:logging/logging.dart'; + +import 'annotation_scanner.dart'; +import 'config_code_generator.dart'; +import 'annotation_data.dart'; + +final _log = Logger('ConfigBuilder'); + +/// Builder that generates lib/locorda_config.g.dart +/// +/// Scans all .dart files in the consumer package's lib/ directory for +/// @LcrdRootResource, @LcrdGroupKey, and @LcrdIndexItem annotations, +/// then generates a LocordaConfig factory function. +class ConfigBuilder implements Builder { + final BuilderOptions options; + + ConfigBuilder(this.options); + + @override + Map> get buildExtensions => { + 'pubspec.yaml': ['lib/locorda_config.g.dart'], + }; + + @override + Future build(BuildStep buildStep) async { + _log.fine('Starting config generation for ${buildStep.inputId.package}'); + + // 1. Find all .dart files in lib/ + final dartFiles = await buildStep + .findAssets(Glob('lib/**.dart')) + .where((id) => !id.path.endsWith('.g.dart')) + .toList(); + + _log.fine('Found ${dartFiles.length} Dart files to scan'); + + // 2. Aggregate scan results + final allRootResources = []; + final allGroupKeys = []; + final allIndexItems = []; + + for (final assetId in dartFiles) { + try { + // Resolve library for annotation analysis + final library = await buildStep.resolver.libraryFor(assetId); + + // Determine import URI + final importUri = assetId.uri.toString(); + + // Scan the library + final scanner = AnnotationScanner(); + final result = scanner.scanLibrary(library, importUri); + + allRootResources.addAll(result.rootResources); + allGroupKeys.addAll(result.groupKeys); + allIndexItems.addAll(result.indexItems); + } catch (e, stackTrace) { + _log.warning('Error scanning $assetId: $e', e, stackTrace); + } + } + + _log.fine( + 'Scanned: ${allRootResources.length} root resources, ${allGroupKeys.length} group keys, ${allIndexItems.length} index items', + ); + + // 3. Generate config code + final generator = ConfigCodeGenerator( + rootResources: allRootResources, + groupKeys: allGroupKeys, + indexItems: allIndexItems, + ); + + final output = generator.generate(); + + // 4. Write output + final outputId = AssetId( + buildStep.inputId.package, + 'lib/locorda_config.g.dart', + ); + await buildStep.writeAsString(outputId, output); + + _log.fine('Generated locorda_config.g.dart'); + } +} + +Builder configBuilder(BuilderOptions options) => ConfigBuilder(options); diff --git a/packages/locorda_init_generator/lib/src/config/config_code_generator.dart b/packages/locorda_init_generator/lib/src/config/config_code_generator.dart new file mode 100644 index 00000000..e3b1712d --- /dev/null +++ b/packages/locorda_init_generator/lib/src/config/config_code_generator.dart @@ -0,0 +1,153 @@ +/// Generates locorda_config.g.dart from collected annotation data. +library; + +import '../code_generation/dart_formatter.dart'; +import '../code_generation/code.dart'; +import 'annotation_data.dart'; + +const _locordaObjectsImport = 'package:locorda_objects/locorda_objects.dart'; +const _locordaCoreImport = 'package:locorda_core/locorda_core.dart'; + +Code locordaObjects(String name) { + return Code.type(name, importUri: _locordaObjectsImport); +} + +Code locordaCore(String name) { + return Code.type(name, importUri: _locordaCoreImport); +} + +/// Generates locorda_config.g.dart from collected annotation data. +class ConfigCodeGenerator { + final List rootResources; + final List groupKeys; + final List indexItems; + final CodeFormatter _formatter; + + ConfigCodeGenerator({ + required this.rootResources, + required this.groupKeys, + required this.indexItems, + CodeFormatter? formatter, + }) : _formatter = formatter ?? DartCodeFormatter(); + + /// Generates the complete file content. + String generate() => _formatter.formatCode(CodeResolver.toDartFileContent( + ''' + // GENERATED CODE - DO NOT MODIFY BY HAND + // ignore_for_file: unused_import, depend_on_referenced_packages, unnecessary_import, implementation_imports + + /// Generated LocordaConfig from annotations. + /// + /// All crdtMapping IRIs are static, app-owned, absolute IRIs + /// fully determined at compile time from annotation values. + library; + ''', + { + _locordaObjectsImport: '', + _locordaCoreImport: '', + }, + locordaObjects('LocordaConfig') + + ' generateLocordaConfig() => ' + + locordaObjects('LocordaConfig').newInstance({ + 'resources': + rootResources.map((r) => _newResourceConfig(r)).toList(), + }) + + ';', + )); + + bool _isEqual(Code a, Code b) { + // Simple equality check based on the generated code string + return a.code == b.code; + } + + Code _newResourceConfig(RootResourceData resource) { + final resourceGroupKeys = groupKeys + .where((gk) => _isEqual(gk.resourceTypeName, resource.className)); + final hasFullIndex = resource.fullIndex.isEnabled; + + return locordaObjects('ResourceConfig').newInstance( + { + "type": resource.className, + "crdtMapping": core('Uri').call('parse', [ + resource.crdtMapping, + ]), + if (hasFullIndex || resourceGroupKeys.isNotEmpty) + // Find indices for this resource + 'indices': [ + if (hasFullIndex) _newFullIndex(resource), + ...resourceGroupKeys.map((gk) => _newGroupIndex(gk)) + ], + }, + ); + } + + Code _newFullIndex(RootResourceData resource) { + // Find FullIndex item + final fullIndexItem = indexItems.firstWhere( + (item) => + _isEqual(item.resourceTypeName, resource.className) && + item.isFullIndexItem, + orElse: () => IndexItemData( + className: null, + resourceTypeName: resource.className, + groupKeyTypeName: null, + properties: const [], + ), + ); + return locordaObjects('FullIndex').newInstance( + { + if (resource.fullIndex.localName != 'default') + "localName": resource.fullIndex.localName, + if (fullIndexItem.className != null && + fullIndexItem.properties.isNotEmpty) + 'item': locordaObjects('IndexItem').newInstance([ + fullIndexItem.className!, + fullIndexItem.properties.map((prop) => prop.code).toSet(), + ]), + if (resource.fullIndex.policy != 'prefetch') + 'itemFetchPolicy': locordaCore('ItemPrefetchPolicy') + .field(resource.fullIndex.policy), + }, + ); + } + + Code _newGroupIndex(GroupKeyData groupKey) { + // Find GroupIndex item + final groupIndexItem = indexItems.firstWhere( + (item) => _isEqual(item.groupKeyTypeName!, groupKey.className), + orElse: () => IndexItemData( + className: null, + resourceTypeName: groupKey.resourceTypeName, + groupKeyTypeName: groupKey.className, + properties: const [], + ), + ); + + return locordaObjects('GroupIndex').newInstance([ + groupKey.className + ], { + if (groupKey.localName != null && groupKey.localName != 'default') + "localName": groupKey.localName, + if (groupKey.groupingProperties.isNotEmpty) + 'groupingProperties': groupKey.groupingProperties.map((prop) { + return locordaCore('GroupingProperty').newInstance([ + prop.property + ], { + if (prop.transforms.isNotEmpty) + 'transforms': prop.transforms.map((transform) { + return locordaCore('RegexTransform').newInstance([ + "r'${transform.pattern}'", + "r'${transform.replacement}'", + ]); + }).toList(), + }); + }).toList(), + if (groupIndexItem.className != null && + groupIndexItem.properties.isNotEmpty) + 'item': locordaObjects('IndexItem').newInstance([ + groupIndexItem.className!, + groupIndexItem.properties.map((prop) => prop.code).toSet(), + ]), + }); + } +} diff --git a/packages/locorda_init_generator/lib/src/init_locorda_builder.dart b/packages/locorda_init_generator/lib/src/init_locorda_builder.dart index 472b659b..77748389 100644 --- a/packages/locorda_init_generator/lib/src/init_locorda_builder.dart +++ b/packages/locorda_init_generator/lib/src/init_locorda_builder.dart @@ -1,7 +1,6 @@ import 'dart:async'; -import 'package:analyzer/dart/analysis/utilities.dart'; -import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/element/element.dart'; import 'package:build/build.dart'; import 'package:logging/logging.dart'; @@ -53,6 +52,12 @@ class InitLocordaBuilder implements Builder { ); _log.fine('Has init_rdf_mapper.g.dart: $hasInitMapper'); + // Step 2b: Detect locorda_config.g.dart + final hasGeneratedConfig = await buildStep.canRead( + AssetId(inputId.package, 'lib/locorda_config.g.dart'), + ); + _log.fine('Has locorda_config.g.dart: $hasGeneratedConfig'); + // Step 3: Analyze Locorda.create parameters dynamically final locordaAnalysis = await _analyzeLocordaSource(buildStep); final locordaParams = locordaAnalysis.params; @@ -60,29 +65,25 @@ class InitLocordaBuilder implements Builder { // Step 4: Analyze initRdfMapper signature (if exists) List mapperParams = []; Set detectedFrameworkParams = {}; - final additionalImports = {}; if (hasInitMapper) { final mapperAnalyzer = MapperAnalyzer(buildStep, inputId.package); final result = await mapperAnalyzer.analyzeInitRdfMapper(); mapperParams = result.customParams; detectedFrameworkParams = result.frameworkParams; - additionalImports.addAll(result.imports); _log.fine('Found ${mapperParams.length} custom mapper params'); _log.fine( 'Found ${detectedFrameworkParams.length} framework params: $detectedFrameworkParams'); } - additionalImports.addAll(locordaAnalysis.imports); - // Step 5: Generate code final generator = CodeGenerator( hasGeneratedWorker: hasGeneratedWorker, hasInitMapper: hasInitMapper, + hasGeneratedConfig: hasGeneratedConfig, locordaParams: locordaParams, mapperParams: mapperParams, detectedFrameworkParams: detectedFrameworkParams, - additionalImports: additionalImports, ); final generatedCode = generator.generate(); @@ -101,11 +102,9 @@ class InitLocordaBuilder implements Builder { class _LocordaSourceAnalysis { final List params; - final Set imports; const _LocordaSourceAnalysis({ required this.params, - required this.imports, }); } @@ -116,44 +115,38 @@ Future<_LocordaSourceAnalysis> _analyzeLocordaSource( throw StateError('locorda_flutter/lib/src/locorda.dart not found'); } - final content = await buildStep.readAsString(assetId); - final parseResult = parseString(content: content); - final unit = parseResult.unit; - - final imports = {}; - for (final directive in unit.directives) { - if (directive is ImportDirective) { - imports.add(directive.toSource()); - } - } - - final params = _extractLocordaCreateParameters(unit); + final library = await buildStep.resolver.libraryFor(assetId); + final params = _extractLocordaCreateParameters(library); return _LocordaSourceAnalysis( params: params, - imports: imports, ); } -List _extractLocordaCreateParameters(CompilationUnit unit) { - for (final declaration in unit.declarations) { - if (declaration is ClassDeclaration && - declaration.name.lexeme == 'Locorda') { - for (final member in declaration.members) { - if (member is MethodDeclaration && member.name.lexeme == 'create') { - final params = member.parameters; - if (params == null) { - throw StateError('Locorda.create has no parameters'); - } - return parseParameterList(params); - } - } - } +List _extractLocordaCreateParameters(LibraryElement library) { + final locordaClass = library.classes + .where((element) => element.displayName == 'Locorda') + .firstOrNull; + + if (locordaClass == null) { + throw StateError('Locorda class not found in locorda.dart'); } - throw StateError('Locorda.create method not found in locorda.dart'); + final createMethod = locordaClass.methods + .where((method) => method.displayName == 'create' && method.isStatic) + .firstOrNull; + + if (createMethod == null) { + throw StateError('Locorda.create method not found in locorda.dart'); + } + + return parseParameterElements(createMethod.formalParameters); } /// Builder factory for build_runner integration. Builder initLocordaBuilder(BuilderOptions options) => InitLocordaBuilder(options); + +extension on Iterable { + T? get firstOrNull => isEmpty ? null : first; +} diff --git a/packages/locorda_init_generator/lib/src/mapper_analyzer.dart b/packages/locorda_init_generator/lib/src/mapper_analyzer.dart index 5ef42f52..a3416255 100644 --- a/packages/locorda_init_generator/lib/src/mapper_analyzer.dart +++ b/packages/locorda_init_generator/lib/src/mapper_analyzer.dart @@ -1,5 +1,4 @@ -import 'package:analyzer/dart/analysis/utilities.dart'; -import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/element/element.dart'; import 'package:build/build.dart'; import 'package:logging/logging.dart'; @@ -24,76 +23,56 @@ class MapperAnalyzer { return const MapperAnalysisResult( customParams: [], frameworkParams: {}, - imports: {}, ); } - final content = await buildStep.readAsString(assetId); - try { - return _parseInitRdfMapper(content); + final library = await buildStep.resolver.libraryFor(assetId); + return _analyzeLibrary(library); } catch (e) { _log.warning('Failed to parse init_rdf_mapper.g.dart: $e'); return const MapperAnalysisResult( customParams: [], frameworkParams: {}, - imports: {}, ); } } - MapperAnalysisResult _parseInitRdfMapper(String content) { - final parseResult = parseString(content: content); - final unit = parseResult.unit; + MapperAnalysisResult _analyzeLibrary(LibraryElement library) { + final function = library.topLevelFunctions + .where((element) => element.displayName == 'initRdfMapper') + .firstOrNull; - // Find initRdfMapper function - FunctionDeclaration? initRdfMapperFunc; - for (final declaration in unit.declarations) { - if (declaration is FunctionDeclaration && - declaration.name.lexeme == 'initRdfMapper') { - initRdfMapperFunc = declaration; - break; - } - } - - if (initRdfMapperFunc == null) { + if (function == null) { _log.warning( 'initRdfMapper function not found in init_rdf_mapper.g.dart'); return const MapperAnalysisResult( customParams: [], frameworkParams: {}, - imports: {}, ); } final customParams = []; final frameworkParams = {}; - final imports = {}; - for (final directive in unit.directives) { - if (directive is ImportDirective) { - imports.add(directive.toSource()); - } - } - - final parameters = initRdfMapperFunc.functionExpression.parameters; - if (parameters != null) { - final parsedParams = parseParameterList(parameters); - for (final param in parsedParams) { - if (param.name == 'rdfMapper' || param.name.startsWith(r'$')) { - if (param.name.startsWith(r'$')) { - frameworkParams.add(param.name); - } - continue; + final parsedParams = parseParameterElements(function.formalParameters); + for (final param in parsedParams) { + if (param.name == 'rdfMapper' || param.name.startsWith(r'$')) { + if (param.name.startsWith(r'$')) { + frameworkParams.add(param.name); } - customParams.add(param); + continue; } + customParams.add(param); } return MapperAnalysisResult( customParams: customParams, frameworkParams: frameworkParams, - imports: imports, ); } } + +extension on Iterable { + TopLevelFunctionElement? get firstOrNull => isEmpty ? null : first; +} diff --git a/packages/locorda_init_generator/lib/src/parameter_info.dart b/packages/locorda_init_generator/lib/src/parameter_info.dart index 73649312..1e985d4c 100644 --- a/packages/locorda_init_generator/lib/src/parameter_info.dart +++ b/packages/locorda_init_generator/lib/src/parameter_info.dart @@ -1,10 +1,12 @@ +import 'package:locorda_init_generator/src/code_generation/code.dart'; + /// Information about a function parameter. class ParameterInfo { final String name; - final String type; + final Code type; final bool isRequired; final bool isNamed; - final String? defaultValue; + final Code? defaultValue; final String? documentation; const ParameterInfo({ @@ -29,11 +31,9 @@ class ParameterInfo { class MapperAnalysisResult { final List customParams; final Set frameworkParams; - final Set imports; const MapperAnalysisResult({ required this.customParams, required this.frameworkParams, - required this.imports, }); } diff --git a/packages/locorda_init_generator/lib/src/parameter_parser.dart b/packages/locorda_init_generator/lib/src/parameter_parser.dart index 9095ca0b..297da31d 100644 --- a/packages/locorda_init_generator/lib/src/parameter_parser.dart +++ b/packages/locorda_init_generator/lib/src/parameter_parser.dart @@ -1,11 +1,14 @@ -import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'code_generation/analyzer_utils.dart'; +import 'code_generation/code.dart'; import 'parameter_info.dart'; -List parseParameterList(FormalParameterList parameters) { +List parseParameterElements( + Iterable parameters) { final result = []; - for (final param in parameters.parameters) { + for (final param in parameters) { final normalized = _normalizeParameter(param); if (normalized == null) { continue; @@ -16,55 +19,26 @@ List parseParameterList(FormalParameterList parameters) { return result; } -ParameterInfo? _normalizeParameter(FormalParameter param) { - if (param is DefaultFormalParameter) { - final normalParam = param.parameter; - final base = _normalizeParameter(normalParam); - if (base == null) { - return null; - } - return ParameterInfo( - name: base.name, - type: base.type, - isRequired: param.isRequired, - isNamed: param.isNamed, - defaultValue: param.defaultValue?.toSource(), - documentation: base.documentation, - ); - } - - if (param is SimpleFormalParameter) { - final name = param.name?.lexeme; - if (name == null) { - return null; - } - return ParameterInfo( - name: name, - type: param.type?.toSource() ?? 'dynamic', - isRequired: param.isRequired, - isNamed: param.isNamed, - defaultValue: null, - ); +ParameterInfo? _normalizeParameter(FormalParameterElement param) { + final name = param.displayName; + if (name.isEmpty) { + return null; } - if (param is FunctionTypedFormalParameter) { - return ParameterInfo( - name: param.name.lexeme, - type: _functionTypeToSource(param), - isRequired: param.isRequired, - isNamed: param.isNamed, - defaultValue: null, - ); - } - - return null; -} - -String _functionTypeToSource(FunctionTypedFormalParameter param) { - final returnType = param.returnType?.toSource() ?? 'dynamic'; - final paramList = param.parameters.toSource(); - final source = param.toSource().trim(); - final isNullable = source.endsWith('?'); - final suffix = isNullable ? '?' : ''; - return '$returnType Function$paramList$suffix'; + final isNamed = param.isNamed; + final isRequired = + param.isRequiredNamed || (!isNamed && param.isRequiredPositional); + + final defaultValue = param.defaultValueCode == null + ? null + : Code.value(param.defaultValueCode!); + + return ParameterInfo( + name: name, + type: typeToCode(param.type), + isRequired: isRequired, + isNamed: isNamed, + defaultValue: defaultValue, + documentation: param.documentationComment, + ); } diff --git a/packages/locorda_init_generator/pubspec.yaml b/packages/locorda_init_generator/pubspec.yaml index 3e3a9b24..ef94ac25 100644 --- a/packages/locorda_init_generator/pubspec.yaml +++ b/packages/locorda_init_generator/pubspec.yaml @@ -12,6 +12,8 @@ dependencies: # See: https://github.com/flutter/flutter/blob/stable/packages/flutter_tools/lib/src/web/compile.dart analyzer: '>=8.1.0 <11.0.0' build: ^4.0.4 + dart_style: ">=2.3.7 <4.0.0" + glob: ^2.1.2 logging: ^1.2.0 path: ^1.9.1 source_gen: ^4.2.0 diff --git a/packages/locorda_init_generator/test/analyzer_utils_test.dart b/packages/locorda_init_generator/test/analyzer_utils_test.dart new file mode 100644 index 00000000..4f8e744b --- /dev/null +++ b/packages/locorda_init_generator/test/analyzer_utils_test.dart @@ -0,0 +1,204 @@ +import 'dart:io'; + +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/dart/analysis/utilities.dart'; +import 'package:locorda_init_generator/src/code_generation/analyzer_utils.dart'; +import 'package:test/test.dart'; + +void main() { + group('typeToCode', () { + test('converts FunctionType with named parameters and keeps imports', + () async { + final tempDir = + await Directory.systemTemp.createTemp('locorda_init_generator_test_'); + addTearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + final typesFile = File('${tempDir.path}/types.dart'); + await typesFile.writeAsString('class Custom {}\n'); + + final mainFile = File('${tempDir.path}/main.dart'); + await mainFile.writeAsString(''' +import 'types.dart'; + +class Holder { + final String Function(Custom input, {required Custom other})? callback; + const Holder(this.callback); +} +'''); + + final result = await resolveFile(path: mainFile.path); + final resolved = result as ResolvedUnitResult; + final holder = resolved.libraryElement.classes + .firstWhere((element) => element.displayName == 'Holder'); + final callbackField = + holder.fields.firstWhere((field) => field.displayName == 'callback'); + + final code = typeToCode(callbackField.type); + final typesUri = typesFile.uri.toString(); + + expect(code.code, contains('Function(')); + expect(code.code, contains('required')); + expect(code.code, contains('Custom')); + expect(code.code, contains('?')); + expect(code.imports.any((uri) => uri.contains('types.dart')), isTrue); + expect(code.imports.contains(typesUri), isTrue); + }); + + test('converts RecordType and keeps imports for field types', () async { + final tempDir = + await Directory.systemTemp.createTemp('locorda_init_generator_test_'); + addTearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + final typesFile = File('${tempDir.path}/types.dart'); + await typesFile.writeAsString('class Custom {}\n'); + + final mainFile = File('${tempDir.path}/main.dart'); + await mainFile.writeAsString(''' +import 'types.dart'; + +class Holder { + final (Custom, {Custom right}) entry; + const Holder(this.entry); +} +'''); + + final result = await resolveFile(path: mainFile.path); + final resolved = result as ResolvedUnitResult; + final holder = resolved.libraryElement.classes + .firstWhere((element) => element.displayName == 'Holder'); + final entryField = + holder.fields.firstWhere((field) => field.displayName == 'entry'); + + final code = typeToCode(entryField.type); + final typesUri = typesFile.uri.toString(); + + expect(code.code, contains('(types.Custom')); + expect(code.code, contains('{types.Custom right}')); + expect(code.imports.any((uri) => uri.contains('types.dart')), isTrue); + expect(code.imports.contains(typesUri), isTrue); + }); + + test('converts typedef aliases and keeps alias import', () async { + final tempDir = + await Directory.systemTemp.createTemp('locorda_init_generator_test_'); + addTearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + final typesFile = File('${tempDir.path}/types.dart'); + await typesFile.writeAsString(''' +class Custom {} +typedef Mapper = T Function(Custom value); +'''); + + final mainFile = File('${tempDir.path}/main.dart'); + await mainFile.writeAsString(''' +import 'types.dart'; + +class Holder { + final Mapper mapper; + const Holder(this.mapper); +} +'''); + + final result = await resolveFile(path: mainFile.path); + final resolved = result as ResolvedUnitResult; + final holder = resolved.libraryElement.classes + .firstWhere((element) => element.displayName == 'Holder'); + final mapperField = + holder.fields.firstWhere((field) => field.displayName == 'mapper'); + + final code = typeToCode(mapperField.type); + final typesUri = typesFile.uri.toString(); + + expect(code.code, contains('types.Mapper<')); + expect(code.code, contains('types.Custom')); + expect(code.imports.contains(typesUri), isTrue); + }); + + test('converts nullable generic typedef aliases', () async { + final tempDir = + await Directory.systemTemp.createTemp('locorda_init_generator_test_'); + addTearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + final typesFile = File('${tempDir.path}/types.dart'); + await typesFile.writeAsString(''' +class Custom {} +typedef Mapper = T Function(Custom value); +'''); + + final mainFile = File('${tempDir.path}/main.dart'); + await mainFile.writeAsString(''' +import 'types.dart'; + +class Holder { + final Mapper? maybeMapper; + const Holder(this.maybeMapper); +} +'''); + + final result = await resolveFile(path: mainFile.path); + final resolved = result as ResolvedUnitResult; + final holder = resolved.libraryElement.classes + .firstWhere((element) => element.displayName == 'Holder'); + final mapperField = holder.fields + .firstWhere((field) => field.displayName == 'maybeMapper'); + + final code = typeToCode(mapperField.type); + final typesUri = typesFile.uri.toString(); + + expect(code.code, contains('types.Mapper?')); + expect(code.imports.contains(typesUri), isTrue); + }); + }); + + group('dartObjectToCode', () { + test('converts function constant tear-off and keeps imports', () async { + final tempDir = + await Directory.systemTemp.createTemp('locorda_init_generator_test_'); + addTearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + final typesFile = File('${tempDir.path}/types.dart'); + await typesFile.writeAsString(''' +int parseValue(String input) => input.length; +'''); + + final mainFile = File('${tempDir.path}/main.dart'); + await mainFile.writeAsString(''' +import 'types.dart'; + +const parser = parseValue; +'''); + + final result = await resolveFile(path: mainFile.path); + final resolved = result as ResolvedUnitResult; + final variable = resolved.libraryElement.topLevelVariables + .firstWhere((element) => element.displayName == 'parser'); + final constValue = variable.computeConstantValue(); + + final code = dartObjectToCode(constValue); + final typesUri = typesFile.uri.toString(); + + expect(code.code, contains('types.parseValue')); + expect(code.imports.contains(typesUri), isTrue); + }); + }); +} diff --git a/packages/locorda_init_generator/test/code_generator_test.dart b/packages/locorda_init_generator/test/code_generator_test.dart index 251a8bdf..c442ee5a 100644 --- a/packages/locorda_init_generator/test/code_generator_test.dart +++ b/packages/locorda_init_generator/test/code_generator_test.dart @@ -1,30 +1,33 @@ import 'package:locorda_init_generator/src/code_generator.dart'; +import 'package:locorda_init_generator/src/code_generation/code.dart'; import 'package:locorda_init_generator/src/parameter_info.dart'; import 'package:test/test.dart'; +Code cType(String typeName) => Code.literal(typeName); + void main() { group('CodeGenerator', () { test('generates basic initLocorda with no detection', () { final generator = CodeGenerator( hasGeneratedWorker: false, hasInitMapper: false, - locordaParams: const [ + hasGeneratedConfig: false, + locordaParams: [ ParameterInfo( name: 'workerSetup', - type: 'WorkerSetup', + type: cType('WorkerSetup'), isRequired: true, isNamed: true, ), ParameterInfo( name: 'config', - type: 'LocordaConfig', + type: cType('LocordaConfig'), isRequired: true, isNamed: true, ), ], mapperParams: const [], detectedFrameworkParams: const {}, - additionalImports: const {}, ); final code = generator.generate(); @@ -39,34 +42,34 @@ void main() { final generator = CodeGenerator( hasGeneratedWorker: true, hasInitMapper: false, - locordaParams: const [ + hasGeneratedConfig: false, + locordaParams: [ ParameterInfo( name: 'workerSetup', - type: 'WorkerSetup', + type: cType('WorkerSetup'), isRequired: true, isNamed: true, ), ParameterInfo( name: 'config', - type: 'LocordaConfig', + type: cType('LocordaConfig'), isRequired: true, isNamed: true, ), ], mapperParams: const [], detectedFrameworkParams: const {}, - additionalImports: const {}, ); final code = generator.generate(); // Should not include workerSetup in signature expect(code, isNot(contains('required WorkerSetup workerSetup,'))); - + // Should include it in the call - expect(code, contains('workerSetup: generatedWorkerSetup,')); + expect(code, contains('generatedWorkerSetup,')); expect(code, contains("jsScript: 'worker_generated.dart.js',")); - + // Should import worker_generated.g.dart expect(code, contains("import 'worker_generated.g.dart'")); }); @@ -75,35 +78,40 @@ void main() { final generator = CodeGenerator( hasGeneratedWorker: false, hasInitMapper: true, - locordaParams: const [ + hasGeneratedConfig: false, + locordaParams: [ ParameterInfo( name: 'workerSetup', - type: 'WorkerSetup', + type: cType('WorkerSetup'), isRequired: true, isNamed: true, ), ParameterInfo( name: 'mapperInitializer', - type: 'MapperInitializerFunction', + type: cType('MapperInitializerFunction'), isRequired: true, isNamed: true, ), ], mapperParams: const [], detectedFrameworkParams: const {'\$resourceIriFactory'}, - additionalImports: const {}, ); final code = generator.generate(); // Should not include mapperInitializer in signature - expect(code, isNot(contains('required MapperInitializerFunction mapperInitializer,'))); - + expect( + code, + isNot(contains( + 'required MapperInitializerFunction mapperInitializer,'))); + // Should generate the lambda - expect(code, contains('mapperInitializer: (context) => initRdfMapper(')); + expect(code, contains('mapperInitializer: (context) =>')); + expect(code, contains('initRdfMapper(')); expect(code, contains('rdfMapper: context.baseRdfMapper,')); - expect(code, contains('\$resourceIriFactory: context.resourceIriFactory,')); - + expect( + code, contains('\$resourceIriFactory: context.resourceIriFactory,')); + // Should import init_rdf_mapper.g.dart expect(code, contains("import 'init_rdf_mapper.g.dart'")); }); @@ -112,33 +120,128 @@ void main() { final generator = CodeGenerator( hasGeneratedWorker: false, hasInitMapper: true, - locordaParams: const [ + hasGeneratedConfig: false, + locordaParams: [ ParameterInfo( name: 'workerSetup', - type: 'WorkerSetup', + type: cType('WorkerSetup'), isRequired: true, isNamed: true, ), ], - mapperParams: const [ + mapperParams: [ ParameterInfo( name: 'categoryService', - type: 'CategoryService', + type: cType('CategoryService'), isRequired: true, isNamed: true, ), ], detectedFrameworkParams: const {}, - additionalImports: const {}, ); final code = generator.generate(); // Should include custom param in signature expect(code, contains('required CategoryService categoryService,')); - + // Should pass it through to initRdfMapper expect(code, contains('categoryService: categoryService,')); }); + + test('generates initLocorda with config detection', () { + final generator = CodeGenerator( + hasGeneratedWorker: false, + hasInitMapper: false, + hasGeneratedConfig: true, + locordaParams: [ + ParameterInfo( + name: 'workerSetup', + type: cType('WorkerSetup'), + isRequired: true, + isNamed: true, + ), + ParameterInfo( + name: 'config', + type: cType('LocordaConfig'), + isRequired: true, + isNamed: true, + ), + ], + mapperParams: const [], + detectedFrameworkParams: const {}, + ); + + final code = generator.generate(); + + // Should not include config in signature + expect(code, isNot(contains('required LocordaConfig config,'))); + + // Should include it in the call + expect(code, contains('generateLocordaConfig(),')); + + // Should import locorda_config.g.dart + expect(code, contains("import 'locorda_config.g.dart'")); + }); + + test('generates initLocorda with all detections', () { + final generator = CodeGenerator( + hasGeneratedWorker: true, + hasInitMapper: true, + hasGeneratedConfig: true, + locordaParams: [ + ParameterInfo( + name: 'workerSetup', + type: cType('WorkerSetup'), + isRequired: true, + isNamed: true, + ), + ParameterInfo( + name: 'mapperInitializer', + type: cType('MapperInitializerFunction'), + isRequired: true, + isNamed: true, + ), + ParameterInfo( + name: 'config', + type: cType('LocordaConfig'), + isRequired: true, + isNamed: true, + ), + ParameterInfo( + name: 'remotes', + type: cType('List'), + isRequired: true, + isNamed: true, + ), + ], + mapperParams: const [], + detectedFrameworkParams: const {}, + ); + + final code = generator.generate(); + + // Should not include auto-configured params in signature + expect(code, isNot(contains('required WorkerSetup workerSetup,'))); + expect( + code, + isNot(contains( + 'required MapperInitializerFunction mapperInitializer,'))); + expect(code, isNot(contains('required LocordaConfig config,'))); + + // Should include remotes (not auto-configured) + expect(code, contains('required List remotes')); + + // Should configure all auto params in the call + expect(code, contains('generatedWorkerSetup,')); + expect(code, contains('mapperInitializer: (context) =>')); + expect(code, contains('initRdfMapper(')); + expect(code, contains('generateLocordaConfig(),')); + + // Should have all imports + expect(code, contains("import 'worker_generated.g.dart'")); + expect(code, contains("import 'init_rdf_mapper.g.dart'")); + expect(code, contains("import 'locorda_config.g.dart'")); + }); }); }