Some things a bit better (formatting, linting, completion)

Hey all,

So, to cut to the chase:

  1. Lint and Format only on save always

    I don’t really know how linting in Kakoune works, but my first assumption is that once linting is enabled in a buffer, it’s always on.
    I personally don’t want that. However, I since I don’t really know, I don’t know if this configuration is redundant or not.

     define-command disable-lint -docstring "disable linting" %{
         lint-disabled
         unset-option buffer lintcmd
         remove-hooks buffer lint
     }
    
     hook global BufWritePost filetype=(sh|fish|nim) %{
         # Only lint on write (save) 
         lint-enabled
         lint
         lint-disabled
     }
    
     hook global BufSetOption filetype=sh %{
         disable-lint
         set-option buffer lintcmd 'shellcheck --color=auto --format=gcc --norc'
     }
    
     hook global BufSetOption filetype=fish %{
         disable-lint
         set-option buffer lintcmd 'fish -n'
     }
    
     hook global BufSetOption filetype=nim %{
         disable-lint
         set-option buffer lintcmd 'nim check'
     }
    

    And the same would go for formatting, but I haven’t set that up yet.

  2. Tab completion

    I’m sure a lot of us have this snippet in our configs somewhere

     hook global InsertCompletionShow .* %{
         try %{
                 map window insert <tab>   <c-n>
                 map window insert <s-tab> <c-p>
         }
     }
    
     hook global InsertCompletionHide .* %{
         unmap window insert <tab> <c-n>
         unmap window insert <s-tab> <c-p>
     }
    

    And this works fine, mostly. One weird thing (well I’m sure it’s not actually once someone explains it), is that <tab> sometimes won’t do anything. It won’t indent the line when first entering insert mode and pressing it, but it will after you’ve entered in a letter and then gotten ride of that letter. So you press i, then you press say a, then you backspace that a, and now <tab> will indent the line.

    I don’t know if there’s a better solution.

  3. More completion! MORE

    The base completing is good, but I wish we had

    1. Tag base completion
    2. Spelling(?) completion
    3. Some sorta omnicompletion (though I don’t fully know exactly what omnicompletion is)

    In Vim, you can have

    set complete+=t,k,kspell
    set omnifunc=syntaxcomplete#Complete
    

    And you get some extra completions that are useful. I don’t think there’s a way to get this in Kakoune, but a lot of you know more about the internals of Kakoune than I.

EDIT: Oh! If an LSP does linting, how does Kakoune and kak-lsp handle that? And do I need to do anything specific?

When you run :lint, Kakoune runs your chosen linting tool on the current buffer, and pipes the output into the *lint* buffer.

If you don’t want to have to check a separate buffer to view lint output every time, :lint-enable will display the results of the last :lint inside the current buffer, by displaying a marker to the left of lines with problems, and displaying the associated warning message in a pop-up when the cursor is on that line.

“…once linting is enabled in a buffer, it’s always on” is true if you mean “Kakoune will keep showing the results of the previous lint even if they no longer apply to the current buffer state”. If you mean “Kakoune will keep re-running the linter after every keypress”, then no: you have to run :lint directly, or via a hook, in order to get updated lint results.

Likewise, if you want formatting, you need to run :format. There is “autoformat” support, but it’s just about wrapping prose at a particular column, not about invoking code formatting tools.

If you mean “completing keywords listed in a ctags file”, that sounds pretty neat. I don’t think it’s available by default, but I imagine it wouldn’t be too difficult to write a plugin to do it.

I have the following fragment in my kakrc to add /usr/dict/words to the list of words Kakoune can complete:

# Add English words to the set of available completions.
evaluate-commands set-option window static_words %sh{
    sed -e "s/'/''/; s/.*/'&'/" /usr/share/dict/words |
    tr '\n' ' '
}

In practice, I find the results are… not great. There’s 654 thousand words in my /usr/share/dict/words, and although it’s still pretty fast in absolute terms, on my slower computers Kakoune begins to get noticeably sluggish. Also, Kakoune’s completion engine orders completion results exclusively by comparing each completion to what you’ve typed already. without considering the likelihood of any given completion. So, if I want to type “snowing”, and I start by typing “snow”, Kakoune gives me the completions: “Snow”, “snow’s”, “snowball”, “snowball’s”, “snowballed”, “snowballing”, “snowballs”, “snowbank”, “snowbank’s”, “snowbanks”, and then I’d have to scroll to see more. If I try to hint it toward the right word by adding a “g”, I get: “snowie”, “snowier”, “snowiest”, “snowily”, “snowiness” “snowiness’s”, “snowinesses”, and finally “snowing”.

