Skip to content

Add a Tilia formatter plugin - #5109

Open
mrkkrp wants to merge 1 commit into
haskell:masterfrom
mrkkrp:tilia-plugin
Open

mrkkrp wants to merge 1 commit into
haskell:masterfrom
mrkkrp:tilia-plugin

Conversation

@mrkkrp

@mrkkrp mrkkrp commented Oct 8, 2026

Copy link
Copy Markdown

Tilia (https://github.com/mrkkrp/tilia) formats a module with the extensions, dependencies and operator fixities of the project it belongs to, all of which it works out from the module's path. The plugin calls Tilia.Editor in process, or with haskell.plugin.tilia.config.external runs tilia for-editor FILE with the buffer on standard input. It formats whole modules and refuses range requests.

This whole PR was generated by Claude code (Opus 5.5 1M context). If it comes to it, I believe I can explain every line of it.



@mrkkrp
mrkkrp force-pushed the tilia-plugin branch 3 times, most recently from f146702 to 52a388b Compare October 8, 2026 21:27
@mrkkrp

mrkkrp commented Oct 9, 2026

Copy link
Copy Markdown
Author

I believe the failure is ghcide test th-stale-bytecode, appears to be flaky? At least I don't immediately see how it could be connected.

@fendor

fendor commented Oct 10, 2026

Copy link
Copy Markdown
Collaborator

@mrkkrp Definitely flakey, don't worry about it :)

case T.decodeUtf8' out of
Left _ -> throwError $ PluginInternalError "Tilia printed output that is not valid UTF-8"
Right after
| after == contents -> pure Nothing

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why do you treat this case specially? makeDiffTextEdit will just not generate a difference any way

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fair point, I amended the commit.

@fendor

fendor commented Oct 10, 2026

Copy link
Copy Markdown
Collaborator

Can you tell us a little bit about tilia?
Does it read the .cabal or might it benefit from HLS telling it the DynFlags?

@mrkkrp

mrkkrp commented Oct 10, 2026

Copy link
Copy Markdown
Author

Does it read the .cabal or might it benefit from HLS telling it the DynFlags?

Tilia reads the .cabal file: it finds the component the module belongs to and takes the language and extensions from it (evaluating conditionals as Cabal does), so DynFlags' extensions would tell it nothing new for a cabal project. It formats all branches of CPP conditionals rather than one configuration, so the -D flags of one build don't apply either; version macros it takes from the build plan. What it does need is the fixities of imported operators, which it gets by reading dependencies' sources/interface files (for those that are installed) according to the build plan, with a cache. That is where HLS could help, since it already has the interfaces, but it would take a new entry point in Tilia, so I'd leave it for a follow-up.

@fendor

fendor commented Oct 14, 2026

Copy link
Copy Markdown
Collaborator

it finds the component the module belongs to and takes the language and extensions from it (evaluating conditionals as Cabal does), so DynFlags' extensions would tell it nothing new for a cabal project.

HLS could tell tilia about the DynFlags and the fixities, so it could do less work, no need to go rummage for a .cabal and cabal.project file.

it finds the component the module belongs to

Out of curiosity, how does it do that?

version macros it takes from the build plan

This seems to indicate, if cabal build was never invoked in the project, then the formatter doesn't work?
HLS doesn't persist build artefacts in the project's dist-newstyle.

What it does need is the fixities of imported operators, which it gets by reading dependencies' sources/interface files (for those that are installed) according to the build plan, with a cache.

Does this mean tilia looks at the plan.json and then goes and parses the .hi files of used dependencies?
I suppose it performs import resolution for the current module to figure out which dependencies it is actually using?
Or are dependencies here, local ones?

@mrkkrp

mrkkrp commented Oct 16, 2026 •

Copy link
Copy Markdown
Author

Out of curiosity, how does it do that?

It first locates the .cabal file by traversing upwards, then it uses information from the build plan to resolve conditionals, os and arch are based on the machine where Tilia is run, flags assume their default values, impl(ghc ...) resolved against compiler-id from the plan (otherwise we default to ghc --numeric-version). I think at some point I should take flags from cabal.project into account, but that's not done for now. Then, since we have hs-source-dirs and module names we can deduce which component a source file belongs to.

It's been working well in practice. I am currently able to format almost all of Hackage with Tilia, including libraries like lens and QuickCheck which were not formattable before (I mean, with Ormolu/Fourmolu), mainly because of CPP. I will need to get the latest numbers from my Hackage scans, but should be around 98%, literally any Haskell that can get a build plan on my machine will format right away, no magic comments or editing is required.

This seems to indicate, if cabal build was never invoked in the project, then the formatter doesn't work?

It works in any case, but the first invocation will be slower, because it will ask Cabal to solve a plan via cabal build :all --dry-run. It will do so whenever it detects that the plan is out of date. It will also perform downloads of source files via cabal build :all --only-download if necessary. This is one-time cost. After that no external tool is invoked anymore. So, there is no expectation for HLS to prepare a build plan.

(It can also work without Cabal with an explicit build plan you provide, but that's advanced usage I won't describe here.)

Does this mean tilia looks at the plan.json and then goes and parses the .hi files of used dependencies?
I suppose it performs import resolution for the current module to figure out which dependencies it is actually using?
Or are dependencies here, local ones?

Tilia guarantees resolution of fixities with absolute precision—this is another novelty compared to Ormolu/Fourmolu (they use a hardcoded dump based on Hoogle, which is simple and efficient, but quite often incorrect/incomplete). Build plan informs Tilia of the dependencies involved and we build an index which lists modules of each dependency (again, that's cached). Then there are 3 paths: local modules (there, Tilia correctly takes into account fixities of the project it is currently formatting, even fixities that are local to a particular where or let binding), interface files (there, Tilia is capable of reading .hi files directly for efficiency in order to avoid ghc startup times, whether it reads .hi files at all is decided based on ghc-pkg dump --global --user which tells us whether a particular dependency is installed), otherwise it will read source files (there, it will handle CPP and many other horrors like hsc, thankfully there are not too many of those out there). It is capable of handling everything GHC supports, like re-exports and keeping track of children as in NonEmpty(..), etc.

There is a blog post: https://markkarpov.com/post/announcing-tilia.

Back to what you are saying regarding saving work—I agree completely. I just thought we'd land this initial version and then iterate, but maybe we could land the final version right away... In fact, I have this PR mrkkrp/tilia#235 which prepares a bridge so that Tilia will be able to load all the useful knowledge from HLS. That will be both fast and precise. I will need to review it before it lands. I am on vacation right now, but I plan to merge that when I am back, then I will release 0.1.2.0 and amend this PR. I guess I could drop the external mode altogether, unless you think it still useful for something? I mostly mimicked the existing Ormolu/Fourmolu plugins here, not sure how useful having two options really is.

@mrkkrp
mrkkrp force-pushed the tilia-plugin branch 5 times, most recently from 4ed42b8 to a4aa435 Compare October 29, 2026 16:22
@mrkkrp

mrkkrp commented Oct 29, 2026

Copy link
Copy Markdown
Author

@fendor I've updated the PR with the new design: source useful information from HLS and fall back to "normal" way of operating if HLS has no GHC session for the file in question. I've removed the "external" mode that calls an external tilia binary. Please take another look when you have time.

Tilia (https://github.com/mrkkrp/tilia) formats a module with the
extensions, dependencies and operator fixities of the project it belongs
to. The plugin calls Tilia.Editor in process and hands it what HLS's
session already knows about the file: the extensions its component puts
in force, the versions of the compiler and of the packages it depends on,
and the interface of every module it can import, from the project's own
modules, compiled already or compiled now, to modules of packages the
component cannot import, such as ghc-internal, where base declares most
of the operators it reexports. Where HLS has no session for the file,
Tilia works the project out with Cabal itself. The plugin formats whole
modules and refuses range requests.
@mrkkrp

mrkkrp commented Nov 11, 2026

Copy link
Copy Markdown
Author

@fendor Is there anything I can do to help to move this forward?

@fendor

fendor commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator

@mrkkrp Thank you for the ping, PR slipped my mind, will get to it in the next days!

This branch has not been deployed

No deployments
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