local Description = require("leetcode-ui.split.description") local Console = require("leetcode-ui.layout.console") local Info = require("leetcode-ui.popup.info") local Object = require("nui.object") local api_question = require("leetcode.api.question") local utils = require("leetcode.utils") local ui_utils = require("leetcode-ui.utils") local config = require("leetcode.config") local log = require("leetcode.logger") ---@alias lc.editor.section "imports" | "code" ---@class lc.ui.Question ---@field file Path ---@field q lc.question_res ---@field description lc.ui.Description ---@field bufnr integer ---@field console lc.ui.Console ---@field lang string ---@field cache lc.cache.Question ---@field reset boolean local Question = Object("LeetQuestion") ---@param raw? boolean function Question:snippet(raw) local snippets = self.q.code_snippets ~= vim.NIL and self.q.code_snippets or {} local snip = vim.tbl_filter(function(snip) return snip.lang_slug == self.lang end, snippets)[1] if not snip then return end local code = snip.code:gsub("\r\n", "\n") return raw and code or self:injector(code) end ---@param start_i integer ---@param end_i integer ---@param lines string[]|string function Question:editor_set_lines(start_i, end_i, lines) if not (self.bufnr and vim.api.nvim_buf_is_valid(self.bufnr)) then return end lines = type(lines) == "string" and vim.split(lines, "\n") or lines ---@cast lines string[] vim.api.nvim_buf_set_lines(self.bufnr, start_i, end_i, false, lines) end function Question:editor_reset() local new_lines = self:snippet() or "" self:editor_set_lines(0, -1, new_lines) end function Question:editor_reset_code() local new_lines = self:snippet(true) or "" self:editor_section_replace(new_lines, "code") end function Question:reset_previous_code() self:editor_reset_code() vim.schedule(function() log.info("Previous code found and reset. To undo, simply press `u` or use `:undo`.") end) end ---@return string path, boolean existed function Question:path() local lang = utils.get_lang(self.lang) local alt = lang.alt and ("." .. lang.alt) or "" -- handle legacy file names first local fn_legacy = -- ("%s.%s-%s.%s"):format(self.q.frontend_id, self.q.title_slug, lang.slug, lang.ft) self.file = config.storage.home:joinpath(fn_legacy) if self.file:exists() then return self.file:absolute(), true end local fn = ("%s.%s%s.%s"):format(self.q.frontend_id, self.q.title_slug, alt, lang.ft) self.file = config.storage.home:joinpath(fn) local existed = self.file:exists() if not existed then self.file:write(self:snippet(), "w") end return self.file:absolute(), existed end function Question:create_buffer() local path, existed = self:path() vim.cmd("$tabe " .. path) self.bufnr = vim.api.nvim_get_current_buf() self.winid = vim.api.nvim_get_current_win() ui_utils.win_set_winfixbuf(self.winid) self:open_buffer(existed) end ---@param strict? boolean function Question:editor_fold_imports(strict) if not (self.bufnr and vim.api.nvim_buf_is_valid(self.bufnr)) then return end local range = self:editor_section_range("imports", true) if range.complete or range.end_i then vim.api.nvim_buf_call(self.bufnr, function() pcall(vim.cmd, ("%d,%dfold"):format(range.start_i or 1, range.end_i)) ---@diagnostic disable-line: param-type-mismatch end) elseif strict then range.log_not_found() end end function Question:editor_yank_code() if not (self.bufnr and vim.api.nvim_buf_is_valid(self.bufnr)) then return end local range = self:editor_section_range("code") if range:is_valid_or_log() then vim.api.nvim_buf_call(self.bufnr, function() vim.cmd(("%d,%dyank"):format(range.start_i, range.end_i)) end) end end ---@param existed boolean function Question:open_buffer(existed) ui_utils.buf_set_opts(self.bufnr, { buflisted = true }) ui_utils.win_set_buf(self.winid, self.bufnr, true) vim.cmd([[match DiagnosticHint /@leet/]]) if config.user.editor.fold_imports then self:editor_fold_imports(false) end if config.user.editor.reset_previous_code and (existed and self.cache.status == "ac") then self:reset_previous_code() end end ---@return string[]? function Question:inject_imports() local inject = config.user.injector[self.lang] or {} local imports = inject.imports local default_imports = config.imports[self.lang] local function valid_imports(tbl) if vim.tbl_isempty(tbl or {}) then return false end if not vim.islist(tbl) then log.error("Invalid imports format for language: " .. self.lang) return false end return true end if not imports then return default_imports end if type(imports) == "function" then local overriden = imports(vim.deepcopy(default_imports or {})) if not valid_imports(overriden) then return default_imports end return overriden end local resolved = type(imports) == "string" and { imports } or imports if not valid_imports(resolved) then return end local merged, seen = {}, {} local function add(list) for _, v in ipairs(list) do if not seen[v] then table.insert(merged, v) seen[v] = true end end end add(default_imports) add(resolved) return not vim.tbl_isempty(merged) and merged or nil end ---@param before boolean ---@return string? function Question:inject(before) local inject = config.user.injector[self.lang] or {} local inj = before and (inject.before or {}) or (inject.after or {}) local res if type(inj) == "table" then res = table.concat(inj, "\n") elseif type(inj) == "string" then res = inj end if res and res ~= "" then return res else return nil end end ---@param lines string|string[] ---@param name lc.editor.section ---@return string function Question:editor_section(lines, name) local comment = utils.get_lang(self.lang).comment local start_tag = comment .. " " .. utils.section_tag(name, true) local end_tag = comment .. " " .. utils.section_tag(name, false) local str = type(lines) ~= "string" and table.concat(lines, "\n") or lines return table.concat({ start_tag, str, end_tag }, "\n") end ---@param lines string[]|string ---@param name string function Question:editor_section_replace(lines, name) local range = self:editor_section_range(name) if range:is_valid_or_log() then self:editor_set_lines(range.start_i - 1, range.end_i, lines) end end ---@param code string function Question:injector(code) local inject = config.user.injector[self.lang] or {} local parts = { self:editor_section(code, "code") } local before = self:inject(true) if before then table.insert(parts, 1, before) end local imports = self:inject_imports() if imports then table.insert(parts, 1, self:editor_section(table.concat(imports, "\n"), "imports")) end local after = self:inject(false) if after then table.insert(parts, after) end local gap = (inject.gap or 1) + 1 return table.concat(parts, ("\n"):rep(gap)) end function Question:_unmount() if vim.v.dying ~= 0 then return end vim.schedule(function() self.info:unmount() self.console:unmount() self.description:unmount() if self.bufnr and vim.api.nvim_buf_is_valid(self.bufnr) then vim.api.nvim_buf_delete(self.bufnr, { force = true, unload = false }) end _Lc_state.questions = vim.tbl_filter(function(q) return q.bufnr ~= self.bufnr end, _Lc_state.questions) self = nil end) end function Question:unmount() if self.winid and vim.api.nvim_win_is_valid(self.winid) then vim.api.nvim_win_close(self.winid, true) end end local group = vim.api.nvim_create_augroup("leetcode_questions", { clear = true }) function Question:autocmds() vim.api.nvim_create_autocmd("WinClosed", { group = group, pattern = tostring(self.winid), callback = function() self:_unmount() end, }) end function Question:handle_mount() self:create_buffer() self.description = Description(self):mount() self.console = Console(self) self.info = Info(self) table.insert(_Lc_state.questions, self) self:autocmds() utils.exec_hooks("question_enter", self) return self end function Question:mount() local tabp = utils.detect_duplicate_question(self.cache.title_slug, config.lang) if tabp then return pcall(vim.api.nvim_set_current_tabpage, tabp) end local q = api_question.by_title_slug(self.cache.title_slug) if not q or q.is_paid_only and not config.auth.is_premium then return log.warn("Question is for premium users only") end self.q = q if self:snippet() then self:handle_mount() else local msg = ("Snippet for `%s` not found. Select a different language"):format(self.lang) log.warn(msg) local picker = require("leetcode.picker") picker.language(self, function(slug) self.lang = slug self:handle_mount() end) end return self end ---@class lc.Question.Editor.Range ---@field start_i? number ---@field end_i? number ---@field lines string[] ---@field sliced string[] ---@field complete boolean ---@param name string ---@param inclusive? boolean function Question:editor_section_range(name, inclusive) local lines = vim.api.nvim_buf_get_lines(self.bufnr, 0, -1, false) local start_i, end_i local start_tag = utils.section_tag(name, true) local end_tag = utils.section_tag(name, false) for i, line in ipairs(lines) do if line:match(start_tag) then start_i = i + (inclusive and 0 or 1) elseif line:match(end_tag) then end_i = i - (inclusive and 0 or 1) end end local res = { start_i = start_i, end_i = end_i, lines = lines, complete = start_i and end_i, } res.log_not_found = function() if res.complete then return end local missing = {} if not res.start_i then table.insert(missing, ("`%s`"):format(start_tag)) end if not res.end_i then table.insert(missing, ("`%s`"):format(end_tag)) end log.warn(table.concat(missing, " and ") .. " not found.") end res.is_partial = function() return res.complete or (res.start_i or res.end_i) end res.is_valid_or_log = function() if res.complete then return true else res.log_not_found() return false end end return res end ---@param submit boolean ---@return string function Question:editor_submit_lines(submit) local range = self:editor_section_range("code") assert(range.complete, "Code section not found") local prefix = not submit and ("\n"):rep(range.start_i - 1) or "" return prefix .. table.concat(range.lines, "\n", range.start_i, range.end_i) end ---@param self lc.ui.Question ---@param lang lc.lang Question.change_lang = vim.schedule_wrap(function(self, lang) local old_lang, old_bufnr = self.lang, self.bufnr local ok, err = pcall(function() self.lang = lang local path, existed = self:path() self.bufnr = vim.fn.bufadd(path) assert(self.bufnr ~= 0, "Failed to create buffer " .. path) local loaded = vim.api.nvim_buf_is_loaded(self.bufnr) vim.fn.bufload(self.bufnr) vim.api.nvim_set_option_value("buflisted", false, { buf = old_bufnr }) self:open_buffer(existed) if not loaded then utils.exec_hooks("question_enter", self) end end) if not ok then log.error("Failed to change language\n" .. err) self.lang = old_lang self.bufnr = old_bufnr end end) ---@param problem lc.cache.Question function Question:init(problem) self.cache = problem self.lang = config.lang end ---@type fun(question: lc.cache.Question): lc.ui.Question local LeetQuestion = Question ---@diagnostic disable-line: assign-type-mismatch return LeetQuestion