diff --git a/queries/roc/textobjects.scm b/queries/roc/textobjects.scm new file mode 100644 index 00000000..bc232d4c --- /dev/null +++ b/queries/roc/textobjects.scm @@ -0,0 +1,39 @@ +(anon_fun_expr + (expr_body) @function.inner) @function.outer + +(argument_patterns + ((_) @parameter.inner + . + ","? @parameter.outer) @parameter.outer) + +(function_type + ((_) @parameter.inner + . + ","? @parameter.outer) @parameter.outer + (#not-eq? @parameter.inner "->")) + +(function_call_expr + . + (_) + (parenthesized_expr + (expr_body) @parameter.inner) @parameter.outer) + +(function_call_expr + . + (_) @parameter.inner @parameter.outer) + +[ + (annotation_type_def) + (alias_type_def) + (opaque_type_def) +] @class.inner @class.outer + +(apply_type_arg) @parameter.inner + +((#offset! line_comment 0 1 0 -1)) @comment.inner + +(line_comment) @comment.outer + +((#offset! doc_comment 0 1 0 -2)) @comment.inner + +(doc_comment) @comment.outer diff --git a/scripts/format-queries.lua b/scripts/format-queries.lua new file mode 100755 index 00000000..f5b129cd --- /dev/null +++ b/scripts/format-queries.lua @@ -0,0 +1,450 @@ +#!/usr/bin/env -S nvim -l + +local ts = vim.treesitter +local get_node_text = ts.get_node_text + +---@type string[] +local files + +local arg = _G.arg[1] or "." +if arg:match ".*%.scm$" then + files = { arg } +else + files = vim.fn.split(vim.fn.glob(arg .. "/**/*.scm")) +end + +ts.query.add_predicate("has-type?", function(match, _, _, pred) + local node = match[pred[2]] + if not node then + return true + end + + local types = { unpack(pred, 3) } + return vim.tbl_contains(types, node:type()) +end, true) + +ts.query.add_predicate("is-start-of-line?", function(match, _, _, pred) + local node = match[pred[2]] + if not node then + return true + end + local start_row, start_col = node:start() + return vim.fn.indent(start_row + 1) == start_col +end) + +--- Control the indent here. Change to \t if uses tab instead +local indent_str = " " +local textwidth = 100 + +-- Query to control the formatter +local format_queries = [[ +;;query +;; Ignore next node with `; format-ignore` +( + (comment) @_pattern + . + (_) @format.ignore + (#lua-match? @_pattern "^;+%s*format%-ignore")) + +;; {{{ +;; Add newlines to top level nodes +;; {{{ +;; Preserve inline comments +(program + . (_) + (comment) @format.prepend-newline + (#is-start-of-line? @format.prepend-newline)) +(program + . (_) + (comment) @_comment + . + (comment) @format.prepend-newline + (#not-is-start-of-line? @_comment) + (#is-start-of-line? @format.prepend-newline)) +; Extra newline for modelines +(program + (comment) @_modeline + . + (_) @format.prepend-newline + (#is-start-of-line? @_modeline) + (#contains? @_modeline "^;+%s*inherits:")) +(program + (comment) @_modeline + . + (_) @format.prepend-newline + (#is-start-of-line? @_modeline) + (#contains? @_modeline "^;+%s*extends%s*$")) +;; }}} +;; Making sure all top-level patterns are separated +(program + (_) @format.append-newline) +(program + (_) @format.cancel-append .) +(program + . (_) + [ + (list) + (grouping) + (named_node) + (anonymous_node) + (field_definition) + ] @format.prepend-newline) + +(program + (comment) @_comment + . + [ + (list) + (grouping) + (named_node) + (anonymous_node) + (field_definition) + (comment) + ] @format.cancel-prepend + (#is-start-of-line? @_comment) + (#not-lua-match? @_comment "^;+%s*inherits:") + (#not-lua-match? @_comment "^;+%s*extends%s*$")) +;; }}} + +;; delims +[ + ":" + "." +] @format.append-space +( + "." @format.prepend-space @format.cancel-append + . + ")") + +;; List handler +;; Only starts indent if 2 or more elements +(list + "[" @format.indent.begin + "]" @format.indent.dedent) +;; Otherwise, remove brackets +(list + "[" @format.remove @format.cancel-append + . + (_) @format.cancel-append + . + "]" @format.remove) +;; [ ... ] @capture1 @capture2 +;; Append newlines for nodes inside the list +(list + (_) @format.append-newline + (#not-has-type? @format.append-newline capture quantifier)) + +;; (_), "_" and _ handler +;; Start indents if it's one of these patterns +(named_node + [ + "_" + name: (identifier) + ] @format.indent.begin + . + [ + (list) ; (foo [...]) + (grouping) ; (foo ((foo))) + (negated_field) ; (foo !field) + (field_definition) ; (foo field: (...)) + (named_node) ; (foo (bar)) + (predicate) ; (named_node (#set!)) + (anonymous_node) + "." + ]) +;; Honoring comment's position within a node +(named_node + [ + "_" + name: (identifier) + ] @format.indent.begin + . + (comment) @_comment + (#is-start-of-line? @_comment)) +(named_node + [ + "_" + name: (identifier) + ] @format.indent.begin @format.cancel-append + . + "."? @format.prepend-newline + . + (comment) @format.prepend-space + (#not-is-start-of-line? @format.prepend-space)) + +;; Add newlines for other nodes, in case the top node is indented +(named_node + [ + (list) + (grouping) + (negated_field) + (field_definition) + (named_node) + (predicate) + (anonymous_node) + "." + ] @format.append-newline) + +;; Collapse closing parentheses +(named_node + [ + "_" + name: (identifier) + (_) + ] @format.cancel-append + . + ")" + (#not-has-type? @format.cancel-append comment)) + +;; All captures should be separated with a space +(capture) @format.prepend-space + +;; Workaround to just use the string's content +(anonymous_node (identifier) @format.keep) + +; ( (_) ) handler +(grouping + "(" + . + [ + (named_node) ; ((foo)) + (list) ; ([foo] (...)) + (anonymous_node) ; ("foo") + (grouping . (_)) + ] @format.indent.begin + . + (_)) +(grouping + "(" + . + (grouping) @format.indent.begin + (predicate)) +(grouping + "(" + [ + (anonymous_node) + (named_node) + (list) + (predicate) + (grouping . (_)) + "." + ] @format.append-newline + (_) .) +;; Collapsing closing parens +(grouping + (_) @format.cancel-append . ")" + (#not-has-type? @format.cancel-append comment)) +(grouping + (capture) @format.prepend-space) +;; Remove unnecessary parens +(grouping + "(" @format.remove + . + (_) + . + ")" @format.remove .) +(grouping + "(" @format.remove + . + [ + (anonymous_node + name: (identifier) .) + (named_node + [ + "_" + name: (identifier) + ] .) + ] + . + ")" @format.remove + . + (capture)) + +; Separate this query to avoid capture duplication +(predicate + "(" @format.indent.begin @format.cancel-append) +(predicate + (parameters + (comment) @format.prepend-newline + . + (_) @format.cancel-prepend) + (#is-start-of-line? @format.prepend-newline)) +(predicate + (parameters + (_) @format.prepend-space) + (#set! conditional-newline)) +(predicate + (parameters + . + (capture) + . (_) @format.prepend-space) + (#set! lookahead-newline) + (#set! conditional-newline)) +;; Workaround to keep the string's content +(string) @format.keep + +;; Comment related handlers +(comment) @format.append-newline +;; comment styling. Feel free to change in the future +((comment) @format.replace + (#gsub! @format.replace "^;+(%s*.-)%s*$" ";%1")) +;; Preserve end of line comments +( + [ + "." + ":" + (list) + (grouping) + (named_node) + (anonymous_node) + (negated_field) + ] @format.cancel-append + . + (quantifier)? + . + "."? @format.prepend-newline ; Make sure anchor are not eol but start of newline + . + (comment) @format.prepend-space + (#not-is-start-of-line? @format.prepend-space)) +]] + +---@param lines string[] +---@param lines_to_append string[] +local function append_lines(lines, lines_to_append) + for i = 1, #lines_to_append, 1 do + lines[#lines] = lines[#lines] .. lines_to_append[i] + if i ~= #lines_to_append then + lines[#lines + 1] = "" + end + end +end + +---@param bufnr integer +---@param node TSNode +---@param lines string[] +---@param q table +---@param level integer +local function iter(bufnr, node, lines, q, level) + --- Sometimes 2 queries apply append twice. This is to prevent the case from happening + local apply_newline = false + for child, _ in node:iter_children() do + local id = child:id() + repeat + if apply_newline then + apply_newline = false + lines[#lines + 1] = string.rep(indent_str, level) + end + if q["format.ignore"][id] then + local text = vim.split(get_node_text(child, bufnr):gsub("\r\n?", "\n"), "\n", { trimempty = true }) + append_lines(lines, text) + break + elseif q["format.remove"][id] then + break + end + if not q["format.cancel-prepend"][id] then + if q["format.prepend-newline"][id] then + lines[#lines + 1] = string.rep(indent_str, level) + elseif q["format.prepend-space"][id] then + if not q["format.prepend-space"][id]["conditional-newline"] then + lines[#lines] = lines[#lines] .. " " + elseif child:byte_length() + 1 + #lines[#lines] > textwidth then + lines[#lines + 1] = string.rep(indent_str, level) + else + -- Do a rough guess of the actual byte length. If it's larger than `columns` then add a newline first + -- column - byte_end + byte_start + local _, _, byte_start = child:start() + local _, _, byte_end = node:end_() + if + q["format.prepend-space"][id]["lookahead-newline"] + and textwidth - (byte_end - byte_start) - #lines[#lines] < 0 + then + lines[#lines + 1] = string.rep(indent_str, level) + else + lines[#lines] = lines[#lines] .. " " + end + end + end + end + if q["format.replace"][id] then + append_lines(lines, vim.split(q["format.replace"][id].text, "\n", { trimempty = true })) + elseif child:named_child_count() == 0 or q["format.keep"][id] then + append_lines( + lines, + vim.split(string.gsub(get_node_text(child, bufnr), "\r\n?", "\n"), "\n+", { trimempty = true }) + ) + else + iter(bufnr, child, lines, q, level) + end + if q["format.indent.begin"][id] then + level = level + 1 + apply_newline = true + break + end + if q["format.indent.dedent"][id] then + if string.match(lines[#lines], "^%s*" .. get_node_text(child, bufnr)) then + lines[#lines] = string.sub(lines[#lines], 1 + #string.rep(indent_str, 1)) + end + end + if q["format.indent.end"][id] then + level = math.max(level - 1, 0) + if string.match(lines[#lines], "^%s*" .. get_node_text(child, bufnr)) then + lines[#lines] = string.sub(lines[#lines], 1 + #string.rep(indent_str, 1)) + end + break + end + until true + repeat + if q["format.cancel-append"][id] then + apply_newline = false + end + if not q["format.cancel-append"][id] then + if q["format.append-newline"][id] then + apply_newline = true + elseif q["format.append-space"][id] then + lines[#lines] = lines[#lines] .. " " + end + end + until true + end +end + +---@param bufnr integer +---@param queries string +local function format(bufnr, queries) + local lines = { "" } + -- stylua: ignore + local map = { + ['format.ignore'] = {}, -- Ignore the node and its children + ['format.indent.begin'] = {}, -- +1 shiftwidth for all nodes after this + ['format.indent.end'] = {}, -- -1 shiftwidth for all nodes after this + ['format.indent.dedent'] = {}, -- -1 shiftwidth for this line only + ['format.prepend-space'] = {}, -- Prepend a space before inserting the node + ['format.prepend-newline'] = {}, -- Prepend a \n before inserting the node + ['format.append-space'] = {}, -- Append a space after inserting the node + ['format.append-newline'] = {}, -- Append a newline after inserting the node + ['format.cancel-append'] = {}, -- Cancel any `@format.append-*` applied to the node + ['format.cancel-prepend'] = {}, -- Cancel any `@format.prepend-*` applied to the node + ['format.keep'] = {}, -- String content is not exposed as a syntax node. This is a workaround for it + ['format.replace'] = {}, -- Dedicated capture used to store results of `(#gsub!)` + ['format.remove'] = {}, -- Do not add the syntax node to the result, i.e. brackets [], parens () + } + local root = ts.get_parser(bufnr, "query"):parse(true)[1]:root() + local query = ts.query.parse("query", queries) + for id, node, metadata in query:iter_captures(root, bufnr) do + if query.captures[id]:sub(1, 1) ~= "_" then + map[query.captures[id]][node:id()] = metadata and (metadata[id] and metadata[id] or metadata) or {} + end + end + + iter(bufnr, root, lines, map, 0) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) +end + +for _, file in ipairs(files) do + local buf = vim.fn.bufadd(file) + vim.fn.bufload(file) + vim.api.nvim_set_current_buf(buf) + format(buf, format_queries) +end + +vim.cmd "silent wa!"