mirror of
https://github.com/neovim/neovim.git
synced 2024-12-19 02:34:59 -07:00
feat(treesitter): add query_linter from nvim-treesitter/playground (#22784)
Co-authored-by: clason <clason@users.noreply.github.com> Co-authored-by: lewis6991 <lewis6991@users.noreply.github.com>
This commit is contained in:
parent
933fdff466
commit
c194acbfc4
@ -663,6 +663,20 @@ To disable this behavior, set the following variable in your vimrc: >
|
||||
|
||||
let g:python_recommended_style = 0
|
||||
|
||||
QUERY *ft-query-plugin*
|
||||
|
||||
|
||||
Linting of tree-sitter queries for installed parsers using
|
||||
|lua-treesitter-query_linter| is enabled by default on
|
||||
`BufEnter` and `BufWrite`. To change the events that
|
||||
trigger linting, use >lua
|
||||
|
||||
vim.g.query_lint_on = { 'InsertLeave', 'TextChanged' }
|
||||
<
|
||||
To disable linting completely, set >lua
|
||||
|
||||
vim.g.query_lint_on = {}
|
||||
<
|
||||
|
||||
QF QUICKFIX *qf.vim* *ft-qf-plugin*
|
||||
|
||||
|
@ -39,6 +39,12 @@ The following new APIs or features were added.
|
||||
iterators |luaref-in|.
|
||||
• Added |vim.keycode()| for translating keycodes in a string.
|
||||
|
||||
• Added automatic linting of treesitter query files (see |ft-query-plugin|).
|
||||
Automatic linting can be turned off via >lua
|
||||
vim.g.query_lint_on = {}
|
||||
<
|
||||
• Enabled treesitter highlighting for treesitter query files by default.
|
||||
|
||||
==============================================================================
|
||||
CHANGED FEATURES *news-changed*
|
||||
|
||||
|
@ -841,6 +841,28 @@ get_files({lang}, {query_name}, {is_included})
|
||||
string[] query_files List of files to load for given query and
|
||||
language
|
||||
|
||||
lint({buf}, {opts}) *vim.treesitter.query.lint()*
|
||||
Lint treesitter queries using installed parser, or clear lint errors.
|
||||
|
||||
Use |treesitter-parsers| in runtimepath to check the query file in {buf}
|
||||
for errors:
|
||||
|
||||
• verify that used nodes are valid identifiers in the grammar.
|
||||
• verify that predicates and directives are valid.
|
||||
• verify that top-level s-expressions are valid.
|
||||
|
||||
The found diagnostics are reported using |diagnostic-api|. By default, the
|
||||
parser used for verification is determined by the containing folder of the
|
||||
query file, e.g., if the path is `**/lua/highlights.scm` , the parser for the `lua` language will be used.
|
||||
|
||||
Parameters: ~
|
||||
• {buf} (integer) Buffer handle
|
||||
• {opts} (QueryLinterOpts|nil) Optional keyword arguments:
|
||||
• langs (string|string[]|nil) Language(s) to use for checking
|
||||
the query. If multiple languages are specified, queries are
|
||||
validated for all of them
|
||||
• clear (boolean) if `true`, just clear current lint errors
|
||||
|
||||
list_directives() *vim.treesitter.query.list_directives()*
|
||||
Lists the currently available directives to use in queries.
|
||||
|
||||
|
@ -1,6 +1,30 @@
|
||||
-- Neovim filetype plugin file
|
||||
-- Language: Tree-sitter query
|
||||
-- Last Change: 2022 Mar 29
|
||||
-- Last Change: 2022 Apr 25
|
||||
|
||||
if vim.b.did_ftplugin == 1 then
|
||||
return
|
||||
end
|
||||
|
||||
-- Do not set vim.b.did_ftplugin = 1 to allow loading of ftplugin/lisp.vim
|
||||
|
||||
-- use treesitter over syntax
|
||||
vim.treesitter.start()
|
||||
|
||||
-- query linter
|
||||
local buf = vim.api.nvim_get_current_buf()
|
||||
local query_lint_on = vim.g.query_lint_on or { 'BufEnter', 'BufWrite' }
|
||||
|
||||
if not vim.b.disable_query_linter and #query_lint_on > 0 then
|
||||
vim.api.nvim_create_autocmd(query_lint_on, {
|
||||
group = vim.api.nvim_create_augroup('querylint', { clear = false }),
|
||||
buffer = buf,
|
||||
callback = function()
|
||||
vim.treesitter.query.lint(buf)
|
||||
end,
|
||||
desc = 'Query linter',
|
||||
})
|
||||
end
|
||||
|
||||
-- it's a lisp!
|
||||
vim.cmd([[ runtime! ftplugin/lisp.vim ]])
|
||||
|
302
runtime/lua/vim/treesitter/_query_linter.lua
Normal file
302
runtime/lua/vim/treesitter/_query_linter.lua
Normal file
@ -0,0 +1,302 @@
|
||||
local namespace = vim.api.nvim_create_namespace('vim.treesitter.query_linter')
|
||||
-- those node names exist for every language
|
||||
local BUILT_IN_NODE_NAMES = { '_', 'ERROR' }
|
||||
|
||||
local M = {}
|
||||
|
||||
--- @class QueryLinterNormalizedOpts
|
||||
--- @field langs string[]
|
||||
--- @field clear boolean
|
||||
|
||||
--- @private
|
||||
--- Caches parse results for queries for each language.
|
||||
--- Entries of parse_cache[lang][query_text] will either be true for successful parse or contain the
|
||||
--- error message of the parse
|
||||
--- @type table<string,table<string,string|true>>
|
||||
local parse_cache = {}
|
||||
|
||||
--- Contains language dependent context for the query linter
|
||||
--- @class QueryLinterLanguageContext
|
||||
--- @field lang string? Current `lang` of the targeted parser
|
||||
--- @field parser_info table? Parser info returned by vim.treesitter.language.inspect
|
||||
--- @field is_first_lang boolean Whether this is the first language of a linter run checking queries for multiple `langs`
|
||||
|
||||
--- @private
|
||||
--- Adds a diagnostic for node in the query buffer
|
||||
--- @param diagnostics Diagnostic[]
|
||||
--- @param node TSNode
|
||||
--- @param buf integer
|
||||
--- @param lint string
|
||||
--- @param lang string?
|
||||
local function add_lint_for_node(diagnostics, node, buf, lint, lang)
|
||||
local node_text = vim.treesitter.get_node_text(node, buf):gsub('\n', ' ')
|
||||
--- @type string
|
||||
local message = lint .. ': ' .. node_text
|
||||
local error_range = { node:range() }
|
||||
diagnostics[#diagnostics + 1] = {
|
||||
lnum = error_range[1],
|
||||
end_lnum = error_range[3],
|
||||
col = error_range[2],
|
||||
end_col = error_range[4],
|
||||
severity = vim.diagnostic.ERROR,
|
||||
message = message,
|
||||
source = lang,
|
||||
}
|
||||
end
|
||||
|
||||
--- @private
|
||||
--- Determines the target language of a query file by its path: <lang>/<query_type>.scm
|
||||
--- @param buf integer
|
||||
--- @return string?
|
||||
local function guess_query_lang(buf)
|
||||
local filename = vim.api.nvim_buf_get_name(buf)
|
||||
if filename ~= '' then
|
||||
local ok, query_lang = pcall(vim.fn.fnamemodify, filename, ':p:h:t')
|
||||
if ok then
|
||||
return query_lang
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- @private
|
||||
--- @param buf integer
|
||||
--- @param opts QueryLinterOpts|QueryLinterNormalizedOpts|nil
|
||||
--- @return QueryLinterNormalizedOpts
|
||||
local function normalize_opts(buf, opts)
|
||||
opts = opts or {}
|
||||
if not opts.langs then
|
||||
opts.langs = guess_query_lang(buf)
|
||||
end
|
||||
|
||||
if type(opts.langs) ~= 'table' then
|
||||
--- @diagnostic disable-next-line:assign-type-mismatch
|
||||
opts.langs = { opts.langs }
|
||||
end
|
||||
|
||||
--- @cast opts QueryLinterNormalizedOpts
|
||||
opts.langs = opts.langs or {}
|
||||
return opts
|
||||
end
|
||||
|
||||
local lint_query = [[;; query
|
||||
(program [(named_node) (list) (grouping)] @toplevel)
|
||||
(named_node
|
||||
name: _ @node.named)
|
||||
(anonymous_node
|
||||
name: _ @node.anonymous)
|
||||
(field_definition
|
||||
name: (identifier) @field)
|
||||
(predicate
|
||||
name: (identifier) @predicate.name
|
||||
type: (predicate_type) @predicate.type)
|
||||
(ERROR) @error
|
||||
]]
|
||||
|
||||
--- @private
|
||||
--- @param node TSNode
|
||||
--- @param buf integer
|
||||
--- @param lang string
|
||||
--- @param diagnostics Diagnostic[]
|
||||
local function check_toplevel(node, buf, lang, diagnostics)
|
||||
local query_text = vim.treesitter.get_node_text(node, buf)
|
||||
|
||||
if not parse_cache[lang] then
|
||||
parse_cache[lang] = {}
|
||||
end
|
||||
|
||||
local lang_cache = parse_cache[lang]
|
||||
|
||||
if lang_cache[query_text] == nil then
|
||||
local ok, err = pcall(vim.treesitter.query.parse, lang, query_text)
|
||||
|
||||
if not ok and type(err) == 'string' then
|
||||
err = err:match('.-:%d+: (.+)')
|
||||
end
|
||||
|
||||
lang_cache[query_text] = ok or err
|
||||
end
|
||||
|
||||
local cache_entry = lang_cache[query_text]
|
||||
|
||||
if type(cache_entry) == 'string' then
|
||||
add_lint_for_node(diagnostics, node, buf, cache_entry, lang)
|
||||
end
|
||||
end
|
||||
|
||||
--- @private
|
||||
--- @param node TSNode
|
||||
--- @param buf integer
|
||||
--- @param lang string
|
||||
--- @param parser_info table
|
||||
--- @param diagnostics Diagnostic[]
|
||||
local function check_field(node, buf, lang, parser_info, diagnostics)
|
||||
local field_name = vim.treesitter.get_node_text(node, buf)
|
||||
if not vim.tbl_contains(parser_info.fields, field_name) then
|
||||
add_lint_for_node(diagnostics, node, buf, 'Invalid field', lang)
|
||||
end
|
||||
end
|
||||
|
||||
--- @private
|
||||
--- @param node TSNode
|
||||
--- @param buf integer
|
||||
--- @param lang string
|
||||
--- @param parser_info (table)
|
||||
--- @param diagnostics Diagnostic[]
|
||||
local function check_node(node, buf, lang, parser_info, diagnostics)
|
||||
local node_type = vim.treesitter.get_node_text(node, buf)
|
||||
local is_named = node_type:sub(1, 1) ~= '"'
|
||||
|
||||
if not is_named then
|
||||
node_type = node_type:gsub('"(.*)".*$', '%1'):gsub('\\(.)', '%1')
|
||||
end
|
||||
|
||||
local found = vim.tbl_contains(BUILT_IN_NODE_NAMES, node_type)
|
||||
or vim.tbl_contains(parser_info.symbols, function(s)
|
||||
return vim.deep_equal(s, { node_type, is_named })
|
||||
end, { predicate = true })
|
||||
|
||||
if not found then
|
||||
add_lint_for_node(diagnostics, node, buf, 'Invalid node type', lang)
|
||||
end
|
||||
end
|
||||
|
||||
--- @private
|
||||
--- @param node TSNode
|
||||
--- @param buf integer
|
||||
--- @param is_predicate boolean
|
||||
--- @return string
|
||||
local function get_predicate_name(node, buf, is_predicate)
|
||||
local name = vim.treesitter.get_node_text(node, buf)
|
||||
if is_predicate then
|
||||
if vim.startswith(name, 'not-') then
|
||||
--- @type string
|
||||
name = name:sub(string.len('not-') + 1)
|
||||
end
|
||||
return name .. '?'
|
||||
end
|
||||
return name .. '!'
|
||||
end
|
||||
|
||||
--- @private
|
||||
--- @param predicate_node TSNode
|
||||
--- @param predicate_type_node TSNode
|
||||
--- @param buf integer
|
||||
--- @param lang string?
|
||||
--- @param diagnostics Diagnostic[]
|
||||
local function check_predicate(predicate_node, predicate_type_node, buf, lang, diagnostics)
|
||||
local type_string = vim.treesitter.get_node_text(predicate_type_node, buf)
|
||||
|
||||
-- Quirk of the query grammar that directives are also predicates!
|
||||
if type_string == '?' then
|
||||
if
|
||||
not vim.tbl_contains(
|
||||
vim.treesitter.query.list_predicates(),
|
||||
get_predicate_name(predicate_node, buf, true)
|
||||
)
|
||||
then
|
||||
add_lint_for_node(diagnostics, predicate_node, buf, 'Unknown predicate', lang)
|
||||
end
|
||||
elseif type_string == '!' then
|
||||
if
|
||||
not vim.tbl_contains(
|
||||
vim.treesitter.query.list_directives(),
|
||||
get_predicate_name(predicate_node, buf, false)
|
||||
)
|
||||
then
|
||||
add_lint_for_node(diagnostics, predicate_node, buf, 'Unknown directive', lang)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- @private
|
||||
--- @param buf integer
|
||||
--- @param match table<integer,TSNode>
|
||||
--- @param query Query
|
||||
--- @param lang_context QueryLinterLanguageContext
|
||||
--- @param diagnostics Diagnostic[]
|
||||
local function lint_match(buf, match, query, lang_context, diagnostics)
|
||||
local predicate --- @type TSNode
|
||||
local predicate_type --- @type TSNode
|
||||
local lang = lang_context.lang
|
||||
local parser_info = lang_context.parser_info
|
||||
|
||||
for id, node in pairs(match) do
|
||||
local cap_id = query.captures[id]
|
||||
|
||||
-- perform language-independent checks only for first lang
|
||||
if lang_context.is_first_lang then
|
||||
if cap_id == 'error' then
|
||||
add_lint_for_node(diagnostics, node, buf, 'Syntax error')
|
||||
elseif cap_id == 'predicate.name' then
|
||||
predicate = node
|
||||
elseif cap_id == 'predicate.type' then
|
||||
predicate_type = node
|
||||
end
|
||||
end
|
||||
|
||||
-- other checks rely on Neovim parser introspection
|
||||
if lang and parser_info then
|
||||
if cap_id == 'toplevel' then
|
||||
check_toplevel(node, buf, lang, diagnostics)
|
||||
elseif cap_id == 'field' then
|
||||
check_field(node, buf, lang, parser_info, diagnostics)
|
||||
elseif cap_id == 'node.named' or cap_id == 'node.anonymous' then
|
||||
check_node(node, buf, lang, parser_info, diagnostics)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if predicate and predicate_type then
|
||||
check_predicate(predicate, predicate_type, buf, lang, diagnostics)
|
||||
end
|
||||
end
|
||||
|
||||
--- @private
|
||||
--- @param buf integer Buffer to lint
|
||||
--- @param opts QueryLinterOpts|QueryLinterNormalizedOpts|nil Options for linting
|
||||
function M.lint(buf, opts)
|
||||
if buf == 0 then
|
||||
buf = vim.api.nvim_get_current_buf()
|
||||
end
|
||||
|
||||
local diagnostics = {}
|
||||
local query = vim.treesitter.query.parse('query', lint_query)
|
||||
|
||||
opts = normalize_opts(buf, opts)
|
||||
|
||||
-- perform at least one iteration even with no langs to perform language independent checks
|
||||
for i = 1, math.max(1, #opts.langs) do
|
||||
local lang = opts.langs[i]
|
||||
|
||||
--- @type boolean, (table|nil)
|
||||
local ok, parser_info = pcall(vim.treesitter.language.inspect, lang)
|
||||
if not ok then
|
||||
parser_info = nil
|
||||
end
|
||||
|
||||
local parser = vim.treesitter.get_parser(buf)
|
||||
parser:parse()
|
||||
parser:for_each_tree(function(tree, ltree)
|
||||
if ltree:lang() == 'query' then
|
||||
for _, match, _ in query:iter_matches(tree:root(), buf, 0, -1) do
|
||||
local lang_context = {
|
||||
lang = lang,
|
||||
parser_info = parser_info,
|
||||
is_first_lang = i == 1,
|
||||
}
|
||||
lint_match(buf, match, query, lang_context, diagnostics)
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
vim.diagnostic.set(namespace, buf, diagnostics)
|
||||
end
|
||||
|
||||
--- @private
|
||||
--- @param buf integer
|
||||
function M.clear(buf)
|
||||
vim.diagnostic.reset(namespace, buf)
|
||||
end
|
||||
|
||||
return M
|
@ -269,6 +269,7 @@ function M.inspect_tree(opts)
|
||||
vim.bo[b].buflisted = false
|
||||
vim.bo[b].buftype = 'nofile'
|
||||
vim.bo[b].bufhidden = 'wipe'
|
||||
vim.b[b].disable_query_linter = true
|
||||
vim.bo[b].filetype = 'query'
|
||||
|
||||
local title --- @type string?
|
||||
|
@ -714,4 +714,33 @@ function Query:iter_matches(node, source, start, stop)
|
||||
return iter
|
||||
end
|
||||
|
||||
---@class QueryLinterOpts
|
||||
---@field langs (string|string[]|nil)
|
||||
---@field clear (boolean)
|
||||
|
||||
--- Lint treesitter queries using installed parser, or clear lint errors.
|
||||
---
|
||||
--- Use |treesitter-parsers| in runtimepath to check the query file in {buf} for errors:
|
||||
---
|
||||
--- - verify that used nodes are valid identifiers in the grammar.
|
||||
--- - verify that predicates and directives are valid.
|
||||
--- - verify that top-level s-expressions are valid.
|
||||
---
|
||||
--- The found diagnostics are reported using |diagnostic-api|.
|
||||
--- By default, the parser used for verification is determined by the containing folder
|
||||
--- of the query file, e.g., if the path is `**/lua/highlights.scm`, the parser for the
|
||||
--- `lua` language will be used.
|
||||
---@param buf (integer) Buffer handle
|
||||
---@param opts (QueryLinterOpts|nil) Optional keyword arguments:
|
||||
--- - langs (string|string[]|nil) Language(s) to use for checking the query.
|
||||
--- If multiple languages are specified, queries are validated for all of them
|
||||
--- - clear (boolean) if `true`, just clear current lint errors
|
||||
function M.lint(buf, opts)
|
||||
if opts and opts.clear then
|
||||
require('vim.treesitter._query_linter').clear(buf)
|
||||
else
|
||||
require('vim.treesitter._query_linter').lint(buf, opts)
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
|
Loading…
Reference in New Issue
Block a user