From 6bfc47b7a8d5d1d552510369830330f5723bbb8e Mon Sep 17 00:00:00 2001
From: Jonathan Apodaca <jrapodaca@gmail.com>
Date: Wed, 19 Mar 2025 23:09:29 -0600
Subject: [PATCH] cleanup and add more tests

---
 lua/u/renderer.lua     | 190 ++++++++++++++++++++++++++++++-----------
 lua/u/utils.lua        |  91 --------------------
 spec/renderer_spec.lua | 131 ++++++++++++++++++++++++++++
 spec/utils_spec.lua    |  70 ---------------
 4 files changed, 269 insertions(+), 213 deletions(-)
 create mode 100644 spec/renderer_spec.lua
 delete mode 100644 spec/utils_spec.lua

diff --git a/lua/u/renderer.lua b/lua/u/renderer.lua
index 3442c72..3f71a8f 100644
--- a/lua/u/renderer.lua
+++ b/lua/u/renderer.lua
@@ -1,11 +1,9 @@
-local utils = require 'u.utils'
-
 local M = {}
+local H = {}
 
 --- @alias Tag { kind: 'tag'; name: string, attributes: table<string, unknown>, children: Tree }
 --- @alias Node nil | boolean | string | Tag
 --- @alias Tree Node | Node[]
-local TagMetaTable = {}
 
 --- @param name string
 --- @param attributes? table<string, any>
@@ -20,9 +18,7 @@ function M.h(name, attributes, children)
   }
 end
 
---------------------------------------------------------------------------------
--- Renderer class
---------------------------------------------------------------------------------
+-- Renderer {{{
 --- @alias RendererExtmark { id?: number; start: [number, number]; stop: [number, number]; opts: any; tag: any }
 
 --- @class Renderer
@@ -35,10 +31,12 @@ local Renderer = {}
 Renderer.__index = Renderer
 M.Renderer = Renderer
 
+--- @private
 --- @param x any
 --- @return boolean
 function Renderer.is_tag(x) return type(x) == 'table' and x.kind == 'tag' end
 
+--- @private
 --- @param x any
 --- @return boolean
 function Renderer.is_tag_arr(x)
@@ -46,7 +44,7 @@ function Renderer.is_tag_arr(x)
   return #x == 0 or not Renderer.is_tag(x)
 end
 --- @param bufnr number|nil
-function Renderer.new(bufnr)
+function Renderer.new(bufnr) -- {{{
   if bufnr == nil then bufnr = vim.api.nvim_get_current_buf() end
 
   if vim.b[bufnr]._renderer_ns == nil then
@@ -61,13 +59,13 @@ function Renderer.new(bufnr)
     curr = { lines = {}, extmarks = {} },
   }, Renderer)
   return self
-end
+end -- }}}
 
 --- @param opts {
 ---   tree: Tree;
 ---   on_tag?: fun(tag: Tag, start0: [number, number], stop0: [number, number]): any;
 --- }
-function Renderer.markup_to_lines(opts)
+function Renderer.markup_to_lines(opts) -- {{{
   --- @type string[]
   local lines = {}
 
@@ -85,7 +83,7 @@ function Renderer.markup_to_lines(opts)
   end
 
   --- @param node Node
-  local function visit(node)
+  local function visit(node) -- {{{
     if node == nil or type(node) == 'boolean' then return end
 
     if type(node) == 'string' then
@@ -99,7 +97,9 @@ function Renderer.markup_to_lines(opts)
 
       -- visit the children:
       if Renderer.is_tag_arr(node.children) then
-        for _, child in ipairs(node.children) do
+        for _, child in
+          ipairs(node.children --[[@as Node[] ]])
+        do
           -- newlines are not controlled by array entries, do NOT output a line here:
           visit(child)
         end
@@ -115,11 +115,11 @@ function Renderer.markup_to_lines(opts)
         visit(child)
       end
     end
-  end
+  end -- }}}
   visit(opts.tree)
 
   return lines
-end
+end -- }}}
 
 --- @param opts {
 ---   tree: string;
@@ -128,7 +128,7 @@ end
 function Renderer.markup_to_string(opts) return table.concat(Renderer.markup_to_lines(opts), '\n') end
 
 --- @param tree Tree
-function Renderer:render(tree)
+function Renderer:render(tree) -- {{{
   local changedtick = vim.b[self.bufnr].changedtick
   if changedtick ~= self.changedtick then
     self.curr = { lines = vim.api.nvim_buf_get_lines(self.bufnr, 0, -1, false) }
@@ -142,7 +142,7 @@ function Renderer:render(tree)
   local lines = Renderer.markup_to_lines {
     tree = tree,
 
-    on_tag = function(tag, start0, stop0)
+    on_tag = function(tag, start0, stop0) -- {{{
       if tag.name == 'text' then
         local hl = tag.attributes.hl
         if type(hl) == 'string' then
@@ -161,7 +161,7 @@ function Renderer:render(tree)
             vim.keymap.set(
               'n',
               lhs,
-              function() return self:_on_expr_map('n', lhs) end,
+              function() return self:_expr_map_callback('n', lhs) end,
               { buffer = self.bufnr, expr = true, replace_keycodes = true }
             )
           end
@@ -176,79 +176,64 @@ function Renderer:render(tree)
           })
         end
       end
-    end,
+    end, -- }}}
   }
 
   self.old = self.curr
   self.curr = { lines = lines, extmarks = extmarks }
   self:_reconcile()
-end
+end -- }}}
 
 --- @private
