diff --git a/.pubignore b/.pubignore new file mode 100644 index 00000000..b71d2dde --- /dev/null +++ b/.pubignore @@ -0,0 +1,4 @@ +.dart_tool/ +build/ +doc/api/ +rootstraps/ diff --git a/doc/dartdoc/module_topic.md b/doc/dartdoc/module_topic.md new file mode 100644 index 00000000..13d116f7 --- /dev/null +++ b/doc/dartdoc/module_topic.md @@ -0,0 +1,7 @@ +# Module topic + +This topic groups the generated docs namespace for one Tizen module. + +The page title identifies the exact Tizen version and module. The listed API +surface is generated from `package:tizen_interop//tizen.dart`, and +each namespace class points back to the corresponding runtime getter. diff --git a/scripts/README.md b/scripts/README.md index 63116787..faed1024 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -40,3 +40,66 @@ Run the script with: ```sh python3 scripts/generate_doc_script.py ``` + +## Preparing module-scoped pub.dev docs + +`pub.dev` generates API docs only from the uploaded package contents. To avoid +publishing one oversized API page for `package:tizen_interop/6.0/tizen.dart`, +prepare a publish-time docs library and a root `dartdoc_options.yaml` +before publishing. + +Use: + +```sh +scripts/prepare_pubdev_module_docs.sh prepare 6.0 +``` + +This generates: + +1. A docs-only library at `lib/6.0/tizen_docs.dart` +2. A publish-time `dartdoc_options.yaml` + +The generated `dartdoc_options.yaml` is intentionally scoped to the docs-only +library: + +* groups APIs under version/module topic pages such as `Tizen 6.0 / accounts_svc` +* excludes the original monolithic `tizen_interop` library +* includes only the generated docs library +* reuses the shared topic markdown at `doc/dartdoc/module_topic.md` + +Running `prepare` again for another version updates the same root +`dartdoc_options.yaml` so it covers all currently prepared versions. For +example, running `prepare 6.0` and then `prepare 6.5` produces one +`dartdoc_options.yaml` containing both `Tizen 6.0 / ...` and +`Tizen 6.5 / ...` module topic entries. + +To verify the exact publish-time setup: + +```sh +scripts/prepare_pubdev_module_docs.sh verify 6.0 +``` + +`verify` prepares the docs library, runs `dart doc --validate-links`, +runs `dart pub publish --dry-run`, and then removes the generated publish-time +files again. Pass `--keep-generated` if the package should remain in the +publish-ready state after verification. If other versions are already prepared, +their generated `dartdoc_options.yaml` entries stay in place and only the +verified version is cleaned up. + +To remove the generated publish-time files: + +```sh +scripts/prepare_pubdev_module_docs.sh clean 6.0 +``` + +`clean` removes only the generated files for that version and rebuilds the root +`dartdoc_options.yaml` from any remaining prepared versions. When the last +prepared version is cleaned, the original `dartdoc_options.yaml` is restored +if one existed before preparation. + +If a symbol from `generated_symbols.dart` is not found in +`generated_bindings.dart` and appears callback-related, the generated docs +namespace points readers to `package:tizen_interop_callbacks` so callback APIs +can be checked through `TizenInteropCallbacks.register()`, +`RegisteredCallback.interopCallback`, and +`RegisteredCallback.interopUserData`. diff --git a/scripts/prepare_pubdev_module_docs.sh b/scripts/prepare_pubdev_module_docs.sh new file mode 100755 index 00000000..44ba91a6 --- /dev/null +++ b/scripts/prepare_pubdev_module_docs.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Copyright 2026 Samsung Electronics Co., Ltd. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +command="${1:-verify}" +if [ "$#" -gt 0 ]; then + shift +fi + +version="6.0" +if [ "$#" -gt 0 ] && [ "${1#--}" = "$1" ]; then + version="$1" + shift +fi + +cd "$ROOT_DIR" +dart run tool/prepare_pubdev_module_docs.dart "$command" --version "$version" "$@" diff --git a/tool/prepare_pubdev_module_docs.dart b/tool/prepare_pubdev_module_docs.dart new file mode 100644 index 00000000..ab00a783 --- /dev/null +++ b/tool/prepare_pubdev_module_docs.dart @@ -0,0 +1,1415 @@ +import 'dart:collection'; +import 'dart:convert'; +import 'dart:io'; + +const _generatedMarker = + '// Generated by tool/prepare_pubdev_module_docs.dart. Do not edit.'; +const _yamlGeneratedMarker = + '# Generated by tool/prepare_pubdev_module_docs.dart. Do not edit.'; +const _sharedModuleCategoryMarkdownPath = 'doc/dartdoc/module_topic.md'; + +class ParameterInfo { + ParameterInfo(this.type, this.name); + + final String type; + final String name; +} + +class MethodInfo { + MethodInfo({ + required this.docLines, + required this.returnType, + required this.name, + required this.parameters, + }); + + final List docLines; + final String returnType; + final String name; + final List parameters; +} + +class TypedefInfo { + TypedefInfo({ + required this.docLines, + required this.name, + required this.definition, + }); + + final List docLines; + final String name; + final String definition; +} + +class CallbackTypedefGroup { + CallbackTypedefGroup({ + required this.callbackType, + required this.nativeSignatureType, + required this.dartSignatureType, + required this.docLines, + }); + + final String callbackType; + final String nativeSignatureType; + final String? dartSignatureType; + final List docLines; +} + +class CallbackInteropInfo { + CallbackInteropInfo({ + required this.helperClassName, + required this.registrationMethodName, + required this.registeredCallbackTypeName, + required this.pointerGetterName, + required this.userDataGetterName, + }); + + final String helperClassName; + final String registrationMethodName; + final String registeredCallbackTypeName; + final String pointerGetterName; + final String userDataGetterName; +} + +class CallbackReference { + CallbackReference({ + required this.group, + required this.usedByMethods, + }); + + final CallbackTypedefGroup group; + final List usedByMethods; +} + +class ModuleInfo { + ModuleInfo({ + required this.symbolMapVariable, + required this.getterName, + required this.libraryName, + required this.fileName, + required this.moduleSlug, + required this.sharedObjects, + required this.descriptions, + required this.methods, + required this.callbackReferences, + required this.callbackRelatedMissingSymbols, + required this.unresolvedSymbols, + }); + + final String symbolMapVariable; + final String getterName; + final String libraryName; + final String fileName; + final String moduleSlug; + final List sharedObjects; + final List descriptions; + final List methods; + final List callbackReferences; + final List callbackRelatedMissingSymbols; + final List unresolvedSymbols; + + String get descriptionText => + descriptions.isEmpty ? 'Unknown module' : descriptions.join(', '); +} + +class _Config { + _Config({ + required this.command, + required this.version, + required this.keepGenerated, + }); + + final String command; + final String version; + final bool keepGenerated; +} + +class _CategorySpec { + _CategorySpec({ + required this.name, + required this.displayName, + required this.markdownPath, + }); + + final String name; + final String displayName; + final String markdownPath; + + Map toJson() => { + 'name': name, + 'displayName': displayName, + 'markdownPath': markdownPath, + }; + + static _CategorySpec fromJson(Map json) { + return _CategorySpec( + name: json['name']! as String, + displayName: json['displayName']! as String, + markdownPath: json['markdownPath']! as String, + ); + } +} + +class _State { + _State({ + required this.version, + required this.generatedFiles, + required this.directoriesToPrune, + required this.categories, + required this.includeLibraries, + required this.footerTextPath, + }); + + final String version; + final List generatedFiles; + final List directoriesToPrune; + final List<_CategorySpec> categories; + final List includeLibraries; + final String? footerTextPath; + + Map toJson() => { + 'version': version, + 'generatedFiles': generatedFiles, + 'directoriesToPrune': directoriesToPrune, + 'categories': categories.map((category) => category.toJson()).toList(), + 'includeLibraries': includeLibraries, + 'footerTextPath': footerTextPath, + }; + + static _State fromJson(Map json) { + final version = json['version']! as String; + final versionId = version.replaceAll('.', '_'); + final generatedFiles = + (json['generatedFiles']! as List).cast(); + + return _State( + version: version, + generatedFiles: generatedFiles, + directoriesToPrune: + (json['directoriesToPrune']! as List).cast(), + categories: (json['categories'] as List?) + ?.cast>() + .map(_CategorySpec.fromJson) + .toList() ?? + _legacyCategories(version, versionId, generatedFiles, json), + includeLibraries: + (json['includeLibraries'] as List?)?.cast() ?? + _legacyIncludeLibraries(versionId, generatedFiles), + footerTextPath: json.containsKey('footerTextPath') + ? json['footerTextPath'] as String? + : 'doc/dartdoc/tizen_$versionId/footer.txt', + ); + } +} + +Future main(List args) async { + final config = _parseArgs(args); + final rootDir = _repoRoot(); + + switch (config.command) { + case 'prepare': + await _prepare(rootDir, config); + return; + case 'verify': + await _prepare(rootDir, config); + final versionId = config.version.replaceAll('.', '_'); + try { + await _run( + 'dart', + [ + 'doc', + '--validate-links', + '-o', + '.dart_tool/pubdev_module_docs/$versionId/preview', + '.', + ], + workingDirectory: rootDir.path, + ); + await _runPublishDryRun(workingDirectory: rootDir.path); + } finally { + if (!config.keepGenerated) { + _clean(rootDir, config.version); + } + } + return; + case 'clean': + _clean(rootDir, config.version); + return; + default: + throw ArgumentError('Unknown command: ${config.command}'); + } +} + +_Config _parseArgs(List args) { + var command = 'verify'; + var version = '6.0'; + var keepGenerated = false; + + for (var index = 0; index < args.length; index++) { + final arg = args[index]; + switch (arg) { + case 'prepare': + case 'verify': + case 'clean': + command = arg; + case '--version': + if (index + 1 >= args.length) { + throw ArgumentError('Missing value for --version'); + } + version = args[++index]; + case '--keep-generated': + keepGenerated = true; + case '-h': + case '--help': + stdout.writeln( + 'Usage: dart run tool/prepare_pubdev_module_docs.dart ' + '[prepare|verify|clean] [--version 6.0] [--keep-generated]', + ); + exit(0); + default: + throw ArgumentError('Unknown argument: $arg'); + } + } + + return _Config( + command: command, + version: version, + keepGenerated: keepGenerated, + ); +} + +Directory _repoRoot() { + final scriptFile = File.fromUri(Platform.script); + return scriptFile.parent.parent; +} + +Future _prepare(Directory rootDir, _Config config) async { + _clean(rootDir, config.version, allowMissing: true); + + final version = config.version; + final versionId = version.replaceAll('.', '_'); + final stateDir = _stateDirectory(rootDir, version) + ..createSync(recursive: true); + + final generatedBindingsFile = File( + '${rootDir.path}/lib/src/bindings/$version/generated_bindings.dart', + ); + final generatedSymbolsFile = File( + '${rootDir.path}/lib/src/bindings/$version/generated_symbols.dart', + ); + final tizenLibraryFile = File('${rootDir.path}/lib/$version/tizen.dart'); + final symgenConfigFile = File('${rootDir.path}/configs/$version/symgen.yaml'); + final callbacksLibraryFile = File( + '${rootDir.path}/packages/tizen_interop_callbacks/lib/' + 'tizen_interop_callbacks.dart', + ); + final sharedCategoryMarkdownFile = File( + '${rootDir.path}/$_sharedModuleCategoryMarkdownPath', + ); + + for (final file in [ + generatedBindingsFile, + generatedSymbolsFile, + tizenLibraryFile, + symgenConfigFile, + callbacksLibraryFile, + sharedCategoryMarkdownFile, + ]) { + if (!file.existsSync()) { + throw StateError('Missing required file: ${file.path}'); + } + } + + final methods = _parseMethods(generatedBindingsFile.readAsLinesSync()); + final symbolMaps = + _parseGeneratedSymbols(generatedSymbolsFile.readAsLinesSync()); + final getters = _parseGetterMap(tizenLibraryFile.readAsStringSync()); + final descriptions = + _parseSymgenDescriptions(symgenConfigFile.readAsLinesSync()); + final typedefs = _parseTypedefs(generatedBindingsFile.readAsLinesSync()); + final callbackGroups = _buildCallbackTypedefGroups(typedefs); + final callbackInterop = + _parseCallbackInteropInfo(callbacksLibraryFile.readAsStringSync()); + final modules = _buildModules( + version: version, + versionId: versionId, + methods: methods, + symbolMaps: symbolMaps, + getters: getters, + descriptions: descriptions, + callbackGroups: callbackGroups, + ); + + final docsLibraryFile = File('${rootDir.path}/lib/$version/tizen_docs.dart'); + if (docsLibraryFile.existsSync()) { + final existingContent = docsLibraryFile.readAsStringSync(); + if (!existingContent.startsWith(_generatedMarker)) { + throw StateError( + 'Refusing to overwrite non-generated file ${docsLibraryFile.path}.', + ); + } + } + + final generatedFiles = []; + final directoriesToPrune = [stateDir.path]; + final stateFile = _stateFile(rootDir, version); + final includeLibraries = ['tizen_${versionId}_docs']; + final moduleCategories = <_CategorySpec>[]; + var stateWritten = false; + + try { + for (final module in modules) { + final category = _CategorySpec( + name: 'tizen_${versionId}_${module.moduleSlug}', + displayName: 'Tizen $version / ${module.moduleSlug}', + markdownPath: _sharedModuleCategoryMarkdownPath, + ); + moduleCategories.add(category); + } + + docsLibraryFile.writeAsStringSync( + _buildDocsLibrary( + version: version, + versionId: versionId, + modules: modules, + moduleCategories: moduleCategories, + callbackInterop: callbackInterop, + ), + ); + generatedFiles.add(docsLibraryFile.path); + final state = _State( + version: version, + generatedFiles: generatedFiles, + directoriesToPrune: directoriesToPrune, + categories: moduleCategories, + includeLibraries: includeLibraries, + footerTextPath: null, + ); + stateFile.parent.createSync(recursive: true); + stateFile.writeAsStringSync( + const JsonEncoder.withIndent(' ').convert(state.toJson()), + ); + stateWritten = true; + + _rewriteDartdocOptions(rootDir); + } catch (_) { + if (stateWritten && stateFile.existsSync()) { + stateFile.deleteSync(); + } + for (final path in generatedFiles.reversed) { + final file = File(path); + if (file.existsSync()) { + file.deleteSync(); + } + } + if (docsLibraryFile.existsSync()) { + final content = docsLibraryFile.readAsStringSync(); + if (content.startsWith(_generatedMarker)) { + docsLibraryFile.deleteSync(); + } + } + _deleteVerifyPreview(rootDir, version); + for (final directoryPath in directoriesToPrune.reversed) { + _deleteEmptyDirectoryChain(directoryPath, stopAt: rootDir.path); + } + try { + _rewriteDartdocOptions(rootDir); + } catch (error, stackTrace) { + stderr.writeln('Failed to restore dartdoc_options.yaml: $error'); + stderr.writeln(stackTrace); + } + rethrow; + } + + final preparedStates = _loadPreparedStates(rootDir); + stdout.writeln( + 'Prepared ${modules.length} module categories for pub.dev in ' + '${docsLibraryFile.path}. ' + 'dartdoc_options.yaml now covers ${preparedStates.length} prepared ' + 'version(s).', + ); +} + +void _clean( + Directory rootDir, + String version, { + bool allowMissing = false, +}) { + final stateFile = _stateFile(rootDir, version); + if (!stateFile.existsSync()) { + if (!allowMissing) { + stdout.writeln( + 'No generated pub.dev module docs state found for version $version.', + ); + } + return; + } + + final state = _State.fromJson( + jsonDecode(stateFile.readAsStringSync()) as Map, + ); + + _deleteVerifyPreview(rootDir, version); + + for (final path in state.generatedFiles.reversed) { + final file = File(path); + if (file.existsSync()) { + file.deleteSync(); + } + } + + stateFile.deleteSync(); + for (final directoryPath in state.directoriesToPrune.reversed) { + _deleteEmptyDirectoryChain(directoryPath, stopAt: rootDir.path); + } + + _rewriteDartdocOptions(rootDir); +} + +File _stateFile(Directory rootDir, String version) { + return File('${_stateDirectory(rootDir, version).path}/state.json'); +} + +Directory _stateDirectory(Directory rootDir, String version) { + final versionId = version.replaceAll('.', '_'); + return Directory('${rootDir.path}/.dart_tool/pubdev_module_docs/$versionId'); +} + +File _dartdocOptionsBackupFile(Directory rootDir) { + return File( + '${rootDir.path}/.dart_tool/pubdev_module_docs/dartdoc_options.yaml.backup', + ); +} + +void _deleteVerifyPreview(Directory rootDir, String version) { + final previewDir = + Directory('${_stateDirectory(rootDir, version).path}/preview'); + if (previewDir.existsSync()) { + previewDir.deleteSync(recursive: true); + } +} + +void _ensureDartdocOptionsBackup(Directory rootDir) { + final dartdocOptionsFile = File('${rootDir.path}/dartdoc_options.yaml'); + final backupFile = _dartdocOptionsBackupFile(rootDir); + if (backupFile.existsSync() || !dartdocOptionsFile.existsSync()) { + return; + } + + final content = dartdocOptionsFile.readAsStringSync(); + if (content.startsWith(_yamlGeneratedMarker)) { + dartdocOptionsFile.deleteSync(); + return; + } + + backupFile.parent.createSync(recursive: true); + dartdocOptionsFile.copySync(backupFile.path); +} + +List<_State> _loadPreparedStates(Directory rootDir) { + final stateRoot = Directory('${rootDir.path}/.dart_tool/pubdev_module_docs'); + if (!stateRoot.existsSync()) { + return const []; + } + + final states = <_State>[]; + for (final entity in stateRoot.listSync()) { + if (entity is! Directory) { + continue; + } + final stateFile = File('${entity.path}/state.json'); + if (!stateFile.existsSync()) { + continue; + } + final state = _State.fromJson( + jsonDecode(stateFile.readAsStringSync()) as Map, + ); + states.add(state); + } + + states.sort((left, right) => _compareVersions(left.version, right.version)); + return states; +} + +int _compareVersions(String left, String right) { + final leftParts = left.split('.').map(int.parse).toList(); + final rightParts = right.split('.').map(int.parse).toList(); + final maxLength = leftParts.length > rightParts.length + ? leftParts.length + : rightParts.length; + + for (var index = 0; index < maxLength; index++) { + final leftPart = index < leftParts.length ? leftParts[index] : 0; + final rightPart = index < rightParts.length ? rightParts[index] : 0; + if (leftPart != rightPart) { + return leftPart.compareTo(rightPart); + } + } + + return 0; +} + +List<_CategorySpec> _legacyCategories( + String version, + String versionId, + List generatedFiles, + Map json, +) { + final legacyName = json['categoryName'] as String? ?? 'tizen_$versionId'; + final markdownPath = + generatedFiles.map((path) => path.replaceAll('\\', '/')).firstWhere( + (path) => path.endsWith('.md'), + orElse: () => _sharedModuleCategoryMarkdownPath, + ); + + final rootIndex = markdownPath.indexOf('/doc/dartdoc/'); + final relativeMarkdownPath = rootIndex >= 0 + ? markdownPath.substring(rootIndex + 1) + : _sharedModuleCategoryMarkdownPath; + + return [ + _CategorySpec( + name: legacyName, + displayName: 'Tizen $version / tizen', + markdownPath: relativeMarkdownPath, + ), + ]; +} + +List _legacyIncludeLibraries( + String versionId, + List generatedFiles, +) { + final libraries = LinkedHashSet()..add('tizen_${versionId}_tizen'); + + for (final path in generatedFiles) { + final normalizedPath = path.replaceAll('\\', '/'); + if (!normalizedPath.contains('/lib/') || + !normalizedPath.endsWith('.dart')) { + continue; + } + final fileName = normalizedPath.split('/').last; + if (fileName == 'index.dart') { + continue; + } + libraries.add( + 'tizen_${versionId}_${fileName.substring(0, fileName.length - 5)}', + ); + } + + return libraries.toList(); +} + +void _rewriteDartdocOptions(Directory rootDir) { + final states = _loadPreparedStates(rootDir); + final dartdocOptionsFile = File('${rootDir.path}/dartdoc_options.yaml'); + final backupFile = _dartdocOptionsBackupFile(rootDir); + + if (states.isEmpty) { + if (backupFile.existsSync()) { + if (dartdocOptionsFile.existsSync()) { + dartdocOptionsFile.deleteSync(); + } + backupFile.renameSync(dartdocOptionsFile.path); + _deleteEmptyDirectoryChain(backupFile.parent.path, stopAt: rootDir.path); + return; + } + + if (dartdocOptionsFile.existsSync()) { + final content = dartdocOptionsFile.readAsStringSync(); + if (content.startsWith(_yamlGeneratedMarker)) { + dartdocOptionsFile.deleteSync(); + } + } + _deleteEmptyDirectoryChain(backupFile.parent.path, stopAt: rootDir.path); + return; + } + + _ensureDartdocOptionsBackup(rootDir); + dartdocOptionsFile.writeAsStringSync(_buildDartdocOptions(states)); +} + +void _deleteEmptyDirectoryChain( + String directoryPath, { + required String stopAt, +}) { + var current = Directory(directoryPath); + final stopPath = Directory(stopAt).absolute.path; + + while (current.existsSync() && current.absolute.path.startsWith(stopPath)) { + if (current.absolute.path == stopPath) { + return; + } + if (current.listSync().isNotEmpty) { + return; + } + final parent = current.parent; + current.deleteSync(); + current = parent; + } +} + +List _buildModules({ + required String version, + required String versionId, + required Map methods, + required Map>> symbolMaps, + required Map getters, + required Map descriptions, + required Map callbackGroups, +}) { + final modules = []; + final moduleEntries = symbolMaps.entries.toList() + ..sort((left, right) { + final leftGetter = getters[left.key] ?? left.key; + final rightGetter = getters[right.key] ?? right.key; + return leftGetter.compareTo(rightGetter); + }); + + for (final entry in moduleEntries) { + final getterName = getters[entry.key]; + if (getterName == null) { + stderr.writeln( + 'Skipping ${entry.key}: getter not found in lib/$version/tizen.dart'); + continue; + } + + final methodMap = LinkedHashMap(); + final missingSymbols = LinkedHashSet(); + final callbackRelatedMissingSymbols = LinkedHashSet(); + final unresolvedSymbols = LinkedHashSet(); + final callbackUsage = LinkedHashMap>(); + final sharedObjects = entry.value.keys.toList(growable: false); + + for (final symbols in entry.value.values) { + for (final symbol in symbols) { + final method = methods[symbol]; + if (method == null) { + if (!missingSymbols.add(symbol)) { + continue; + } + if (_looksLikeCallbackApiSymbol(symbol)) { + callbackRelatedMissingSymbols.add(symbol); + } else { + unresolvedSymbols.add(symbol); + } + continue; + } + + methodMap.putIfAbsent(method.name, () => method); + for (final callbackType in _extractReferencedCallbackTypes( + method, + callbackGroups, + )) { + callbackUsage + .putIfAbsent(callbackType, LinkedHashSet.new) + .add(method.name); + } + } + } + + final callbackReferences = callbackUsage.entries + .map( + (callbackEntry) => CallbackReference( + group: callbackGroups[callbackEntry.key]!, + usedByMethods: callbackEntry.value.toList(growable: false), + ), + ) + .toList(growable: false); + final moduleSlug = _moduleSlugFromGetter(getterName); + + modules.add( + ModuleInfo( + symbolMapVariable: entry.key, + getterName: getterName, + libraryName: 'tizen_${versionId}_${_camelToSnake(getterName)}', + fileName: '$moduleSlug.dart', + moduleSlug: moduleSlug, + sharedObjects: sharedObjects, + descriptions: sharedObjects + .map((library) => descriptions[library] ?? library) + .toSet() + .toList(growable: false), + methods: methodMap.values.toList(growable: false), + callbackReferences: callbackReferences, + callbackRelatedMissingSymbols: + callbackRelatedMissingSymbols.toList(growable: false), + unresolvedSymbols: unresolvedSymbols.toList(growable: false), + ), + ); + } + + return modules; +} + +Map _parseMethods(List lines) { + final methods = {}; + final docBuffer = []; + var index = 0; + + while (index < lines.length) { + final line = lines[index]; + + if (line.startsWith(' ///')) { + docBuffer.add(line.substring(2)); + index++; + continue; + } + + final zeroArgumentMethod = RegExp( + r'^ {2}(?! )(?!late final)(?!Tizen\d+Native)(.+?)\s+(\w+)\(\)\s*\{$', + ).firstMatch(line); + if (zeroArgumentMethod != null) { + final method = MethodInfo( + docLines: List.from(docBuffer), + returnType: zeroArgumentMethod.group(1)!.trim(), + name: zeroArgumentMethod.group(2)!.trim(), + parameters: const [], + ); + methods[method.name] = method; + docBuffer.clear(); + index++; + continue; + } + + final multiArgumentMethod = RegExp( + r'^ {2}(?! )(?!late final)(?!Tizen\d+Native)(.+?)\s+(\w+)\(\s*$', + ).firstMatch(line); + if (multiArgumentMethod != null) { + final parameterLines = []; + index++; + while (index < lines.length && lines[index].trim() != ') {') { + final parameterLine = lines[index].trim(); + if (parameterLine.isNotEmpty) { + parameterLines.add(parameterLine); + } + index++; + } + final method = MethodInfo( + docLines: List.from(docBuffer), + returnType: multiArgumentMethod.group(1)!.trim(), + name: multiArgumentMethod.group(2)!.trim(), + parameters: _parseParameters(parameterLines.join(' ')), + ); + methods[method.name] = method; + docBuffer.clear(); + index++; + continue; + } + + if (line.trim().isNotEmpty) { + docBuffer.clear(); + } + index++; + } + + return methods; +} + +List _parseParameters(String parameterBlock) { + if (parameterBlock.trim().isEmpty) { + return const []; + } + return _splitParameters(parameterBlock).map(_parseParameter).toList(); +} + +List _splitParameters(String parameterBlock) { + final parameters = []; + final buffer = StringBuffer(); + var angleDepth = 0; + var parenthesisDepth = 0; + var squareDepth = 0; + + for (final rune in parameterBlock.runes) { + final char = String.fromCharCode(rune); + if (char == '<') { + angleDepth++; + } else if (char == '>') { + if (angleDepth > 0) { + angleDepth--; + } + } else if (char == '(') { + parenthesisDepth++; + } else if (char == ')') { + if (parenthesisDepth > 0) { + parenthesisDepth--; + } + } else if (char == '[') { + squareDepth++; + } else if (char == ']') { + if (squareDepth > 0) { + squareDepth--; + } + } else if (char == ',' && + angleDepth == 0 && + parenthesisDepth == 0 && + squareDepth == 0) { + final parameter = buffer.toString().trim(); + if (parameter.isNotEmpty) { + parameters.add(parameter); + } + buffer.clear(); + continue; + } + buffer.write(char); + } + + final trailing = buffer.toString().trim(); + if (trailing.isNotEmpty) { + parameters.add(trailing); + } + + return parameters; +} + +ParameterInfo _parseParameter(String parameter) { + final separatorIndex = parameter.lastIndexOf(' '); + if (separatorIndex <= 0 || separatorIndex == parameter.length - 1) { + throw StateError('Unable to parse parameter: $parameter'); + } + return ParameterInfo( + parameter.substring(0, separatorIndex).trim(), + parameter.substring(separatorIndex + 1).trim(), + ); +} + +Map _parseTypedefs(List lines) { + final typedefs = {}; + final docBuffer = []; + var index = 0; + + while (index < lines.length) { + final line = lines[index]; + + if (line.startsWith('///')) { + docBuffer.add(line); + index++; + continue; + } + + if (line.startsWith('typedef ')) { + final definitionBuffer = StringBuffer(line.trimRight()); + while (!definitionBuffer.toString().trimRight().endsWith(';')) { + index++; + if (index >= lines.length) { + throw StateError('Unexpected EOF while parsing typedef.'); + } + definitionBuffer.write('\n${lines[index].trimRight()}'); + } + + final definition = definitionBuffer.toString(); + final nameMatch = RegExp(r'^typedef\s+(\w+)').firstMatch(definition); + if (nameMatch == null) { + throw StateError('Unable to determine typedef name: $definition'); + } + + final name = nameMatch.group(1)!; + typedefs[name] = TypedefInfo( + docLines: List.from(docBuffer), + name: name, + definition: definition, + ); + docBuffer.clear(); + index++; + continue; + } + + if (line.trim().isNotEmpty) { + docBuffer.clear(); + } + index++; + } + + return typedefs; +} + +Map _buildCallbackTypedefGroups( + Map typedefs, +) { + final groups = {}; + + for (final entry in typedefs.entries) { + final callbackType = entry.key; + if (!callbackType.endsWith('_cb')) { + continue; + } + + final nativeSignatureType = '${callbackType}Function'; + final dartSignatureType = 'Dart${callbackType}Function'; + final nativeSignature = typedefs[nativeSignatureType]; + if (nativeSignature == null) { + continue; + } + + groups[callbackType] = CallbackTypedefGroup( + callbackType: callbackType, + nativeSignatureType: nativeSignatureType, + dartSignatureType: + typedefs.containsKey(dartSignatureType) ? dartSignatureType : null, + docLines: entry.value.docLines, + ); + } + + return groups; +} + +Map>> _parseGeneratedSymbols( + List lines, +) { + final symbolMaps = >>{}; + String? currentMap; + String? currentLibrary; + + for (final rawLine in lines) { + final line = rawLine.trim(); + + final mapMatch = RegExp( + r'^const\s+Map>\s+(\w+)\s*=\s*\{$', + ).firstMatch(line); + if (mapMatch != null) { + currentMap = mapMatch.group(1)!; + symbolMaps[currentMap] = >{}; + continue; + } + + if (currentMap == null) { + continue; + } + + final libraryMatch = RegExp(r"^'([^']+)':\s*\[$").firstMatch(line); + if (libraryMatch != null) { + currentLibrary = libraryMatch.group(1)!; + symbolMaps[currentMap]![currentLibrary] = []; + continue; + } + + final symbolMatch = RegExp(r"^'([^']+)'[,]?$").firstMatch(line); + if (symbolMatch != null && currentLibrary != null) { + symbolMaps[currentMap]![currentLibrary]!.add(symbolMatch.group(1)!); + continue; + } + + if (line == '],') { + currentLibrary = null; + continue; + } + + if (line == '};') { + currentMap = null; + currentLibrary = null; + } + } + + return symbolMaps; +} + +Map _parseGetterMap(String content) { + final getterMap = {}; + final pattern = RegExp( + r'Tizen\w+Native\?\s+_(\w+);\s+Tizen\w+Native get (\w+)\s*=>.*?_getTizenNative\((\w+)\);', + multiLine: true, + dotAll: true, + ); + + for (final match in pattern.allMatches(content)) { + getterMap[match.group(3)!] = match.group(2)!; + } + return getterMap; +} + +Map _parseSymgenDescriptions(List lines) { + final descriptions = {}; + final pattern = RegExp(r'-\s+([\w\.\-]+)\s+.*#\s+(.*)$'); + + for (final line in lines) { + final match = pattern.firstMatch(line); + if (match != null) { + descriptions[match.group(1)!.trim()] = match.group(2)!.trim(); + } + } + + return descriptions; +} + +CallbackInteropInfo _parseCallbackInteropInfo(String content) { + final helperClassName = + RegExp(r'class\s+(\w+)\s*\{').firstMatch(content)?.group(1) ?? + 'TizenInteropCallbacks'; + final registeredCallbackTypeName = + RegExp(r'class\s+(\w+)\s*\{') + .firstMatch(content) + ?.group(1) ?? + 'RegisteredCallback'; + final registrationMethodName = RegExp( + r'(\w+)\(\s*String callbackName,', + dotAll: true, + ).firstMatch(content)?.group(1) ?? + 'register'; + final pointerGetterName = + RegExp(r'Pointer> get (\w+) =>') + .firstMatch(content) + ?.group(1) ?? + 'interopCallback'; + final userDataGetterName = + RegExp(r'Pointer get (\w+) =>').firstMatch(content)?.group(1) ?? + 'interopUserData'; + + return CallbackInteropInfo( + helperClassName: helperClassName, + registrationMethodName: registrationMethodName, + registeredCallbackTypeName: registeredCallbackTypeName, + pointerGetterName: pointerGetterName, + userDataGetterName: userDataGetterName, + ); +} + +Iterable _extractReferencedCallbackTypes( + MethodInfo method, + Map callbackGroups, +) sync* { + final seen = {}; + for (final parameter in method.parameters) { + final callbackType = parameter.type.trim(); + if (!callbackGroups.containsKey(callbackType) || !seen.add(callbackType)) { + continue; + } + yield callbackType; + } +} + +bool _looksLikeCallbackApiSymbol(String symbol) { + return symbol.endsWith('_cb') || + symbol.contains('_cb_') || + symbol.contains('_callback') || + symbol.contains('callback'); +} + +String _buildDocsLibrary({ + required String version, + required String versionId, + required List modules, + required List<_CategorySpec> moduleCategories, + required CallbackInteropInfo callbackInterop, +}) { + final buffer = StringBuffer() + ..writeln(_generatedMarker) + ..writeln( + '// ignore_for_file: camel_case_types, non_constant_identifier_names', + ) + ..writeln() + ..writeln( + '/// Docs-only categorized library for Tizen $version pub.dev API pages.', + ) + ..writeln('///') + ..writeln( + '/// Import the actual runtime API from ' + '`package:tizen_interop/$version/tizen.dart`.', + ) + ..writeln('///') + ..writeln( + '/// This generated library exists only so dartdoc can publish module ' + 'topic pages without creating one wrapper library and one markdown file ' + 'per module.', + ) + ..writeln('library tizen_${versionId}_docs;') + ..writeln() + ..writeln("import 'dart:ffi' as ffi;") + ..writeln() + ..writeln("import 'package:tizen_interop/$version/tizen.dart';"); + + for (var index = 0; index < modules.length; index++) { + buffer.writeln(); + _writeModuleDocsClass( + buffer: buffer, + version: version, + module: modules[index], + category: moduleCategories[index], + callbackInterop: callbackInterop, + ); + } + + return buffer.toString(); +} + +void _writeModuleDocsClass({ + required StringBuffer buffer, + required String version, + required ModuleInfo module, + required _CategorySpec category, + required CallbackInteropInfo callbackInterop, +}) { + buffer + ..writeln('/// Docs namespace for `${module.getterName}`.') + ..writeln('///') + ..writeln( + '/// Actual runtime entrypoint: `${module.getterName}` from ' + '`package:tizen_interop/$version/tizen.dart`.', + ) + ..writeln('/// Module symbol map: `${module.symbolMapVariable}`.') + ..writeln( + '/// Shared object(s): ' + '${module.sharedObjects.map((value) => '`$value`').join(', ')}.', + ); + + if (module.descriptions.isNotEmpty) { + buffer.writeln('/// ${module.descriptionText}.'); + } + + if (module.callbackReferences.isNotEmpty) { + buffer + ..writeln('///') + ..writeln('/// Callback typedefs used by this module:'); + for (final callbackReference in module.callbackReferences) { + buffer.writeln('/// - `${callbackReference.group.callbackType}`'); + } + } + + if (module.callbackRelatedMissingSymbols.isNotEmpty) { + buffer + ..writeln('///') + ..writeln( + '/// Callback-related symbols declared in `generated_symbols.dart` ' + 'but not found in `generated_bindings.dart`:', + ) + ..writeln( + '/// Register callbacks through `package:tizen_interop_callbacks` ' + 'using `${callbackInterop.helperClassName}.' + '${callbackInterop.registrationMethodName}()`, then pass ' + '`${callbackInterop.registeredCallbackTypeName}.' + '${callbackInterop.pointerGetterName}` and ' + '`${callbackInterop.registeredCallbackTypeName}.' + '${callbackInterop.userDataGetterName}` to the native API.', + ); + for (final symbol in module.callbackRelatedMissingSymbols) { + buffer.writeln('/// - `$symbol`'); + } + } + + if (module.unresolvedSymbols.isNotEmpty) { + buffer + ..writeln('///') + ..writeln( + '/// Symbols declared in `generated_symbols.dart` but not found in ' + '`generated_bindings.dart`:', + ); + for (final symbol in module.unresolvedSymbols) { + buffer.writeln('/// - `$symbol`'); + } + } + + buffer + ..writeln('///') + ..writeln('/// {@category ${category.name}}') + ..writeln( + 'abstract final class ${_moduleDocsTypeName(version, module)} {', + ); + + for (var index = 0; index < module.methods.length; index++) { + buffer.writeln(); + _writeMethodWrapper(buffer, module, module.methods[index]); + } + + buffer.writeln('}'); +} + +void _writeMethodWrapper( + StringBuffer buffer, + ModuleInfo module, + MethodInfo method, +) { + for (final docLine in method.docLines) { + buffer.writeln(' ${_normalizeDocLine(docLine)}'); + } + + if (method.docLines.isNotEmpty) { + buffer + ..writeln(' ///') + ..writeln(' /// Runtime module getter: `${module.getterName}`.'); + } + + if (method.parameters.isEmpty) { + buffer + ..writeln(' static ${method.returnType} ${method.name}() =>') + ..writeln(' ${module.getterName}.${method.name}();'); + return; + } + + buffer..writeln(' static ${method.returnType} ${method.name}('); + for (final parameter in method.parameters) { + buffer.writeln(' ${parameter.type} ${parameter.name},'); + } + buffer + ..writeln(' ) =>') + ..writeln(' ${module.getterName}.${method.name}('); + for (final parameter in method.parameters) { + buffer.writeln(' ${parameter.name},'); + } + buffer..writeln(' );'); +} + +String _buildDartdocOptions(List<_State> states) { + final versions = states.map((state) => state.version).join(', '); + final categories = states.expand((state) => state.categories).toList(); + final footerTextPaths = + states.map((state) => state.footerTextPath).whereType().toList(); + final includeLibraries = LinkedHashSet(); + for (final state in states) { + includeLibraries.addAll(state.includeLibraries); + } + final buffer = StringBuffer() + ..writeln(_yamlGeneratedMarker) + ..writeln( + '# Publish-time dartdoc configuration for Tizen versions: $versions.', + ) + ..writeln( + '# This file is temporary and is created by ' + 'scripts/prepare_pubdev_module_docs.sh.', + ) + ..writeln( + '# pub.dev builds API docs only from the uploaded package contents, so ' + 'the generated docs libraries and this file must exist before ' + 'publishing.', + ) + ..writeln('dartdoc:') + ..writeln( + ' # Group the generated docs namespaces under version/module topic ' + 'pages.', + ) + ..writeln(' categories:'); + + if (categories.isEmpty) { + buffer.writeln(' {}'); + } else { + for (final category in categories) { + buffer + ..writeln(' ${category.name}:') + ..writeln(' markdown: ${category.markdownPath}') + ..writeln(' displayName: ${category.displayName}'); + } + } + + buffer + ..writeln() + ..writeln( + ' # Hide the original monolithic library from the published API docs.', + ) + ..writeln(' exclude:') + ..writeln(' - tizen_interop'); + + if (footerTextPaths.isNotEmpty) { + buffer + ..writeln() + ..writeln(' # Explain that these are generated categorized docs pages.') + ..writeln(' footerText:'); + for (final footerTextPath in footerTextPaths) { + buffer.writeln(' - $footerTextPath'); + } + } + + buffer + ..writeln() + ..writeln(' # Publish only the generated docs libraries.') + ..writeln(' include:'); + for (final library in includeLibraries) { + buffer.writeln(' - $library'); + } + + return buffer.toString(); +} + +String _camelToSnake(String value) { + return value + .replaceAllMapped( + RegExp(r'([a-z0-9])([A-Z])'), + (match) => '${match.group(1)}_${match.group(2)}', + ) + .toLowerCase(); +} + +String _moduleSlugFromGetter(String getterName) { + const prefix = 'tizen'; + final trimmed = getterName.startsWith(prefix) + ? getterName.substring(prefix.length) + : getterName; + return _camelToSnake(trimmed).replaceFirst(RegExp(r'^_+'), ''); +} + +String _moduleDocsTypeName(String version, ModuleInfo module) { + const prefix = 'tizen'; + final trimmed = module.getterName.startsWith(prefix) + ? module.getterName.substring(prefix.length) + : _uppercaseFirst(module.getterName); + return 'Tizen${version.replaceAll('.', '')}${_uppercaseFirst(trimmed)}Docs'; +} + +String _uppercaseFirst(String value) { + if (value.isEmpty) { + return value; + } + return '${value[0].toUpperCase()}${value.substring(1)}'; +} + +String _normalizeDocLine(String line) { + return line + .replaceAll('@param[in,out]', 'Parameter') + .replaceAll('@param[out]', 'Output parameter') + .replaceAll('@param[in]', 'Parameter') + .replaceAll('[in,out]', 'in,out') + .replaceAll('[out]', 'out') + .replaceAll('[in]', 'in'); +} + +Future _run( + String executable, + List arguments, { + required String workingDirectory, +}) async { + final process = await Process.start( + executable, + arguments, + workingDirectory: workingDirectory, + ); + final stdoutDone = stdout.addStream(process.stdout); + final stderrDone = stderr.addStream(process.stderr); + final exitCode = await process.exitCode; + await stdoutDone; + await stderrDone; + + if (exitCode != 0) { + throw ProcessException(executable, arguments, '', exitCode); + } +} + +Future _runPublishDryRun({ + required String workingDirectory, +}) async { + final result = await Process.run( + 'dart', + ['pub', 'publish', '--dry-run'], + workingDirectory: workingDirectory, + ); + + stdout.write(result.stdout); + stderr.write(result.stderr); + + if (result.exitCode == 0) { + return; + } + + final output = '${result.stdout}\n${result.stderr}'; + final hasOnlyWarnings = RegExp(r'Package has \d+ warning').hasMatch(output) && + !RegExp(r'Package has .*error', caseSensitive: false).hasMatch(output); + if (hasOnlyWarnings) { + stdout.writeln( + 'dart pub publish --dry-run returned warnings only; continuing.', + ); + return; + } + + throw ProcessException( + 'dart', + const ['pub', 'publish', '--dry-run'], + '', + result.exitCode, + ); +}