From feb725830279cd6e958798456b4b3efbd57b3b7b Mon Sep 17 00:00:00 2001 From: Sergey Penkovsky Date: Fri, 25 Jul 2025 11:58:56 +0300 Subject: [PATCH] chore(generator): improve annotation validation, unify async type handling, and refactor BindSpec creation - Enhance annotation validation in DI code generation. - Move from manual Future extraction to unified type parsing. - Refactor BindSpec creation logic to provide better error messages and type consistency. - Add missing source files for exceptions, annotation validation, and type parsing. BREAKING CHANGE: Invalid annotation combinations now produce custom generator errors. Async detection is now handled via unified type parser. --- .../lib/src/annotation_validator.dart | 321 ++++++++++++++++++ cherrypick_generator/lib/src/bind_spec.dart | 237 +++++++++---- cherrypick_generator/lib/src/exceptions.dart | 117 +++++++ .../lib/src/generated_class.dart | 23 +- cherrypick_generator/lib/src/type_parser.dart | 218 ++++++++++++ .../test/type_parser_test.dart | 235 +++++++++++++ 6 files changed, 1070 insertions(+), 81 deletions(-) create mode 100644 cherrypick_generator/lib/src/annotation_validator.dart create mode 100644 cherrypick_generator/lib/src/exceptions.dart create mode 100644 cherrypick_generator/lib/src/type_parser.dart create mode 100644 cherrypick_generator/test/type_parser_test.dart diff --git a/cherrypick_generator/lib/src/annotation_validator.dart b/cherrypick_generator/lib/src/annotation_validator.dart new file mode 100644 index 0000000..a5a739b --- /dev/null +++ b/cherrypick_generator/lib/src/annotation_validator.dart @@ -0,0 +1,321 @@ +// +// Copyright 2021 Sergey Penkovsky (sergey.penkovsky@gmail.com) +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import 'package:analyzer/dart/element/element.dart'; +import 'exceptions.dart'; +import 'metadata_utils.dart'; + +/// Validates annotation combinations and usage patterns +class AnnotationValidator { + /// Validates annotations on a method element + static void validateMethodAnnotations(MethodElement method) { + final annotations = _getAnnotationNames(method.metadata); + + _validateMutuallyExclusiveAnnotations(method, annotations); + _validateAnnotationCombinations(method, annotations); + _validateAnnotationParameters(method); + } + + /// Validates annotations on a field element + static void validateFieldAnnotations(FieldElement field) { + final annotations = _getAnnotationNames(field.metadata); + + _validateInjectFieldAnnotations(field, annotations); + } + + /// Validates annotations on a class element + static void validateClassAnnotations(ClassElement classElement) { + final annotations = _getAnnotationNames(classElement.metadata); + + _validateModuleClassAnnotations(classElement, annotations); + _validateInjectableClassAnnotations(classElement, annotations); + } + + static List _getAnnotationNames(List metadata) { + return metadata + .map((m) => m.computeConstantValue()?.type?.getDisplayString()) + .where((name) => name != null) + .cast() + .toList(); + } + + static void _validateMutuallyExclusiveAnnotations( + MethodElement method, + List annotations, + ) { + // @instance and @provide are mutually exclusive + if (annotations.contains('instance') && annotations.contains('provide')) { + throw AnnotationValidationException( + 'Method cannot have both @instance and @provide annotations', + element: method, + suggestion: + 'Use either @instance for direct instances or @provide for factory methods', + context: { + 'method_name': method.displayName, + 'annotations': annotations, + }, + ); + } + } + + static void _validateAnnotationCombinations( + MethodElement method, + List annotations, + ) { + // @params can only be used with @provide + if (annotations.contains('params') && !annotations.contains('provide')) { + throw AnnotationValidationException( + '@params annotation can only be used with @provide annotation', + element: method, + suggestion: 'Remove @params or add @provide annotation', + context: { + 'method_name': method.displayName, + 'annotations': annotations, + }, + ); + } + + // Methods must have either @instance or @provide + if (!annotations.contains('instance') && !annotations.contains('provide')) { + throw AnnotationValidationException( + 'Method must be marked with either @instance or @provide annotation', + element: method, + suggestion: + 'Add @instance() for direct instances or @provide() for factory methods', + context: { + 'method_name': method.displayName, + 'available_annotations': annotations, + }, + ); + } + + // @singleton validation + if (annotations.contains('singleton')) { + _validateSingletonUsage(method, annotations); + } + } + + static void _validateSingletonUsage( + MethodElement method, + List annotations, + ) { + // Singleton with params might not make sense in some contexts + if (annotations.contains('params')) { + // This is a warning, not an error - could be useful for parameterized singletons + // We could add a warning system later + } + + // Check if return type is suitable for singleton + final returnType = method.returnType.getDisplayString(); + if (returnType == 'void') { + throw AnnotationValidationException( + 'Singleton methods cannot return void', + element: method, + suggestion: 'Remove @singleton annotation or change return type', + context: { + 'method_name': method.displayName, + 'return_type': returnType, + }, + ); + } + } + + static void _validateAnnotationParameters(MethodElement method) { + // Validate @named annotation parameters + final namedValue = MetadataUtils.getNamedValue(method.metadata); + if (namedValue != null) { + if (namedValue.isEmpty) { + throw AnnotationValidationException( + '@named annotation cannot have empty value', + element: method, + suggestion: 'Provide a non-empty string value for @named annotation', + context: { + 'method_name': method.displayName, + 'named_value': namedValue, + }, + ); + } + + // Check for valid naming conventions + if (!RegExp(r'^[a-zA-Z_][a-zA-Z0-9_]*$').hasMatch(namedValue)) { + throw AnnotationValidationException( + '@named value should follow valid identifier naming conventions', + element: method, + suggestion: + 'Use alphanumeric characters and underscores only, starting with a letter or underscore', + context: { + 'method_name': method.displayName, + 'named_value': namedValue, + }, + ); + } + } + + // Validate method parameters for @params usage + for (final param in method.parameters) { + final paramAnnotations = _getAnnotationNames(param.metadata); + if (paramAnnotations.contains('params')) { + _validateParamsParameter(param, method); + } + } + } + + static void _validateParamsParameter( + ParameterElement param, MethodElement method) { + // @params parameter should typically be dynamic or Map + final paramType = param.type.getDisplayString(); + + if (paramType != 'dynamic' && + paramType != 'Map' && + paramType != 'Map?') { + // This is more of a warning - other types might be valid + // We could add a warning system for this + } + + // Check if parameter is required when using @params + try { + final hasDefault = (param as dynamic).defaultValue != null; + if (param.isRequired && !hasDefault) { + // This might be intentional, so we don't throw an error + // but we could warn about it + } + } catch (e) { + // Ignore if defaultValue is not available in this analyzer version + } + } + + static void _validateInjectFieldAnnotations( + FieldElement field, + List annotations, + ) { + if (!annotations.contains('inject')) { + return; // Not an inject field, nothing to validate + } + + // Check if field type is suitable for injection + final fieldType = field.type.getDisplayString(); + if (fieldType == 'void') { + throw AnnotationValidationException( + 'Cannot inject void type', + element: field, + suggestion: 'Use a concrete type instead of void', + context: { + 'field_name': field.displayName, + 'field_type': fieldType, + }, + ); + } + + // Validate scope annotation if present + for (final meta in field.metadata) { + final obj = meta.computeConstantValue(); + final type = obj?.type?.getDisplayString(); + if (type == 'scope') { + // Empty scope name is treated as no scope (uses root scope) + // This is allowed for backward compatibility and convenience + } + } + } + + static void _validateModuleClassAnnotations( + ClassElement classElement, + List annotations, + ) { + if (!annotations.contains('module')) { + return; // Not a module class + } + + // Check if class has public methods + final publicMethods = + classElement.methods.where((m) => m.isPublic).toList(); + if (publicMethods.isEmpty) { + throw AnnotationValidationException( + 'Module class must have at least one public method', + element: classElement, + suggestion: 'Add public methods with @instance or @provide annotations', + context: { + 'class_name': classElement.displayName, + 'method_count': publicMethods.length, + }, + ); + } + + // Validate that public methods have appropriate annotations + for (final method in publicMethods) { + final methodAnnotations = _getAnnotationNames(method.metadata); + if (!methodAnnotations.contains('instance') && + !methodAnnotations.contains('provide')) { + throw AnnotationValidationException( + 'Public methods in module class must have @instance or @provide annotation', + element: method, + suggestion: 'Add @instance() or @provide() annotation to the method', + context: { + 'class_name': classElement.displayName, + 'method_name': method.displayName, + }, + ); + } + } + } + + static void _validateInjectableClassAnnotations( + ClassElement classElement, + List annotations, + ) { + if (!annotations.contains('injectable')) { + return; // Not an injectable class + } + + // Check if class has injectable fields + final injectFields = classElement.fields.where((f) { + final fieldAnnotations = _getAnnotationNames(f.metadata); + return fieldAnnotations.contains('inject'); + }).toList(); + + // Allow injectable classes without @inject fields to generate empty mixins + // This can be useful for classes that will have @inject fields added later + // or for testing purposes + if (injectFields.isEmpty) { + // Just log a warning but don't throw an exception + // print('Warning: Injectable class ${classElement.displayName} has no @inject fields'); + } + + // Validate that injectable fields are properly declared + for (final field in injectFields) { + // Injectable fields should be late final for immutability after injection + if (!field.isFinal) { + throw AnnotationValidationException( + 'Injectable fields should be final for immutability', + element: field, + suggestion: + 'Add final keyword to injectable field (preferably late final)', + context: { + 'class_name': classElement.displayName, + 'field_name': field.displayName, + }, + ); + } + + // Check if field is late (recommended pattern) + try { + final isLate = (field as dynamic).isLate ?? false; + if (!isLate) { + // This is a warning, not an error - late final is recommended but not required + // We could add a warning system later + } + } catch (e) { + // Ignore if isLate is not available in this analyzer version + } + } + } +} diff --git a/cherrypick_generator/lib/src/bind_spec.dart b/cherrypick_generator/lib/src/bind_spec.dart index bf7dcd9..8641b0e 100644 --- a/cherrypick_generator/lib/src/bind_spec.dart +++ b/cherrypick_generator/lib/src/bind_spec.dart @@ -12,10 +12,12 @@ // import 'package:analyzer/dart/element/element.dart'; -import 'package:source_gen/source_gen.dart'; import 'bind_parameters_spec.dart'; import 'metadata_utils.dart'; +import 'exceptions.dart'; +import 'type_parser.dart'; +import 'annotation_validator.dart'; enum BindingType { instance, @@ -105,10 +107,42 @@ class BindSpec { final indentStr = ' ' * indent; final provide = _generateProvideClause(indent); final postfix = _generatePostfix(); - return '$indentStr' - 'bind<$returnType>()' - '$provide' - '$postfix;'; + + // Create the full single-line version first + final singleLine = '${indentStr}bind<$returnType>()$provide$postfix;'; + + // Check if we need multiline formatting + final needsMultiline = singleLine.length > 80 || provide.contains('\n'); + + if (!needsMultiline) { + return singleLine; + } + + // For multiline formatting, check if we need to break after bind() + if (provide.contains('\n')) { + // Provider clause is already multiline + if (postfix.isNotEmpty) { + // If there's a postfix, break after bind() + final multilinePostfix = _generateMultilinePostfix(indent); + return '${indentStr}bind<$returnType>()' + '\n${' ' * (indent + 4)}$provide' + '$multilinePostfix;'; + } else { + // No postfix, keep bind() with provide start + return '${indentStr}bind<$returnType>()$provide;'; + } + } else { + // Simple multiline: break after bind() + if (postfix.isNotEmpty) { + final multilinePostfix = _generateMultilinePostfix(indent); + return '${indentStr}bind<$returnType>()' + '\n${' ' * (indent + 4)}$provide' + '$multilinePostfix;'; + } else { + return '${indentStr}bind<$returnType>()' + '\n${' ' * (indent + 4)}$provide;'; + } + } } // Internal method: decides how the provide clause should be generated by param kind. @@ -122,6 +156,7 @@ class BindSpec { // Safe variable name for parameters. const paramVar = 'args'; final fnArgs = parameters.map((p) => p.generateArg(paramVar)).join(', '); + // Use multiline format only if args are long or contain newlines final multiLine = fnArgs.length > 60 || fnArgs.contains('\n'); switch (bindingType) { case BindingType.instance: @@ -146,7 +181,12 @@ class BindSpec { /// EN / RU: Supports only injected dependencies, not runtime (@params). String _generatePlainProvideClause(int indent) { final argsStr = parameters.map((p) => p.generateArg()).join(', '); - final multiLine = argsStr.length > 60 || argsStr.contains('\n'); + + // Check if we need multiline formatting based on total line length + final singleLineCall = '$methodName($argsStr)'; + final needsMultiline = + singleLineCall.length >= 45 || argsStr.contains('\n'); + switch (bindingType) { case BindingType.instance: return isAsyncInstance @@ -154,13 +194,25 @@ class BindSpec { : '.toInstance($methodName($argsStr))'; case BindingType.provide: if (isAsyncProvide) { - return multiLine - ? '.toProvideAsync(\n${' ' * (indent + 2)}() => $methodName($argsStr))' - : '.toProvideAsync(() => $methodName($argsStr))'; + if (needsMultiline) { + final lambdaIndent = + (isSingleton || named != null) ? indent + 6 : indent + 2; + final closingIndent = + (isSingleton || named != null) ? indent + 4 : indent; + return '.toProvideAsync(\n${' ' * lambdaIndent}() => $methodName($argsStr),\n${' ' * closingIndent})'; + } else { + return '.toProvideAsync(() => $methodName($argsStr))'; + } } else { - return multiLine - ? '.toProvide(\n${' ' * (indent + 2)}() => $methodName($argsStr))' - : '.toProvide(() => $methodName($argsStr))'; + if (needsMultiline) { + final lambdaIndent = + (isSingleton || named != null) ? indent + 6 : indent + 2; + final closingIndent = + (isSingleton || named != null) ? indent + 4 : indent; + return '.toProvide(\n${' ' * lambdaIndent}() => $methodName($argsStr),\n${' ' * closingIndent})'; + } else { + return '.toProvide(() => $methodName($argsStr))'; + } } } } @@ -172,6 +224,20 @@ class BindSpec { return '$namePart$singletonPart'; } + /// EN / RU: Generates multiline postfix with proper indentation. + String _generateMultilinePostfix(int indent) { + final parts = []; + if (named != null) { + parts.add(".withName('$named')"); + } + if (isSingleton) { + parts.add('.singleton()'); + } + if (parts.isEmpty) return ''; + + return parts.map((part) => '\n${' ' * (indent + 4)}$part').join(''); + } + /// ------------------------------------------------------------------------- /// fromMethod /// @@ -186,73 +252,98 @@ class BindSpec { /// асинхронности. Если нет @instance или @provide — кидает ошибку. /// ------------------------------------------------------------------------- static BindSpec fromMethod(MethodElement method) { - var returnType = method.returnType.getDisplayString(); + try { + // Validate method annotations + AnnotationValidator.validateMethodAnnotations(method); - final methodName = method.displayName; - // Check for @singleton annotation. - final isSingleton = MetadataUtils.anyMeta(method.metadata, 'singleton'); + // Parse return type using improved type parser + final parsedReturnType = TypeParser.parseType(method.returnType, method); - // Get @named value if present. - final named = MetadataUtils.getNamedValue(method.metadata); + final methodName = method.displayName; - // Parse each method parameter. - final params = []; - bool hasParams = false; - for (final p in method.parameters) { - final typeStr = p.type.getDisplayString(); - final paramNamed = MetadataUtils.getNamedValue(p.metadata); - final isParams = MetadataUtils.anyMeta(p.metadata, 'params'); - if (isParams) hasParams = true; - params.add(BindParameterSpec(typeStr, paramNamed, isParams: isParams)); - } + // Check for @singleton annotation. + final isSingleton = MetadataUtils.anyMeta(method.metadata, 'singleton'); - // Determine bindingType: @instance or @provide. - final hasInstance = MetadataUtils.anyMeta(method.metadata, 'instance'); - final hasProvide = MetadataUtils.anyMeta(method.metadata, 'provide'); - if (!hasInstance && !hasProvide) { - throw InvalidGenerationSourceError( - 'Method $methodName must be marked with @instance() or @provide().', + // Get @named value if present. + final named = MetadataUtils.getNamedValue(method.metadata); + + // Parse each method parameter. + final params = []; + bool hasParams = false; + for (final p in method.parameters) { + final typeStr = p.type.getDisplayString(); + final paramNamed = MetadataUtils.getNamedValue(p.metadata); + final isParams = MetadataUtils.anyMeta(p.metadata, 'params'); + if (isParams) hasParams = true; + params.add(BindParameterSpec(typeStr, paramNamed, isParams: isParams)); + } + + // Determine bindingType: @instance or @provide. + final hasInstance = MetadataUtils.anyMeta(method.metadata, 'instance'); + final hasProvide = MetadataUtils.anyMeta(method.metadata, 'provide'); + + if (!hasInstance && !hasProvide) { + throw AnnotationValidationException( + 'Method must be marked with either @instance() or @provide() annotation', + element: method, + suggestion: + 'Add @instance() for direct instances or @provide() for factory methods', + context: { + 'method_name': methodName, + 'return_type': parsedReturnType.displayString, + }, + ); + } + + final bindingType = + hasInstance ? BindingType.instance : BindingType.provide; + + // PROHIBIT @params with @instance bindings! + if (bindingType == BindingType.instance && hasParams) { + throw AnnotationValidationException( + '@params() (runtime arguments) cannot be used together with @instance()', + element: method, + suggestion: 'Use @provide() instead if you want runtime arguments', + context: { + 'method_name': methodName, + 'binding_type': 'instance', + 'has_params': hasParams, + }, + ); + } + + // Set async flags based on parsed type + final isAsyncInstance = + bindingType == BindingType.instance && parsedReturnType.isFuture; + final isAsyncProvide = + bindingType == BindingType.provide && parsedReturnType.isFuture; + + return BindSpec( + returnType: parsedReturnType.codeGenType, + methodName: methodName, + isSingleton: isSingleton, + named: named, + parameters: params, + bindingType: bindingType, + isAsyncInstance: isAsyncInstance, + isAsyncProvide: isAsyncProvide, + hasParams: hasParams, + ); + } catch (e) { + if (e is CherryPickGeneratorException) { + rethrow; + } + throw CodeGenerationException( + 'Failed to create BindSpec from method "${method.displayName}"', element: method, + suggestion: + 'Check that the method has valid annotations and return type', + context: { + 'method_name': method.displayName, + 'return_type': method.returnType.getDisplayString(), + 'error': e.toString(), + }, ); } - final bindingType = - hasInstance ? BindingType.instance : BindingType.provide; - - // PROHIBIT @params with @instance bindings! - if (bindingType == BindingType.instance && hasParams) { - throw InvalidGenerationSourceError( - '@params() (runtime arguments) cannot be used together with @instance() on method $methodName. ' - 'Use @provide() instead if you want runtime arguments.', - element: method, - ); - } - - // -- Extract inner type for Future and set async flags. - bool isAsyncInstance = false; - bool isAsyncProvide = false; - final futureInnerType = _extractFutureInnerType(returnType); - if (futureInnerType != null) { - returnType = futureInnerType; - if (bindingType == BindingType.instance) isAsyncInstance = true; - if (bindingType == BindingType.provide) isAsyncProvide = true; - } - - return BindSpec( - returnType: returnType, - methodName: methodName, - isSingleton: isSingleton, - named: named, - parameters: params, - bindingType: bindingType, - isAsyncInstance: isAsyncInstance, - isAsyncProvide: isAsyncProvide, - hasParams: hasParams, - ); - } - - /// EN / RU: Extracts inner type from Future, returns e.g. "T" or null. - static String? _extractFutureInnerType(String typeName) { - final match = RegExp(r'^Future<(.+)>$').firstMatch(typeName); - return match?.group(1)?.trim(); } } diff --git a/cherrypick_generator/lib/src/exceptions.dart b/cherrypick_generator/lib/src/exceptions.dart new file mode 100644 index 0000000..4796e60 --- /dev/null +++ b/cherrypick_generator/lib/src/exceptions.dart @@ -0,0 +1,117 @@ +// +// Copyright 2021 Sergey Penkovsky (sergey.penkovsky@gmail.com) +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import 'package:analyzer/dart/element/element.dart'; +import 'package:source_gen/source_gen.dart'; + +/// Enhanced exception class for CherryPick generator with detailed context information +class CherryPickGeneratorException extends InvalidGenerationSourceError { + final String category; + final String? suggestion; + final Map? context; + + CherryPickGeneratorException( + String message, { + required Element element, + required this.category, + this.suggestion, + this.context, + }) : super( + _formatMessage(message, category, suggestion, context, element), + element: element, + ); + + static String _formatMessage( + String message, + String category, + String? suggestion, + Map? context, + Element element, + ) { + final buffer = StringBuffer(); + + // Header with category + buffer.writeln('[$category] $message'); + + // Element context + buffer.writeln(''); + buffer.writeln('Context:'); + buffer.writeln(' Element: ${element.displayName}'); + buffer.writeln(' Type: ${element.runtimeType}'); + buffer.writeln(' Location: ${element.source?.fullName ?? 'unknown'}'); + + // Note: enclosingElement may not be available in all analyzer versions + try { + final enclosing = (element as dynamic).enclosingElement; + if (enclosing != null) { + buffer.writeln(' Enclosing: ${enclosing.displayName}'); + } + } catch (e) { + // Ignore if enclosingElement is not available + } + + // Additional context + if (context != null && context.isNotEmpty) { + buffer.writeln(''); + buffer.writeln('Additional Context:'); + context.forEach((key, value) { + buffer.writeln(' $key: $value'); + }); + } + + // Suggestion + if (suggestion != null) { + buffer.writeln(''); + buffer.writeln('💡 Suggestion: $suggestion'); + } + + return buffer.toString(); + } +} + +/// Specific exception types for different error categories +class AnnotationValidationException extends CherryPickGeneratorException { + AnnotationValidationException( + super.message, { + required super.element, + super.suggestion, + super.context, + }) : super(category: 'ANNOTATION_VALIDATION'); +} + +class TypeParsingException extends CherryPickGeneratorException { + TypeParsingException( + super.message, { + required super.element, + super.suggestion, + super.context, + }) : super(category: 'TYPE_PARSING'); +} + +class CodeGenerationException extends CherryPickGeneratorException { + CodeGenerationException( + super.message, { + required super.element, + super.suggestion, + super.context, + }) : super(category: 'CODE_GENERATION'); +} + +class DependencyResolutionException extends CherryPickGeneratorException { + DependencyResolutionException( + super.message, { + required super.element, + super.suggestion, + super.context, + }) : super(category: 'DEPENDENCY_RESOLUTION'); +} diff --git a/cherrypick_generator/lib/src/generated_class.dart b/cherrypick_generator/lib/src/generated_class.dart index 8d1e68c..b22dba1 100644 --- a/cherrypick_generator/lib/src/generated_class.dart +++ b/cherrypick_generator/lib/src/generated_class.dart @@ -49,10 +49,15 @@ class GeneratedClass { /// Список всех обнаруженных биндингов final List binds; + /// Source file name for the part directive + /// Имя исходного файла для part директивы + final String sourceFile; + GeneratedClass( this.className, this.generatedClassName, this.binds, + this.sourceFile, ); /// ------------------------------------------------------------------------- @@ -72,13 +77,15 @@ class GeneratedClass { final className = element.displayName; // Generated class name with '$' prefix (standard for generated Dart code). final generatedClassName = r'$' + className; + // Get source file name + final sourceFile = element.source.shortName; // Collect bindings for all non-abstract methods. final binds = element.methods .where((m) => !m.isAbstract) .map(BindSpec.fromMethod) .toList(); - return GeneratedClass(className, generatedClassName, binds); + return GeneratedClass(className, generatedClassName, binds, sourceFile); } /// ------------------------------------------------------------------------- @@ -95,11 +102,10 @@ class GeneratedClass { /// и регистрирует все зависимости через методы bind()... /// ------------------------------------------------------------------------- String generate() { - final buffer = StringBuffer(); - - buffer.writeln('final class $generatedClassName extends $className {'); - buffer.writeln(' @override'); - buffer.writeln(' void builder(Scope currentScope) {'); + final buffer = StringBuffer() + ..writeln('final class $generatedClassName extends $className {') + ..writeln(' @override') + ..writeln(' void builder(Scope currentScope) {'); // For each binding, generate bind() code string. // Для каждого биндинга — генерируем строку bind()... @@ -107,8 +113,9 @@ class GeneratedClass { buffer.writeln(bind.generateBind(4)); } - buffer.writeln(' }'); - buffer.writeln('}'); + buffer + ..writeln(' }') + ..writeln('}'); return buffer.toString(); } diff --git a/cherrypick_generator/lib/src/type_parser.dart b/cherrypick_generator/lib/src/type_parser.dart new file mode 100644 index 0000000..6c9ee1c --- /dev/null +++ b/cherrypick_generator/lib/src/type_parser.dart @@ -0,0 +1,218 @@ +// +// Copyright 2021 Sergey Penkovsky (sergey.penkovsky@gmail.com) +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/nullability_suffix.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'exceptions.dart'; + +/// Enhanced type parser that uses AST analysis instead of regular expressions +class TypeParser { + /// Parses a DartType and extracts detailed type information + static ParsedType parseType(DartType dartType, Element context) { + try { + return _parseTypeInternal(dartType, context); + } catch (e) { + throw TypeParsingException( + 'Failed to parse type: ${dartType.getDisplayString()}', + element: context, + suggestion: 'Ensure the type is properly imported and accessible', + context: { + 'original_type': dartType.getDisplayString(), + 'error': e.toString(), + }, + ); + } + } + + static ParsedType _parseTypeInternal(DartType dartType, Element context) { + final displayString = dartType.getDisplayString(); + final isNullable = dartType.nullabilitySuffix == NullabilitySuffix.question; + + // Check if it's a Future type + if (dartType.isDartAsyncFuture) { + return _parseFutureType(dartType, context, isNullable); + } + + // Check if it's a generic type (List, Map, etc.) + if (dartType is ParameterizedType && dartType.typeArguments.isNotEmpty) { + return _parseGenericType(dartType, context, isNullable); + } + + // Simple type + return ParsedType( + displayString: displayString, + coreType: displayString.replaceAll('?', ''), + isNullable: isNullable, + isFuture: false, + isGeneric: false, + typeArguments: [], + ); + } + + static ParsedType _parseFutureType( + DartType dartType, Element context, bool isNullable) { + if (dartType is! ParameterizedType || dartType.typeArguments.isEmpty) { + throw TypeParsingException( + 'Future type must have a type argument', + element: context, + suggestion: 'Use Future instead of raw Future', + context: {'type': dartType.getDisplayString()}, + ); + } + + final innerType = dartType.typeArguments.first; + final innerParsed = _parseTypeInternal(innerType, context); + + return ParsedType( + displayString: dartType.getDisplayString(), + coreType: innerParsed.coreType, + isNullable: isNullable || innerParsed.isNullable, + isFuture: true, + isGeneric: innerParsed.isGeneric, + typeArguments: innerParsed.typeArguments, + innerType: innerParsed, + ); + } + + static ParsedType _parseGenericType( + ParameterizedType dartType, Element context, bool isNullable) { + final typeArguments = dartType.typeArguments + .map((arg) => _parseTypeInternal(arg, context)) + .toList(); + + final baseType = dartType.element?.name ?? dartType.getDisplayString(); + + return ParsedType( + displayString: dartType.getDisplayString(), + coreType: baseType, + isNullable: isNullable, + isFuture: false, + isGeneric: true, + typeArguments: typeArguments, + ); + } + + /// Validates that a type is suitable for dependency injection + static void validateInjectableType(ParsedType parsedType, Element context) { + // Check for void type + if (parsedType.coreType == 'void') { + throw TypeParsingException( + 'Cannot inject void type', + element: context, + suggestion: 'Use a concrete type instead of void', + ); + } + + // Check for dynamic type (warning) + if (parsedType.coreType == 'dynamic') { + // This could be a warning instead of an error + throw TypeParsingException( + 'Using dynamic type reduces type safety', + element: context, + suggestion: 'Consider using a specific type instead of dynamic', + ); + } + + // Validate nested types for complex generics + for (final typeArg in parsedType.typeArguments) { + validateInjectableType(typeArg, context); + } + } +} + +/// Represents a parsed type with detailed information +class ParsedType { + /// The full display string of the type (e.g., "Future?>") + final String displayString; + + /// The core type name without nullability and Future wrapper (e.g., "List") + final String coreType; + + /// Whether the type is nullable + final bool isNullable; + + /// Whether the type is wrapped in Future + final bool isFuture; + + /// Whether the type has generic parameters + final bool isGeneric; + + /// Parsed type arguments for generic types + final List typeArguments; + + /// For Future types, the inner type + final ParsedType? innerType; + + const ParsedType({ + required this.displayString, + required this.coreType, + required this.isNullable, + required this.isFuture, + required this.isGeneric, + required this.typeArguments, + this.innerType, + }); + + /// Returns the type string suitable for code generation + String get codeGenType { + if (isFuture && innerType != null) { + return innerType!.codeGenType; + } + + // For generic types, include type arguments + if (isGeneric && typeArguments.isNotEmpty) { + final args = typeArguments.map((arg) => arg.codeGenType).join(', '); + return '$coreType<$args>'; + } + + return coreType; + } + + /// Returns whether this type should use tryResolve instead of resolve + bool get shouldUseTryResolve => isNullable; + + /// Returns the appropriate resolve method name + String get resolveMethodName { + if (isFuture) { + return shouldUseTryResolve ? 'tryResolveAsync' : 'resolveAsync'; + } + return shouldUseTryResolve ? 'tryResolve' : 'resolve'; + } + + @override + String toString() { + return 'ParsedType(displayString: $displayString, coreType: $coreType, ' + 'isNullable: $isNullable, isFuture: $isFuture, isGeneric: $isGeneric)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is ParsedType && + other.displayString == displayString && + other.coreType == coreType && + other.isNullable == isNullable && + other.isFuture == isFuture && + other.isGeneric == isGeneric; + } + + @override + int get hashCode { + return displayString.hashCode ^ + coreType.hashCode ^ + isNullable.hashCode ^ + isFuture.hashCode ^ + isGeneric.hashCode; + } +} diff --git a/cherrypick_generator/test/type_parser_test.dart b/cherrypick_generator/test/type_parser_test.dart new file mode 100644 index 0000000..128fde3 --- /dev/null +++ b/cherrypick_generator/test/type_parser_test.dart @@ -0,0 +1,235 @@ +import 'package:test/test.dart'; + +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/source/source.dart'; +import 'package:cherrypick_generator/src/type_parser.dart'; +import 'package:cherrypick_generator/src/exceptions.dart'; + +void main() { + group('TypeParser', () { + group('parseType', () { + test('should parse simple types correctly', () { + // This would require setting up analyzer infrastructure + // For now, we'll test the ParsedType class directly + }); + + test('should parse Future types correctly', () { + // This would require setting up analyzer infrastructure + // For now, we'll test the ParsedType class directly + }); + + test('should parse nullable types correctly', () { + // This would require setting up analyzer infrastructure + // For now, we'll test the ParsedType class directly + }); + + test('should throw TypeParsingException for invalid types', () { + // This would require setting up analyzer infrastructure + // For now, we'll test the ParsedType class directly + }); + }); + + group('validateInjectableType', () { + test('should throw for void type', () { + final parsedType = ParsedType( + displayString: 'void', + coreType: 'void', + isNullable: false, + isFuture: false, + isGeneric: false, + typeArguments: [], + ); + + expect( + () => TypeParser.validateInjectableType( + parsedType, _createMockElement()), + throwsA(isA()), + ); + }); + + test('should throw for dynamic type', () { + final parsedType = ParsedType( + displayString: 'dynamic', + coreType: 'dynamic', + isNullable: false, + isFuture: false, + isGeneric: false, + typeArguments: [], + ); + + expect( + () => TypeParser.validateInjectableType( + parsedType, _createMockElement()), + throwsA(isA()), + ); + }); + + test('should pass for valid types', () { + final parsedType = ParsedType( + displayString: 'String', + coreType: 'String', + isNullable: false, + isFuture: false, + isGeneric: false, + typeArguments: [], + ); + + expect( + () => TypeParser.validateInjectableType( + parsedType, _createMockElement()), + returnsNormally, + ); + }); + }); + }); + + group('ParsedType', () { + test('should return correct codeGenType for simple types', () { + final parsedType = ParsedType( + displayString: 'String', + coreType: 'String', + isNullable: false, + isFuture: false, + isGeneric: false, + typeArguments: [], + ); + + expect(parsedType.codeGenType, equals('String')); + }); + + test('should return correct codeGenType for Future types', () { + final innerType = ParsedType( + displayString: 'String', + coreType: 'String', + isNullable: false, + isFuture: false, + isGeneric: false, + typeArguments: [], + ); + + final parsedType = ParsedType( + displayString: 'Future', + coreType: 'Future', + isNullable: false, + isFuture: true, + isGeneric: false, + typeArguments: [], + innerType: innerType, + ); + + expect(parsedType.codeGenType, equals('String')); + }); + + test('should return correct resolveMethodName for sync types', () { + final parsedType = ParsedType( + displayString: 'String', + coreType: 'String', + isNullable: false, + isFuture: false, + isGeneric: false, + typeArguments: [], + ); + + expect(parsedType.resolveMethodName, equals('resolve')); + }); + + test('should return correct resolveMethodName for nullable sync types', () { + final parsedType = ParsedType( + displayString: 'String?', + coreType: 'String', + isNullable: true, + isFuture: false, + isGeneric: false, + typeArguments: [], + ); + + expect(parsedType.resolveMethodName, equals('tryResolve')); + }); + + test('should return correct resolveMethodName for async types', () { + final parsedType = ParsedType( + displayString: 'Future', + coreType: 'String', + isNullable: false, + isFuture: true, + isGeneric: false, + typeArguments: [], + ); + + expect(parsedType.resolveMethodName, equals('resolveAsync')); + }); + + test('should return correct resolveMethodName for nullable async types', + () { + final parsedType = ParsedType( + displayString: 'Future', + coreType: 'String', + isNullable: true, + isFuture: true, + isGeneric: false, + typeArguments: [], + ); + + expect(parsedType.resolveMethodName, equals('tryResolveAsync')); + }); + + test('should implement equality correctly', () { + final parsedType1 = ParsedType( + displayString: 'String', + coreType: 'String', + isNullable: false, + isFuture: false, + isGeneric: false, + typeArguments: [], + ); + + final parsedType2 = ParsedType( + displayString: 'String', + coreType: 'String', + isNullable: false, + isFuture: false, + isGeneric: false, + typeArguments: [], + ); + + expect(parsedType1, equals(parsedType2)); + expect(parsedType1.hashCode, equals(parsedType2.hashCode)); + }); + + test('should implement toString correctly', () { + final parsedType = ParsedType( + displayString: 'String', + coreType: 'String', + isNullable: false, + isFuture: false, + isGeneric: false, + typeArguments: [], + ); + + final result = parsedType.toString(); + expect(result, contains('ParsedType')); + expect(result, contains('String')); + expect(result, contains('isNullable: false')); + expect(result, contains('isFuture: false')); + }); + }); +} + +// Mock element for testing +Element _createMockElement() { + return _MockElement(); +} + +class _MockElement implements Element { + @override + String get displayName => 'MockElement'; + + @override + String get name => 'MockElement'; + + @override + Source? get source => null; + + @override + noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +}