1

Regenerate nvim config

This commit is contained in:
2024-06-02 03:29:20 +02:00
parent 75eea0c030
commit ef2e28883d
5576 changed files with 604886 additions and 503 deletions

View File

@ -0,0 +1,49 @@
---@diagnostic disable: deprecated, duplicate-doc-field, duplicate-doc-alias
---@mod haskell-tools.compat Functions for backward compatibility with older Neovim versions
---@brief [[
---WARNING: This is not part of the public API.
---Breaking changes to this module will not be reflected in the semantic versioning of this plugin.
---@brief ]]
local compat = {}
compat.joinpath = vim.fs.joinpath or function(...)
return (table.concat({ ... }, '/'):gsub('//+', '/'))
end
compat.get_clients = vim.lsp.get_clients or vim.lsp.get_active_clients
compat.uv = vim.uv or vim.loop
--- @class vim.SystemCompleted
--- @field code integer
--- @field signal integer
--- @field stdout? string
--- @field stderr? string
--- @alias lsp.Client vim.lsp.Client
compat.system = vim.system
-- wrapper around vim.fn.system to give it a similar API to vim.system
or function(cmd, _, on_exit)
local output = vim.fn.system(cmd)
local ok = vim.v.shell_error
---@type vim.SystemCompleted
local systemObj = {
signal = 0,
stdout = ok and (output or '') or nil,
stderr = not ok and (output or '') or nil,
code = vim.v.shell_error,
}
on_exit(systemObj)
return systemObj
end
---@type fun(tbl:table):table
compat.tbl_flatten = vim.iter and function(tbl)
return vim.iter(tbl):flatten(math.huge):totable()
end or vim.tbl_flatten
return compat

View File

@ -0,0 +1,202 @@
---@mod haskell-tools.config.check haskell-tools configuration check
---@brief [[
---WARNING: This is not part of the public API.
---Breaking changes to this module will not be reflected in the semantic versioning of this plugin.
---@brief ]]
---@class HtConfigCheck
local HtConfigCheck = {}
---@param path string
---@param msg string|nil
---@return string
local function mk_error_msg(path, msg)
return msg and path .. '.' .. msg or path
end
---@param path string The config path
---@param tbl table The table to validate
---@see vim.validate
---@return boolean is_valid
---@return string|nil error_message
local function validate(path, tbl)
local prefix = 'Invalid config: '
local ok, err = pcall(vim.validate, tbl)
return ok or false, prefix .. mk_error_msg(path, err)
end
---Validates the config.
---@param cfg HTConfig
---@return boolean is_valid
---@return string|nil error_message
function HtConfigCheck.validate(cfg)
local ok, err
local hls = cfg.hls
ok, err = validate('hls', {
auto_attach = { hls.auto_attach, { 'boolean', 'function' } },
capabilities = { hls.capabilities, 'table' },
cmd = { hls.cmd, { 'table', 'function' } },
debug = { hls.debug, 'boolean' },
default_settings = { hls.default_settings, 'table' },
on_attach = { hls.on_attach, 'function' },
settings = { hls.settings, { 'function', 'table' } },
})
if not ok then
return false, err
end
local tools = cfg.tools
local codeLens = tools.codeLens
ok, err = validate('tools.codeLens', {
autoRefresh = { codeLens.autoRefresh, { 'boolean', 'function' } },
})
if not ok then
return false, err
end
local definition = tools.definition
ok, err = validate('tools.definition', {
hoogle_signature_fallback = { definition.hoogle_signature_fallback, { 'boolean', 'function' } },
})
if not ok then
return false, err
end
local hoogle = tools.hoogle
local valid_modes = { 'auto', 'telescope-local', 'telescope-web', 'browser' }
ok, err = validate('tools.hoogle', {
mode = {
hoogle.mode,
function(mode)
return vim.tbl_contains(valid_modes, mode)
end,
'one of ' .. vim.inspect(valid_modes),
},
})
if not ok then
return false, err
end
local hover = tools.hover
ok, err = validate('tools.hover', {
auto_focus = { hover.auto_focus, 'boolean', true },
border = { hover.border, 'table', true },
enable = { hover.enable, { 'boolean', 'function' } },
stylize_markdown = { hover.stylize_markdown, 'boolean' },
})
if not ok then
return false, err
end
local log = tools.log
ok, err = validate('tools.log', {
log = { log.level, { 'number', 'string' } },
})
if not ok then
return false, err
end
local repl = tools.repl
local valid_handlers = { 'builtin', 'toggleterm' }
local valid_backends = { 'cabal', 'stack' }
ok, err = validate('tools.repl', {
auto_focus = { repl.auto_focus, 'boolean', true },
builtin = { repl.builtin, 'table' },
handler = {
repl.handler,
function(handler)
return type(handler) == 'function' or vim.tbl_contains(valid_handlers, handler)
end,
'one of ' .. vim.inspect(valid_handlers),
},
prefer = {
repl.prefer,
function(backend)
return type(backend) == 'function' or vim.tbl_contains(valid_backends, backend)
end,
'one of ' .. vim.inspect(valid_backends),
},
})
if not ok then
return false, err
end
local builtin = repl.builtin
ok, err = validate('tools.repl.builtin', {
create_repl_window = { builtin.create_repl_window, 'function' },
})
if not ok then
return false, err
end
local tags = tools.tags
ok, err = validate('tools.tags', {
enable = { tags.enable, { 'boolean', 'function' } },
package_events = { tags.package_events, 'table' },
})
if not ok then
return false, err
end
local dap = cfg.dap
local valid_dap_log_levels = { 'Debug', 'Info', 'Warning', 'Error' }
ok, err = validate('dap', {
auto_discover = { dap.auto_discover, { 'boolean', 'table' } },
cmd = { dap.cmd, { 'function', 'table' } },
logFile = { dap.logFile, 'string' },
logLevel = {
dap.logLevel,
function(level)
return type(level) == 'string' and vim.tbl_contains(valid_dap_log_levels, level)
end,
'one of ' .. vim.inspect(valid_backends),
},
})
if not ok then
return false, err
end
local auto_discover = dap.auto_discover
if type(auto_discover) == 'table' then
---@cast auto_discover AddDapConfigOpts
ok, err = validate('dap.auto_discover', {
autodetect = { auto_discover.autodetect, 'boolean' },
settings_file_pattern = { auto_discover.settings_file_pattern, 'string' },
})
if not ok then
return false, err
end
end
return true
end
---Recursively check a table for unrecognized keys,
---using a default table as a reference
---@param tbl table
---@param default_tbl table
---@param ignored_keys string[]
---@return string[]
function HtConfigCheck.get_unrecognized_keys(tbl, default_tbl, ignored_keys)
local unrecognized_keys = {}
for k, _ in pairs(tbl) do
unrecognized_keys[k] = true
end
for k, _ in pairs(default_tbl) do
unrecognized_keys[k] = false
end
local ret = {}
for k, _ in pairs(unrecognized_keys) do
if unrecognized_keys[k] then
ret[k] = k
end
if type(default_tbl[k]) == 'table' and tbl[k] then
for _, subk in pairs(HtConfigCheck.get_unrecognized_keys(tbl[k], default_tbl[k], {})) do
local key = k .. '.' .. subk
ret[key] = key
end
end
end
for k, _ in pairs(ret) do
for _, ignore in pairs(ignored_keys) do
if vim.startswith(k, ignore) then
ret[k] = nil
end
end
end
return vim.tbl_keys(ret)
end
return HtConfigCheck

View File

@ -0,0 +1,123 @@
---@mod haskell-tools.config plugin configuration
---
---@brief [[
---To configure haskell-tools.nvim, set the variable `vim.g.haskell_tools`,
---which is a `HTOpts` table, in your neovim configuration.
---
---Example:
--->
------@type HTOpts
---vim.g.haskell_tools = {
--- ---@type ToolsOpts
--- tools = {
--- -- ...
--- },
--- ---@type HaskellLspClientOpts
--- hls = {
--- on_attach = function(client, bufnr)
--- -- Set keybindings, etc. here.
--- end,
--- -- ...
--- },
--- ---@type HTDapOpts
--- dap = {
--- -- ...
--- },
--- }
---<
---
---Note: `vim.g.haskell_tools` can also be a function that returns a 'HTOpts' table.
---
---@brief ]]
local config = {}
---@type (fun():HTOpts) | HTOpts | nil
vim.g.haskell_tools = vim.g.haskell_tools
---@class HTOpts
---@field tools? ToolsOpts haskell-tools module options.
---@field hls? HaskellLspClientOpts haskell-language-server client options.
---@field dap? HTDapOpts debug adapter config for nvim-dap.
---@class ToolsOpts
---@field codeLens? CodeLensOpts LSP codeLens options.
---@field hoogle? HoogleOpts Hoogle type signature search options.
---@field hover? HoverOpts LSP hover options.
---@field definition? DefinitionOpts LSP go-to-definition options.
---@field repl? ReplOpts GHCi repl options.
---@field tags? FastTagsOpts fast-tags module options.
---@field log? HTLogOpts haskell-tools logger options.
---@class CodeLensOpts
---@field autoRefresh? (fun():boolean) | boolean (default: `true`) Whether to auto-refresh code-lenses.
---@class HoogleOpts
---@field mode? HoogleMode Use a telescope with a local hoogle installation or a web backend, or use the browser for hoogle signature search?
---@alias HoogleMode 'auto' | 'telescope-local' | 'telescope-web' | 'browser'
---@class HoverOpts
---@field enable? (fun():boolean) | boolean (default: `true`) Whether to enable haskell-tools hover.
---@field border? string[][] The hover window's border. Set to `nil` to disable.
---@field stylize_markdown? boolean (default: `false`) The builtin LSP client's default behaviour is to stylize markdown. Setting this option to false sets the file type to markdown and enables treesitter syntax highligting for Haskell snippets if nvim-treesitter is installed.
---@field auto_focus? boolean (default: `false`) Whether to automatically switch to the hover window.
---@class DefinitionOpts
---@field hoogle_signature_fallback? (fun():boolean) | boolean (default: `false`) Configure `vim.lsp.definition` to fall back to hoogle search (does not affect `vim.lsp.tagfunc`).
---@class ReplOpts
---@field handler? (fun():ReplHandler) | ReplHandler `'builtin'`: Use the simple builtin repl. `'toggleterm'`: Use akinsho/toggleterm.nvim.
---@field prefer? (fun():repl_backend) | repl_backend Prefer cabal or stack when both stack and cabal project files are present?
---@field builtin? BuiltinReplOpts Configuration for the builtin repl.
---@field auto_focus? boolean Whether to auto-focus the repl on toggle or send. If unset, the handler decides.
---@alias ReplHandler 'builtin' | 'toggleterm'
---@alias repl_backend 'cabal' | 'stack'
---@class BuiltinReplOpts
---@field create_repl_window? (fun(view:ReplView):fun(mk_repl_cmd:mk_repl_cmd_fun)) How to create the repl window. Should return a function that calls one of the `ReplView`'s functions.
---@class ReplView
---@field create_repl_split? fun(opts:ReplViewOpts):mk_repl_cmd_fun Create the REPL in a horizontally split window.
---@field create_repl_vsplit? fun(opts:ReplViewOpts):mk_repl_cmd_fun Create the REPL in a vertically split window.
---@field create_repl_tabnew? fun(opts:ReplViewOpts):mk_repl_cmd_fun Create the REPL in a new tab.
---@field create_repl_cur_win? fun(opts:ReplViewOpts):mk_repl_cmd_fun Create the REPL in the current window.
---@class ReplViewOpts
---@field delete_buffer_on_exit? boolean Whether to delete the buffer when the Repl quits.
---@field size? (fun():number) | number The size of the window or a function that determines it.
---@alias mk_repl_cmd_fun fun():(string[]|nil)
---@class FastTagsOpts
---@field enable? boolean | (fun():boolean) Enabled by default if the `fast-tags` executable is found.
---@field package_events? string[] `autocmd` Events to trigger package tag generation.
---@class HTLogOpts
---@field level? number | string The log level.
---@see vim.log.levels
---@class HaskellLspClientOpts
---@field auto_attach? (fun():boolean) | boolean Whether to automatically attach the LSP client. Defaults to `true` if the haskell-language-server executable is found.
---@field debug? boolean Whether to enable haskell-language-server debug logging.
---@field on_attach? fun(client:number,bufnr:number,ht:HaskellTools) Callback that is invoked when the client attaches to a buffer.
---@field cmd? (fun():string[]) | string[] The command to start haskell-language-server with.
---@field capabilities? lsp.ClientCapabilities LSP client capabilities.
---@field settings? (fun(project_root:string|nil):table) | table The haskell-language-server settings or a function that creates them. To view the default settings, run `haskell-language-server generate-default-config`.
---@field default_settings? table The default haskell-language-server settings that will be used if no settings are specified or detected.
---@field logfile? string The path to the haskell-language-server log file.
---@brief [[
--- To print all options that are available for your haskell-language-server version, run `haskell-language-server-wrapper generate-default-config`
---See: https://haskell-language-server.readthedocs.io/en/latest/configuration.html.
---@brief ]]
---@class HTDapOpts
---@field cmd? string[] The command to start the debug adapter server with.
---@field logFile? string Log file path for detected configurations.
---@field logLevel? HaskellDebugAdapterLogLevel The log level for detected configurations.
---@field auto_discover? boolean | AddDapConfigOpts Set to `false` to disable auto-discovery of launch configurations. `true` uses the default configurations options`.
---@alias HaskellDebugAdapterLogLevel 'Debug' | 'Info' | 'Warning' | 'Error'
return config

View File

@ -0,0 +1,295 @@
---@mod haskell-tools.config.internal
---@brief [[
---WARNING: This is not part of the public API.
---Breaking changes to this module will not be reflected in the semantic versioning of this plugin.
--- The internal configuration.
--- Merges the default config with `vim.g.haskell_tools`.
---@brief ]]
local deps = require('haskell-tools.deps')
local compat = require('haskell-tools.compat')
---@type HTConfig
local HTConfig = {}
local ht_capabilities = vim.lsp.protocol.make_client_capabilities()
local cmp_capabilities = deps.if_available('cmp_nvim_lsp', function(cmp_nvim_lsp)
return cmp_nvim_lsp.default_capabilities()
end, {})
local selection_range_capabilities = deps.if_available('lsp-selection-range', function(lsp_selection_range)
return lsp_selection_range.update_capabilities {}
end, {})
local folding_range_capabilities = deps.if_available('ufo', function(_)
return {
textDocument = {
foldingRange = {
dynamicRegistration = false,
lineFoldingOnly = true,
},
},
}
end, {})
local capabilities = vim.tbl_deep_extend(
'keep',
ht_capabilities,
cmp_capabilities,
selection_range_capabilities,
folding_range_capabilities
)
---@class HTConfig haskell-tools.nvim plugin configuration.
local HTDefaultConfig = {
---@class ToolsConfig haskell-tools module config.
tools = {
---@class CodeLensConfig LSP codeLens options.
codeLens = {
---@type boolean | (fun():boolean) (default: `true`) Whether to auto-refresh code-lenses.
autoRefresh = true,
},
---@class HoogleConfig hoogle type signature search config.
hoogle = {
---@type HoogleMode Use a telescope with a local hoogle installation or a web backend, or use the browser for hoogle signature search?
mode = 'auto',
},
---@class HoverConfig Enhanced LSP hover options.
hover = {
---@type boolean | (fun():boolean) (default: `true`) Whether to enable haskell-tools hover.
enable = true,
---@type string[][] | nil The hover window's border. Set to `nil` to disable.
border = {
{ '', 'FloatBorder' },
{ '', 'FloatBorder' },
{ '', 'FloatBorder' },
{ '', 'FloatBorder' },
{ '', 'FloatBorder' },
{ '', 'FloatBorder' },
{ '', 'FloatBorder' },
{ '', 'FloatBorder' },
},
---@type boolean (default: `false`) The builtin LSP client's default behaviour is to stylize markdown. Setting this option to false sets the file type to markdown and enables treesitter syntax highligting for Haskell snippets if nvim-treesitter is installed.
stylize_markdown = false,
---@type boolean (default: `false`) Whether to automatically switch to the hover window.
auto_focus = false,
},
---@class DefinitionConfig Enhanced LSP go-to-definition options.
definition = {
---@type boolean | (fun():boolean) (default: `false`) Configure `vim.lsp.definition` to fall back to hoogle search (does not affect `vim.lsp.tagfunc`).
hoogle_signature_fallback = false,
},
---@class ReplConfig GHCi repl options.
repl = {
---@type ReplHandler | (fun():ReplHandler) `'builtin'`: Use the simple builtin repl. `'toggleterm'`: Use akinsho/toggleterm.nvim.
handler = 'builtin',
---@type repl_backend | (fun():repl_backend) Prefer cabal or stack when both stack and cabal project files are present?
prefer = function()
return vim.fn.executable('stack') == 1 and 'stack' or 'cabal'
end,
---@class BuiltinReplConfig Configuration for the builtin repl
builtin = {
---@type fun(view:ReplView):fun(mk_repl_cmd:mk_repl_cmd_fun) How to create the repl window. Should return a function that calls one of the `ReplView`'s functions.
create_repl_window = function(view)
return view.create_repl_split { size = vim.o.lines / 3 }
end,
},
---@type boolean | nil Whether to auto-focus the repl on toggle or send. If unset, the handler decides.
auto_focus = nil,
},
---@class FastTagsConfig fast-tags module options.
tags = {
---@type boolean | (fun():boolean) Enabled by default if the `fast-tags` executable is found.
enable = function()
return vim.fn.executable('fast-tags') == 1
end,
---@type string[] `autocmd` Events to trigger package tag generation.
package_events = { 'BufWritePost' },
},
---@class HTLogConfig haskell-tools logger options.
log = {
---@diagnostic disable-next-line: param-type-mismatch
logfile = compat.joinpath(vim.fn.stdpath('log'), 'haskell-tools.log'),
---@type number | string The log level.
---@see vim.log.levels
level = vim.log.levels.WARN,
},
},
---@class HaskellLspClientConfig haskell-language-server client options.
hls = {
---@type boolean | (fun():boolean) Whether to automatically attach the LSP client. Defaults to `true` if the haskell-language-server executable is found.
auto_attach = function()
local Types = require('haskell-tools.types.internal')
local cmd = Types.evaluate(HTConfig.hls.cmd)
---@cast cmd string[]
local hls_bin = cmd[1]
return vim.fn.executable(hls_bin) == 1
end,
---@type boolean Whether to enable haskell-language-server debug logging.
debug = false,
---@type (fun(client:number,bufnr:number,ht:HaskellTools)) Callback that is invoked when the client attaches to a buffer.
---@see vim.lsp.start
on_attach = function(_, _, _) end,
---@type string[] | (fun():string[]) The command to start haskell-language-server with.
---@see vim.lsp.start
cmd = function()
-- Some distributions don't prorvide a hls wrapper.
-- So we check if it exists and fall back to hls if it doesn't
local hls_bin = 'haskell-language-server'
local hls_wrapper_bin = hls_bin .. '-wrapper'
local bin = vim.fn.executable(hls_wrapper_bin) == 1 and hls_wrapper_bin or hls_bin
local cmd = { bin, '--lsp', '--logfile', HTConfig.hls.logfile }
if HTConfig.hls.debug then
table.insert(cmd, '--debug')
end
return cmd
end,
---@type lsp.ClientCapabilities | nil LSP client capabilities.
---@see vim.lsp.protocol.make_client_capabilities
---@see vim.lsp.start
capabilities = capabilities,
---@type table | (fun(project_root:string|nil):table) | nil The haskell-language-server settings or a function that creates them. To view the default settings, run `haskell-language-server generate-default-config`.
settings = function(project_root)
local ht = require('haskell-tools')
return ht.lsp.load_hls_settings(project_root)
end,
---@type table The default haskell-language-server settings that will be used if no settings are specified or detected.
default_settings = {
haskell = {
-- The formatting providers.
formattingProvider = 'fourmolu',
-- Maximum number of completions sent to the LSP client.
maxCompletions = 40,
-- Whether to typecheck the entire project on initial load.
-- Could drive to bad performance in large projects, if set to true.
checkProject = true,
-- When to typecheck reverse dependencies of a file;
-- one of NeverCheck, CheckOnSave (means dependent/parent modules will only be checked when you save),
-- or AlwaysCheck (means re-typechecking them on every change).
checkParents = 'CheckOnSave',
plugin = {
alternateNumberFormat = { globalOn = true },
callHierarchy = { globalOn = true },
changeTypeSignature = { globalOn = true },
class = {
codeActionsOn = true,
codeLensOn = true,
},
eval = {
globalOn = true,
config = {
diff = true,
exception = true,
},
},
excplicitFixity = { globalOn = true },
gadt = { globalOn = true },
['ghcide-code-actions-bindings'] = { globalOn = true },
['ghcide-code-actions-fill-holes'] = { globalOn = true },
['ghcide-code-actions-imports-exports'] = { globalOn = true },
['ghcide-code-actions-type-signatures'] = { globalOn = true },
['ghcide-completions'] = {
globalOn = true,
config = {
autoExtendOn = true,
snippetsOn = true,
},
},
['ghcide-hover-and-symbols'] = {
hoverOn = true,
symbolsOn = true,
},
['ghcide-type-lenses'] = {
globalOn = true,
config = {
mode = 'always',
},
},
haddockComments = { globalOn = true },
hlint = {
codeActionsOn = true,
diagnosticsOn = true,
},
importLens = {
globalOn = true,
codeActionsOn = true,
codeLensOn = true,
},
moduleName = { globalOn = true },
pragmas = {
codeActionsOn = true,
completionOn = true,
},
qualifyImportedNames = { globalOn = true },
refineImports = {
codeActionsOn = true,
codeLensOn = true,
},
rename = {
globalOn = true,
config = { crossModule = true },
},
retrie = { globalOn = true },
splice = { globalOn = true },
tactics = {
codeActionsOn = true,
codeLensOn = true,
config = {
auto_gas = 4,
hole_severity = nil,
max_use_ctor_actions = 5,
proofstate_styling = true,
timeout_duration = 2,
},
hoverOn = true,
},
},
},
},
---@type string The path to the haskell-language-server log file.
logfile = vim.fn.tempname() .. '-haskell-language-server.log',
},
---@class HTDapConfig debug adapter config for nvim-dap.
dap = {
---@type string[] | (fun():string[]) The command to start the debug adapter server with.
cmd = { 'haskell-debug-adapter' },
---@type string Log file path for detected configurations.
logFile = vim.fn.stdpath('data') .. '/haskell-dap.log',
---@type HaskellDebugAdapterLogLevel The log level for detected configurations.
logLevel = 'Warning',
---@type boolean | AddDapConfigOpts Set to `false` to disable auto-discovery of launch configurations. `true` uses the default configurations options`.
auto_discover = true,
},
debug_info = {
---@type boolean
was_g_haskell_tools_sourced = vim.g.haskell_tools ~= nil,
},
}
local haskell_tools = vim.g.haskell_tools or {}
---@type HTOpts
local opts = type(haskell_tools) == 'function' and haskell_tools() or haskell_tools
---@type HTConfig
HTConfig = vim.tbl_deep_extend('force', {}, HTDefaultConfig, opts)
local check = require('haskell-tools.config.check')
local ok, err = check.validate(HTConfig)
if not ok then
vim.notify('haskell-tools: ' .. err, vim.log.levels.ERROR)
end
local dont_warn = {
'tools.repl.auto_focus',
'hls.capabilities',
'hls.settings',
'hls.default_settings',
}
local unrecognized_keys = check.get_unrecognized_keys(opts, HTDefaultConfig, dont_warn)
if #unrecognized_keys > 0 then
vim.notify('unrecognized configs in vim.g.haskell_tools: ' .. vim.inspect(unrecognized_keys), vim.log.levels.WARN)
end
HTConfig.debug_info.unrecognized_keys = unrecognized_keys
return HTConfig

