Skip to content

Conversation

@HPRIOR
Copy link
Contributor

@HPRIOR HPRIOR commented May 5, 2025

Adds indent option for module structs, types and signatures.

@HPRIOR HPRIOR changed the title Add module indent configuration Add module indent option May 5, 2025
@EmileTrotignon
Copy link
Collaborator

Thanks for the PR !

The implementation looks very clean, I don't think this will cause a maintenance burden in the future.

I think the option should be named struct-indent, and the documentation should make clear that this affects the inside of sig ... end and struct ... end blocks, currently the explanation is not precise enough. I know struct-indent feels a bit weird because it also affects sig, but there is precedent with break-struct also affecting sig.

The tests could include attributes and comments to check that everything works well when they are present.

dune build @gen_manpage --auto-promote should be run to regenerate the manpage.

There also need to be an entry in CHANGES.md.
The CI will stay red because test-branch is currently broken, but the Build on Linux should become green once the manpage is up to date.

@EmileTrotignon EmileTrotignon requested a review from Julow May 6, 2025 13:15
@Julow
Copy link
Collaborator

Julow commented May 6, 2025

I know struct-indent feels a bit weird because it also affects sig, but there is precedent with break-struct also affecting sig.

I don't think struct-indent is a good name. I understand that module in the option name means struct .. end and sig .. end and I don't think can be misunderstood for other module expressions. struct seems to explicitly exclude signatures, when the option doesn't from its conception.
The way to go for consistency, I think, is to rename the other options instead.

The CI will stay red because test-branch is currently broken

test-branch shouldn't fail the CI, even when there are errors. It should be fixed because it's an extremely important tool.

Copy link
Collaborator

@Julow Julow left a comment

Choose a reason for hiding this comment

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

OCamlformat has been widely used for many years, and I've never seen this option being requested until last month (#2677).
We generally don't want to add options to OCamlformat because it makes the code less consistent and harder to read across the community. It also creates an exponentially increasing number of possible configurations that we can't test and that contain countless bugs.

The implementation looks good and I won't object to merging. I wonder what the community feels about this PR ?

@EmileTrotignon
Copy link
Collaborator

It was requested in the past here: #1613

My opinion on introducing options is that it really depends on the option. This one is very simple, should not cause maintenance issues nor bugs.

That is not the case for other options that have a complex implementation and a hard to explain behavior (the worst one of course being ocp-indent-compat, but there are others in between).

@EmileTrotignon
Copy link
Collaborator

As for the name, in the discussions leading up the PR, the question of this option affecting functor applications came up, so its at least a little confusing. Also, module excludes sig, because sig .. end is the syntax for module types.

What about struct-sig-indent ?

@HPRIOR
Copy link
Contributor Author

HPRIOR commented May 9, 2025

Thank you for reviewing @Julow.

We generally don't want to add options to OCamlFormat because it makes the code less consistent and harder to read across the community. It also creates an exponentially increasing number of possible configurations that we can't test and that contain countless bugs.

I understand the concern about adding too many options. I think my PR does contribute to this problem. There are several syntax elements that I’ve left out of the implementation because it’s unclear whether they should follow the module-indent setting.

For example, extension nodes:

 module M =
-  [%demo
-  module Foo = Bar
+    [%demo
+    module Foo = Bar

-  type t]
+    type t]

And anonymous modules:

 module Subst = Map.Make (struct
-    type t = string
+        type t = string

-    let compare = compare
-  end)
+        let compare = compare
+    end)

If these aren't covered by module-indent, we’d need separate options like module-extension-node-indent or anon-module-indent, which would add to the option/maintenance bloat.

@Julow would you be supportive of a global indent option instead? Without being complemented by additional options, the current implementation is insufficient for my needs since I'd like all module items to be consistently indented. @EmileTrotignon mentioned in the discussion here #2677 (comment) that there may be some hidden edge cases when increasing the indentation levels above the default. Given the formatter already supports wrapping/breaking at arbitrary widths it seems like the existing logic is capable of supporting the feature otherwise. While it may slightly increase the developement cost where edge cases are found by the minority of users using non-default indentation, it would not cause configuration/option bloat.

I don't think it's worth arguing for the merits of different indentation levels. What matters is that people have strong preferences, and there’s currently no way to reflect them in ocaml. As @ELLIOTTCABLE argued in #1613, even very opinionated formatters like Prettier allow users to choose indentation width. The widespread use of the default isn’t surprising when there’s never been a choice.

@HPRIOR
Copy link
Contributor Author

HPRIOR commented May 9, 2025

Thanks for reviewing @EmileTrotignon,

The tests could include attributes and comments to check that everything works well when they are present.

dune build @gen_manpage --auto-promote should be run to regenerate the manpage.

There also need to be an entry in CHANGES.md. The CI will stay red because test-branch is currently broken, but the Build on Linux should become green once the manpage is up to date.