---- @param info string
 --- @param start integer
 --- @param end_ integer
 --- @param strict_indexing boolean
 --- @param replacement string[]
-function Renderer:_set_lines(info, start, end_, strict_indexing, replacement)
-  self:_log { 'set_lines', self.bufnr, start, end_, strict_indexing, replacement }
+function Renderer:_set_lines(start, end_, strict_indexing, replacement)
   vim.api.nvim_buf_set_lines(self.bufnr, start, end_, strict_indexing, replacement)
-  self:_log { 'after(' .. info .. ')', vim.api.nvim_buf_get_lines(self.bufnr, 0, -1, false) }
 end
 
 --- @private
---- @param info string
 --- @param start_row integer
 --- @param start_col integer
 --- @param end_row integer
 --- @param end_col integer
 --- @param replacement string[]
-function Renderer:_set_text(info, start_row, start_col, end_row, end_col, replacement)
-  self:_log { 'set_text', self.bufnr, start_row, start_col, end_row, end_col, replacement }
+function Renderer:_set_text(start_row, start_col, end_row, end_col, replacement)
   vim.api.nvim_buf_set_text(self.bufnr, start_row, start_col, end_row, end_col, replacement)
-  self:_log { 'after(' .. info .. ')', vim.api.nvim_buf_get_lines(self.bufnr, 0, -1, false) }
 end
 
 --- @private
-function Renderer:_log(...)
-  --
-  -- vim.print(...)
-end
-
---- @private
-function Renderer:_reconcile()
-  local line_changes = utils.levenshtein(self.old.lines, self.curr.lines)
+function Renderer:_reconcile() -- {{{
+  local line_changes = H.levenshtein(self.old.lines, self.curr.lines)
   self.old = self.curr
 
   --
   -- Step 1: morph the text to the desired state:
   --
-  self:_log { line_changes = line_changes }
   for _, line_change in ipairs(line_changes) do
     local lnum0 = line_change.index - 1
 
     if line_change.kind == 'add' then
-      self:_set_lines('add-line', lnum0, lnum0, true, { line_change.item })
+      self:_set_lines(lnum0, lnum0, true, { line_change.item })
     elseif line_change.kind == 'change' then
       -- Compute inter-line diff, and apply:
-      self:_log '--------------------------------------------------------------------------------'
-      local col_changes = utils.levenshtein(vim.split(line_change.from, ''), vim.split(line_change.to, ''))
+      local col_changes = H.levenshtein(vim.split(line_change.from, ''), vim.split(line_change.to, ''))
 
       for _, col_change in ipairs(col_changes) do
         local cnum0 = col_change.index - 1
-        self:_log { line_change = col_change, cnum = cnum0, lnum = lnum0 }
         if col_change.kind == 'add' then
-          self:_set_text('add-char', lnum0, cnum0, lnum0, cnum0, { col_change.item })
+          self:_set_text(lnum0, cnum0, lnum0, cnum0, { col_change.item })
         elseif col_change.kind == 'change' then
-          self:_set_text('change-char', lnum0, cnum0, lnum0, cnum0 + 1, { col_change.to })
+          self:_set_text(lnum0, cnum0, lnum0, cnum0 + 1, { col_change.to })
         elseif col_change.kind == 'delete' then
-          self:_set_text('del-char', lnum0, cnum0, lnum0, cnum0 + 1, {})
+          self:_set_text(lnum0, cnum0, lnum0, cnum0 + 1, {})
         else
           -- No change
         end
       end
     elseif line_change.kind == 'delete' then
-      self:_set_lines('del-line', lnum0, lnum0 + 1, true, {})
+      self:_set_lines(lnum0, lnum0 + 1, true, {})
     else
       -- No change
     end
@@ -274,12 +259,12 @@ function Renderer:_reconcile()
       }, extmark.opts)
     )
   end
