Merge pull request #9 from Myzel394/add-add-command

Add: Create Key Command
This commit is contained in:
Myzel394 2024-05-03 20:00:32 +02:00 committed by GitHub
commit 5536e5951d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 828 additions and 0 deletions

21
.github/workflows/run-tests.yaml vendored Normal file
View File

@ -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)

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "luaunit"]
path = luaunit
url = https://github.com/bluebird75/luaunit

View File

@ -11,6 +11,7 @@ It's completely customizable and even supports highlighting of the values.
## Features ## Features
* 🔍 Search for deeply nested keys - `expo.android.imageAsset.0.uri` * 🔍 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) * 🎨 See values with their correct syntax highlighting (numbers, strings, booleans, null; configurable)
* 💻 Use your LSP or the built-in JSON parser * 💻 Use your LSP or the built-in JSON parser
* 🗑 Values automatically cached for faster navigation * 🗑 Values automatically cached for faster navigation
@ -67,6 +68,25 @@ Go to a JSON file and run:
```lua ```lua
:Telescope jsonfly :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 `<C-a>` (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 ## See also
* [jsonpath.nvim](https://github.com/phelipetls/jsonpath.nvim) - Copy JSON paths to your clipboard * [jsonpath.nvim](https://github.com/phelipetls/jsonpath.nvim) - Copy JSON paths to your clipboard

412
lua/jsonfly/insert.lua Normal file
View File

@ -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;

View File

@ -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 = {} local M = {}
function M:truncate_overflow(value, max_length, overflow_marker) function M:truncate_overflow(value, max_length, overflow_marker)
@ -54,4 +107,98 @@ function M:replace_previous_keys(key, replacement)
return key return key
end 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 return M

View File

@ -12,6 +12,10 @@
---@field subkeys_display "normal"|"waterfall" - Display subkeys in a normal or waterfall style, Default: "normal" ---@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 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 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] <mode, key>; Example: {"n", "a"} -> When in normal mode, press "a" to add the key; Example: {"i", "<C-a>"} -> When in insert mode, press <C-a> to add the key; Default: {"i", "<C-a>"}
--- ---
---@class Highlights ---@class Highlights
---@field number string - Highlight group for numbers, Default: "@number.json" ---@field number string - Highlight group for numbers, Default: "@number.json"
@ -23,6 +27,7 @@
local parsers = require"jsonfly.parsers" local parsers = require"jsonfly.parsers"
local utils = require"jsonfly.utils" local utils = require"jsonfly.utils"
local cache = require"jsonfly.cache" local cache = require"jsonfly.cache"
local insert = require"jsonfly.insert"
local json = require"jsonfly.json" local json = require"jsonfly.json"
local finders = require "telescope.finders" local finders = require "telescope.finders"
@ -31,6 +36,8 @@ local conf = require"telescope.config".values
local make_entry = require "telescope.make_entry" local make_entry = require "telescope.make_entry"
local entry_display = require "telescope.pickers.entry_display" local entry_display = require "telescope.pickers.entry_display"
local action_state = require "telescope.actions.state"
---@type Options ---@type Options
local opts = { local opts = {
key_max_length = 50, key_max_length = 50,
@ -50,6 +57,9 @@ local opts = {
subkeys_display = "normal", subkeys_display = "normal",
backend = "lsp", backend = "lsp",
use_cache = 500, use_cache = 500,
commands = {
add_key = {"i", "<C-a>"}
}
} }
---@param entries Entry[] ---@param entries Entry[]
@ -76,6 +86,22 @@ local function show_picker(entries, buffer)
pickers.new(opts, { pickers.new(opts, {
prompt_title = opts.prompt_title, 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 { finder = finders.new_table {
results = entries, results = entries,
---@param entry Entry ---@param entry Entry

1
luaunit Submodule

@ -0,0 +1 @@
Subproject commit 0e0d3dd06fe1955a01f0e6763bc8dc6847ee3e8d

198
tests/test_utils.lua Normal file
View File

@ -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() )