irongit

A Neovim plugin enabling you to solve LeetCode problems.

init

kawreauthored
commit 73621765a6cc4de4e3b17d82fb2298cdba55858dBrowse files

21 files changed, +768 -0

+20-0.luarc.json
@@ -0,0 +1,20 @@
1+{
2+ "$schema": "https://raw.githubusercontent.com/sumneko/vscode-lua/master/setting/schema.json",
3+ "diagnostics": {
4+ "globals": [
5+ "vim"
6+ ]
7+ },
8+ "format": {
9+ "enable": false
10+ },
11+ "runtime": {
12+ "version": "LuaJIT"
13+ },
14+ "workspace.library": [
15+ "/home/kawre/.local/share/nvim/lazy/neodev.nvim/types/stable",
16+ "/usr/share/nvim/runtime/lua",
17+ "/home/kawre/projects/leetcode.nvim/lua",
18+ "${3rd}/luv/library"
19+ ]
20+}
+7-0.stylua.toml
@@ -0,0 +1,7 @@
1+column_width = 100
2+line_endings = "Unix"
3+indent_type = "Spaces"
4+indent_width = 2
5+quote_style = "ForceDouble"
6+call_parentheses = "Always"
7+collapse_simple_statement = "Always"
+19-0lua/leetcode.lua
@@ -0,0 +1,19 @@
1+local config = require("leetcode.config")
2+local api = require("leetcode.api")
3+
4+local M = {}
5+
6+---@param cfg? lc.Config
7+function M.setup(cfg)
8+ config.apply(cfg or {})
9+
10+ vim.api.nvim_create_autocmd("VimEnter", {
11+ pattern = "*",
12+ nested = true,
13+ callback = function()
14+ if config.user.invoke_name == vim.fn.expand("%") then api.cmd.leetcode() end
15+ end,
16+ })
17+end
18+
19+return M
+58-0lua/leetcode/api/command/init.lua
@@ -0,0 +1,58 @@
1+local cache = require("leetcode.cache")
2+local logger = require("leetcode.logger")
3+local config = require("leetcode.config")
4+
5+local ui = require("leetcode.ui")
6+local dashboard = require("leetcode.ui.dashboard")
7+local gql = require("leetcode.graphql")
8+
9+local path = require("plenary.path")
10+
11+---@class lc.Commands
12+local M = {}
13+
14+function M.lc_problems()
15+ local _, res = assert(pcall(cache.problems.read))
16+
17+ ui.pick_one(
18+ res,
19+ "Select a Problem",
20+ cache.problems.formatter,
21+
22+ ---@param problem lc.Problem
23+ function(problem)
24+ if not problem then return end
25+
26+ local dir = config.user.directory .. "/solutions/"
27+ local fn = problem.index .. "." .. problem.title_slug .. ".java"
28+ local file = path:new(dir .. fn)
29+
30+ if not file:exists() then
31+ local editor_data = gql.question.editor_data(problem.title_slug)
32+ local code
33+
34+ if editor_data.code_snippets ~= vim.NIL then
35+ for _, v in ipairs(editor_data.code_snippets) do
36+ if v.langSlug == config.user.lang or v.langSlug == config.user.sql then
37+ code = v.code
38+ break
39+ end
40+ end
41+ end
42+ assert(code, "Failed to fetch code snippet")
43+
44+ file:write(code, "w")
45+ end
46+
47+ vim.cmd("cd " .. dir)
48+ vim.cmd("e " .. file:absolute())
49+ end
50+ )
51+end
52+
53+function M.leetcode()
54+ dashboard.setup()
55+ vim.cmd("Alpha | bd #")
56+end
57+
58+return M
+19-0lua/leetcode/api/headers.lua
@@ -0,0 +1,19 @@
1+local M = {}
2+
3+M.get = function()
4+ local cookie = require("leetcode.cache.cookie").read()
5+
6+ return {
7+ ["Cookie"] = cookie and string.format(
8+ "LEETCODE_SESSION=%s;csrftoken=%s",
9+ cookie.leetcode_session,
10+ cookie.csrftoken
11+ ) or "",
12+ ["Content-Type"] = "application/json",
13+ ["Accept"] = "application/json",
14+ ["x-csrftoken"] = cookie and cookie.csrftoken or nil,
15+ ["Referer"] = "https://leetcode.com",
16+ }
17+end
18+
19+return M
+6-0lua/leetcode/api/init.lua
@@ -0,0 +1,6 @@
1+---@class lc.Api
2+local Api = {
3+ cmd = require("leetcode.api.command"),
4+}
5+
6+return Api
+52-0lua/leetcode/cache/cookie.lua
@@ -0,0 +1,52 @@
1+local path = require("plenary.path")
2+
3+local file = path:new(vim.fn.stdpath("data") .. "/leetcode/.cookie")
4+
5+---@class lc.Cookie
6+---@field csrftoken string
7+---@field leetcode_session string
8+
9+local M = {}
10+
11+---@param cookie_str string
12+---
13+---@return lc.Cookie
14+function M.new(cookie_str)
15+ local _, t = assert(pcall(M.parse, cookie_str))
16+
17+ if not file:exists() then file:touch() end
18+ file:write(cookie_str, "w")
19+
20+ return t
21+end
22+
23+---@return lc.Cookie | nil
24+function M.read()
25+ local r_ok, contents = pcall(path.read, file)
26+ if not r_ok then return end
27+
28+ local n_ok, cookie = pcall(M.new, contents)
29+ if not n_ok then return end
30+
31+ return cookie
32+end
33+
34+function M.delete() file:rm() end
35+
36+---@param cookie_str string
37+---
38+function M.parse(cookie_str)
39+ local cookie = {}
40+
41+ local csrf_ok, csrf = pcall(string.match, cookie_str, "csrftoken=[^;]+")
42+ assert(csrf_ok and csrf, "Bad csrf token format")
43+ cookie.csrftoken = csrf:sub(11)
44+
45+ local ls_ok, ls = pcall(string.match, cookie_str, "LEETCODE_SESSION=[^;]+")
46+ assert(ls_ok and ls, "Bad leetcode session token format")
47+ cookie.leetcode_session = ls:sub(18)
48+
49+ return cookie
50+end
51+
52+return M
+8-0lua/leetcode/cache/init.lua
@@ -0,0 +1,8 @@
1+---@class lc.Cache
2+local Cache = {
3+ cookie = require("leetcode.cache.cookie"),
4+ problems = require("leetcode.cache.problems"),
5+ -- solutions = require("leetcode.cache.solutions"),
6+}
7+
8+return Cache
+77-0lua/leetcode/cache/problems.lua
@@ -0,0 +1,77 @@
1+local path = require("plenary.path")
2+local async = require("plenary.async")
3+
4+local config = require("leetcode.config")
5+local gql = require("leetcode.graphql")
6+
7+local file = path:new(config.user.directory .. "/.problems")
8+
9+---@class lc.Problem
10+---@field index string
11+---@field title string
12+---@field title_slug string
13+---@field ac_rate number
14+---@field difficulty "Easy" | "Medium" | "Hard"
15+
16+local M = {}
17+
18+local function populate()
19+ file:touch()
20+ local problems = assert(gql.problems.all())
21+ file:write(vim.json.encode(problems), "w")
22+end
23+
24+---@return lc.Problem[] | nil
25+function M.read()
26+ async.run(M.update)
27+
28+ local contents = file:read()
29+
30+ local ok, problems = pcall(M.parse, contents)
31+ if not ok then return end
32+
33+ return problems
34+end
35+
36+function M.update()
37+ local stats = file:_stat()
38+
39+ if vim.tbl_isempty(stats) then
40+ assert(populate())
41+ return
42+ end
43+
44+ local mod_time = stats.mtime.sec
45+ local curr_time = os.time()
46+
47+ if (curr_time - mod_time) > 60 * 60 * 24 * 7 then
48+ -- logger.info("Updating cache. This way take a while...")
49+ assert(populate())
50+ end
51+end
52+
53+---@param problem lc.Problem
54+---
55+---@return string
56+M.formatter = function(problem)
57+ return string.format(
58+ "%d. %s (%d%%, %s)",
59+ problem.index,
60+ problem.title,
61+ problem.ac_rate,
62+ problem.difficulty
63+ )
64+end
65+
66+---@param problems_str string
67+---
68+---@return lc.Problem[]
69+function M.parse(problems_str)
70+ ---@type boolean, lc.Problem[]
71+ local ok, problems = pcall(vim.json.decode, problems_str)
72+ assert(ok)
73+
74+ return problems
75+end
76+
77+return M
+0-0lua/leetcode/cache/solutions.lua

