~technomancy/fennel#242: 
fnlfmt - Support additional body forms and macros

Hey there!

Using fnlfmt, I'd like to be able to specify configurable global body forms. I've got some macros where prefixing with with- and def doesn't make much sense. In order of preference:

  1. A ~/.fnlfmtrc file that looks for the first valid option from the current working directory, using package loaders, etc.
  2. A comment at the top of the file:
;fnlfmt {:extra-body-forms [:if-let :with-logging :partial-right]}

I know fnlfmt was designed to not require configuration, but unless fnlfmt can dynamically determine macros, having configuration for body forms would allow for formatting more expressive DSLs and remove the need to extrapolate def and with-, particularly in cases where that's not desired.

I can open a PR, but I wanted to get thoughts first. 🙂

Status
REPORTED
Submitter
~emlabee
Assigned to
No-one
Submitted
4 months ago
Updated
a month ago
Labels
bug fnlfmt

~technomancy 4 months ago

The difficulty is that fnlfmt is meant to implement some "standard" formatting style which other formatting tools (emacs, vim, etc) can also implement. If it loads configuration from a file that is specific to fnlfmt, then it's unreasonable for other formatting tools to be able to consistently agree with fnlfmt, which severely diminishes the utility of fnlfmt.

In theory this problem of having one standard implementation used by all tools could be solved by LSP, but unfortunately LSP does not implement the necessary commands to support this as the spec does not include any commands for reindenting the line you're editing. (LSP does include a command to format an entire file, but this is not sufficient as it erases authorship data for lines that would not otherwise be edited.)

It's a tricky problem. I don't like it, but given the desire to emphasize consistency across all tools so far I've yet to come up with a better solution than the convention of with-* and def*.

~emlabee 4 months ago

Thanks for getting back to me so quickly! I think I understand your reasoning here.

it's unreasonable for other formatting tools to be able to consistently agree with fnlfmt, which severely diminishes the utility of fnlfmt.

Could you expand on this? I'm not sure what formatting tools you would use with fnlfmt when it's considered canonical and I want to understand these use-cases better.

(LSP does include a command to format an entire file, but this is not sufficient as it erases authorship data for lines that would not otherwise be edited.)

As in Git history, right? Yeah, that's a drag; I wonder if there are tools out there that specifically work with VCS in this way, albeit whether or not someone considers formatting as authorship is a whole other bikeshed. 😅


To demonstrate where I'm coming from, I started by comparing fnlfmt with other language formatters like gofmt and prettier as a means of applying a consistent formatting standard and that makes conceptual sense to me, assuming these are reasonable comparisons. (I like prettier's take on this in that by using the library, you're opting into a specific aesthetic.)

When I do compare these, the only significant and meaningful difference I see between Fennel and Go / JavaScript are macros, which are inherently non-standard outside of Fennel's standard library. (Strings vs. keywords is the only other difference I could think of where context matters, but it's not relevant and is easily mitigated with ;;fnlfmt: skip.)

Given the code-generating nature of macros, I'm wondering if it's okay for a codebase to opt-in to losing the guarantees that a standard format gives you, in exchange for the ability to utilize macros on their own terms. I think this is possible without the slippery slope of allowing configuration to become too personalized, and I'd be curious to see if configuration could actually empower tooling as well. For example, tooling built on top of fnlfmt could benefit from access to explicit config over implicit config, e.g. a tool today needs to know about fnlfmt macro heuristics with- and def, but in the future can read the same ruleset, especially if fnlfmt provides the function to access the configuration.


If it's helpful to know, I use a neovim plugin called conform which runs fnlfmt on save, and these are the workarounds I have enabled currently, as well as some I have tried before and / or thought about:

#1. Customize and recompile fnlfmt with my macros in place, and install that globally / per-project

Not ideal, but it works well. This is what I use right now.

#2. Intercept the format function in conform and look for naming convention that tells me not to run fnlfmt

In this case with my test spec library, I named the files file.spec.test and looked for .spec.test as an extension. This worked, but not having fnlfmt at all was a bummer.

#3. Use ;;fnlfmt: skip on each line.

I don't think this is sustainable with nested macros. This could me mitigated by having fnlfmt skip all children expressions, but then you lose out on running fnlfmt where applicable.

#4. Create another tool that runs fnlfmt first, then its own custom formatting.

I'm wondering if this is what you meant by additional tooling, but this could be a wrapper library that calls fnlfmt, then corrects any mistakes from fnlfmt. It doesn't benefit from changes in fnlfmt and is more likely to duplicate effort, but this could be mitigated through fnlfmt exporting more inner functionality if necessary. This is a bit more verbose than I'd like (and requires me to also implement #2), but it could be an acceptable tradeoff.

