summaryrefslogtreecommitdiff
path: root/lua/blink/cmp/completion/trigger/init.lua
blob: 1bd330fc11af50937f9c4066e52f8b81db76e06c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
--- @alias blink.cmp.CompletionTriggerKind 'manual' | 'prefetch' | 'keyword' | 'trigger_character'
---
-- Handles hiding and showing the completion window. When a user types a trigger character
-- (provided by the sources) or anything matching the `keyword_regex`, we create a new `context`.
-- This can be used downstream to determine if we should make new requests to the sources or not.
--- @class blink.cmp.CompletionTrigger
--- @field buffer_events blink.cmp.BufferEvents
--- @field cmdline_events blink.cmp.CmdlineEvents
--- @field current_context_id number
--- @field context? blink.cmp.Context
--- @field show_emitter blink.cmp.EventEmitter<{ context: blink.cmp.Context }>
--- @field hide_emitter blink.cmp.EventEmitter<{}>
---
--- @field activate fun()
--- @field is_keyword_character fun(char: string): boolean
--- @field is_trigger_character fun(char: string, is_show_on_x?: boolean): boolean
--- @field suppress_events_for_callback fun(cb: fun())
--- @field show_if_on_trigger_character fun(opts?: { is_accept?: boolean })
--- @field show fun(opts?: { trigger_kind: blink.cmp.CompletionTriggerKind, trigger_character?: string, force?: boolean, send_upstream?: boolean, providers?: string[] }): blink.cmp.Context?
--- @field hide fun()
--- @field within_query_bounds fun(cursor: number[]): boolean
--- @field get_bounds fun(regex: vim.regex, line: string, cursor: number[]): blink.cmp.ContextBounds

local config = require('blink.cmp.config').completion.trigger
local context = require('blink.cmp.completion.trigger.context')
local utils = require('blink.cmp.completion.trigger.utils')

--- @type blink.cmp.CompletionTrigger
--- @diagnostic disable-next-line: missing-fields
local trigger = {
  current_context_id = -1,
  show_emitter = require('blink.cmp.lib.event_emitter').new('show'),
  hide_emitter = require('blink.cmp.lib.event_emitter').new('hide'),
}

function trigger.activate()
  trigger.buffer_events = require('blink.cmp.lib.buffer_events').new({
    -- TODO: should this ignore trigger.kind == 'prefetch'?
    has_context = function() return trigger.context ~= nil end,
    show_in_snippet = config.show_in_snippet,
  })
  trigger.cmdline_events = require('blink.cmp.lib.cmdline_events').new()

  local function on_char_added(char, is_ignored)
    -- we were told to ignore the text changed event, so we update the context
    -- but don't send an on_show event upstream
    if is_ignored then
      if trigger.context ~= nil then trigger.show({ send_upstream = false, trigger_kind = 'keyword' }) end

    -- character forces a trigger according to the sources, create a fresh context
    elseif trigger.is_trigger_character(char) and (config.show_on_trigger_character or trigger.context ~= nil) then
      trigger.context = nil
      trigger.show({ trigger_kind = 'trigger_character', trigger_character = char })

    -- character is part of a keyword
    elseif trigger.is_keyword_character(char) and (config.show_on_keyword or trigger.context ~= nil) then
      trigger.show({ trigger_kind = 'keyword' })

    -- nothing matches so hide
    else
      trigger.hide()
    end
  end

  local function on_cursor_moved(event, is_ignored)
    local cursor = context.get_cursor()
    local cursor_col = cursor[2]

    local char_under_cursor = utils.get_char_at_cursor()
    local is_keyword = trigger.is_keyword_character(char_under_cursor)

    -- we were told to ignore the cursor moved event, so we update the context
    -- but don't send an on_show event upstream
    if is_ignored and event == 'CursorMoved' then
      if trigger.context ~= nil then
        -- TODO: If we `auto_insert` with the `path` source, we may end up on a trigger character
        -- i.e. `downloads/`. If we naively update the context, we'll show the menu with the
        -- existing context. So we clear the context if we're not on a keyword character.
        -- Is there a better solution here?
        if not is_keyword then trigger.context = nil end

        trigger.show({ send_upstream = false, trigger_kind = 'keyword' })
      end
      return
    end

    local is_on_trigger_for_show = trigger.is_trigger_character(char_under_cursor)

    -- TODO: doesn't handle `a` where the cursor moves immediately after
    -- Reproducable with `example.|a` and pressing `a`, should not show the menu
    local insert_enter_on_trigger_character = config.show_on_trigger_character
      and config.show_on_insert_on_trigger_character
      and event == 'InsertEnter'
      and trigger.is_trigger_character(char_under_cursor, true)

    -- check if we're still within the bounds of the query used for the context
    if trigger.context ~= nil and trigger.context:within_query_bounds(cursor) then
      trigger.show({ trigger_kind = 'keyword' })

    -- check if we've entered insert mode on a trigger character
    -- or if we've moved onto a trigger character while open
    elseif
      insert_enter_on_trigger_character
      or (is_on_trigger_for_show and trigger.context ~= nil and trigger.context.trigger.kind ~= 'prefetch')
    then
      trigger.context = nil
      trigger.show({ trigger_kind = 'trigger_character', trigger_character = char_under_cursor })

    -- show if we currently have a context, and we've moved outside of it's bounds by 1 char
    elseif is_keyword and trigger.context ~= nil and cursor_col == trigger.context.bounds.start_col - 1 then
      trigger.context = nil
      trigger.show({ trigger_kind = 'keyword' })

    -- prefetch completions without opening window on InsertEnter
    elseif event == 'InsertEnter' and config.prefetch_on_insert then
      trigger.show({ trigger_kind = 'prefetch' })

    -- otherwise hide
    else
      trigger.hide()
    end
  end

  trigger.buffer_events:listen({
    on_char_added = on_char_added,
    on_cursor_moved = on_cursor_moved,
    on_insert_leave = function() trigger.hide() end,
  })
  trigger.cmdline_events:listen({
    on_char_added = on_char_added,
    on_cursor_moved = on_cursor_moved,
    on_leave = function() trigger.hide() end,
  })