-end
+end -- }}}
 
 --- @private
 --- @param mode string
 --- @param lhs string
-function Renderer:_on_expr_map(mode, lhs)
+function Renderer:_expr_map_callback(mode, lhs) -- {{{
   -- find the tag with the smallest intersection that contains the cursor:
   local pos0 = vim.api.nvim_win_get_cursor(0)
   pos0[1] = pos0[1] - 1 -- make it actually 0-based
@@ -308,7 +293,7 @@ function Renderer:_on_expr_map(mode, lhs)
 
   -- Resort to default behavior:
   return cancel and '' or lhs
-end
+end -- }}}
 
 --- Returns pairs of extmarks and tags associate with said extmarks. The
 --- returned tags/extmarks are sorted smallest (innermost) to largest
@@ -317,7 +302,7 @@ end
 --- @private (private for now)
 --- @param pos0 [number; number]
 --- @return { extmark: RendererExtmark; tag: Tag; }[]
-function Renderer:get_pos_infos(pos0)
+function Renderer:get_pos_infos(pos0) -- {{{
   local cursor_line0, cursor_col0 = pos0[1], pos0[2]
 
   -- The cursor (block) occupies **two** extmark spaces: one for it's left
@@ -388,12 +373,10 @@ function Renderer:get_pos_infos(pos0)
     :totable()
 
   return matching_tags
-end
-
---------------------------------------------------------------------------------
--- TreeBuilder class
---------------------------------------------------------------------------------
+end -- }}}
+-- }}}
 