If I decide to proceed with the PR I'll make these changes, otherwise it's useful to know for the next one.

@EmileTrotignon
Copy link
Collaborator

While it may slightly increase the developement cost where edge cases are found by the minority of users using non-default indentation, it would not cause configuration/option bloat.

In my opinion, such a widespread configuration option would be way harder to merge than the current, or even a slightly extented version of the current PR. The reason being that this PR has a defined scope which is very important for a bug-free implementation. To me, the biggest issue with option bloat is the implementation. In this perspective, a small, clearly bugfree PR like is easy to merge, but a PR with bigger changes not so much.

That being said, I understand the appeal of the feature. A plan to get it would be to have more small indentation options
in the future, and a master option to set them all at once (the logic for that already exists in the option for ocp-indent configuration).

@HPRIOR HPRIOR force-pushed the add-module-indent-configuration branch from 0ff4c3d to 6dbbde7 Compare May 20, 2025 20:42
@ELLIOTTCABLE
Copy link

For whatever it's worth, I strongly support a simple, global tab-width setting that applies to all types of indent. For existing category-specific configuration values, they could override the global default when explicitly set.

That said, my original issue, which this PR addresses, feels like the wrong approach if a global setting is on the table. The bloat of granular settings, as far as I'm concerned, is unnecessary maintenance burden.

If there's good reason not to include a global option, then I do support this PR as a minimal substitute. (=

@EmileTrotignon
Copy link
Collaborator

That said, my original issue, which this PR addresses, feels like the wrong approach if a global setting is on the table. The bloat of granular settings, as far as I'm concerned, is unnecessary maintenance burden.

I think a global setting is going to be way harder to implement and therefore maintain. The number of option isn't really what makes the maintenance difficult, having options with a large scope is much worse. That being said, it is indeed a nice feature, and implementing in a maintainable way would probably make the code much cleaner.

If there's good reason not to include a global option, then I do support this PR as a minimal substitute. (=

Its unlikely to be trivial to implement, can be a step in a larger project.

Consider the following code:

  let fun_type_annot c = if ocp c then 2 else 4

  let fun_args c = if ocp c then 6 else 4

  let docked_function_after_fun (c : Conf.t) ~parens ~ctx0 ~ctx =
    match ctx0 with
    | Str _ | Lb _ ->
        (* Cases must be 2-indented relative to the [let], even when
           [let_binding_deindent_fun] is on. *)
        if c.fmt_opts.let_binding_deindent_fun.v then 1 else 0
    | _ when ctx_is_infix ctx0 -> 0
    | _ when ocp c -> (
      match Exp.ctx_is_apply_and_exp_is_arg ~ctx ~ctx0 with
      | Some (_, _, true) -> (* Last argument *) 2
      | _ -> if parens then 3 else 2 )
    | _ -> 2

