Skip to content

Make @as part of the AST - #8619

Open
cristianoc wants to merge 10 commits into
lambda/split-traversalsfrom
lambda/as-in-the-ast
Open

Make @as part of the AST#8619
cristianoc wants to merge 10 commits into
lambda/split-traversalsfrom
lambda/as-in-the-ast

Conversation

@cristianoc

Copy link
Copy Markdown
Collaborator

@as renames a record field or a variant constructor at run time. It was an
attribute that many parts of the compiler re-read and re-interpreted
independently: record construction and access, record spread and coercion,
signature inclusion, duplicate-field detection, the @tag conflict check,
@deriving(abstract) and gentype each derived the runtime name for
themselves. The declaration now records it once and they all read one field.

Extraction happens in Ast_helper.Type.field and Type.constructor, so the
parser, every ppx and the frozen-AST bridge inherit it without changes of
their own. @as keeps its surface syntax: the parsetree holds the literal
with its source spelling and its own location, so printers put the attribute
back exactly where it was written, and only the typed tree keeps the decoded
name. Two indirection hooks installed into Builtin_attributes are gone with
the module that filled them.

Three user-visible consequences, each with a fixture:

  • An integer in a constructor's @as that exceeds the compiler's range used
    to abort with Failure("int_of_string"); it now reports an error.
  • @as(42) on a record field renamed nothing and was accepted in silence; it
    now warns as the unused attribute it is.
  • An @as payload written with backquotes formats with ordinary quotes, since
    it names the same thing either way.

Magic numbers are bumped once per stage, for the parsetree and for the cmi
and cmt.

Part of #8573. Stacked on #8618.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4623e05b33

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread compiler/ml/parsetree0.ml Outdated
(* [@as] is a field of [label_declaration] in the current parsetree and an
attribute in this frozen one, so it is put back on the way out and read off
again on the way in, the same as [pld_optional]. *)
let add_as_attr ~runtime_name attrs =

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Move the bridge helper out of frozen Parsetree0

Adding add_as_attr modifies the frozen compatibility module even though this conversion is specific to ast_mapper_to0.ml. Since parsetree0.ml is the stable v0 boundary used by existing PPXs, keep it unchanged and define the helper in the mapping module instead.

AGENTS.md reference: AGENTS.md:L37-L39

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Fixed, and one step further than requested: rather than relocating the helper, it's gone entirely.

label_declaration now reuses Ast_helper.Type.field_attributes, exactly as constructor_declaration already reused constructor_attributes. So parsetree0.ml is back to a zero diff against master, and the whole change to ast_mapper_to0.ml is three lines with no payload construction of its own.

Fixed in a03315d.

Comment thread compiler/ml/parsetree0.ml Outdated
};
]
in
(Location.mkloc "as" loc, payload) :: attrs

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve attribute order on the v0 PPX wire

For a field written as @dead("x") @as("wire") name, extraction leaves @dead in pld_attributes, but this cons operation sends the external PPX [@as; @dead] rather than the original order. The later location-based reconstruction hides the change from round-trip snapshots, while order-sensitive PPXs still observe it; insert the synthesized attribute according to its saved location, as field_attributes already does.

AGENTS.md reference: AGENTS.md:L37-L39

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and the change above resolves this one too: field_attributes inserts the synthesized attribute at its saved location, so a field written @dead("x") @as("wire") a now reaches an external ppx as [@dead; @as], in the order written.

Added field_runtime_name_keeps_its_place_on_the_wire to tests/ounit_tests/ounit_ast_mapper0_tests.ml, per the AGENTS.md guidance that v0 wire shape belongs there. I checked it earns its place: reverting the insertion to a cons-at-front makes it fail.

Comment thread compiler/ml/parsetree.ml

