Repository navigation
feat(php): PHP 8 binding over the prebuilt libkcl (Phase 5) - #403
Merged
Merged
Conversation
Peefy
force-pushed
the
feat/php-binding
branch
from
October 8, 2026 06:08
9981064 to
57fbd19
Compare
The first two sub-parts of the PHP binding (docs/architecture.md): * php/src/KclLib.php — FFI::cdef over the call_native dispatcher and the service-handle entry points (docs/abi.md §2/§6), with the 4 MiB caller-allocated result buffer, KCL_PHP_LIB resolution (file or go/lib/<platform>/ directory, otherwise found by walking up to the enclosing checkout) and a plugin agent registered as a PHP callable: PHP 8.5's FFI builds the C trampoline from the Closure at the call boundary, the JSON reply is copied into a persistent C buffer because a trampoline cannot return a PHP string as const char *, and agent exceptions become JSON error objects because an FFI callback must not throw. php/ffi/kcl_ffi.h is the verbatim copy of c/include/kcl_ffi.h the cdef mirrors (PHP's FFI parser has no preprocessor). * php/src/ServiceClient.php — one method per RPC in spec/spec.proto, in service declaration order; args as message objects or arrays through the protobuf-JSON codec, ERROR: prefix decoded to KclException. * php/src/Api — protoc 36.2 --php_out gencode from spec/spec.proto, vendored with PSR-4 mappings; google/protobuf ^4.33 is the matching runtime (pins recorded in spec/Makefile and php/Makefile). * php/composer.json, php/Makefile, php/phpunit.xml — composer install + ./vendor/bin/phpunit is the whole toolchain; there is no Rust build. Signed-off-by: Peefy <xpf6677@163.com>
…tools/astgen Sub-parts 4 and 5 of the PHP binding (docs/architecture.md), emitted by a new tools/astgen/emit_php.py from the same AstModel as the python, typescript and go packages: * php/src/Ast — one final class per ast.rs struct under KclLib\Ast (PSR-4, one file per class), with Expr/Stmt/Type as abstract bases whose static fromWire dispatches on the wire tag; shared payloads (Identifier is both Expr::Identifier and Keyword.arg's target) extend the base once and decode the same fields in both positions. Every field shape maps to one Wire helper, which is the single place the four shapes are told apart and which substitutes the parser-shaped default for absent keys instead of throwing. * Adjacently-tagged payloads that inline their content (Type::Basic, Type::Named, Type::Literal) decode the payload itself rather than a key inside it, and the MemberOrIndex arms decode whole NodeRef wrappers. * php/src/Ast/AstBuild.php — one factory per node type in kotlin AstBuild.kt's reference form, every parameter defaulted to the value the Rust parser produces, plus nodeRef/emptyNodeRef/nameRef wrappers. * tools/astgen/cli.php registers the emitter and the php/src/Ast orphan check; hack/dump/php.php walks the decoded golden capture by reflection and hack/ast_diff.rb php agrees with the reference at 1012/1012 objects (0 disagreements). The decoder caught two real wire shapes during bring-up that the emitter now documents: LiteralType's inner document is kept verbatim (its four variants share no field set), and Type::Any is a unit variant whose wire document is the bare tag. Signed-off-by: Peefy <xpf6677@163.com>
* hack/check_ast_field_types.rb: check_php walks the generated php/src/Ast tree, attributing each Wire::<helper>($w, 'key', 'payload') read to the final class that declares it — the helper decides the list rule, the third argument names the payload in the shared vocabulary, and the string helpers name their own. LEAF_DECODER's :php slot extracts the generated Comment decoder, which Wire::nodeRef hands the payload with node already lifted. CHECKS registers the binding; the self-test gains twelve mutation cases (payload swaps, list/single flips, the bare-struct DictComp.entry read, both Comment descent directions) with a coverage floor at the exact node-decoder count, the same floor-at-the-count pattern typescript and go use. * hack/ast_ctors/php.rb: check_php parses the generated AstBuild.php, joining constructor to struct on the return type — PHP's promoted constructor parameters are camelCase and the checker's snake_case fallback resolves them, so no alias table is needed. Every parameter is defaulted (that is the generated form), so rule 1 cannot fire and defaulted is read off the = token itself. NOT_MODELED records only SerializeProgram: Ast::parseProgram unwraps the envelope like Go and Lua. IntLiteralType is modeled, unlike in most bindings — the emitter gives it a class and AstBuild::intLiteralType builds it. The self-test gains five php cases covering rules 1-3, the KNOWN_HELPERS boundary, and the deletion coverage direction (php joins on the return type, so the un-reach mutation deletes the constructor). Signed-off-by: Peefy <xpf6677@163.com>
* ServiceClientTest — Ping (array and message args), ExecProgram over
in-memory code, overrides, the ERROR: in-band error contract surfacing as
KclException, BuiltinService.ListMethod (skipped on cores that predate
it), the plugin agent receiving kcl_plugin.* calls through a service
handle, and GetVersion — all against the vendored prebuilt libkcl.
* AstContractTest — decodes the shared testdata/ast/alignment.json golden
and pins the wire shape: comment text round-tripping verbatim through the
{text: …} object, the flat ImportStmt with its Node<String> path, every
Type variant (including LiteralType's verbatim inner document and
inner_tag, and Type::Any as a bare tag), the schema index signature,
Arguments/Vec<Option<NodeRef>> index alignment, MemberOrIndex arms as
whole NodeRef wrappers, Ast::parseProgram's envelope flattening, and
unknown tags decoding to null rather than zero nodes.
* ConsistencyTest — the 13 hermetic cases from tests/consistency/cases.json
with the same reduction as lua's runner: snake_case args through the
protobuf-JSON codec, repo-relative testdata paths resolved, exactly the
manifest-pinned fields compared, new_core cases skipped on cores that do
not list the RPC.
Signed-off-by: Peefy <xpf6677@163.com>
* .github/workflows/php-test.yaml — ubuntu-latest + macos-latest over PHP 8.3/8.4, shivammathur/setup-php with the FFI extension pinned, composer install + phpunit; the paths filter covers php/**, the shared consistency manifest and testdata, spec/**, the vendored prebuilt libraries and the workflow itself. * ast-diff-test.yaml — a php matrix leg (setup-php + composer install in lib/php, the dumper needs the autoloader but not libkcl: the diff is pure AST over the capture) with php/src/Ast/** and hack/dump/php.php on the paths list. * ast-shape-test.yaml — php/src/Ast/** on the paths list, next to the other generated decoders the two shape checks read. * ast-codegen.yaml — php/src/Ast/** and the PHP contract test on the paths list, and a PHP leg that installs dependencies and runs AstContractTest (pure AST, no FFI) next to the python/wasm/go contract legs. * hack/check_generated_ast.rb — the header names php/src/Ast/ as the fourth generated tree the freshness check now covers. * php/README.md — install, requirements (CLI FFI always on; web SAPIs need ffi.enable=true), usage including the plugin agent, layout and the L3 checks this binding is held to. Signed-off-by: Peefy <xpf6677@163.com>
The manifest grew from 13 cases to 29 (pr/ast-collection-defaults); the runner is the lua 29-case spec's port, so a case the suite does not handle fails the suite instead of passing silently: * All 29 cases dispatched, one test each, including the file-writing RPCs (OverrideFile, FormatPath, LoadSettingsFiles, UpdateDependencies, the LintPath-with-errors case) which run against per-case temp copies of the tests/consistency/testdata scratch templates — real copies, never links, so an RPC that rewrites files never reaches the repository. A case naming the same template twice gets two copies, as in the other runners, and tearDown takes the copies down with the case. * Field accessors in the manifest's default dialect: has_error_message for validate_code_invalid (the diagnostic carries ANSI escapes and a temp path, so only its presence is pinned), option/kcl_options pairs sorted on the key (a protobuf map's order is not pinnable), list_variables values, Test names/failed sorted, GetVersion pinned to the major.minor pair, and the KclType projections for both schema-mapping RPCs — absent-when-unset, filename dropped, keys sorted, rendered through one canonical form on both sides of the comparison with a line-wise diff on mismatch. * Path handling compares basenames split on both separators, so the path pins survive Windows CI; repo-relative manifest paths resolve against the repository root, absolute entries are kept, exec_args resolves k_filename_list entries against the resolved work_dir the way the core actually reads them, and ParseFile's path is forwarded verbatim (a label, not a file to read). * The 13-case count assertion is retargeted: 29 cases, and a coverage guard that reads the hard-maintained test list — not anything the suite recorded at run time — and fails when the manifest grows a case this runner does not dispatch, the same shape as the sibling runners' guard. phpunit: 49 tests / 387 assertions, all green, no skips (the vendored core lists all 28 methods, so the new_core Generate* cases run). Signed-off-by: Peefy <xpf6677@163.com>
Signed-off-by: Peefy <xpf6677@163.com>
Peefy
force-pushed
the
feat/php-binding
branch
from
October 8, 2026 06:41
57fbd19 to
887d408
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
First of the four Phase 5 bindings (docs/architecture.md item G): a pure-PHP FFI binding — no native build, modeled on the dart binding's loader.
The five sub-parts:
php/src/KclLib.php):FFI::cdefovercall_native(php/ffi/kcl_ffi.h, verbatim fromc/include/kcl_ffi.h), library resolution viaKCL_PHP_LIBor walk-up togo/lib/<platform>/, 4 MiB result buffer,ERROR:prefix handling, and the plugin-agent entry point through the service-handle API (the prebuilt libkcl does not exportcall_native_with_plugin_agent; dart's model is used instead — a PHP Closure becomes an FFI trampoline with a persistent reply buffer).protoc --php_out(protoc 36.2, pinned inspec/Makefile) — 89 message classes + GPBMetadata, PSR-4 autoloaded,google/protobufruntime.php/src/ServiceClient.php): one method per RPC (27), proto3-JSON via the runtime'smergeFromJsonString/serializeToJsonString.tools/astgen/emit_php.pyemitter (registered incli.py, covered byhack/check_generated_ast.rb) — 72 files mirroringast.rsfield by field, withWire/NodeRef/Astdecoding.AstBuildNew*constructors covering the checker surface.L3 wiring (a binding is not done without it)
hack/ast_ctors/php.rb+ collectors/self-tests in both shape checks;php: ok (50 constructors, 126 field decoders).hack/dump/php.php+hack/ast_diff/bindings.rbentry —ruby hack/ast_diff.rb php: 1 agreed, 0 disagreed.php-test.yaml(ubuntu/macos × PHP 8.3/8.4), legs inast-diff-test.yaml/ast-codegen.yaml, paths inast-shape-test.yaml.php/tests/ConsistencyTest.phpdispatches all 29 manifest cases (zero skips on the v0.13.1 core) with the coverage guard.Base
Stacked on #396 (constructor-check infrastructure lives there; this PR's diff is php-only). Retargets to main automatically once the stack lands. Local verification: phpunit 49 tests / 387 assertions green; both L3 check scripts green with php included.