Skip to content

Latest commit

 

History

History
500 lines (374 loc) · 20.3 KB

design_notes.md

File metadata and controls

500 lines (374 loc) · 20.3 KB

Design Notes


Scope

I will only cover the writting and maintenance of portable code across multiple versions of Vim, and even neovim. I won't cover Vim9-script nor Lua.

Good practices

Here are a few good practices that I regularly share when reviewing Vim codes.

Function definitions

Where shall we define functions?

While global-functions are probably the oldest, they are best avoided. The reason? Unless you find a convoluted naming scheme, you have no guarantee that other plugins won't use the same name.

Most examples you'll find also use global-functions as they as the default and thus they don't require extra explanations about local-functions (the new default for Vim9!) nor autoload-functions

See also Object Oriented Programming in vim scripts regarding dictionary-function.

Script-local functions

If your function is internal (it's only used to define other functions, or to define mappings/commands/menus/... in the same file), we can make them local. The name will start with s:, and then, use any naming convention you fancy (the first letter of the function name no longer needs to be capitalized).

The only drawbacks are:

  • The name will start with <SID> when used from a mapping.
  • They cannot be tested from other files, like for instance from Unit Testing frameworks. But as we all know, private functions shall not be tested...
Library functions

Since vim7, library functions are best defined as autoload-functions.

Pro:

  • Nice naming scheme. We can use snake-case in lowercase. I usually go for {my-initials}#{plugin-name}#{function-name}.
  • They are loaded lazily/on-demand.

Note: 20ish years ago with Vim6, we either defined such functions as global functions in plugin/ files (always loaded), or in macros/ files (needed to be loaded explicitly), or in ftplugin/ files (which required convoluted workarounds to avoid defining them multiple times).

Functions that don't need to be defined when Vim starts

Lazy function loading is the reason d'être behind autoload-functions.

They are loaded on-demand (they may never be loaded). And as long as we avoid crazy circular dependencies, they are perfect most of the time.

Unlike script-local functions, they can be called from anywhere, and thus tested in Unit Testing. It's up to us to have a naming policy that says: «This is a private function». I use Python conventions here.

Nowadays, I define support functions for mappings, commands... as protected functions from autoload plugins. They as thus lazily loaded when the mapping/command... is executed (=> no impact on vim startup time), and reloading the vim script file, will permit to update the function definition.

I will seldom define functions in my .vimrc, in plugin/ files, ftplugin/ files... It may still happen when I'm testing small things.

Function signature

bang -> :function!

When I'm maintening vimscripts, I reload my files a lot. Really. As a consequence, my functions are always defined banged. Even if they may be overriden from other plugins...

abort

A long time ago, functions were continuing their execution even if a error has been detected. On the contrary, what we want 99% of the time, is for the execution to stop as soon an an error is encountered. As introducing changes in behaviour is always source of regression. It's been made up to us to be explicit about what we really want:

we need to append abort at the end of function definitions.


Regarding debugging and maintenance

Softwares have bugs, and neither lh-vim-lib nor my other plugins are exempted.

Various techniques exists, and I'm using them in my plugins. These techniques have consequences on the code of my plugins, and on features I've ended up defining in lh-vim-lib.

Let's have a quick tour.

Debugging

Vim provides a debugger for its Vim script language. It is started with :h :debug. And from here we can display expressions with :echo, >step into a command, execute the >next command and do a few more things.

This debugger has the merit to exist, but it's definitively not ergonomic. Indeed once a debugging session has started we can no longer operate Vim as a text editor and browse the buffers it would display the rest of the time. To add a breakpoint, we will have to remember where (or to open the relevant files in another process). Vim kernel would need to be rewritten in order to have a non blocking/modal debug mode.

I'm aware of Alberto Fanjul's vim-breakpts project that tries to work around this limitation (by running two instances of Vim, IIRC)

Debugging loops

Debugging loops is one of the things that annoys me the most when debugging vim scripts. We have to do >next several times, check manually the thing that changes (variant/index/element) at each iteration (as there is no possible >watch because of the design of Vim kernel), and there is no >finish-the-loop, only a >finish-the-function.

Of course we could have breakpoints but... I don't want to open the file in another window to see where it must go. By the way, there are no conditional breakpoints.

As a workaround I avoid loops and instead I try to use map() and filter() as much as possible. Even when I'm looking for the first element that matches a predicate I filter the whole list and... it's not even slower but much faster that manual loops -- because loops are interpreted while list functions are coded in C.

BTW, there are also many list-related functions that I miss and thus I emulate them in lh-vim-lib. Sometimes they are added later in Vim like the recent (as far as I'm concerned) reduce().

Code instrumentation (Logs and global variables)

One of the oldest alternative approach to debug consist in instrumenting our source code to observe what happens -- I guess this is even much older than debuggers.

Two approaches mainly.

Variables

We can store local-variables into global-variables

let g:foobar = foobar

This is quite efficient to store complex things like lists or dictionaries just before a crash. It's more complex to follow the code flow with them.

Logs

Logs can start with a single :echomsg. In the past I was even playing with confirm() dialog box to have the time to read the message.

That does the job. Dr Charles Campbell even defines a Decho command to display logs.

But I needed more.

  • I needed first to not spend my time commenting and uncommenting log instructions depending on whether I needed them or not. In particular I did not want such changes to parasite my commits.
  • And I also needed to see where the log was happening, and why not display the log in the quickfix-window it already has everything to navigate between its entries.

Thanks to a (slow) hack I found, I was able to retrieve the current callstack and thus to display logs in the quickfix window with the exact reference of the calling line (and even the calling functions!).

The result is my logging framework. Given my needs, the fact that I don't really need logging levels but just errors that stops, warnings that are notified, debug logs, and information notification messages, the framework ended up minimalist. Logs are always logged (in qf-window or loclist-windows, or as :messages), and it's up to each vim files to control whether it logs or not. As such all my autoload plugin files have the same first lines that help control their verbosity level. I can activate logs in one file and not the other. e.g. :call lh#path#verbose(1) (or :Verbose pa<tab> thanks to a small miscellaneous plugin I have)

Warnings

There is some room for something in between silent debug logs, and errors that stop the execution. Warnings. They make sense when we don't intend to stop violently the execution, but yet report that something is fishy, if not plain wrong.

Most of the time printing in a different colour what happened is a good start. lh#common#warning_msg() does that.

Sometimes, we still smell that some investigation is required and yet this isn't a full programming error (see next section on design by contract). That's why I've introduced lh#warning#emit(message), to emit a new warning, and :Warnings to display the last warnings in their context. In other words, lh#warning#emit() records the callstack at the point of emission, and :Warning displays the messages and the full callstack in the quickfix-window.

Design by Contract

There is a lot to say about Designing by Contract, and I've already said a lot, but in French, and for C++.

To make it short, it's about specifying contracts on functions, classes, points in the program... And when a contract is not respected this means we have a design error or a programming error.

Often I write functions and consider they are not meant to be called with a certain context (parameter state, buffer state...). Because I don't care for this extra situation, and think it should absolutely never happen. Because it would be too complex to handle. And so on.

I put a contract on a function, on a line in the program: I assert a given state and I know that if that state isn't verified, there is, or there will be, an error. Continuing makes absolutely no sense. At best we can abort. We can fail fast.

My take on the subject is that recovering from errors is hardly possible. Sometimes this means some internal state is completely corrupted. Sometimes the only solution we have is to get rid of a buffer (with :bw) or even restart Vim.

So, I prefer to be aggressive with my assertions. I like to fail fast with as much as context as possible in order to be able to investigate and fix the error.

That's how I ended up defining my DbC framework. And thus I use assertions in my vim scripts in critical places in case I need investigating in the future.

Or course I could instead throw an exception and investigate its stack with :WTF. But, with exceptions I won't have any access to the full program state (like local variables). With my assertion framework I have the choice between ignore-and-continue, abort, and start-the-debugger when an error is detected.

I reserve exceptions for exceptional but plausible situations. Not for aborting when a plugin is in a corrupted or unexpected state.

Last thing, DbC assertions are perfect for preconditions but definitively not the best tools for post-conditions. Unit tests are much better for post-conditions.

Unit Testing

I also have my own solution for unit-testing. An old one.

Its primary design goal was to see the assertion failures appear in the quickfix-window, and also to be able to see the callstack of uncaught exceptions.

A few version back Vim introduced assertion functions. Since Vim 8.2.1297 we can also access directly to the callstack.

I'll continue with my framework as it works well, and it answers my first need: filling the quickfix-window.

:WTF

Thanks to :WTF I have a nice tool to analyse error messages and fill the quickfix-window with the error call stack.

In fair honesty, this feature is directly inspired from https://github.com/tweekmonster/exception.vim I've ported it to lh-vim-lib has I had already the required tools to analyse the error messages (as they are in the same format as v:throwpoint). Beside, I handle i18n issues which the original plugin did not.

Also, as I try to avoid defining too many commands, mappings... in lh-vim-lib, :WTF isn't not defined. It's up to us to bind lh#exception#say_what() to whatever we want in our .vimrc.

Plugin reloading

TBC


Regarding OO

I delved into the subject in another document: Object Oriented Programming in vim scripts.


Regarding dependencies

This is an edited copy of an answer I wrote on vi.SE.

As you likely have noticed my plugins have dependencies, and lh-vim-lib is the central one they all depend upon.

When we want to reuse a function between unrelated plugins, we have a few different approaches available.

1. Standalone plugins

This is the dominant approach. Code from other plugins is copied.

pro:

  • The end-user won't have to install several plugins;
  • It's the friendliest approach with the plugin managers everybody use;
  • We perfectly control the version of the dependency used.

cons:

  • Maintainability is catastrophic: you won't profit from bug fixes, performance improvements, or even added features;
  • If you're serious about licences, this could get ugly if we start mixing codes with different licences in the same file.

2. Plugins that depend on other plugins

Very few plugins follow this approach. End-users have to install the plugins we depend upon. Dare I say this is the most professional one.

pro:

  • Maintainability -- it's the exact opposite of the approach 1;
  • Copyright: it's easier to depend on plugins with different licences without having to use a licence different from the one we would have chosen, or to mix licences within a same plugin, or to violate original licences by changing it without the initial author knowledge/explicit authorization.

cons

  • Installing a plugin that depends on others may become very complicated without assistance: see lh-cpp requirements for instance. Without VAM or vim-flavor, this is a nightmare;
  • Very few people use plugin managers that understand dependencies => this is a nightmare for maintainers to track dependencies (what if a plugin we depend upon introduce a new dependency?), and for end-users to know exactly what is required by each plugin, and to know when a plugin introduce a new dependency...;
  • If we depend on a specific version of a plugin, this could get ugly -- see the dependencies issues in Ruby or Python world. vim-flavor helps a little here.

3. Submodules

We could also introduce our dependencies as submodules.

pro

  • Maintainability and Copyright: as with previous solution, we share something that is maintained elsewhere;
  • Installation could almost become transparent whatever plugin manager is used -- if we ignore the fact the new submodule may not be correctly registered in vim 'runtimepath' option;
  • Specifying the required version would be quite easy.

cons

  • A same plugin may be installed several times. As Vim provides no way (yet?) to isolate plugins we could observe some quirky situations. Just for mu-template we would have

    ```
    mu-template/
    +-> lh-vim-lib/
    +-> lh-brackets/
        +-> lh-vim-lib/
    +-> lh-style/
        +-> lh-vim-lib/
        +-> editorconfig-vim/
    ```
    

    where lh-vim-lib would appear 3 times in runtimepath. Hopefully every plugin depends on the same version...

My choice on the subject

I'm maintaining something like almost 20 different plugins. A long time ago after playing with duplicated functions, I've eventually chosen to define this plugin library that other plugins depend upon. This library contains a lot of things. I definitively don't regret to have made this choice. Thanks to that I've a efficient solution to debug and log what happens in my plugins, many list related functions that should have been defined in Vim, and so on. And I don't maintain it several times, but only once. When I've added DbC for lh-tags, I've been able to use it immediately in build-tools-wrappers, where I've introduced new assertions that were available in lh-tags without having to synchronize any file.

Regarding installation, every time somebody asks about plugin managers, I explain why I prefer VAM or vim-flavor: these tools have understood the importance of dependencies. Nobody would use a yum/dnf/apt-get/pip that don't handle dependencies, and yet this is what most people do in vim world. As trendy plugin managers don't understand dependencies, plugins avoid to have dependencies, as thus plugin managers don't feel the need to support dependencies, and so on. This is a vicious circle.

For plugin maintainers, the real question is to find the trade-off between the burden we will impose on our end-users and the burden we are ready to accept to maintain our plugins.

I've chosen to not repeat myself and to build more complex solutions by stacking layers of thematic and independent features -- which is far from being an easy feat.