if parens then 3 else 2 probably is because you want the | to be indented by 2 relative to function and not relative to (function. But how to be sure ? There are probably other place in the code were the indentation is a magic constant computed by keyword length + indentation where the keyword in question is implicit. In some places you might also get indentation - keyword length which is even worse to understand and change correctly.

Or the following option:

--let-binding-deindent-fun
  Deindent a line beginning with `fun`. The flag is set by default.

This option produces and indentation of 1 btw. What is it supposed to do with the global indentation option ?

This is just stuff I had on top of my head, there is most likely multiple other weird spots. To find them you would have to try to implement the option.

All of this is doable, but I don't think its reasonable to do it all at once.

@HPRIOR
Copy link
Contributor Author

HPRIOR commented May 21, 2025

I'm currently working on a provisional PR for a global indent option. There are a few complicated areas, but majority of changes are just a trivial replacement of 2 with a configuration option.

if parens then 3 else 2 probably is because you want the | to be indented by 2 relative to function and not relative to (function. But how to be sure ?

Changing 3 to 2 gives this output in the tests:

 let _ =
   { foo =
       (fun _ -> function
-         | _ ->
-           let _ = 42 in
-           ()
-         | () -> ())
+        | _ ->
+          let _ = 42 in
+          ()
+        | () -> ())
   }
 ;;

In this case the logic would change to:

| _ when ocp c -> (
      match Exp.ctx_is_apply_and_exp_is_arg ~ctx ~ctx0 with
      | Some (_, _, true) -> (* Last argument *) 2
      | _ -> if parens then c.fmt_opts.indent.v + 1 else c.fmt_opts.indent.v )

Another question would be whether changing the default indentation level breaks ocp-indent compatibility. I'm not sure about the rest but I imagine some simple maths around the configured indent level will be required. let_binding_deindent_fun is an interesting option and would need some thought.

All of this is doable, but I don't think its reasonable to do it all at once.

Aren't the tests comprehensive enough to ensure regressions aren't introduced for the default indentation level? Splitting out the indentation options could result is 20/30+ indentation options. Is this desirable?

@EmileTrotignon
Copy link
Collaborator

There are a few complicated areas, but majority of changes are just a trivial replacement of 2 with a configuration option.

I am not surprised by that, but the complicated areas might be rabbit holes.
Also some spots have very poor code quality, like the indentation of function is very hard to understand, I would think it would be better to simplify it before making the code more general. Maybe its fine, I haven't tried.

let_binding_deindent_fun is an interesting option and would need some thought.

I don't really get why we need this option, I would rather delete it. Having an indentation of 1 is so weird. Maybe I can start by making the default false instead of true.

Aren't the tests comprehensive enough to ensure regressions aren't introduced for the default indentation level?

Probably not.

Splitting out the indentation options could result is 20/30+ indentation options. Is this desirable?

20 to 30 is too many, maybe there would be a way to group them so that it gets to 10.

Another issue I have with a global option is how do you unsure its complete ? With limited scope option its easy, you can test the whole scope, but if its supposed to be for everything then its more complicated.

Also keep in mind that no one is working on ocamlformat full-time, so a huge PR might just be impossible to review.

@HPRIOR
Copy link
Contributor Author

HPRIOR commented May 29, 2025

Any chance this can be merged as a stopgap between a more comprehensive indentation option @EmileTrotignon @Julow ?

@EmileTrotignon
Copy link
Collaborator

I am in favour of merging. The CI needs to be green (maybe a rebase will take care of it), and I will rereview next week to check for anything that might have slipped in.

I think @Julow was reluctant but did not want to veto.

@HPRIOR HPRIOR force-pushed the add-module-indent-configuration branch from 6dbbde7 to 7fd6ad5 Compare June 1, 2025 20:53
@EmileTrotignon
Copy link
Collaborator

I had one issue with documentation, apart from that I am happy with the PR. If @Julow does not object I want to go forward and merge.

@HPRIOR HPRIOR force-pushed the add-module-indent-configuration branch from 7fd6ad5 to 770370c Compare June 8, 2025 15:51
@EmileTrotignon
Copy link
Collaborator

I don't understand the CI failures, they seem unrelated to this PR, I am merging.

@EmileTrotignon EmileTrotignon merged commit 1851bc4 into ocaml-ppx:main Jun 16, 2025
2 of 3 checks passed
Julow added a commit to Julow/opam-repository that referenced this pull request Oct 24, 2025
….28.1)

CHANGES:

### Highlight