No content changes (mode or rename only).

+24-0lua/leetcode/config/init.lua
@@ -0,0 +1,24 @@
1+local template = require("leetcode.config.template")
2+
3+---@class lc.Settings
4+local Config = {
5+ name = "leetcode.nvim",
6+ debug = true,
7+}
8+
9+---Default configurations.
10+---
11+---@type lc.Config
12+Config.default = template
13+
14+---User configurations.
15+---
16+---@type lc.Config
17+Config.user = Config.default
18+
19+---Merge configurations into default configurations and set it as user configurations.
20+---
21+---@param cfg lc.Config Configurations to be merged.
22+function Config.apply(cfg) Config.user = vim.tbl_deep_extend("force", template, cfg) end
23+
24+return Config
+22-0lua/leetcode/config/template.lua
@@ -0,0 +1,22 @@
1+---@class lc.Config
2+local M = {
3+ ---@type string
4+ domain = "com",
5+
6+ ---@type string
7+ invoke_name = "leetcode.nvim",
8+
9+ ---@type string
10+ lang = "java",
11+
12+ ---@type
13+ ---| "pythondata"
14+ ---| "mysql"
15+ ---| "mssql"
16+ ---| "oraclesql"
17+ sql = "mysql",
18+
19+ directory = vim.fn.stdpath("data") .. "/leetcode",
20+}
21+
22+return M
+30-0lua/leetcode/graphql/auth.lua
@@ -0,0 +1,30 @@
1+local gql = require("leetcode.graphql.utils")
2+
3+---@class lc.AuthApi
4+local M = {}
5+
6+---@return table | nil
7+function M.user_status()
8+ local query = [[
9+ query globalData {
10+ userStatus {
11+ userId
12+ isSignedIn
13+ isMockUser
14+ isPremium
15+ isVerified
16+ username
17+ isTranslator
18+ activeSessionId
19+ checkedInToday
20+ }
21+ }
22+ ]]
23+
24+ local ok, res = pcall(gql.query, query)
25+ if not ok then return end
26+
27+ return res["userStatus"]
28+end
29+
30+return M
+8-0lua/leetcode/graphql/init.lua
@@ -0,0 +1,8 @@
1+---@class lc.Graphql
2+local Gql = {
3+ problems = require("leetcode.graphql.problems"),
4+ question = require("leetcode.graphql.question"),
5+ auth = require("leetcode.graphql.auth"),
6+}
7+
8+return Gql
+84-0lua/leetcode/graphql/problems.lua
@@ -0,0 +1,84 @@
1+local logger = require("leetcode.logger")
2+local gql = require("leetcode.graphql.utils")
3+
4+---@class lc.ProblemsApi
5+local M = {}
6+
7+---@return integer
8+function M.total_num()
9+ local query = [[
10+ query problemsetQuestionList {
11+ problemsetQuestionList: questionList(
12+ categorySlug: ""
13+ filters: {}
14+ ) {
15+ total: totalNum
16+ }
17+ }
18+ ]]
19+
20+ local total = gql.query(query)["problemsetQuestionList"]["total"]
21+
22+ return total
23+end
24+
25+---@return lc.Problem[]
26+function M.all()
27+ local variables = {
28+ limit = 3000,
29+ }
30+
31+ local query = [[
32+ query problemsetQuestionList($limit: Int) {
33+ problemsetQuestionList: questionList(
34+ categorySlug: ""
35+ limit: $limit
36+ filters: {}
37+ ) {
38+ questions: data {
39+ index: questionFrontendId
40+ title
41+ title_slug: titleSlug
42+ ac_rate: acRate
43+ difficulty
44+ }
45+ }
46+ }
47+ ]]
48+
49+ local ok, res = pcall(gql.query, query, variables)
50+ assert(ok)
51+
52+ return res["problemsetQuestionList"]["questions"]
53+end
54+
55+function M.question_of_today()
56+ local query = [[
57+ query questionOfToday {
58+ activeDailyCodingChallengeQuestion {
59+ userStatus
60+ link
61+ question {
62+ acRate
63+ difficulty
64+ freqBar
65+ index: questionFrontendId
66+ isFavor
67+ paidOnly: isPaidOnly
68+ status
69+ title
70+ titleSlug
71+ }
72+ }
73+ }
74+ ]]
75+
76+ local ok, res = pcall(gql.query, query)
77+ assert(ok)
78+
79+ logger.inspect(res)
80+
81+ return res["activeDailyCodingChallengeQuestion"]
82+end
83+
84+return M
+60-0lua/leetcode/graphql/question.lua
@@ -0,0 +1,60 @@
1+local gql = require("leetcode.graphql.utils")
2+local logger = require("leetcode.logger")
3+
4+local M = {}
5+
6+---@param title_slug string
7+function M.get_by_title_slug(title_slug)
8+ local variables = {
9+ titleSlug = title_slug,
10+ }
11+
12+ local query = [[
13+ query questionContent($titleSlug: String!) {
14+ question(titleSlug: $titleSlug) {
15+ content
16+ mysqlSchemas
17+ dataSchemas
18+ }
19+ }
20+ ]]
21+
22+ local ok, res = pcall(gql.query, query, variables)
23+ assert(ok)
24+
25+ logger.inspect(res)
26+
27+ return res["questionContent"]
28+end
29+
30+---@param title_slug string
31+function M.editor_data(title_slug)
32+ local variables = {
33+ titleSlug = title_slug,
34+ }
35+
36+ local query = [[
37+ query questionEditorData($titleSlug: String!) {
38+ question(titleSlug: $titleSlug) {
39+ questionId
40+ questionFrontendId
41+ code_snippets: codeSnippets {
42+ lang
43+ langSlug
44+ code
45+ }
46+ envInfo
47+ enableRunCode
48+ hasFrontendPreview
49+ frontendPreviews
50+ }
51+ }
52+ ]]
53+
54+ local ok, res = pcall(gql.query, query, variables)
55+ assert(ok)
56+
57+ return res["question"]
58+end
59+
60+return M
+30-0lua/leetcode/graphql/utils.lua
@@ -0,0 +1,30 @@
1+local headers = require("leetcode.api.headers")
2+local config = require("leetcode.config")
3+
4+local curl = require("plenary.curl")
5+
6+local lc = "https://leetcode." .. config.user.domain
7+local endpoint = lc .. "/graphql"
8+
9+local M = {}
10+
11+---@param query string
12+---@param variables? table optional
13+---
14+---@return table
15+function M.query(query, variables)
16+ local response = curl.post(endpoint, {
17+ headers = headers.get(),
18+ body = vim.json.encode({
19+ query = query,
20+ variables = variables or {},
21+ }),
22+ })
23+
24+ local ok, data = pcall(vim.json.decode, response["body"])
25+ assert(ok and data and data["data"])
26+
27+ return data["data"]
28+end
29+
30+return M
+25-0lua/leetcode/logger/init.lua
@@ -0,0 +1,25 @@
1+local config = require("leetcode.config")
2+
3+---@class lc.Logger
4+local Logger = {}
5+
6+---@param msg string
7+---@param lvl? string
8+Logger.log = function(msg, lvl) require("notify")(config.name .. ": " .. msg, lvl or "info") end
9+
10+---@param msg string
11+Logger.info = function(msg) Logger.log(msg) end
12+
13+---@param msg string
14+Logger.warn = function(msg) Logger.log(msg, "warn") end
15+
16+---@param msg string
17+Logger.debug = function(msg) Logger.log(msg, "debug") end
18+
19+---@param msg table
20+Logger.inspect = function(msg) Logger.log(vim.inspect(msg)) end
21+
22+---@param msg string
23+Logger.error = function(msg) Logger.log(msg, "error") end
24+
25+return Logger
+133-0lua/leetcode/ui/dashboard.lua
@@ -0,0 +1,133 @@
1+local utils = require("leetcode.utils")
2+local gql = require("leetcode.graphql")
3+
4+local M = {}
5+
6+local header = {
7+ type = "text",
8+ val = {
9+ [[ /$$ /$$ /$$$$$$ /$$ ]],
10+ [[| $$ | $$ /$$__ $$ | $$ ]],
11+ [[| $$ /$$$$$$ /$$$$$$ /$$$$$$ | $$ \__/ /$$$$$$ /$$$$$$$ /$$$$$$ ]],
12+ [[| $$ /$$__ $$/$$__ $|_ $$_/ | $$ /$$__ $$/$$__ $$/$$__ $$]],
13+ [[| $$ | $$$$$$$| $$$$$$$$ | $$ | $$ | $$ \ $| $$ | $| $$$$$$$$]],
14+ [[| $$ | $$_____| $$_____/ | $$ /$| $$ $| $$ | $| $$ | $| $$_____/]],
15+ [[| $$$$$$$| $$$$$$| $$$$$$$ | $$$$| $$$$$$| $$$$$$| $$$$$$| $$$$$$$]],
16+ [[|________/\_______/\_______/ \___/ \______/ \______/ \_______/\_______/]],
17+ },
18+ opts = {
19+ position = "center",
20+ hl = "Keyword",
21+ },
22+}
23+
24+local notifications = {
25+ type = "text",
26+ val = {},
27+ opts = {
28+ position = "center",
29+ hl = "DiagnosticInfo",
30+ },
31+}
32+
33+local buttons = {
34+ type = "group",
35+ val = {},
36+ opts = {
37+ spacing = 1,
38+ },
39+}
40+
41+local footer = {
42+ type = "text",
43+ val = {},
44+ opts = {
45+ position = "center",
46+ hl = "Number",
47+ },
48+}
49+
50+function M.setup()
51+ local user = gql.auth.user_status()
52+ local is_signed_in = (user ~= nil and user.isSignedIn)
53+
54+ local alpha = require("alpha")
55+ local dashboard = require("alpha.themes.dashboard")
56+
57+ notifications.val = not is_signed_in and { " Sign in to use LeetCode.nvim" } or {}
58+
59+ buttons.val = is_signed_in
60+ and {
61+ dashboard.button(
62+ "p",
63+ " " .. " All Problems",
64+ "<cmd>lua require('leetcode.api').cmd.lc_problems()<CR>"
65+ ),
66+ dashboard.button(
67+ "t",
68+ "󰃭 " .. " Question of today",
69+ "<cmd>lua require('leetcode.api').gql.problems.question_of_today()<CR>"
70+ ),
71+ dashboard.button("r", " " .. " Recent problems", ""),
72+ dashboard.button(
73+ "c",
74+ "󰆘 " .. " Update cookie",
75+ "<cmd>lua require('leetcode.utils').prompt_for_cookie()<cr>"
76+ ),
77+ dashboard.button(
78+ "s",
79+ " " .. " Sign out",
80+ "<cmd>lua require('leetcode.utils').remove_cookie()<cr>"
81+ ),
82+ dashboard.button("q", "󰩈 " .. " Exit LeetCode", "<cmd>qa<CR>"),
83+ }
84+ or {
85+ dashboard.button(
86+ "s",
87+ " " .. " Sign in (By Cookie)",
88+ "<cmd>lua require('leetcode.utils').prompt_for_cookie()<cr>"
89+ ),
90+ dashboard.button("q", "󰩈 " .. " Exit LeetCode", "<cmd>qa!<CR>"),
91+ }
92+
93+ footer.val = is_signed_in and "Signed in as: " .. user.username or ""
94+
95+ local section = {
96+ header = header,
97+ notifications = notifications,
98+ buttons = buttons,
99+ footer = footer,
100+ }
101+
102+ local config = {
103+ layout = {
104+ -- header
105+ { type = "padding", val = 4 },
106+ section.header,
107+
108+ -- notifications
109+ { type = "padding", val = 2 },
110+ section.notifications,
111+ { type = "padding", val = 2 },
112+
113+ -- buttons
114+ section.buttons,
115+
116+ --footer
117+ section.footer,
118+ },
119+ opts = {
120+ margin = 5,
121+ },
122+ }
123+
124+ alpha.setup(config)
125+end
126+
127+function M.update()
128+ utils.alpha_move_cursor_top()
129+ M.setup()
130+ pcall(vim.cmd, "AlphaRedraw")
131+end
132+
133+return M
+54-0lua/leetcode/ui/init.lua
@@ -0,0 +1,54 @@
1+---@class lc.UI
2+local Ui = {}
3+
4+---@param items table
5+---@param prompt? string
6+---@param fn? fun(table)
7+---@param callback fun(table)
8+function Ui.pick_one(items, prompt, fn, callback)
9+ vim.ui.select(items, {
10+ prompt = prompt or "",
11+ format_item = fn or function(item) return item end,
12+ telescope = require("telescope.themes").get_dropdown(),
13+ }, callback)
14+end
15+
16+function Ui.input(opts, callback) vim.ui.input(opts, callback) end
17+
18+function Ui.oldfiles() Ui.create_win() end
19+
20+function Ui.create_win()
21+ -- We save handle to window from which we open the navigation
22+ start_win = vim.api.nvim_get_current_win()
23+
24+ vim.api.nvim_command("topleft vnew") -- We open a new vertical window at the far right
25+ win = vim.api.nvim_get_current_win() -- We save our navigation window handle...
26+ buf = vim.api.nvim_get_current_buf() -- ...and it's buffer handle.
27+
28+ -- We should name our buffer. All buffers in vim must have unique names.
29+ -- The easiest solution will be adding buffer handle to it
30+ -- because it is already unique and it's just a number.
31+ vim.api.nvim_buf_set_name(buf, "Oldfiles #" .. buf)
32+
33+ -- Now we set some options for our buffer.
34+ -- nofile prevent mark buffer as modified so we never get warnings about not saved changes.
35+ -- Also some plugins treat nofile buffers different.
36+ -- For example coc.nvim don't triggers aoutcompletation for these.
37+ vim.api.nvim_buf_set_option(buf, "buftype", "nofile")
38+ -- We do not need swapfile for this buffer.
39+ vim.api.nvim_buf_set_option(buf, "swapfile", false)
40+ -- And we would rather prefer that this buffer will be destroyed when hide.
41+ vim.api.nvim_buf_set_option(buf, "bufhidden", "wipe")
42+ -- It's not necessary but it is good practice to set custom filetype.
43+ -- This allows users to create their own autocommand or colorschemes on filetype.
44+ -- and prevent collisions with other plugins.
45+ vim.api.nvim_buf_set_option(buf, "filetype", "leetcode.nvim")
46+
47+ -- For better UX we will turn off line wrap and turn on current line highlight.
48+ vim.api.nvim_win_set_option(win, "wrap", false)
49+ vim.api.nvim_win_set_option(win, "cursorline", true)
50+
51+ set_mappings() -- At end we will set mappings for our navigation.
52+end
53+
54+return Ui
+32-0lua/leetcode/utils.lua
@@ -0,0 +1,32 @@
1+local ui = require("leetcode.ui")
2+
3+---@class lc.Utils
4+local Utils = {}
5+
6+function Utils.prompt_for_cookie()
7+ local cookie = require("leetcode.cache.cookie")
8+
9+ ui.input(
10+ "Enter cookie",
11+ ---@param cookie_str string
12+ function(cookie_str)
13+ if not cookie_str then return end
14+
15+ cookie.new(cookie_str)
16+ require("leetcode.ui.dashboard").update()
17+ end
18+ )
19+end
20+
21+function Utils.remove_cookie()
22+ require("leetcode.cache.cookie").delete()
23+ require("leetcode.ui.dashboard").update()
24+end
25+
26+function Utils.alpha_move_cursor_top()
27+ local curr_win = vim.api.nvim_get_current_win()
28+ vim.api.nvim_win_set_cursor(curr_win, { 1, 0 })
29+ require("alpha").move_cursor(curr_win)
30+end
31+
32+return Utils