View File

@ -0,0 +1,156 @@
---@mod haskell-tools.dap haskell-tools nvim-dap setup
local deps = require('haskell-tools.deps')
local Types = require('haskell-tools.types.internal')
local compat = require('haskell-tools.compat')
---@param root_dir string
local function get_ghci_dap_cmd(root_dir)
local HtProjectHelpers = require('haskell-tools.project.helpers')
if HtProjectHelpers.is_cabal_project(root_dir) then
return 'cabal exec -- ghci-dap --interactive -i ${workspaceFolder}'
else
return 'stack ghci --test --no-load --no-build --main-is TARGET --ghci-options -fprint-evld-with-show'
end
end
---@param root_dir string
---@param opts AddDapConfigOpts
---@return HsDapLaunchConfiguration[]
local function find_json_configurations(root_dir, opts)
---@type HsDapLaunchConfiguration[]
local configurations = {}
local log = require('haskell-tools.log.internal')
local results = vim.fn.glob(compat.joinpath(root_dir, opts.settings_file_pattern), true, true)
if #results == 0 then
log.info(opts.settings_file_pattern .. ' not found in project root ' .. root_dir)
else
for _, launch_json in pairs(results) do
local OS = require('haskell-tools.os')
local content = OS.read_file(launch_json)
local success, settings = pcall(vim.json.decode, content)
if not success then
local msg = 'Could not decode ' .. launch_json .. '.'
log.warn { msg, error }
elseif settings and settings.configurations and type(settings.configurations) == 'table' then
configurations = vim.list_extend(configurations, settings.configurations)
end
end
end
return configurations
end
---@param root_dir string
---@return HsDapLaunchConfiguration[]
local function detect_launch_configurations(root_dir)
local launch_configurations = {}
local HTConfig = require('haskell-tools.config.internal')
local dap_opts = HTConfig.dap
---@param entry_point HsEntryPoint
---@return HsDapLaunchConfiguration
local function mk_launch_configuration(entry_point)
---@class HsDapLaunchConfiguration
local HsDapLaunchConfiguration = {
type = 'ghc',
request = 'launch',
name = entry_point.package_name .. ':' .. entry_point.exe_name,
workspace = '${workspaceFolder}',
startup = compat.joinpath(entry_point.package_dir, entry_point.source_dir, entry_point.main),
startupFunc = '', -- defaults to 'main' if not set
startupArgs = '',
stopOnEntry = false,
mainArgs = '',
logFile = dap_opts.logFile,
logLevel = dap_opts.logLevel,
ghciEnv = vim.empty_dict(),
ghciPrompt = 'λ: ',
ghciInitialPrompt = 'ghci> ',
ghciCmd = get_ghci_dap_cmd(root_dir),
forceInspect = false,
}
return HsDapLaunchConfiguration
end
local HtProjectHelpers = require('haskell-tools.project.helpers')
for _, entry_point in pairs(HtProjectHelpers.parse_project_entrypoints(root_dir)) do
table.insert(launch_configurations, mk_launch_configuration(entry_point))
end
return launch_configurations
end
---@type table<string, table>
local _configuration_cache = {}
if not deps.has('dap') then
---@type HsDapTools
local NullHsDapTools = {
discover_configurations = function(_) end,
}
return NullHsDapTools
end
local dap = require('dap')
---@class HsDapTools
local HsDapTools = {}
---@class AddDapConfigOpts
local DefaultAutoDapConfigOpts = {
---@type boolean Whether to automatically detect launch configurations for the project.
autodetect = true,
---@type string File name or pattern to search for. Defaults to 'launch.json'.
settings_file_pattern = 'launch.json',
}
---Discover nvim-dap launch configurations for haskell-debug-adapter.
---@param bufnr number|nil The buffer number
---@param opts AddDapConfigOpts|nil
---@return nil
HsDapTools.discover_configurations = function(bufnr, opts)
local HTConfig = require('haskell-tools.config.internal')
local HTDapConfig = HTConfig.dap
local log = require('haskell-tools.log.internal')
local dap_cmd = Types.evaluate(HTDapConfig.cmd) or {}
if #dap_cmd == 0 or vim.fn.executable(dap_cmd[1]) ~= 1 then
log.debug { 'DAP server executable not found.', dap_cmd }
return
end
---@cast dap_cmd string[]
dap.adapters.ghc = {
type = 'executable',
command = table.concat(dap_cmd, ' '),
}
bufnr = bufnr or 0 -- Default to current buffer
opts = vim.tbl_deep_extend('force', {}, DefaultAutoDapConfigOpts, opts or {})
local filename = vim.api.nvim_buf_get_name(bufnr)
local HtProjectHelpers = require('haskell-tools.project.helpers')
local project_root = HtProjectHelpers.match_project_root(filename)
if not project_root then
log.warn('dap: Unable to detect project root for file ' .. filename)
return
end
if _configuration_cache[project_root] then
log.debug('dap: Found cached configuration. Skipping.')
return
end
local discovered_configurations = {}
local json_configurations = find_json_configurations(project_root, opts)
vim.list_extend(discovered_configurations, json_configurations)
if opts.autodetect then
local detected_configurations = detect_launch_configurations(project_root)
vim.list_extend(discovered_configurations, detected_configurations)
end
_configuration_cache[project_root] = discovered_configurations
---@type HsDapLaunchConfiguration[]
local dap_configurations = dap.configurations.haskell or {}
for _, cfg in ipairs(discovered_configurations) do
for i, existing_config in pairs(dap_configurations) do
if cfg.name == existing_config.name and cfg.startup == existing_config.startup then
table.remove(dap_configurations, i)
end
end
table.insert(dap_configurations, cfg)
end
dap.configurations.haskell = dap_configurations
end
return HsDapTools

View File

@ -0,0 +1,33 @@
local compat = require('haskell-tools.compat')
---@class DapInternal
local Dap = {}
---@param package_name string
---@param exe_name string
---@param package_dir string
---@param mains string[]
---@param source_dirs string[]
---@return HsEntryPoint[] entry_points
Dap.mk_entry_points = function(package_name, exe_name, package_dir, mains, source_dirs)
---@type HsEntryPoint[]
local entry_points = {}
for _, source_dir in pairs(source_dirs) do
for _, main in pairs(mains) do
local filename = compat.joinpath(package_dir, source_dir, main)
if vim.fn.filereadable(filename) == 1 then
local entry_point = {
package_name = package_name,
exe_name = exe_name,
main = main,
source_dir = source_dir,
package_dir = package_dir,
}
table.insert(entry_points, entry_point)
end
end
end
return entry_points
end
return Dap

View File

@ -0,0 +1,80 @@
---@mod haskell-tools.deps
---@brief [[
---WARNING: This is not part of the public API.
---Breaking changes to this module will not be reflected in the semantic versioning of this plugin.
---@brief ]]
---@class Deps
local Deps = {}
---@param modname string The name of the module
---@param on_available any|nil Callback. Can be a function that takes the module name as an argument or a value.
---@param on_not_available any|nil Callback to execute if the module is not available. Can be a function or a value.
---@return any result Return value of on_available or on_not_available
function Deps.if_available(modname, on_available, on_not_available)
local has_mod, mod = pcall(require, modname)
if has_mod and type(on_available) == 'function' then
return on_available(mod)
elseif has_mod then
return on_available
end
if not on_not_available then
return nil
end
if type(on_not_available) == 'function' then
return on_not_available()
end
return on_not_available
end
---Require a module or fail
---@param modname string
---@param plugin_name string
---@return unknown
---@require
function Deps.require_or_err(modname, plugin_name)
return Deps.if_available(modname, function(mod)
return mod
end, function()
error('haskell-tools: This plugin requires the ' .. plugin_name .. ' plugin.')
end)
end
---@param modname string The name of the module
---@return boolean
function Deps.has(modname)
return Deps.if_available(modname, true, false)
end
---@return boolean
function Deps.has_telescope()
return Deps.has('telescope')
end
---@return unknown
---@require
function Deps.require_telescope(modname)
return Deps.require_or_err(modname, 'nvim-telescope/telescope.nvim')
end
---@return unknown
---@require
function Deps.require_toggleterm(modname)
return Deps.require_or_err(modname, 'akinsho/toggleterm')
end
---@return boolean
function Deps.has_toggleterm()
return Deps.has('toggleterm')
end
---@return unknown
---@require
function Deps.require_iron(modname)
return Deps.require_or_err(modname, 'hkupty/iron.nvim')
end
return Deps

View File

