Skip to content

Resolve FFI externals during type checking - #8728

Draft
cknitt wants to merge 2 commits into
masterfrom
codex/resolve-external-in-typedecl
Draft

cknitt wants to merge 2 commits into
masterfrom
codex/resolve-external-in-typedecl

Conversation

@cknitt

@cknitt cknitt commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Why

Parsetree is the AST that PPXs see, and we want to freeze a version of it as "v1" at some point (#8624). Before that, it should only describe source code.

Externals didn't. After the frontend processed the FFI attributes of an external, it replaced the primitive string with the computed FFI spec (Prim_ffi, Prim_inline_const). As a result:

  • parsetree.ml depended on External_ffi_types, the compiler's internal FFI representation. That type would become part of any frozen AST, and of a standalone syntax package we'd like to publish on opam.
  • The AST type had cases a PPX can never see, because PPXs run before the frontend. The v0 bridge needed an error branch for them.

What changes

pval_prim is now just the string from the source, with its location (string loc option). For @send external join: … = "join" it is "join"; the @send stays an attribute.

The FFI attributes are now interpreted during type checking. Typedecl calls Primitive.resolve_external, which the frontend registers, since that code lives above compiler/ml. The type checker ends up with the same types and primitive descriptions as before, so the generated JS is unchanged.

The frontend still processes each external once, for two reasons:

  • errors and warnings stay where they were;
  • it needs to know whether the external uses a relative @module path, which stops other modules from inlining it.

It now leaves the declaration as written, though. The type checker's second pass runs with warnings off, so nothing is reported twice.

Smaller changes that follow from this:

  • @inline constants used to be encoded as Prim_inline_const. They are now external x: T = "#rescript-inline" with the @inline(<literal>) attribute kept.
  • The unused-attribute and leftover-json checks now look at the processed form of each external rather than the declaration as written.
  • The outcome printer (hover, interface output) gets its own small type for processed primitives, Outcometree.out_primitive.
  • Typedtree.val_prim is a string as well; gentype now reads the external's name from the type checker's primitive description.
  • The AST and CMT magic numbers are bumped.

Tests

  • New ast-mapping fixture with several kinds of externals, all printing back unchanged after the v0 round trip.
  • New ounit test for an external's string and attributes surviving the v0 round trip.
  • New super_errors fixtures for an unused attribute on an external's argument and a json literal that an external doesn't consume, both of which are now checked on the processed form.

🤖 Generated with Claude Code

@codecov

codecov Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.16129% with 23 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.83%. Comparing base (1cc1da7) to head (242e4d3).

Files with missing lines Patch % Lines
compiler/frontend/ast_external_mk.ml 78.94% 4 Missing ⚠️
compiler/ml/oprint.ml 0.00% 3 Missing ⚠️
compiler/ml/primitive.ml 40.00% 3 Missing ⚠️
compiler/frontend/ast_external.ml 93.33% 2 Missing ⚠️
compiler/frontend/bs_ast_invariant.ml 90.00% 2 Missing ⚠️
compiler/frontend/bs_builtin_ppx.ml 88.88% 2 Missing ⚠️
compiler/syntax/src/res_outcome_printer.ml 33.33% 2 Missing ⚠️
compiler/ml/printast.ml 0.00% 1 Missing ⚠️
compiler/ml/printtyped.ml 0.00% 1 Missing ⚠️
compiler/syntax/src/res_printer.ml 50.00% 1 Missing ⚠️
... and 2 more
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #8728      +/-   ##
==========================================
+ Coverage   79.79%   79.83%   +0.04%     
==========================================
  Files         464      464              
  Lines       63134    63135       +1     
==========================================
+ Hits        50377    50406      +29     
+ Misses      12757    12729      -28     
Files with missing lines Coverage Δ
compiler/core/lam_compile_external_call.ml 92.90% <ø> (ø)
compiler/frontend/ast_attributes.ml 94.59% <100.00%> (+2.70%) ⬆️
compiler/frontend/ast_exp_handle_external.ml 82.50% <100.00%> (ø)
compiler/frontend/ast_external_process.ml 77.81% <100.00%> (+1.65%) ⬆️
compiler/frontend/ppx_entry.ml 91.66% <100.00%> (+1.66%) ⬆️
compiler/gentype/translation.ml 92.85% <100.00%> (+1.42%) ⬆️
compiler/ml/ast_iterator.ml 94.55% <100.00%> (+0.01%) ⬆️
compiler/ml/ast_mapper.ml 90.15% <100.00%> (+0.02%) ⬆️
compiler/ml/ast_mapper_from0.ml 72.46% <100.00%> (+0.03%) ⬆️
compiler/ml/ast_mapper_to0.ml 72.23% <100.00%> (+0.28%) ⬆️
... and 18 more
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

The current parsetree carried the frontend's FFI resolution result in
`pval_prim` (`Prim_ffi` with an `External_ffi_types.t` spec, and
`Prim_inline_const`), so the PPX-facing AST depended on post-PPX state.

`pval_prim` is now `string loc option`: the primitive string as written,
with its location. The frontend still resolves each FFI external to report
errors and warnings and to decide cross-module inlining, but leaves the
declaration as written. The type checker resolves it through the
`Primitive.resolve_external` hook, which `Ast_external` registers.

- `@inline` constants become an `external` with the `#rescript-inline`
  primitive and an `@inline(<literal>)` attribute.
- The unused-attribute and stray-json checks run on each external's
  resolved form, and skip the unresolved declaration.
- `Outcometree` gets its own `out_primitive` for printing resolved externals.
- Bump the current-AST and CMT magic numbers.

Signed-off-by: Christoph Knittel <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
@cknitt
cknitt force-pushed the codex/resolve-external-in-typedecl branch from 36bd95a to b71e398 Compare October 7, 2026 04:59
@pkg-pr-new

pkg-pr-new Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

rescript

npm i https://pkg.pr.new/rescript@8728

@rescript/belt

npm i https://pkg.pr.new/@rescript/belt@8728

@rescript/darwin-arm64

npm i https://pkg.pr.new/@rescript/darwin-arm64@8728

@rescript/darwin-x64

npm i https://pkg.pr.new/@rescript/darwin-x64@8728

@rescript/linux-arm64

npm i https://pkg.pr.new/@rescript/linux-arm64@8728

@rescript/linux-x64

npm i https://pkg.pr.new/@rescript/linux-x64@8728

@rescript/runtime

npm i https://pkg.pr.new/@rescript/runtime@8728

@rescript/win32-x64

npm i https://pkg.pr.new/@rescript/win32-x64@8728

commit: 242e4d3

@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown

- `Ast_external_process.resolve` returns its record directly instead of
  wrapping a tuple.
- Rename `rs_externals` to `is_ffi_external`.
- `typedecl.transl_value_decl` resolves once, in a single match.
- Run the unused-attribute and leftover-json checks on FFI externals in the
  post-mapping pass again, on each external's resolved form. Running them
  while mapping reported warnings in reverse order, since the built-in PPX
  maps the rest of a structure before its head.
- Add fixtures for an unused attribute on an external argument and a json
  literal on an argument that resolution does not consume.

Signed-off-by: Christoph Knittel <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>

This branch has not been deployed

No deployments
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