Basically, I find it’s easier just to type the word I want completely by myself than to use completion.

“Omnicompletion” is Vim’s term for “completions generated by a plugin, rather than hard-coded into Vim”. In that sense, almost all of Kakoune’s completions are omni-completions.

:lsp-diagnostics is like :lint, where it gathers errors and warnings and sticks them into a separate buffer for you to browse.

By default (i.e. if you just run lsp-enable without any further configuration), kak-lsp re-lints on every keypress (because that’s how language servers are designed to work), and displays the results in a column on the left, like :lint-enable.

Ah well I have this

hook global BufWritePost filetype=(sh|fish|nim) %{
    # Only lint on write (save) 
    lint-enabled
    lint
    lint-disabled
}

is the lint-disabled redundant? Or will it just cause problems?

That’s exactly equivalent to:

hook global BufWritePost filetype=(sh|fish|nim) %{
    # Only lint on write (save) 
    lint
}

If you :lint-enable and then :lint-disable before Kakoune has even had a chance to repaint the screen, it has no effect at all other than wasting a bit of electricity.

So here’s what I have now, and in say a fish-shell file I’m getting the error that the lintcmd option is not set.

# === Linting & Formatting ===

define-command disable-lint -docstring "disable linting" %{
    unset-option buffer lintcmd
    remove-hooks buffer lint
}

define-command disable-fmt -docstring "disable formatting" %{
    unset-option buffer formatcmd
    remove-hooks buffer format
}

hook global WinSetOption .* %{
    disable-lint
    disable-fmt
}

hook global WinSetOption filetype=sh %{
    set-option buffer lintcmd 'shellcheck --color=auto --format=gcc --norc'
    lint-enable
    hook buffer -group lint BufWritePost .* lint
    format-enable
    set-option buffer formatcmd "sleep 1.5; shfmt -i 4"
    hook buffer -group format BufWritePost .* format
}

hook global WinSetOption filetype=fish %{
    set-option buffer lintcmd 'fish -n'
    lint-enable
    hook buffer -group lint BufWritePost .* lint
}

hook global WinSetOption filetype=nim %{
    set-option buffer lintcmd 'nim check'
    hook buffer -group lint BufWritePost .* lint
}

hook global WinSetOption filetype=elixir %{
    set-option buffer lintcmd 'mix credo'
    hook buffer -group lint BufWritePost .* lint
}

hook global WinSetOption filetype=(javascript|typescript|css|scss|json|markdown|yaml|html) %{
    format-enable
    set-option buffer formatcmd "sleep 1.5; prettier --stdin-filepath=%val{buffile}"
    hook buffer -group format BufWritePost .* format
}

I think I’m missing something.

I don’t have time to dig into that code in detail, but the first thing that jumps out at me is:

hook global WinSetOption .* %{
    disable-lint
    disable-fmt
}

This hook triggers whenever any option changes at Window scope for any reason, whether it’s lint-related, format-related, or completely unrelated. In fact, the filetype=sh hook sets lintcmd, which will probably trigger the above hook and immediately call disable-lint, which probably isn’t what you want.

Another thing that jumps out at me is that all the hooks are WinSetOption (i.e. when an option changes at window scope) but all the options are actually set and unset at buffer scope. I’m not sure that’s actually the cause of any of the problems you’re experiencing, but I’m sure it would make things more difficult to debug: for example, if an option is set at window scope, then setting and unsetting the option at buffer scope will never trigger the hook, because the value visible at window scope never changes.

Lastly, I see you invoke the :format command in BufWritePost, which means the unformatted code will be saved to disk, and then formatting will be done, requiring you to :w a second time to actually save the formatted code. Normally, formatting is done in BufWritePre to avoid that second step.

Mmm I see what you mean. Getting rid of that line fixes it.

My only questions is how do I exclude a filetype? I forgot shellcheck doesn’t work on zsh files.

Or what might be better is change my lint command on the zsh filetype (it would just require adding --shell=bash).