+-- TreeBuilder {{{
 --- @class TreeBuilder
 --- @field private nodes Node[]
 local TreeBuilder = {}
@@ -433,5 +416,108 @@ end
 
 --- @return Tree
 function TreeBuilder:tree() return self.nodes end
+-- }}}
+
+-- Levenshtein utility {{{
+--- @alias LevenshteinChange<T> ({ kind: 'add'; item: T; index: number; } | { kind: 'delete'; item: T; index: number; } | { kind: 'change'; from: T; to: T; index: number; })
+--- @private
+--- @generic T
+--- @param x `T`[]
+--- @param y T[]
+--- @param cost? { of_delete?: fun(x: T): number; of_add?: fun(x: T): number; of_change?: fun(x: T, y: T): number; }
+--- @return LevenshteinChange<T>[]
+function H.levenshtein(x, y, cost)
+  -- At the moment, this whole `cost` plumbing is not used. Deletes have the
+  -- same cost as Adds or Changes. I can imagine a future, however, where
+  -- fudging with the costs of operations produces a more optimized change-set
+  -- that is tailored to working better with how NeoVim manipulates text. I've
+  -- done no further investigation in this area, however, so it's impossible to
+  -- tell if such tuning would produce real benefit. For now, I'm leaving this
+  -- in here even though it's not actively used. Hopefully having this
+  -- callback-based plumbing does not cause too much of a performance hit to
+  -- the renderer.
+  cost = cost or {}
+  local cost_of_delete_f = cost.of_delete or function() return 1 end
+  local cost_of_add_f = cost.of_add or function() return 1 end
+  local cost_of_change_f = cost.of_change or function() return 1 end
+
+  local m, n = #x, #y
+  -- Initialize the distance matrix
+  local dp = {}
+  for i = 0, m do
+    dp[i] = {}
+    for j = 0, n do
+      dp[i][j] = 0
+    end
+  end
+
+  -- Fill the base cases
+  for i = 0, m do
+    dp[i][0] = i
+  end
+  for j = 0, n do
+    dp[0][j] = j
+  end
+
+  -- Compute the Levenshtein distance dynamically
+  for i = 1, m do
+    for j = 1, n do
+      if x[i] == y[j] then
+        dp[i][j] = dp[i - 1][j - 1] -- no cost if items are the same
+      else
+        local costDelete = dp[i - 1][j] + cost_of_delete_f(x[i])
+        local costAdd = dp[i][j - 1] + cost_of_add_f(y[j])
+        local costChange = dp[i - 1][j - 1] + cost_of_change_f(x[i], y[j])
+        dp[i][j] = math.min(costDelete, costAdd, costChange)
+      end
+    end
+  end
+
+  -- Backtrack to find the changes
+  local i = m
+  local j = n
+  --- @type LevenshteinChange[]
+  local changes = {}
+
+  while i > 0 or j > 0 do
+    local default_cost = dp[i][j]
+    local cost_of_change = (i > 0 and j > 0) and dp[i - 1][j - 1] or default_cost
+    local cost_of_add = j > 0 and dp[i][j - 1] or default_cost
+    local cost_of_delete = i > 0 and dp[i - 1][j] or default_cost
+
+    --- @param u number
+    --- @param v number
+    --- @param w number
+    local function is_first_min(u, v, w) return u <= v and u <= w end
+
+    if is_first_min(cost_of_change, cost_of_add, cost_of_delete) then
+      -- potential change
+      if x[i] ~= y[j] then
+        --- @type LevenshteinChange
+        local change = { kind = 'change', from = x[i], index = i, to = y[j] }
+        table.insert(changes, change)
+      end
+      i = i - 1
+      j = j - 1
+    elseif is_first_min(cost_of_add, cost_of_change, cost_of_delete) then
+      -- addition
+      --- @type LevenshteinChange
+      local change = { kind = 'add', item = y[j], index = i + 1 }
+      table.insert(changes, change)
+      j = j - 1
+    elseif is_first_min(cost_of_delete, cost_of_change, cost_of_add) then
+      -- deletion
+      --- @type LevenshteinChange
+      local change = { kind = 'delete', item = x[i], index = i }
+      table.insert(changes, change)
+      i = i - 1
+    else
+      error 'unreachable'
+    end
+  end
+
+  return changes
+end
+-- }}}
 
 return M
diff --git a/lua/u/utils.lua b/lua/u/utils.lua
index 593e08b..f0f4242 100644
--- a/lua/u/utils.lua
+++ b/lua/u/utils.lua
@@ -121,95 +121,4 @@ end
 
 function M.get_editor_dimensions() return { width = vim.go.columns, height = vim.go.lines } end
 
