aboutsummaryrefslogtreecommitdiffstats
---@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', '<Tab>', function()
---         if vim.lsp.inline_completion.is_visible() then
---           return vim.lsp.inline_completion.accept()
---         else
---           return '<Tab>'
---         end
---       end, { expr = true, buffer = bufnr, desc = 'GitLab Duo: Accept suggestion' })
---
---       -- Alt/Option+[ for previous suggestion
---       vim.keymap.set('i', '<M-[>', vim.lsp.inline_completion.select_prev,
---         { buffer = bufnr, desc = 'GitLab Duo: Previous suggestion' })
---
---       -- Alt/Option+] for next suggestion
---       vim.keymap.set('i', '<M-]>', 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,
}