From a8bed11e286e4a64782555778dda68b48b0892c4 Mon Sep 17 00:00:00 2001 From: sonali-dudhia Date: Fri, 10 Jul 2026 16:43:04 +0530 Subject: [PATCH] Add add-function.php to scaffold new bridge functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding one bridge function today means hand-editing six files across three languages (nativephp.json, Contract, Plugin, Facade, Kotlin, Swift) plus a test — miss one and it only fails at runtime on a device. add-function.php runs after configure.php, inside an already configured plugin, and does all of it in one command: php add-function.php --name=Level --description="Reads the battery level." Everything it needs is discovered at runtime (nativephp.json, composer.json's psr-4 map, the single Contract/Facade files, the resources/android and resources/ios bridge files) so the script has no baked-in coupling to any one plugin and configure.php never has to touch it (confirmed: configure.php's placeholder map doesn't collide with the {{ function }}/{{ function_camel }} family the scaffolder uses, plus a one-line defensive skip added to configure.php anyway). Validates before writing anything: PascalCase names only, rejects duplicates (checked against nativephp.json and the contract), rejects PHP/Kotlin/Swift reserved words. --skip-android/--skip-ios omit that platform's file and manifest key. --dry-run previews with zero writes. If a native bridge file or insertion anchor can't be found, that one file is skipped with a warning and the rest still completes. Also: relaxed tests/ManifestTest.php's hardcoded bridge_functions count assertion, since it broke the instant a second function was added — it now checks the Example entry specifically instead. Co-Authored-By: Claude Sonnet 5 --- README.md | 14 +- add-function.php | 730 ++++++++++++++++++++++++++++++++ configure.php | 2 +- docs/bridge-functions.md | 32 +- docs/creating-first-plugin.md | 19 +- stubs/bridge-function-test.stub | 29 ++ tests/ManifestTest.php | 10 +- 7 files changed, 815 insertions(+), 21 deletions(-) create mode 100644 add-function.php create mode 100644 stubs/bridge-function-test.stub diff --git a/README.md b/README.md index 57ef71f..279c04a 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ resources/android/ Kotlin bridge implementation copied into Android builds resources/ios/ Swift bridge implementation copied into iOS builds android/ Android module starter and packaging notes ios/ iOS module starter and packaging notes -stubs/ Files for future scaffolding automation +stubs/ Templates used by add-function.php to scaffold new bridge functions tests/ Pest tests for PHP and manifest behavior docs/ Maintainer and user documentation .github/ Issue templates, workflows, release automation @@ -109,16 +109,12 @@ composer test composer lint ``` -## Future Automation +## Adding Bridge Functions -The `stubs/` directory is designed for a future companion scaffolder such as `nativephp-plugin-maker`. - -Possible workflows: +Once the template is configured, add a new bridge function with one command: ```bash -composer create-project {{ vendor }}/nativephp-plugin-template my-plugin +php add-function.php --name=Level --description="Reads the battery level." ``` -```bash -nativephp-plugin new Battery -``` +This adds the manifest entry, the contract method, the `Plugin` implementation, the facade `@method` line, the Kotlin and Swift bridge classes, and a Pest test — see `docs/bridge-functions.md` for what each file gets and the available flags (`--skip-android`, `--skip-ios`, `--dry-run`, `--no-interaction`). diff --git a/add-function.php b/add-function.php new file mode 100644 index 0000000..808e897 --- /dev/null +++ b/add-function.php @@ -0,0 +1,730 @@ +#!/usr/bin/env php + + */ + private array $warnings = []; + + /** + * @var array + */ + private array $summary = []; + + /** + * @param array $options + */ + public function __construct( + private readonly array $options, + private readonly string $root, + ) {} + + public function run(): void + { + $this->writeln(''); + $this->writeln($this->bold('NativePHP Bridge Function Scaffolder')); + $this->writeln('Add a new bridge function to an already configured plugin.'); + $this->writeln(''); + + $nativephpPath = $this->root.'/nativephp.json'; + $composerPath = $this->root.'/composer.json'; + + if (! file_exists($nativephpPath) || ! file_exists($composerPath)) { + $this->fail('nativephp.json or composer.json is missing. Run this script from the plugin root.'); + } + + $nativephpRaw = (string) file_get_contents($nativephpPath); + + if (str_contains($nativephpRaw, '{{ ')) { + $this->fail('This repository is still an unconfigured template. Run "php configure.php" first, then re-run add-function.php.'); + } + + /** @var array $manifest */ + $manifest = json_decode($nativephpRaw, true) ?? []; + $bridgeNamespace = is_string($manifest['namespace'] ?? null) ? $manifest['namespace'] : ''; + + if ($bridgeNamespace === '') { + $this->fail('nativephp.json is missing a "namespace" value.'); + } + + /** @var array $composer */ + $composer = json_decode((string) file_get_contents($composerPath), true) ?? []; + $phpNamespace = $this->discoverPhpNamespace($composer); + + if ($phpNamespace === null) { + $this->fail('Could not find a "src/" entry under composer.json "autoload.psr-4".'); + } + + $contractPath = $this->findSingleFile('src/Contracts', 'Contract.php', 'Contracts'); + $facadePath = $this->findSingleFile('src/Facades', '.php', 'Facades'); + $pluginPath = $this->root.'/src/Plugin.php'; + + if (! file_exists($pluginPath)) { + $this->fail('Expected src/Plugin.php. Add the new method manually.'); + } + + $name = $this->option('name', $this->ask('Bridge function name (PascalCase, e.g. Level)', '')); + $description = $this->option('description', $this->ask('Description', '')); + $skipAndroid = $this->hasOption('skip-android'); + $skipIos = $this->hasOption('skip-ios'); + $dryRun = $this->hasOption('dry-run'); + + $this->validateName($name); + $methodName = lcfirst($name); + $this->validateReservedWords($methodName, $name, $skipAndroid, $skipIos); + + $fqName = "{$bridgeNamespace}.{$name}"; + + /** @var array> $bridgeFunctions */ + $bridgeFunctions = is_array($manifest['bridge_functions'] ?? null) ? $manifest['bridge_functions'] : []; + + $this->rejectDuplicates($fqName, $bridgeFunctions, $contractPath, $methodName); + + $exampleEntry = $this->findExampleEntry($bridgeFunctions, $bridgeNamespace); + + $androidTarget = $skipAndroid ? null : $this->deriveTarget( + is_string($exampleEntry['android'] ?? null) ? $exampleEntry['android'] : null, + $name, + fn (): string => $this->fallbackAndroidTarget($bridgeNamespace, $name), + ); + + $iosTarget = $skipIos ? null : $this->deriveTarget( + is_string($exampleEntry['ios'] ?? null) ? $exampleEntry['ios'] : null, + $name, + fn (): string => "{$bridgeNamespace}Functions.{$name}", + ); + + $writes = []; + + $writes[$nativephpPath] = $this->buildManifest($manifest, $bridgeFunctions, $fqName, $androidTarget, $iosTarget, $description); + $writes[$contractPath] = $this->addContractMethod((string) file_get_contents($contractPath), $fqName, $methodName); + $writes[$pluginPath] = $this->addPluginMethod((string) file_get_contents($pluginPath), $fqName, $methodName); + $writes[$facadePath] = $this->addFacadeMethod((string) file_get_contents($facadePath), $methodName); + + $this->summary['manifest'] = true; + $this->summary['contract'] = true; + $this->summary['plugin'] = true; + $this->summary['facade'] = true; + + if ($skipAndroid) { + $this->summary['kotlin'] = 'skipped'; + } else { + $kotlinResult = $this->addKotlinFunction($name); + + if ($kotlinResult !== null) { + [$kotlinPath, $kotlinContents] = $kotlinResult; + $writes[$kotlinPath] = $kotlinContents; + $this->summary['kotlin'] = true; + } else { + $this->summary['kotlin'] = 'manual'; + } + } + + if ($skipIos) { + $this->summary['swift'] = 'skipped'; + } else { + $swiftResult = $this->addSwiftFunction($name); + + if ($swiftResult !== null) { + [$swiftPath, $swiftContents] = $swiftResult; + $writes[$swiftPath] = $swiftContents; + $this->summary['swift'] = true; + } else { + $this->summary['swift'] = 'manual'; + } + } + + $testPath = $this->root.'/tests/'.$name.'Test.php'; + $writes[$testPath] = $this->buildTest($phpNamespace, $bridgeNamespace, $name, $methodName); + $this->summary['test'] = true; + + if ($dryRun) { + $this->printDryRun($writes); + + return; + } + + foreach ($writes as $path => $contents) { + $directory = dirname($path); + + if (! is_dir($directory)) { + mkdir($directory, 0777, true); + } + + file_put_contents($path, $contents); + } + + $this->printSummary($fqName, $writes, $androidTarget, $iosTarget, $skipAndroid, $skipIos); + } + + private function validateName(string $name): void + { + if ($name === '') { + $this->fail('--name is required.'); + } + + if (preg_match('/^[A-Z][A-Za-z0-9]*$/', $name) !== 1) { + $this->fail("Invalid function name [{$name}]. Use PascalCase, starting with an uppercase letter (e.g. Level, ChargeState)."); + } + } + + private function validateReservedWords(string $methodName, string $name, bool $skipAndroid, bool $skipIos): void + { + if (in_array(strtolower($methodName), self::PHP_RESERVED, true)) { + $this->fail("[{$name}] camelCases to the PHP reserved word [{$methodName}] and cannot be used as a method name. Choose a different name."); + } + + if (! $skipAndroid && in_array(strtolower($name), self::KOTLIN_RESERVED, true)) { + $this->fail("[{$name}] collides with a Kotlin reserved word. Choose a different name or pass --skip-android."); + } + + if (! $skipIos && in_array(strtolower($name), self::SWIFT_RESERVED, true)) { + $this->fail("[{$name}] collides with a Swift reserved word. Choose a different name or pass --skip-ios."); + } + } + + /** + * @param array> $bridgeFunctions + */ + private function rejectDuplicates(string $fqName, array $bridgeFunctions, string $contractPath, string $methodName): void + { + foreach ($bridgeFunctions as $entry) { + if (($entry['name'] ?? null) === $fqName) { + $this->fail("[{$fqName}] already exists in nativephp.json. Choose a different name."); + } + } + + $contractContents = (string) file_get_contents($contractPath); + + if (preg_match('/\bfunction\s+'.preg_quote($methodName, '/').'\s*\(/', $contractContents) === 1) { + $this->fail("A method named [{$methodName}] already exists on the contract. Choose a different name."); + } + } + + /** + * @param array> $bridgeFunctions + * @return array + */ + private function findExampleEntry(array $bridgeFunctions, string $bridgeNamespace): array + { + foreach ($bridgeFunctions as $entry) { + if (($entry['name'] ?? null) === "{$bridgeNamespace}.Example") { + return $entry; + } + } + + return $bridgeFunctions[0] ?? []; + } + + private function deriveTarget(?string $exampleValue, string $name, Closure $fallback): string + { + if ($exampleValue !== null && str_ends_with($exampleValue, 'Example')) { + return substr($exampleValue, 0, -strlen('Example')).$name; + } + + return $fallback(); + } + + private function fallbackAndroidTarget(string $bridgeNamespace, string $name): string + { + $kotlinPath = $this->root.'/resources/android/'.$bridgeNamespace.'Functions.kt'; + $package = 'com.plugin'; + + if (file_exists($kotlinPath)) { + $contents = (string) file_get_contents($kotlinPath); + + if (preg_match('/^package\s+(.+)$/m', $contents, $matches) === 1) { + $package = trim($matches[1]); + } + } + + return "{$package}.{$bridgeNamespace}Functions.{$name}"; + } + + /** + * @param array $manifest + * @param array> $bridgeFunctions + */ + private function buildManifest( + array $manifest, + array $bridgeFunctions, + string $fqName, + ?string $androidTarget, + ?string $iosTarget, + string $description, + ): string { + $entry = ['name' => $fqName]; + + if ($androidTarget !== null) { + $entry['android'] = $androidTarget; + } + + if ($iosTarget !== null) { + $entry['ios'] = $iosTarget; + } + + $entry['description'] = $description; + + $bridgeFunctions[] = $entry; + $manifest['bridge_functions'] = $bridgeFunctions; + + return json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES).PHP_EOL; + } + + private function addContractMethod(string $contents, string $fqName, string $methodName): string + { + $member = << \$payload + * @return array + */ + public function {$methodName}(array \$payload = []): array; + PHP; + + return $this->insertBeforeFinalBrace($contents, $member); + } + + private function addPluginMethod(string $contents, string $fqName, string $methodName): string + { + $member = <<callBridge('{$fqName}', \$payload); + } + PHP; + + return $this->insertBeforeFinalBrace($contents, $member); + } + + private function addFacadeMethod(string $contents, string $methodName): string + { + $lines = explode("\n", $contents); + $lastMethodLine = null; + + foreach ($lines as $index => $line) { + if (str_contains($line, '@method')) { + $lastMethodLine = $index; + } + } + + if ($lastMethodLine === null) { + $this->fail('Could not find an existing "@method" line in the facade docblock.'); + } + + $newLine = " * @method static array {$methodName}(array \$payload = [])"; + array_splice($lines, $lastMethodLine + 1, 0, [$newLine]); + + return implode("\n", $lines); + } + + /** + * @return array{0: string, 1: string}|null + */ + private function addKotlinFunction(string $name): ?array + { + $matches = glob($this->root.'/resources/android/*.kt') ?: []; + + if (count($matches) !== 1) { + $this->warnings[] = 'Android bridge file not found under resources/android — add the '.$name.' class to it manually.'; + + return null; + } + + $stubPath = $this->root.'/stubs/bridge-function-android.stub'; + + if (! file_exists($stubPath)) { + $this->warnings[] = 'stubs/bridge-function-android.stub is missing — add the '.$name.' class to '.$matches[0].' manually.'; + + return null; + } + + try { + $block = $this->extractBracedBlock((string) file_get_contents($stubPath), 'class {{ function }}'); + } catch (RuntimeException $exception) { + $this->warnings[] = 'Could not locate the Android bridge function anchor ('.$exception->getMessage().') — add the '.$name.' class to '.$matches[0].' manually.'; + + return null; + } + + $block = str_replace('{{ function }}', $name, $block); + $contents = (string) file_get_contents($matches[0]); + + try { + $updated = $this->insertBeforeFinalBrace($contents, $block); + } catch (RuntimeException $exception) { + $this->warnings[] = 'Could not find an insertion point in '.$matches[0].' ('.$exception->getMessage().') — add the '.$name.' class manually.'; + + return null; + } + + return [$matches[0], $updated]; + } + + /** + * @return array{0: string, 1: string}|null + */ + private function addSwiftFunction(string $name): ?array + { + $matches = glob($this->root.'/resources/ios/*.swift') ?: []; + + if (count($matches) !== 1) { + $this->warnings[] = 'iOS bridge file not found under resources/ios — add the '.$name.' class to it manually.'; + + return null; + } + + $stubPath = $this->root.'/stubs/bridge-function-ios.stub'; + + if (! file_exists($stubPath)) { + $this->warnings[] = 'stubs/bridge-function-ios.stub is missing — add the '.$name.' class to '.$matches[0].' manually.'; + + return null; + } + + try { + $block = $this->extractBracedBlock((string) file_get_contents($stubPath), 'class {{ function }}'); + } catch (RuntimeException $exception) { + $this->warnings[] = 'Could not locate the iOS bridge function anchor ('.$exception->getMessage().') — add the '.$name.' class to '.$matches[0].' manually.'; + + return null; + } + + $block = str_replace('{{ function }}', $name, $block); + $contents = (string) file_get_contents($matches[0]); + + try { + $updated = $this->insertBeforeFinalBrace($contents, $block); + } catch (RuntimeException $exception) { + $this->warnings[] = 'Could not find an insertion point in '.$matches[0].' ('.$exception->getMessage().') — add the '.$name.' class manually.'; + + return null; + } + + return [$matches[0], $updated]; + } + + private function buildTest(string $phpNamespace, string $bridgeNamespace, string $name, string $methodName): string + { + $stubPath = $this->root.'/stubs/bridge-function-test.stub'; + $stub = (string) file_get_contents($stubPath); + + return str_replace( + ['{{ namespace }}', '{{ plugin }}', '{{ function_camel }}', '{{ function }}'], + [$phpNamespace, $bridgeNamespace, $methodName, $name], + $stub, + ); + } + + /** + * Insert a member before the file's final closing brace, preserving a + * single blank line before it and the file's trailing newline. Only + * safe for single-class/interface files with one top-level brace pair. + */ + private function insertBeforeFinalBrace(string $contents, string $block): string + { + $body = rtrim($contents); + $pos = strrpos($body, '}'); + + if ($pos === false) { + throw new RuntimeException('no closing brace found'); + } + + $head = rtrim(substr($body, 0, $pos), "\n"); + + return $head."\n\n".rtrim($block)."\n".substr($body, $pos)."\n"; + } + + /** + * Extract a balanced-brace block starting at the line containing + * $anchor, inclusive of its closing brace. + */ + private function extractBracedBlock(string $contents, string $anchor): string + { + $lines = explode("\n", $contents); + $start = null; + + foreach ($lines as $index => $line) { + if (str_contains($line, $anchor)) { + $start = $index; + + break; + } + } + + if ($start === null) { + throw new RuntimeException("anchor [{$anchor}] not found"); + } + + $depth = 0; + $end = null; + + for ($i = $start; $i < count($lines); $i++) { + $depth += substr_count($lines[$i], '{'); + $depth -= substr_count($lines[$i], '}'); + + if ($depth === 0 && $i > $start) { + $end = $i; + + break; + } + } + + if ($end === null) { + throw new RuntimeException("unbalanced braces after anchor [{$anchor}]"); + } + + return implode("\n", array_slice($lines, $start, $end - $start + 1)); + } + + /** + * @param array $composer + */ + private function discoverPhpNamespace(array $composer): ?string + { + $psr4 = $composer['autoload']['psr-4'] ?? null; + + if (! is_array($psr4)) { + return null; + } + + foreach ($psr4 as $namespace => $path) { + if ($path === 'src/' && is_string($namespace)) { + return rtrim($namespace, '\\'); + } + } + + return null; + } + + private function findSingleFile(string $relativeDirectory, string $suffix, string $label): string + { + $matches = glob($this->root.'/'.$relativeDirectory.'/*'.$suffix) ?: []; + + if (count($matches) !== 1) { + $this->fail('Expected exactly one file under '.$relativeDirectory.' but found '.count($matches).'. ('.$label.')'); + } + + return $matches[0]; + } + + /** + * @param array $writes + */ + private function printDryRun(array $writes): void + { + $this->writeln($this->bold('Dry run — no files were changed.')); + $this->writeln(''); + + foreach ($writes as $path => $contents) { + $exists = file_exists($path); + $this->writeln($this->bold(($exists ? 'Would modify: ' : 'Would create: ').$this->relative($path))); + } + + if ($this->warnings !== []) { + $this->writeln(''); + $this->writeln($this->bold('Warnings:')); + + foreach ($this->warnings as $warning) { + $this->writeln('- '.$warning); + } + } + } + + /** + * @param array $writes + */ + private function printSummary(string $fqName, array $writes, ?string $androidTarget, ?string $iosTarget, bool $skipAndroid, bool $skipIos): void + { + $this->writeln(''); + $this->writeln($this->green($this->bold('Bridge function added: '.$fqName))); + $this->writeln(''); + $this->writeln('Files changed:'); + + foreach (array_keys($writes) as $path) { + $this->writeln('- '.$this->relative($path)); + } + + if ($this->warnings !== []) { + $this->writeln(''); + $this->writeln($this->bold('Warnings:')); + + foreach ($this->warnings as $warning) { + $this->writeln('- '.$warning); + } + } + + $this->writeln(''); + $this->writeln($this->bold('Next steps:')); + + $steps = []; + + if (! $skipAndroid && ($this->summary['kotlin'] ?? null) === true) { + $steps[] = 'Implement the native logic in resources/android/*.kt ('.$androidTarget.').'; + } + + if (! $skipIos && ($this->summary['swift'] ?? null) === true) { + $steps[] = 'Implement the native logic in resources/ios/*.swift ('.$iosTarget.').'; + } + + $steps[] = 'Run composer test.'; + + foreach ($steps as $index => $step) { + $this->writeln(($index + 1).'. '.$step); + } + + $this->writeln(''); + } + + private function relative(string $path): string + { + return str_replace($this->root.'/', '', $path); + } + + private function option(string $name, string $default = ''): string + { + $value = $this->options[$name] ?? $default; + + return is_string($value) ? $value : $default; + } + + private function hasOption(string $name): bool + { + return array_key_exists($name, $this->options) && $this->options[$name] !== false; + } + + private function ask(string $question, string $default = ''): string + { + if ($this->hasOption('no-interaction')) { + return $default; + } + + $suffix = $default !== '' ? " [{$default}]" : ''; + $answer = readline("{$question}{$suffix}: "); + $answer = trim((string) $answer); + + return $answer !== '' ? $answer : $default; + } + + private function fail(string $message): never + { + fwrite(STDERR, $this->red($message).PHP_EOL); + exit(1); + } + + private function writeln(string $message): void + { + fwrite(STDOUT, $message.PHP_EOL); + } + + private function bold(string $message): string + { + return $this->ansi('1', $message); + } + + private function green(string $message): string + { + return $this->ansi('32', $message); + } + + private function red(string $message): string + { + return $this->ansi('31', $message); + } + + private function ansi(string $code, string $message): string + { + if (! function_exists('posix_isatty') || ! posix_isatty(STDOUT)) { + return $message; + } + + return "\033[{$code}m{$message}\033[0m"; + } +} + +/** + * @param list $arguments + * @return array + */ +function parseOptions(array $arguments): array +{ + $options = []; + + foreach (array_slice($arguments, 1) as $argument) { + if (! str_starts_with($argument, '--')) { + continue; + } + + $argument = substr($argument, 2); + + if (! str_contains($argument, '=')) { + $options[$argument] = true; + + continue; + } + + [$key, $value] = explode('=', $argument, 2); + $options[$key] = $value; + } + + return $options; +} + +(new FunctionScaffolder(parseOptions($argv), __DIR__))->run(); diff --git a/configure.php b/configure.php index a34bda5..c8072f2 100644 --- a/configure.php +++ b/configure.php @@ -448,7 +448,7 @@ private function shouldSkipPath(string $relativePath): bool $segments = explode(DIRECTORY_SEPARATOR, $relativePath); return in_array($segments[0] ?? '', ['.git', 'vendor', 'node_modules'], true) - || $relativePath === basename(__FILE__); + || in_array($relativePath, [basename(__FILE__), 'add-function.php'], true); } private function removeEmptyPlaceholderDirectories(): void diff --git a/docs/bridge-functions.md b/docs/bridge-functions.md index a0da28d..d98058e 100644 --- a/docs/bridge-functions.md +++ b/docs/bridge-functions.md @@ -26,9 +26,31 @@ A bridge function connects PHP to native platform code. ## Adding A Function +```bash +php add-function.php --name=Level --description="Reads the battery level." +``` + +This does, in one command, what would otherwise mean hand-editing six files across three languages: + 1. Add a `bridge_functions` entry to `nativephp.json`. -2. Add the Kotlin class under `resources/android`. -3. Add the Swift class under `resources/ios`. -4. Add a PHP method on `{{ namespace }}\Contracts\{{ plugin }}Contract`. -5. Implement the method on `{{ namespace }}\Plugin`. -6. Add tests for the manifest and PHP call. +2. Add a PHP method on `{{ namespace }}\Contracts\{{ plugin }}Contract`. +3. Implement the method on `{{ namespace }}\Plugin`. +4. Add the `@method` line to `{{ namespace }}\Facades\{{ plugin }}`. +5. Add the Kotlin class under `resources/android`. +6. Add the Swift class under `resources/ios`. +7. Add a Pest test for the PHP call. + +Flags: + +| Flag | Effect | +| --- | --- | +| `--name=Level` | Required. PascalCase bridge function name. | +| `--description="..."` | Optional. Stored in the `nativephp.json` entry. | +| `--skip-android` | Skip the Kotlin file and omit the manifest `android` key. | +| `--skip-ios` | Skip the Swift file and omit the manifest `ios` key. | +| `--dry-run` | Print the planned changes; write nothing. | +| `--no-interaction` | Don't prompt; fail if `--name` is missing. | + +Run it once per bridge function, as many times as needed. It validates before writing anything: it rejects names that already exist (checked against both `nativephp.json` and the contract), names that aren't PascalCase, and names that collide with PHP, Kotlin, or Swift reserved words. + +If it can't find an Android or iOS bridge file to splice a new function into, it skips that file, warns you, and still finishes the rest — add the native class by hand in that case. diff --git a/docs/creating-first-plugin.md b/docs/creating-first-plugin.md index f8fce08..e89bd8a 100644 --- a/docs/creating-first-plugin.md +++ b/docs/creating-first-plugin.md @@ -3,8 +3,9 @@ 1. Clone `nativephp-plugin-template`. 2. Run `php configure.php`. 3. Review `composer.json`, `nativephp.json`, PHP namespaces, and native bridge targets. -4. Replace the template bridge function body with platform code. -5. Run tests and static analysis. +4. Run `php add-function.php --name=Level --description="Reads the battery level."` for each bridge function your plugin needs, replacing the template `Example` function with your own. +5. Implement the native logic in `resources/android/*.kt` and `resources/ios/*.swift`. +6. Run tests and static analysis. Keep the package type as `nativephp-plugin`. @@ -15,3 +16,17 @@ Use `--no-interaction` when driving the template from a scaffolder: ```bash php configure.php --no-interaction --vendor=acme --package=mobile-battery --plugin=Battery --namespace="Acme\\MobileBattery" --description="NativePHP Mobile battery plugin." --android-package=mobilebattery ``` + +## What add-function.php does under the hood + +Adding a bridge function by hand means editing six files across three languages, and a missed one only fails at runtime on a device. `add-function.php` automates exactly that manual sequence: + +1. Add a `bridge_functions` entry to `nativephp.json`. +2. Add a method to the plugin's `Contract`. +3. Implement the method on `Plugin`. +4. Add the `@method` line to the facade docblock. +5. Add the Kotlin class under `resources/android`. +6. Add the Swift class under `resources/ios`. +7. Add a Pest test asserting the wrapper forwards to the bridge with the right function name and payload. + +See `docs/bridge-functions.md` for the full flag list. diff --git a/stubs/bridge-function-test.stub b/stubs/bridge-function-test.stub new file mode 100644 index 0000000..ed8c995 --- /dev/null +++ b/stubs/bridge-function-test.stub @@ -0,0 +1,29 @@ + $payload + * @return array + */ + public function call(string $function, array $payload): array + { + return [ + 'function' => $function, + 'payload' => $payload, + ]; + } + }; + + app()->instance('nativephp.mobile.bridge', $bridge); + + expect({{ plugin }}::{{ function_camel }}(['message' => 'test']))->toBe([ + 'function' => '{{ plugin }}.{{ function }}', + 'payload' => ['message' => 'test'], + ]); +}); diff --git a/tests/ManifestTest.php b/tests/ManifestTest.php index 9939424..f58ebd2 100644 --- a/tests/ManifestTest.php +++ b/tests/ManifestTest.php @@ -13,11 +13,13 @@ $manifest = json_decode((string) file_get_contents($path), true, flags: JSON_THROW_ON_ERROR); $typedManifest = new Manifest($manifest); + $example = collect($typedManifest->bridgeFunctions())->firstWhere('name', '{{ plugin }}.Example'); + expect($typedManifest->namespace())->toBe('{{ plugin }}') - ->and($typedManifest->bridgeFunctions())->toHaveCount(1) - ->and($typedManifest->bridgeFunctions()[0]['name'])->toBe('{{ plugin }}.Example') - ->and($typedManifest->bridgeFunctions()[0]['android'])->toContain('{{ plugin }}Functions.Example') - ->and($typedManifest->bridgeFunctions()[0]['ios'])->toBe('{{ plugin }}Functions.Example'); + ->and($typedManifest->bridgeFunctions())->not->toBeEmpty() + ->and($example)->not->toBeNull() + ->and($example['android'])->toContain('{{ plugin }}Functions.Example') + ->and($example['ios'])->toBe('{{ plugin }}Functions.Example'); }); it('documents every required replacement placeholder', function (): void {