`refmt` rewrites valid code into code that doesn't compile
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
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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