end

function trigger.is_keyword_character(char)
  -- special case for hyphen, since we don't consider a lone hyphen to be a keyword
  if char == '-' then return true end

  local keyword_start_col, keyword_end_col = require('blink.cmp.fuzzy').get_keyword_range(char, #char, 'prefix')
  return keyword_start_col ~= keyword_end_col
end

function trigger.is_trigger_character(char, is_show_on_x)
  local sources = require('blink.cmp.sources.lib')
  local is_trigger = vim.tbl_contains(sources.get_trigger_characters(context.get_mode()), char)

  local show_on_blocked_trigger_characters = type(config.show_on_blocked_trigger_characters) == 'function'
      and config.show_on_blocked_trigger_characters()
    or config.show_on_blocked_trigger_characters
  --- @cast show_on_blocked_trigger_characters string[]
  local show_on_x_blocked_trigger_characters = type(config.show_on_x_blocked_trigger_characters) == 'function'
      and config.show_on_x_blocked_trigger_characters()
    or config.show_on_x_blocked_trigger_characters
  --- @cast show_on_x_blocked_trigger_characters string[]

  local is_blocked = vim.tbl_contains(show_on_blocked_trigger_characters, char)
    or (is_show_on_x and vim.tbl_contains(show_on_x_blocked_trigger_characters, char))

  return is_trigger and not is_blocked
end

--- Suppresses on_hide and on_show events for the duration of the callback
function trigger.suppress_events_for_callback(cb)
  local mode = vim.api.nvim_get_mode().mode == 'c' and 'cmdline' or 'default'

  local events = mode == 'default' and trigger.buffer_events or trigger.cmdline_events
  if not events then return cb() end

  events:suppress_events_for_callback(cb)
end

function trigger.show_if_on_trigger_character(opts)
  if
    (opts and opts.is_accept)
    and (not config.show_on_trigger_character or not config.show_on_accept_on_trigger_character)
  then
    return
  end

  local cursor_col = context.get_cursor()[2]
  local char_under_cursor = context.get_line():sub(cursor_col, cursor_col)

  if trigger.is_trigger_character(char_under_cursor, true) then
    trigger.show({ trigger_kind = 'trigger_character', trigger_character = char_under_cursor })
  end
end

function trigger.show(opts)
  if not require('blink.cmp.config').enabled() then return trigger.hide() end

  opts = opts or {}

  -- already triggered at this position, ignore
  local mode = context.get_mode()
  local cursor = context.get_cursor()
  if
    not opts.force
    and trigger.context ~= nil
    and trigger.context.mode == mode
    and cursor[1] == trigger.context.cursor[1]
    and cursor[2] == trigger.context.cursor[2]
  then
    return
  end

  -- update the context id to indicate a new context, and not an update to an existing context
  if trigger.context == nil or opts.providers ~= nil then
    trigger.current_context_id = trigger.current_context_id + 1
  end

  local providers = opts.providers
    or (trigger.context and trigger.context.providers)
    or require('blink.cmp.sources.lib').get_enabled_provider_ids(context.get_mode())

  local initial_trigger_kind = trigger.context and trigger.context.trigger.initial_kind or opts.trigger_kind
  -- if we prefetched, don't keep that as the initial trigger kind
  if initial_trigger_kind == 'prefetch' then initial_trigger_kind = opts.trigger_kind end
  -- if we're manually triggering, set it as the initial trigger kind
  if opts.trigger_kind == 'manual' then initial_trigger_kind = 'manual' end

  trigger.context = context.new({
    id = trigger.current_context_id,
    providers = providers,
    initial_trigger_kind = initial_trigger_kind,
    initial_trigger_character = trigger.context and trigger.context.trigger.initial_character or opts.trigger_character,
    trigger_kind = opts.trigger_kind,
    trigger_character = opts.trigger_character,
  })

  if opts.send_upstream ~= false then trigger.show_emitter:emit({ context = trigger.context }) end
  return trigger.context
end

function trigger.hide()
  if not trigger.context then return end
  trigger.context = nil
  trigger.hide_emitter:emit()
end

return trigger