@ -0,0 +1,262 @@
---@mod haskell-tools.health Health checks
---@brief [[
---WARNING: This is not part of the public API.
---Breaking changes to this module will not be reflected in the semantic versioning of this plugin.
---@brief ]]
local health = {}
local Types = require('haskell-tools.types.internal')
local deps = require('haskell-tools.deps')
local HTConfig = require('haskell-tools.config.internal')
local h = vim.health or require('health')
---@diagnostic disable-next-line: deprecated
local start = h.start or h.report_start
---@diagnostic disable-next-line: deprecated
local ok = h.ok or h.report_ok
---@diagnostic disable-next-line: deprecated
local error = h.error or h.report_error
---@diagnostic disable-next-line: deprecated
local warn = h.warn or h.report_warn
---@class LuaDependency
---@field module string The name of a module
---@field optional fun():boolean Function that returns whether the dependency is optional
---@field url string URL (markdown)
---@field info string Additional information
---@type LuaDependency[]
local lua_dependencies = {
{
module = 'telescope',
optional = function()
if not HTConfig then
return true
end
local hoogle_mode = HTConfig.tools.hoogle.mode
return hoogle_mode:match('telescope') == nil
end,
url = '[nvim-telescope/telescope.nvim](https://github.com/nvim-telescope/telescope.nvim)',
info = 'Required for hoogle search modes "telescope-local" and "telescope-web"',
},
}
---@class ExternalDependency
---@field name string Name of the dependency
---@field get_binaries fun():string[]Function that returns the binaries to check for
---@field optional fun():boolean Function that returns whether the dependency is optional
---@field url string URL (markdown)
---@field info string Additional information
---@field extra_checks function|nil Optional extra checks to perform if the dependency is installed
---@type ExternalDependency[]
local external_dependencies = {
{
name = 'haskell-language-server',
get_binaries = function()
local default = { 'haskell-language-server-wrapper', 'haskell-language-server' }
if not HTConfig then
return default
end
local cmd = Types.evaluate(HTConfig.hls.cmd)
if not cmd or #cmd == 0 then
return default
end
return { cmd[1] }
end,
optional = function()
return true
end,
url = '[haskell-language-server](https://haskell-language-server.readthedocs.io)',
info = 'Required by the LSP client.',
},
{
name = 'hoogle',
get_binaries = function()
return { 'hoogle' }
end,
optional = function()
if not HTConfig then
return true
end
local hoogle_mode = HTConfig.tools.hoogle.mode
return hoogle_mode ~= 'telescope-local'
end,
url = '[ndmitchell/hoogle](https://github.com/ndmitchell/hoogle)',
info = [[
Recommended for better Hoogle search performance.
Without a local installation, the web API will be used by default.
Required if the hoogle mode is set to "telescope-local".
]],
extra_checks = function()
local handle, errmsg = io.popen('hoogle base')
if handle then
handle:close()
end
if errmsg then
local hoogle_mode = HTConfig.tools.hoogle.mode
if hoogle_mode and hoogle_mode == 'auto' or hoogle_mode == 'telescope-local' then
error('hoogle: ' .. errmsg)
else
warn('hoogle: ' .. errmsg)
end
end
end,
},
{
name = 'fast-tags',
get_binaries = function()
return { 'fast-tags' }
end,
optional = function()
return true
end,
url = '[fast-tags](https://hackage.haskell.org/package/fast-tags)',
info = 'Optional, for generating tags as a `tagfunc` fallback.',
},
{
name = 'curl',
get_binaries = function()
return { 'curl' }
end,
optional = function()
local hoogle_mode = HTConfig.tools.hoogle.mode
return not hoogle_mode or hoogle_mode ~= 'telescope-web'
end,
url = '[curl](https://curl.se/)',
info = 'Required for "telescope-web" hoogle seach mode.',
},
{
name = 'haskell-debug-adapter',
get_binaries = function()
return { 'haskell-debug-adapter' }
end,
optional = function()
return true
end,
url = '[haskell-debug-adapter](https://github.com/phoityne/haskell-debug-adapter)',
info = 'Optional, for `dap` support.',
},
{
name = 'ghci-dap',
get_binaries = function()
return { 'ghci-dap' }
end,
optional = function()
return true
end,
url = '[ghci-dap](https://github.com/phoityne/ghci-dap)',
info = 'Optional, for `dap` support.',
},
}
---@param dep LuaDependency
local function check_lua_dependency(dep)
if deps.has(dep.module) then
ok(dep.url .. ' installed.')
return
end
if dep.optional() then
warn(('%s not installed. %s %s'):format(dep.module, dep.info, dep.url))
else
error(('Lua dependency %s not found: %s'):format(dep.module, dep.url))
end
end
---@param dep ExternalDependency
---@return boolean is_installed
---@return string|nil version
local check_installed = function(dep)
local binaries = dep.get_binaries()
for _, binary in ipairs(binaries) do
if vim.fn.executable(binary) == 1 then
local handle = io.popen(binary .. ' --version')
if handle then
local binary_version, error_msg = handle:read('*a')
handle:close()
if error_msg then
return true
end
return true, binary_version
end
return true
end
end
return false
end
---@param dep ExternalDependency
local function check_external_dependency(dep)
local installed, mb_version = check_installed(dep)
if installed then
local mb_version_newline_idx = mb_version and mb_version:find('\n')
local mb_version_len = mb_version and (mb_version_newline_idx and mb_version_newline_idx - 1 or mb_version:len())
local version = mb_version and mb_version:sub(0, mb_version_len) or '(unknown version)'
ok(('%s: found %s'):format(dep.name, version))
if dep.extra_checks then
dep.extra_checks()
end
return
end
if dep.optional() then
warn(([[
%s: not found.
Install %s for extended capabilities.
%s
]]):format(dep.name, dep.url, dep.info))
else
error(([[
%s: not found.
haskell-tools.nvim requires %s.
%s
]]):format(dep.name, dep.url, dep.info))
end
end
local function check_config()
start('Checking config')
if vim.g.haskell_tools and not HTConfig.debug_info.was_g_haskell_tools_sourced then
error('vim.g.haskell_tools is set, but was not sourced before haskell-tools.nvim was initialized.')
return
end
local valid, err = require('haskell-tools.config.check').validate(HTConfig)
if valid then
ok('No errors found in config.')
else
error(err or '' .. vim.g.haskell_tools and '' or ' This looks like a plugin bug!')
end
local unrecognized_keys = HTConfig.debug_info.unrecognized_keys
if #unrecognized_keys > 0 then
warn('unrecognized configs in vim.g.haskell_tools: ' .. vim.inspect(unrecognized_keys))
end
end
local function check_for_conflicts()
start('Checking for conflicting plugins')
for _, autocmd in ipairs(vim.api.nvim_get_autocmds { event = 'FileType', pattern = 'haskell' }) do
if autocmd.group_name and autocmd.group_name == 'lspconfig' and autocmd.desc and autocmd.desc:match(' hls ') then
error('lspconfig.hls has been setup. This will likely lead to conflicts with the haskell-tools LSP client.')
return
end
end
ok('No conflicting plugins detected.')
end
function health.check()
start('Checking for Lua dependencies')
for _, dep in ipairs(lua_dependencies) do
check_lua_dependency(dep)
end
start('Checking external dependencies')
for _, dep in ipairs(external_dependencies) do
check_external_dependency(dep)
end
check_config()
check_for_conflicts()
end
return health

View File

