diff --git a/rocket_cli/README.md b/rocket_cli/README.md index 9e15f3f..1bea324 100644 --- a/rocket_cli/README.md +++ b/rocket_cli/README.md @@ -1,10 +1,49 @@ -```dart -import 'package:rocket_cli/rocket_cli.dart'; - -void main(List arguments) { - Generator gen = Generator(); - ModelsController models = gen.generate( - '{"name":"John Doe","age":30,"cars":[{"hello":"World"}]}', "Person"); - print(models.models); -} -``` \ No newline at end of file +# Rocket CLI + +A powerful command-line tool to generate `RocketModel` classes from JSON data. + +## Features + +- Generate models from raw JSON strings. +- Generate models from JSON files. +- Automatically handles nested objects and lists. +- Supports custom class names and output directories. + +## Installation + +You can run it directly using `dart run` within the package: + +```bash +dart run rocket_cli [arguments] +``` + +Or activate it globally: + +```bash +dart pub global activate --source path . +rocket_cli [arguments] +``` + +## Usage + +### Generate from JSON file + +```bash +rocket_cli -f data.json -n UserProfile -o lib/models +``` + +### Generate from raw JSON string + +```bash +rocket_cli -j '{"id":1, "name":"John"}' -n User +``` + +### Arguments + +| Argument | Abbr | Description | Default | +| --- | --- | --- | --- | +| `--json` | `-j` | Raw JSON string | | +| `--file` | `-f` | Path to JSON file | | +| `--name` | `-n` | Root class name | `MyModel` | +| `--output` | `-o` | Output directory | `lib/models` | +| `--help` | `-h` | Show help | | \ No newline at end of file diff --git a/rocket_cli/bin/rocket_cli.dart b/rocket_cli/bin/rocket_cli.dart index b746a28..1f68760 100644 --- a/rocket_cli/bin/rocket_cli.dart +++ b/rocket_cli/bin/rocket_cli.dart @@ -1,12 +1,83 @@ +import 'dart:io'; +import 'package:args/args.dart'; import 'package:rocket_cli/rocket_cli.dart'; -void main(List arguments) { - Generator gen = Generator(); - ModelsController controller = ModelsController(); - gen.generate('{"name":"John Doe","age":30,"cars":[{"hello":"World"}]}', - "Person", controller); +void main(List arguments) async { + final parser = ArgParser() + ..addOption('json', abbr: 'j', help: 'Raw JSON string') + ..addOption('file', abbr: 'f', help: 'Path to JSON file') + ..addOption('name', abbr: 'n', help: 'Class name', defaultsTo: 'MyModel') + ..addOption('output', + abbr: 'o', help: 'Output directory', defaultsTo: 'lib/models') + ..addFlag('help', abbr: 'h', negatable: false, help: 'Show help'); + + ArgResults argResults; + try { + argResults = parser.parse(arguments); + } catch (e) { + print(e); + print(parser.usage); + return; + } + + if (argResults['help']) { + print('Rocket CLI - Model Generator'); + print(parser.usage); + return; + } + + String? jsonContent; + + if (argResults['json'] != null) { + jsonContent = argResults['json']; + } else if (argResults['file'] != null) { + final file = File(argResults['file']); + if (!await file.exists()) { + print('Error: File not found at ${argResults['file']}'); + return; + } + jsonContent = await file.readAsString(); + } else { + print('Error: You must provide either --json or --file'); + print(parser.usage); + return; + } + + final String className = argResults['name']; + final String outputDir = argResults['output']; + + final Generator gen = Generator(); + final ModelsController controller = ModelsController(); + + try { + await gen.generate(jsonContent!, className, controller); + } catch (e) { + print('Error during generation: $e'); + return; + } + + if (controller.models.isEmpty) { + print('No models generated.'); + return; + } + + final directory = Directory(outputDir); + if (!await directory.exists()) { + await directory.create(recursive: true); + } + for (var model in controller.models) { - print(model.name); - print(model.result); + // Generate filename from class name (camelCase to snake_case) + String fileName = model.name + .replaceAllMapped( + RegExp(r'([a-z0-9])([A-Z])'), (Match m) => '${m[1]}_${m[2]}') + .toLowerCase(); + + final file = File('${directory.path}/$fileName.dart'); + await file.writeAsString(model.result); + print('✅ Generated: ${file.path}'); } + + print( + '\nSuccess! ${controller.models.length} model(s) created in $outputDir'); } diff --git a/rocket_cli/lib/src/model_generator/models/generator.dart b/rocket_cli/lib/src/model_generator/models/generator.dart index c91b8ab..fd61643 100644 --- a/rocket_cli/lib/src/model_generator/models/generator.dart +++ b/rocket_cli/lib/src/model_generator/models/generator.dart @@ -9,21 +9,39 @@ import 'utils/template.dart'; class Generator { late String copyTemplate; + final Set _generatedClasses = {}; + Future generate( String inputUser, String className, ModelsController controller, {bool multi = false}) async { copyTemplate = template; className = className.isEmpty ? "MyModel" : className.firstUpper; + + if (_generatedClasses.contains(className)) return; + inputUser = inputUser.isEmpty ? '{"MVCRocket Package":"MvcRocket"}' : inputUser; - var jsonInputUser = json.decode(inputUser.trim()); + + dynamic jsonInputUser; + try { + jsonInputUser = json.decode(inputUser.trim()); + } catch (e) { + print("Error decoding JSON: $e"); + return; + } + if (jsonInputUser is List) { + if (jsonInputUser.isEmpty) { + print("Empty list, cannot infer type for $className"); + return; + } return generate(json.encode(jsonInputUser.first), className, controller, multi: true); } else if (jsonInputUser is Map) { + _generatedClasses.add(className); generateFields(jsonInputUser, className, controller, multi: multi); } else { - print("Unsupported type"); + print("Unsupported JSON type for $className"); } } @@ -31,16 +49,22 @@ class Generator { return item is String || item is int || item is double || item is bool; } + bool _isDateField(String value) { + return DateTime.tryParse(value) != null && + RegExp(r'^\d{4}-\d{2}-\d{2}').hasMatch(value); + } + generateFields(Map fields, String className, ModelsController controller, {bool multi = false}) { ModelItems modelItems = ModelItems(); fields.forEach((key, value) { - String fieldType = _solveDouble(value); + String fieldType = _solveType(value); late String line; late String fromJson, toJson; String initFields = ""; late String fieldsKey, updateFieldsParams, updateFieldsBody; + final String fieldKeyMap = "${className.toLowerCase()}${key.camel.firstUpper}Field"; final String fieldLine = @@ -48,55 +72,77 @@ class Generator { final String updateFieldParamLine = "$fieldType? ${key.camel}Field,"; final String updateFieldBodyLine = "${key.camel} = ${key.camel}Field ?? ${key.camel};"; + bool isPrimitive = _isPrimitive(value); - if (isPrimitive) { - line = "$fieldType? ${key.camel};"; + + if (value == null) { + line = "dynamic? ${key.camel};"; fromJson = "${key.camel} = json[$fieldKeyMap];"; toJson = "data[$fieldKeyMap] = ${key.camel};"; fieldsKey = fieldLine; + updateFieldsParams = "dynamic? ${key.camel}Field,"; + updateFieldsBody = updateFieldBodyLine; + } else if (isPrimitive) { + line = "$fieldType? ${key.camel};"; + if (fieldType == "DateTime") { + fromJson = + "${key.camel} = json[$fieldKeyMap] != null ? DateTime.tryParse(json[$fieldKeyMap].toString()) : null;"; + toJson = "data[$fieldKeyMap] = ${key.camel}?.toIso8601String();"; + } else { + fromJson = "${key.camel} = json[$fieldKeyMap];"; + toJson = "data[$fieldKeyMap] = ${key.camel};"; + } + fieldsKey = fieldLine; updateFieldsParams = updateFieldParamLine; updateFieldsBody = updateFieldBodyLine; } else if (value is List) { bool isNotEmpty = value.isNotEmpty; - bool isPrimitive = false; - if (isNotEmpty) isPrimitive = _isPrimitive(value.first); - if (!isNotEmpty || isPrimitive) { + bool isListOrPrimitive = false; + + dynamic firstItem = isNotEmpty ? value.first : null; + if (isNotEmpty) { + isListOrPrimitive = _isPrimitive(firstItem) || firstItem is List; + } + + if (!isNotEmpty || isListOrPrimitive) { final String fieldSubType = - isNotEmpty ? _solveDouble(value.first) : "dynamic"; + isNotEmpty ? _solveType(firstItem) : "dynamic"; line = "List<$fieldSubType>? ${key.camel};"; - fromJson = "${key.camel} = json[$fieldKeyMap];"; + fromJson = + "${key.camel} = json[$fieldKeyMap]?.cast<$fieldSubType>();"; toJson = "data[$fieldKeyMap] = ${key.camel};"; fieldsKey = fieldLine; updateFieldsParams = "List<$fieldSubType>? ${key.camel}Field,"; updateFieldsBody = updateFieldBodyLine; } else { - line = "${key.firstUpper}? $key;"; - fromJson = "${key.camel}!.setMulti(json['$key']);"; + String subClassName = key.camel.firstUpper; + line = "$subClassName? ${key.camel};"; + fromJson = + "if (json[$fieldKeyMap] != null) ${key.camel}!.setMulti(json[$fieldKeyMap]);"; toJson = - "data[$fieldKeyMap] = ${key.camel}!.all.map((e)=> e.toJson()).toList();"; + "data[$fieldKeyMap] = ${key.camel}!.all?.map((e)=> e.toJson()).toList();"; fieldsKey = fieldLine; - updateFieldsParams = "${key.firstUpper}? ${key.camel}Field,"; + updateFieldsParams = "$subClassName? ${key.camel}Field,"; updateFieldsBody = updateFieldBodyLine; - initFields = "$key??=${key.firstUpper}();"; + initFields = "${key.camel}??=$subClassName();"; - Generator reGenerate = Generator(); - reGenerate.generate(json.encode(value), key.firstUpper, controller, - multi: true); + generate(json.encode(value), subClassName, controller, multi: true); } - } else if (value is Map) { - line = "${key.firstUpper}? $key;"; + } else if (value is Map) { + String subClassName = key.camel.firstUpper; + line = "$subClassName? ${key.camel};"; fromJson = "${key.camel}!.fromJson(json[$fieldKeyMap]);"; toJson = "data[$fieldKeyMap] = ${key.camel}!.toJson();"; fieldsKey = fieldLine; - updateFieldsParams = "${key.firstUpper}? ${key.camel}Field,"; + updateFieldsParams = "$subClassName? ${key.camel}Field,"; updateFieldsBody = updateFieldBodyLine; - initFields = "$key??=${key.firstUpper}();"; + initFields = "${key.camel}??=$subClassName();"; - Generator reGenerate = Generator(); - reGenerate.generate(json.encode(value), key.firstUpper, controller); + generate(json.encode(value), subClassName, controller); } else { - print("Unsupported type"); + print("Unsupported type for key: $key"); } + modelItems.constFields += "this.${key.camel},"; modelItems.fieldsLines += line; modelItems.fromJsonFields += fromJson; @@ -105,21 +151,39 @@ class Generator { modelItems.updateFieldsParams += updateFieldsParams; modelItems.updateFieldsBody += updateFieldsBody; modelItems.initFields += initFields; - if (multi) { - modelItems.instance = "@override get instance => $className();"; - } }); + + if (multi) { + modelItems.instance = + "@override $className get instance => $className();"; + } + modelItems.className = className; - String result = DartFormatter().format(modelItems.result); - controller.addModel(result, className); - // return controller; + try { + String result = DartFormatter().format(modelItems.result); + controller.addModel(result, className); + } catch (e) { + print("Error formatting $className: $e"); + controller.addModel(modelItems.result, className); + } } - String _solveDouble(dynamic field) { - if (field is int) { + String _solveType(dynamic field) { + if (field == null) return "dynamic"; + if (field is String) { + if (_isDateField(field)) return "DateTime"; + return "String"; + } + if (field is bool) return "bool"; + if (field is num) { if (field.isDouble) { return "double"; } + return "int"; + } + if (field is List) { + String subType = field.isNotEmpty ? _solveType(field.first) : "dynamic"; + return "List<$subType>"; } return field.runtimeType.toString(); } diff --git a/rocket_cli/lib/src/model_generator/models/utils/extensions.dart b/rocket_cli/lib/src/model_generator/models/utils/extensions.dart index 3ae8547..ca2173e 100644 --- a/rocket_cli/lib/src/model_generator/models/utils/extensions.dart +++ b/rocket_cli/lib/src/model_generator/models/utils/extensions.dart @@ -1,20 +1,24 @@ extension EString on String { String get upper => toUpperCase(); - String get firstUpper => substring(0, 1).toUpperCase() + substring(1); + String get firstUpper => + isEmpty ? "" : substring(0, 1).toUpperCase() + substring(1); String get camel { - if (contains("_")) { - List splited = split("_"); + if (isEmpty) return ""; + String internal = replaceAll(RegExp(r'[-_ ]'), '_'); + if (internal.contains("_")) { + List splited = internal.split("_"); return splited.map((e) { - if (splited.first != e) { - return e.firstUpper; + if (splited.first == e) { + return e.toLowerCase(); } - return e; + return e.firstUpper; }).join(""); } - return toLowerCase(); + return this[0].toLowerCase() + substring(1); } } -extension EInt on int { - bool get isDouble => toString().contains("."); +extension EInt on num { + bool get isDouble => + this is double || (this is int && toString().contains(".")); } diff --git a/rocket_cli/pubspec.lock b/rocket_cli/pubspec.lock index 49552aa..cc2124c 100644 --- a/rocket_cli/pubspec.lock +++ b/rocket_cli/pubspec.lock @@ -18,13 +18,13 @@ packages: source: hosted version: "5.12.0" args: - dependency: transitive + dependency: "direct main" description: name: args - sha256: c372bb384f273f0c2a8aaaa226dad84dc27c8519a691b888725dec59518ad53a + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.7.0" async: dependency: transitive description: @@ -378,4 +378,4 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.0.0 <4.0.0" + dart: ">=3.3.0 <4.0.0" diff --git a/rocket_cli/pubspec.yaml b/rocket_cli/pubspec.yaml index 8aa4b1f..b9d2f7f 100644 --- a/rocket_cli/pubspec.yaml +++ b/rocket_cli/pubspec.yaml @@ -1,9 +1,8 @@ name: rocket_cli -description: A sample command-line for rocket package. +description: A command-line tool for generating RocketModel classes from JSON for the flutter_rocket package. version: 1.0.0 -repository: https://github.com/JahezAcademy/rocket/tree/master/rocket_cli -homepage: https://github.com/JahezAcademy/rocket -publish_to: none +repository: https://github.com/JahezAcademy/flutter_rocket/tree/dev/rocket_cli +homepage: https://github.com/JahezAcademy/flutter_rocket environment: sdk: ^3.0.0 @@ -11,6 +10,10 @@ environment: # Add regular dependencies here. dependencies: dart_style: ^2.3.1 + args: ^2.4.2 + +executables: + rocket_cli: dev_dependencies: lints: ^2.0.0 diff --git a/rocket_cli/test/rocket_cli_test.dart b/rocket_cli/test/rocket_cli_test.dart index 8b13789..051db73 100644 --- a/rocket_cli/test/rocket_cli_test.dart +++ b/rocket_cli/test/rocket_cli_test.dart @@ -1 +1,154 @@ +import 'package:test/test.dart'; +import 'package:rocket_cli/rocket_cli.dart'; +import 'package:rocket_cli/src/model_generator/models/utils/extensions.dart'; +void main() { + group('EString Extension Tests', () { + test('firstUpper converts first character to uppercase', () { + expect('hello'.firstUpper, 'Hello'); + expect('world'.firstUpper, 'World'); + expect(''.firstUpper, ''); + }); + + test('camel converts various formats to camelCase', () { + expect('hello_world'.camel, 'helloWorld'); + expect('hello-world'.camel, 'helloWorld'); + expect('hello world'.camel, 'helloWorld'); + expect('HelloWorld'.camel, 'helloWorld'); + expect(''.camel, ''); + }); + }); + + group('Generator Tests', () { + late Generator generator; + late ModelsController controller; + + setUp(() { + generator = Generator(); + controller = ModelsController(); + }); + + test('Generates primitive fields correctly', () async { + const jsonStr = + '{"name": "John", "age": 30, "is_active": true, "score": 95.5}'; + await generator.generate(jsonStr, 'User', controller); + + expect(controller.models.length, 1); + final result = controller.models.first.result; + + expect(result, contains('String? name;')); + expect(result, contains('int? age;')); + expect(result, contains('bool? isActive;')); + expect(result, contains('double? score;')); + expect(result, contains('name = json[userNameField];')); + }); + + test('Detects DateTime fields', () async { + const jsonStr = '{"created_at": "2024-01-20T10:00:00Z"}'; + await generator.generate(jsonStr, 'Post', controller); + + expect(controller.models.length, 1); + final result = controller.models.first.result; + + expect(result, contains('DateTime? createdAt;')); + expect(result, + contains('DateTime.tryParse(json[postCreatedAtField].toString())')); + expect(result, contains('createdAt?.toIso8601String()')); + }); + + test('Handles nested objects', () async { + const jsonStr = '{"user": {"id": 1, "name": "John"}}'; + await generator.generate(jsonStr, 'Root', controller); + + // Should generate 2 models: User and Root + expect(controller.models.length, 2); + + final rootModel = + controller.models.firstWhere((m) => m.name == 'Root').result; + final userModel = + controller.models.firstWhere((m) => m.name == 'User').result; + + expect(rootModel, contains('User? user;')); + expect(userModel, contains('int? id;')); + expect(userModel, contains('String? name;')); + }); + + test('Handles list of primitives', () async { + const jsonStr = '{"tags": ["flutter", "dart"]}'; + await generator.generate(jsonStr, 'Item', controller); + + final result = controller.models.first.result; + expect(result, contains('List? tags;')); + expect(result, contains('json[itemTagsField]?.cast()')); + }); + + test('Handles list of objects', () async { + const jsonStr = '{"comments": [{"id": 1, "text": "nice"}]}'; + await generator.generate(jsonStr, 'Post', controller); + + expect(controller.models.length, 2); + final postResult = + controller.models.firstWhere((m) => m.name == 'Post').result; + final commentsResult = + controller.models.firstWhere((m) => m.name == 'Comments').result; + + expect(postResult, contains('Comments? comments;')); + expect( + postResult, contains('comments!.setMulti(json[postCommentsField])')); + expect(commentsResult, contains('int? id;')); + expect(commentsResult, contains('String? text;')); + }); + + test('Handles null values', () async { + const jsonStr = '{"data": null}'; + await generator.generate(jsonStr, 'Response', controller); + + final result = controller.models.first.result; + expect(result, contains('dynamic? data;')); + }); + + test('Prevents duplicate class generation', () async { + // We'll simulate a case where a class name might be reused + await generator.generate('{"id": 1}', 'User', controller); + await generator.generate('{"name": "John"}', 'User', controller); + + expect(controller.models.length, 1); + }); + + test('Handles complex nested lists (List of List of primitives)', () async { + const jsonStr = '{"matrix": [[1, 2], [3, 4]]}'; + await generator.generate(jsonStr, 'Matrix', controller); + + final result = controller.models.first.result; + expect(result, contains('List>? matrix;')); + expect(result, contains('json[matrixMatrixField]?.cast>()')); + }); + + test('Handles deeply nested objects', () async { + const jsonStr = '{"a": {"b": {"c": {"d": 1}}}}'; + await generator.generate(jsonStr, 'Deep', controller); + + // Deep, A, B, C models should be generated + expect(controller.models.length, 4); + expect(controller.models.any((m) => m.name == 'Deep'), isTrue); + expect(controller.models.any((m) => m.name == 'A'), isTrue); + expect(controller.models.any((m) => m.name == 'B'), isTrue); + expect(controller.models.any((m) => m.name == 'C'), isTrue); + }); + + test('Gracefully handles malformed JSON', () async { + const jsonStr = '{"name": "John", "age": }'; // Malformed + await generator.generate(jsonStr, 'Error', controller); + + expect(controller.models, isEmpty); + }); + + test('Handles empty list correctly', () async { + const jsonStr = '{"items": []}'; + await generator.generate(jsonStr, 'Empty', controller); + + final result = controller.models.first.result; + expect(result, contains('List? items;')); + }); + }); +} diff --git a/rocket_cli/test_output.txt b/rocket_cli/test_output.txt new file mode 100644 index 0000000..446023c --- /dev/null +++ b/rocket_cli/test_output.txt @@ -0,0 +1,72 @@ + 00:00 +0: loading test/rocket_cli_test.dart Could not find a command named "/Users/m97chahboun/Development/flutter/bin/cache/dart-sdk/bin/snapshots/frontend_server.dart.snapshot". + +Usage: dart [arguments] + +Global options: +-v, --verbose Show additional command output. + --version Print the Dart SDK version. + --enable-analytics Enable analytics. + --disable-analytics Disable analytics. + --suppress-analytics Disallow analytics for this `dart *` run without changing the analytics configuration. +-h, --help Print this usage information. + +Available commands: + +Project + compile Compile Dart to various formats. + create Create a new Dart project. + pub Work with packages. + run Run a Dart program. + test Run tests for a project. + +Source code + analyze Analyze Dart code in a directory. + doc Generate API documentation for Dart projects. + fix Apply automated fixes to Dart source code. + format Idiomatically format Dart source code. + +Tools + devtools Open DevTools (optionally connecting to an existing application). + info Show diagnostic information about the installed tooling. + +Run "dart help " for more information about a command. +See https://dart.dev/tools/dart-tool for detailed documentation. + 00:00 +0 -1: loading test/rocket_cli_test.dart [E] + Failed to load "test/rocket_cli_test.dart": + SocketException: Write failed (OS Error: Broken pipe, errno = 32), port = 0 + dart:io-patch/socket_patch.dart 1484:34 _NativeSocket.write + dart:io-patch/socket_patch.dart 2408:15 _RawSocket.write + dart:io-patch/socket_patch.dart 2944:18 _Socket._write + dart:io-patch/socket_patch.dart 2667:28 _SocketStreamConsumer.write + dart:io-patch/socket_patch.dart 2615:13 _SocketStreamConsumer.addStream. + dart:async/zone.dart 1538:47 _rootRunUnary + dart:async/zone.dart 1429:19 _CustomZone.runUnary + dart:async/zone.dart 1329:7 _CustomZone.runUnaryGuarded + dart:async/stream_impl.dart 381:11 _BufferingStreamSubscription._sendData + dart:async/stream_impl.dart 312:7 _BufferingStreamSubscription._add + dart:async/stream_controller.dart 798:19 _SyncStreamControllerDispatch._sendData + dart:async/stream_controller.dart 663:7 _StreamController._add + dart:async/stream_controller.dart 618:5 _StreamController.add + dart:io/io_sink.dart 155:17 _StreamSinkImpl.add + dart:io/io_sink.dart 293:5 _IOSinkImpl.write + dart:io-patch/socket_patch.dart 2773:36 _Socket.write + dart:io/stdio.dart 442:13 _StdSink.writeln + package:frontend_server_client/src/frontend_server_client.dart 328:21 FrontendServerClient._sendCommand + package:frontend_server_client/src/frontend_server_client.dart 245:5 FrontendServerClient.accept + package:test_core/src/runner/vm/test_compiler.dart 128:30 _TestCompilerForLanguageVersion._compile + ===== asynchronous gap =========================== + package:pool/pool.dart 127:14 Pool.withResource + ===== asynchronous gap =========================== + package:test_core/src/runner/vm/platform.dart 252:22 VMPlatform._compileToKernel + ===== asynchronous gap =========================== + package:test_core/src/runner/vm/platform.dart 232:15 VMPlatform._spawnIsolate + ===== asynchronous gap =========================== + package:test_core/src/runner/vm/platform.dart 76:19 VMPlatform.load + ===== asynchronous gap =========================== + package:test_core/src/runner/loader.dart 232:27 Loader.loadFile. + ===== asynchronous gap =========================== + package:test_core/src/runner/load_suite.dart 99:19 new LoadSuite.. + + +To run this test again: /Users/m97chahboun/Development/flutter/bin/cache/dart-sdk/bin/dart test test/rocket_cli_test.dart -p vm --plain-name 'loading test/rocket_cli_test.dart' + 00:00 +0 -1: Some tests failed.