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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/pigeon/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`
Expand Down
2 changes: 1 addition & 1 deletion packages/pigeon/lib/src/generator_tools.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
199 changes: 191 additions & 8 deletions packages/pigeon/lib/src/pigeon_lib.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -454,22 +455,45 @@ class Pigeon {
/// [sdkPath] for specifying the Dart SDK path for
/// [AnalysisContextCollection].
ParseResults parseFile(String inputPath, {String? sdkPath}) {
final includedPaths = <String>[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>[
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 = <Error>[];
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);
Comment on lines +485 to +491
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Merging the contents of the main file and its parts into a single rootInputString causes a regression in error reporting. Any errors identified by RootBuilder (e.g., invalid types, unsupported constructs) will report line numbers relative to this concatenated string and attribute them to the main file path, making it difficult for users to locate the actual issue in their source files. Consider a strategy that preserves the mapping to original files or avoids string concatenation by visiting multiple CompilationUnits while providing the correct source context for each.

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(
Expand Down Expand Up @@ -497,6 +521,151 @@ class Pigeon {
}
}

String? _readFileOrNull(String filePath) {
final file = File(filePath);
if (!file.existsSync()) {
return null;
}
return file.readAsStringSync();
}

List<String> _getPartPaths(
Iterable<dart_ast.Directive> directives, {
required String sourcePath,
}) {
final parts = <String>[];
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;
}
Comment on lines +532 to +550
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The current implementation of _getPartPaths only retrieves part files referenced directly in the main Pigeon file. It does not support nested part files (i.e., a part file that itself contains part directives), which is valid Dart. To fully support the part mechanism, this should recursively resolve part paths.


({String body, List<String> imports}) _stripPartsAndCollectImports({
required String sourceContent,
required dart_ast.CompilationUnit unit,
required bool collectImports,
}) {
final imports = <String>[];
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));
}
Comment on lines +569 to +573
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The _stripPartsAndCollectImports function does not handle LibraryDirectives. If the main Pigeon file contains a library directive, it will remain in the body while imports are moved to the top in _getInputString. This results in invalid Dart code (as library must precede import), which may cause the subsequent parseString call or RootBuilder to behave unexpectedly. Consider stripping LibraryDirective as well.

      } 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<String> paths,
required Map<String, String> contents,
required Map<String, dart_ast.CompilationUnit> units,
}) {
final ({String body, List<String> imports}) mainResult =
_stripPartsAndCollectImports(
sourceContent: contents[inputPath]!,
unit: units[inputPath]!,
collectImports: true,
);
final List<String> imports = mainResult.imports;
final partBodies = <String>[];
for (final String currentPath in paths.skip(1)) {
final ({String body, List<String> 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 = <String>[];
final contents = <String, String>{};
final units = <String, dart_ast.CompilationUnit>{};
final pending = <String>[inputPath];
final seen = <String>{};
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<String> 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 '''
Expand Down Expand Up @@ -862,3 +1031,17 @@ class ParseResults {
/// [ConfigurePigeon] during parsing.
final Map<String, Object>? pigeonOptions;
}

class _CollectedInput {
_CollectedInput({
required this.paths,
required this.contents,
required this.units,
required this.missingPath,
});

final List<String> paths;
final Map<String, String> contents;
final Map<String, dart_ast.CompilationUnit> units;
final String? missingPath;
}
2 changes: 1 addition & 1 deletion packages/pigeon/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
115 changes: 115 additions & 0 deletions packages/pigeon/test/pigeon_lib_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,26 @@ void main() {
return results!;
}

ParseResults parseSourceWithParts({
required String mainSource,
required Map<String, String> partSources,
}) {
final Pigeon dartle = Pigeon.setup();
final Directory dir = Directory.systemTemp.createTempSync();
try {
for (final MapEntry<String, String> 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(<String>[
'--input',
Expand Down Expand Up @@ -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: <String, String>{
'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(<String>['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: <String, String>{
'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 {
Expand Down