---@brief --- --- GitLab Duo Language Server Configuration for Neovim --- --- https://gitlab.com/gitlab-org/editor-extensions/gitlab-lsp --- --- The GitLab LSP enables any editor or IDE to integrate with GitLab Duo --- for AI-powered code suggestions via the Language Server Protocol. --- --- Prerequisites: --- - Node.js and npm installed --- - GitLab account with Duo Pro license --- - Internet connection for OAuth device flow --- --- Setup: --- 1. Run :LspGitLabDuoSignIn to start OAuth authentication --- 2. Follow the browser prompts to authorize --- 3. Enable inline completion in LspAttach event (see example below) --- --- Inline Completion Example: --- ```lua --- vim.api.nvim_create_autocmd('LspAttach', { --- callback = function(args) --- local bufnr = args.buf --- local client = assert(vim.lsp.get_client_by_id(args.data.client_id)) --- --- if vim.lsp.inline_completion and --- client:supports_method(vim.lsp.protocol.Methods.textDocument_inlineCompletion, bufnr) then --- vim.lsp.inline_completion.enable(true, { bufnr = bufnr }) --- --- -- Tab to accept suggestion --- vim.keymap.set('i', '', function() --- if vim.lsp.inline_completion.is_visible() then --- return vim.lsp.inline_completion.accept() --- else --- return '' --- end --- end, { expr = true, buffer = bufnr, desc = 'GitLab Duo: Accept suggestion' }) --- --- -- Alt/Option+[ for previous suggestion --- vim.keymap.set('i', '', vim.lsp.inline_completion.select_prev, --- { buffer = bufnr, desc = 'GitLab Duo: Previous suggestion' }) --- --- -- Alt/Option+] for next suggestion --- vim.keymap.set('i', '', vim.lsp.inline_completion.select_next, --- { buffer = bufnr, desc = 'GitLab Duo: Next suggestion' }) --- end --- end --- }) --- ``` -- Configuration local config = { gitlab_url = 'https://gitlab.com', -- This is a oauth application created from tachyons-gitlab account with `api` scope client_id = '00bb391f527d2e77b3467b0b6b900151cc6a28dcfb18fa1249871e43bc3e5832', scopes = 'api', token_file = vim.fn.stdpath('data') .. '/gitlab_duo_oauth.json', } -- Helper function to make POST requests with curl via vim.system local function curl_post(url, data, headers) local curl_args = { 'curl', '-s', '-w', '\n%{http_code}', '-X', 'POST', url, } -- Add headers for key, value in pairs(headers or {}) do table.insert(curl_args, '-H') table.insert(curl_args, key .. ': ' .. value) end -- Add data if data then table.insert(curl_args, '-d') table.insert(curl_args, data) end local result = vim.system(curl_args, { text = true }):wait() -- Split body and status code local output = result.stdout or '' local body_end = output:match('.*\n()%d+$') local body = '' local status = 0 if body_end then body = output:sub(1, body_end - 2) -- -2 to remove trailing newline status = tonumber(output:match('\n(%d+)$')) or 0 else body = output end return { status = status, body = body, } end -- Token management local function save_token(token_data) token_data.saved_at = os.time() local file = io.open(config.token_file, 'w') if file then file:write(vim.json.encode(token_data)) file:close() return true end return false end local function load_token() if vim.fn.filereadable(config.token_file) == 0 then return nil end local blob = vim.fn.readblob(config.token_file) return vim.json.decode(blob) end local function is_token_expired(token_data) if not token_data or not token_data.saved_at or not token_data.expires_in then return true end local token_age = os.time() - token_data.saved_at return token_age >= (token_data.expires_in - 60) -- 60 second buffer end local function refresh_access_token(refresh_token) vim.notify('Refreshing GitLab OAuth token...', vim.log.levels.INFO) local response = curl_post( config.gitlab_url .. '/oauth/token', string.format('client_id=%s&refresh_token=%s&grant_type=refresh_token', config.client_id, refresh_token), { ['Content-Type'] = 'application/x-www-form-urlencoded' } ) if response.status ~= 200 then vim.notify('Failed to refresh token: ' .. (response.body or 'Unknown error'), vim.log.levels.ERROR) return nil end local ok, body = pcall(vim.json.decode, response.body) if not ok or not body.access_token then vim.notify('Invalid refresh response', vim.log.levels.ERROR) return nil end save_token(body) vim.notify('Token refreshed successfully', vim.log.levels.INFO) return body end local function get_valid_token() local token_data = load_token() if not token_data then return nil, 'no_token' end if is_token_expired(token_data) then if token_data.refresh_token then local new_token_data = refresh_access_token(token_data.refresh_token) if new_token_data then return new_token_data.access_token, 'refreshed' end return nil, 'refresh_failed' end return nil, 'expired' end return token_data.access_token, 'valid' end -- OAuth Device Flow local function device_authorization() local response = curl_post( config.gitlab_url .. '/oauth/authorize_device', string.format('client_id=%s&scope=%s', config.client_id, config.scopes), { ['Content-Type'] = 'application/x-www-form-urlencoded' } ) if response.status ~= 200 then vim.notify('Device authorization failed: ' .. response.status, vim.log.levels.ERROR) return nil end local data = vim.json.decode(response.body) return data end local function poll_for_token(device_code, interval, client) local max_attempts = 60 local attempts = 0 local function poll() attempts = attempts + 1 local response = curl_post( config.gitlab_url .. '/oauth/token', string.format( 'client_id=%s&device_code=%s&grant_type=urn:ietf:params:oauth:grant-type:device_code', config.client_id, device_code ), { ['Content-Type'] = 'application/x-www-form-urlencoded' } ) local ok, body = pcall(vim.json.decode, response.body) if not ok then vim.notify('Failed to parse token response', vim.log.levels.ERROR) return end if response.status == 200 and body.access_token then save_token(body) vim.notify('GitLab Duo authentication successful!', vim.log.levels.INFO) -- Update LSP with new token vim.schedule(function() client:notify('workspace/didChangeConfiguration', { settings = { token = body.access_token, baseUrl = config.gitlab_url, }, }) end) return end if body.error == 'authorization_pending' then if attempts < max_attempts then vim.defer_fn(poll, interval * 1000) else vim.notify('Authorization timed out', vim.log.levels.ERROR) end elseif body.error == 'slow_down' then vim.defer_fn(poll, (interval + 5) * 1000) elseif body.error == 'access_denied' then vim.notify('Authorization denied', vim.log.levels.ERROR) elseif body.error == 'expired_token' then vim.notify('Device code expired. Please run :LspGitLabDuoSignIn again', vim.log.levels.ERROR) else vim.notify('OAuth error: ' .. (body.error or 'unknown'), vim.log.levels.ERROR) end end poll() end ---@param client vim.lsp.Client local function sign_in(client) vim.notify('Starting GitLab device authorization...', vim.log.levels.INFO) local auth_data = device_authorization() if not auth_data then return end vim.ui.open(auth_data.verification_uri .. '?user_code=' .. auth_data.user_code) poll_for_token(auth_data.device_code, auth_data.interval or 5, client) end ---@param client vim.lsp.Client local function sign_out(client) local ok = os.remove(config.token_file) if ok then vim.notify('Signed out. Token removed.', vim.log.levels.INFO) client:notify('workspace/didChangeConfiguration', { settings = { token = '' }, }) else vim.notify('Failed to remove token file', vim.log.levels.ERROR) end end local function show_status() local token_data = load_token() if not token_data then vim.notify('Not signed in. Run :LspGitLabDuoSignIn to authenticate.', vim.log.levels.INFO) return end local info = { 'GitLab Duo Status:', '', 'Instance: ' .. config.gitlab_url, 'Signed in: Yes', 'Has refresh token: ' .. (token_data.refresh_token and 'Yes' or 'No'), } if token_data.saved_at and token_data.expires_in then local time_left = token_data.expires_in - (os.time() - token_data.saved_at) if time_left > 0 then local hours = math.floor(time_left / 3600) local minutes = math.floor((time_left % 3600) / 60) table.insert(info, string.format('Token expires in: %dh %dm', hours, minutes)) else table.insert(info, 'Token status: EXPIRED') end end vim.notify(table.concat(info, '\n'), vim.log.levels.INFO) end ---@type vim.lsp.Config return { cmd = { 'npx', '--registry=https://gitlab.com/api/v4/packages/npm/', '@gitlab-org/gitlab-lsp', '--stdio', }, root_markers = { '.git' }, filetypes = { 'ruby', 'go', 'javascript', 'typescript', 'typescriptreact', 'javascriptreact', 'rust', 'lua', 'python', 'java', 'cpp', 'c', 'php', 'cs', 'kotlin', 'swift', 'scala', 'vue', 'svelte', 'html', 'css', 'scss', 'json', 'yaml', }, init_options = { editorInfo = { name = 'Neovim', version = tostring(vim.version()), }, editorPluginInfo = { name = 'Neovim LSP', version = tostring(vim.version()), }, ide = { name = 'Neovim', version = tostring(vim.version()), vendor = 'Neovim', }, extension = { name = 'Neovim LSP Client', version = tostring(vim.version()), }, }, settings = { baseUrl = config.gitlab_url, logLevel = 'info', codeCompletion = { enableSecretRedaction = true, }, telemetry = { enabled = false, }, featureFlags = { streamCodeGenerations = false, }, }, on_init = function(client) -- Handle token validation errors client.handlers['$/gitlab/token/check'] = function(_, result) if result and result.reason then vim.notify(string.format('GitLab Duo: %s - %s', result.reason, result.message or ''), vim.log.levels.ERROR) -- Try to refresh if possible local token_data = load_token() if token_data and token_data.refresh_token then vim.schedule(function() local new_token_data = refresh_access_token(token_data.refresh_token) if new_token_data then client:notify('workspace/didChangeConfiguration', { settings = { token = new_token_data.access_token, baseUrl = config.gitlab_url }, }) else vim.notify('Run :LspGitLabDuoSignIn to re-authenticate', vim.log.levels.WARN) end end) else vim.notify('Run :LspGitLabDuoSignIn to authenticate', vim.log.levels.WARN) end end end -- Handle feature state changes client.handlers['$/gitlab/featureStateChange'] = function(_, result) if result and result.state == 'disabled' and result.checks then for _, check in ipairs(result.checks) do vim.notify(string.format('GitLab Duo: %s', check.message or check.id), vim.log.levels.WARN) end end end -- Check authentication status local token, status = get_valid_token() if token then client:notify('workspace/didChangeConfiguration', { settings = { token = token, baseUrl = config.gitlab_url, }, }) end if not token then vim.notify('GitLab Duo: Not authenticated. Run :LspGitLabDuoSignIn to sign in.', vim.log.levels.WARN) elseif status == 'refreshed' then vim.notify('GitLab Duo: Token refreshed automatically', vim.log.levels.INFO) end end, on_attach = function(client, bufnr) vim.api.nvim_buf_create_user_command(bufnr, 'LspGitLabDuoSignIn', function() sign_in(client) end, { desc = 'Sign in to GitLab Duo with OAuth' }) vim.api.nvim_buf_create_user_command(bufnr, 'LspGitLabDuoSignOut', function() sign_out(client) end, { desc = 'Sign out from GitLab Duo' }) vim.api.nvim_buf_create_user_command(bufnr, 'LspGitLabDuoStatus', function() show_status() end, { desc = 'Show GitLab Duo authentication status' }) end, }