---- @alias LevenshteinChange<T> ({ kind: 'add'; item: T; index: number; } | { kind: 'delete'; item: T; index: number; } | { kind: 'change'; from: T; to: T; index: number; })
---- @private
---- @generic T
---- @param x `T`[]
---- @param y T[]
---- @param cost? { of_delete?: fun(x: T): number; of_add?: fun(x: T): number; of_change?: fun(x: T, y: T): number; }
---- @return LevenshteinChange<T>[]
-function M.levenshtein(x, y, cost)
-  cost = cost or {}
-  local cost_of_delete_f = cost.of_delete or function() return 1 end
-  local cost_of_add_f = cost.of_add or function() return 1 end
-  local cost_of_change_f = cost.of_change or function() return 1 end
-
-  local m, n = #x, #y
-  -- Initialize the distance matrix
-  local dp = {}
-  for i = 0, m do
-    dp[i] = {}
-    for j = 0, n do
-      dp[i][j] = 0
-    end
-  end
-
-  -- Fill the base cases
-  for i = 0, m do
-    dp[i][0] = i
-  end
-  for j = 0, n do
-    dp[0][j] = j
-  end
-
-  -- Compute the Levenshtein distance dynamically
-  for i = 1, m do
-    for j = 1, n do
-      if x[i] == y[j] then
-        dp[i][j] = dp[i - 1][j - 1] -- no cost if items are the same
-      else
-        local costDelete = dp[i - 1][j] + cost_of_delete_f(x[i])
-        local costAdd = dp[i][j - 1] + cost_of_add_f(y[j])
-        local costChange = dp[i - 1][j - 1] + cost_of_change_f(x[i], y[j])
-        dp[i][j] = math.min(costDelete, costAdd, costChange)
-      end
-    end
-  end
-
-  -- Backtrack to find the changes
-  local i = m
-  local j = n
-  --- @type LevenshteinChange[]
-  local changes = {}
-
-  while i > 0 or j > 0 do
-    local default_cost = dp[i][j]
-    local cost_of_change = (i > 0 and j > 0) and dp[i - 1][j - 1] or default_cost
-    local cost_of_add = j > 0 and dp[i][j - 1] or default_cost
-    local cost_of_delete = i > 0 and dp[i - 1][j] or default_cost
-
-    --- @param u number
-    --- @param v number
-    --- @param w number
-    local function is_first_min(u, v, w) return u <= v and u <= w end
-
-    if is_first_min(cost_of_change, cost_of_add, cost_of_delete) then
-      -- potential change
-      if x[i] ~= y[j] then
-        --- @type LevenshteinChange
-        local change = { kind = 'change', from = x[i], index = i, to = y[j] }
-        table.insert(changes, change)
-      end
-      i = i - 1
-      j = j - 1
-    elseif is_first_min(cost_of_add, cost_of_change, cost_of_delete) then
-      -- addition
-      --- @type LevenshteinChange
-      local change = { kind = 'add', item = y[j], index = i + 1 }
-      table.insert(changes, change)
-      j = j - 1
-    elseif is_first_min(cost_of_delete, cost_of_change, cost_of_add) then
-      -- deletion
-      --- @type LevenshteinChange
-      local change = { kind = 'delete', item = x[i], index = i }
-      table.insert(changes, change)
-      i = i - 1
-    else
-      error 'unreachable'
-    end
-  end
-
-  return changes
-end
-
 return M
