diff options
author | Stephan Seitz <stephan.seitz@fau.de> | 2023-04-29 18:22:26 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-04-29 18:22:26 +0200 |
commit | c194acbfc479d8e5839fa629363f93f6550d035c (patch) | |
tree | 134a12e3a5b6ee970081bcfc83c3494201dfa00f /runtime/lua/vim | |
parent | 933fdff4660a17b1df7809105c57825e0ece1fc6 (diff) | |
download | rneovim-c194acbfc479d8e5839fa629363f93f6550d035c.tar.gz rneovim-c194acbfc479d8e5839fa629363f93f6550d035c.tar.bz2 rneovim-c194acbfc479d8e5839fa629363f93f6550d035c.zip |
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>
Diffstat (limited to 'runtime/lua/vim')
-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 |
3 files changed, 332 insertions, 0 deletions
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 |