and label_declaration = {
pld_name: string loc;
pld_runtime_name: String_literal.string_literal loc option;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include field runtime names in parsetree dumps

For every valid record-field @as, Type.field removes the attribute and stores it only in pld_runtime_name, but Printast.label_decl and Sexp_ast.label_declaration still emit only pld_attributes. Consequently -dparsetree silently drops the rename and makes renamed and unrenamed record fields appear identical; reconstruct the attribute with Ast_helper.Type.field_attributes or print this field explicitly, as the constructor dump now does.

AGENTS.md reference: AGENTS.md:L160-L164

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed for Printast.label_decl, which now prints Ast_helper.Type.field_attributes ld — the same reconstruction the constructor dump already used. -dparsetree on @dead("x") @as("wire") a shows both attributes again, in source order.

One note on the other half: there is no Sexp_ast module in this repository, so that part doesn't apply here. Printast was the only dumper reading pld_attributes directly.

@cristianoc
cristianoc changed the base branch from lambda/split-traversals to master September 4, 2026 12:48
@codecov

codecov Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.82353% with 38 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.41%. Comparing base (ffad101) to head (326f17c).

Files with missing lines Patch % Lines
compiler/ml/ast_untagged_variants.ml 76.74% 10 Missing ⚠️
compiler/ml/printtyp.ml 66.66% 4 Missing ⚠️
compiler/ml/ast_helper.ml 93.87% 3 Missing ⚠️
compiler/ml/lambda.ml 75.00% 3 Missing ⚠️
compiler/core/js_of_lam_variant.ml 33.33% 2 Missing ⚠️
compiler/core/js_stmt_make.ml 0.00% 2 Missing ⚠️
compiler/frontend/ast_attributes.ml 91.30% 2 Missing ⚠️
compiler/ml/ast_payload.ml 92.00% 2 Missing ⚠️
compiler/ml/printast.ml 0.00% 2 Missing ⚠️
compiler/ml/variant_coercion.ml 77.77% 2 Missing ⚠️
... and 5 more
Additional details and impacted files
@@                     Coverage Diff                     @@
##           lambda/split-traversals    #8619      +/-   ##
===========================================================
+ Coverage                    77.35%   77.41%   +0.05%     
===========================================================
  Files                          471      471              
  Lines                        63476    63549      +73     
===========================================================
+ Hits                         49102    49194      +92     
+ Misses                       14374    14355      -19     
Files with missing lines Coverage Δ
compiler/core/bs_conditional_initial.ml 100.00% <ø> (ø)
compiler/core/js_dump.ml 90.83% <100.00%> (ø)
compiler/core/lam_compile.ml 89.77% <100.00%> (+0.02%) ⬆️
compiler/core/lam_compile_const.ml 93.93% <100.00%> (ø)
compiler/core/lam_compile_primitive.ml 78.21% <ø> (-0.08%) ⬇️
compiler/frontend/ast_derive_abstract.ml 93.10% <100.00%> (+0.12%) ⬆️
compiler/frontend/ast_derive_js_mapper.ml 84.78% <ø> (ø)
compiler/frontend/ast_external_process.ml 78.40% <ø> (ø)
compiler/frontend/ast_polyvar.ml 93.93% <ø> (ø)
compiler/frontend/bs_ast_invariant.ml 90.32% <100.00%> (-0.16%) ⬇️
... and 40 more

... and 4 files with indirect coverage changes

🚀 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.

@cristianoc
cristianoc changed the base branch from master to lambda/split-traversals September 4, 2026 13:28
@pkg-pr-new

pkg-pr-new Bot commented Sep 4, 2026

Copy link
Copy Markdown

Open in StackBlitz

rescript

npm i https://pkg.pr.new/rescript-lang/rescript@8619

@rescript/belt

npm i https://pkg.pr.new/rescript-lang/rescript/@rescript/belt@8619

@rescript/darwin-arm64

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

@rescript/darwin-x64

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

@rescript/linux-arm64

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

@rescript/linux-x64

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

@rescript/runtime

npm i https://pkg.pr.new/rescript-lang/rescript/@rescript/runtime@8619

@rescript/win32-x64

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

commit: 326f17c

@cristianoc

cristianoc commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator Author

Astra found that converting object types containing spreads could drop a record field's @as attribute, so formatting {...base, @as("renamed") "b": int} lost the rename.

Fixed in commit 498a0e7. The two object-spread conversions in compiler/syntax/src/res_core.ml now use Ast_helper.Type.field_attributes, matching the non-spread path. The regression snapshot is tests/syntax_tests/data/printer/ObjectSpreadFieldAs.res.

cristianoc and others added 10 commits September 5, 2026 02:47
A record field is named by its [@as("...")] attribute when it has one and by
its declared name otherwise. That rule was written out nine times inside
lambda.ml, once more in typecore_record_rest, and reached record_attributes_check
through Lambda.find_name - so the IR module was parsing attribute payloads on
behalf of two other layers.

Record_runtime holds it now, next to Variant_runtime, which already owns how a
variant is represented at run time. The nine sites in lambda.ml ask for the
name instead of deriving it, and the other two callers share the same
definition.

lambda.ml no longer mentions Parsetree or Ast_payload. It still takes
Types.label_description, since the tag_info builders are given labels; passing
them decoded names instead would move the decoding to translcore, which is a
separate question.

Generated JavaScript is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Lambda's nine block and field builders each took a
Types.label_description and worked out the field's runtime name
themselves. The name is now decided once, when the label description is
built, and the builders take it as a plain string:

  -val fld_record : Types.label_description -> field_dbg_info
  +val fld_record : string -> field_dbg_info

so Lambda no longer reaches into Types or Typedtree to find out what a
field is called.

Note that typecore's dictionary path copies a label description with a
replaced name, so it has to replace the runtime name with it; otherwise
every dictionary field reads the name of the original.

Signed-off-by: Cristiano Calcagno <ccrisccris@gmail.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
@as("x") renames a record field at run time. It was an attribute that
seven separate places re-read and re-interpreted: record construction
and access, record spread, record coercion, signature inclusion,
duplicate-field detection, the @tag conflict check, @deriving(abstract)
and gentype. The declaration now records the runtime name, and they all
read the same field.

Extraction happens in Ast_helper.Type.field, so the parser, every ppx
and the frozen-AST bridge inherit it: ast_mapper_from0 needs no change
at all, and ast_mapper_to0 puts the attribute back the way it already
does for pld_optional.

This removes the two hooks installed into Builtin_attributes to reach an
attribute reader from compiler/core, and the module that filled them.

@as keeps its surface syntax. The parsetree holds the literal with its
source spelling and its own location, so printers put it back exactly
where it was written; the typed tree holds only the decoded name, which
is what Asttypes.constant already does for every other string literal.
A payload that does not denote a name is left alone, and a second @as is
left for the type checker rather than rejected at parse time, so an
invalid file can still be formatted.

Signed-off-by: Cristiano Calcagno <ccrisccris@gmail.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Following the same move for record fields, a constructor's @as runtime
tag is now recorded on the declaration instead of being re-interpreted
from its attributes. Extraction happens in Ast_helper.Type.constructor,
so the parser, every ppx and the frozen-AST bridge inherit it, and
ast_mapper_to0 puts the attribute back the way it does for records.

The parsetree keeps the payload's source spelling, so @as(0xA) and
@as("A") print back as written rather than as 10 and "A". The typed
declaration keeps only the decoded tag.

This deletes process_tag_type, which decoded the same attribute a second
time, and with it four payload readers that had no other caller.
has_undefined_literal was dead. constructor_tag and block_runtime took
an attribute list that every caller passed empty, since they build tags
for constructors the compiler generates itself, so they now say that.

An out-of-range integer in @as used to abort the compiler with
Failure("int_of_string"); it now reports its own error at the
annotation.

Signed-off-by: Cristiano Calcagno <ccrisccris@gmail.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
The seven cases a constructor's @as can state existed twice: once as
declared_tag, and again as seven of tag_type's eight constructors,
joined by a conversion. Nothing ever matched on declared_tag for a
decision; every use mapped it straight to tag_type. So tag_type now
holds the narrower type rather than repeating it, and the conversion
becomes the constructor:

  type tag_type = Literal of literal_tag | Untagged of block_type

Renamed from declared_tag, because two sites build the literal for a
constructor that declares nothing -- the default encoding is the
constructor's own name -- and because the surrounding code already calls
these literals: literal_tags, literal_cases, is_a_literal_case,
check_literal.

Functions that only ever handled literals now say so in their type:
js_exp_make's tag_type splits into the JS value a literal stands for and
the typeof string an untagged payload answers to, which were unrelated
jobs sharing one match, and printtyp's @as reconstruction takes a
literal_tag. Where a match arm per literal remains it unwraps Literal
once instead of in every arm, and variant_coercion's four arms that
differed only in the expected type become a table.

Signed-off-by: Cristiano Calcagno <ccrisccris@gmail.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
A constructor's stored tag could only ever be a declared literal: the
untagged shape is synthesized during matching, at one place, from a
payload's block type. The record now says so, and the two roles get
separate names rather than one field standing for both:

  type tag = {name: string; literal: literal_tag option}
  type matchable_tag = {name: string; tag_type: tag_type option}

  val to_matchable_tag : tag -> matchable_tag

so constructor_tag returns a literal_tag option, literal_tags and
literal_cases are literal lists, and the coercion errors carry literals.
Their readers stop unwrapping a case that could not occur -- printtyp's
@as reconstruction loses its "should never happen" arm, and the overlap
predicates in the untagged checks stop matching on Untagged to answer
questions only a literal can answer.

Three functions had no callers. tag_can_be_undefined guarded the
optimisation removed in 244cdad, which compiled {field: VariantCase}
to field: VariantCase and made a single-case variant look exhaustive.
get_tag_name was a one-line wrapper that 108aa8f inlined past.
tag_type_to_user_visible_string is dead as of this commit: its Literal
branch delegated to literal_tag_to_user_visible_string, which is what
the narrowed errors call, and its Untagged branch was unreachable.

Signed-off-by: Cristiano Calcagno <ccrisccris@gmail.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Three functions read the single @as an item may carry, differing only in
the payload they accept: a string, an int, or any of int, string and
json. Each repeated the same skeleton -- find the attribute, reject a
second one, mark it used -- so that part is now written once and takes
the decoder:

  let as_string (attrs : t) : string option =
    single_as attrs ~decode:(fun ~loc payload ->
        match Ast_payload.semantic_string_of_payload payload with
        | None -> Bs_syntaxerr.err loc Expect_string_literal
        | Some v -> v)

The decoder runs before a second attribute is looked at, so a malformed
payload is still reported ahead of the duplicate that follows it rather
than the other way round.

Renamed from iter_process_bs_string_as and its siblings, which described
the traversal rather than the result and kept a bs prefix that no longer
means anything.

Signed-off-by: Cristiano Calcagno <ccrisccris@gmail.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
iter_process_bs_string_int_unwrap_uncurry advertised uncurry, which it
does not handle, and omitted ignore, which it does. It answers which of
@string, @int, @ignore and @unwrap an external's argument carries, so it
is now arg_encoding.

Signed-off-by: Cristiano Calcagno <ccrisccris@gmail.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Every @as on a record field was marked used, which made sense when the
attribute was what the compiler read. Now the one that names the field
is taken out of the attributes when the field is built, so an @as still
there is either a second one, which the type checker rejects, or a
payload that is not a name at all.

@as(42) on a record field was accepted in silence: it renamed nothing
and nothing reported it. Only the attribute the compiler acts on is
marked now, so that case warns and a duplicate still reports just its
own error rather than both.

Signed-off-by: Cristiano Calcagno <ccrisccris@gmail.com>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8g8qwBARAcvW9MyuKQq8H
Signed-off-by: Cristiano Calcagno <cristianoc@users.noreply.github.com>
@cknitt

cknitt commented Sep 5, 2026

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 5, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-05T05:08:55.028200Z 326f17c Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 326f17c222

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread compiler/ml/ast_helper.ml
| [] -> (None, [])
| (({txt = "as"; loc}, payload) : Parsetree.attribute) :: rest -> (
match Ast_payload.constructor_tag_of_payload payload with
| Some txt -> (Some {Asttypes.txt; loc}, List.rev_append seen rest)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Traverse extracted @as attributes in Ast_iterator

When completion analyzes a syntactically valid @as on a record field or variant constructor, these branches remove the attribute from pld_attributes/pcd_attributes, but Ast_iterator.default_iterator still traverses only those lists (compiler/ml/ast_iterator.ml lines 529-543). Consequently the attribute callback in analysis/src/completion_front_end.ml never sees the decorator, regressing decorator completion and cursor handling for these declarations; reconstruct the full attribute list in the iterator as the mapper and printers do.

AGENTS.md reference: AGENTS.md:L45-L45

Useful? React with 👍 / 👎.

Comment thread compiler/ml/ast_helper.ml
Comment on lines +509 to +513
let written_earlier (({loc = other}, _) : Parsetree.attribute) =
other.loc_start.pos_cnum < loc.loc_start.pos_cnum
in
let earlier, later = List.partition written_earlier ld.pld_attributes in
earlier @ ((Location.mkloc "as" loc, payload) :: later)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve @as order when attribute locations tie

When a PPX produces [dead; as] with generated or Location.none locations, both attributes have the same pos_cnum, so this strict comparison classifies dead as later and reconstructs the list as [as; dead]. Thus a from0→to0 bridge changes the wire order observed by the next order-sensitive PPX; the new location-based reconstruction fixes distinct source locations but equal locations are fresh evidence that the ordering issue remains, so retain the original list position or otherwise preserve ties.

AGENTS.md reference: AGENTS.md:L37-L39

Useful? React with 👍 / 👎.

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.

2 participants