diff --git a/spec/renderer_spec.lua b/spec/renderer_spec.lua
new file mode 100644
index 0000000..174b79a
--- /dev/null
+++ b/spec/renderer_spec.lua
@@ -0,0 +1,131 @@
+local R = require 'u.renderer'
+local withbuf = loadfile './spec/withbuf.lua'()
+
+local function getlines() return vim.api.nvim_buf_get_lines(0, 0, -1, true) end
+
+describe('Renderer', function()
+  it('should render text in an empty buffer', function()
+    withbuf({}, function()
+      local r = R.Renderer.new(0)
+      r:render { 'hello', ' ', 'world' }
+      assert.are.same(getlines(), { 'hello world' })
+    end)
+  end)
+
+  it('should result in the correct text after repeated renders', function()
+    withbuf({}, function()
+      local r = R.Renderer.new(0)
+      r:render { 'hello', ' ', 'world' }
+      assert.are.same(getlines(), { 'hello world' })
+
+      r:render { 'goodbye', ' ', 'world' }
+      assert.are.same(getlines(), { 'goodbye world' })
+
+      r:render { 'hello', ' ', 'universe' }
+      assert.are.same(getlines(), { 'hello universe' })
+    end)
+  end)
+
+  it('should handle tags correctly', function()
+    withbuf({}, function()
+      local r = R.Renderer.new(0)
+      r:render {
+        R.h('text', { hl = 'HighlightGroup' }, 'hello '),
+        R.h('text', { hl = 'HighlightGroup' }, 'world'),
+      }
+      assert.are.same(getlines(), { 'hello world' })
+    end)
+  end)
+
+  it('should reconcile added lines', function()
+    withbuf({}, function()
+      local r = R.Renderer.new(0)
+      r:render { 'line 1', '\n', 'line 2' }
+      assert.are.same(getlines(), { 'line 1', 'line 2' })
+
+      -- Add a new line:
+      r:render { 'line 1', '\n', 'line 2\n', 'line 3' }
+      assert.are.same(getlines(), { 'line 1', 'line 2', 'line 3' })
+    end)
+  end)
+
+  it('should reconcile deleted lines', function()
+    withbuf({}, function()
+      local r = R.Renderer.new(0)
+      r:render { 'line 1', '\nline 2', '\nline 3' }
+      assert.are.same(getlines(), { 'line 1', 'line 2', 'line 3' })
+
+      -- Remove a line:
+      r:render { 'line 1', '\nline 3' }
+      assert.are.same(getlines(), { 'line 1', 'line 3' })
+    end)
+  end)
+
+  it('should handle multiple nested elements', function()
+    withbuf({}, function()
+      local r = R.Renderer.new(0)
+      r:render {
+        R.h('text', {}, {
+          'first line',
+        }),
+        '\n',
+        R.h('text', {}, 'second line'),
+      }
+      assert.are.same(getlines(), { 'first line', 'second line' })
+
+      r:render {
+        R.h('text', {}, 'updated first line'),
+        '\n',
+        R.h('text', {}, 'third line'),
+      }
+      assert.are.same(getlines(), { 'updated first line', 'third line' })
+    end)
+  end)
+
+  --
+  -- get_pos_infos
+  --
+
+  it('should return no extmarks for an empty buffer', function()
+    withbuf({}, function()
+      local r = R.Renderer.new(0)
+      local pos_infos = r:get_pos_infos { 0, 0 }
+      assert.are.same(pos_infos, {})
+    end)
+  end)
+
+  it('should return correct extmark for a given position', function()
+    withbuf({}, function()
+      local r = R.Renderer.new(0)
+      r:render {
+        R.h('text', { hl = 'HighlightGroup1' }, 'Hello'),
+        R.h('text', { hl = 'HighlightGroup2' }, ' World'),
+      }
+
+      local pos_infos = r:get_pos_infos { 0, 2 }
+
+      assert.are.same(#pos_infos, 1)
+      assert.are.same(pos_infos[1].tag.attributes.hl, 'HighlightGroup1')
+      assert.are.same(pos_infos[1].extmark.start, { 0, 0 })
+      assert.are.same(pos_infos[1].extmark.stop, { 0, 5 })
+    end)
+  end)
+
+  it('should return multiple extmarks for overlapping text', function()
+    withbuf({}, function()
+      local r = R.Renderer.new(0)
+      r:render {
+        R.h('text', { hl = 'HighlightGroup1' }, {
+          'Hello',
+          R.h('text', { hl = 'HighlightGroup2', extmark = { hl_group = 'HighlightGroup2' } }, ' World'),
+        }),
+      }
+
+      local pos_infos = r:get_pos_infos { 0, 5 }
+
+      assert.are.same(#pos_infos, 2)
+      assert.are.same(pos_infos[1].tag.attributes.hl, 'HighlightGroup2')
+      assert.are.same(pos_infos[2].tag.attributes.hl, 'HighlightGroup1')
+    end)
+  end)
+end)
diff --git a/spec/utils_spec.lua b/spec/utils_spec.lua
deleted file mode 100644
index 75bfdf9..0000000
--- a/spec/utils_spec.lua
+++ /dev/null
@@ -1,70 +0,0 @@
-local utils = require 'u.utils'
-
---- @param s string
-local function split(s) return vim.split(s, '') end
-
---- @param original string
---- @param changes LevenshteinChange[]
-local function morph(original, changes)
-  local t = split(original)
-  for _, change in ipairs(changes) do
-    if change.kind == 'add' then
-      table.insert(t, change.index, change.item)
-    elseif change.kind == 'delete' then
-      table.remove(t, change.index)
-    elseif change.kind == 'change' then
-      t[change.index] = change.to
-    end
-  end
-  return vim.iter(t):join ''
-end
-
-describe('utils', function()
-  it('levenshtein', function()
-    local original = 'abc'
-    local result = 'absece'
-    local changes = utils.levenshtein(split(original), split(result))
-    assert.are.same(changes, {
-      {
-        item = 'e',
-        kind = 'add',
-        index = 4,
-      },
-      {
-        item = 'e',
-        kind = 'add',
-        index = 3,
-      },
-      {
-        item = 's',
-        kind = 'add',
-        index = 3,
-      },
-    })
-    assert.are.same(morph(original, changes), result)
-
-    original = 'jonathan'
-    result = 'ajoanthan'
-    changes = utils.levenshtein(split(original), split(result))
-    assert.are.same(changes, {
-      {
-        from = 'a',
-        index = 4,
-        kind = 'change',
-        to = 'n',
-      },
-      {
-        from = 'n',
-        index = 3,
-        kind = 'change',
-        to = 'a',
-      },
-      {
-        index = 1,
-        item = 'a',
-        kind = 'add',
-      },
-    })
-    assert.are.same(morph(original, changes), result)
-  end)
-end)