diff --git a/packages/pigeon/CHANGELOG.md b/packages/pigeon/CHANGELOG.md index 1d8884d3e71c..d3a14ccbaab4 100644 --- a/packages/pigeon/CHANGELOG.md +++ b/packages/pigeon/CHANGELOG.md @@ -1,3 +1,8 @@ +## 26.3.5 + +* Adds support for parsing Pigeon definitions split across multiple Dart `part` + files. + ## 26.3.4 * [kotlin] Updates generated error class to inherit from `RuntimeException` diff --git a/packages/pigeon/lib/src/generator_tools.dart b/packages/pigeon/lib/src/generator_tools.dart index 55874a95684a..c4870feba80f 100644 --- a/packages/pigeon/lib/src/generator_tools.dart +++ b/packages/pigeon/lib/src/generator_tools.dart @@ -15,7 +15,7 @@ import 'generator.dart'; /// The current version of pigeon. /// /// This must match the version in pubspec.yaml. -const String pigeonVersion = '26.3.4'; +const String pigeonVersion = '26.3.5'; /// Default plugin package name. const String defaultPluginPackageName = 'dev.flutter.pigeon'; diff --git a/packages/pigeon/lib/src/pigeon_lib.dart b/packages/pigeon/lib/src/pigeon_lib.dart index fd6b6c827b2b..6e34e95c1c0d 100644 --- a/packages/pigeon/lib/src/pigeon_lib.dart +++ b/packages/pigeon/lib/src/pigeon_lib.dart @@ -13,6 +13,7 @@ import 'package:analyzer/dart/analysis/analysis_context_collection.dart' show AnalysisContextCollection; import 'package:analyzer/dart/analysis/results.dart' show ParsedUnitResult; import 'package:analyzer/dart/analysis/session.dart' show AnalysisSession; +import 'package:analyzer/dart/analysis/utilities.dart' show parseString; import 'package:analyzer/dart/ast/ast.dart' as dart_ast; import 'package:analyzer/diagnostic/diagnostic.dart' show Diagnostic; import 'package:args/args.dart'; @@ -454,22 +455,45 @@ class Pigeon { /// [sdkPath] for specifying the Dart SDK path for /// [AnalysisContextCollection]. ParseResults parseFile(String inputPath, {String? sdkPath}) { - final includedPaths = [path.absolute(path.normalize(inputPath))]; + final String normalizedInputPath = path.absolute(path.normalize(inputPath)); + final _CollectedInput input = _collectInputAndParts(normalizedInputPath); + if (input.missingPath != null) { + return ParseResults( + root: Root.makeEmpty(), + errors: [ + Error( + message: 'File ${input.missingPath} does not exist', + filename: input.missingPath, + ), + ], + pigeonOptions: null, + ); + } + final collection = AnalysisContextCollection( - includedPaths: includedPaths, + includedPaths: input.paths, sdkPath: sdkPath, ); final compilationErrors = []; - final rootBuilder = RootBuilder(File(inputPath).readAsStringSync()); + final String rootInputString = _getInputString( + inputPath: normalizedInputPath, + contents: input.contents, + units: input.units, + paths: input.paths, + ); + final rootBuilder = RootBuilder(rootInputString); + final dart_ast.CompilationUnit mergedUnit = parseString( + content: rootInputString, + path: normalizedInputPath, + throwIfDiagnostics: false, + ).unit; + mergedUnit.accept(rootBuilder); for (final AnalysisContext context in collection.contexts) { - for (final String path in context.contextRoot.analyzedFiles()) { + for (final String path in input.paths) { final AnalysisSession session = context.currentSession; final result = session.getParsedUnit(path) as ParsedUnitResult; - if (result.diagnostics.isEmpty) { - final dart_ast.CompilationUnit unit = result.unit; - unit.accept(rootBuilder); - } else { + if (result.diagnostics.isNotEmpty) { for (final Diagnostic diagnostic in result.diagnostics) { compilationErrors.add( Error( @@ -497,6 +521,151 @@ class Pigeon { } } + String? _readFileOrNull(String filePath) { + final file = File(filePath); + if (!file.existsSync()) { + return null; + } + return file.readAsStringSync(); + } + + List _getPartPaths( + Iterable directives, { + required String sourcePath, + }) { + final parts = []; + for (final directive in directives) { + if (directive is dart_ast.PartDirective) { + final String? uri = directive.uri.stringValue; + if (uri != null) { + parts.add( + path.absolute( + path.normalize(path.join(path.dirname(sourcePath), uri)), + ), + ); + } + } + } + return parts; + } + + ({String body, List imports}) _stripPartsAndCollectImports({ + required String sourceContent, + required dart_ast.CompilationUnit unit, + required bool collectImports, + }) { + final imports = []; + final removalRanges = <({int start, int end})>[]; + for (final dart_ast.Directive directive in unit.directives) { + if (directive is dart_ast.ImportDirective) { + if (collectImports) { + imports.add( + sourceContent + .substring(directive.offset, directive.end) + .trimRight(), + ); + } + removalRanges.add((start: directive.offset, end: directive.end)); + } else if (directive is dart_ast.PartDirective || + directive is dart_ast.PartOfDirective || + directive is dart_ast.LibraryDirective) { + removalRanges.add((start: directive.offset, end: directive.end)); + } + } + + final body = StringBuffer(); + var start = 0; + for (final range in removalRanges) { + body.write(sourceContent.substring(start, range.start)); + start = range.end; + } + body.write(sourceContent.substring(start)); + return (body: body.toString().trim(), imports: imports); + } + + String _getInputString({ + required String inputPath, + required List paths, + required Map contents, + required Map units, + }) { + final ({String body, List imports}) mainResult = + _stripPartsAndCollectImports( + sourceContent: contents[inputPath]!, + unit: units[inputPath]!, + collectImports: true, + ); + final List imports = mainResult.imports; + final partBodies = []; + for (final String currentPath in paths.skip(1)) { + final ({String body, List imports}) partResult = + _stripPartsAndCollectImports( + sourceContent: contents[currentPath]!, + unit: units[currentPath]!, + collectImports: false, + ); + partBodies.add(partResult.body); + } + + final output = StringBuffer(); + if (imports.isNotEmpty) { + output.writeln(imports.join('\n')); + output.writeln(); + } + output.writeln(mainResult.body); + for (final partBody in partBodies) { + if (partBody.isNotEmpty) { + output.writeln(); + output.writeln(); + output.write(partBody); + } + } + return output.toString().trimRight(); + } + + _CollectedInput _collectInputAndParts(String inputPath) { + final paths = []; + final contents = {}; + final units = {}; + final pending = [inputPath]; + final seen = {}; + while (pending.isNotEmpty) { + final String currentPath = pending.removeAt(0); + if (seen.contains(currentPath)) { + continue; + } + seen.add(currentPath); + final String? content = _readFileOrNull(currentPath); + if (content == null) { + return _CollectedInput( + paths: paths, + contents: contents, + units: units, + missingPath: currentPath, + ); + } + final dart_ast.CompilationUnit unit = parseString( + content: content, + path: currentPath, + throwIfDiagnostics: false, + ).unit; + paths.add(currentPath); + contents[currentPath] = content; + units[currentPath] = unit; + final List partPaths = _getPartPaths( + unit.directives, + sourcePath: currentPath, + ); + pending.addAll(partPaths); + } + return _CollectedInput( + paths: paths, + contents: contents, + units: units, + missingPath: null, + ); + } + /// String that describes how the tool is used. static String get usage { return ''' @@ -862,3 +1031,17 @@ class ParseResults { /// [ConfigurePigeon] during parsing. final Map? pigeonOptions; } + +class _CollectedInput { + _CollectedInput({ + required this.paths, + required this.contents, + required this.units, + required this.missingPath, + }); + + final List paths; + final Map contents; + final Map units; + final String? missingPath; +} diff --git a/packages/pigeon/pubspec.yaml b/packages/pigeon/pubspec.yaml index 804181258e69..cc14e1b3ee5a 100644 --- a/packages/pigeon/pubspec.yaml +++ b/packages/pigeon/pubspec.yaml @@ -2,7 +2,7 @@ name: pigeon description: Code generator tool to make communication between Flutter and the host platform type-safe and easier. repository: https://github.com/flutter/packages/tree/main/packages/pigeon issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+pigeon%22 -version: 26.3.4 # This must match the version in lib/src/generator_tools.dart +version: 26.3.5 # This must match the version in lib/src/generator_tools.dart environment: sdk: ^3.9.0 diff --git a/packages/pigeon/test/pigeon_lib_test.dart b/packages/pigeon/test/pigeon_lib_test.dart index 7b2d3b78a1c7..6adf7c333000 100644 --- a/packages/pigeon/test/pigeon_lib_test.dart +++ b/packages/pigeon/test/pigeon_lib_test.dart @@ -65,6 +65,26 @@ void main() { return results!; } + ParseResults parseSourceWithParts({ + required String mainSource, + required Map partSources, + }) { + final Pigeon dartle = Pigeon.setup(); + final Directory dir = Directory.systemTemp.createTempSync(); + try { + for (final MapEntry part in partSources.entries) { + final partFile = File('${dir.path}/${part.key}'); + partFile.createSync(recursive: true); + partFile.writeAsStringSync(part.value); + } + final mainFile = File('${dir.path}/source.dart') + ..writeAsStringSync(mainSource); + return dartle.parseFile(mainFile.path); + } finally { + dir.deleteSync(recursive: true); + } + } + test('parse args - input', () { final PigeonOptions opts = Pigeon.parseArgs([ '--input', @@ -249,6 +269,101 @@ abstract class Api1 { expect(unused?.fields[0].type.isNullable, isTrue); }); + test('parse source split across multiple part files', () { + const mainSource = ''' +part 'shared_classes.dart'; +part 'api.dart'; +'''; + const sharedClassesPart = ''' +part of 'source.dart'; + +class Input1 { + String? input; +} + +class Output1 { + String? output; +} +'''; + const apiPart = ''' +part of 'source.dart'; + +@HostApi() +abstract class Api1 { + Output1 doit(Input1 input); +} +'''; + final ParseResults parseResult = parseSourceWithParts( + mainSource: mainSource, + partSources: { + 'shared_classes.dart': sharedClassesPart, + 'api.dart': apiPart, + }, + ); + + expect(parseResult.errors, isEmpty); + expect(parseResult.root.apis, hasLength(1)); + expect(parseResult.root.apis[0].name, equals('Api1')); + expect(parseResult.root.apis[0].methods, hasLength(1)); + expect(parseResult.root.apis[0].methods[0].name, equals('doit')); + expect(parseResult.root.apis[0].methods[0].returnType.baseName, 'Output1'); + expect(parseResult.root.apis[0].methods[0].parameters, hasLength(1)); + expect( + parseResult.root.apis[0].methods[0].parameters[0].type.baseName, + equals('Input1'), + ); + expect( + parseResult.root.classes.map( + (Class classDefinition) => classDefinition.name, + ), + containsAll(['Input1', 'Output1']), + ); + }); + + test('missing part file returns parse error', () { + const source = ''' +part 'missing.dart'; +'''; + final ParseResults parseResult = parseSource(source); + expect(parseResult.root.apis, isEmpty); + expect(parseResult.root.classes, isEmpty); + expect(parseResult.errors, isNotEmpty); + }); + + test('part errors keep part file line numbers', () { + const mainSource = ''' +part 'api.dart'; +part 'extra.dart'; +'''; + const apiPart = ''' +part of 'source.dart'; + +@HostApi() +@FlutterApi() +abstract class Api1 { + void ping(); +} +'''; + const extraPart = ''' +part of 'source.dart'; +class Extra { + int? value; +} +'''; + final ParseResults parseResult = parseSourceWithParts( + mainSource: mainSource, + partSources: { + 'api.dart': apiPart, + 'extra.dart': extraPart, + }, + ); + final Error apiAnnotationError = parseResult.errors.firstWhere( + (Error error) => + error.message.contains('can only have one API annotation'), + ); + expect(apiAnnotationError.lineNumber, 4); + }); + test('invalid datatype', () { const source = ''' class InvalidDatatype {