haskell-language-server icon indicating copy to clipboard operation
haskell-language-server copied to clipboard

Invalid snippet syntax causes failures in neovim text editor

Open rmullin7286 opened this issue 1 year ago • 1 comments

Your environment

Which OS do you use?

MacOS and Arch Linux, the issue appears on both.

Which version of GHC do you use and how did you install it?

GHC 9.10.1 using ghcup

How is your project built (alternative: link to the project)? My main reproduction case is a simple haskell script with embedded cabal metadata:

#!/usr/bin/env cabal
{- cabal:
    build-depends: turtle, base
-}

main :: IO ()
main = putStrLn "foo"

However, the issue also appears in full cabal projects.

Which LSP client (editor/plugin) do you use?

neovim 0.10.0 using the haskell-tools plugin, lsp-config and nvim-cmp. My configuration can be found here.

Which version of HLS do you use and how did you install it?

HLS 2.9.0.1 using ghcup

Have you configured HLS in any way (especially: a hie.yaml file)? No

Steps to reproduce

  1. Create a new file, test.hs
  2. Start typing the code provided in the above example.

Expected behaviour

Autocompletion should work correctly

Actual behaviour

At some point, every time a character is typed, the following error message appears:

Error executing vim.schedule lua callback: ...local/share/nvim/lazy/nvim-cmp/lua/cmp/utils/snippet.lua:422
: snippet parsing failed
stack traceback:
        [C]: in function 'error'
        ...local/share/nvim/lazy/nvim-cmp/lua/cmp/utils/snippet.lua:422: in function 'parse'
        ...s/ryan/.local/share/nvim/lazy/nvim-cmp/lua/cmp/entry.lua:130: in function 'callback'
        .../.local/share/nvim/lazy/nvim-cmp/lua/cmp/utils/cache.lua:38: in function 'get_word'
        ...s/ryan/.local/share/nvim/lazy/nvim-cmp/lua/cmp/entry.lua:81: in function 'callback'
        .../.local/share/nvim/lazy/nvim-cmp/lua/cmp/utils/cache.lua:38: in function 'get_offset'
        .../ryan/.local/share/nvim/lazy/nvim-cmp/lua/cmp/source.lua:353: in function ''
        vim/_editor.lua: in function <vim/_editor.lua:0>

Debug information

I've spent a bit of time diving into this issue and I think I understand what the root cause is. There's a few components at work:

  1. nvim-cmp has recently updated their codebase to perform validation on code snippets: see this commit
  2. I modified the error message of nvim-cmp to see what the snippet was that it was failing to parse. The snippet it showed was $!
  3. Given that $! is a standard operator in Haskell, I began to suspect that the issue is actually coming from HLS
  4. Sure enough, looking at the debug logs for the lsp, I see this, in the response from method "textDocument/completion". I've only copied the relevant bit:
 itemName = { "GHC.Internal.Base", "ghc-internal", "v", "$!" },            itemNeedsType = true          }        },        
detail = "from Prelude",        documentation = {          kind = "markdown",          value = "*Imported from 'Prelude'*\n"        },
 insertText = "$!",        insertTextFormat = 2,        kind = 3,        label = "$!",        sortText = "01"      }, {        data = { 
resolvePlugin = "ghcide-completions",          resolveURI = "file:///Users/ryan/test3.hs",          resolveValue = {            itemFile =
"file:///Users/ryan/test3.hs",            itemName = { "GHC.Internal.Base", "ghc-internal", "v", "." },            itemNeedsType = true          }
},        detail = "from Prelude",        documentation = {          kind = "markdown",          value = "*Imported from 'Prelude'*\n"        }, 

So HLS is responding with a completion item of kind Snippet, and with an insertText of "$!". 5. Here's the documentation for the snippet syntax specification from vscode, which nvim-cmp also uses. Specifically, the section describing how $ characters must be escaped using \, otherwise they will be interpreted as a placeholder variable: link. 6. Looking at the HLS source code, specifically ghcide/src/Development/IDE/Plugin/Completions/Logic.hs line 210:

  let ci = CompletionItem
                 {_label = label,
                  _kind = kind,
                  _tags = Nothing,
                  _detail =
                      case (typeText, provenance) of
                          (Just t,_) | not(T.null t) -> Just $ ":: " <> t
                          (_, ImportedFrom mod)      -> Just $ "from " <> mod
                          (_, DefinedIn mod)         -> Just $ "from " <> mod
                          _                          -> Nothing,
                  _documentation = documentation,
                  _deprecated = Nothing,
                  _preselect = Nothing,
                  _sortText = Nothing,
                  _filterText = Nothing,
                  _insertText = Just insertText,
                  _insertTextFormat = Just InsertTextFormat_Snippet,
                  _insertTextMode = Nothing,
                  _textEdit = Nothing,
                  _additionalTextEdits = Nothing,
                  _commitCharacters = Nothing,
                  _command = mbCommand,
                  _data_ = toJSON <$> fmap (CompletionResolveData uri (isNothing typeText)) nameDetails,
                  _labelDetails = Nothing,
                  _textEditText = Nothing}

It appears that the language server hardcodes every response to be of kind Snippet, and does not use PlainText responses, except in the case that snippets are disabled or the symbol being types is infix (e.g. foo `on` bar).

  1. All put together, since $ is a valid character in Haskell but is not handled or escaped by HLS, it responds with invalid snippets, and nvim-cmp correctly fails.

There's two potential solutions here:

  1. Continue returning all responses as snippets, but ensure that all $ characters outside of actual snippet placeholders are escaped as \$
  2. Tag non-snippet items as _insertTextFormat = InsertTextFormat_PlainText. Save InsertTextFormat_Snippet for actual snippet items.

Currently the only workaround is to disable snippets in the language server, or role back to a version of nvim-cmp that doesn't perform validation, otherwise the functionality will be broken. The following code in init.lua should work as a temporary fix:

vim.g.haskell_tools = {
    hls = {
        settings = {
            plugin = {
                ['ghcide-completions'] = {
                    config = {
                        snippetsOn = false,
                        autoExtendOn = true
                    }
                }
            }
        }
    }
}

rmullin7286 avatar Jul 17 '24 02:07 rmullin7286

Hi, thank you for your bug report!

An even bigger thank you for this awesome analysis! Since you have identified the issue so accurately, would you be up for fixing this issue? If you get stuck, feel free to ping me here or on matrix.

Personally, I think we have to escape $ when the CompletionItem is a Snippet. So (1) is a must-have either way. Your suggestion (2) is a nice cherry-on-top afaict, which I am not opposed to.

fendor avatar Aug 06 '24 15:08 fendor

I get a similar error when I type a . following a qualifier, although that does not seem to clash with anything in the snippet syntax.

Soupstraw avatar Oct 23 '25 07:10 Soupstraw

Oh, and worth noting that this happens when I qualify import Graphics.Rendering.OpenGL qualified as GL and start typing GL.. This is probably because that module re-exports the $= operator from StateVar, which triggers the same bug. It does not seem to happen with most other modules.

Soupstraw avatar Oct 23 '25 08:10 Soupstraw