As far as Kakoune is concerned, zsh files are sh files - or at least, when I edit a file called .zshrc, it sets filetype=sh. file --mime-type also reports files that start with #!/bin/zsh as text/x-shellscript, just like any other file.

The hook that sets lintcmd should probably try to guess which variant shell the file expects (say, from a file-extension or hashbang) and set lintcmd appropriately.

It’s not really Kakoune that’s this issue. Kakoune does correctly say that zsh files are under the sh filetype, but shellcheck sees the shebang at the top of the file and says “nah, can’t do that”.

How would I go about setting lintcmd depending on filetype/extension? Maybe somewhat like sh.kak does?

if ($buf_filetype == zsh) %{
   set-option buffer lintcmd 'cmd1'

} else %{
   set-option buffer lintcmd 'cmd2'
}

I don’t really know all of the variables and such available to me to be able to do something like the above pseudo code.

EDIT:
I do have this, but it doesn’t feel very elegant:

hook global WinSetOption filetype=sh %{
    set-option buffer lintcmd 'shellcheck --color=auto --format=gcc --norc'
    hook global BufCreate .*\.z(sh|shrc|profile|login)? %{
        set-option buffer lintcmd 'shellcheck --shell=bash --color=auto --format=gcc --norc'
    }
    lint-enable
	hook buffer -group lint BufWritePost .* lint
	format-enable
	set-option buffer formatcmd "sleep 1.5; shfmt -i 4"
    hook buffer -group format BufWritePre .* format
}

You already have code to set lintcmd based on filetype:

hook global WinSetOption filetype=sh %{
    set-option buffer lintcmd 'shellcheck --color=auto --format=gcc --norc'
}

As I mentioned before, if you’re going to hook WinSetOption you should probably do everything at window scope, just for consistency:

hook global WinSetOption filetype=sh %{
    set-option window lintcmd 'shellcheck --color=auto --format=gcc --norc'
    #          ^^^^^^---changed scope to "window"
}

The usual idiom for undoing filetype changes is to put a second one-shot hook inside the first one, like this:

hook global WinSetOption filetype=sh %{
    set-option window lintcmd 'shellcheck --color=auto --format=gcc --norc'

    hook -once window WinSetOption filetype=.* %{
        unset-option window lintcmd
    }
}

This second, inner hook is only installed once the first hook executes, and the second hook only executes if the filetype option is set to anything other that sh (if it were set to sh, it wouldn’t be a change and therefore the hook wouldn’t fire). Because the second hook uses the -once option, it immediately removes itself after firing, so it won’t mess up anything loaded by future filetype hooks.

When it comes to setting different lintcmd values depending on the file extension, that’s not something that can be done directly in Kakoune script, so we have to write a fragment of shell-script to generate the correct commands. To do this, we’ll use Kakoune’s shell-expansion (which turns the output of a shell-script into a string) and the evaluate-commands command, which executes the individual commands in a string. Here’s a very simple example of how this works:

evaluate-commands %sh{
    printf "%s\n" "edit $HOME/.config/kak/kakrc"
}

printf is a shell-command that takes a pattern, and one or more strings to substitute into that pattern. In this case, we’re using it as a portable way to display a string followed by a newline. When the above code executes, the following things happen:

  • The shell replaces $HOME with the contents of the HOME environment variable, then passes the result to printf
  • printf does the formatting (appends a newline) and writes the result to stdout
  • Kakoune replaces the %sh{} block with the string it reads from the shell-script’s stdout, then passes the result to evaluate-commands
  • evaluate-commands breaks the string into individual commands and executes them one-by-one

To solve your particular problem, the shell-script will need to examine the filename of the current buffer. Luckily, that information is available to shell-scripts in the $kak_buffile variable (there’s lots of other useful values you can use, see :doc expansions for more information). The result might look something like this:

hook global WinSetOption filetype=sh %{
    evaluate-commands %sh{
        shell_flag=""
        case "$(basename "$kak_buffile")" in
            *.zsh|.zshrc|.zprofile|.zlogin)
                shell_flag="--shell=bash"
                ;;
        esac

        printf "%s\n" "set-option window lintcmd 'shell-check $shell_flag --color=auto --format=gcc --norc"
    }

    hook -once window WinSetOption filetype=.* %{
        unset-option window lintcmd
    }
}

Note that the pattern in case/esac is a pipe-separated list of shell globs, not a regex, so it’s not quite as compact as your example.

1 Like