- \* Support for OCaml 5.4
  (ocaml-ppx/ocamlformat#2717, ocaml-ppx/ocamlformat#2720, ocaml-ppx/ocamlformat#2732, ocaml-ppx/ocamlformat#2733, ocaml-ppx/ocamlformat#2735, @Julow, @Octachron, @cod1r, @EmileTrotignon)
  OCamlformat now supports OCaml 5.4 syntax.
  Module packing of the form `((module M) : (module S))` are no longer
  rewritten to `(module M : S)` because these are now two different syntaxes.

- \* Reduce indentation after `|> map (fun` (ocaml-ppx/ocamlformat#2694, @EmileTrotignon)
  Notably, the indentation no longer depends on the length of the infix
  operator, for example:
  ```ocaml
  (* before *)
  v
  |>>>>>> map (fun x ->
              x )
  (* after *)
  v
  |>>>>>> map (fun x ->
      x )
  ```
  `@@ match` can now also be on one line.

### Added

- Added option `module-indent` option (ocaml-ppx/ocamlformat#2711, @HPRIOR) to control the indentation
  of items within modules. This affects modules and signatures. For example,
  module-indent=4:
  ```ocaml
  module type M = sig
      type t

      val f : (string * int) list -> int
  end
  ```

- `exp-grouping=preserve` is now the default in `default` and `ocamlformat`
  profiles. This means that its now possible to use `begin ... end` without
  tweaking ocamlformat. (ocaml-ppx/ocamlformat#2716, @EmileTrotignon)

### Deprecated

- Starting in this release, ocamlformat can use cmdliner >= 2.0.0. When that is
  the case, the tool no longer accepts unambiguous option names prefixes. For
  example, `--max-iter` is not accepted anymore, you have to pass the full
  option `--max-iters`. This does not apply to the keys in the `.ocamlformat`
  configuration files, which have always required the full name.
  See dbuenzli/cmdliner#200.
  (ocaml-ppx/ocamlformat#2680, @emillon)

### Changed

- \* The formatting of infix extensions is now consistent with regular
  formatting by construction. This reduces indentation in `f @@ match%e`
  expressions to the level of indentation in `f @@ match`. Other unknown
  inconsistencies might also be fixed. (ocaml-ppx/ocamlformat#2676, @EmileTrotignon)

- \* The spacing of infix attributes is now consistent across keywords. Every
  keyword but `begin` `function`, and `fun` had attributes stuck to the keyword:
  `match[@A]`, but `fun [@A]`. Now its also `fun[@A]`. (ocaml-ppx/ocamlformat#2676, @EmileTrotignon)

- \* The formatting of`let a = b in fun ...` is now consistent with other
  contexts like `a ; fun ...`. A check for the syntax `let a = fun ... in ...`
  was made more precise. (ocaml-ppx/ocamlformat#2705, @EmileTrotignon)

- \* `|> begin`, `~arg:begin`, `begin if`, `lazy begin`, `begin match`,
  `begin fun` and `map li begin fun`  can now be printed on the same line, with
  one less indentation level for the body of the inner expression.
  (ocaml-ppx/ocamlformat#2664, ocaml-ppx/ocamlformat#2666, ocaml-ppx/ocamlformat#2671, ocaml-ppx/ocamlformat#2672, ocaml-ppx/ocamlformat#2681, ocaml-ppx/ocamlformat#2685, ocaml-ppx/ocamlformat#2693, @EmileTrotignon)
  For example :
  ```ocaml
  (* before *)
  begin
    fun x ->
      some code
  end
  (* after *)
  begin fun x ->
    some code
  end
  ```

- \* `break-struct=natural` now also applies to `sig ... end`. (ocaml-ppx/ocamlformat#2682, @EmileTrotignon)

### Fixed

- Fixed `wrap-comments=true` not working with the janestreet profile (ocaml-ppx/ocamlformat#2645, @Julow)
  Asterisk-prefixed comments are also now formatted the same way as with the
  default profile.

- Fixed `nested-match=align` not working with `match%ext` (ocaml-ppx/ocamlformat#2648, @EmileTrotignon)

- Fixed the AST generated for bindings of the form `let pattern : type = function ...`
  (ocaml-ppx/ocamlformat#2651, @v-gb)

- Print valid syntax for the corner case (1).a (ocaml-ppx/ocamlformat#2653, @v-gb)

- `Ast_mapper.default_mapper` now iterates on the location of `in` in `let+ .. in ..`
  (ocaml-ppx/ocamlformat#2658, @v-gb)

- Fix missing parentheses in `let+ (Cstr _) : _ = _` (ocaml-ppx/ocamlformat#2661, @Julow)
  This caused a crash as the generated code wasn't valid syntax.

- Fix bad indentation of `let%ext { ...` (ocaml-ppx/ocamlformat#2663, @EmileTrotignon)
  with `dock-collection-brackets` enabled.

- ocamlformat is now more robust when used as a library to print modified ASTs
  (ocaml-ppx/ocamlformat#2659, @v-gb)

- Fix crash due to edge case with asterisk-prefixed comments (ocaml-ppx/ocamlformat#2674, @Julow)

- Fix crash when formatting `mld` files that cannot be lexed as ocaml (e.g.
  containing LaTeX or C code) (ocaml-ppx/ocamlformat#2684, @emillon)

- \* Fix double parens around module constraint in functor application :
  `module M = F ((A : T))` becomes `module M = F (A : T)`. (ocaml-ppx/ocamlformat#2678, @EmileTrotignon)

- Fix misplaced `;;` due to interaction with floating doc comments.
  (ocaml-ppx/ocamlformat#2691, @EmileTrotignon)

- The formatting of attributes of expression is now aware of the attributes
  infix or postix positions: `((fun [@A] x -> y) [@b])` is formatted without
  moving attributes. (ocaml-ppx/ocamlformat#2676, @EmileTrotignon)

- `begin%e ... end` and `begin [@A] ... end` nodes are always preserved.
  (ocaml-ppx/ocamlformat#2676, @EmileTrotignon)

- `begin end` syntax for `()` is now preserved. (ocaml-ppx/ocamlformat#2676, @EmileTrotignon)

- Fix a crash on `type 'a t = A : 'a. {a: 'a} -> 'a t`. (ocaml-ppx/ocamlformat#2710, @EmileTrotignon)

- Fix a crash where `type%e nonrec t = t` was formatted as `type nonrec%e t = t`,
  which is invalid syntax. (ocaml-ppx/ocamlformat#2712, @EmileTrotignon)

- Fix commandline parsing being quadratic in the number of arguments
  (ocaml-ppx/ocamlformat#2724, @let-def)

- \* Fix `;;` being added after a documentation comment (ocaml-ppx/ocamlformat#2683, @EmileTrotignon)
  This results in more `;;` being inserted, for example:
  ```ocaml
  (* before *)
  print_endline "foo"
  let a = 3

  (* after *)
  print_endline "foo" ;;
  let a = 3
  ```

- Fix dropped comment in `if then (* comment *) begin .. end` (ocaml-ppx/ocamlformat#2734, @Julow)
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.

4 participants