Skip to content

feat(php): PHP 8 binding over the prebuilt libkcl (Phase 5) - #403

Merged
Peefy merged 7 commits into
mainfrom
feat/php-binding
Oct 8, 2026
Merged

Peefy merged 7 commits into
mainfrom
feat/php-binding

Conversation

@Peefy

@Peefy Peefy commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

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:

  1. FFI loader (php/src/KclLib.php): FFI::cdef over call_native (php/ffi/kcl_ffi.h, verbatim from c/include/kcl_ffi.h), library resolution via KCL_PHP_LIB or walk-up to go/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 export call_native_with_plugin_agent; dart's model is used instead — a PHP Closure becomes an FFI trampoline with a persistent reply buffer).
  2. Protobuf types: protoc --php_out (protoc 36.2, pinned in spec/Makefile) — 89 message classes + GPBMetadata, PSR-4 autoloaded, google/protobuf runtime.
  3. Service client (php/src/ServiceClient.php): one method per RPC (27), proto3-JSON via the runtime's mergeFromJsonString/serializeToJsonString.
  4. AST DTO + decoder: generated by a new tools/astgen/emit_php.py emitter (registered in cli.py, covered by hack/check_generated_ast.rb) — 72 files mirroring ast.rs field by field, with Wire/NodeRef/Ast decoding.
  5. Ergonomics: AstBuild New* 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.rb entry — ruby hack/ast_diff.rb php: 1 agreed, 0 disagreed.
  • php-test.yaml (ubuntu/macos × PHP 8.3/8.4), legs in ast-diff-test.yaml/ast-codegen.yaml, paths in ast-shape-test.yaml.
  • php/tests/ConsistencyTest.php dispatches 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.

@Peefy
Peefy changed the base branch from pr/ast-collection-defaults to main October 8, 2026 03:48
@Peefy
Peefy force-pushed the feat/php-binding branch from 9981064 to 57fbd19 Compare October 8, 2026 06:08
Peefy added 7 commits October 8, 2026 14:41
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
Peefy force-pushed the feat/php-binding branch from 57fbd19 to 887d408 Compare October 8, 2026 06:41
@Peefy
Peefy merged commit 315c239 into main Oct 8, 2026
31 of 32 checks passed
@Peefy
Peefy deleted the feat/php-binding branch October 8, 2026 06:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant