irongit

A Neovim plugin enabling you to solve LeetCode problems.

refactor!: editor sections

Editor can now be split into multiple sections wrapped in `@leet` tags. Imports are now by default wrapped in `@leet imports` tags and folded when opening a problem. To disable this behavior, disable `editor.fold_imports`.
kawreauthored
parent 7bbeeafcommit 133e5e4d775ae6b02a17f11b70da9eb9fc188d93Browse files

8 files changed, +261 -123

+29-26README.md
@@ -254,26 +254,26 @@ logging = true
254254
255255 Inject code before or after your solution, injected code won't be submitted or run.
256256
257-#### default imports
258-
259-You can also pass `before = true` to inject default imports for the language.
260-Supported languages are `python`, `python3`, `java`
261-
262-Access default imports via `require("leetcode.config.imports")`
257+Imports will be injected at the top of the buffer, automatically
258+folded by default.
263259
264260 ```lua
265261 injector = { ---@type table<lc.lang, lc.inject>
266262 ["python3"] = {
267- before = true
263+ imports = function(default_imports)
264+ vim.list_extend(default_imports, { "from .leetcode import *" })
265+ return default_imports
266+ end,
267+ after = { "def test():", " print('test')" },
268268 },
269269 ["cpp"] = {
270- before = { "#include <bits/stdc++.h>", "using namespace std;" },
270+ imports = function()
271+ -- return a different list to omit default imports
272+ return { "#include <bits/stdc++.h>", "using namespace std;" }
273+ end,
271274 after = "int main() {}",
272275 },
273- ["java"] = {
274- before = "import java.util.*;",
275- },
276-}
276+},
277277 ```
278278
279279 ### picker
@@ -354,7 +354,7 @@ image_support = false,
354354
355355 - `tabs` opens a picker with all currently opened question tabs
356356
357-- `yank` yanks the current question solution
357+- `yank` yanks the code section
358358
359359 - `lang` opens a picker to change the language of the current question
360360
@@ -366,25 +366,27 @@ image_support = false,
366366
367367 - `random` opens a random question
368368
369-- `daily` opens the question of today
369+- `daily` opens the question of today problem
370370
371-- `list` opens a problem list picker
371+- `list` opens a picker with all available leetcode problems
372372
373373 - `open` opens the current question in a default browser
374374
375-- `reset` reset current question to default code definition
375+- `restore` try to restore default question layout
376376
377-- `last_submit` retrieve last submitted code for the current question
377+- `last_submit` tries to replace the editor code section with the latest submitted code
378378
379-- `restore` try to restore default question layout
379+- `reset` resets editor code section to the default snippet
380380
381-- `inject` re-inject code for the current question
381+- `inject` re-injects editor code, keeping the code section intact
382382
383-- `session`
384- - `create` create a new session
385- - `change` change the current session
383+- `fold` applies folding to the current question imports section
386384
387- - `update` update the current session in case it went out of sync
385+<!-- - `session` -->
386+<!-- - `create` create a new session -->
387+<!-- - `change` change the current session -->
388+<!---->
389+<!-- - `update` update the current session in case it went out of sync -->
388390
389391 - `desc` toggle question description
390392 - `toggle` same as `Leet desc`
@@ -394,10 +396,10 @@ image_support = false,
394396 - `cookie`
395397 - `update` opens a prompt to enter a new cookie
396398
397- - `delete` sign-out
399+ - `delete` deletes stored cookie and logs out of [leetcode.nvim]
398400
399401 - `cache`
400- - `update` updates cache
402+ - `update` fetches all available problems and updates the local cache of [leetcode.nvim]
401403
402404 #### Some commands can take optional arguments. To stack argument values separate them by a `,`
403405
@@ -424,9 +426,10 @@ This plugin can be initiated in two ways:
424426 nvim leetcode.nvim
425427 ```
426428
427-- _**(Experimental)**_ Alternatively, you can use `:Leet` command to open [leetcode.nvim]
429+- Use `:Leet` command to open [leetcode.nvim]
428430 within your preferred dashboard plugin. The only requirement is that [Neovim]
429431 must not have any listed buffers open.
432+ To bypass this requirement use [`non_standalone`](#non-standalone-mode) plugin.
430433
431434 ### Switching between questions
432435
+4-0lua/leetcode-ui/layout/console.lua
@@ -60,6 +60,10 @@ function ConsoleLayout:mount()
6060 end
6161
6262 function ConsoleLayout:run(submit)
63+ if not self.question:editor_section_is_present("code") then
64+ return
65+ end
66+
6367 if config.user.console.open_on_runcode then
6468 self:show()
6569 end
+175-60lua/leetcode-ui/question.lua
@@ -9,6 +9,8 @@ local ui_utils = require("leetcode-ui.utils")
99 local config = require("leetcode.config")
1010 local log = require("leetcode.logger")
1111
12+---@alias lc.editor.section "imports" | "code"
13+
1214 ---@class lc.ui.Question
1315 ---@field file Path
1416 ---@field q lc.question_res
@@ -34,28 +36,33 @@ function Question:snippet(raw)
3436 return raw and code or self:injector(code)
3537 end
3638
37----@param code? string
38-function Question:set_lines(code)
39+---@param start_i integer
40+---@param end_i integer
41+---@param lines string[]|string
42+function Question:editor_set_lines(start_i, end_i, lines)
3943 if not (self.bufnr and vim.api.nvim_buf_is_valid(self.bufnr)) then
4044 return
4145 end
4246
43- pcall(vim.cmd.undojoin)
44- local s_i, e_i, lines = self:range()
45- s_i = s_i or 1
46- e_i = e_i or #lines
47- code = code and code or (self:snippet(true) or "")
48- vim.api.nvim_buf_set_lines(self.bufnr, s_i - 1, e_i, false, vim.split(code, "\n"))
47+ lines = type(lines) == "string" and vim.split(lines, "\n") or lines ---@cast lines string[]
48+ vim.api.nvim_buf_set_lines(self.bufnr, start_i, end_i, false, lines)
49+end
50+
51+function Question:editor_reset()
52+ local new_lines = self:snippet() or ""
53+ self:editor_set_lines(0, -1, new_lines)
4954 end
5055
51-function Question:reset_lines()
56+function Question:editor_reset_code()
5257 local new_lines = self:snippet(true) or ""
58+ self:editor_section_replace(new_lines, "code")
59+end
5360
54- vim.schedule(function() --
55- log.info("Previous code found and reset\nTo undo, simply press `u`")
61+function Question:reset_previous_code()
62+ self:editor_reset_code()
63+ vim.schedule(function()
64+ log.info("Previous code found and reset. To undo, simply press `u` or use `:undo`.")
5665 end)
57-
58- self:set_lines(new_lines)
5966 end
6067
6168 ---@return string path, boolean existed
@@ -94,32 +101,108 @@ function Question:create_buffer()
94101 self:open_buffer(existed)
95102 end
96103
104+---@param strict? boolean
105+function Question:editor_fold_imports(strict)
106+ if not (self.bufnr and vim.api.nvim_buf_is_valid(self.bufnr)) then
107+ return
108+ end
109+
110+ local range = self:editor_section_range("imports", { inclusive = true, strict = strict })
111+ if range and range.end_i then
112+ vim.api.nvim_buf_call(self.bufnr, function()
113+ ---@diagnostic disable-next-line: param-type-mismatch
114+ pcall(vim.cmd, ("%d,%dfold"):format(range.start_i or 1, range.end_i))
115+ end)
116+ end
117+end
118+
119+function Question:editor_yank_code()
120+ if not (self.bufnr and vim.api.nvim_buf_is_valid(self.bufnr)) then
121+ return
122+ end
123+
124+ local range = self:editor_section_range("code", { strict = true })
125+ if range then
126+ vim.api.nvim_buf_call(self.bufnr, function()
127+ vim.cmd(("%d,%dyank"):format(range.start_i, range.end_i))
128+ end)
129+ end
130+end
131+
97132 ---@param existed boolean
98133 function Question:open_buffer(existed)
99134 ui_utils.buf_set_opts(self.bufnr, { buflisted = true })
100135 ui_utils.win_set_buf(self.winid, self.bufnr, true)
101136
102- local i = self:fold_range()
103- if i then
104- pcall(vim.cmd, ("%d,%dfold"):format(1, i))
137+ vim.cmd([[match DiagnosticHint /@leet/]])
138+
139+ if config.user.editor.fold_imports then
140+ self:editor_fold_imports(false)
105141 end
106142
107- if existed and self.cache.status == "ac" then
108- self:reset_lines()
143+ if config.user.editor.reset_previous_code and (existed and self.cache.status == "ac") then
144+ self:reset_previous_code()
109145 end
110146 end
111147
112----@param before boolean
113-function Question:inject(before)
148+---@return string[]?
149+function Question:inject_imports()
114150 local inject = config.user.injector[self.lang] or {}
115- local inj = before and inject.before or inject.after
116151
117- local res
152+ local imports = inject.imports
153+ local default_imports = config.imports[self.lang]
154+
155+ local function valid_imports(tbl)
156+ if vim.tbl_isempty(tbl or {}) then
157+ return false
158+ end
159+ if not vim.islist(tbl) then
160+ log.error("Invalid imports format for language: " .. self.lang)
161+ return false
162+ end
163+ return true
164+ end
165+
166+ if not imports then
167+ return default_imports
168+ end
169+
170+ if type(imports) == "function" then
171+ local overriden = imports(vim.deepcopy(default_imports or {}))
172+ if not valid_imports(overriden) then
173+ return default_imports
174+ end
175+ return overriden
176+ end
118177
119- if type(inj) == "boolean" and inj == true and before then
120- inj = config.imports[self.lang]
178+ local resolved = type(imports) == "string" and { imports } or imports
179+ if not valid_imports(resolved) then
180+ return
121181 end
122182
183+ local merged, seen = {}, {}
184+ local function add(list)
185+ for _, v in ipairs(list) do
186+ if not seen[v] then
187+ table.insert(merged, v)
188+ seen[v] = true
189+ end
190+ end
191+ end
192+
193+ add(default_imports)
194+ add(resolved)
195+
196+ return not vim.tbl_isempty(merged) and merged or nil
197+end
198+
199+---@param before boolean
200+---@return string?
201+function Question:inject(before)
202+ local inject = config.user.injector[self.lang] or {}
203+ local inj = before and (inject.before or {}) or (inject.after or {})
204+
205+ local res
123206 if type(inj) == "table" then
124207 res = table.concat(inj, "\n")
125208 elseif type(inj) == "string" then
@@ -127,33 +210,63 @@ function Question:inject(before)
127210 end
128211
129212 if res and res ~= "" then
130- return before and (res .. "\n") or ("\n" .. res)
213+ return res
131214 else
132215 return nil
133216 end
134217 end
135218
219+function Question:editor_section_is_present(name)
220+ local range = self:editor_section_range(name)
221+ return range and true or false
222+end
223+
224+---@param lines string|string[]
225+---@param name lc.editor.section
226+---@return string
227+function Question:editor_section(lines, name)
228+ local comment = utils.get_lang(self.lang).comment
229+
230+ local start_tag = comment .. " " .. utils.section_tag(name, true)
231+ local end_tag = comment .. " " .. utils.section_tag(name, false)
232+
233+ local str = type(lines) ~= "string" and table.concat(lines, "\n") or lines
234+ return table.concat({ start_tag, str, end_tag }, "\n")
235+end
236+
237+---@param lines string[]|string
238+---@param name string
239+function Question:editor_section_replace(lines, name)
240+ local range = self:editor_section_range(name)
241+
242+ if range then
243+ self:editor_set_lines(range.start_i - 1, range.end_i, lines)
244+ end
245+end
246+
136247 ---@param code string
137248 function Question:injector(code)
138- local lang = utils.get_lang(self.lang)
249+ local inject = config.user.injector[self.lang] or {}
139250
140- local parts = {
141- ("%s @leet start"):format(lang.comment),
142- code,
143- ("%s @leet end"):format(lang.comment),
144- }
251+ local parts = { self:editor_section(code, "code") }
145252
146253 local before = self:inject(true)
147254 if before then
148255 table.insert(parts, 1, before)
149256 end
150257
258+ local imports = self:inject_imports()
259+ if imports then
260+ table.insert(parts, 1, self:editor_section(table.concat(imports, "\n"), "imports"))
261+ end
262+
151263 local after = self:inject(false)
152264 if after then
153265 table.insert(parts, after)
154266 end
155267
156- return table.concat(parts, "\n")
268+ local gap = (inject.gap or 1) + 1
269+ return table.concat(parts, ("\n"):rep(gap))
157270 end
158271
159272 function Question:_unmount()
@@ -238,49 +351,51 @@ function Question:mount()
238351 return self
239352 end
240353
241----@param inclusive? boolean
242----@return integer, integer, string[]
243-function Question:range(inclusive)
354+---@class lc.Question.Editor.Range
355+---@field start_i? number
356+---@field end_i? number
357+---@field lines string[]
358+---@field sliced string[]
359+---@field complete boolean
360+
361+---@param name string
362+---@param opts? { inclusive?: boolean, strict?: boolean }
363+---@return lc.Question.Editor.Range?
364+function Question:editor_section_range(name, opts)
365+ opts = vim.tbl_extend("keep", opts or {}, { strict = true, inclusive = false })
244366 local lines = vim.api.nvim_buf_get_lines(self.bufnr, 0, -1, false)
245367 local start_i, end_i
246368
369+ local start_tag = utils.section_tag(name, true)
370+ local end_tag = utils.section_tag(name, false)
371+
247372 for i, line in ipairs(lines) do
248- if line:match("@leet start") then
249- start_i = i + (inclusive and 0 or 1)
250- elseif line:match("@leet end") then
251- end_i = i - (inclusive and 0 or 1)
373+ if line:match(start_tag) then
374+ start_i = i + (opts.inclusive and 0 or 1)
375+ elseif line:match(end_tag) then
376+ end_i = i - (opts.inclusive and 0 or 1)
252377 end
253378 end
254379
255- return start_i, end_i, lines
256-end
257-
258-function Question:fold_range()
259- local start_i, _, lines = self:range(true)
260- if start_i == nil or start_i <= 1 then
261- return
262- end
263-
264- local i = start_i - 1
265- while lines[i] == "" do
266- i = i - 1
380+ if opts.strict and not (start_i and end_i) then
381+ log.error(("Section `@leet %s` not found in editor."):format(name))
382+ return nil
267383 end
268384
269- if 1 < i then
270- return i
271- end
385+ return { start_i = start_i, end_i = end_i, lines = lines, complete = start_i and end_i }
272386 end
273387
274388 ---@param submit boolean
275389 ---@return string
276-function Question:lines(submit)
277- local start_i, end_i, lines = self:range()
390+function Question:editor_submit_lines(submit)
391+ local range = self:editor_section_range("code")
392+ assert(range, "Code section not found in editor")
278393
279- start_i = start_i or 1
280- end_i = end_i or #lines
394+ local start_i = range.start_i or 1
395+ local end_i = range.end_i or #range.lines
281396
282397 local prefix = not submit and ("\n"):rep(start_i - 1) or ""
283- return prefix .. table.concat(lines, "\n", start_i, end_i)
398+ return prefix .. table.concat(range.lines, "\n", start_i, end_i)
284399 end
285400
286401 ---@param self lc.ui.Question
@@ -320,6 +435,6 @@ function Question:init(problem)
320435 end
321436
322437 ---@type fun(question: lc.cache.Question): lc.ui.Question
323-local LeetQuestion = Question
438+local LeetQuestion = Question ---@diagnostic disable-line: assign-type-mismatch
324439
325440 return LeetQuestion
+35-35lua/leetcode/command/init.lua
@@ -40,7 +40,7 @@ function cmd.cookie_prompt(cb)
4040 row = "50%",
4141 col = "50%",
4242 },
43- size = 100,
43+ size = 40,
4444 border = {
4545 style = "rounded",
4646 text = {
@@ -195,18 +195,7 @@ function cmd.yank()
195195 return
196196 end
197197
198- if
199- (q.bufnr and api.nvim_buf_is_valid(q.bufnr))
200- and (q.winid and api.nvim_win_is_valid(q.winid))
201- then
202- api.nvim_set_current_win(q.winid)
203- utils.with_version("0.10.0", nil, function()
204- api.nvim_set_current_buf(q.bufnr)
205- end)
206-
207- local start_i, end_i, lines = q:range()
208- vim.cmd(("%d,%dyank"):format(start_i or 1, end_i or #lines))
209- end
198+ q:editor_yank_code()
210199 end
211200
212201 ---@param page lc-menu.page
@@ -335,7 +324,7 @@ function cmd.reset()
335324 return
336325 end
337326
338- q:set_lines()
327+ q:editor_reset_code()
339328 end
340329
341330 function cmd.last_submit()
@@ -359,7 +348,9 @@ function cmd.last_submit()
359348 end
360349
361350 if type(res) == "table" and res.code then
362- q:set_lines(res.code)
351+ ---@type string
352+ local lines = res.code
353+ q:editor_section_replace(lines, "code")
363354 else
364355 log.error("Something went wrong")
365356 end
@@ -400,32 +391,40 @@ function cmd.inject()
400391 end
401392
402393 if q.bufnr and api.nvim_buf_is_valid(q.bufnr) then
403- local start_i, end_i = q:range(true)
404- local not_found = {}
394+ local range = q:editor_section_range("code")
395+ assert(range, "Code section should be present")
405396
406- if not start_i then
407- table.insert(not_found, "`@leet start`")
408- else
409- local before = q:inject(true)
410- if before then
411- api.nvim_buf_set_lines(q.bufnr, 0, start_i - 1, false, vim.split(before, "\n"))
412- _, end_i = q:range(true)
413- end
414- end
397+ if not range.start_i or not range.end_i then
398+ local missing = {}
415399
416- if not end_i then
417- table.insert(not_found, "`@leet end`")
418- else
419- local after = q:inject(false)
420- if after then
421- api.nvim_buf_set_lines(q.bufnr, end_i, -1, false, vim.split(after, "\n"))
400+ if not range.start_i then
401+ local start_tag = utils.section_tag("code", true)
402+ table.insert(missing, ("`%s`"):format(start_tag))
403+ end
404+ if not range.end_i then
405+ local end_tag = utils.section_tag("code", false)
406+ table.insert(missing, ("`%s`"):format(end_tag))
422407 end
423- end
424408
425- if not vim.tbl_isempty(not_found) then
426- log.error(table.concat(not_found, " and ") .. " not found")
409+ log.error(table.concat(missing, " and ") .. " not found.")
410+ return
427411 end
412+
413+ local lines = table.concat(range.lines, "\n", range.start_i, range.end_i)
414+
415+ q:editor_reset()
416+ q:editor_section_replace(lines, "code")
417+ end
418+end
419+
420+function cmd.fold()
421+ local utils = require("leetcode.utils")
422+ local q = utils.curr_question()
423+ if not q then
424+ return
428425 end
426+
427+ q:editor_fold_imports(false)
429428 end
430429
431430 function cmd.get_active_session()
@@ -632,6 +631,7 @@ cmd.commands = {
632631 last_submit = { cmd.last_submit },
633632 restore = { cmd.restore },
634633 inject = { cmd.inject },
634+ fold = { cmd.fold },
635635 -- session = {
636636 -- change = {
637637 -- cmd.change_session,
+2-0lua/leetcode/config/imports.lua
@@ -1,6 +1,8 @@
11 ---@class lc.Imports
22 local imports = {}
33
4+-- Pull requests are welcome to add more imports for other languages.
5+
46 imports["python3"] = {
57 "from string import *",
68 "from re import *",
+6-1lua/leetcode/config/template.lua
@@ -34,7 +34,7 @@
3434
3535 ---@alias lc.direction "col" | "row"
3636
37----@alias lc.inject { before?: string|string[], after?: string|string[] }
37+---@alias lc.inject { gap?: number, imports?: (fun(default_imports: string[]): string[])|string|string[], before?: string|string[], after?: string|string[] }
3838
3939 ---@alias lc.storage table<"cache"|"home", string>
4040
@@ -74,6 +74,11 @@ local M = {
7474 update_interval = 60 * 60 * 24 * 7, ---@type integer 7 days
7575 },
7676
77+ editor = {
78+ reset_previous_code = true, ---@type boolean
79+ fold_imports = true, ---@type boolean
80+ },
81+
7782 console = {
7883 open_on_runcode = true, ---@type boolean
7984
+1-1lua/leetcode/runner/init.lua
@@ -38,7 +38,7 @@ function Runner:handle(submit)
3838
3939 local body = {
4040 lang = question.lang,
41- typed_code = self.question:lines(submit),
41+ typed_code = self.question:editor_submit_lines(submit),
4242 question_id = question.q.id,
4343 data_input = not submit and question.console.testcase:content(),
4444 }
+9-0lua/leetcode/utils.lua
@@ -144,4 +144,13 @@ function utils.with_version(v, with, without)
144144 return (vim.fn.has("nvim-" .. v) == 1 and with or without)()
145145 end
146146
147+---@param name lc.editor.section
148+---@param start? boolean
149+function utils.section_tag(name, start)
150+ if name == "code" then
151+ return ("@leet %s"):format(start and "start" or "end")
152+ end
153+ return ("@leet %s %s"):format(name, start and "start" or "end")
154+end
155+
147156 return utils