reasonml / reasonml/reason

`refmt` rewrites valid code into code that doesn't compile

Open
#2,925 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
OCaml
Stars
10.3k
Forks
438
PR merge metrics
No merged PRs in 30d

Description

Signature constraint on a first-class-module lambda body is hoisted into a return annotation that re-parses as an arrow type.

Narrowing a first-class module to a smaller signature inside Option.map — this program compiles:

// narrow.re
module type FOO = {
  let foo: string;
};

module type FOO_WITH_X = {
  let foo: string;
  let x: int;
};

let some_foo_with_x: option(module FOO_WITH_X) =
  Some(
    (module
     {
       let foo = "foo";
       let x = 1;
     }),
  );

let some_foo =
  some_foo_with_x
  |> Option.map((module Foo_with_x: FOO_WITH_X) =>
       (module Foo_with_x: FOO)
     );

Run refmt narrow.re once. The body's signature constraint is hoisted into a return-annotation position on the lambda:

let some_foo =
  some_foo_with_x
  |> Option.map((module Foo_with_x: FOO_WITH_X): (module FOO) =>
       (module Foo_with_x)
     );

That output no longer compiles, because it re-parses with the annotation as an arrow type, swallowing the lambda body into a type expression. refmt --parse re --print ml on the formatted file:

let some_foo =
  some_foo_with_x |>
    (Option.map (((module
       Foo_with_x) : (module FOO_WITH_X)) : (module FOO) ->
                                              (module Foo_with_x)))
$ ocamlc -c narrow_fmt.ml
Error: Unbound module type Foo_with_x

So a plain refmt / dune fmt pass silently converts a compiling program into a non-compiling one. It is also not idempotent — a second pass rewrites the lambda again:

  |> Option.map(
       ((module Foo_with_x): (module FOO_WITH_X)):
                                                   (module FOO) =>
                                                   (module Foo_with_x),
     );

The same wrong reading applies if you write the return annotation by hand: (module M: SIG): (module SUB) => body parses as a pattern annotated with an arrow type, rather than as a lambda with a return annotation — unlike the same shape with ordinary (non-module) patterns.

Versions

Reproduced identically on Reason 3.14.0 and 3.17.3 (macOS/arm64, ocamlc 5.1.1) and 3.18.0 (Linux/x86_64, ocamlc 4.14.2). All snippets above verified verbatim.

Expected

Printing must preserve semantics: refmt shouldn't hoist the body's package constraint into a position that re-parses as an arrow type. (And ideally, (pattern): T => body with a (module …) pattern would parse as a lambda with return annotation, consistent with ordinary patterns.)

Workaround

Unpack in the body instead — typechecks, and is stable under refmt:

|> Option.map((foo_with_x: (module FOO_WITH_X)) => {
     module Foo_with_x = (val foo_with_x: FOO_WITH_X);
     ((module Foo_with_x): (module FOO));
   })

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the refmt behavior using the provided narrow.re reproducer and compare the original, formatted, and refmt --parse re --print ml output. Trace how the first-class-module lambda constraint is printed and add a regression test for formatting and compilation. Done means refmt preserves compiling semantics and repeated formatting is idempotent.

Written by the indexing model from the issue text.

Assessment

Tech stack
ocaml
Domain
compilers
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.