haskell / haskell-language-server

Official haskell ide support via language server (LSP). Successor of ghcide & haskell-ide-engine.
Apache License 2.0
2.71k stars 368 forks source link

Invalid snippet syntax causes failures in neovim text editor #4363

Open rmullin7286 opened 3 months ago

rmullin7286 commented 3 months ago

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
                    }
                }
            }
        }
    }
}
fendor commented 2 months ago

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.