All that said, I'd love to hear more so I can understand your position and fnlfmt values better. At the moment, native configuration for macros in fnlfmt feels like the option with the least negative tradeoffs that the user can specifically opt into, and I also know you and other Fennel folks have far more context than I do about this. 🙂

~technomancy 4 months ago

Could you expand on this? I'm not sure what formatting tools you would use with fnlfmt when it's considered canonical and I want to understand these use-cases better.

Maybe the best way to explain it is that fnlfmt's goal is not to be the canonical formatter--the goal is to be the reference implementation of the canonical formatting style. Every text editor that supports Fennel is also a formatting tool that should try to implement the same thing.

Without solving the M:N problem LSP-style, every complication to the formatting rules has its cost spread across N different editors.

Given the code-generating nature of macros, I'm wondering if it's okay for a codebase to opt-in to losing the guarantees that a standard format gives you, in exchange for the ability to utilize macros on their own terms.

I don't want to say this is a point we'll never compromise on, but I don't want to sacrifice the "implementation of the standard formatting algorithm" without exploring the alternatives.

For example, tooling built on top of fnlfmt could benefit from access to explicit config over implicit config, e.g. a tool today needs to know about fnlfmt macro heuristics with- and def, but in the future can read the same ruleset, especially if fnlfmt provides the function to access the configuration.

You're right that the with-* and def* rules need to be better documented; having them noted in some sub-bullet of the readme isn't great. In fact, a full description of the canonical algorithm that fnlfmt implements should be its own separate document.

As an example of an alternative, a configuration that does not require a full Fennel parser would be better. Rather than telling Vimscript and elisp programmers that in order to implement their indentation rules they have to understand the entire .fnlfmtconfig.fnl file, what if there was a .fnlmacros file that contained a newline-separated list of identifiers that should be formatted as if they had a body form?

I'm not really sure how much I think this is a good idea, but it's at least an example of an alternate solution with a lower implementation burden across the entire ecosystem.

If it's helpful to know, I use a neovim plugin called conform which runs fnlfmt on save, and these are the workarounds I have enabled currently, as well as some I have tried before and / or thought about:

This works fine for single-person repositories, but I highly discourage format-on-save for any codebase that has multiple contributors, due to the issues of authorship erasure. Indenting on a per-line or a per-function basis is the only thing that works on a larger scale.

(Strings vs. keywords is the only other difference I could think of where context matters, but it's not relevant and is easily mitigated with ;;fnlfmt: skip.)

This is one thing I think we can do better on without any negative trade-offs; recent changes to the Fennel parser now make it possible to wrap AST nodes in a way that can preserve formatting details like :kw-style or 0xfab0 notation for numbers. So there's potential for improvement here.

-Phil

~technomancy 3 months ago

I've been thinking about this some more, and I have another potential solution. What if instead of the rule of with-* and def* being assumed to have a body, we expanded it to any call which has a name that starts with a built-in body-having form? That would make it so let-foo could be indented like let for example.

~andreyorst 3 months ago

Nov 24, 2024 21:32:08 ~technomancy outgoing@sr.ht:

I've been thinking about this some more, and I have another potential solution. What if instead of the rule of with-* and def* being assumed to have a body, we expanded it to any call which has a name that starts with a built-in body-having form? That would make it so let-foo could be indented like let for example.

what about each-other and for-tuna style macros?

-- Andrey Listopadov

~technomancy 3 months ago

what about each-other and for-tuna style macros?

Without knowing anything more about those macros, I think it would be appropriate to treat them as having a body?

~andreyorst 3 months ago

Without knowing anything more about those macros, I think it would be appropriate to treat them as having a body?

Are these macros? I don't know. Same for (fn defeat-enemy [...]), meaning that even def* is a bit too broad. for-each may well be a function like map. Yes let-* is unlikely to be a function, but if we're gonna make it such that all fennel body-forming macros have a user-naming pattern, we may incorrectly format a lot of code.

There are a lot of body-forming macros: each, for, when, do, let, while. We can kinda count fn and macro here too.

Then, there are also false positive things like *collect and accumulate don't have bodies, but are still indented like they are. I'd change them to indent body expression with 4 spaces, and in case collect specifically indent two forms with 4 spaces, and the rest with 2, indicating that this is not exactly valid.

def would probably be used in the same scenario as local but local is not indented like bodyform (I changed that in my config to indent local as bodyform though)

What I'm saying here is that it's too broad of a rule to add each-*, for-*, while-*, when-*, do-*, let-* as patterns for something being a macro with a bodyform.

But if we will do only some of them, why don't we do the other too? It'll be weird that this rule applies to some but not the other.

Anyway, these are just thoughts, I'm not against this, but I'll probably will have to do a lot of workarounds in my config if this gets to fennel-mode too to undo some false positives.

Register here or Log in to comment, or comment via email.