Magnus web site
Random stuff
Switching to project.el
I've used projectile ever since I created my own Emacs config. I have a vague memory choosing it because some other package only supported it. (It might have been lsp-mode, but I'm not sure.) Anyway, now that I'm trying out eglot, again, I thought I might as well see if I can switch to project.el, which is included in Emacs nowadays.
A non-VC project marker
Projectile allows using a file, .projectile, in the root of a project. This
makes it possible to turn a folder into a project without having to use version
control. It's possible to configure project.el to respect more VC markers than
what's built-in. This can be used to define a non-VC marker.
(setopt project-vc-extra-root-markers '(".projectile" ".git"))
Since I've set vc-handled-backends to nil (the default made VC interfere
with magit, so I turned it off completely) I had to add ".git" to make git
repos be recognised as projects too.
Xref history
The first thing to solve was that the xref stack wasn't per project. Somewhat
disappointingly there only seems to be two options for xref-history-storage
shipped with Emacs
xref-global-history- a single global history (the default)
xref-window-local-history- a history per window
I had the same issue with projectile, and ended up writing my own package for it. For project.el I settled on using xref-project-history.
(use-package xref-project-history
:ensure (:type git
:repo "https://codeberg.org/imarko/xref-project-history.git"
:branch "master")
:custom
(xref-history-storage #'xref-project-history))
Jumping between implementation and test
Projectile has a function for jumping between implementation and test. Not too
surprisingly it's called projectile-toggle-between-implementation-and-test. I
found some old emails in an archive suggesting that project.el might have had
something similar in the past, but if that's the case it's been removed by now.
When searching for a package I came across this email comparing tools for
finding related files. The author mentions two that are included with Emacs
ff-find-other-file- part of find-file.el, which a few other functions and a rather impressive set of settings to customise its behaviour.
find-sibling-file- a newer command, I believe, that also can be customised.
So, there are options, but neither of them are made to work nicely with project.el out of the box. My most complicated use case seems to be in Haskell projects where modules for implementation and test live in separate (mirrored) folder hierarchies, e.g.
src
└── Sider
└── Data
├── Command.hs
├── Pipeline.hs
└── Resp.hs
test
└── Sider
└── Data
├── CommandSpec.hs
├── PipelineSpec.hs
└── RespSpec.hs
I'm not really sure how I'd configure find-sibling-rules, which are regular
expressions, to deal with folder hierarchies like this. To be honest, I didn't
really see a way of configuring ff-find-other-file at first either. Then I
happened on a post about switching between a module and its tests in Python.
With its help I came up with the following
(defun mes/setup-hs-ff ()
(when-let* ((proj-root (project-root (project-current)))
(rel-proj-root (-some--> (buffer-file-name)
(file-name-directory it)
(f-relative proj-root it)))
(sub-tree (car (f-split (f-relative (buffer-file-name) proj-root))))
(search-dirs (--> '("src" "test")
(remove sub-tree it)
(-map (lambda (p) (f-join proj-root p)) it)
(-select #'f-directory? it)
(-mapcat (lambda (p) (f-directories p nil t)) it)
(-map (lambda (p) (f-relative p proj-root)) it)
(-map (lambda (p) (f-join rel-proj-root p)) it))))
(setq-local ff-search-directories search-dirs
ff-other-file-alist '(("Spec\\.hs$" (".hs"))
("\\.hs$" ("Spec.hs"))))))
A few things to note
- The order of rules in
ff-other-file-alistis important, the first match is chosen. (buffer-file-name)can, and really does, returnnilat times, andfile-name-directorydoesn't deal with anything but strings.- The entries in
ff-search-directorieshave to be relative to the file in the current buffer, hence the rather involvedvarlistin thewhen-let*expression.
With this in place I get the following values for ff-search-directories
src/Sider/Data/Command.hs("../../../test/Sider" "../../../test/Sider/Data")test/Sider/Data/CommandSpec.hs("../../../src/Sider" "../../../src/Sider/Data")
And ff-find-other-file works beautifully.
Conclusion
My setup with project.el now covers everything I used from projectile so I'm fairly confident I'll be happy keeping it.
Using advice to limit lsp-ui-doc nuisance
I've switched back to lsp-mode temporarily until I've had time to fix a few
things with my eglot setup. Returning prompted me to finally address an
irritating behaviour with lsp-ui-doc.
No matter what I set lsp-ui-doc-position to it ends up covering information
that I want to see. While waiting for a fix I decided to work around it. It
seems to me that this is exactly what advice is for.
I came up with the following to make sure the frame appears on the half of the
buffer where point isn't.
(defun my-lsp-ui-doc-wrapper (&rest _)
(let* ((pos-line (- (line-number-at-pos (point))
(line-number-at-pos (window-start))))
(pos (if (<= pos-line (/ (window-body-height) 2))
'bottom
'top)))
(setopt lsp-ui-doc-position pos)))
(advice-add 'lsp-ui-doc--move-frame :before #'my-lsp-ui-doc-wrapper)
More on the switch to eglot
Since the switching to eglot I've ended up making a few related changes.
Replacing flycheck with flymake
Since eglot it's written to work with other packages in core, which means it
integrates with flymake. The switch comprised
- Use
:ensure nilto make sureelpacaknows there's nothing to download. - Add a call to
flymake-modetoprog-mode-hook. - Define two functions to toggle showing a list of diagnostics for the current buffer and the project.
- Redefine the relevant keybindings.
The two functions for toggling showing diagnostics look like this
(defun mes/toggle-flymake-buffer-diagnostics ()
(interactive)
(if-let* ((window (get-buffer-window (flymake--diagnostics-buffer-name))))
(save-selected-window (quit-window nil window))
(flymake-show-buffer-diagnostics)))
(defun mes/toggle-flymake-project-diagnostics ()
(interactive)
(if-let* ((window (get-buffer-window (flymake--project-diagnostics-buffer (projectile-project-root)))))
(save-selected-window (quit-window nil window))
(flymake-show-project-diagnostics)))
And the changed keybindings are
| flycheck | flymake |
|---|---|
| flycheck-next-error | flymake-goto-next-error |
| flycheck-previous-error | flymake-goto-prev-error |
| mes/toggle-flycheck-error-list | mes/toggle-flymake-buffer-diagnostics |
| mes/toggle-flycheck-projectile-error-list | mes/toggle-flymake-project-diagnostics |
Using with-eval-after-load instead of :after eglot
When it comes to use-package I keep on being surprised, and after the switch
to elpaca I've found some new surprises. One of them was that using :after
eglot like this
(use-package haskell-ng-mode
:afer eglot
:ensure (:type git
:repo "git@gitlab.com:magus/haskell-ng-mode.git"
:branch "main")
:init
(add-to-list 'major-mode-remap-alist '(haskell-mode . haskell-ng-mode))
(add-to-list 'eglot-server-programs '(haskell-ng-mode "haskell-language-server-wrapper" "--lsp"))
(setq-default eglot-workspace-configuration
(plist-put eglot-workspace-configuration
:haskell
'(:formattingProvider "fourmolu"
:plugin (:stan (:global-on :json-false)))))
...
:hook
(haskell-ng-mode . eglot-ensure)
...)
would delay initialisation until after eglot had been loaded. However, it
turned out that nothing in :init ... seemed to run and upon opening a haskell file
no mode was loaded.
After a bit of thinking and tinkering I got it working by removing :after
eglot and using with-eval-after-load
(use-package haskell-ng-mode
:ensure (:type git
:repo "git@gitlab.com:magus/haskell-ng-mode.git"
:branch "main")
:init
(add-to-list 'major-mode-remap-alist '(haskell-mode . haskell-ng-mode))
(with-eval-after-load 'eglot
(add-to-list 'eglot-server-programs '(haskell-ng-mode "haskell-language-server-wrapper" "--lsp"))
(setq-default eglot-workspace-configuration
(plist-put eglot-workspace-configuration
:haskell
'(:formattingProvider "fourmolu"
:plugin (:stan (:global-on :json-false))))))
...
:hook
(haskell-ng-mode . eglot-ensure)
...)
That change worked for haskell, and it seemed to work for python too, but after a little while I realised that python needed a bit more attention.
Getting the configuration for Python to work properly
The python setup looked like this
(use-package python
:init
(add-to-list 'major-mode-remap-alist '(python-mode . python-ts-mode))
(with-eval-after-load 'eglot
(assoc-delete-all '(python-mode python-ts-mode) eglot-server-programs)
(add-to-list 'eglot-server-programs
`((python-mode python-ts-mode) . ,(eglot-alternatives
'(("rass" "python") "pylsp")))))
...
:hook (python-ts-mode . eglot-ensure)
...)
and it worked all right, but then I visited the package (using elpaca-visit)
and realised that the downloaded package was all of emacs. That's a bit of
overkill, I'd say.
However, adding :ensure nil didn't have the expected effect of just using the
version that's in core. Instead the whole configuration seemed to never take
effect and again I was back to the situation where I had to jump to
python-ts-mode manually.
The documentation for use-package says that :init is for
Code to run before PACKAGE-NAME has been loaded.
but I'm guessing "before" isn't quite before enough. Then I noticed :preface
with the description
Code to be run before everything except
:disabled; this can be used to define functions for use in:if, or that should be seen by the byte-compiler.
and yes, "before everything" is early enough. The final python configuration looks like this
(use-package python
:ensure nil
:preface
(add-to-list 'major-mode-remap-alist '(python-mode . python-ts-mode))
:init
(with-eval-after-load 'eglot
(assoc-delete-all '(python-mode python-ts-mode) eglot-server-programs)
(add-to-list 'eglot-server-programs
`((python-mode python-ts-mode) . ,(eglot-alternatives
'(("rass" "python") "pylsp")))))
...
:hook (python-ts-mode . eglot-ensure)
...)
Closing remark
I'm still not sure I have the correct intuition about how to use use-package,
but hopefully it's more correct now than before. I have a growing suspicion
that use-package changes behaviour based on the package manager I use. Or
maybe it's just that some package managers make use-package more forgiving of
bad use.
Trying eglot, again
I've been using lsp-mode since I switched to Emacs several years ago. When eglot
made into Emacs core I used it very briefly but quickly switched back. Mainly I
found eglot a bit too bare-bones; I liked some of the bells and whistles of
lsp-ui. Fast-forward a few years and I've grown a bit tired of those bells and
whistles. Specifically that it's difficult to make lsp-ui-sideline and
lsp-ui-doc work well together. lsp-ui-sidedline is shown on the right side,
which is good, but combining it with lsp-ui-doc leads to situations where the
popup covers the sideline. What I've done so far is centre the line to bring the
sideline text out. I was playing a little bit with making the setting of
lsp-ui-doc-position change depending on the location of the current position.
It didn't work that well though so I decided to try to find a simpler setup.
Instead of simplifying the setup of lsp-config I thought I'd give eglot
another shot.
Basic setup
I removed the statements pulling in lsp-mode, lsp-ui, and all
language-specific packages like lsp-haskell. Then I added this to configure
eglot
(use-package eglot
:ensure nil
:custom
(eglot-autoshutdown t)
(eglot-confirm-server-edits '((eglot-rename . nil)
(t . diff))))
The rest was mainly just switching lsp-mode functions for eglot functions.
| lsp-mode function | eglot function |
|---|---|
lsp-deferred |
eglot-ensure |
lsp-describe-thing-at-point |
eldoc |
lsp-execute-code-action |
eglot-code-actions |
lsp-find-type-definition |
eglot-find-typeDefinition |
lsp-format-buffer |
eglot-format-buffer |
lsp-format-region |
eglot-format |
lsp-organize-imports |
eglot-code-action-organize-imports |
lsp-rename |
eglot-rename |
lsp-workspace-restart |
eglot-reconnect |
lsp-workspace-shutdown |
eglot-shutdown |
I haven't verified that the list is fully correct yet, but it looks good so far.
The one thing I might miss is lenses, and using lsp-avy-lens. However,
everything that I use lenses for can be done using actions, and to be honest I
don't think I'll miss the huge lens texts from missing type annotations in
Haskell.
Configuration
One good thing about lsp-mode's use of language-specific packages is that
configuration of the various servers is performed through functions. This makes
it easy to discover what options are available, though it also means not all
options may be available. In eglot configuration is less organised, I have to
know about the options for each language server and put the options into
eglot-workspace-configuration myself. It's not always easy to track down what
options are available, and I've found no easy way to verify the settings. For
instance, with lsp-mode I configures HLS like this
(lsp-haskell-formatting-provider "fourmolu")
(lsp-haskell-plugin-stan-global-on nil)
which translates to this for eglot
(setq-default eglot-workspace-configuration
(plist-put eglot-workspace-configuration
:haskell
'(:formattingProvider "fourmolu"
:plugin (:stan (:global-on :json-false)))))
and I can verify that this configuration has taken effect because I know enough about the Haskell tools.
I do some development in Python and I used to configure pylsp like this
(lsp-pylsp-plugins-mypy-enabled t)
(lsp-pylsp-plugins-ruff-enabled t)
which I think translates to this for eglot
(setq-default eglot-workspace-configuration
(plist-put eglot-workspace-configuration
:pylsp
'(:plugins (:ruff (:enabled t)
:mypy (:enabled t)))))
but I don't know any convenient way of verifying these settings. I'm simply not
familiar enough with the Python tools. I can check the value of
eglot-workspace-configuration by inspecting it or calling
eglot-show-workspace-configuration but is there really no way of asking the
language server for its active configuration?
Closing remark
The last time I gave up on eglot very quickly, probably too quickly to be
honest. I made these changes to my configuration over the weekend, so the real
test of eglot starts when I'm back in the office. I have a feeling I'll stick
to it longer this time.
Validation of data in a servant server
I've been playing around with adding more validation of data received by an HTTP
endpoint in a servant server. Defining a type with a FromJSON instance is very
easy, just derive a Generic instance and it just works. Here's a simple
example
data Person = Person
{ name :: Text
, age :: Int
, occupation :: Occupation
}
deriving (Generic, Show)
deriving (FromJSON, ToJSON) via (Generically Person)
data Occupation = UnderAge | Student | Unemployed | SelfEmployed | Retired | Occupation Text
deriving (Eq, Generic, Ord, Show)
deriving (FromJSON, ToJSON) via (Generically Occupation)
However, the validation is rather limited, basically it's just checking that
each field is present and of the correct type. For the type above I'd like to
enforce some constraints for the combination of age and occupation.
The steps I thought of are
- Hide the default constructor and define a smart one. (This is the standard suggestion for placing extra constraints values.)
- Manually define the
FromJSONinstance using theGenericinstance to limit the amount of code and the smart constructor.
The smart constructor
I give the constructor the result type Either String Person to make sure it
can both be usable in code and when defining parseJSON.
mkPerson :: Text -> Int -> Occupation -> Either String Person
mkPerson name age occupation = do
guardE mustBeUnderAge
guardE notUnderAge
guardE tooOldToBeStudent
guardE mustBeRetired
pure $ Person name age occupation
where
guardE (pred, err) = when pred $ Left err
mustBeUnderAge = (age < 8 && occupation > UnderAge, "too young for occupation")
notUnderAge = (age > 15 && occupation == UnderAge, "too old to be under age")
tooOldToBeStudent = (age > 45 && occupation == Student, "too old to be a student")
mustBeRetired = (age > 65 && occupation /= Retired, "too old to not be retired")
Here I'm making use of Either e being a Monad and use when to apply the
constraints and ensure the reason for failure is given to the caller.
The FromJSON instance
When defining the instance I take advantage of the Generic instance to make
the implementation short and simple.
instance FromJSON Person where
parseJSON v = do
Person{name, age, occupation} <- genericParseJSON defaultOptions v
either fail pure $ mkPerson name age occupation
If there are many more fields in the type I'd consider using RecordWildCards.
Conclusion
No, it's nothing ground-breaking but I think it's a fairly nice example of how things can fit together in Haskell.