diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml new file mode 100644 index 0000000..8e18e29 --- /dev/null +++ b/.github/workflows/run-tests.yaml @@ -0,0 +1,21 @@ +name: Run tests + +on: + pull_request: + +jobs: + debug-builds: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: "recursive" + + - name: Install Lua + uses: leafo/gh-actions-lua@v10 + with: + luaVersion: "5.4.6" + + - name: Run tests + run: lua -v ./tests/$(ls ./tests) + diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..4b72ba1 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "luaunit"] + path = luaunit + url = https://github.com/bluebird75/luaunit diff --git a/README.md b/README.md index 6c661f8..f4bdef6 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ It's completely customizable and even supports highlighting of the values. ## Features * 🔍 Search for deeply nested keys - `expo.android.imageAsset.0.uri` +* 👀 Insert nested keys quickly into your buffer * 🎨 See values with their correct syntax highlighting (numbers, strings, booleans, null; configurable) * 💻 Use your LSP or the built-in JSON parser * 🗑 Values automatically cached for faster navigation @@ -67,6 +68,25 @@ Go to a JSON file and run: ```lua :Telescope jsonfly + +Now you can search for keys, subkeys, part of keys etc. + +### Inserting Keys + +JSON(fly) supports inserting your current search prompt into your buffer. + +If you search for a key that doesn't exist you can add it to your buffer by pressing `` (CTRL + a). + +You can enter nested keys, arrays, indices, subkeys etc. JSON(fly) will automatically manage everything for you. + +The following schemas are valid: + +* Nested keys: `expo.android.imageAssets.` +* Array indices: `expo.android.imageAssets.0.uri`, `expo.android.imageAssets.3.uri`, `expo.android.imageAssets.[3].uri` +* Escaping: `expo.android.tests.\0.name` -> Will not create an array but a key with the name `0` + +Please note: JSON(fly) is intended to be used with **human-readable** JSON files. Inserting keys won't work with minified JSON files. + ## See also * [jsonpath.nvim](https://github.com/phelipetls/jsonpath.nvim) - Copy JSON paths to your clipboard diff --git a/lua/jsonfly/insert.lua b/lua/jsonfly/insert.lua new file mode 100644 index 0000000..9f3418f --- /dev/null +++ b/lua/jsonfly/insert.lua @@ -0,0 +1,412 @@ +local utils = require"jsonfly.utils" + +-- This string will be used to position the cursor properly. +-- Once everything is set, the cursor searches for this string and jumps to it. +-- After that, it will be removed immediately. +local CURSOR_SEARCH_HELPER = "_jsonFfFfFfLyY0904857CursorHelperRrRrRrR" + +local M = {}; + +-- https://stackoverflow.com/a/24823383/9878135 +function table.slice(tbl, first, last, step) + local sliced = {} + + for i = first or 1, last or #tbl, step or 1 do + sliced[#sliced+1] = tbl[i] + end + + return sliced +end + +---@param line string +---@param also_match_end_bracket boolean - Whether to also match only a closing bracket +---@return boolean - Whether the line contains an empty JSON object +local function line_contains_empty_json(line, also_match_end_bracket) + -- Starting and ending on same line + return string.match(line, ".*[%{%[]%s*[%}%]]%s*,?*%s*") + -- Opening bracket on line + or string.match(line, ".*[%{%[]%s*") + -- Closing bracket on line + or (also_match_end_bracket and string.match(line, ".*.*[%}%]]%s*,?%s*")) +end + +---@param entry Entry +---@param key string +---@param index number +local function check_key_equal(entry, key, index) + local splitted = utils:split_by_char(entry.key, ".") + + return splitted[index] == key +end + +---Find the entry in `entries` with the most matching keys at the beginning based on the `keys`. +---Returns the index of the entry +---@param entries Entry[] +---@param keys string[] +---@return number|nil +local function find_best_fitting_entry(entries, keys) + local entry_index + local current_indexes = {1, #entries} + + for kk=1, #keys do + local key = keys[kk] + + local start_index = current_indexes[1] + local end_index = current_indexes[2] + + current_indexes = {nil, nil} + + for ii=start_index, end_index do + if check_key_equal(entries[ii], key, kk) then + if current_indexes[1] == nil then + current_indexes[1] = ii + end + + current_indexes[2] = ii + end + end + + if current_indexes[1] == nil then + -- No entries found + break + else + entry_index = current_indexes[1] + end + end + + return entry_index +end + +---@param keys KeyDescription +---@param index number - Index of the key +---@param lines string[] - Table to write the lines to +local function write_keys(keys, index, lines) + local key = keys[index] + + if index == #keys then + lines[#lines + 1] = "\"" .. key.key .. "\": \"" .. CURSOR_SEARCH_HELPER .. "\"" + return + end + + if key.type == "object_wrapper" then + local previous_line = lines[#lines] or "" + if line_contains_empty_json(previous_line, true) or #lines == 0 then + lines[#lines + 1] = "{" + else + lines[#lines] = previous_line .. " {" + end + + write_keys(keys, index + 1, lines) + + lines[#lines + 1] = "}" + elseif key.type == "key" then + lines[#lines + 1] = "\"" .. key.key .. "\":" + + write_keys(keys, index + 1, lines) + elseif key.type == "array_wrapper" then + local previous_line = lines[#lines] or "" + -- Starting and ending on same line + if line_contains_empty_json(previous_line, true) or #lines == 0 then + lines[#lines + 1] = "[" + else + lines[#lines] = previous_line .. " [" + end + write_keys(keys, index + 1, lines) + + lines[#lines + 1] = "]" + elseif key.type == "array_index" then + local amount = tonumber(key.key) + -- Write previous empty array objects + for _=1, amount do + lines[#lines + 1] = "{}," + end + + write_keys(keys, index + 1, lines) + end +end + +---@param buffer number +---@param insertion_line number +local function add_comma(buffer, insertion_line) + local BUFFER_SIZE = 5 + + -- Find next non-empty character in reverse + for ii=insertion_line, 0, -BUFFER_SIZE do + local previous_lines = vim.api.nvim_buf_get_lines( + buffer, + math.max(0, ii - BUFFER_SIZE), + ii, + false + ) + + if #previous_lines == 0 then + return + end + + for jj=#previous_lines, 1, -1 do + local line = previous_lines[jj] + + for char_index=#line, 1, -1 do + local char = line:sub(char_index, char_index) + + if char ~= " " and char ~= "\t" and char ~= "\n" and char ~= "\r" then + if char == "," or char == "{" or char == "[" then + return + end + + -- Insert comma at position + local line_number = math.max(0, ii - BUFFER_SIZE) + jj - 1 + vim.api.nvim_buf_set_text( + buffer, + line_number, + char_index, + line_number, + char_index, + {","} + ) + return + end + end + end + end +end + +---@return number - The new line number to be used, as the buffer has been modified +local function expand_empty_object(buffer, line_number) + local line = vim.api.nvim_buf_get_lines(buffer, line_number, line_number + 1, false)[1] or "" + + if line_contains_empty_json(line, false) then + local position_closing_bracket = string.find(line, "[%}%]]") + local remaining_line = string.sub(line, position_closing_bracket + 1) + + vim.api.nvim_buf_set_lines( + buffer, + line_number, + line_number + 1, + false, + { + "{", + "}" .. remaining_line + } + ) + + return line_number + 1 + end + + return line_number +end + +---@param keys KeyDescription[] +---@param input_key_depth number +local function get_key_descriptor_index(keys, input_key_depth) + local depth = 0 + local index = 0 + + for ii=1, #keys do + if keys[ii].type == "key" or keys[ii].type == "array_index" then + depth = depth + 1 + end + + if depth >= input_key_depth then + index = ii + break + end + end + + return index +end + +---@param entries Entry[] +---@param keys string[] +---@return integer|nil - The index of the entry +local function get_entry_by_keys(entries, keys) + for ii=1, #entries do + local entry = entries[ii] + local splitted = utils:split_by_char(entry.key, ".") + + local found = true + + for jj=1, #keys do + if keys[jj] ~= splitted[jj] then + found = false + break + end + end + + if found then + return ii + end + end +end + +---@param keys KeyDescription[] +---@return string[] +local function flatten_key_description(keys) + local flat_keys = {} + + for ii=1, #keys do + if keys[ii].type == "key" then + flat_keys[#flat_keys + 1] = keys[ii].key + elseif keys[ii].type == "array_index" then + flat_keys[#flat_keys + 1] = tostring(keys[ii].key) + end + end + + return flat_keys +end + +---Subtracts indexes if there are other indexes before already +---This ensures that no extra objects are created in `write_keys` +---Example: Entry got 4 indexes, keys want to index `6`. This will subtract 4 from `6` to get `2`. +---@param entries Entry[] +---@param starting_keys KeyDescription[] +---@param key KeyDescription - Th key to be inserted; must be of type `array_index`; will be modified in-place +local function normalize_array_indexes(entries, starting_keys, key) + local starting_keys_flat = flatten_key_description(starting_keys) + local starting_key_index = get_entry_by_keys(entries, starting_keys_flat) + local entry = entries[starting_key_index] + + key.key = key.key - #entry.value +end + +---@param entries Entry[] - Entries, they must be children of a top level array +---Counts how many top level children an array has +local function count_array_children(entries) + for ii=1, #entries do + if string.match(entries[ii].key, "^%d+$") then + return ii + end + end + + return #entries +end + +---Jump to the cursor helper and remove it +---@param buffer number +function M:jump_to_cursor_helper(buffer) + vim.fn.search(CURSOR_SEARCH_HELPER) + + -- Remove cursor helper + local position = vim.api.nvim_win_get_cursor(0) + vim.api.nvim_buf_set_text( + buffer, + position[1] - 1, + position[2], + position[1] - 1, + position[2] + #CURSOR_SEARCH_HELPER, + {""} + ) + + -- -- Go into insert mode + vim.cmd [[execute "normal a"]] +end + +-- TODO: Handle top level empty arrays +---@param entries Entry[] +---@param keys KeyDescription[] +---@param buffer number +function M:insert_new_key(entries, keys, buffer) + -- Close current buffer + vim.cmd [[quit!]] + + local input_key = flatten_key_description(keys) + ---@type boolean + local should_add_comma = true + + ---@type KeyDescription[] + local remaining_keys + ---@type Entry + local entry + + if #entries == 0 then + remaining_keys = table.slice(keys, 2, #keys) + + entry = { + key = "", + position = { + key_start = 1, + line_number = 1, + value_start = 1 + } + } + should_add_comma = false + else + local entry_index = find_best_fitting_entry(entries, input_key) or 0 + entry = entries[entry_index] + + ---@type integer + local existing_keys_index + + if entry == nil then + -- Insert as root + existing_keys_index = 0 + remaining_keys = table.slice(keys, 2, #keys) + + -- Top level array + if entries[1].key == "0" then + -- Normalize array indexes + remaining_keys[1].key = remaining_keys[1].key - count_array_children(entries) + end + + entry = { + key = "", + position = { + key_start = 1, + line_number = 1, + value_start = 1 + } + } + else + local existing_input_keys_depth = #utils:split_by_char(entry.key, ".") + 1 + existing_keys_index = get_key_descriptor_index(keys, existing_input_keys_depth) + remaining_keys = table.slice(keys, existing_keys_index, #keys) + + if remaining_keys[1].type == "array_index" then + local starting_keys = table.slice(keys, 1, existing_keys_index - 1) + normalize_array_indexes(entries, starting_keys, remaining_keys[1]) + end + end + end + + local _writes = {} + write_keys(remaining_keys, 1, _writes) + local writes = {} + + for ii=1, #_writes do + if _writes[ii] == true then + -- Unwrap table + writes[#writes] = writes[#writes][1] + else + writes[#writes + 1] = _writes[ii] + end + end + + -- Hacky way to jump to end of object + vim.api.nvim_win_set_cursor(0, {entry.position.line_number, entry.position.value_start}) + vim.cmd [[execute "normal %"]] + + local changes = #writes + local start_line = vim.api.nvim_win_get_cursor(0)[1] - 1 + + -- Add comma to previous JSON entry + if should_add_comma then + add_comma(buffer, start_line) + end + local new_start_line = expand_empty_object(buffer, start_line) + + if new_start_line ~= start_line then + changes = changes + math.abs(new_start_line - start_line) + start_line = new_start_line + end + + -- Insert new lines + vim.api.nvim_buf_set_lines(buffer, start_line, start_line, false, writes) + + -- Format lines + vim.api.nvim_win_set_cursor(0, {start_line, 1}) + vim.cmd('execute "normal =' .. changes .. 'j"') + + M:jump_to_cursor_helper(buffer) +end + +return M; diff --git a/lua/jsonfly/utils.lua b/lua/jsonfly/utils.lua index 66f30bf..17c36b8 100644 --- a/lua/jsonfly/utils.lua +++ b/lua/jsonfly/utils.lua @@ -1,3 +1,56 @@ +---@class KeyDescription +---@field key string +---@field type "object_wrapper"|"key"|"array_wrapper"|"array_index" + +-- Examples: +--{ +-- hello: [ +-- { +-- test: "abc" +-- } +-- ] +--} +-- hello.[0].test +-- { key = "hello", type = "object" } +-- { type = "array" } +-- { type = "array_index", key = 0 } +-- { key = "test", type = "object" } +-- +--{ +-- hello: [ +-- [ +-- { +-- test: "abc" +-- } +-- ] +-- ] +--} +-- hello.[0].[0].test +-- { key = "hello", type = "object" } +-- { type = "array" } +-- { type = "array_index", key = 0 } +-- { type = "array" } +-- { type = "array_index", key = 0 } +-- { key = "test", type = "object" } +-- +--{ +-- hello: [ +-- {}, +-- [ +-- { +-- test: "abc" +-- } +-- ] +-- ] +--} +-- hello.[1].[0].test +-- { key = "hello", type = "object" } +-- { type = "array" } +-- { type = "array_index", key = 1 } +-- { type = "array" } +-- { type = "array_index", key = 0 } +-- { key = "test", type = "object" } + local M = {} function M:truncate_overflow(value, max_length, overflow_marker) @@ -54,4 +107,98 @@ function M:replace_previous_keys(key, replacement) return key end +---@param text string +---@param char string +---@return string[] +function M:split_by_char(text, char) + local parts = {} + local current = "" + + for i = 1, #text do + local c = text:sub(i, i) + + if c == char then + parts[#parts + 1] = current + current = "" + else + current = current .. c + end + end + + parts[#parts + 1] = current + + return parts +end + +---@param text string +---@return KeyDescription[] +function M:extract_key_description(text) + local keys = {} + + local splitted = M:split_by_char(text, ".") + + local index = 1 + + while index <= #splitted do + local token = splitted[index] + + -- Escape + if string.sub(token, 1, 1) == "\\" then + token = token:sub(2) + + keys[#keys + 1] = { + type = "object_wrapper", + } + keys[#keys + 1] = { + key = token, + type = "key", + } + -- Array + elseif string.match(token, "%[%d+%]") then + local array_index = tonumber(string.sub(token, 2, -2)) + + keys[#keys + 1] = { + type = "array_wrapper", + } + keys[#keys + 1] = { + key = array_index, + type = "array_index", + } + -- Array + elseif string.match(token, "%d+") then + local array_index = tonumber(token) + + keys[#keys + 1] = { + type = "array_wrapper", + } + keys[#keys + 1] = { + key = array_index, + type = "array_index", + } + -- Object + else + keys[#keys + 1] = { + type = "object_wrapper", + } + keys[#keys + 1] = { + key = token, + type = "key", + } + end + + index = index + 1 + end + + if #keys == 0 then + return { + { + key = text, + type = "key", + } + } + end + + return keys +end + return M diff --git a/lua/telescope/_extensions/jsonfly.lua b/lua/telescope/_extensions/jsonfly.lua index cd0c19f..ed59e01 100644 --- a/lua/telescope/_extensions/jsonfly.lua +++ b/lua/telescope/_extensions/jsonfly.lua @@ -12,6 +12,10 @@ ---@field subkeys_display "normal"|"waterfall" - Display subkeys in a normal or waterfall style, Default: "normal" ---@field backend "lua"|"lsp" - Backend to use for parsing JSON, "lua" = Use our own Lua parser to parse the JSON, "lsp" = Use your LSP to parse the JSON (currently only https://github.com/Microsoft/vscode-json-languageservice is supported). If the "lsp" backend is selected but the LSP fails, it will fallback to the "lua" backend, Default: "lsp" ---@field use_cache number - Whether to use cache the parsed JSON. The cache will be activated if the number of lines is greater or equal to this value, By default, the cache is activate when the file if 1000 lines or more; `0` to disable the cache, Default: 500 +---@field commands Commands - Shortcuts for commands +-- +---@class Commands +---@field add_key string[] - Add the currently entered key to the JSON. Must be of type [string, string] ; Example: {"n", "a"} -> When in normal mode, press "a" to add the key; Example: {"i", ""} -> When in insert mode, press to add the key; Default: {"i", ""} --- ---@class Highlights ---@field number string - Highlight group for numbers, Default: "@number.json" @@ -23,6 +27,7 @@ local parsers = require"jsonfly.parsers" local utils = require"jsonfly.utils" local cache = require"jsonfly.cache" +local insert = require"jsonfly.insert" local json = require"jsonfly.json" local finders = require "telescope.finders" @@ -31,6 +36,8 @@ local conf = require"telescope.config".values local make_entry = require "telescope.make_entry" local entry_display = require "telescope.pickers.entry_display" +local action_state = require "telescope.actions.state" + ---@type Options local opts = { key_max_length = 50, @@ -50,6 +57,9 @@ local opts = { subkeys_display = "normal", backend = "lsp", use_cache = 500, + commands = { + add_key = {"i", ""} + } } ---@param entries Entry[] @@ -76,6 +86,22 @@ local function show_picker(entries, buffer) pickers.new(opts, { prompt_title = opts.prompt_title, + attach_mappings = function(_, map) + map( + opts.commands.add_key[1], + opts.commands.add_key[2], + function(prompt_bufnr) + local current_picker = action_state.get_current_picker(prompt_bufnr) + local input = current_picker:_get_prompt() + + local key_descriptor = utils:extract_key_description(input) + + insert:insert_new_key(entries, key_descriptor, buffer) + end + ) + + return true + end, finder = finders.new_table { results = entries, ---@param entry Entry diff --git a/luaunit b/luaunit new file mode 160000 index 0000000..0e0d3dd --- /dev/null +++ b/luaunit @@ -0,0 +1 @@ +Subproject commit 0e0d3dd06fe1955a01f0e6763bc8dc6847ee3e8d diff --git a/tests/test_utils.lua b/tests/test_utils.lua new file mode 100644 index 0000000..e934dbb --- /dev/null +++ b/tests/test_utils.lua @@ -0,0 +1,198 @@ +local lu = require"luaunit.luaunit" +local utils = require"lua.jsonfly.utils" + +function testBasicKey() + local key = "foo.bar" + ---@type KeyDescription[] + local EXPECTED = { + { + type = "object_wrapper", + }, + { + type = "key", + key = "foo", + }, + { + type = "object_wrapper", + }, + { + type = "key", + key = "bar", + } + } + + local descriptor = utils:extract_key_description(key) + + lu.assertEquals(descriptor, EXPECTED) +end + +function testArrayKey() + local key = "foo.0.bar" + ---@type KeyDescription[] + local EXPECTED = { + { + type = "object_wrapper", + }, + { + type = "key", + key = "foo", + }, + { + type = "array_wrapper", + }, + { + type = "array_index", + key = 0, + }, + { + type = "object_wrapper", + }, + { + type = "key", + key = "bar", + } + } + + local descriptor = utils:extract_key_description(key) + + lu.assertEquals(descriptor, EXPECTED) +end + +function testNestedArrayKey() + local key = "foo.0.bar.1.baz" + ---@type KeyDescription[] + local EXPECTED = { + { + type = "object_wrapper", + }, + { + type = "key", + key = "foo", + }, + { + type = "array_wrapper", + }, + { + type = "array_index", + key = 0, + }, + { + type = "object_wrapper", + }, + { + type = "key", + key = "bar", + }, + { + type = "array_wrapper", + }, + { + type = "array_index", + key = 1, + }, + { + type = "object_wrapper", + }, + { + type = "key", + key = "baz", + } + } + + local descriptor = utils:extract_key_description(key) + + lu.assertEquals(descriptor, EXPECTED) +end + +function testEscapedArrayDoesNotCreateArray() + local key = "foo.\\0.bar" + ---@type KeyDescription[] + local EXPECTED = { + { + type = "object_wrapper", + }, + { + type = "key", + key = "foo", + }, + { + type = "object_wrapper", + }, + { + type = "key", + key = "0", + }, + { + type = "object_wrapper", + }, + { + type = "key", + key = "bar", + } + } + + local descriptor = utils:extract_key_description(key) + + lu.assertEquals(descriptor, EXPECTED) +end + +function testBracketArrayKey() + local key = "foo.[0].bar" + ---@type KeyDescription[] + local EXPECTED = { + { + type = "object_wrapper", + }, + { + type = "key", + key = "foo", + }, + { + type = "array_wrapper", + }, + { + type = "array_index", + key = 0, + }, + { + type = "object_wrapper", + }, + { + type = "key", + key = "bar", + } + } + + local descriptor = utils:extract_key_description(key) + + lu.assertEquals(descriptor, EXPECTED) +end + +function testRootArrayKey() + local key = "0.foo" + ---@type KeyDescription[] + local EXPECTED = { + { + type = "array_wrapper", + }, + { + type = "array_index", + key = 0, + }, + { + type = "object_wrapper", + }, + { + type = "key", + key = "foo", + } + } + + local descriptor = utils:extract_key_description(key) + + lu.assertEquals(descriptor, EXPECTED) +end + + +os.exit( lu.LuaUnit.run() ) +