@ -0,0 +1,147 @@
---@mod haskell-tools.hoogle.helpers
---@brief [[
---WARNING: This is not part of the public API.
---Breaking changes to this module will not be reflected in the semantic versioning of this plugin.
--- This module provides hoogle search capabilities for telescope.nvim,
--- The telescope search is mostly inspired by telescope_hoogle by Luc Tielen,
--- but has been redesigned for searching for individual terms.
--- https://github.com/luc-tielen/telescope_hoogle
---@brief ]]
local deps = require('haskell-tools.deps')
local OS = require('haskell-tools.os')
local actions = deps.require_telescope('telescope.actions')
local actions_state = deps.require_telescope('telescope.actions.state')
local entry_display = deps.require_telescope('telescope.pickers.entry_display')
---@class HoogleHelpers
local HoogleHelpers = {}
---@param buf number the telescope buffebuffer numberr
---@param map fun(mode:string,keys:string,action:function) callback for creating telescope keymaps
---@return boolean
function HoogleHelpers.hoogle_attach_mappings(buf, map)
actions.select_default:replace(function()
-- Copy type signature to clipboard
local entry = actions_state.get_selected_entry()
local reg = vim.o.clipboard == 'unnamedplus' and '+' or '"'
vim.fn.setreg(reg, entry.type_sig)
actions.close(buf)
end)
map('i', '<C-b>', function()
-- Open in browser
local entry = actions_state.get_selected_entry()
OS.open_browser(entry.url)
end)
map('i', '<C-r>', function()
-- Replace word under cursor
local entry = actions_state.get_selected_entry()
local func_name = entry.type_sig:match('([^%s]*)%s::')
if not func_name then
vim.notify('Hoogle (replace): Not a function.', vim.log.levels.WARN)
return
end
actions.close(buf)
vim.api.nvim_input('ciw' .. func_name .. '<ESC>')
end)
return true
end
---Format an html string to be displayed by Neovim
---@param html string
---@return string nvim_str
local function format_html(html)
return html and html:gsub('&lt;', '<'):gsub('&gt;', '>'):gsub('&amp', '&') or ''
end
---@class TelescopeHoogleEntry
---@field value string
---@field valid boolean
---@field type_sig string The entry's type signature
---@field module_name string The name of the module that contains the entry
---@field url string|nil The entry's Hackage URL
---@field docs string|nil The Hoogle entry's documentation
---@field display fun(TelescopeHoogleEntry):TelescopeDisplay
---@field ordinal string
---@field preview_command fun(TelescopeHoogleEntry, number):nil
---Show a preview in the Telescope previewer
---@param entry TelescopeHoogleEntry
---@param buf number the Telescope preview buffer
local function show_preview(entry, buf)
local docs = format_html(entry.docs)
local lines = vim.split(docs, '\n')
vim.api.nvim_buf_set_lines(buf, 0, -1, true, lines)
vim.api.nvim_buf_call(buf, function()
local win = vim.fn.win_findbuf(buf)[1]
vim.wo[win].conceallevel = 2
vim.wo[win].wrap = true
vim.wo[win].linebreak = true
vim.bo[buf].textwidth = 80
end)
end
---@class TelescopeDisplay
---@param entry TelescopeHoogleEntry
---@return TelescopeDisplay
local function make_display(entry)
local module = entry.module_name
local displayer = entry_display.create {
separator = '',
items = {
{ width = module and #module + 1 or 0 },
{ remaining = true },
},
}
return displayer { { module, 'Structure' }, { entry.type_sig, 'Type' } }
end
---@param hoogle_item string
---@return string type_signature
local function get_type_sig(hoogle_item)
local name = hoogle_item:match('<span class=name><s0>(.*)</s0></span>')
local sig = hoogle_item:match(':: (.*)')
if name and sig then
local name_with_type = (name .. ' :: ' .. format_html(sig)):gsub('%s+', ' ') -- trim duplicate whitespace
return name_with_type
end
return hoogle_item
end
---@class HoogleData
---@field module HoogleModule|nil
---@field item string|nil
---@field url string|nil
---@field docs string|nil
---@class HoogleModule
---@field name string
---@param data HoogleData
---@return TelescopeHoogleEntry|nil
function HoogleHelpers.mk_hoogle_entry(data)
local module_name = (data.module or {}).name
local type_sig = data.item and get_type_sig(data.item) or ''
if not module_name or not type_sig then
return nil
end
return {
value = data,
valid = true,
type_sig = type_sig,
module_name = module_name,
url = data.url,
docs = data.docs,
display = make_display,
ordinal = data.item .. data.url,
preview_command = show_preview,
}
end
return HoogleHelpers

View File

@ -0,0 +1,114 @@
---@mod haskell-tools.hoogle haskell-tools Hoogle search
local log = require('haskell-tools.log.internal')
local lsp_util = vim.lsp.util
---@type fun(sig_or_func_name:string, options:table|nil):nil
local handler
---@param options table
---@return fun(err: lsp.ResponseError|nil, result: any, context: lsp.HandlerContext, config: table|nil)
local function mk_lsp_hoogle_signature_handler(options)
return function(_, result, _, _)
if not (result and result.contents) then
vim.notify('hoogle: No information available')
return
end
local func_name = vim.fn.expand('<cword>')
---@cast func_name string
local HtParser = require('haskell-tools.parser')
local signature_or_func_name = HtParser.try_get_signatures_from_markdown(func_name, result.contents.value)
or func_name
log.debug { 'Hoogle LSP signature search', signature_or_func_name }
if signature_or_func_name ~= '' then
handler(signature_or_func_name, options)
end
end
end
---@param options table
local function lsp_hoogle_signature(options)
local params = lsp_util.make_position_params()
return vim.lsp.buf_request(0, 'textDocument/hover', params, mk_lsp_hoogle_signature_handler(options))
end
local HTConfig = require('haskell-tools.config.internal')
local opts = HTConfig.tools.hoogle
local hoogle_web = require('haskell-tools.hoogle.web')
local hoogle_local = require('haskell-tools.hoogle.local')
---@return nil
local function set_web_handler()
handler = hoogle_web.telescope_search
log.debug('handler = telescope-web')
end
---@return nil
local function set_local_handler()
handler = hoogle_local.telescope_search
log.debug('handler = telescope-local')
end
---@return nil
local function set_browser_handler()
handler = hoogle_web.browser_search
log.debug('handler = browser')
end
if opts.mode == 'telescope-web' then
set_web_handler()
elseif opts.mode == 'telescope-local' then
if not hoogle_local.has_hoogle() then
local msg = 'handler set to "telescope-local" but no hoogle executable found.'
log.warn(msg)
vim.notify('haskell-tools.hoogle: ' .. msg, vim.log.levels.WARN)
set_web_handler()
return
end
local deps = require('haskell-tools.deps')
if not deps.has_telescope() then
local msg = 'handler set to "telescope-local" but telescope.nvim is not installed.'
log.warn(msg)
vim.notify('haskell-tools.hoogle: ' .. msg, vim.log.levels.WARN)
set_web_handler()
return
end
set_local_handler()
elseif opts.mode == 'browser' then
set_browser_handler()
elseif opts.mode == 'auto' then
local deps = require('haskell-tools.deps')
if not deps.has_telescope() then
set_browser_handler()
elseif hoogle_local.has_hoogle() then
set_local_handler()
else
set_web_handler()
end
end
---@class HoogleTools
local HoogleTools = {}
---@param options table<string,any>|nil Includes the `search_term` and options to pass to the telescope picker (if available)
---@return nil
HoogleTools.hoogle_signature = function(options)
options = options or {}
log.debug { 'Hoogle signature search options', options }
if options.search_term then
handler(options.search_term)
return
end
local LspHelpers = require('haskell-tools.lsp.helpers')
local clients = LspHelpers.get_clients { bufnr = vim.api.nvim_get_current_buf() }
if #clients > 0 then
lsp_hoogle_signature(options)
else
log.debug('Hoogle signature search: No clients attached. Falling back to <cword>.')
local cword = vim.fn.expand('<cword>')
---@cast cword string
handler(cword, options)
end
end
return HoogleTools

View File

@ -0,0 +1,102 @@
---@mod haskell-tools.hoogle.local
---@brief [[
---WARNING: This is not part of the public API.
---Breaking changes to this module will not be reflected in the semantic versioning of this plugin.
---@brief ]]
local log = require('haskell-tools.log.internal')
local deps = require('haskell-tools.deps')
local compat = require('haskell-tools.compat')
---@class LocalHoogleHandler
local HoogleLocal = {}
---@return boolean has_hoogle `true` if the `hoogle` executable exists
function HoogleLocal.has_hoogle()
return vim.fn.executable('hoogle') == 1
end
if not HoogleLocal.has_hoogle() then
return HoogleLocal
end
if not deps.has_telescope() then
return HoogleLocal
end
---@class LocalHoogleOpts
---@field entry_maker function|nil telescope entry maker
---@field count number|nil number of results to display
---Construct the hoogle cli arguments
---@param search_term string The Hoogle search term
---@param opts LocalHoogleOpts
---@return string[] hoogle_args
local function mk_hoogle_args(search_term, opts)
local count = opts.count or 50
local args = compat.tbl_flatten { '--json', '--count=' .. count, search_term }
log.debug { 'Hoogle local args', args }
return args
end
local pickers = deps.require_telescope('telescope.pickers')
local finders = deps.require_telescope('telescope.finders')
local previewers = deps.require_telescope('telescope.previewers')
local HoogleHelpers = require('haskell-tools.hoogle.helpers')
---@param search_term string The Hoogle search term
---@param opts LocalHoogleOpts|nil
---@return nil
function HoogleLocal.telescope_search(search_term, opts)
opts = opts or {}
opts.entry_maker = opts.entry_maker or HoogleHelpers.mk_hoogle_entry
local config = deps.require_telescope('telescope.config').values
if not config then
local msg = 'telescope.nvim has not been setup.'
log.error(msg)
vim.notify_once('haskell-tools.hoogle: ' .. msg, vim.log.levels.ERROR)
return
end
local cmd = vim.list_extend({ 'hoogle' }, mk_hoogle_args(search_term, opts))
compat.system(
cmd,
nil,
vim.schedule_wrap(function(result)
---@cast result vim.SystemCompleted
local output = result.stdout
if result.code ~= 0 or output == nil then
local err_msg = 'haskell-tools: hoogle search failed. Exit code: ' .. result.code
log.error(err_msg)
vim.notify(err_msg, vim.log.levels.ERROR)
return
end
local success, results = pcall(vim.json.decode, output)
if not success then
log.error { 'Hoogle: Could not process result.', output }
vim.notify('Hoogle: Could not process result - ' .. vim.inspect(output), vim.log.levels.ERROR)
return
end
if #results < 1 or output == 'No results found' then
vim.notify('Hoogle: No results found.', vim.log.levels.INFO)
return
end
pickers
.new(opts, {
prompt_title = 'Hoogle: ' .. search_term,
sorter = config.generic_sorter(opts),
finder = finders.new_table {
results = results,
entry_maker = HoogleHelpers.mk_hoogle_entry,
},
previewer = previewers.display_content.new(opts),
attach_mappings = HoogleHelpers.hoogle_attach_mappings,
})
:find()
end)
)
end
return HoogleLocal

View File

@ -0,0 +1,129 @@
---@mod haskell-tools.hoogle.web
---@brief [[
---WARNING: This is not part of the public API.
---Breaking changes to this module will not be reflected in the semantic versioning of this plugin.
---@brief ]]
local log = require('haskell-tools.log.internal')
local deps = require('haskell-tools.deps')
local OS = require('haskell-tools.os')
local compat = require('haskell-tools.compat')
---@class WebHoogleHandler
local WebHoogleHandler = {}
---@param c string A single character
---@return string The hex representation
local char_to_hex = function(c)
return string.format('%%%02X', c:byte())
end
---Encode a URL so it can be opened in a browser
---@param url string
---@return string encoded_url
local function urlencode(url)
url = url:gsub('\n', '\r\n')
url = url:gsub('([^%w ])', char_to_hex)
url = url:gsub(' ', '+')
return url
end
---@class TelescopeHoogleWebOpts
---@field hoogle HoogleWebSearchOpts|nil
---@class HoogleWebSearchOpts
---@field scope string|nil The scope of the search
---@field json boolean|nil Whather to request JSON enocded results
---Build a Hoogle request URL
---@param search_term string
---@param opts TelescopeHoogleWebOpts
local function mk_hoogle_request(search_term, opts)
local hoogle_opts = opts.hoogle or {}
local scope_param = hoogle_opts.scope and '&scope=' .. hoogle_opts.scope or ''
local hoogle_request = 'https://hoogle.haskell.org/?hoogle='
.. urlencode(search_term)
.. scope_param
.. (hoogle_opts.json and '&mode=json' or '')
log.debug { 'Hoogle web request', hoogle_request }
return hoogle_request
end
if deps.has_telescope() then
local pickers = deps.require_telescope('telescope.pickers')
local finders = deps.require_telescope('telescope.finders')
local previewers = deps.require_telescope('telescope.previewers')
local HoogleHelpers = require('haskell-tools.hoogle.helpers')
---@param search_term string
---@param opts TelescopeHoogleWebOpts|nil
---@return nil
function WebHoogleHandler.telescope_search(search_term, opts)
local config = deps.require_telescope('telescope.config').values
if not config then
local msg = 'telescope.nvim has not been setup. Falling back to browser search.'
log.warn(msg)
vim.notify_once('haskell-tools.hoogle: ' .. msg, vim.log.levels.WARN)
WebHoogleHandler.browser_search(search_term, opts)
return
end
if vim.fn.executable('curl') == 0 then
log.error('curl executable not found.')
vim.notify("haskell-tools.hoogle-web: 'curl' executable not found! Aborting.", vim.log.levels.ERROR)
return
end
opts = opts or {}
opts.hoogle = opts.hoogle or {}
opts.hoogle.json = true
local url = mk_hoogle_request(search_term, opts)
local curl_command = { 'curl', '--silent', url, '-H', 'Accept: application/json' }
log.debug(curl_command)
compat.system(curl_command, nil, function(result)
---@cast result vim.SystemCompleted
log.debug { 'Hoogle web response', result }
local response = result.stdout
if result.code ~= 0 or response == nil then
vim.notify('hoogle web: ' .. (result.stderr or 'error calling curl'), vim.log.levels.ERROR)
return
end
local ok, results = pcall(vim.json.decode, response)
vim.schedule(function()
if not ok then
log.error { 'Hoogle web response (invalid JSON)', curl_command, 'result: ' .. result }
vim.notify(
"haskell-tools.hoogle: Received invalid JSON from curl. Likely due to a failed request. See ':HtLog' for details'",
vim.log.levels.ERROR
)
return
end
pickers
.new(opts, {
prompt_title = 'Hoogle: ' .. search_term,
finder = finders.new_table {
results = results,
entry_maker = HoogleHelpers.mk_hoogle_entry,
},
sorter = config.generic_sorter(opts),
previewer = previewers.display_content.new(opts),
attach_mappings = HoogleHelpers.hoogle_attach_mappings,
})
:find()
end)
end)
end
end
---@param search_term string
---@param opts TelescopeHoogleWebOpts|nil
---@return nil
function WebHoogleHandler.browser_search(search_term, opts)
opts = vim.tbl_deep_extend('keep', opts or {}, {
hoogle = { json = false },
})
OS.open_browser(mk_hoogle_request(search_term, opts))
end
return WebHoogleHandler

View File

@ -0,0 +1,58 @@
---@toc haskell-tools.contents
---@mod intro Introduction
---@brief [[
---This plugin automatically configures the `haskell-language-server` builtin LSP client
---and integrates with other haskell tools.
---@brief ]]
---
---@brief [[
---WARNING:
---Do not call the `lspconfig.hls` setup or set up the lsp manually,
---as doing so may cause conflicts.
---@brief ]]
---
---@brief [[
---NOTE: This plugin is a filetype plugin.
---There is no need to call a `setup` function.
---@brief ]]
---@mod haskell-tools The haskell-tools module
---@brief [[
---Entry-point into this plugin's public API.
---@brief ]]
---@param module string
---@return table
local function lazy_require(module)
return setmetatable({}, {
__index = function(_, key)
return require(module)[key]
end,
__newindex = function(_, key, value)
require(module)[key] = value
end,
})
end
---@class HaskellTools
local HaskellTools = {
---@type HlsTools
lsp = lazy_require('haskell-tools.lsp'),
---@type HoogleTools
hoogle = lazy_require('haskell-tools.hoogle'),
---@type HsReplTools
repl = lazy_require('haskell-tools.repl'),
---@type HsProjectTools
project = lazy_require('haskell-tools.project'),
---@type FastTagsTools
tags = lazy_require('haskell-tools.tags'),
---@type HsDapTools
dap = lazy_require('haskell-tools.dap'),
---@type HaskellToolsLog
log = lazy_require('haskell-tools.log'),
}
return HaskellTools

View File

@ -0,0 +1,79 @@
---@mod haskell-tools.internal
---@brief [[
---WARNING: This is not part of the public API.
---Breaking changes to this module will not be reflected in the semantic versioning of this plugin.
--- The internal API for use by this plugin's ftplugins
---@brief ]]
local HTConfig = require('haskell-tools.config.internal')
---@class InternalApi
local InternalApi = {}
---@return boolean tf Is LSP supported for the current buffer?
local function buf_is_lsp_supported()
local bufnr = vim.api.nvim_get_current_buf()
local HtProjectHelpers = require('haskell-tools.project.helpers')
if not HtProjectHelpers.is_cabal_file(bufnr) then
return true
end
local LspHelpers = require('haskell-tools.lsp.helpers')
return LspHelpers.is_hls_version_with_cabal_support()
end
---Starts or attaches an LSP client to the current buffer and sets up the plugin if necessary.
---
---@see haskell-tools.config for configuration options.
---@see ftplugin
---@see base-directories
---@usage [[
----- In your neovim configuration, set:
---vim.g.haskell_tools = {
--- tools = {
--- -- ...
--- },
--- hls = {
--- on_attach = function(client, bufnr)
--- -- Set keybindings, etc. here.
--- end,
--- -- ...
--- },
--- }
----- In `~/.config/nvim/ftplugin/after/<filetype>.lua`, call
---local ht = require('haskell-tools')
---ht.start_or_attach()
---@usage ]]
local function start_or_attach()
local Types = require('haskell-tools.types.internal')
local HaskellTools = require('haskell-tools')
if Types.evaluate(HTConfig.hls.auto_attach) and buf_is_lsp_supported() then
HaskellTools.lsp.start()
end
if Types.evaluate(HTConfig.tools.tags.enable) then
HaskellTools.tags.generate_project_tags(nil, { refresh = false })
end
end
---Auto-discover nvim-dap launch configurations (if auto-discovery is enabled)
local function dap_discover()
local auto_discover = HTConfig.dap.auto_discover
if not auto_discover then
return
elseif type(auto_discover) == 'boolean' then
return require('haskell-tools').dap.discover_configurations()
end
---@cast auto_discover AddDapConfigOpts
local bufnr = vim.api.nvim_get_current_buf()
require('haskell-tools').dap.discover_configurations(bufnr, auto_discover)
end
---ftplugin implementation
function InternalApi.ftplugin()
start_or_attach()
dap_discover()
end
return InternalApi

View File

@ -0,0 +1,76 @@
---@mod haskell-tools.log haskell-tools Logging
---
---@brief [[
--- The following commands are available:
---
--- * `:HtLog` - Open the haskell-tools.nvim log file.
--- * `:HlsLog` - Open the haskell-language-server log file.
--- * `:HtSetLogLevel` - Set the haskell-tools.nvim and LSP client log level.
---@brief ]]
---@class HaskellToolsLog
local HaskellToolsLog = {}
---Get the haskell-language-server log file
---@return string filepath
function HaskellToolsLog.get_hls_logfile()
return require('haskell-tools.log.internal').get_hls_logfile()
end
---Get the haskell-tools.nvim log file path.
---@return string filepath
function HaskellToolsLog.get_logfile()
return require('haskell-tools.log.internal').get_logfile()
end
---Open the haskell-language-server log file
---@return nil
function HaskellToolsLog.nvim_open_hls_logfile()
return require('haskell-tools.log.internal').nvim_open_hls_logfile()
end
---Open the haskell-tools.nvim log file.
---@return nil
function HaskellToolsLog.nvim_open_logfile()
return require('haskell-tools.log.internal').nvim_open_logfile()
end
---Set the haskell-tools.nvim and LSP client log level
---@param level (string|integer) The log level
---@return nil
---@see vim.log.levels
function HaskellToolsLog.set_level(level)
return require('haskell-tools.log.internal').set_level(level)
end
local commands = {
{
'HtLog',
function()
HaskellToolsLog.nvim_open_logfile()
end,
{},
},
{
'HlsLog',
function()
HaskellToolsLog.nvim_open_hls_logfile()
end,
{ nargs = 1 },
},
{
'HtSetLogLevel',
function(tbl)
local level = vim.fn.expand(tbl.args)
---@cast level string
HaskellToolsLog.set_level(tonumber(level) or level)
end,
{ nargs = 1 },
},
}
for _, command in ipairs(commands) do
vim.api.nvim_create_user_command(unpack(command))
end
return HaskellToolsLog

View File

@ -0,0 +1,148 @@
---@mod haskell-tools.log.internal haskell-tools Logging (internal)
---@brief [[
---WARNING: This is not part of the public API.
---Breaking changes to this module will not be reflected in the semantic versioning of this plugin.
--- The internal API for use by this plugin's ftplugins
---@brief ]]
local compat = require('haskell-tools.compat')
---@class HaskellToolsLogInternal
local HaskellToolsLogInternal = {
-- NOTE: These functions are initialised as empty for type checking purposes
-- and implemented later.
trace = function(_) end,
debug = function(_) end,
info = function(_) end,
warn = function(_) end,
error = function(_) end,
}
local LARGE = 1e9
local log_date_format = '%F %H:%M:%S'
local function format_log(arg)
return vim.inspect(arg, { newline = '' })
end
local HTConfig = require('haskell-tools.config.internal')
local logfilename = HTConfig.tools.log.logfile
---Get the haskell-tools.nvim log file path.
---@return string filepath
function HaskellToolsLogInternal.get_logfile()
return logfilename
end
---Open the haskell-tools.nvim log file.
function HaskellToolsLogInternal.nvim_open_logfile()
vim.cmd('e ' .. HaskellToolsLogInternal.get_logfile())
end
local logfile, openerr
--- @private
--- Opens log file. Returns true if file is open, false on error
--- @return boolean
local function open_logfile()
-- Try to open file only once
if logfile then
return true
end
if openerr then
return false
end
logfile, openerr = io.open(logfilename, 'a+')
if not logfile then
local err_msg = string.format('Failed to open haskell-tools.nvim log file: %s', openerr)
vim.notify(err_msg, vim.log.levels.ERROR)
return false
end
local log_info = compat.uv.fs_stat(logfilename)
if log_info and log_info.size > LARGE then
local warn_msg =
string.format('haskell-tools.nvim log is large (%d MB): %s', log_info.size / (1000 * 1000), logfilename)
vim.notify(warn_msg, vim.log.levels.WARN)
end
-- Start message for logging
logfile:write(string.format('[START][%s] haskell-tools.nvim logging initiated\n', os.date(log_date_format)))
return true
end
local opts = HTConfig.tools.log
local hls_log = HTConfig.hls.logfile
--- Get the haskell-language-server log file
function HaskellToolsLogInternal.get_hls_logfile()
return hls_log
end
-- Open the haskell-language-server log file
function HaskellToolsLogInternal.nvim_open_hls_logfile()
vim.cmd('e ' .. HaskellToolsLogInternal.get_hls_logfile())
end
local log_levels = vim.deepcopy(vim.log.levels)
for levelstr, levelnr in pairs(log_levels) do
log_levels[levelnr] = levelstr
end
--- Set the log level
--- @param level (string|integer) The log level
--- @see vim.log.levels
function HaskellToolsLogInternal.set_level(level)
if type(level) == 'string' then
HaskellToolsLogInternal.level =
assert(log_levels[string.upper(level)], string.format('haskell-tools: Invalid log level: %q', level))
else
assert(log_levels[level], string.format('haskell-tools: Invalid log level: %d', level))
HaskellToolsLogInternal.level = level
end
vim.lsp.set_log_level(HaskellToolsLogInternal.level)
end
HaskellToolsLogInternal.set_level(opts.level)
for level, levelnr in pairs(vim.log.levels) do
HaskellToolsLogInternal[level:lower()] = function(...)
if HaskellToolsLogInternal.level == vim.log.levels.OFF or not open_logfile() then
return false
end
local argc = select('#', ...)
if levelnr < HaskellToolsLogInternal.level then
return false
end
if argc == 0 then
return true
end
local info = debug.getinfo(2, 'Sl')
local fileinfo = string.format('%s:%s', info.short_src, info.currentline)
local parts = {
table.concat({ level, '|', os.date(log_date_format), '|', fileinfo, '|' }, ' '),
}
for i = 1, argc do
local arg = select(i, ...)
if arg == nil then
table.insert(parts, '<nil>')
elseif type(arg) == 'string' then
table.insert(parts, arg)
else
table.insert(parts, format_log(arg))
end
end
logfile:write(table.concat(parts, ' '), '\n')
logfile:flush()
end
end
HaskellToolsLogInternal.debug { 'Config', HTConfig }
return HaskellToolsLogInternal

View File

@ -0,0 +1,29 @@
---@mod haskell-tools.lsp.definition LSP textDocument/definition override
---@brief [[
---WARNING: This is not part of the public API.
---Breaking changes to this module will not be reflected in the semantic versioning of this plugin.
---@brief ]]
local log = require('haskell-tools.log.internal')
local lsp_definition = {}
---@param opts table<string,any>|nil
---@return nil
function lsp_definition.mk_hoogle_fallback_definition_handler(opts)
return function(_, result, ...)
local ht = require('haskell-tools')
if #result > 0 then
local default_handler = vim.lsp.handlers['textDocument/definition']
return default_handler(_, result, ...)
end
log.debug('Definition not found. Falling back to Hoogle search.')
vim.notify('Definition not found. Falling back to Hoogle search...', vim.log.levels.WARN)
ht.hoogle.hoogle_signature(opts or {})
end
end
return lsp_definition

View File

@ -0,0 +1,57 @@
---@mod haskell-tools.lsp.eval LSP code snippet evaluation
---@brief [[
---WARNING: This is not part of the public API.
---Breaking changes to this module will not be reflected in the semantic versioning of this plugin.
--- General utility functions that may need to be moded somewhere else
---@brief ]]
local eval = {}
local LspHelpers = require('haskell-tools.lsp.helpers')
---@param bufnr number The buffer number
---@return table[] The `evalCommand` lenses, in reverse order
local function get_eval_command_lenses(bufnr, exclude_lines)
exclude_lines = exclude_lines or {}
local eval_cmd_lenses = {}
for _, lens in pairs(vim.lsp.codelens.get(bufnr)) do
if lens.command.command:match('evalCommand') and not vim.tbl_contains(exclude_lines, lens.range.start.line) then
table.insert(eval_cmd_lenses, 1, lens)
end
end
return eval_cmd_lenses
end
---@param client table LSP client
---@param lens table
---@param bufnr number
---@param exclude_lines number[]|nil -- (optional) `codeLens.range.start.line`s to exclude
---@return nil
local function go(client, bufnr, lens, exclude_lines)
local command = lens.command
client.request_sync('workspace/executeCommand', command, 1000, bufnr)
exclude_lines[#exclude_lines + 1] = lens.range.start.line
local new_lenses = get_eval_command_lenses(bufnr, exclude_lines)
if #new_lenses > 0 then
go(client, bufnr, new_lenses[1], exclude_lines)
end
end
---@param bufnr number|nil The buffer number
---@return nil
function eval.all(bufnr)
bufnr = bufnr or vim.api.nvim_win_get_buf(0)
local clients = LspHelpers.get_active_ht_clients(bufnr)
if not clients or #clients == 0 then
return
end
local client = clients[1]
local lenses = get_eval_command_lenses(bufnr)
if #lenses > 0 then
go(client, bufnr, lenses[1], {})
vim.lsp.codelens.refresh()
end
end
return eval

View File

@ -0,0 +1,98 @@
---@mod haskell-tools.lsp.helpers
---@brief [[
---WARNING: This is not part of the public API.
---Breaking changes to this module will not be reflected in the semantic versioning of this plugin.
--- LSP helper functions
---@brief ]]
local Types = require('haskell-tools.types.internal')
---@class LspHelpers
local LspHelpers = {}
local compat = require('haskell-tools.compat')
LspHelpers.get_clients = compat.get_clients
LspHelpers.haskell_client_name = 'haskell-tools.nvim'
LspHelpers.cabal_client_name = 'haskell-tools.nvim (cabal)'
---@param bufnr number the buffer to get clients for
---@return lsp.Client[] haskell_clients
---@see util.get_clients
function LspHelpers.get_active_haskell_clients(bufnr)
return LspHelpers.get_clients { bufnr = bufnr, name = LspHelpers.haskell_client_name }
end
---@param bufnr number the buffer to get clients for
---@return lsp.Client[] cabal_clinets
---@see util.get_clients
function LspHelpers.get_active_cabal_clients(bufnr)
return LspHelpers.get_clients { bufnr = bufnr, name = LspHelpers.cabal_client_name }
end
---@param bufnr number the buffer to get clients for
---@return lsp.Client[] ht_clients The haskell + cabal clients
---@see util.get_clients
---@see util.get_active_haskell_clients
---@see util.get_active_cabal_clients
function LspHelpers.get_active_ht_clients(bufnr)
local clients = {}
vim.list_extend(clients, LspHelpers.get_active_haskell_clients(bufnr))
vim.list_extend(clients, LspHelpers.get_active_cabal_clients(bufnr))
return clients
end
---@return string[] cmd The command to invoke haskell-language-server
LspHelpers.get_hls_cmd = function()
local HTConfig = require('haskell-tools.config.internal')
local cmd = Types.evaluate(HTConfig.hls.cmd)
---@cast cmd string[]
assert(type(cmd) == 'table', 'haskell-tools: hls.cmd should evaluate to a string[]')
assert(#cmd > 1, 'haskell-tools: hls.cmd evaluates to an empty list.')
return cmd
end
---Returns nil if the hls version cannot be determined.
---@return number[]|nil hls_version The haskell-language-server version
local function get_hls_version()
local hls_bin = LspHelpers.get_hls_cmd()[1]
if vim.fn.executable(hls_bin) ~= 1 then
return nil
end
local handle = io.popen(hls_bin .. ' --version')
if not handle then
return nil
end
local output, error_msg = handle:read('*a')
handle:close()
if error_msg then
return nil
end
local version_str = output:match('version:%s([^%s]*)%s.*')
if not version_str then
return nil
end
local function parse_version()
local version = {}
for str in string.gmatch(version_str, '([^%.]+)') do
table.insert(version, tonumber(str))
end
return #version > 1 and version
end
local ok, version = pcall(parse_version)
return ok and version or nil
end
---@return boolean
LspHelpers.is_hls_version_with_cabal_support = function()
local version = get_hls_version()
-- XXX: If the version cannot be parsed, we assume it supports
--- cabal (in case there is a newer version that we cannot
--- parse the version from).
return version == nil or version[1] > 1 or version[2] >= 9
end
return LspHelpers

View File

@ -0,0 +1,312 @@
---@brief [[
---WARNING: This is not part of the public API.
---Breaking changes to this module will not be reflected in the semantic versioning of this plugin.
--- Inspired by rust-tools.nvim's hover_actions
---@brief ]]
local log = require('haskell-tools.log.internal')
local lsp_util = vim.lsp.util
local HtParser = require('haskell-tools.parser')
local OS = require('haskell-tools.os')
local HtProjectHelpers = require('haskell-tools.project.helpers')
local hover = {}
---@class HtHoverState
---@field winnr number|nil The hover window number
---@field commands (fun():nil)[] List of hover actions
---@type HtHoverState
local _state = {
winnr = nil,
commands = {},
}
---@return nil
local function close_hover()
local winnr = _state.winnr
if winnr ~= nil and vim.api.nvim_win_is_valid(winnr) then
vim.api.nvim_win_close(winnr, true)
_state.winnr = nil
_state.commands = {}
end
end
---Execute the command at the cursor position
---@retrun nil
local function run_command()
local winnr = vim.api.nvim_get_current_win()
local line = vim.api.nvim_win_get_cursor(winnr)[1]
if line > #_state.commands then
return
end
local action = _state.commands[line]
close_hover()
action()
end
---@param x string hex
---@return string char
local function hex_to_char(x)
return string.char(tonumber(x, 16))
end
---Formats a location in a Haskell file, shortening it to a relative path if possible.
---@param location string The location provided by LSP hover
---@param current_file string The current file path or an empty string
---@return string formatted_location or the original location if the file is not a Haskell file
local function format_location(location, current_file)
-- remove *
-- replace quotes with markdown backticks
-- decode url-encoded characters
local formatted_location = ('%s')
:format(location)
:gsub('%*', '') -- remove *
:gsub('', '`') -- markdown backticks
:gsub('', '`')
:gsub('%%(%x%x)', hex_to_char) -- decode url-encoded characters
local file_location = formatted_location:match('(.*).hs:')
if not file_location then
return formatted_location
end
local is_current_buf = formatted_location:find(current_file, 1, true) == 1
if is_current_buf then
return formatted_location:sub(#current_file + 2)
end
local path = file_location .. '.hs'
local package_path = HtProjectHelpers.match_package_root(path)
if package_path then
return formatted_location:sub(#package_path + 2) -- trim package path + first '/'
end
local project_path = HtProjectHelpers.match_project_root(path)
if project_path then
formatted_location = formatted_location:sub(#project_path + 2):gsub('/', ':', 1) -- trim project path + first '/'
end
return formatted_location
end
---@param result table LSP result
---@return string location string
local function mk_location(result)
local range_start = result.range and result.range.start or {}
local line = range_start.line
local character = range_start.character
local uri = result.uri and result.uri:gsub('file://', '')
return line and character and uri and uri .. ':' .. tostring(line + 1) .. ':' .. tostring(character + 1) or ''
end
---Is the result's start location the same as the params location?
---@param params table LSP location params
---@param result table LSP result
---@return boolean
local function is_same_position(params, result)
local range_start = result.range and result.range.start or {}
return params.textDocument.uri == result.uri
and params.position.line == range_start.line
and params.position.character == range_start.character
end
---LSP handler for textDocument/hover
---@param result table
---@param ctx table
---@param config table<string,any>|nil
---@return number|nil bufnr
---@return number|nil winnr
function hover.on_hover(_, result, ctx, config)
local ht = require('haskell-tools')
config = config or {}
config.focus_id = ctx.method
if vim.api.nvim_get_current_buf() ~= ctx.bufnr then
-- Ignore result since buffer changed.
return
end
if not (result and result.contents) then
vim.notify('No information available')
return
end
local markdown_lines = lsp_util.convert_input_to_markdown_lines(result.contents)
if vim.tbl_isempty(markdown_lines) then
log.debug('No hover information available.')
vim.notify('No information available')
return
end
local to_remove = {}
local actions = {}
_state.commands = {}
local func_name = vim.fn.expand('<cword>')
---@cast func_name string
local _, signatures = HtParser.try_get_signatures_from_markdown(func_name, result.contents.value)
for _, signature in pairs(signatures) do
table.insert(actions, 1, string.format('%d. Hoogle search: `%s`', #actions + 1, signature))
table.insert(_state.commands, function()
log.debug { 'Hover: Hoogle search for signature', signature }
ht.hoogle.hoogle_signature { search_term = signature }
end)
end
local cword = vim.fn.expand('<cword>')
table.insert(actions, 1, string.format('%d. Hoogle search: `%s`', #actions + 1, cword))
table.insert(_state.commands, function()
log.debug { 'Hover: Hoogle search for cword', cword }
ht.hoogle.hoogle_signature { search_term = cword }
end)
local params = ctx.params
local found_location = false
local found_type_definition = false
local found_documentation = false
local found_source = false
for i, value in ipairs(markdown_lines) do
if vim.startswith(value, '[Documentation]') and not found_documentation then
found_documentation = true
table.insert(to_remove, 1, i)
table.insert(actions, 1, string.format('%d. Open documentation in browser', #actions + 1))
local uri = string.match(value, '%[Documentation%]%((.+)%)')
table.insert(_state.commands, function()
log.debug { 'Hover: Open documentation in browser', uri }
OS.open_browser(uri)
end)
elseif vim.startswith(value, '[Source]') and not found_source then
found_source = true
table.insert(to_remove, 1, i)
table.insert(actions, 1, string.format('%d. View source in browser', #actions + 1))
local uri = string.match(value, '%[Source%]%((.+)%)')
table.insert(_state.commands, function()
log.debug { 'Hover: View source in browser', uri }
OS.open_browser(uri)
end)
end
local location = string.match(value, '*Defined [ia][nt] (.+)')
local current_file = params.textDocument.uri:gsub('file://', '')
local results, err, definition_results
if location == nil or found_location then
goto SkipDefinition
end
found_location = true
table.insert(to_remove, 1, i)
results, err = vim.lsp.buf_request_sync(0, 'textDocument/definition', params, 1000)
if err or results == nil or #results == 0 then
goto SkipDefinition
end
definition_results = results[1] and results[1].result or {}
if #definition_results > 0 then
local location_suffix = ('%s'):format(format_location(location, current_file))
local definition_result = definition_results[1]
if not is_same_position(params, definition_result) then
log.debug { 'Hover: definition location', location_suffix }
table.insert(actions, 1, string.format('%d. Go to definition at ' .. location_suffix, #actions + 1))
table.insert(_state.commands, function()
-- We don't call vim.lsp.buf.definition() because the location params may have changed
local definition_ctx = vim.tbl_extend('force', ctx, {
method = 'textDocument/definition',
})
log.debug { 'Hover: Go to definition', definition_result }
---Neovim 0.9 has a bug in the lua doc
---@diagnostic disable-next-line: param-type-mismatch
vim.lsp.handlers['textDocument/definition'](nil, definition_result, definition_ctx)
end)
end
else -- Display Hoogle search instead
local pkg = location:match('(.+)')
local search_term = pkg and pkg .. '.' .. cword or cword
table.insert(actions, 1, string.format('%d. Hoogle search: `%s`', #actions + 1, search_term))
table.insert(_state.commands, function()
log.debug { 'Hover: Hoogle search for definition', search_term }
ht.hoogle.hoogle_signature { search_term = search_term }
end)
end
table.insert(actions, 1, string.format('%d. Find references', #actions + 1))
table.insert(_state.commands, function()
local reference_params = vim.tbl_deep_extend('force', params, { context = { includeDeclaration = true } })
log.debug { 'Hover: Find references', reference_params }
-- We don't call vim.lsp.buf.references() because the location params may have changed
---@diagnostic disable-next-line: missing-parameter
vim.lsp.buf_request(0, 'textDocument/references', reference_params)
end)
::SkipDefinition::
if found_type_definition then
goto SkipTypeDefinition
end
results, err = vim.lsp.buf_request_sync(0, 'textDocument/typeDefinition', params, 1000)
if err or results == nil or #results == 0 then -- Can go to type definition
goto SkipTypeDefinition
end
found_type_definition = true
local type_definition_results = results[1] and results[1].result or {}
if #type_definition_results == 0 then
goto SkipTypeDefinition
end
local type_definition_result = type_definition_results[1]
local type_def_suffix = format_location(mk_location(type_definition_result), current_file)
if is_same_position(params, result) then
goto SkipTypeDefinition
end
log.debug { 'Hover: type definition location', type_def_suffix }
table.insert(actions, 1, string.format('%d. Go to type definition at ' .. type_def_suffix, #actions + 1))
table.insert(_state.commands, function()
-- We don't call vim.lsp.buf.typeDefinition() because the location params may have changed
local type_definition_ctx = vim.tbl_extend('force', ctx, {
method = 'textDocument/typeDefinition',
})
log.debug { 'Hover: Go to type definition', type_definition_result }
---Neovim 0.9 has a bug in the lua doc
---@diagnostic disable-next-line: param-type-mismatch
vim.lsp.handlers['textDocument/typeDefinition'](nil, type_definition_result, type_definition_ctx)
end)
::SkipTypeDefinition::
end
for _, pos in ipairs(to_remove) do
table.remove(markdown_lines, pos)
end
for _, action in ipairs(actions) do
table.insert(markdown_lines, 1, action)
end
if #actions > 0 then
table.insert(markdown_lines, #actions + 1, '')
table.insert(markdown_lines, #actions + 1, '')
end
local HTConfig = require('haskell-tools.config.internal')
local opts = HTConfig.tools.hover
config = vim.tbl_extend('keep', {
border = opts.border,
stylize_markdown = opts.stylize_markdown,
focusable = true,
focus_id = 'haskell-tools-hover',
close_events = { 'CursorMoved', 'BufHidden', 'InsertCharPre' },
}, config or {})
local bufnr, winnr = lsp_util.open_floating_preview(markdown_lines, 'markdown', config)
if opts.stylize_markdown == false then
vim.bo[bufnr].filetype = 'markdown'
end
if opts.auto_focus == true then
vim.api.nvim_set_current_win(winnr)
end
if _state.winnr ~= nil then
return bufnr, winnr
end
_state.winnr = winnr
vim.keymap.set('n', '<Esc>', close_hover, { buffer = bufnr, noremap = true, silent = true })
vim.api.nvim_buf_attach(bufnr, false, {
on_detach = function()
_state.winnr = nil
end,
})
if #_state.commands == 0 then
return bufnr, winnr
end
vim.api.nvim_set_option_value('cursorline', true, { win = winnr })
-- run the command under the cursor
vim.keymap.set('n', '<CR>', function()
run_command()
end, { buffer = bufnr, noremap = true, silent = true })
return bufnr, winnr
end
return hover

View File

@ -0,0 +1,265 @@
---@mod haskell-tools.lsp haskell-language-server LSP client tools
local HTConfig = require('haskell-tools.config.internal')
local log = require('haskell-tools.log.internal')
local Types = require('haskell-tools.types.internal')
local compat = require('haskell-tools.compat')
---@brief [[
--- The following commands are available:
---
--- * `:HlsStart` - Start the LSP client.
--- * `:HlsStop` - Stop the LSP client.
--- * `:HlsRestart` - Restart the LSP client.
--- * `:HlsEvalAll` - Evaluate all code snippets in comments.
---@brief ]]
---To minimise the risk of this occurring, we attempt to shut down hls cleanly before exiting neovim.
---@param client lsp.Client The LSP client
---@param bufnr number The buffer number
---@return nil
local function ensure_clean_exit_on_quit(client, bufnr)
vim.api.nvim_create_autocmd('VimLeavePre', {
group = vim.api.nvim_create_augroup('haskell-tools-hls-clean-exit-' .. tostring(client.id), { clear = true }),
callback = function()
log.debug('Stopping LSP client...')
vim.lsp.stop_client(client, false)
end,
buffer = bufnr,
})
end
---A workaround for #48:
---Some plugins that add LSP client capabilities which are not built-in to neovim
---(like nvim-ufo and nvim-lsp-selection-range) cause error messages, because
---haskell-language-server falsly advertises those server_capabilities for cabal files.
---@param client lsp.Client
---@return nil
local function fix_cabal_client(client)
local LspHelpers = require('haskell-tools.lsp.helpers')
if client.name == LspHelpers.cabal_client_name and client.server_capabilities then
---@diagnostic disable-next-line: inject-field
client.server_capabilities = vim.tbl_extend('force', client.server_capabilities, {
foldingRangeProvider = false,
selectionRangeProvider = false,
documentHighlightProvider = false,
})
end
end
---@class LoadHlsSettingsOpts
---@field settings_file_pattern string|nil File name or pattern to search for. Defaults to 'hls.json'
log.debug('Setting up the LSP client...')
local hls_opts = HTConfig.hls
local handlers = {}
local tools_opts = HTConfig.tools
local definition_opts = tools_opts.definition or {}
if Types.evaluate(definition_opts.hoogle_signature_fallback) then
local lsp_definition = require('haskell-tools.lsp.definition')
log.debug('Wrapping vim.lsp.buf.definition with Hoogle signature fallback.')
handlers['textDocument/definition'] = lsp_definition.mk_hoogle_fallback_definition_handler(definition_opts)
end
local hover_opts = tools_opts.hover
if Types.evaluate(hover_opts.enable) then
local hover = require('haskell-tools.lsp.hover')
handlers['textDocument/hover'] = hover.on_hover
end
---@class HlsTools
local HlsTools = {}
---Search the project root for a haskell-language-server settings JSON file and load it to a Lua table.
---Falls back to the `hls.default_settings` if no file is found or file cannot be read or decoded.
---@param project_root string|nil The project root
---@param opts LoadHlsSettingsOpts|nil
---@return table hls_settings
---@see https://haskell-language-server.readthedocs.io/en/latest/configuration.html
HlsTools.load_hls_settings = function(project_root, opts)
local default_settings = HTConfig.hls.default_settings
if not project_root then
return default_settings
end
local default_opts = { settings_file_pattern = 'hls.json' }
opts = vim.tbl_deep_extend('force', {}, default_opts, opts or {})
local results = vim.fn.glob(compat.joinpath(project_root, opts.settings_file_pattern), true, true)
if #results == 0 then
log.info(opts.settings_file_pattern .. ' not found in project root ' .. project_root)
return default_settings
end
local settings_json = results[1]
local OS = require('haskell-tools.os')
local content = OS.read_file(settings_json)
local success, settings = pcall(vim.json.decode, content)
if not success then
local msg = 'Could not decode ' .. settings_json .. '. Falling back to default settings.'
log.warn { msg, error }
vim.notify('haskell-tools.lsp: ' .. msg, vim.log.levels.WARN)
return default_settings
end
log.debug { 'hls settings', settings }
return settings or default_settings
end
---Start or attach the LSP client.
---Fails silently if the buffer's filetype is not one of the filetypes specified in the config.
---@param bufnr number|nil The buffer number (optional), defaults to the current buffer
---@return number|nil client_id The LSP client ID
HlsTools.start = function(bufnr)
local ht = require('haskell-tools')
bufnr = bufnr or vim.api.nvim_get_current_buf()
local file = vim.api.nvim_buf_get_name(bufnr)
if not file or #file == 0 then
local msg = 'Could not determine the name of buffer ' .. bufnr .. '.'
log.debug('lsp.start: ' .. msg)
return
end
local HtProjectHelpers = require('haskell-tools.project.helpers')
local is_cabal = HtProjectHelpers.is_cabal_file(bufnr)
local project_root = ht.project.root_dir(file)
local hls_settings = type(hls_opts.settings) == 'function' and hls_opts.settings(project_root) or hls_opts.settings
local LspHelpers = require('haskell-tools.lsp.helpers')
local cmd = LspHelpers.get_hls_cmd()
local hls_bin = cmd[1]
if vim.fn.executable(hls_bin) == 0 then
log.warn('Executable ' .. hls_bin .. ' not found.')
end
local lsp_start_opts = {
name = is_cabal and LspHelpers.cabal_client_name or LspHelpers.haskell_client_name,
cmd = Types.evaluate(cmd),
root_dir = project_root,
filetypes = is_cabal and { 'cabal', 'cabalproject' } or { 'haskell', 'lhaskell' },
capabilities = hls_opts.capabilities,
handlers = handlers,
settings = hls_settings,
on_attach = function(client_id, buf)
log.debug('LSP attach')
local ok, err = pcall(hls_opts.on_attach, client_id, buf, ht)
if not ok then
---@cast err string
log.error { 'on_attach failed', err }
vim.notify('haskell-tools.lsp: Error in hls.on_attach: ' .. err)
end
local function buf_refresh_codeLens()
vim.schedule(function()
for _, client in pairs(LspHelpers.get_active_ht_clients(bufnr)) do
if client.server_capabilities.codeLensProvider then
vim.lsp.codelens.refresh()
return
end
end
end)
end
local code_lens_opts = tools_opts.codeLens or {}
if Types.evaluate(code_lens_opts.autoRefresh) then
vim.api.nvim_create_autocmd({ 'InsertLeave', 'BufWritePost', 'TextChanged' }, {
group = vim.api.nvim_create_augroup('haskell-tools-code-lens', {}),
callback = buf_refresh_codeLens,
buffer = buf,
})
buf_refresh_codeLens()
end
end,
on_init = function(client, _)
ensure_clean_exit_on_quit(client, bufnr)
fix_cabal_client(client)
end,
}
log.debug('LSP start options: lsp_start_opts')
local client_id = vim.lsp.start(lsp_start_opts)
return client_id
end
---Stop the LSP client.
---@param bufnr number|nil The buffer number (optional), defaults to the current buffer
---@return table[] clients A list of clients that will be stopped
HlsTools.stop = function(bufnr)
bufnr = bufnr or vim.api.nvim_get_current_buf()
local LspHelpers = require('haskell-tools.lsp.helpers')
local clients = LspHelpers.get_active_ht_clients(bufnr)
vim.lsp.stop_client(clients)
return clients
end
---Restart the LSP client.
---Fails silently if the buffer's filetype is not one of the filetypes specified in the config.
---@param bufnr number|nil The buffer number (optional), defaults to the current buffer
---@return number|nil client_id The LSP client ID after restart
HlsTools.restart = function(bufnr)
local lsp = require('haskell-tools').lsp
bufnr = bufnr or vim.api.nvim_get_current_buf()
local clients = lsp.stop(bufnr)
local timer, err_name, err_msg = compat.uv.new_timer()
if not timer then
log.error { 'Could not create timer', err_name, err_msg }
return
end
local attempts_to_live = 50
local stopped_client_count = 0
timer:start(200, 100, function()
for _, client in ipairs(clients) do
if client:is_stopped() then
stopped_client_count = stopped_client_count + 1
vim.schedule(function()
lsp.start(bufnr)
end)
end
end
if stopped_client_count >= #clients then
timer:stop()
attempts_to_live = 0
elseif attempts_to_live <= 0 then
vim.notify('haslell-tools.lsp: Could not restart all LSP clients.', vim.log.levels.ERROR)
timer:stop()
attempts_to_live = 0
end
attempts_to_live = attempts_to_live - 1
end)
end
---Evaluate all code snippets in comments.
---@param bufnr number|nil Defaults to the current buffer.
---@return nil
HlsTools.buf_eval_all = function(bufnr)
local eval = require('haskell-tools.lsp.eval')
return eval.all(bufnr)
end
local commands = {
{
'HlsStart',
function()
HlsTools.start()
end,
{},
},
{
'HlsStop',
function()
HlsTools.stop()
end,
{},
},
{
'HlsRestart',
function()
HlsTools.restart()
end,
{},
},
{
'HlsEvalAll',
function()
HlsTools.buf_eval_all()
end,
{},
},
}
for _, command in ipairs(commands) do
vim.api.nvim_create_user_command(unpack(command))
end
return HlsTools

View File

@ -0,0 +1,80 @@
---@mod haskell-tools.os
---@brief [[
---WARNING: This is not part of the public API.
---Breaking changes to this module will not be reflected in the semantic versioning of this plugin.
--- Functions for interacting with the operating system
---@brief ]]
local compat = require('haskell-tools.compat')
local log = require('haskell-tools.log.internal')
local uv = compat.uv
---@class OS
local OS = {}
---@param url string
---@return nil
OS.open_browser = function(url)
local browser_cmd
if vim.fn.has('unix') == 1 then
if vim.fn.executable('sensible-browser') == 1 then
browser_cmd = 'sensible-browser'
else
browser_cmd = 'xdg-open'
end
end
if vim.fn.has('mac') == 1 then
browser_cmd = 'open'
end
if browser_cmd and vim.fn.executable(browser_cmd) == 1 then
local cmd = { browser_cmd, url }
log.debug { 'Opening browser', cmd }
compat.system(cmd, nil, function(sc)
---@cast sc vim.SystemCompleted
if sc.code ~= 0 then
log.error { 'Error opening browser', sc.code, sc.stderr }
end
end)
return
end
local msg = 'No executable found to open the browser.'
log.error(msg)
vim.notify('haskell-tools.hoogle: ' .. msg, vim.log.levels.ERROR)
end
---Read the content of a file
---@param filename string
---@return string|nil content
OS.read_file = function(filename)
local content
local f = io.open(filename, 'r')
if f then
content = f:read('*a')
f:close()
end
return content
end
---Asynchronously the content of a file
---@param filename string
---@return string|nil content
---@async
OS.read_file_async = function(filename)
local file_fd = uv.fs_open(filename, 'r', 438)
if not file_fd then
return nil
end
local stat = uv.fs_fstat(file_fd)
if not stat then
return nil
end
local data = uv.fs_read(file_fd, stat.size, 0)
uv.fs_close(file_fd)
---@cast data string?
return data
end
return OS

View File

@ -0,0 +1,52 @@
---@mod haskell-tools.parser
---@brief [[
---WARNING: This is not part of the public API.
---Breaking changes to this module will not be reflected in the semantic versioning of this plugin.
--- Parsing functions
---@brief ]]
---@class HtParser
local HtParser = {}
--- Pretty-print a type signature
--- @param sig string|nil The raw signature
--- @return string|nil pp_sig The pretty-printed signature
local function pp_signature(sig)
local pp_sig = sig
and sig
:gsub('\n', ' ') -- join lines
:gsub('forall .*%.%s', '') -- hoogle cannot search for `forall a.`
:gsub('^%s*(.-)%s*$', '%1') -- trim
return pp_sig
end
--- Get the type signature of the word under the cursor from markdown
--- @param func_name string the name of the function
--- @param docs string Markdown docs
--- @return string|nil function_signature Type signature, or the word under the cursor if none was found
--- @return string[] signatures Other type signatures returned by hls
HtParser.try_get_signatures_from_markdown = function(func_name, docs)
local all_sigs = {}
---@type string|nil
local raw_func_sig = docs:match('```haskell\n' .. func_name .. '%s::%s([^```]*)')
for code_block in docs:gmatch('```haskell\n([^```]*)\n```') do
---@type string|nil
local sig = code_block:match('::%s([^```]*)')
local pp_sig = sig and pp_signature(sig)
if sig and not vim.tbl_contains(all_sigs, pp_sig) then
table.insert(all_sigs, pp_sig)
end
end
return raw_func_sig and pp_signature(raw_func_sig), all_sigs
end
---@param str string
---@return integer indent
HtParser.get_indent = function(str)
return #(str:match('^(%s+)%S') or '')
end
return HtParser

View File

@ -0,0 +1,124 @@
---@mod haskell-tools.cabal
---@brief [[
---WARNING: This is not part of the public API.
---Breaking changes to this module will not be reflected in the semantic versioning of this plugin.
--- Helper functions related to cabal projects
---@brief ]]
local Strings = require('haskell-tools.strings')
local HtParser = require('haskell-tools.parser')
local Dap = require('haskell-tools.dap.internal')
local OS = require('haskell-tools.os')
local compat = require('haskell-tools.compat')
---@class CabalProjectHelper
local CabalProjectHelper = {}
---@class CabalEntryPointParserData
---@field idx integer
---@field lines string[]
---@field line string
---@field package_dir string
---@class CabalEntryPointParserState
---@field package_name string
---@field entry_points HsEntryPoint[]
---@field mains string[]
---@field source_dirs string[]
---@field src_dir_indent_pattern string
---@field exe_name string | nil
---@param data CabalEntryPointParserData
---@param state CabalEntryPointParserState
local function get_entrypoint_from_line(data, state)
local package_dir = data.package_dir
local idx = data.idx
local lines = data.lines
local line = data.line
state.package_name = state.package_name or line:match('^name:%s*(.+)')
local no_indent = HtParser.get_indent(line) == 0
if no_indent or idx == #lines then
vim.list_extend(
state.entry_points,
Dap.mk_entry_points(state.package_name, state.exe_name, package_dir, state.mains, state.source_dirs)
)
state.mains = {}
state.source_dirs = {}
state.exe_name = nil
end
state.exe_name = state.exe_name or line:match('^%S+%s+(.+)') or state.package_name
-- main detection
local main = line:match('main%-is:%s+(.+)%.hs')
if not main and lines[idx + 1] and line:match('main%-is:') then
main = (lines[idx + 1]):match('%s+(.+)%.hs')
end
if main then
table.insert(state.mains, main .. '.hs')
end
-- source directory detection
local is_src_dir_end = state.src_dir_indent_pattern and (line == '' or line:match(state.src_dir_indent_pattern))
if is_src_dir_end then
state.src_dir_indent_pattern = nil
end
if state.src_dir_indent_pattern then
local source_dir = line:match(',%s*(.*)') or line:match('%s+(.*)')
if source_dir then
table.insert(state.source_dirs, source_dir)
end
else
local source_dirs_indent = line:match('(%s*)hs%-source%-dirs:')
if source_dirs_indent then
state.src_dir_indent_pattern = '^' .. ('%s'):rep(#source_dirs_indent) .. '%S+'
end
end
end
---Parse the DAP entry points from a *.cabal file
---@param package_file string Path to the *.cabal file
---@return HsEntryPoint[] entry_points
---@async
local function parse_package_entrypoints(package_file)
local state = {
entry_points = {},
mains = {},
source_dirs = {},
}
local package_dir = vim.fn.fnamemodify(package_file, ':h') or package_file
local entry_points = {}
local content = OS.read_file_async(package_file)
if not content then
return entry_points
end
local lines = vim.split(content, '\n') or {}
for idx, line in ipairs(lines) do
local is_comment = vim.startswith(Strings.trim(line), '--')
if not is_comment then
---@type CabalEntryPointParserData
local data = {
package_dir = package_dir,
line = line,
lines = lines,
idx = idx,
}
get_entrypoint_from_line(data, state)
end
end
return state.entry_points
end
---Parse the DAP entry points from a *.cabal file
---@param package_path string Path to a package directory
---@return HsEntryPoint[] entry_points
---@async
function CabalProjectHelper.parse_package_entrypoints(package_path)
local entry_points = {}
for _, package_file in pairs(vim.fn.glob(compat.joinpath(package_path, '*.cabal'), true, true)) do
vim.list_extend(entry_points, parse_package_entrypoints(package_file))
end
return entry_points
end
return CabalProjectHelper

View File

@ -0,0 +1,264 @@
---@mod haskell-tools.project
---@brief [[
---WARNING: This is not part of the public API.
---Breaking changes to this module will not be reflected in the semantic versioning of this plugin.
--- Utility functions for analysing a project.
---@brief ]]
local log = require('haskell-tools.log.internal')
local Strings = require('haskell-tools.strings')
local OS = require('haskell-tools.os')
local cabal = require('haskell-tools.project.cabal')
local stack = require('haskell-tools.project.stack')
local compat = require('haskell-tools.compat')
---@class HtProjectHelpers
local HtProjectHelpers = {}
---@param path string
---@return string stripped_path For zipfile: or tarfile: virtual paths, returns the path to the archive. Other paths are returned unaltered.
--- Taken from nvim-lspconfig
local function strip_archive_subpath(path)
-- Matches regex from zip.vim / tar.vim
path = vim.fn.substitute(path, 'zipfile://\\(.\\{-}\\)::[^\\\\].*$', '\\1', '') or path
path = vim.fn.substitute(path, 'tarfile:\\(.\\{-}\\)::.*$', '\\1', '') or path
return path
end
---@param path string the file path to search in
---@param ... string Search patterns (can be globs)
---@return string|nil The first file that matches the globs
local function find_file(path, ...)
for _, search_term in ipairs(compat.tbl_flatten { ... }) do
local results = vim.fn.glob(compat.joinpath(path, search_term), true, true)
if #results > 0 then
return results[1]
end
end
end
---Iterate the path until we find the rootdir.
---@param startpath string The start path
---@return fun(_:any,path:string):(string?,string?)
---@return string startpath
---@return string startpath
local function iterate_parents(startpath)
---@param _ any Ignored
---@param path string file path
---@return string|nil path
---@return string|nil startpath
local function it(_, path)
local next = vim.fn.fnamemodify(path, ':h')
if not next or vim.fn.isdirectory(next) == 0 or next == path or next == '/nix/store' then
return
end
if compat.uv.fs_realpath(next) then
return next, startpath
end
end
return it, startpath, startpath
end
---@param startpath string The start path to search upward from
---@param matcher fun(path:string):string|nil
---@return string|nil
local function search_ancestors(startpath, matcher)
if matcher(startpath) then
return startpath
end
local max_iterations = 100
for path in iterate_parents(startpath) do
max_iterations = max_iterations - 1
if max_iterations == 0 then
return
end
if not path then
return
end
if matcher(path) then
return path
end
end
end
---@param ... string Globs to match in the root directory
---@return fun(path:string):(string|nil)
local function root_pattern(...)
local args = compat.tbl_flatten { ... }
local function matcher(path)
return find_file(path, unpack(args))
end
return function(path)
local startpath = strip_archive_subpath(path)
return search_ancestors(startpath, matcher)
end
end
---@param path string
---@return string escaped_path
local function escape_glob_wildcards(path)
local escaped_path = path:gsub('([%[%]%?%*])', '\\%1')
return escaped_path
end
---Get the root of a cabal multi-package project for a path
HtProjectHelpers.match_cabal_multi_project_root = root_pattern('cabal.project')
---Get the root of a cabal package for a path
HtProjectHelpers.match_cabal_package_root = root_pattern('*.cabal')
---Get the root of the cabal project for a path
---@param path string File path
HtProjectHelpers.match_cabal_project_root = function(path)
return HtProjectHelpers.match_cabal_multi_project_root(path) or HtProjectHelpers.match_cabal_package_root(path)
end
---Get the root of the stack project for a path
HtProjectHelpers.match_stack_project_root = root_pattern('stack.yaml')
---Get the root of the project for a path
HtProjectHelpers.match_project_root = root_pattern('cabal.project', 'stack.yaml')
---Get the root of the package for a path
HtProjectHelpers.match_package_root = root_pattern('*.cabal', 'package.yaml')
---Get the directory containing a haskell-language-server hie.yaml
HtProjectHelpers.match_hie_yaml = root_pattern('hie.yaml')
---Get the package.yaml for a given path
---@param path string
---@return string|nil package_yaml_path
function HtProjectHelpers.get_package_yaml(path)
local match = root_pattern('package.yaml')
local dir = match(path)
return dir and dir .. '/package.yaml'
end
---Get the *.cabal for a given path
---@param path string
---@return string|nil cabal_file_path
function HtProjectHelpers.get_package_cabal(path)
local match = root_pattern('*.cabal')
local dir = match(path)
if not dir then
return nil
end
dir = escape_glob_wildcards(dir)
for _, pattern in ipairs(vim.fn.glob(compat.joinpath(dir, '*.cabal'), true, true)) do
if pattern then
return pattern
end
end
end
---Is `path` part of a cabal project?
---@param path string
---@return boolean is_cabal_project
function HtProjectHelpers.is_cabal_project(path)
local get_root = root_pattern('*.cabal', 'cabal.project')
if get_root(path) ~= nil then
log.debug('Detected cabal project.')
return true
end
return false
end
---Is `path` part of a stack project?
---@param path string
---@return boolean is_stack_project
function HtProjectHelpers.is_stack_project(path)
if HtProjectHelpers.match_stack_project_root(path) ~= nil then
log.debug('Detected stack project.')
return true
end
return false
end
---Get the package name for a given path
---@param path string
---@return string|nil package_name
function HtProjectHelpers.get_package_name(path)
local package_path = HtProjectHelpers.match_package_root(path)
return package_path and vim.fn.fnamemodify(package_path, ':t')
end
---Parse the package paths (absolute) from a project file
---@param project_file string project file (cabal.project or stack.yaml)
---@return string[] package_paths
---@async
function HtProjectHelpers.parse_package_paths(project_file)
local package_paths = {}
local content = OS.read_file_async(project_file)
if not content then
return package_paths
end
local project_dir = vim.fn.fnamemodify(project_file, ':h')
local lines = vim.split(content, '\n') or {}
local packages_start = false
for _, line in ipairs(lines) do
if packages_start then
local is_indented = line:match('^%s') ~= nil
local is_yaml_list_elem = line:match('^%-') ~= nil
if not (is_indented or is_yaml_list_elem) then
return package_paths
end
end
if packages_start then
local trimmed = Strings.trim(line)
local pkg_rel_path = trimmed:match('/(.+)')
local pkg_path = compat.joinpath(project_dir, pkg_rel_path)
if vim.fn.isdirectory(pkg_path) == 1 then
package_paths[#package_paths + 1] = pkg_path
end
end
if line:match('packages:') then
packages_start = true
end
end
return package_paths
end
---Parse the DAP entry points from a *.cabal file
---@param package_path string Path to a package directory
---@return HsEntryPoint[] entry_points
---@async
function HtProjectHelpers.parse_package_entrypoints(package_path)
if HtProjectHelpers.is_cabal_project(package_path) then
return cabal.parse_package_entrypoints(package_path)
end
return stack.parse_package_entrypoints(package_path)
end
---@param project_root string Project root directory
---@return HsEntryPoint[]
---@async
function HtProjectHelpers.parse_project_entrypoints(project_root)
local entry_points = {}
local project_file = compat.joinpath(project_root, 'cabal.project')
if vim.fn.filereadable(project_file) == 1 then
for _, package_path in pairs(HtProjectHelpers.parse_package_paths(project_file)) do
vim.list_extend(entry_points, cabal.parse_package_entrypoints(package_path))
end
return entry_points
end
project_file = compat.joinpath(project_root, 'stack.yaml')
if vim.fn.filereadable(project_file) == 1 then
for _, package_path in pairs(HtProjectHelpers.parse_package_paths(project_file)) do
vim.list_extend(entry_points, stack.parse_package_entrypoints(package_path))
end
return entry_points
end
return cabal.parse_package_entrypoints(project_root)
end
---@param bufnr number The buffer number
---@return boolean is_cabal_file
HtProjectHelpers.is_cabal_file = function(bufnr)
local filetype = vim.bo[bufnr].filetype
return filetype == 'cabal' or filetype == 'cabalproject'
end
return HtProjectHelpers

View File

@ -0,0 +1,174 @@
---@mod haskell-tools.project haskell-tools Project module
local log = require('haskell-tools.log.internal')
local deps = require('haskell-tools.deps')
---@brief [[
--- The following commands are available:
---
--- * `:HsProjectFile` - Open the project file for the current buffer (cabal.project or stack.yaml).
--- * `:HsPackageYaml` - Open the package.yaml file for the current buffer.
--- * `:HsPackageCabal` - Open the *.cabal file for the current buffer.
---@brief ]]
---@param callback fun(opts:table<string,any>):nil
---@param opts table<string,any>
local function telescope_package_search(callback, opts)
local file = vim.api.nvim_buf_get_name(0)
if vim.fn.filewritable(file) == 0 then
local err_msg = 'Telescope package search: File not found: ' .. file
log.error(err_msg)
vim.notify(err_msg, vim.log.levels.ERROR)
return
end
local HtProjectHelpers = require('haskell-tools.project.helpers')
local package_root = HtProjectHelpers.match_package_root(file)
if not package_root then
local err_msg = 'Telescope package search: No package root found for file ' .. file
log.error(err_msg)
vim.notify(err_msg, vim.log.levels.ERROR)
return
end
opts = vim.tbl_deep_extend('keep', {
cwd = package_root,
prompt_title = (opts.prompt_title_prefix or 'Package') .. ': ' .. vim.fn.fnamemodify(package_root, ':t'),
}, opts or {})
callback(opts)
end
log.debug('Setting up project tools...')
--- Live grep the current package with telescope.
--- available if nvim-telescope/telescope.nvim is installed.
---@param opts table|nil telescope options
local function telescope_package_grep(opts)
local t = require('telescope.builtin')
opts = vim.tbl_deep_extend('keep', { prompt_title_prefix = 'package live grep' }, opts or {})
telescope_package_search(t.live_grep, opts)
end
--- Find file in the current package with telescope
--- available if nvim-telescope/telescope.nvim is installed.
---@param opts table|nil telescope options
local function telescope_package_files(opts)
local t = require('telescope.builtin')
opts = vim.tbl_deep_extend('keep', { prompt_title_prefix = 'package file search' }, opts or {})
telescope_package_search(t.find_files, opts)
end
---@class HsProjectTools
local HsProjectTools = {}
---Get the project's root directory
---@param project_file string The path to a project file
---@return string|nil
HsProjectTools.root_dir = function(project_file)
local HtProjectHelpers = require('haskell-tools.project.helpers')
return HtProjectHelpers.match_cabal_project_root(project_file)
or HtProjectHelpers.match_stack_project_root(project_file)
or HtProjectHelpers.match_package_root(project_file)
or HtProjectHelpers.match_hie_yaml(project_file)
end
---Open the package.yaml of the package containing the current buffer.
---@return nil
HsProjectTools.open_package_yaml = function()
local HtProjectHelpers = require('haskell-tools.project.helpers')
vim.schedule(function()
local file = vim.api.nvim_buf_get_name(0)
local result = HtProjectHelpers.get_package_yaml(file)
if not result then
local context = ''
if HtProjectHelpers.is_cabal_project(file) then
context = ' cabal project file'
end
local err_msg = 'HsPackageYaml: Cannot find package.yaml file for' .. context .. ': ' .. file
log.error(err_msg)
vim.notify(err_msg, vim.log.levels.ERROR)
return
end
vim.cmd('e ' .. result)
end)
end
---Open the *.cabal file of the package containing the current buffer.
---@return nil
HsProjectTools.open_package_cabal = function()
vim.schedule(function()
local HtProjectHelpers = require('haskell-tools.project.helpers')
local file = vim.api.nvim_buf_get_name(0)
if vim.fn.filewritable(file) ~= 0 and not HtProjectHelpers.is_cabal_project(file) then
vim.notify('HsPackageCabal: Not a cabal project?', vim.log.levels.ERROR)
return
end
local result = HtProjectHelpers.get_package_cabal(file)
if not result then
local err_msg = 'HsPackageCabal: Cannot find *.cabal file for: ' .. file
log.error(err_msg)
vim.notify(err_msg, vim.log.levels.ERROR)
return
end
vim.cmd('e ' .. result)
end)
end
---Open the current buffer's project file (cabal.project or stack.yaml).
---@return nil
HsProjectTools.open_project_file = function()
vim.schedule(function()
local HtProjectHelpers = require('haskell-tools.project.helpers')
local file = vim.api.nvim_buf_get_name(0)
local stack_project_root = HtProjectHelpers.match_stack_project_root(file)
if stack_project_root then
vim.cmd('e ' .. stack_project_root .. '/stack.yaml')
return
end
local cabal_project_root = HtProjectHelpers.match_cabal_multi_project_root(file)
if cabal_project_root then
vim.cmd('e ' .. cabal_project_root .. '/cabal.project')
return
end
local package_cabal = HtProjectHelpers.get_package_cabal(file)
if package_cabal then
vim.cmd('e ' .. package_cabal)
end
local err_msg = 'HsProjectFile: Cannot find project file from: ' .. file
log.error(err_msg)
vim.notify(err_msg, vim.log.levels.ERROR)
end)
end
HsProjectTools.telescope_package_grep = deps.has('telescope.builtin') and telescope_package_grep or nil
HsProjectTools.telescope_package_files = deps.has('telescope.builtin') and telescope_package_files or nil
local commands = {
{
'HsPackageYaml',
function()
HsProjectTools.open_package_yaml()
end,
{},
},
{
'HsPackageCabal',
function()
HsProjectTools.open_package_cabal()
end,
{},
},
{
'HsProjectFile',
function()
HsProjectTools.open_project_file()
end,
{},
},
}
--- Available if nvim-telescope/telescope.nvim is installed.
for _, command in ipairs(commands) do
vim.api.nvim_create_user_command(unpack(command))
end
return HsProjectTools

View File

@ -0,0 +1,159 @@
---@mod haskell-tools.stack
---@brief [[
---WARNING: This is not part of the public API.
---Breaking changes to this module will not be reflected in the semantic versioning of this plugin.
--- Helper functions related to stack projects
---@brief ]]
local Strings = require('haskell-tools.strings')
local HtParser = require('haskell-tools.parser')
local Dap = require('haskell-tools.dap.internal')
local OS = require('haskell-tools.os')
local compat = require('haskell-tools.compat')
---@class StackProjectHelper
local StackProjectHelper = {}
---@param str string
---@return boolean is_yaml_comment
local function is_yaml_comment(str)
return vim.startswith(Strings.trim(str), '#')
end
---@class StackEntryPointParserData
---@field idx integer
---@field lines string[]
---@field line string
---@field package_dir string
---@field next_line string|nil
---@class StackEntryPointParserState
---@field package_name string
---@field entry_points HsEntryPoint[]
---@field mains string[]
---@field source_dirs string[]
---@field parsing_exe_list boolean
---@field parsing_exe boolean
---@field parsing_source_dirs boolean
---@field exe_indent integer | nil
---@field exe_name string | nil
---@param data StackEntryPointParserData
---@param state StackEntryPointParserState
local function parse_exe_list_line(data, state)
local package_dir = data.package_dir
local idx = data.idx
local lines = data.lines
local line = data.line
local next_line = lines[idx + 1]
local indent = HtParser.get_indent(line)
state.exe_indent = state.exe_indent or indent
state.exe_name = indent == state.exe_indent and line:match('%s*(.+):') or state.exe_name
if state.parsing_exe then
local main = line:match('main:%s+(.+)%.hs')
if not main and line:match('main:') and next_line then
main = next_line:match('%s+(.+)%.hs')
end
if main then
table.insert(state.mains, main .. '.hs')
end
local source_dir = line:match('source%-dirs:%s+(.+)')
if source_dir then
-- Single source directory
state.parsing_source_dirs = false
end
if state.parsing_source_dirs then
source_dir = line:match('%s+%-%s*(.*)') or line:match('%s+(.*)')
end
if source_dir then
table.insert(state.source_dirs, source_dir)
end
local is_source_dir_list = not source_dir and line:match('source%-dirs:') ~= nil
state.parsing_source_dirs = is_source_dir_list
or (
state.parsing_source_dirs
and next_line
and (next_line:match('^%s+%-') or HtParser.get_indent(next_line) > indent)
)
end
if state.parsing_exe and (not next_line or HtParser.get_indent(next_line) == 0 or indent <= state.exe_indent) then
vim.list_extend(
state.entry_points,
Dap.mk_entry_points(state.package_name, state.exe_name, package_dir, state.mains, state.source_dirs)
)
state.mains = {}
state.source_dirs = {}
state.parsing_exe = false
else
state.parsing_exe = indent >= state.exe_indent
end
end
---@param data StackEntryPointParserData
---@param state StackEntryPointParserState
local function get_entrypoint_from_line(data, state)
local line = data.line
state.package_name = state.package_name or line:match('^name:%s*(.+)')
local indent = HtParser.get_indent(line)
if indent == 0 then
state.parsing_exe_list = false
state.exe_indent = nil
end
if state.parsing_exe_list then
parse_exe_list_line(data, state)
end
end
---Parse the DAP entry points from a *.cabal file
---@param package_file string Path to the *.cabal file
---@return HsEntryPoint[] entry_points
---@async
local function parse_package_entrypoints(package_file)
local state = {
entry_points = {},
mains = {},
source_dirs = {},
parsing_exe_list = false,
parsing_exe = false,
parsing_source_dirs = false,
}
local package_dir = vim.fn.fnamemodify(package_file, ':h') or package_file
local content = OS.read_file_async(package_file)
if not content then
return state.entry_points
end
local lines = vim.split(content, '\n') or {}
for idx, line in ipairs(lines) do
if not is_yaml_comment(line) then
---@type StackEntryPointParserData
local data = {
package_dir = package_dir,
line = line,
lines = lines,
idx = idx,
}
get_entrypoint_from_line(data, state)
end
if line:match('^executables:') or line:match('^tests:') then
state.parsing_exe_list = true
end
end
return state.entry_points
end
---Parse the DAP entry points from a package.yaml file
---@param package_path string Path to a package directory
---@return HsEntryPoint[] entry_points
---@async
function StackProjectHelper.parse_package_entrypoints(package_path)
local entry_points = {}
for _, package_file in pairs(vim.fn.glob(compat.joinpath(package_path, 'package.yaml'), true, true)) do
vim.list_extend(entry_points, parse_package_entrypoints(package_file))
end
return entry_points
end
return StackProjectHelper

View File

@ -0,0 +1,279 @@
---@mod haskell-tools.repl.builtin
---@brief [[
---WARNING: This is not part of the public API.
---Breaking changes to this module will not be reflected in the semantic versioning of this plugin.
---Utility functions for the ghci repl module.
---@brief ]]
local log = require('haskell-tools.log.internal')
---@class BuiltinRepl
---@field bufnr number
---@field job_id number
---@field cmd string[]
---@type BuiltinRepl | nil
local BuiltinRepl = nil
local function is_repl_loaded()
return BuiltinRepl ~= nil and vim.api.nvim_buf_is_loaded(BuiltinRepl.bufnr)
end
---@param callback fun(repl:BuiltinRepl)
---@return nil
local function when_repl_loaded(callback)
if is_repl_loaded() then
local repl = BuiltinRepl
---@cast repl BuiltinRepl
callback(repl)
end
end
--- @param cmd string[]?
local function is_new_cmd(cmd)
return BuiltinRepl ~= nil and table.concat(BuiltinRepl.cmd) ~= table.concat(cmd or {})
end
---Creates a repl on buffer with id `bufnr`.
---@param bufnr number Buffer to be used.
---@param cmd string[] command to start the repl
---@param opts ReplViewOpts?
---@return nil
local function buf_create_repl(bufnr, cmd, opts)
vim.api.nvim_win_set_buf(0, bufnr)
opts = vim.tbl_extend('force', vim.empty_dict(), opts or {})
local function delete_repl_buf()
local winid = vim.fn.bufwinid(bufnr)
if winid ~= nil then
vim.api.nvim_win_close(winid, true)
end
vim.api.nvim_buf_delete(bufnr, { force = true })
end
if opts.delete_buffer_on_exit then
opts.on_exit = function(_, exit_code, _)
log.debug('repl.builtin: exit')
if exit_code ~= 0 then
local msg = 'repl.builtin: non-zero exit code: ' .. exit_code
log.warn(msg)
vim.notify(msg, vim.log.levels.WARN)
end
delete_repl_buf()
end
local repl_log = function(logger)
return function(_, data, name)
logger { 'repl.builtin', data, name }
end
end
opts.on_stdout = repl_log(log.debug)
opts.on_stderr = repl_log(log.warn)
opts.on_stdin = repl_log(log.debug)
end
log.debug { 'repl.builtin: Opening terminal', cmd, opts }
local job_id = vim.fn.termopen(cmd, opts)
if not job_id then
log.error('repl.builtin: Failed to open a terminal')
vim.notify('haskell-tools: Could not start the repl.', vim.log.levels.ERROR)
delete_repl_buf()
return
end
BuiltinRepl = {
bufnr = bufnr,
job_id = job_id,
cmd = cmd,
}
log.debug { 'repl.builtin: Created repl.', BuiltinRepl }
end
---Create a split
---@param size function|number|nil
local function create_split(size)
size = size and (type(size) == 'function' and size() or size) or vim.o.lines / 3
local args = vim.empty_dict() or {}
table.insert(args, size)
table.insert(args, 'split')
vim.cmd(table.concat(args, ' '))
end
---Create a vertical split
---@param size function|number?
local function create_vsplit(size)
size = size and (type(size) == 'function' and size() or size) or vim.o.columns / 2
local args = vim.empty_dict() or {}
table.insert(args, size)
table.insert(args, 'vsplit')
vim.cmd(table.concat(args, ' '))
end
---Create a new tab
---@param _ any
local function create_tab(_)
vim.cmd('tabnew')
end
---@param mk_repl_cmd fun(string):(string[]?)
---@param options ReplConfig
---@return ReplHandlerImpl handler
return function(mk_repl_cmd, options)
---@class ReplHandlerImpl
local ReplHandlerImpl = {}
---Create a new repl (or toggle its visibility)
---@param create_win function|number Function for creating the window or an existing window number
---@param mk_cmd fun():string[] Function for creating the repl command
---@param opts ReplViewOpts?
---@return nil
local function create_or_toggle(create_win, mk_cmd, opts)
local cmd = mk_cmd()
if cmd == nil then
local err_msg = 'haskell-tools.repl.builtin: Could not create a repl command.'
log.error(err_msg)
vim.notify(err_msg, vim.log.levels.ERROR)
return
end
if is_new_cmd(cmd) then
log.debug { 'repl.builtin: New command', cmd }
ReplHandlerImpl.quit()
end
if is_repl_loaded() then
local repl = BuiltinRepl
---@cast repl BuiltinRepl
log.debug('repl.builtin: is loaded')
local winid = vim.fn.bufwinid(repl.bufnr)
if winid ~= -1 then
log.debug('repl.builtin: Hiding window ' .. winid)
vim.api.nvim_win_hide(winid)
else
create_win()
vim.api.nvim_set_current_buf(repl.bufnr)
winid = vim.fn.bufwinid(repl.bufnr)
if winid ~= nil then
log.debug('repl.builtin: Created window ' .. winid)
vim.api.nvim_set_current_win(winid)
end
end
return
end
log.debug('repl.builtin: is not loaded')
opts = opts or vim.empty_dict()
local bufnr = vim.api.nvim_create_buf(true, true)
create_win()
vim.api.nvim_set_current_buf(bufnr)
local winid = vim.fn.bufwinid(bufnr)
if winid ~= nil then
log.debug('repl.builtin: Created window ' .. winid)
vim.api.nvim_set_current_win(winid)
end
buf_create_repl(bufnr, cmd, opts)
end
---@type ReplView
local ReplView = {
---Create a new repl in a horizontal split
---@param opts ReplViewOpts?
---@return fun(mk_cmd_fun) create_repl
create_repl_split = function(opts)
return function(mk_cmd)
create_or_toggle(create_split, mk_cmd, opts)
end
end,
---Create a new repl in a vertical split
---@param opts ReplViewOpts?
---@return fun(function) create_repl
create_repl_vsplit = function(opts)
return function(mk_cmd)
create_or_toggle(create_vsplit, mk_cmd, opts)
end
end,
---Create a new repl in a new tab
---@param opts ReplViewOpts?
---@return fun(function) create_repl
create_repl_tabnew = function(opts)
return function(mk_cmd)
create_or_toggle(create_tab, mk_cmd, opts)
end
end,
---Create a new repl in the current window
---@param opts ReplViewOpts?
---@return fun(function) create_repl
create_repl_cur_win = function(opts)
return function(mk_cmd)
create_or_toggle(function(_) end, mk_cmd, opts)
end
end,
}
log.debug { 'repl.builtin setup', options }
---@private
ReplHandlerImpl.go_back = options.auto_focus ~= true
---@param filepath string path of the file to load into the repl
---@param _ table?
function ReplHandlerImpl.toggle(filepath, _)
local cur_win = vim.api.nvim_get_current_win()
if filepath and not vim.endswith(filepath, '.hs') then
local err_msg = 'haskell-tools.repl.builtin: Not a Haskell file: ' .. filepath
log.error(err_msg)
vim.notify(err_msg, vim.log.levels.ERROR)
return
end
---@return string[]?
local function mk_repl_cmd_wrapped()
return mk_repl_cmd(filepath)
end
local create_or_toggle_callback = options.builtin.create_repl_window(ReplView)
create_or_toggle_callback(mk_repl_cmd_wrapped)
if cur_win ~= -1 and ReplHandlerImpl.go_back then
vim.api.nvim_set_current_win(cur_win)
else
vim.cmd('startinsert')
end
end
---Quit the repl
---@return nil
function ReplHandlerImpl.quit()
when_repl_loaded(function(repl)
log.debug('repl.builtin: sending quit to repl.')
local success, result = pcall(ReplHandlerImpl.send_cmd, ':q')
if not success then
log.warn { 'repl.builtin: Could not send quit command', result }
end
local winid = vim.fn.bufwinid(repl.bufnr)
if winid ~= -1 then
vim.api.nvim_win_close(winid, true)
end
vim.api.nvim_buf_delete(repl.bufnr, { force = true })
end)
end
---Send a command to the repl, followed by <cr>
---@param txt string The text to send
---@return nil
function ReplHandlerImpl.send_cmd(txt)
when_repl_loaded(function(repl)
local cr = '\13'
local repl_winid = vim.fn.bufwinid(repl.bufnr)
local function repl_set_cursor()
if repl_winid ~= -1 then
vim.api.nvim_win_set_cursor(repl_winid, { vim.api.nvim_buf_line_count(repl.bufnr), 0 })
end
end
repl_set_cursor()
vim.api.nvim_chan_send(repl.job_id, txt .. cr)
repl_set_cursor()
if not ReplHandlerImpl.go_back and repl_winid ~= nil then
vim.api.nvim_set_current_win(repl_winid)
vim.cmd('startinsert')
end
end)
end
return ReplHandlerImpl
end

View File

@ -0,0 +1,291 @@
---@mod haskell-tools.repl haskell-tools GHCi REPL module
---@bruief [[
---Tools for interaction with a GHCi REPL
---@bruief ]]
---@brief [[
--- The following commands are available:
---
--- * `:HtReplToggle` - Toggle a GHCi repl.
--- * `:HtReplQuit` - Quit the current repl.
--- * `:HtReplLoad` - Load a Haskell file into the repl.
--- * `:HtReplReload` - Reload the current repl.
---@brief ]]
local log = require('haskell-tools.log.internal')
local Types = require('haskell-tools.types.internal')
local compat = require('haskell-tools.compat')
---Extend a repl command for `file`.
---If `file` is `nil`, create a repl the nearest package.
---@param cmd string[] The command to extend
---@param file string|nil An optional project file
---@return string[]|nil
local function extend_repl_cmd(cmd, file)
local HtProjectHelpers = require('haskell-tools.project.helpers')
if file == nil then
file = vim.api.nvim_buf_get_name(0)
log.debug('extend_repl_cmd: No file specified. Using current buffer: ' .. file)
local project_root = HtProjectHelpers.match_project_root(file)
local subpackage = project_root and HtProjectHelpers.get_package_name(file)
if subpackage then
table.insert(cmd, subpackage)
log.debug { 'extend_repl_cmd: Extended cmd with package.', cmd }
return cmd
else
log.debug { 'extend_repl_cmd: No subpackage or no package found.', cmd }
return cmd
end
end
log.debug('extend_repl_cmd: File: ' .. file)
local project_root = HtProjectHelpers.match_project_root(file)
local subpackage = project_root and HtProjectHelpers.get_package_name(file)
if not subpackage then
log.debug { 'extend_repl_cmd: No package found.', cmd }
return cmd
end
if vim.endswith(file, '.hs') then
table.insert(cmd, file)
else
log.debug('extend_repl_cmd: Not a Haskell file.')
table.insert(cmd, subpackage)
end
log.debug { 'extend_repl_cmd', cmd }
return cmd
end
---Create a cabal repl command for `file`.
---If `file` is `nil`, create a repl the nearest package.
---@param file string|nil
---@return string[]|nil command
local function mk_cabal_repl_cmd(file)
return extend_repl_cmd({ 'cabal', 'repl', '--ghc-option', '-Wwarn' }, file)
end
---Create a stack repl command for `file`.
---If `file` is `nil`, create a repl the nearest package.
---@param file string|nil
---@return string[]|nil command
local function mk_stack_repl_cmd(file)
return extend_repl_cmd({ 'stack', 'ghci' }, file)
end
---Create the command to create a repl for a file.
---If `file` is `nil`, create a repl for the nearest package.
---@param file string|nil
---@return table|nil command
local function mk_repl_cmd(file)
local chk_path = file
if not chk_path then
chk_path = vim.api.nvim_buf_get_name(0)
if vim.fn.filewritable(chk_path) == 0 then
local err_msg = 'haskell-tools.repl: File not found. Has it been saved? ' .. chk_path
log.error(err_msg)
vim.notify(err_msg, vim.log.levels.ERROR)
return nil
end
end
local HTConfig = require('haskell-tools.config.internal')
local opts = HTConfig.tools.repl
local HtProjectHelpers = require('haskell-tools.project.helpers')
if Types.evaluate(opts.prefer) == 'stack' and HtProjectHelpers.is_stack_project(chk_path) then
return mk_stack_repl_cmd(file)
end
if HtProjectHelpers.is_cabal_project(chk_path) then
return mk_cabal_repl_cmd(file)
end
if HtProjectHelpers.is_stack_project(chk_path) then
return mk_stack_repl_cmd(file)
end
if vim.fn.executable('ghci') == 1 then
local cmd = compat.tbl_flatten { 'ghci', file and { file } or {} }
log.debug { 'mk_repl_cmd', cmd }
return cmd
end
local err_msg = 'haskell-tools.repl: No ghci executable found.'
log.error(err_msg)
vim.notify(err_msg, vim.log.levels.ERROR)
return nil
end
local HTConfig = require('haskell-tools.config.internal')
local opts = HTConfig.tools.repl
---@type ReplHandlerImpl
local handler
local handler_type = Types.evaluate(opts.handler)
---@cast handler_type ReplHandler
if handler_type == 'toggleterm' then
log.info('handler = toggleterm')
handler = require('haskell-tools.repl.toggleterm')(mk_repl_cmd, opts)
else
if handler_type ~= 'builtin' then
log.warn('Invalid repl handler type. Falling back to builtin')
vim.notify_once(
'haskell-tools.repl: the handler "' .. handler_type .. '" is invalid. Defaulting to "builtin".',
vim.log.levels.WARN
)
else
log.info('handler = builtin')
end
handler = require('haskell-tools.repl.builtin')(mk_repl_cmd, opts)
end
local function handle_reg(cmd, reg)
local data = vim.fn.getreg(reg or '"')
handler.send_cmd(cmd .. ' ' .. data)
end
local function handle_cword(cmd)
local cword = vim.fn.expand('<cword>')
handler.send_cmd(cmd .. ' ' .. cword)
end
---@param lines string[]
local function repl_send_lines(lines)
if #lines > 1 then
handler.send_cmd(':{')
for _, line in ipairs(lines) do
handler.send_cmd(line)
end
handler.send_cmd(':}')
else
handler.send_cmd(lines[1])
end
end
---@class HsReplTools
local HsReplTools = {}
HsReplTools.mk_repl_cmd = mk_repl_cmd
---Create the command to create a repl for the current buffer.
---@return table|nil command
HsReplTools.buf_mk_repl_cmd = function()
local file = vim.api.nvim_buf_get_name(0)
return mk_repl_cmd(file)
end
---Toggle a GHCi REPL
HsReplTools.toggle = handler.toggle
---Quit the REPL
HsReplTools.quit = handler.quit
---Can be used to send text objects to the repl.
---@usage [[
---vim.keymap.set('n', 'ghc', ht.repl.operator, {noremap = true})
---@usage ]]
---@see operatorfunc
HsReplTools.operator = function()
local old_operator_func = vim.go.operatorfunc
_G.op_func_send_to_repl = function()
local start = vim.api.nvim_buf_get_mark(0, '[')
local finish = vim.api.nvim_buf_get_mark(0, ']')
local text = vim.api.nvim_buf_get_text(0, start[1] - 1, start[2], finish[1], finish[2] + 1, {})
repl_send_lines(text)
vim.go.operatorfunc = old_operator_func
_G.op_func_formatting = nil
end
vim.go.operatorfunc = 'v:lua.op_func_send_to_repl'
vim.api.nvim_feedkeys('g@', 'n', false)
end
---Paste from register `reg` to the REPL
---@param reg string|nil register (defaults to '"')
HsReplTools.paste = function(reg)
local data = vim.fn.getreg(reg or '"')
---@cast data string
if vim.endswith(data, '\n') then
data = data:sub(1, #data - 1)
end
local lines = vim.split(data, '\n')
if #lines <= 1 then
lines = { data }
end
repl_send_lines(lines)
end
---Query the REPL for the type of register `reg`
---@param reg string|nil register (defaults to '"')
HsReplTools.paste_type = function(reg)
handle_reg(':t', reg)
end
---Query the REPL for the type of word under the cursor
HsReplTools.cword_type = function()
handle_cword(':t')
end
---Query the REPL for info on register `reg`
---@param reg string|nil register (defaults to '"')
HsReplTools.paste_info = function(reg)
handle_reg(':i', reg)
end
---Query the REPL for the type of word under the cursor
HsReplTools.cword_info = function()
handle_cword(':i')
end
---Load a file into the REPL
---@param filepath string The absolute file path
HsReplTools.load_file = function(filepath)
if vim.fn.filereadable(filepath) == 0 then
local err_msg = 'File: ' .. filepath .. ' does not exist or is not readable.'
log.error(err_msg)
vim.notify(err_msg, vim.log.levels.ERROR)
end
handler.send_cmd(':l ' .. filepath)
end
---Reload the repl
HsReplTools.reload = function()
handler.send_cmd(':r')
end
vim.keymap.set('n', 'ghc', HsReplTools.operator, { noremap = true })
local commands = {
{
'HtReplToggle',
---@param tbl table
function(tbl)
local filepath = tbl.args ~= '' and vim.fn.expand(tbl.args)
---@cast filepath string
HsReplTools.toggle(filepath)
end,
{ nargs = '?' },
},
{
'HtReplLoad',
---@param tbl table
function(tbl)
local filepath = vim.fn.expand(tbl.args)
---@cast filepath string
HsReplTools.load_file(filepath)
end,
{ nargs = 1 },
},
{
'HtReplQuit',
function()
HsReplTools.quit()
end,
{},
},
{
'HtReplReload',
function()
HsReplTools.reload()
end,
{},
},
}
for _, command in ipairs(commands) do
vim.api.nvim_create_user_command(unpack(command))
end
return HsReplTools

View File

@ -0,0 +1,138 @@
---@mod haskell-tools.repl.toggleterm
---@brief [[
---WARNING: This is not part of the public API.
---Breaking changes to this module will not be reflected in the semantic versioning of this plugin.
---Wraps the toggleterm.nvim API to provide a GHIi repl.
---@brief ]]
local log = require('haskell-tools.log.internal')
local deps = require('haskell-tools.deps')
local last_cmd = ''
---@param cmd string?
local function is_new_cmd(cmd)
return last_cmd ~= (cmd or '')
end
--- Quote a string
--- @param str string
--- @return string quoted_string
local function quote(str)
return '"' .. str .. '"'
end
---@param mk_repl_cmd fun(string?):string[]? Function for building the repl that takes an optional file path
---@param opts ReplConfig
---@return ReplHandlerImpl
return function(mk_repl_cmd, opts)
local ReplHandlerImpl = {
---@private
---@type Terminal | nil
terminal = nil,
}
opts = opts or vim.empty_dict()
if opts.auto_focus == nil then
---@private
ReplHandlerImpl.go_back = true
else
---@private
ReplHandlerImpl.go_back = not opts.auto_focus
end
log.debug('repl.toggleterm setup')
---@type Terminal
local Terminal = deps.require_toggleterm('toggleterm.terminal').Terminal
---@param cmd string The command to execute in the terminal
---@return Terminal
local function mk_new_terminal(cmd)
local terminal_opts = {
cmd = cmd,
hidden = true,
close_on_exit = false,
on_stdout = function(_, job, data, name)
log.debug { 'Job ' .. job .. ' - stdout', data, name }
end,
on_stderr = function(_, job, data, name)
log.warn { 'Job ' .. job .. ' - stderr', data, name }
end,
on_exit = function(_, job, exit_code, name)
log.debug { 'Job ' .. job .. ' - exit code ' .. exit_code, name }
end,
}
log.debug { 'Creating new terminal', terminal_opts }
return Terminal:new(terminal_opts)
end
--- @param filepath string? Path of the file to load into the repl
function ReplHandlerImpl.toggle(filepath, _)
opts = opts or vim.empty_dict()
if filepath and not vim.endswith(filepath, '.hs') then
local err_msg = 'haskell-tools.repl.toggleterm: Not a Haskell file: ' .. filepath
log.error(err_msg)
vim.notify(err_msg, vim.log.levels.ERROR)
return
end
local cmd = mk_repl_cmd(filepath and quote(filepath)) or {}
if #cmd == 0 then
local err_msg = 'haskell-tools.repl.toggleterm: Could not create a repl command.'
log.error(err_msg)
vim.notify(err_msg, vim.log.levels.ERROR)
return
end
local cmd_str = table.concat(cmd, ' ')
if is_new_cmd(cmd_str) then
log.debug { 'repl.toggleterm: New command', cmd_str }
ReplHandlerImpl.quit()
end
local cur_win = vim.api.nvim_get_current_win()
local is_normal_mode = vim.api.nvim_get_mode().mode == 'n'
ReplHandlerImpl.terminal = ReplHandlerImpl.terminal or mk_new_terminal(cmd_str)
local function toggle()
ReplHandlerImpl.terminal:toggle()
end
local success, result = pcall(toggle)
if not success then
log.error { 'repl.toggleterm: toggle failed', result }
end
if cur_win ~= -1 and ReplHandlerImpl.go_back then
vim.api.nvim_set_current_win(cur_win)
if is_normal_mode then
vim.cmd('stopinsert')
end
end
last_cmd = cmd_str
end
---Quit the repl
---@retrun nil
function ReplHandlerImpl.quit()
if ReplHandlerImpl.terminal ~= nil then
log.debug('repl.toggleterm: sending quit to repl.')
local success, result = pcall(ReplHandlerImpl.send_cmd, ':q')
if not success then
log.warn { 'repl.toggleterm: Could not send quit command', result }
end
ReplHandlerImpl.terminal:close()
ReplHandlerImpl.terminal = nil
end
end
---Send a command to the repl, followed by <cr>
---@param txt string the command text to send
---@return nil
function ReplHandlerImpl.send_cmd(txt)
opts = opts or vim.empty_dict()
vim.tbl_extend('force', {
go_back = false,
}, opts)
if ReplHandlerImpl.terminal ~= nil then
ReplHandlerImpl.terminal:send(txt, ReplHandlerImpl.go_back)
end
end
return ReplHandlerImpl
end

View File

@ -0,0 +1,21 @@
---@mod haskell-tools.strings
---@brief [[
---WARNING: This is not part of the public API.
---Breaking changes to this module will not be reflected in the semantic versioning of this plugin.
--- Helper functions for working with strings
---@brief ]]
---@class StringsUtil
local Strings = {}
---Trim leading and trailing whitespace.
---@param str string
---@return string trimmed
Strings.trim = function(str)
return (str:match('^%s*(.*)') or str):gsub('%s*$', '')
end
return Strings

View File

@ -0,0 +1,107 @@
---@mod haskell-tools.tags haskell-tools fast-tags module
local HTConfig = require('haskell-tools.config.internal')
local Types = require('haskell-tools.types.internal')
local log = require('haskell-tools.log.internal')
local compat = require('haskell-tools.compat')
local _state = {
fast_tags_generating = false,
projects = {},
}
log.debug('Setting up fast-tags tools')
local config = HTConfig.tools.tags
---@class GenerateProjectTagsOpts
---@field refresh boolean Whether to refresh the tags if they have already been generated
--- for the project (default: true)
---@class FastTagsTools
local FastTagsTools = {}
---Generates tags for the current project
---@param path string|nil File path
---@param opts GenerateProjectTagsOpts|nil Options
FastTagsTools.generate_project_tags = function(path, opts)
path = path or vim.api.nvim_buf_get_name(0)
opts = vim.tbl_extend('force', { refresh = true }, opts or {})
local HtProjectHelpers = require('haskell-tools.project.helpers')
local project_root = HtProjectHelpers.match_project_root(path)
if not project_root then
log.warn('generate_project_tags: No project root found.')
return
end
if opts.refresh == false and _state.projects[project_root] then
log.debug('Project tags already generated. Skipping.')
return
end
_state.projects[project_root] = true
_state.fast_tags_generating = true
if project_root then
log.debug('Generating project tags for' .. project_root)
compat.system({ 'fast-tags', '-R', project_root }, nil, function(sc)
if sc.code ~= 0 then
log.error { 'Error running fast-tags on project root', sc.code, sc.stderr }
end
---@cast sc vim.SystemCompleted
_state.fast_tags_generating = false
end)
end
end
---Generate tags for the package containing `path`
---@param path string|nil File path
FastTagsTools.generate_package_tags = function(path)
path = path or vim.api.nvim_buf_get_name(0)
_state.fast_tags_generating = true
local HtProjectHelpers = require('haskell-tools.project.helpers')
local rel_package_root = HtProjectHelpers.match_package_root(path)
if not rel_package_root then
log.warn('generate_package_tags: No rel_package root found.')
return
end
local package_root = vim.fn.getcwd() .. '/' .. rel_package_root
local project_root = HtProjectHelpers.match_project_root(path) or vim.fn.getcwd()
if not package_root then
log.warn('generate_package_tags: No package root found.')
return
end
if not project_root then
log.warn('generate_package_tags: No project root found.')
return
end
compat.system({ 'fast-tags', '-R', package_root, project_root }, nil, function(sc)
---@cast sc vim.SystemCompleted
if sc.code ~= 0 then
log.error { 'Error running fast-tags on package', sc.code, sc.stderr }
end
_state.fast_tags_generating = false
end)
end
if not Types.evaluate(config.enable) then
return
end
if vim.fn.executable('fast-tags') ~= 1 then
local err_msg = 'haskell-tools: fast-tags fallback configured, but fast-tags executable not found'
log.error(err_msg)
vim.notify(err_msg, vim.log.levels.ERROR)
return
end
local package_events = config.package_events
if #package_events > 0 then
vim.api.nvim_create_autocmd(package_events, {
group = vim.api.nvim_create_augroup('haskell-tools-generate-package-tags', {}),
pattern = { 'haskell', '*.hs' },
callback = function(meta)
if _state.fast_tags_generating then
return
end
FastTagsTools.generate_package_tags(meta.file)
end,
})
end
return FastTagsTools

View File

@ -0,0 +1,32 @@
---@mod haskell-tools.internal-types
---@brief [[
---WARNING: This is not part of the public API.
---Breaking changes to this module will not be reflected in the semantic versioning of this plugin.
--- Type definitions
---@brief ]]
---@class HsEntryPoint
---@field package_dir string
---@field package_name string
---@field exe_name string
---@field main string
---@field source_dir string
local Types = {}
---Evaluate a value that may be a function
---or an evaluated value
---@generic T
---@param value (fun():T)|T
---@return T
Types.evaluate = function(value)
if type(value) == 'function' then
return value()
end
return value
end
return Types