diff options
-rw-r--r-- | runtime/doc/filetype.txt | 14 | ||||
-rw-r--r-- | runtime/doc/news.txt | 6 | ||||
-rw-r--r-- | runtime/doc/treesitter.txt | 22 | ||||
-rw-r--r-- | runtime/ftplugin/query.lua | 26 | ||||
-rw-r--r-- | runtime/lua/vim/treesitter/_query_linter.lua | 302 | ||||
-rw-r--r-- | runtime/lua/vim/treesitter/playground.lua | 1 | ||||
-rw-r--r-- | runtime/lua/vim/treesitter/query.lua | 29 |
7 files changed, 399 insertions, 1 deletions
diff --git a/runtime/doc/filetype.txt b/runtime/doc/filetype.txt index f69ffeabfe..175c531950 100644 --- a/runtime/doc/filetype.txt +++ b/runtime/doc/filetype.txt @@ -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* diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 2a776ea30a..c343525a09 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -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* diff --git a/runtime/doc/treesitter.txt b/runtime/doc/treesitter.txt index dc1afe89f8..32c97ce3ad 100644 --- a/runtime/doc/treesitter.txt +++ b/runtime/doc/treesitter.txt @@ -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. diff --git a/runtime/ftplugin/query.lua b/runtime/ftplugin/query.lua index 3b99d67247..842d338fd9 100644 --- a/runtime/ftplugin/query.lua +++ b/runtime/ftplugin/query.lua @@ -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 ]]) diff --git a/runtime/lua/vim/treesitter/_query_linter.lua b/runtime/lua/vim/treesitter/_query_linter.lua new file mode 100644 index 0000000000..62f28d3097 --- /dev/null +++ b/runtime/lua/vim/treesitter/_query_linter.lua @@ -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 diff --git a/runtime/lua/vim/treesitter/playground.lua b/runtime/lua/vim/treesitter/playground.lua index c512710810..8293c1bd0a 100644 --- a/runtime/lua/vim/treesitter/playground.lua +++ b/runtime/lua/vim/treesitter/playground.lua @@ -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? diff --git a/runtime/lua/vim/treesitter/query.lua b/runtime/lua/vim/treesitter/query.lua index 8a747ba14c..492bfd1ffb 100644 --- a/runtime/lua/vim/treesitter/query.lua +++ b/runtime/lua/vim/treesitter/query.lua @@ -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 |