diff options
| -rw-r--r-- | .github/workflows/metadata-diff.yml | 2 | ||||
| -rw-r--r-- | .github/workflows/tests.yml | 19 | ||||
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | Makefile | 22 | ||||
| -rw-r--r-- | lua/nvim-lsp-installer/dispatcher.lua | 8 | ||||
| -rw-r--r-- | lua/nvim-lsp-installer/path.lua | 2 | ||||
| -rw-r--r-- | lua/nvim-lsp-installer/ui/display.lua | 19 | ||||
| -rw-r--r-- | lua/nvim-lsp-installer/ui/status-win/init.lua | 1 | ||||
| -rw-r--r-- | tests/README.md | 1 | ||||
| -rw-r--r-- | tests/data_spec.lua | 135 | ||||
| -rw-r--r-- | tests/dispatcher_spec.lua | 24 | ||||
| -rw-r--r-- | tests/fs_spec.lua | 18 | ||||
| -rw-r--r-- | tests/luassertx/lua/luassertx.lua | 27 | ||||
| -rw-r--r-- | tests/minimal_init.vim | 30 | ||||
| -rw-r--r-- | tests/path_spec.lua | 23 | ||||
| -rw-r--r-- | tests/server_spec.lua | 56 | ||||
| -rw-r--r-- | tests/ui_spec.lua | 249 |
17 files changed, 624 insertions, 14 deletions
diff --git a/.github/workflows/metadata-diff.yml b/.github/workflows/metadata-diff.yml index b2b1679a..f98b6c15 100644 --- a/.github/workflows/metadata-diff.yml +++ b/.github/workflows/metadata-diff.yml @@ -16,7 +16,7 @@ jobs: - uses: rhysd/action-setup-vim@v1 with: neovim: true - version: v0.5.1 + version: v0.6.0 - name: Clone latest lspconfig run: | mkdir -p ~/.local/share/nvim/site/pack/packer/start diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..4e6640fd --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,19 @@ +name: Tests + +on: + push: + branches: + - "main" + pull_request: + +jobs: + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: rhysd/action-setup-vim@v1 + with: + neovim: true + version: v0.6.0 + - name: Run tests + run: make test diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..978d3570 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/dependencies +/tests/fixtures/lsp_servers diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..48a28d7a --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +INSTALL_ROOT_DIR:=$(shell pwd)/tests/fixtures/lsp_servers +NVIM_HEADLESS:=nvim --headless --noplugin -u tests/minimal_init.vim + +dependencies: + git clone --depth 1 https://github.com/nvim-lua/plenary.nvim dependencies/pack/vendor/start/plenary.nvim + +.PHONY: clean_dependencies +clean_dependencies: + rm -rf dependencies + +.PHONY: clean_servers +clean_servers: + rm -rf "${INSTALL_ROOT_DIR}" + +.PHONY: clean +clean: clean_servers clean_dependencies + +.PHONY: test +test: clean_servers dependencies + INSTALL_ROOT_DIR=${INSTALL_ROOT_DIR} $(NVIM_HEADLESS) -c "call RunTests()" + +# vim:noexpandtab diff --git a/lua/nvim-lsp-installer/dispatcher.lua b/lua/nvim-lsp-installer/dispatcher.lua index 0ad41897..762da3e3 100644 --- a/lua/nvim-lsp-installer/dispatcher.lua +++ b/lua/nvim-lsp-installer/dispatcher.lua @@ -4,6 +4,7 @@ local M = {} local registered_callbacks = {} +---@param server Server M.dispatch_server_ready = function(server) for _, callback in pairs(registered_callbacks) do local ok, err = pcall(callback, server) @@ -13,12 +14,11 @@ M.dispatch_server_ready = function(server) end end -local idx = 0 +---@param callback fun(server: Server) function M.register_server_ready_callback(callback) - local key = idx + 1 - registered_callbacks[("%d"):format(key)] = callback + registered_callbacks[callback] = callback return function() - table.remove(registered_callbacks, key) + registered_callbacks[callback] = nil end end diff --git a/lua/nvim-lsp-installer/path.lua b/lua/nvim-lsp-installer/path.lua index ed906954..533be48d 100644 --- a/lua/nvim-lsp-installer/path.lua +++ b/lua/nvim-lsp-installer/path.lua @@ -39,7 +39,7 @@ function M.realpath(relpath, depth) end function M.is_subdirectory(root_path, path) - return path:sub(1, #root_path) == root_path + return root_path == path or path:sub(1, #root_path + 1) == root_path .. sep end return M diff --git a/lua/nvim-lsp-installer/ui/display.lua b/lua/nvim-lsp-installer/ui/display.lua index 43550222..889c3ac3 100644 --- a/lua/nvim-lsp-installer/ui/display.lua +++ b/lua/nvim-lsp-installer/ui/display.lua @@ -92,12 +92,14 @@ local function render_node(viewport_context, node, _render_context, _output) local content, hl_group = span[1], span[2] local col_start = #full_line full_line = full_line .. content - line_highlights[#line_highlights + 1] = { - hl_group = hl_group, - line = #output.lines, - col_start = col_start, - col_end = col_start + #content, - } + if hl_group ~= "" then + line_highlights[#line_highlights + 1] = { + hl_group = hl_group, + line = #output.lines, + col_start = col_start, + col_end = col_start + #content, + } + end end local active_styles = get_styles(full_line, render_context) @@ -135,6 +137,9 @@ local function render_node(viewport_context, node, _render_context, _output) return output end +-- exported for tests +M._render_node = render_node + local function create_popup_window_opts() local win_height = vim.o.lines - vim.o.cmdheight - 2 -- Add margin for status and buffer line local win_width = vim.o.columns @@ -314,7 +319,7 @@ function M.new_view_only_win(name) output.lines, output.virt_texts, output.highlights, output.keybinds -- set line contents - vim.api.nvim_buf_clear_namespace(0, namespace, 0, -1) + vim.api.nvim_buf_clear_namespace(bufnr, namespace, 0, -1) vim.api.nvim_buf_set_option(bufnr, "modifiable", true) vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) vim.api.nvim_buf_set_option(bufnr, "modifiable", false) diff --git a/lua/nvim-lsp-installer/ui/status-win/init.lua b/lua/nvim-lsp-installer/ui/status-win/init.lua index 6adcf2ae..3191c6ae 100644 --- a/lua/nvim-lsp-installer/ui/status-win/init.lua +++ b/lua/nvim-lsp-installer/ui/status-win/init.lua @@ -769,7 +769,6 @@ local function init(all_servers) end) window.open { - win_width = 95, highlight_groups = { "hi def LspInstallerHeader gui=bold guifg=#ebcb8b", "hi def LspInstallerServerExpanded gui=italic", diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..bd9259d4 --- /dev/null +++ b/tests/README.md @@ -0,0 +1 @@ +Refer to https://github.com/williamboman/nvim-lspconfig-test for system tests. diff --git a/tests/data_spec.lua b/tests/data_spec.lua new file mode 100644 index 00000000..0e13d44c --- /dev/null +++ b/tests/data_spec.lua @@ -0,0 +1,135 @@ +local Data = require "nvim-lsp-installer.data" +local spy = require "luassert.spy" + +describe("data", function() + it("creates enums", function() + local colors = Data.enum { + "BLUE", + "YELLOW", + } + assert.equal( + vim.inspect { + ["BLUE"] = "BLUE", + ["YELLOW"] = "YELLOW", + }, + vim.inspect(colors) + ) + end) + + it("creates sets", function() + local colors = Data.set_of { + "BLUE", + "YELLOW", + "BLUE", + "RED", + } + assert.equal( + vim.inspect { + ["BLUE"] = true, + ["YELLOW"] = true, + ["RED"] = true, + }, + vim.inspect(colors) + ) + end) + + it("reverses lists", function() + local colors = { "BLUE", "YELLOW", "RED" } + assert.equal( + vim.inspect { + "RED", + "YELLOW", + "BLUE", + }, + vim.inspect(Data.list_reverse(colors)) + ) + -- should not modify in-place + assert.equal(vim.inspect { "BLUE", "YELLOW", "RED" }, vim.inspect(colors)) + end) + + it("maps over list", function() + local colors = { "BLUE", "YELLOW", "RED" } + assert.equal( + vim.inspect { + "LIGHT_BLUE1", + "LIGHT_YELLOW2", + "LIGHT_RED3", + }, + vim.inspect(Data.list_map(function(color, i) + return "LIGHT_" .. color .. i + end, colors)) + ) + -- should not modify in-place + assert.equal(vim.inspect { "BLUE", "YELLOW", "RED" }, vim.inspect(colors)) + end) + + it("coalesces first non-nil value", function() + assert.equal("Hello World!", Data.coalesce(nil, nil, "Hello World!", "")) + end) + + it("makes a shallow copy of a list", function() + local list = { "BLUE", { nested = "TABLE" }, "RED" } + local list_copy = Data.list_copy(list) + assert.equal(vim.inspect { "BLUE", { nested = "TABLE" }, "RED" }, vim.inspect(list_copy)) + assert.is_not.is_true(list == list_copy) + assert.is_true(list[2] == list_copy[2]) + end) + + it("finds first item that fulfills predicate", function() + local predicate = spy.new(function(item) + return item == "Waldo" + end) + + assert.equal( + "Waldo", + Data.list_find_first({ + "Where", + "On Earth", + "Is", + "Waldo", + "?", + }, predicate) + ) + assert.spy(predicate).was.called(4) + end) + + it("determines whether any item in the list fulfills predicate", function() + local predicate = spy.new(function(item) + return item == "On Earth" + end) + + assert.is_true(Data.list_any({ + "Where", + "On Earth", + "Is", + "Waldo", + "?", + }, predicate)) + + assert.spy(predicate).was.called(2) + end) + + it("memoizes functions with default cache mechanism", function() + local expensive_function = spy.new(function(s) + return s + end) + local memoized_fn = Data.memoize(expensive_function) + assert.equal("key", memoized_fn "key") + assert.equal("key", memoized_fn "key") + assert.equal("new_key", memoized_fn "new_key") + assert.spy(expensive_function).was_called(2) + end) + + it("memoizes function with custom cache mechanism", function() + local expensive_function = spy.new(function(arg1, arg2) + return arg1 .. arg2 + end) + local memoized_fn = Data.memoize(expensive_function, function(arg1, arg2) + return arg1 .. arg2 + end) + assert.equal("key1key2", memoized_fn("key1", "key2")) + assert.equal("key1key2", memoized_fn("key1", "key2")) + assert.equal("key1key3", memoized_fn("key1", "key3")) + assert.spy(expensive_function).was_called(2) + end) +end) diff --git a/tests/dispatcher_spec.lua b/tests/dispatcher_spec.lua new file mode 100644 index 00000000..da2148bc --- /dev/null +++ b/tests/dispatcher_spec.lua @@ -0,0 +1,24 @@ +local dispatcher = require "nvim-lsp-installer.dispatcher" +local spy = require "luassert.spy" + +describe("dispatcher", function() + it("calls registered callbacks", function() + local server = {} + local callback = spy.new() + dispatcher.register_server_ready_callback(callback) + dispatcher.dispatch_server_ready(server) + + assert.spy(callback).was_called(1) + assert.spy(callback).was_called_with(server) + end) + + it("deregisters callbacks", function() + local server = {} + local callback = spy.new() + local deregister = dispatcher.register_server_ready_callback(callback) + deregister() + dispatcher.dispatch_server_ready(server) + + assert.spy(callback).was_not_called() + end) +end) diff --git a/tests/fs_spec.lua b/tests/fs_spec.lua new file mode 100644 index 00000000..e227b9da --- /dev/null +++ b/tests/fs_spec.lua @@ -0,0 +1,18 @@ +local fs = require "nvim-lsp-installer.fs" +local lsp_installer = require "nvim-lsp-installer" + +describe("fs", function() + before_each(function() + lsp_installer.settings { + install_root_dir = "/foo", + } + end) + + it("refuses to rmrf unsafe paths", function() + local e = assert.has.errors(function() + fs.rmrf "/thisisa/path" + end) + + assert.equal("Refusing to operate on path (/thisisa/path) outside of the servers root dir (/foo).", e) + end) +end) diff --git a/tests/luassertx/lua/luassertx.lua b/tests/luassertx/lua/luassertx.lua new file mode 100644 index 00000000..33fa9957 --- /dev/null +++ b/tests/luassertx/lua/luassertx.lua @@ -0,0 +1,27 @@ +local a = require "plenary.async" +local assert = require "luassert" + +local function wait_for(_, arguments) + ---@type fun() @Function to execute until it does not error. + local assertions_fn = arguments[1] + ---@type number @Timeout in milliseconds. Defaults to 5000. + local timeout = arguments[2] + timeout = timeout or 15000 + + local start = vim.loop.hrtime() + local is_ok, err + repeat + is_ok, err = pcall(assertions_fn) + if not is_ok then + a.util.sleep(math.min(timeout, 100)) + end + until is_ok or ((vim.loop.hrtime() - start) / 1e6) > timeout + + if not is_ok then + error(err) + end + + return is_ok +end + +assert:register("assertion", "wait_for", wait_for) diff --git a/tests/minimal_init.vim b/tests/minimal_init.vim new file mode 100644 index 00000000..d0fe4e41 --- /dev/null +++ b/tests/minimal_init.vim @@ -0,0 +1,30 @@ +" Avoid neovim/neovim#11362 +set display=lastline +set directory="" +set noswapfile + +let $lsp_installer = getcwd() +let $luassertx_rtp = getcwd() .. "/tests/luassertx" +let $dependencies = getcwd() .. "/dependencies" + +set rtp+=$lsp_installer,$luassertx_rtp +set packpath=$dependencies + +packloadall + +" Luassert extensions +lua require("luassertx") + +lua <<EOF +require("nvim-lsp-installer").settings { + install_root_dir = os.getenv("INSTALL_ROOT_DIR"), +} +EOF + +function! RunTests() abort + lua <<EOF + require("plenary.test_harness").test_directory(os.getenv("FILE") or "./tests", { + minimal_init = vim.fn.getcwd() .. "/tests/minimal_init.vim", + }) +EOF +endfunction diff --git a/tests/path_spec.lua b/tests/path_spec.lua new file mode 100644 index 00000000..3ccaf3ad --- /dev/null +++ b/tests/path_spec.lua @@ -0,0 +1,23 @@ +local path = require "nvim-lsp-installer.path" + +describe("path", function() + it("concatenates paths", function() + assert.equal("foo/bar/baz/~", path.concat { "foo", "bar", "baz", "~" }) + end) + + it("concatenates paths on Windows", function() + local old_os = jit.os + jit.os = "windows" + package.loaded["nvim-lsp-installer.path"] = nil + local path = require "nvim-lsp-installer.path" + assert.equal([[foo\bar\baz\~]], path.concat { "foo", "bar", "baz", "~" }) + jit.os = old_os + end) + + it("identifies subdirectories", function() + assert.is_true(path.is_subdirectory("/foo/bar", "/foo/bar/baz")) + assert.is_true(path.is_subdirectory("/foo/bar", "/foo/bar")) + assert.is_false(path.is_subdirectory("/foo/bar", "/foo/bas/baz")) + assert.is_false(path.is_subdirectory("/foo/bar", "/foo/bars/baz")) + end) +end) diff --git a/tests/server_spec.lua b/tests/server_spec.lua new file mode 100644 index 00000000..0695710e --- /dev/null +++ b/tests/server_spec.lua @@ -0,0 +1,56 @@ +local lsp_installer = require "nvim-lsp-installer" +local server = require "nvim-lsp-installer.server" +local spy = require "luassert.spy" +local a = require "plenary.async" + +describe("server", function() + a.tests.it("calls registered on_ready handlers upon successful installation", function() + local on_ready_handler = spy.new() + local generic_handler = spy.new() + + lsp_installer.on_server_ready(generic_handler) + + local srv = server.Server:new { + name = "on_ready_fixture", + root_dir = server.get_server_root_path "on_ready_fixture", + installer = function(_, callback) + callback(true) + end, + default_options = { + cmd = { "my-server" }, + }, + } + srv:on_ready(on_ready_handler) + srv:install() + assert.wait_for(function() + assert.spy(on_ready_handler).was_called(1) + assert.spy(generic_handler).was_called(1) + assert.spy(generic_handler).was_called_with(srv) + end) + assert.is_true(srv:is_installed()) + end) + + a.tests.it("doesn't call on_ready handler when server fails installation", function() + local on_ready_handler = spy.new() + local generic_handler = spy.new() + + lsp_installer.on_server_ready(generic_handler) + + local srv = server.Server:new { + name = "on_ready_fixture_failing", + root_dir = server.get_server_root_path "on_ready_fixture_failing", + installer = function(_, callback) + callback(false) + end, + default_options = { + cmd = { "my-server" }, + }, + } + srv:on_ready(on_ready_handler) + srv:install() + a.util.sleep(500) + assert.spy(on_ready_handler).was_not_called() + assert.spy(generic_handler).was_not_called() + assert.is_false(srv:is_installed()) + end) +end) diff --git a/tests/ui_spec.lua b/tests/ui_spec.lua new file mode 100644 index 00000000..df771a4d --- /dev/null +++ b/tests/ui_spec.lua @@ -0,0 +1,249 @@ +local display = require "nvim-lsp-installer.ui.display" +local match = require "luassert.match" +local spy = require "luassert.spy" +local Ui = require "nvim-lsp-installer.ui" +local a = require "plenary.async" + +describe("ui", function() + it("produces a correct tree", function() + local function renderer(state) + return Ui.CascadingStyleNode({ "INDENT" }, { + Ui.When(not state.is_active, function() + return Ui.Text { + "I'm not active", + "Another line", + } + end), + Ui.When(state.is_active, function() + return Ui.Text { + "I'm active", + "Yet another line", + } + end), + }) + end + + assert.equal( + vim.inspect { + children = { + { + type = "HL_TEXT", + lines = { + { { "I'm not active", "" } }, + { { "Another line", "" } }, + }, + }, + { + type = "NODE", + children = {}, + }, + }, + styles = { "INDENT" }, + type = "CASCADING_STYLE", + }, + vim.inspect(renderer { is_active = false }) + ) + + assert.equal( + vim.inspect { + children = { + { + type = "NODE", + children = {}, + }, + { + type = "HL_TEXT", + lines = { + { { "I'm active", "" } }, + { { "Yet another line", "" } }, + }, + }, + }, + styles = { "INDENT" }, + type = "CASCADING_STYLE", + }, + vim.inspect(renderer { is_active = true }) + ) + end) + + it("renders a tree correctly", function() + local render_output = display._render_node( + { + win_width = 120, + }, + Ui.CascadingStyleNode({ "INDENT" }, { + Ui.Keybind("i", "INSTALL_SERVER", { "sumneko_lua" }, true), + Ui.HlTextNode { + { + { "Hello World!", "MyHighlightGroup" }, + }, + { + { "Another Line", "Comment" }, + }, + }, + Ui.HlTextNode { + { + { "Install something idk", "Stuff" }, + }, + }, + Ui.Keybind("<CR>", "INSTALL_SERVER", { "tsserver" }, false), + Ui.Text { "I'm a text node" }, + }) + ) + + assert.equal( + vim.inspect { + highlights = { + { + col_start = 2, + col_end = 14, + line = 0, + hl_group = "MyHighlightGroup", + }, + { + col_start = 2, + col_end = 14, + line = 1, + hl_group = "Comment", + }, + { + col_start = 2, + col_end = 23, + line = 2, + hl_group = "Stuff", + }, + }, + lines = { " Hello World!", " Another Line", " Install something idk", " I'm a text node" }, + virt_texts = {}, + keybinds = { + { + effect = "INSTALL_SERVER", + key = "i", + line = -1, + payload = { "sumneko_lua" }, + }, + { + effect = "INSTALL_SERVER", + key = "<CR>", + line = 3, + payload = { "tsserver" }, + }, + }, + }, + vim.inspect(render_output) + ) + end) +end) + +describe("integration test", function() + a.tests.it("calls vim APIs as expected during rendering", function() + local window = display.new_view_only_win "test" + + window.view(function(state) + return Ui.Node { + Ui.Keybind("U", "EFFECT", nil, true), + Ui.Text { + "Line number 1!", + state.text, + }, + Ui.Keybind("R", "R_EFFECT", { state.text }), + Ui.HlTextNode { + { + { "My highlighted text", "MyHighlightGroup" }, + }, + }, + } + end) + + local mutate_state = window.init { text = "Initial state" } + + window.open { + effects = { + ["EFFECT"] = function() end, + ["R_EFFECT"] = function() end, + }, + highlight_groups = { + "hi def MyHighlight gui=bold", + }, + } + + local clear_namespace = spy.on(vim.api, "nvim_buf_clear_namespace") + local buf_set_option = spy.on(vim.api, "nvim_buf_set_option") + local win_set_option = spy.on(vim.api, "nvim_win_set_option") + local set_lines = spy.on(vim.api, "nvim_buf_set_lines") + local set_extmark = spy.on(vim.api, "nvim_buf_set_extmark") + local add_highlight = spy.on(vim.api, "nvim_buf_add_highlight") + local set_keymap = spy.on(vim.api, "nvim_buf_set_keymap") + + -- Initial window and buffer creation + initial render + a.util.scheduler() + + assert.spy(win_set_option).was_called(8) + assert.spy(win_set_option).was_called_with(match.is_number(), "number", false) + assert.spy(win_set_option).was_called_with(match.is_number(), "relativenumber", false) + assert.spy(win_set_option).was_called_with(match.is_number(), "wrap", false) + assert.spy(win_set_option).was_called_with(match.is_number(), "spell", false) + assert.spy(win_set_option).was_called_with(match.is_number(), "foldenable", false) + assert.spy(win_set_option).was_called_with(match.is_number(), "signcolumn", "no") + assert.spy(win_set_option).was_called_with(match.is_number(), "colorcolumn", "") + assert.spy(win_set_option).was_called_with(match.is_number(), "cursorline", true) + + assert.spy(buf_set_option).was_called(9) + assert.spy(buf_set_option).was_called_with(match.is_number(), "modifiable", false) + assert.spy(buf_set_option).was_called_with(match.is_number(), "swapfile", false) + assert.spy(buf_set_option).was_called_with(match.is_number(), "textwidth", 0) + assert.spy(buf_set_option).was_called_with(match.is_number(), "buftype", "nofile") + assert.spy(buf_set_option).was_called_with(match.is_number(), "bufhidden", "wipe") + assert.spy(buf_set_option).was_called_with(match.is_number(), "buflisted", false) + assert.spy(buf_set_option).was_called_with(match.is_number(), "filetype", "lsp-installer") + + assert.spy(set_lines).was_called(1) + assert.spy(set_lines).was_called_with( + match.is_number(), + 0, + -1, + false, + { "Line number 1!", "Initial state", "My highlighted text" } + ) + + assert.spy(set_extmark).was_called(0) + + assert.spy(add_highlight).was_called(1) + assert.spy(add_highlight).was_called_with(match.is_number(), match.is_number(), "MyHighlightGroup", 2, 0, 19) + + assert.spy(set_keymap).was_called(2) + assert.spy(set_keymap).was_called_with( + match.is_number(), + "n", + "U", + match.has_match [[<cmd>lua require%('nvim%-lsp%-installer%.ui%.display'%)%.dispatch_effect%(%d, "55"%)<cr>]], + { nowait = true, silent = true, noremap = true } + ) + assert.spy(set_keymap).was_called_with( + match.is_number(), + "n", + "R", + match.has_match [[<cmd>lua require%('nvim%-lsp%-installer%.ui%.display'%)%.dispatch_effect%(%d, "52"%)<cr>]], + { nowait = true, silent = true, noremap = true } + ) + + assert.spy(clear_namespace).was_called(1) + assert.spy(clear_namespace).was_called_with(match.is_number(), match.is_number(), 0, -1) + + mutate_state(function(state) + state.text = "New state" + end) + + assert.spy(set_lines).was_called(1) + a.util.scheduler() + assert.spy(set_lines).was_called(2) + + assert.spy(set_lines).was_called_with( + match.is_number(), + 0, + -1, + false, + { "Line number 1!", "New state", "My highlighted text" } + ) + end) +end) |
