diff --git a/pkgs/ffigen/CHANGELOG.md b/pkgs/ffigen/CHANGELOG.md index 6765fc3845..63d5a4a49f 100644 --- a/pkgs/ffigen/CHANGELOG.md +++ b/pkgs/ffigen/CHANGELOG.md @@ -3,6 +3,14 @@ - Bump `package:meta` dependency to `^1.19.0` and stop generating `experimental_member_use` lint ignore in generated bindings when `@RecordUse()` is used. +- Add `dart run ffigen:migrate` to generate a Dart script (using the + `FfiGenerator` API) from an existing YAML configuration, to help migrate off + the YAML config format. +- Re-export `Logger` from `package:logging` in `package:ffigen/ffigen.dart`, + since it appears in the public API (e.g. in `defaultCompilerOpts`). +- Add `Headers.includeGlobs`, a helper to build a `Headers.include` filter + from glob patterns, matching the semantics of the YAML config's + `include-directives`. ## 21.0.0 diff --git a/pkgs/ffigen/README.md b/pkgs/ffigen/README.md index 190f275cc4..ef95f080a7 100644 --- a/pkgs/ffigen/README.md +++ b/pkgs/ffigen/README.md @@ -149,11 +149,40 @@ in the following way: 1. Install Xcode. 2. Install Xcode command line tools: `xcode-select --install`. +## Migrating from YAML + +If you have an existing YAML configuration and want to move to the Dart API, +run the bundled migration tool: + +```bash +dart run ffigen:migrate --config config.yaml --output tool/ffigen.dart +``` + +If `--config` is omitted, the tool looks for an `ffigen:` key in +`pubspec.yaml`, matching `dart run ffigen`'s own default. If `--output` is +omitted, the script is written to `tool/ffigen.dart`. The generated script can +then be run directly with `dart run tool/ffigen.dart` (or via a +[build hook](#getting-started)) to regenerate bindings, and can be committed +in place of the YAML file. + +The migration is best-effort: it translates each YAML key to the closest +equivalent `FfiGenerator` API call, falling back to inline callbacks (for +example, for regex-based `include`/`exclude`/`rename` entries) where there is +no direct equivalent. Options that have no Dart API equivalent are preserved +as `// TODO(ffigen migration): ...` comments in the generated file so nothing +from the original configuration is silently dropped. Review the generated +script, resolve any `TODO`s, and compare output before deleting the original +YAML file. + +Run `dart run ffigen:migrate --help` to see all available options. + ## YAML Configuration Reference In addition to the Dart API shown in the "Getting Started" section, FFIgen can also be configured via YAML. Support for the YAML configuration will be -eventually phased out, and using the Dart API is recommended. +eventually phased out, and using the Dart API is recommended. See +["Migrating from YAML"](#migrating-from-yaml) above for an automated migration +path. A YAML configuration can be either provided in the project's `pubspec.yaml` file under the key `ffigen` or via a custom YAML file. To generate bindings diff --git a/pkgs/ffigen/bin/migrate.dart b/pkgs/ffigen/bin/migrate.dart new file mode 100644 index 0000000000..924f6dd235 --- /dev/null +++ b/pkgs/ffigen/bin/migrate.dart @@ -0,0 +1,5 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +export 'package:ffigen/src/executables/migrate.dart'; diff --git a/pkgs/ffigen/lib/ffigen.dart b/pkgs/ffigen/lib/ffigen.dart index 77e8b50f35..64bc18d14c 100644 --- a/pkgs/ffigen/lib/ffigen.dart +++ b/pkgs/ffigen/lib/ffigen.dart @@ -13,6 +13,12 @@ /// @docImport 'src/config_provider.dart'; library; +// `Logger` appears in the public API (e.g. as the parameter of +// [defaultCompilerOpts]), so re-export it to allow users (and the scripts +// generated by `ffigen:migrate`) to construct one without a direct +// dependency on `package:logging`. +export 'package:logging/logging.dart' show Logger; + export 'src/code_generator/imports.dart' show ImportedType, LibraryImport; export 'src/config_provider.dart' show diff --git a/pkgs/ffigen/lib/src/config_provider/config.dart b/pkgs/ffigen/lib/src/config_provider/config.dart index b45cac1199..442fed24db 100644 --- a/pkgs/ffigen/lib/src/config_provider/config.dart +++ b/pkgs/ffigen/lib/src/config_provider/config.dart @@ -6,6 +6,7 @@ import 'dart:ffi'; import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; +import 'package:quiver/pattern.dart' as quiver; import '../code_generator.dart'; import '../ffigen.dart'; @@ -136,6 +137,24 @@ final class Headers { static bool _includeDefault(Uri header) => true; + /// Returns a function to pass to [include] that includes any header whose + /// file path fully matches one of [globs]. + /// + /// `*` matches anything within a path segment, `**` matches across path + /// segments, and `?` matches a single character. Since the header's whole + /// (absolute) path is matched, patterns typically start with `**`, e.g. + /// `'**/include/*.h'`. + /// + /// This matches the semantics of the legacy YAML config's + /// `include-directives`. + static bool Function(Uri) includeGlobs(List globs) { + final patterns = [for (final glob in globs) quiver.Glob(glob)]; + return (Uri header) { + final path = header.toFilePath(); + return patterns.any((p) => quiver.matchesFull(p, path)); + }; + } + /// Command line arguments to pass to clang_compiler. final List? compilerOptions; diff --git a/pkgs/ffigen/lib/src/config_provider/yaml_to_dart.dart b/pkgs/ffigen/lib/src/config_provider/yaml_to_dart.dart new file mode 100644 index 0000000000..a1f5b19a09 --- /dev/null +++ b/pkgs/ffigen/lib/src/config_provider/yaml_to_dart.dart @@ -0,0 +1,1286 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// Generates Dart source code for a `FfiGenerator` script that's equivalent +/// to a legacy YAML ffigen configuration. +/// +/// This is a best-effort, syntactic translation performed directly on the raw +/// YAML: exact strings (header globs, include/exclude patterns, renames, +/// paths, etc.) are preserved rather than going through `YamlConfig`'s +/// validation/extraction pipeline. Constructs that don't map cleanly onto the +/// public `FfiGenerator` Dart API are emitted as `// TODO(ffigen migration):` +/// comments so that nothing from the original config is silently dropped. +library; + +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart'; + +import '../strings.dart' as strings; +import 'spec_utils.dart' show compilerOptsToList, headersExtractor; +import 'utils.dart' show normalizePath; + +/// Generates a Dart script equivalent to [yaml], a parsed legacy ffigen YAML +/// configuration. +/// +/// [configFilename] is the path to the YAML file (or `pubspec.yaml`) that +/// [yaml] was loaded from. It's used to resolve relative paths the same way +/// the legacy YAML config does. +/// +/// [outputFilePath] is the path the generated Dart script will be written to. +/// It's used to compute paths in the generated script that are relative to +/// the script itself (using `Platform.script`), so the script keeps working +/// if the whole package is moved or checked out elsewhere. +String emitDartConfig( + YamlMap yaml, { + String? configFilename, + required String outputFilePath, +}) { + return _YamlToDartEmitter( + yaml, + configFilename: configFilename, + outputFilePath: outputFilePath, + ).emit(); +} + +class _NamePattern { + final Set full; + final List regex; + _NamePattern(this.full, this.regex); + bool get isEmpty => full.isEmpty && regex.isEmpty; +} + +bool _isFullDeclarationName(String s) => RegExp(r'^[a-zA-Z_0-9]*$').hasMatch(s); + +const _sdkVars = { + r'$XCODE': 'xcodePath', + r'$IOS_SDK': 'iosSdkPath', + r'$MACOS_SDK': 'macSdkPath', +}; + +bool _hasSdkVar(String s) => _sdkVars.keys.any(s.contains); + +/// Whether [s] contains any special characters of `package:glob`'s syntax. +bool _containsGlobChars(String s) => s.contains(RegExp(r'[*?\[\]{}]')); + +const _builtInLibraryImportPaths = { + 'ffi': 'dart:ffi', + 'pkg_ffi': 'package:ffi/ffi.dart', + 'meta': 'package:meta/meta.dart', + 'objc': 'package:objective_c/objective_c.dart', + 'self': '', +}; + +class _YamlToDartEmitter { + final YamlMap yaml; + final String? configFilename; + final String outputFilePath; + + final List todos = []; + bool needsIncluderHelpers = false; + bool needsRenameHelpers = false; + bool needsModuleHelper = false; + bool needsPackingHelper = false; + bool needsLogger = false; + + late final bool excludeAllByDefault = + yaml[strings.excludeAllByDefault] == true; + late final bool isObjC = + (yaml[strings.language] as String?) == strings.langObjC; + late final String _configDirPath = configFilename != null + ? p.dirname(p.absolute(configFilename!)) + : p.absolute('.'); + late final String _outputDirPath = p.dirname(p.absolute(outputFilePath)); + late final Map _libraryImportPaths = { + for (final e + in (yaml[strings.libraryImports] as YamlMap?)?.entries ?? + const >[]) + e.key.toString(): e.value.toString(), + }; + + final _logger = Logger('ffigen.migrate'); + + _YamlToDartEmitter( + this.yaml, { + required this.configFilename, + required this.outputFilePath, + }); + + static const _knownTopLevelKeys = { + strings.excludeAllByDefault, + strings.llvmPath, + strings.output, + strings.language, + strings.headers, + strings.ignoreSourceErrors, + strings.compilerOpts, + strings.compilerOptsAuto, + strings.libraryImports, + strings.functions, + strings.structs, + strings.unions, + strings.enums, + strings.unnamedEnums, + strings.globals, + strings.macros, + strings.typedefs, + strings.objcInterfaces, + strings.objcProtocols, + strings.objcCategories, + strings.import, + strings.typeMap, + strings.includeUnusedTypedefs, + strings.includeTransitiveObjCInterfaces, + strings.includeTransitiveObjCProtocols, + strings.includeTransitiveObjCCategories, + strings.generateForPackageObjectiveC, + strings.sort, + strings.useSupportedTypedefs, + strings.comments, + strings.name, + strings.description, + strings.preamble, + strings.ffiNative, + strings.silenceEnumWarning, + strings.externalVersions, + }; + + String emit() { + for (final key in yaml.keys) { + final keyStr = key.toString(); + if (!_knownTopLevelKeys.contains(keyStr)) { + todos.add("Unhandled top-level key '$keyStr' was not migrated."); + } + } + + if (yaml[strings.sort] == true) { + todos.add( + "'${strings.sort}: true' has no equivalent in the Dart API and " + 'was dropped.', + ); + } + + final llvmPathRaw = yaml[strings.llvmPath]; + if (llvmPathRaw != null) { + todos.add( + "'${strings.llvmPath}: $llvmPathRaw' has no direct equivalent. " + 'Pass a resolved path to the `libclangDylib` parameter of ' + '`generate()` instead, e.g. ' + "generator.generate(libclangDylib: Uri.file('/path/to/libclang.so'))", + ); + } + if (yaml.containsKey(strings.import)) { + final symbolFiles = + (yaml[strings.import] as YamlMap?)?[strings.symbolFilesImport]; + if (symbolFiles != null) { + todos.add( + "'${strings.import} -> ${strings.symbolFilesImport}: " + '$symbolFiles\' was not migrated. Symbol file imports need to be ' + 'ported to `FfiGenerator.importedTypesByUsr` by hand.', + ); + } + } + if ((yaml[strings.unnamedEnums] as YamlMap?)?.containsKey( + strings.enumAsInt, + ) == + true) { + todos.add( + "'${strings.unnamedEnums} -> ${strings.enumAsInt}' has no " + 'equivalent in the Dart API (`UnnamedEnums` has no int-style ' + 'option) and was dropped.', + ); + } + for (final section in [strings.functions]) { + final varArgs = (yaml[section] as YamlMap?)?[strings.varArgFunctions]; + if (varArgs != null) { + todos.add( + "'$section -> ${strings.varArgFunctions}: $varArgs' was not " + 'migrated. Variadic function overrides need to be ported to ' + '`Functions.varArgs` by hand (the `Type` hierarchy used to ' + 'build `VarArgFunction`s is not yet part of the public API).', + ); + } + } + + final headers = _buildHeaders(); + final output = _buildOutput(); + + final ctorParams = {'headers': headers, 'output': output}; + + void addSection(String param, String? expr) { + if (expr != null) ctorParams[param] = expr; + } + + addSection('functions', _buildFunctions()); + addSection('structs', _buildStructsOrUnions(strings.structs, 'Structs')); + addSection('unions', _buildStructsOrUnions(strings.unions, 'Unions')); + addSection('enums', _buildEnums()); + addSection( + 'unnamedEnums', + _buildDeclSection(strings.unnamedEnums, 'UnnamedEnums'), + ); + addSection('globals', _buildGlobals()); + addSection('macros', _buildDeclSection(strings.macros, 'Macros')); + addSection('typedefs', _buildTypedefs()); + addSection('integers', _buildIntegers()); + if (isObjC) ctorParams['objectiveC'] = _buildObjectiveC(); + + final buf = StringBuffer(); + if (todos.isNotEmpty) { + buf.writeln('// TODO(ffigen migration): review the following before'); + buf.writeln('// relying on this generated script:'); + for (final todo in todos) { + for (final line in _wrapComment(todo)) { + buf.writeln('// $line'); + } + } + buf.writeln(); + } + buf.writeln("import 'dart:io';"); + buf.writeln(); + buf.writeln("import 'package:ffigen/ffigen.dart';"); + buf.writeln(); + + if (needsIncluderHelpers || + needsRenameHelpers || + needsModuleHelper || + needsPackingHelper) { + buf.writeln(_helperSource()); + } + + buf.writeln('void main() {'); + buf.writeln( + " final packageRoot = Platform.script.resolve('$_packageRootRelPath');", + ); + if (needsLogger) { + buf.writeln(" final logger = Logger('ffigen');"); + } + buf.writeln(' FfiGenerator('); + for (final entry in ctorParams.entries) { + buf.writeln(' ${entry.key}: ${entry.value},'); + } + buf.writeln(' ).generate();'); + buf.writeln('}'); + + return buf.toString(); + } + + String get _packageRootRelPath { + final rel = p.relative(_configDirPath, from: _outputDirPath); + return '${_toPosix(rel)}/'; + } + + // --------------------------------------------------------------------- + // headers + // --------------------------------------------------------------------- + + String _buildHeaders() { + final headers = yaml[strings.headers] as YamlMap; + final entryPointsRaw = (headers[strings.entryPoints] as YamlList) + .map((e) => e.toString()) + .toList(); + + final entryExprs = []; + for (final raw in entryPointsRaw) { + if (_hasSdkVar(raw)) { + entryExprs.add('Uri.file(${_interpolated(raw)})'); + if (raw.contains('*') || raw.contains('?')) { + todos.add( + "Header entry point '$raw' contains an SDK variable and a " + 'glob pattern; globs are not supported once SDK variables are ' + 'involved, so it was kept as a single literal path. Expand it ' + 'manually if needed.', + ); + } + } else if (_containsGlobChars(raw)) { + // Globs can only be expanded against the filesystem, so this is done + // at migration time; the expansion is frozen into the script. + final resolved = headersExtractor(_logger, { + strings.entryPoints: [raw], + }, configFilename); + if (resolved.entryPoints.isEmpty) { + todos.add( + "Header entry point glob '$raw' matched no files when this " + 'script was generated, so no entry points were emitted for it. ' + 'Add the resolved header paths manually.', + ); + } + for (final uri in resolved.entryPoints) { + entryExprs.add(_pathExprForAbsolute(uri.toFilePath())); + } + } else { + // A plain path: emit it as-is, without checking that it exists, so + // that migration works even when the headers aren't present (e.g. a + // fresh checkout). + entryExprs.add( + _pathExprForAbsolute(normalizePath(raw, configFilename)), + ); + } + } + + final params = ['entryPoints: [${entryExprs.join(', ')}]']; + + final includeDirectives = headers[strings.includeDirectives] as YamlList?; + if (includeDirectives != null && includeDirectives.isNotEmpty) { + final globExprs = includeDirectives + .map((raw) { + final s = raw.toString(); + if (_hasSdkVar(s)) return _interpolated(s); + return _q(normalizePath(s, configFilename)); + }) + .join(', '); + params.add('include: Headers.includeGlobs([$globExprs])'); + } + + final compilerOptions = _buildCompilerOptions(); + if (compilerOptions != null) { + params.add('compilerOptions: $compilerOptions'); + } + + if (yaml[strings.ignoreSourceErrors] == true) { + params.add('ignoreSourceErrors: true'); + } + + return 'Headers(${params.join(',\n')})'; + } + + String? _buildCompilerOptions() { + final opts = []; + final raw = yaml[strings.compilerOpts]; + if (raw is String) { + opts.addAll(compilerOptsToList(raw).map(_interpolatedOrLiteral)); + } else if (raw is YamlList) { + opts.addAll( + raw + .map((e) => e.toString()) + .expand(compilerOptsToList) + .map(_interpolatedOrLiteral), + ); + } + + final auto = yaml[strings.compilerOptsAuto] as YamlMap?; + String? autoExpr; + if (auto != null) { + final macIncludeStdLib = + (auto[strings.macos] as YamlMap?)?[strings.includeCStdLib]; + needsLogger = true; + autoExpr = macIncludeStdLib == false + ? 'defaultCompilerOpts(logger, macIncludeStdLib: false)' + : 'defaultCompilerOpts(logger)'; + } + + if (opts.isEmpty && autoExpr == null) return null; + final items = [if (autoExpr != null) '...$autoExpr', ...opts]; + return '[${items.join(', ')}]'; + } + + // --------------------------------------------------------------------- + // output + // --------------------------------------------------------------------- + + String _buildOutput() { + final params = []; + final outputRaw = yaml[strings.output]; + if (outputRaw is String) { + params.add('dartFile: ${_pathExpr(outputRaw)}'); + } else { + final map = outputRaw as YamlMap; + params.add('dartFile: ${_pathExpr(map[strings.bindings] as String)}'); + final objcBindings = map[strings.objCBindings] as String?; + if (objcBindings != null) { + params.add('objectiveCFile: ${_pathExpr(objcBindings)}'); + } + final symbolFile = map[strings.symbolFile] as YamlMap?; + if (symbolFile != null) { + final importExpr = _pathExpr(symbolFile[strings.importPath] as String); + final outExpr = _pathExpr(symbolFile[strings.output] as String); + params.add('symbolFile: SymbolFile($importExpr, $outExpr)'); + } + } + + final commentExpr = _buildCommentType(); + if (commentExpr != null) params.add('commentType: $commentExpr'); + + final preamble = yaml[strings.preamble] as String?; + if (preamble != null) params.add('preamble: ${_q(preamble)}'); + + params.add('style: ${_buildBindingStyle()}'); + + return 'Output(${params.join(',\n')})'; + } + + String? _buildCommentType() { + if (!yaml.containsKey(strings.comments)) return null; + final val = yaml[strings.comments]; + if (val is bool) { + return val ? 'const CommentType.def()' : 'const CommentType.none()'; + } + final map = val as YamlMap; + final styleVal = map[strings.style] as String?; + final lengthVal = map[strings.length] as String?; + final style = styleVal == strings.any + ? 'CommentStyle.any' + : 'CommentStyle.doxygen'; + final length = lengthVal == strings.brief + ? 'CommentLength.brief' + : 'CommentLength.full'; + return 'CommentType($style, $length)'; + } + + String _buildBindingStyle() { + if (yaml.containsKey(strings.ffiNative) && + yaml[strings.ffiNative] != null) { + final map = yaml[strings.ffiNative] as YamlMap; + final assetId = + (map[strings.ffiNativeAsset] ?? map['assetId']) as String?; + if (assetId != null) { + return 'NativeExternalBindings(assetId: ${_q(assetId)})'; + } + return 'const NativeExternalBindings()'; + } + // Either `ffi-native` is absent, or explicitly `ffi-native: null` + // (disabled): the legacy default is dynamic-library bindings, which + // differs from the Dart API's own default (native `@Native` bindings). + final name = (yaml[strings.name] as String?) ?? 'NativeLibrary'; + final description = yaml[strings.description] as String?; + final params = ['wrapperName: ${_q(name)}']; + if (description != null) { + params.add('wrapperDocComment: ${_q(description)}'); + } + return 'DynamicLibraryBindings(${params.join(', ')})'; + } + + // --------------------------------------------------------------------- + // declaration sections + // --------------------------------------------------------------------- + + /// Builds a `Functions`/`UnnamedEnums`/`Macros`/`Typedefs`-shaped section + /// (include/exclude + rename, no member-rename). + String? _buildDeclSection( + String yamlKey, + String className, { + bool withMemberRename = false, + }) { + final section = yaml[yamlKey] as YamlMap?; + final params = {}; + final includeExpr = _buildInclude(section); + if (includeExpr != 'Declarations.excludeAll') { + params['include'] = includeExpr; + } + final renameExpr = _buildRename(section?[strings.rename] as YamlMap?); + if (renameExpr != null) params['rename'] = renameExpr; + if (withMemberRename) { + final memberRenameExpr = _buildMemberRename( + section?[strings.memberRename] as YamlMap?, + ); + if (memberRenameExpr != null) params['renameMember'] = memberRenameExpr; + } + if (params.isEmpty) return null; + return '$className(${_paramsToString(params)})'; + } + + String? _buildFunctions() { + final section = yaml[strings.functions] as YamlMap?; + final params = {}; + final includeExpr = _buildInclude(section); + if (includeExpr != 'Declarations.excludeAll') { + params['include'] = includeExpr; + } + final renameExpr = _buildRename(section?[strings.rename] as YamlMap?); + if (renameExpr != null) params['rename'] = renameExpr; + final memberRenameExpr = _buildMemberRename( + section?[strings.memberRename] as YamlMap?, + ); + if (memberRenameExpr != null) params['renameMember'] = memberRenameExpr; + final symbolAddrExpr = _buildSimpleIncluder( + section?[strings.symbolAddress] as YamlMap?, + ); + if (symbolAddrExpr != null) params['includeSymbolAddress'] = symbolAddrExpr; + final exposeExpr = _buildSimpleIncluder( + section?[strings.exposeFunctionTypedefs] as YamlMap?, + ); + if (exposeExpr != null) params['includeTypedef'] = exposeExpr; + final leafExpr = _buildSimpleIncluder( + section?[strings.leafFunctions] as YamlMap?, + ); + if (leafExpr != null) params['isLeaf'] = leafExpr; + if (params.isEmpty) return null; + return 'Functions(${_paramsToString(params)})'; + } + + String? _buildStructsOrUnions(String yamlKey, String className) { + final section = yaml[yamlKey] as YamlMap?; + final params = {}; + final includeExpr = _buildInclude(section); + if (includeExpr != 'Declarations.excludeAll') { + params['include'] = includeExpr; + } + final renameExpr = _buildRename(section?[strings.rename] as YamlMap?); + if (renameExpr != null) params['rename'] = renameExpr; + final memberRenameExpr = _buildMemberRename( + section?[strings.memberRename] as YamlMap?, + ); + if (memberRenameExpr != null) params['renameMember'] = memberRenameExpr; + + // Legacy default is `full`, Dart default is `opaque`: always emit. + final dependencyOnly = section?[strings.dependencyOnly] as String?; + params['dependencies'] = + dependencyOnly == strings.opaqueCompoundDependencies + ? 'CompoundDependencies.opaque' + : 'CompoundDependencies.full'; + + if (yamlKey == strings.structs) { + final packExpr = _buildPackingOverride( + section?[strings.structPack] as YamlMap?, + ); + if (packExpr != null) params['packingOverride'] = packExpr; + } + + final importedExpr = _buildImportedTypeList( + (yaml[strings.typeMap] as YamlMap?)?[yamlKey == strings.structs + ? strings.typeMapStructs + : strings.typeMapUnions] + as YamlMap?, + ); + + final extra = [ + if (importedExpr != null) + '// ignore: deprecated_member_use\n imported: $importedExpr', + ]; + return '$className(${_buildParamsList(params, extra: extra)})'; + } + + String? _buildPackingOverride(YamlMap? packMap) { + if (packMap == null || packMap.isEmpty) return null; + needsPackingHelper = true; + final entries = packMap.entries + .map((e) { + final value = e.value; + final packingValue = value == 'none' ? 'null' : value.toString(); + final pattern = _rawStringLiteral(e.key.toString()); + return 'MapEntry(RegExp($pattern, dotAll: true), $packingValue)'; + }) + .join(', '); + return ''' +(Declaration decl) { + final overrides = >[$entries]; + for (final e in overrides) { + if (_fullRegexMatch(e.key, decl.originalName)) { + return PackingValue(e.value); + } + } + return null; + }'''; + } + + /// Joins [params] (rendered as `key: value`) with any pre-rendered [extra] + /// argument strings (which may include their own leading comments). + String _buildParamsList( + Map params, { + List extra = const [], + }) { + final parts = [ + for (final e in params.entries) '${e.key}: ${e.value}', + ...extra, + ]; + return parts.join(',\n '); + } + + String? _buildEnums() { + final section = yaml[strings.enums] as YamlMap?; + final params = {}; + final includeExpr = _buildInclude(section); + if (includeExpr != 'Declarations.excludeAll') { + params['include'] = includeExpr; + } + final renameExpr = _buildRename(section?[strings.rename] as YamlMap?); + if (renameExpr != null) params['rename'] = renameExpr; + final memberRenameExpr = _buildMemberRename( + section?[strings.memberRename] as YamlMap?, + ); + if (memberRenameExpr != null) params['renameMember'] = memberRenameExpr; + + if (section?.containsKey(strings.enumAsInt) == true) { + final asIntExpr = _buildSimpleIncluder( + section![strings.enumAsInt] as YamlMap?, + ); + params['style'] = + '(Declaration decl, EnumStyle? suggestedStyle) {\n' + ' if (suggestedStyle != null) return suggestedStyle;\n' + ' return (${asIntExpr ?? 'Declarations.excludeAll'})(decl)\n' + ' ? EnumStyle.intConstants\n' + ' : EnumStyle.dartEnum;\n' + ' }'; + } + + final silence = _buildSilenceWarning(); + if (silence != null) params['silenceWarning'] = silence; + + if (params.isEmpty) return null; + return 'Enums(${_paramsToString(params)})'; + } + + String? _buildSilenceWarning() { + final explicit = yaml[strings.silenceEnumWarning] as bool?; + final legacyDefault = isObjC; + final value = explicit ?? legacyDefault; + return value ? 'true' : null; + } + + String? _buildGlobals() { + final section = yaml[strings.globals] as YamlMap?; + final params = {}; + final includeExpr = _buildInclude(section); + if (includeExpr != 'Declarations.excludeAll') { + params['include'] = includeExpr; + } + final renameExpr = _buildRename(section?[strings.rename] as YamlMap?); + if (renameExpr != null) params['rename'] = renameExpr; + final symbolAddrExpr = _buildSimpleIncluder( + section?[strings.symbolAddress] as YamlMap?, + ); + if (symbolAddrExpr != null) params['includeSymbolAddress'] = symbolAddrExpr; + if (params.isEmpty) return null; + return 'Globals(${_paramsToString(params)})'; + } + + String? _buildTypedefs() { + final section = yaml[strings.typedefs] as YamlMap?; + final params = {}; + final includeExpr = _buildInclude(section); + if (includeExpr != 'Declarations.excludeAll') { + params['include'] = includeExpr; + } + final renameExpr = _buildRename(section?[strings.rename] as YamlMap?); + if (renameExpr != null) params['rename'] = renameExpr; + if (yaml[strings.includeUnusedTypedefs] == true) { + params['includeUnused'] = 'true'; + } + if (yaml[strings.useSupportedTypedefs] == false) { + params['useSupportedTypedefs'] = 'false'; + } + final importedExpr = _buildImportedTypeList( + (yaml[strings.typeMap] as YamlMap?)?[strings.typeMapTypedefs] as YamlMap?, + ); + final extra = [ + if (importedExpr != null) + '// ignore: deprecated_member_use\n imported: $importedExpr', + ]; + if (params.isEmpty && extra.isEmpty) return null; + return 'Typedefs(${_buildParamsList(params, extra: extra)})'; + } + + String? _buildIntegers() { + final importedExpr = _buildImportedTypeList( + (yaml[strings.typeMap] as YamlMap?)?[strings.typeMapNativeTypes] + as YamlMap?, + ); + if (importedExpr == null) return null; + return 'Integers(\n' + ' // ignore: deprecated_member_use\n' + ' imported: $importedExpr,\n' + ' )'; + } + + String? _buildImportedTypeList(YamlMap? categoryMap) { + if (categoryMap == null || categoryMap.isEmpty) return null; + final items = []; + for (final entry in categoryMap.entries) { + final nativeType = entry.key.toString(); + final v = entry.value as YamlMap; + final lib = v[strings.lib] as String; + final cType = v[strings.cType] as String; + final dartType = v[strings.dartType] as String; + items.add( + 'ImportedType(${_libraryImportExpr(lib)}, ${_q(cType)}, ' + '${_q(dartType)}, ${_q(nativeType)})', + ); + } + return '[${items.join(', ')}]'; + } + + String _libraryImportExpr(String libName) { + final path = + _builtInLibraryImportPaths[libName] ?? _libraryImportPaths[libName]; + if (path == null) { + todos.add( + "Unknown library '$libName' referenced from 'type-map'; please fix " + 'the generated `LibraryImport` manually.', + ); + return 'LibraryImport(${_q(libName)}, ${_q('')})'; + } + return 'LibraryImport(${_q(libName)}, ${_q(path)})'; + } + + // --------------------------------------------------------------------- + // Objective-C + // --------------------------------------------------------------------- + + String _buildObjectiveC() { + final params = []; + + final interfacesTransitive = + yaml[strings.includeTransitiveObjCInterfaces] == true ? 'true' : null; + final protocolsTransitive = + yaml[strings.includeTransitiveObjCProtocols] == true ? 'true' : null; + final categoriesTransitiveRaw = + yaml[strings.includeTransitiveObjCCategories] as bool?; + final categoriesTransitive = categoriesTransitiveRaw == false + ? 'false' + : null; + + final interfaces = _buildObjcClassExpr( + strings.objcInterfaces, + 'Interfaces', + includeModule: true, + transitiveParam: interfacesTransitive, + ); + if (interfaces != null) params.add('interfaces: $interfaces'); + + final protocols = _buildObjcClassExpr( + strings.objcProtocols, + 'Protocols', + includeModule: true, + transitiveParam: protocolsTransitive, + ); + if (protocols != null) params.add('protocols: $protocols'); + + final categories = _buildObjcClassExpr( + strings.objcCategories, + 'Categories', + includeModule: false, + transitiveParam: categoriesTransitive, + ); + if (categories != null) params.add('categories: $categories'); + + final extVersions = _buildExternalVersions(); + if (extVersions != null) params.add('externalVersions: $extVersions'); + + if (yaml[strings.generateForPackageObjectiveC] == true) { + params.add( + '// ignore: deprecated_member_use\n generateForPackageObjectiveC: true', + ); + } + + return 'ObjectiveC(${params.join(',\n')})'; + } + + String? _buildObjcClassExpr( + String sectionKey, + String className, { + required bool includeModule, + String? transitiveParam, + }) { + final section = yaml[sectionKey] as YamlMap?; + final params = {}; + final includeExpr = _buildInclude(section); + if (includeExpr != 'Declarations.excludeAll') { + params['include'] = includeExpr; + } + final renameExpr = _buildRename(section?[strings.rename] as YamlMap?); + if (renameExpr != null) params['rename'] = renameExpr; + final memberRenameExpr = _buildMemberRename( + section?[strings.memberRename] as YamlMap?, + ); + if (memberRenameExpr != null) params['renameMember'] = memberRenameExpr; + final memberFilterExpr = _buildMemberFilter( + section?[strings.memberFilter] as YamlMap?, + ); + if (memberFilterExpr != null) params['includeMember'] = memberFilterExpr; + if (includeModule) { + final moduleExpr = _buildModule(section?[strings.objcModule] as YamlMap?); + if (moduleExpr != null) params['module'] = moduleExpr; + } + if (transitiveParam != null) params['includeTransitive'] = transitiveParam; + if (params.isEmpty) return null; + return '$className(${_paramsToString(params)})'; + } + + String? _buildModule(YamlMap? moduleMap) { + if (moduleMap == null || moduleMap.isEmpty) return null; + needsModuleHelper = true; + final entries = moduleMap.entries + .map( + (e) => + 'MapEntry(${_rawStringLiteral(e.key.toString())}, ' + '${_q(e.value.toString())})', + ) + .join(', '); + return '_moduleMatcher([$entries])'; + } + + String? _buildExternalVersions() { + final ext = yaml[strings.externalVersions] as YamlMap?; + if (ext == null || ext.isEmpty) return null; + final parts = []; + for (final plat in strings.externalVersionsPlatforms) { + final platMap = ext[plat] as YamlMap?; + final min = platMap?[strings.externalVersionsMin] as String?; + final max = platMap?[strings.externalVersionsMax] as String?; + final args = [ + if (min != null) 'min: Version.parse(${_q(min)})', + if (max != null) 'max: Version.parse(${_q(max)})', + ]; + if (args.isNotEmpty) { + parts.add('$plat: Versions(${args.join(', ')})'); + } + } + if (parts.isEmpty) return null; + return 'ExternalVersions(${parts.join(', ')})'; + } + + // --------------------------------------------------------------------- + // include/exclude filters + // --------------------------------------------------------------------- + + List _asStringList(dynamic node) { + if (node == null) return const []; + return (node as YamlList).map((e) => e.toString()).toList(); + } + + _NamePattern _splitNamePattern(List items) { + final full = {}; + final regex = []; + for (final s in items) { + if (_isFullDeclarationName(s)) { + full.add(s); + } else { + regex.add(s); + } + } + return _NamePattern(full, regex); + } + + /// Builds the `include:` expression (a `bool Function(Declaration)`) for a + /// declaration section. + /// + /// The result is always a fully-formed expression (e.g. + /// `Declarations.includeAll`, `Declarations.includeSet({...})`, or a + /// fallback lambda), so callers can compare it against + /// `'Declarations.excludeAll'` to decide whether it can be omitted (i.e. + /// whether it already matches the Dart API's own default for that field). + String _buildInclude(YamlMap? section) { + final hasIncludeKey = + section != null && section.containsKey(strings.include); + final includeList = _asStringList(section?[strings.include]); + final excludeList = _asStringList(section?[strings.exclude]); + + if (hasIncludeKey && includeList.isEmpty) { + return 'Declarations.excludeAll'; + } + + final include = _splitNamePattern(includeList); + final exclude = _splitNamePattern(excludeList); + + if (include.regex.isEmpty && exclude.isEmpty) { + if (include.full.isNotEmpty) { + return 'Declarations.includeSet({${_setLiteral(include.full)}})'; + } + return excludeAllByDefault + ? 'Declarations.excludeAll' + : 'Declarations.includeAll'; + } + + needsIncluderHelpers = true; + return '_declarationIncluder(${_includeArgsString(include, exclude)})'; + } + + /// Builds a `bool Function(Declaration)` expression for the simple + /// include/exclude objects used by `symbol-address`, `leaf`, + /// `expose-typedefs` and `as-int`. These all default to "exclude + /// everything" when absent, matching `Declarations.excludeAll`. + String? _buildSimpleIncluder(YamlMap? obj) { + if (obj == null) return null; + final includeList = _asStringList(obj[strings.include]); + final excludeList = _asStringList(obj[strings.exclude]); + if (includeList.isEmpty && excludeList.isEmpty) return null; + + final include = _splitNamePattern(includeList); + final exclude = _splitNamePattern(excludeList); + + if (include.regex.isEmpty && exclude.isEmpty && include.full.isNotEmpty) { + return 'Declarations.includeSet({${_setLiteral(include.full)}})'; + } + + needsIncluderHelpers = true; + return '_declarationIncluder(${_includeArgsString(include, exclude)})'; + } + + String _includeArgsString(_NamePattern include, _NamePattern exclude) { + final parts = []; + if (include.full.isNotEmpty) { + parts.add('includeNames: {${_setLiteral(include.full)}}'); + } + if (include.regex.isNotEmpty) { + parts.add('includePatterns: [${_regexListLiteral(include.regex)}]'); + } + if (exclude.full.isNotEmpty) { + parts.add('excludeNames: {${_setLiteral(exclude.full)}}'); + } + if (exclude.regex.isNotEmpty) { + parts.add('excludePatterns: [${_regexListLiteral(exclude.regex)}]'); + } + if (excludeAllByDefault) parts.add('excludeAllByDefault: true'); + return parts.join(', '); + } + + // --------------------------------------------------------------------- + // rename / member-rename / member-filter + // --------------------------------------------------------------------- + + String? _buildRename(YamlMap? renameMap) { + if (renameMap == null || renameMap.isEmpty) return null; + final full = {}; + final regex = >[]; + for (final e in renameMap.entries) { + final key = e.key.toString(); + final value = e.value.toString(); + if (_isFullDeclarationName(key)) { + full[key] = value; + } else { + regex.add(MapEntry(key, value)); + } + } + if (regex.isEmpty) { + return 'Declarations.renameWithMap({${_mapLiteral(full)}})'; + } + needsRenameHelpers = true; + final buf = StringBuffer('(Declaration decl) {\n'); + buf.writeln(' final name = decl.originalName;'); + if (full.isNotEmpty) { + buf.writeln(' const renameMap = {${_mapLiteral(full)}};'); + buf.writeln( + ' if (renameMap.containsKey(name)) return renameMap[name]!;', + ); + } + for (final e in regex) { + buf.writeln(' {'); + buf.writeln( + ' final result = _tryRegexRename(name, ' + 'RegExp(${_rawStringLiteral(e.key)}, dotAll: true), ${_q(e.value)});', + ); + buf.writeln(' if (result != null) return result;'); + buf.writeln(' }'); + } + buf.writeln(' return name;'); + buf.write(' }'); + return buf.toString(); + } + + String? _buildMemberRename(YamlMap? memberRenameMap) { + if (memberRenameMap == null || memberRenameMap.isEmpty) return null; + + final declFull = {}; + final declRegex = >[]; + for (final e in memberRenameMap.entries) { + final key = e.key.toString(); + final value = e.value as YamlMap; + if (_isFullDeclarationName(key)) { + declFull[key] = value; + } else { + declRegex.add(MapEntry(key, value)); + } + } + + final allClean = + declRegex.isEmpty && + declFull.values.every( + (m) => m.keys.every((mk) => _isFullDeclarationName(mk.toString())), + ); + if (allClean) { + final entries = declFull.entries + .map((e) { + final memberMap = { + for (final me in e.value.entries) + me.key.toString(): me.value.toString(), + }; + return '${_q(e.key)}: {${_mapLiteral(memberMap)}}'; + }) + .join(', '); + return 'Declarations.renameMemberWithMap({$entries})'; + } + + needsRenameHelpers = true; + final buf = StringBuffer('(Declaration decl, String member) {\n'); + buf.writeln(' final name = decl.originalName;'); + for (final e in declFull.entries) { + buf.writeln(' if (name == ${_q(e.key)}) {'); + buf.write(_memberRenameBody(e.value)); + buf.writeln(' }'); + } + for (final e in declRegex) { + buf.writeln( + ' if (_fullRegexMatch(RegExp(${_rawStringLiteral(e.key)}, ' + 'dotAll: true), name)) {', + ); + buf.write(_memberRenameBody(e.value)); + buf.writeln(' }'); + } + buf.writeln(' return member;'); + buf.write(' }'); + return buf.toString(); + } + + String _memberRenameBody(YamlMap memberMap) { + final full = {}; + final regex = >[]; + for (final e in memberMap.entries) { + final key = e.key.toString(); + final value = e.value.toString(); + if (_isFullDeclarationName(key)) { + full[key] = value; + } else { + regex.add(MapEntry(key, value)); + } + } + final buf = StringBuffer(); + if (full.isNotEmpty) { + buf.writeln(' const memberMap = {${_mapLiteral(full)}};'); + buf.writeln( + ' if (memberMap.containsKey(member)) return memberMap[member]!;', + ); + } + for (final e in regex) { + buf.writeln(' {'); + buf.writeln( + ' final result = _tryRegexRename(member, ' + 'RegExp(${_rawStringLiteral(e.key)}, dotAll: true), ${_q(e.value)});', + ); + buf.writeln(' if (result != null) return result;'); + buf.writeln(' }'); + } + buf.writeln(' return member;'); + return buf.toString(); + } + + String? _buildMemberFilter(YamlMap? memberFilterMap) { + if (memberFilterMap == null || memberFilterMap.isEmpty) return null; + + final declFull = {}; + final declRegex = >[]; + for (final e in memberFilterMap.entries) { + final key = e.key.toString(); + final value = e.value as YamlMap; + if (_isFullDeclarationName(key)) { + declFull[key] = value; + } else { + declRegex.add(MapEntry(key, value)); + } + } + + needsIncluderHelpers = true; + if (declRegex.isNotEmpty) needsRenameHelpers = true; // for _fullRegexMatch + + final buf = StringBuffer('(Declaration decl, String member) {\n'); + buf.writeln(' final name = decl.originalName;'); + for (final e in declFull.entries) { + buf.writeln( + ' if (name == ${_q(e.key)}) return _includeName(member, ' + '${_memberFilterArgs(e.value)});', + ); + } + for (final e in declRegex) { + buf.writeln( + ' if (_fullRegexMatch(RegExp(${_rawStringLiteral(e.key)}, ' + 'dotAll: true), name)) {', + ); + buf.writeln( + ' return _includeName(member, ${_memberFilterArgs(e.value)});', + ); + buf.writeln(' }'); + } + buf.writeln(' return true;'); + buf.write(' }'); + return buf.toString(); + } + + String _memberFilterArgs(YamlMap obj) { + final include = _splitNamePattern(_asStringList(obj[strings.include])); + final exclude = _splitNamePattern(_asStringList(obj[strings.exclude])); + final args = _includeArgsStringNoDefault(include, exclude); + return args; + } + + String _includeArgsStringNoDefault( + _NamePattern include, + _NamePattern exclude, + ) { + final parts = []; + if (include.full.isNotEmpty) { + parts.add('includeNames: {${_setLiteral(include.full)}}'); + } + if (include.regex.isNotEmpty) { + parts.add('includePatterns: [${_regexListLiteral(include.regex)}]'); + } + if (exclude.full.isNotEmpty) { + parts.add('excludeNames: {${_setLiteral(exclude.full)}}'); + } + if (exclude.regex.isNotEmpty) { + parts.add('excludePatterns: [${_regexListLiteral(exclude.regex)}]'); + } + return parts.join(', '); + } + + // --------------------------------------------------------------------- + // Path helpers + // --------------------------------------------------------------------- + + String _pathExpr(String raw) { + if (Uri.parse(raw).scheme == 'package') { + return 'Uri.parse(${_q(raw)})'; + } + if (_hasSdkVar(raw)) { + return 'Uri.file(${_interpolated(raw)})'; + } + final absolute = normalizePath(raw, configFilename); + return _pathExprForAbsolute(absolute); + } + + String _pathExprForAbsolute(String absolutePath) { + final rel = p.relative(absolutePath, from: _configDirPath); + return 'packageRoot.resolve(${_q(_toPosix(rel))})'; + } + + String _toPosix(String path) => path.replaceAll(r'\', '/'); + + // --------------------------------------------------------------------- + // Literal helpers + // --------------------------------------------------------------------- + + String _q(String s) { + final escaped = s + .replaceAll('\\', '\\\\') + .replaceAll("'", "\\'") + .replaceAll(r'$', r'\$') + .replaceAll('\n', r'\n'); + return "'$escaped'"; + } + + String _rawStringLiteral(String s) { + if (!s.contains("'")) return "r'$s'"; + if (!s.contains('"')) return 'r"$s"'; + return _q(s); + } + + String _interpolated(String raw) { + var s = raw; + for (final entry in _sdkVars.entries) { + s = s.replaceAll(entry.key, '\${${entry.value}}'); + } + final escaped = s.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); + return '"$escaped"'; + } + + String _interpolatedOrLiteral(String raw) => + _hasSdkVar(raw) ? _interpolated(raw) : _q(raw); + + String _setLiteral(Set names) => names.map(_q).join(', '); + + String _mapLiteral(Map map) => + map.entries.map((e) => '${_q(e.key)}: ${_q(e.value)}').join(', '); + + String _regexListLiteral(List patterns) => patterns + .map((p) => 'RegExp(${_rawStringLiteral(p)}, dotAll: true)') + .join(', '); + + String _paramsToString(Map params) => + params.entries.map((e) => '${e.key}: ${e.value}').join(',\n '); + + List _wrapComment(String text) { + const width = 76; + final words = text.split(' '); + final lines = []; + var current = StringBuffer(); + for (final word in words) { + if (current.isNotEmpty && current.length + word.length + 1 > width) { + lines.add(current.toString()); + current = StringBuffer(); + } + if (current.isNotEmpty) current.write(' '); + current.write(word); + } + if (current.isNotEmpty) lines.add(current.toString()); + return lines; + } + + String _helperSource() { + final buf = StringBuffer(); + buf.writeln(''' +/// Migrated from FFIgen's legacy YAML `include`/`exclude`/`rename` semantics. +bool _fullRegexMatch(RegExp re, String s) { + final m = re.matchAsPrefix(s); + return m != null && m.end == s.length; +} +'''); + if (needsIncluderHelpers) { + buf.writeln(''' +bool _matchesAny(String name, Set names, List patterns) { + if (names.contains(name)) return true; + return patterns.any((p) => _fullRegexMatch(p, name)); +} + +bool _includeName( + String name, { + Set includeNames = const {}, + List includePatterns = const [], + Set excludeNames = const {}, + List excludePatterns = const [], + bool excludeAllByDefault = false, +}) { + if (_matchesAny(name, excludeNames, excludePatterns)) return false; + if (_matchesAny(name, includeNames, includePatterns)) return true; + if (includeNames.isNotEmpty || includePatterns.isNotEmpty) return false; + return !excludeAllByDefault; +} + +bool Function(Declaration) _declarationIncluder({ + Set includeNames = const {}, + List includePatterns = const [], + Set excludeNames = const {}, + List excludePatterns = const [], + bool excludeAllByDefault = false, +}) { + return (Declaration decl) => _includeName( + decl.originalName, + includeNames: includeNames, + includePatterns: includePatterns, + excludeNames: excludeNames, + excludePatterns: excludePatterns, + excludeAllByDefault: excludeAllByDefault, + ); +} +'''); + } + if (needsRenameHelpers) { + buf.writeln(''' +String? _tryRegexRename(String name, RegExp pattern, String replacement) { + final match = pattern.matchAsPrefix(name); + if (match == null || match.end != name.length) return null; + return replacement.replaceAllMapped(RegExp(r'\\\$([0-9])'), (m) { + final group = int.parse(m.group(1)!); + return (group == 0 ? match.group(0) : match.group(group)) ?? ''; + }); +} +'''); + } + if (needsModuleHelper) { + buf.writeln(''' +String? Function(Declaration) _moduleMatcher( + List> patterns, +) { + final compiled = [ + for (final e in patterns) MapEntry(RegExp(e.key, dotAll: true), e.value), + ]; + return (Declaration decl) { + for (final e in compiled) { + if (_fullRegexMatch(e.key, decl.originalName)) return e.value; + } + return null; + }; +} +'''); + } + return buf.toString(); + } +} diff --git a/pkgs/ffigen/lib/src/executables/migrate.dart b/pkgs/ffigen/lib/src/executables/migrate.dart new file mode 100644 index 0000000000..2be3f27834 --- /dev/null +++ b/pkgs/ffigen/lib/src/executables/migrate.dart @@ -0,0 +1,211 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +// Executable script to migrate a legacy YAML ffigen config to a Dart script +// that uses the `FfiGenerator` Dart API. +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:cli_util/cli_logging.dart' show Ansi; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart' as yaml; + +import '../code_generator/utils.dart' show dartExecutable; +import '../config_provider/yaml_config.dart'; +import '../config_provider/yaml_to_dart.dart'; + +final _ansi = Ansi(Ansi.terminalSupportsAnsi); +final logger = () { + final l = Logger('ffigen.migrate'); + l.onRecord.listen((record) { + final levelStr = '[${record.level.name}]'.padRight(9); + final log = '$levelStr: ${record.message}'; + if (record.level < Level.SEVERE) { + print(log); + } else { + print('${_ansi.red}$log${_ansi.none}'); + } + }); + return l; +}(); + +const _configOpt = 'config'; +const _outputOpt = 'output'; +const _formatFlag = 'format'; +const _verboseOpt = 'verbose'; +const _helpFlag = 'help'; +const _pubspecName = 'pubspec.yaml'; +const _configKey = 'ffigen'; +const _defaultOutputPath = 'tool/ffigen.dart'; +const _logAll = 'all'; +const _logFine = 'fine'; +const _logInfo = 'info'; +const _logWarning = 'warning'; +const _logSevere = 'severe'; + +Future main(List args) async { + final argResult = _getArgResults(args); + + Logger.root.level = _parseLogLevel(argResult); + + final configPath = argResult[_configOpt] as String?; + final outputPath = argResult[_outputOpt] as String; + + final String yamlSource; + final String configFilename; + if (configPath != null) { + final configFile = File(configPath); + if (!configFile.existsSync()) { + logger.severe('Error: $configPath not found.'); + exit(1); + } + yamlSource = configFile.readAsStringSync(); + configFilename = configFile.absolute.path; + } else { + final pubspecFile = File(_pubspecName); + if (!pubspecFile.existsSync()) { + logger.severe( + 'Error: $_pubspecName not found, please run this tool from the ' + 'root of your package, or pass --$_configOpt.', + ); + exit(1); + } + yamlSource = pubspecFile.readAsStringSync(); + configFilename = pubspecFile.absolute.path; + } + + yaml.YamlMap configMap; + try { + final doc = yaml.loadYaml(yamlSource, sourceUrl: Uri.file(configFilename)); + if (configPath != null) { + configMap = doc as yaml.YamlMap; + } else { + final fromPubspec = (doc as yaml.YamlMap)[_configKey] as yaml.YamlMap?; + if (fromPubspec == null) { + logger.severe( + "Error: Couldn't find an entry for '$_configKey' in " + '$_pubspecName.', + ); + exit(1); + } + configMap = fromPubspec; + } + } on yaml.YamlException catch (e) { + logger.severe('Error parsing YAML: $e'); + exit(1); + } + + // Reuse the legacy config's validation to surface configuration errors + // early, but keep going even if it fails -- this is a best-effort + // migration tool. + try { + YamlConfig.fromYaml(configMap, logger, filename: configFilename); + } on FormatException { + logger.warning( + 'The input configuration has validation errors (see above). ' + 'Migration will continue on a best-effort basis.', + ); + } + + final dartSource = emitDartConfig( + configMap, + configFilename: configFilename, + outputFilePath: p.absolute(outputPath), + ); + + final outputFile = File(outputPath); + outputFile.parent.createSync(recursive: true); + outputFile.writeAsStringSync(dartSource); + + if (argResult[_formatFlag] as bool) { + final result = Process.runSync(dartExecutable, [ + 'format', + outputFile.absolute.path, + ], workingDirectory: outputFile.parent.absolute.path); + if (result.exitCode != 0) { + logger.warning( + 'Formatting the generated script failed, it may need manual ' + 'cleanup:\n${result.stdout}\n${result.stderr}', + ); + } + } + + logger.info('Wrote migrated Dart config to $outputPath.'); + logger.info( + 'Review the TODO comments (if any) at the top of the generated file, ' + 'then run it with `dart run $outputPath` to generate your bindings.', + ); +} + +ArgResults _getArgResults(List args) { + final parser = ArgParser(allowTrailingOptions: true); + + parser.addSeparator( + 'FFIGEN MIGRATE: Migrate a YAML ffigen config to the Dart API\nUsage:', + ); + parser.addOption( + _configOpt, + abbr: 'c', + help: + 'Path to the YAML file containing the configuration to migrate. ' + 'Defaults to the `ffigen` key in pubspec.yaml.', + ); + parser.addOption( + _outputOpt, + abbr: 'o', + defaultsTo: _defaultOutputPath, + help: 'Path to write the migrated Dart script to.', + ); + parser.addFlag( + _formatFlag, + help: 'Format the generated Dart script.', + defaultsTo: true, + negatable: true, + ); + parser.addOption( + _verboseOpt, + abbr: 'v', + defaultsTo: _logInfo, + allowed: [_logAll, _logFine, _logInfo, _logWarning, _logSevere], + ); + parser.addFlag( + _helpFlag, + abbr: 'h', + help: 'Prints this usage', + negatable: false, + ); + + ArgResults results; + try { + results = parser.parse(args); + if (results.wasParsed(_helpFlag)) { + print(parser.usage); + exit(0); + } + } catch (e) { + print(e); + print(parser.usage); + exit(1); + } + + return results; +} + +Level _parseLogLevel(ArgResults result) { + switch (result[_verboseOpt] as String?) { + case _logAll: + return Level.ALL; + case _logFine: + return Level.FINE; + case _logInfo: + return Level.INFO; + case _logWarning: + return Level.WARNING; + case _logSevere: + return Level.SEVERE; + default: + return Level.INFO; + } +} diff --git a/pkgs/ffigen/test/config_tests/migrate_test.dart b/pkgs/ffigen/test/config_tests/migrate_test.dart new file mode 100644 index 0000000000..3a3c373951 --- /dev/null +++ b/pkgs/ffigen/test/config_tests/migrate_test.dart @@ -0,0 +1,643 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:io'; + +import 'package:dart_style/dart_style.dart'; +import 'package:ffigen/src/config_provider/yaml_to_dart.dart'; +import 'package:file/local.dart'; +import 'package:glob/glob.dart'; +import 'package:path/path.dart' as path; +import 'package:test/test.dart'; +import 'package:yaml/yaml.dart'; + +import '../test_utils.dart'; + +/// Emits Dart source for [yamlBody], a legacy ffigen YAML config. +String emit( + String yamlBody, { + String? configFilename, + String outputFilePath = '/repo/tool/ffigen.dart', +}) { + return emitDartConfig( + loadYaml(yamlBody) as YamlMap, + configFilename: configFilename, + outputFilePath: outputFilePath, + ); +} + +/// Formats [source] with `package:dart_style`, throwing if it's not valid +/// Dart. Used to make sure the emitter always produces syntactically valid +/// code. +String formatOrThrow(String source) { + return DartFormatter( + languageVersion: DartFormatter.latestLanguageVersion, + ).format(source); +} + +const _minimalHeader = ''' +output: out.dart +headers: + entry-points: + - 'a.h' +'''; + +void main() { + group('migrate', () { + test('minimal config formats and has expected shape', () { + final dart = emit(_minimalHeader); + expect(dart, contains("import 'package:ffigen/ffigen.dart';")); + expect(dart, contains('final packageRoot = Platform.script.resolve(')); + // Plain entry point paths are kept even if the file doesn't exist at + // migration time. + expect( + dart, + contains("Headers(entryPoints: [packageRoot.resolve('a.h')]"), + ); + expect( + dart, + contains("Output(dartFile: packageRoot.resolve('out.dart')"), + ); + // Legacy default is include-everything, unlike the Dart API's own + // default of excluding everything, so it must always be emitted. + expect( + dart, + contains('functions: Functions(include: Declarations.includeAll'), + ); + expect(formatOrThrow(dart), isNotEmpty); + }); + + test('output as a full map with objc-bindings and symbol-file', () { + final dart = emit(''' +output: + bindings: 'out.dart' + objc-bindings: 'out.m.dart' + symbol-file: + output: 'package:foo/foo.dart' + import-path: 'package:foo/foo.dart' +headers: + entry-points: + - 'a.h' +'''); + expect(dart, contains("dartFile: packageRoot.resolve('out.dart')")); + expect( + dart, + contains("objectiveCFile: packageRoot.resolve('out.m.dart')"), + ); + expect( + dart, + contains( + "symbolFile: SymbolFile(Uri.parse('package:foo/foo.dart'), " + "Uri.parse('package:foo/foo.dart'))", + ), + ); + formatOrThrow(dart); + }); + + test('name/description become DynamicLibraryBindings by default', () { + final dart = emit(''' +name: MyLib +description: 'My lib bindings' +$_minimalHeader +'''); + expect( + dart, + contains( + "DynamicLibraryBindings(wrapperName: 'MyLib', " + "wrapperDocComment: 'My lib bindings')", + ), + ); + formatOrThrow(dart); + }); + + test('ffi-native becomes NativeExternalBindings', () { + final dart = emit(''' +ffi-native: + asset-id: 'package:foo/foo.dart' +$_minimalHeader +'''); + expect( + dart, + contains("NativeExternalBindings(assetId: 'package:foo/foo.dart')"), + ); + formatOrThrow(dart); + }); + + test('ffi-native: null still uses NativeExternalBindings default', () { + final dart = emit(''' +ffi-native: null +$_minimalHeader +'''); + expect(dart, contains('DynamicLibraryBindings(wrapperName:')); + formatOrThrow(dart); + }); + + test('comments: false becomes CommentType.none', () { + final dart = emit(''' +comments: false +$_minimalHeader +'''); + expect(dart, contains('commentType: const CommentType.none()')); + formatOrThrow(dart); + }); + + test('preamble is preserved verbatim', () { + final dart = emit(''' +preamble: | + // my preamble +$_minimalHeader +'''); + expect(dart, contains('my preamble')); + formatOrThrow(dart); + }); + + test('compiler-opts as a string is split into a list', () { + final dart = emit(''' +compiler-opts: '-DFOO -DBAR' +$_minimalHeader +'''); + expect(dart, contains("compilerOptions: ['-DFOO', '-DBAR']")); + formatOrThrow(dart); + }); + + test('compiler-opts-automatic uses defaultCompilerOpts', () { + final dart = emit(''' +compiler-opts-automatic: + macos: + include-c-standard-library: false +$_minimalHeader +'''); + expect( + dart, + contains('...defaultCompilerOpts(logger, macIncludeStdLib: false)'), + ); + // `Logger` comes from package:ffigen's own exports; the generated + // script must not import packages the target package may not depend on. + expect(dart, contains("final logger = Logger('ffigen');")); + expect(dart, isNot(contains("import 'package:logging/logging.dart';"))); + formatOrThrow(dart); + }); + + test('include-directives become Headers.includeGlobs', () { + final dart = emit(''' +output: out.dart +headers: + entry-points: + - 'a.h' + include-directives: + - '**include/*.h' +'''); + // The path separator is platform-dependent (normalizePath), so only + // check the invocation shape. + expect(dart, contains('include: Headers.includeGlobs([')); + expect(dart, contains('*.h')); + // The generated script may only import package:ffigen itself. + expect(dart, isNot(contains("import 'package:glob/glob.dart';"))); + formatOrThrow(dart); + }); + + test('glob entry points that match nothing get a TODO', () { + final dart = emit(''' +output: out.dart +headers: + entry-points: + - 'no/such/dir/*.h' +'''); + expect(dart, contains('Headers(entryPoints: []')); + expect(dart, contains('// TODO(ffigen migration):')); + expect(dart, contains('matched no files')); + formatOrThrow(dart); + }); + + group('include/exclude', () { + test('plain names become includeSet', () { + final dart = emit(''' +functions: + include: + - foo + - bar +$_minimalHeader +'''); + expect(dart, contains('Functions(include: Declarations.includeSet({')); + expect(dart, contains("'foo'")); + expect(dart, contains("'bar'")); + formatOrThrow(dart); + }); + + test('include: [] excludes everything', () { + final dart = emit(''' +functions: + include: [] +$_minimalHeader +'''); + expect(dart, isNot(contains('functions:'))); + formatOrThrow(dart); + }); + + test('regex patterns fall back to a helper lambda', () { + final dart = emit(''' +functions: + include: + - 'clang_.*' +$_minimalHeader +'''); + expect(dart, contains('_declarationIncluder(includePatterns: [')); + expect(dart, contains(r"RegExp(r'clang_.*', dotAll: true)")); + expect(dart, contains('bool _matchesAny(')); + formatOrThrow(dart); + }); + + test('exclude forces the fallback lambda', () { + final dart = emit(''' +functions: + include: + - foo + exclude: + - bar +$_minimalHeader +'''); + expect(dart, contains('_declarationIncluder(')); + expect(dart, contains('includeNames: {')); + expect(dart, contains('excludeNames: {')); + formatOrThrow(dart); + }); + + test('exclude-all-by-default with no overrides omits the section', () { + final dart = emit(''' +exclude-all-by-default: true +$_minimalHeader +'''); + // Structs/unions always emit `dependencies`, but functions/enums/etc + // should be omitted entirely since `Declarations.excludeAll` is + // already the Dart API's own default. + expect(dart, isNot(contains('functions:'))); + expect(dart, isNot(contains('enums:'))); + expect(dart, contains('structs: Structs(dependencies:')); + formatOrThrow(dart); + }); + + test( + 'exclude-all-by-default is threaded through the fallback lambda', + () { + final dart = emit(''' +exclude-all-by-default: true +functions: + exclude: + - 'foo.*' +$_minimalHeader +'''); + expect(dart, contains('excludeAllByDefault: true')); + formatOrThrow(dart); + }, + ); + }); + + group('rename', () { + test('plain names become renameWithMap', () { + final dart = emit(''' +functions: + rename: + foo: bar +$_minimalHeader +'''); + expect( + dart, + contains("rename: Declarations.renameWithMap({'foo': 'bar'})"), + ); + formatOrThrow(dart); + }); + + test(r'regex rename falls back to $-group substitution helper', () { + final dart = emit( + r''' +functions: + rename: + 'clang_(.*)': '$1' +''' + '\n$_minimalHeader', + ); + expect(dart, contains('_tryRegexRename(name,')); + expect(dart, contains('String? _tryRegexRename(')); + formatOrThrow(dart); + }); + + test('member-rename with plain names becomes renameMemberWithMap', () { + final dart = emit(''' +functions: + member-rename: + Foo: + bar: baz +$_minimalHeader +'''); + expect(dart, contains('Declarations.renameMemberWithMap({')); + formatOrThrow(dart); + }); + + test('member-rename with a regex declaration falls back to a lambda', () { + final dart = emit(''' +functions: + member-rename: + 'Foo.*': + bar: baz +$_minimalHeader +'''); + expect(dart, contains('(Declaration decl, String member) {')); + expect(dart, contains('_fullRegexMatch(RegExp(')); + formatOrThrow(dart); + }); + }); + + group('structs/unions', () { + test('dependency-only is always emitted (default differs from Dart)', () { + final dartFull = emit(''' +structs: + dependency-only: full +$_minimalHeader +'''); + expect(dartFull, contains('dependencies: CompoundDependencies.full')); + + final dartOpaque = emit(''' +structs: + dependency-only: opaque +$_minimalHeader +'''); + expect( + dartOpaque, + contains('dependencies: CompoundDependencies.opaque'), + ); + formatOrThrow(dartFull); + formatOrThrow(dartOpaque); + }); + + test('pack becomes a packingOverride lambda', () { + final dart = emit(''' +structs: + pack: + Foo: 4 + Bar: none +$_minimalHeader +'''); + expect(dart, contains('packingOverride: (Declaration decl) {')); + expect(dart, contains('PackingValue(e.value)')); + expect( + dart, + contains('MapEntry(RegExp(r\'Bar\', dotAll: true), null)'), + ); + formatOrThrow(dart); + }); + }); + + group('enums', () { + test('as-int becomes a style callback', () { + final dart = emit(''' +enums: + as-int: + include: + - Foo +$_minimalHeader +'''); + expect(dart, contains('EnumStyle? suggestedStyle')); + expect(dart, contains('EnumStyle.intConstants')); + expect(dart, contains('EnumStyle.dartEnum')); + formatOrThrow(dart); + }); + + test('silence-enum-warning defaults to true for objc', () { + final dart = emit(''' +language: objc +$_minimalHeader +'''); + expect(dart, contains('silenceWarning: true')); + formatOrThrow(dart); + }); + }); + + group('type-map / library-imports', () { + test('native-types wires up Integers.imported', () { + final dart = emit(''' +type-map: + native-types: + MyInt: + lib: ffi + c-type: Int32 + dart-type: int +$_minimalHeader +'''); + expect(dart, contains('Integers(')); + expect(dart, contains("LibraryImport('ffi', 'dart:ffi')")); + expect( + dart, + contains( + "ImportedType(LibraryImport('ffi', 'dart:ffi'), " + "'Int32', 'int', 'MyInt')", + ), + ); + formatOrThrow(dart); + }); + + test('custom library-imports are resolved by alias', () { + final dart = emit(''' +library-imports: + foo: 'package:foo/foo.dart' +type-map: + typedefs: + MyType: + lib: foo + c-type: Foo + dart-type: Foo +$_minimalHeader +'''); + expect(dart, contains("LibraryImport('foo', 'package:foo/foo.dart')")); + formatOrThrow(dart); + }); + }); + + group('objective-c', () { + test('module becomes a module-matcher helper', () { + final dart = emit(''' +language: objc +objc-interfaces: + module: + 'NS.*': 'foundation' +$_minimalHeader +'''); + expect(dart, contains('module: _moduleMatcher([')); + expect(dart, contains('String? Function(Declaration) _moduleMatcher(')); + formatOrThrow(dart); + }); + + test('include-transitive-objc-interfaces is emitted only when true', () { + final dart = emit(''' +language: objc +include-transitive-objc-interfaces: true +$_minimalHeader +'''); + expect(dart, contains('includeTransitive: true')); + formatOrThrow(dart); + }); + + test('include-transitive-objc-categories is emitted only when false', () { + final dart = emit(''' +language: objc +include-transitive-objc-categories: false +$_minimalHeader +'''); + expect(dart, contains('includeTransitive: false')); + formatOrThrow(dart); + }); + + test('external-versions migrates both min and max', () { + final dart = emit(''' +language: objc +external-versions: + ios: + min: 12.0.0 + max: 17.0.0 + macos: + max: 14.0.0 +$_minimalHeader +'''); + expect( + dart, + contains( + "ios: Versions(min: Version.parse('12.0.0'), " + "max: Version.parse('17.0.0'))", + ), + ); + expect(dart, contains("macos: Versions(max: Version.parse('14.0.0'))")); + formatOrThrow(dart); + }); + + test('member-filter becomes an includeMember lambda', () { + final dart = emit(''' +language: objc +objc-interfaces: + member-filter: + Foo: + exclude: + - bar +$_minimalHeader +'''); + expect( + dart, + contains('includeMember: (Declaration decl, String member) {'), + ); + expect(dart, contains('_includeName(member,')); + formatOrThrow(dart); + }); + }); + + group('TODOs for unmappable options', () { + test('sort: true is dropped with a TODO', () { + final dart = emit(''' +sort: true +$_minimalHeader +'''); + expect(dart, contains('// TODO(ffigen migration):')); + expect(dart, contains("'sort: true'")); + }); + + test('llvm-path gets a TODO pointing at generate()', () { + final dart = emit(''' +llvm-path: + - /usr/lib +$_minimalHeader +'''); + expect(dart, contains('libclangDylib')); + }); + + test('unnamed-enums as-int has no Dart equivalent', () { + final dart = emit(''' +unnamed-enums: + as-int: + include: + - Foo +$_minimalHeader +'''); + expect(dart, contains('UnnamedEnums')); + expect(dart, contains('// TODO(ffigen migration):')); + }); + + test('variadic-arguments has no public-API equivalent', () { + final dart = emit(''' +functions: + variadic-arguments: + printf: + - types: ['int'] +$_minimalHeader +'''); + expect(dart, contains('// TODO(ffigen migration):')); + expect(dart, contains('variadic-arguments')); + }); + + test('unknown top-level keys are flagged', () { + final dart = emit(''' +some-made-up-key: 123 +$_minimalHeader +'''); + expect(dart, contains("Unhandled top-level key 'some-made-up-key'")); + }); + }); + + test('resolves relative header/output paths against the output script', () { + final dart = emit( + ''' +output: 'lib/out.dart' +headers: + entry-points: + - '${absPath('test/config_tests/exclude_all_by_default.h')}' +''', + configFilename: absPath('test/config_tests/some_config.yaml'), + outputFilePath: absPath( + path.join('test', 'config_tests', 'tool', 'ffigen.dart'), + ), + ); + expect(dart, contains("Platform.script.resolve('../')")); + expect(dart, contains("packageRoot.resolve('lib/out.dart')")); + formatOrThrow(dart); + }); + + test('all in-repo config.yaml files can be migrated without throwing', () { + final configYamlGlob = Glob('**config.yaml'); + final configYamlFiles = configYamlGlob.listFileSystemSync( + const LocalFileSystem(), + root: packagePathForTests, + ); + expect(configYamlFiles, isNotEmpty); + + for (final fileEntity in configYamlFiles) { + final file = fileEntity.absolute.path; + final doc = loadYaml( + File(file).readAsStringSync(), + sourceUrl: Uri.file(file), + ); + if (doc is! YamlMap) continue; + + String dart; + try { + dart = emitDartConfig( + doc, + configFilename: file, + outputFilePath: path.join( + path.dirname(file), + 'tool', + 'ffigen.dart', + ), + ); + } catch (e, st) { + fail('emitDartConfig threw for $file:\n$e\n$st'); + } + + try { + formatOrThrow(dart); + } catch (e, st) { + fail( + 'Generated Dart for $file did not format cleanly:\n' + '$e\n$st\n\n$dart', + ); + } + } + }); + }); +}