Files
tia-lsp.nvim/lua/scl/blink_cmp_source.lua
T
2026-02-14 15:05:12 +01:00

831 lines
28 KiB
Lua

-- blink.cmp completion source for SCL
-- Provides completion for UDT types, members, and variables
local M = {}
function M.new(opts)
return setmetatable({ opts = opts or {} }, { __index = M })
end
function M:get_trigger_characters()
return { ".", "(", "#", " " }
end
function M.is_available(context)
local ft = vim.bo[context.bufnr].filetype
if ft ~= "scl" and ft ~= "udt" then
return false
end
-- Get current line directly from buffer
local cursor = vim.api.nvim_win_get_cursor(0)
local row = cursor[1]
local col = cursor[2]
local line = vim.api.nvim_buf_get_lines(context.bufnr, row - 1, row, false)[1] or ""
-- Don't trigger on empty line (entering insert mode with o, i, etc.)
if line == "" or line:match("^%s*$") then
return false
end
-- Trigger on specific characters
if col > 0 then
local char_at = line:sub(col, col)
local prev_char = col > 1 and line:sub(col - 1, col - 1) or ""
-- Only trigger on our specific characters
if char_at == "." or char_at == "(" or char_at == "#" or
prev_char == "." or prev_char == "(" or prev_char == "#" then
return true
end
end
return false
end
function M.get_completion_types()
return { "syntax", "words", "line", "trigger" }
end
-- Check if we're after BEGIN keyword
local function is_after_begin(bufnr)
local cursor_row = vim.api.nvim_buf_get_mark(bufnr, ".")[1] - 1
for i = cursor_row, 0, -1 do
local line = vim.api.nvim_buf_get_lines(bufnr, i, i + 1, false)[1] or ""
if line:match("^%s*BEGIN%s*$") or line:match("^%s*BEGIN%s*;") then
return true
end
if line:match("^%s*ORGANIZATION_BLOCK%s+") or
line:match("^%s*FUNCTION_BLOCK%s+") or
line:match("^%s*FUNCTION%s+") then
return false
end
end
return false
end
-- Check if we're inside a VAR declaration section
local function is_in_var_section(bufnr)
local cursor_row = vim.api.nvim_buf_get_mark(bufnr, ".")[1] - 1
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, cursor_row + 1, false)
local in_var = false
for i = 1, cursor_row + 1 do
local line = lines[i] or ""
if line:match("^%s*VAR") then
in_var = true
elseif line:match("^%s*END_VAR") then
in_var = false
end
end
return in_var
end
-- Get all known local variables
local function get_known_variables(bufnr)
local scl_vars = require("scl.variables")
local variables = scl_vars.extract_variables(bufnr)
local known = {}
for _, v in ipairs(variables) do
known[v.name] = true
end
return known
end
-- Check if a type is an FB/Function and get its interface
local function get_fb_interface(var_type)
local fb_parser = require("scl.fb_parser")
local clean_type = var_type:gsub('"', '')
-- If cache is empty, try to scan workspace for FBs
if fb_parser.get_cache_count() == 0 then
local ws = require("scl.workspace_types")
ws.scan_project_fbs(vim.api.nvim_get_current_buf())
end
return fb_parser.get_fb_interface(clean_type)
end
-- Check if a type is an FB/Function
local function is_fb_type(var_type)
local fb_parser = require("scl.fb_parser")
local clean_type = var_type:gsub('"', '')
-- If cache is empty, try to scan workspace for FBs
if fb_parser.get_cache_count() == 0 then
local ws = require("scl.workspace_types")
ws.scan_project_fbs(vim.api.nvim_get_current_buf())
end
return fb_parser.is_fb_type(clean_type)
end
function M:get_completions(context, callback)
local bufnr = context.bufnr
local line = context.line
local cursor = context.cursor
local col = cursor[2] or cursor[1] or 0
-- Check context for FB parameter completion
-- We want to show FB params when:
-- - Just opened paren: fbName(
-- - After a comma: fbName(param1 := ,
-- - At start of parameter: fbName(param (before :)
--
-- We DON'T want to show FB params when:
-- - After := or => with an existing value: fbName(param1 := myVar
-- - After := or => with trailing value: fbName(param1 := value,
local line_before_cursor = line:sub(1, col)
-- Check if we're after a comma (ready for next parameter)
local is_after_comma = line_before_cursor:match(",%s*$") ~= nil
-- Check if we're typing a parameter value (after := or => with content)
-- This is true if we have ":= something" or "=> something" without a comma after
local has_param_assignment = line:match("%w+%s*:=%s*%S") ~= nil or line:match("%w+%s*=>%s*%S") ~= nil
-- If we have a param assignment but NOT after a comma, we're typing a value
local is_after_param_assignment = has_param_assignment and not is_after_comma
-- Check filetype
local ft = vim.bo[bufnr].filetype
if ft ~= "scl" and ft ~= "udt" then
callback({ items = {} })
return
end
local scl_vars = require("scl.variables")
local variables = scl_vars.extract_variables(bufnr)
-- Get all known local variables and their types
local known_vars = {}
local var_types = {}
for _, v in ipairs(variables) do
known_vars[v.name] = true
var_types[v.name] = v.type
end
-- Skip FB parameter completions if user is typing a parameter value (after := or =>)
if not is_after_param_assignment then
-- Search for any FB call pattern in the entire line
-- This handles cases where completion triggers before full text is typed
local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "")
-- Find all potential FB calls in the line
for fb_name in trimmed:gmatch("#?([%w_]+)%s*%(%s*%)?") do
if known_vars[fb_name] then
local var_type = var_types[fb_name]
if var_type then
local clean_type = var_type:gsub('"', '')
if is_fb_type(clean_type) then
local fb_interface = get_fb_interface(clean_type)
if fb_interface then
local wrapped_callback = vim.schedule_wrap(function(response)
callback({
items = response.items,
is_incomplete_backward = false,
is_incomplete_forward = false,
})
end)
local items = {}
for _, inp in ipairs(fb_interface.inputs or {}) do
local doc_value = inp.type
if inp.comment and inp.comment ~= "" then
doc_value = doc_value .. "\n\n" .. inp.comment
end
if inp.default_value then
doc_value = doc_value .. "\n\nDefault: " .. inp.default_value
end
table.insert(items, {
label = inp.name .. " :=",
kind = vim.lsp.protocol.CompletionItemKind.Property,
detail = inp.type .. " (Input)",
insertText = inp.name .. " := ",
documentation = {
kind = "markdown",
value = doc_value,
},
})
end
for _, inout in ipairs(fb_interface.inouts or {}) do
local doc_value = inout.type
if inout.comment and inout.comment ~= "" then
doc_value = doc_value .. "\n\n" .. inout.comment
end
if inout.default_value then
doc_value = doc_value .. "\n\nDefault: " .. inout.default_value
end
table.insert(items, {
label = inout.name .. " :=",
kind = vim.lsp.protocol.CompletionItemKind.Property,
detail = inout.type .. " (InOut)",
insertText = inout.name .. " := ",
documentation = {
kind = "markdown",
value = doc_value,
},
})
end
for _, out in ipairs(fb_interface.outputs or {}) do
local doc_value = out.type
if out.comment and out.comment ~= "" then
doc_value = doc_value .. "\n\n" .. out.comment
end
if out.default_value then
doc_value = doc_value .. "\n\nDefault: " .. out.default_value
end
table.insert(items, {
label = out.name .. " =>",
kind = vim.lsp.protocol.CompletionItemKind.Property,
detail = out.type .. " (Output)",
insertText = out.name .. " => ",
documentation = {
kind = "markdown",
value = doc_value,
},
})
end
wrapped_callback({ items = items })
return
end
end
end
end
end
end -- if not is_after_param_assignment
-- If no FB call found on current line, search previous lines
-- This handles multi-line parameter completion
-- Skip if user is typing a parameter value (after := or =>)
if not is_after_param_assignment then
local cursor_row = vim.api.nvim_buf_get_mark(bufnr, ".")[1] - 1
local prev_lines = vim.api.nvim_buf_get_lines(bufnr, 0, cursor_row, false)
-- Check if any previous line has a closed FB call (ending with ");")
-- If so, don't provide completions
local has_closed_call = false
for i = 1, #prev_lines do
if prev_lines[i]:match("%);%s*$") then
has_closed_call = true
break
end
end
if not has_closed_call then
for i = #prev_lines, 1, -1 do
local prev_line = prev_lines[i]:gsub("%s+$", "")
for fb_name in prev_line:gmatch("#?([%w_]+)%s*%(%s*%)?") do
if known_vars[fb_name] then
local var_type = var_types[fb_name]
if var_type then
local clean_type = var_type:gsub('"', '')
if is_fb_type(clean_type) then
local fb_interface = get_fb_interface(clean_type)
if fb_interface then
local wrapped_callback = vim.schedule_wrap(function(response)
callback({
items = response.items,
is_incomplete_backward = false,
is_incomplete_forward = false,
})
end)
local items = {}
for _, inp in ipairs(fb_interface.inputs or {}) do
local doc_value = inp.type
if inp.comment and inp.comment ~= "" then
doc_value = doc_value .. "\n\n" .. inp.comment
end
if inp.default_value then
doc_value = doc_value .. "\n\nDefault: " .. inp.default_value
end
table.insert(items, {
label = inp.name .. " :=",
kind = vim.lsp.protocol.CompletionItemKind.Property,
detail = inp.type .. " (Input)",
insertText = inp.name .. " := ",
documentation = {
kind = "markdown",
value = doc_value,
},
})
end
for _, inout in ipairs(fb_interface.inouts or {}) do
local doc_value = inout.type
if inout.comment and inout.comment ~= "" then
doc_value = doc_value .. "\n\n" .. inout.comment
end
if inout.default_value then
doc_value = doc_value .. "\n\nDefault: " .. inout.default_value
end
table.insert(items, {
label = inout.name .. " :=",
kind = vim.lsp.protocol.CompletionItemKind.Property,
detail = inout.type .. " (InOut)",
insertText = inout.name .. " := ",
documentation = {
kind = "markdown",
value = doc_value,
},
})
end
for _, out in ipairs(fb_interface.outputs or {}) do
local doc_value = out.type
if out.comment and out.comment ~= "" then
doc_value = doc_value .. "\n\n" .. out.comment
end
if out.default_value then
doc_value = doc_value .. "\n\nDefault: " .. out.default_value
end
table.insert(items, {
label = out.name .. " =>",
kind = vim.lsp.protocol.CompletionItemKind.Property,
detail = out.type .. " (Output)",
insertText = out.name .. " => ",
documentation = {
kind = "markdown",
value = doc_value,
},
})
end
wrapped_callback({ items = items })
return
end
end
end
end
end
end
end
end
local base_var = nil
local member_path = nil
-- Strip leading whitespace for regex matching
local trim_line = line:gsub("^%s+", "")
-- Note: known_vars is already defined above
-- Function to extract base_var and member_path from a var.member... pattern
local function extract_from_pattern(full_text)
if not full_text then return nil, nil end
-- Handle # prefixed
local var_pattern = full_text:match("^#([%w_]+)")
if var_pattern then
local after = full_text:sub(#var_pattern + 2)
-- Remove leading dot if present
if after:sub(1, 1) == "." then
after = after:sub(2)
end
-- Remove trailing dot if present
if after:sub(-1) == "." then
after = after:sub(1, -2)
end
return var_pattern, after
end
-- Non-prefixed - extract first identifier as variable
local var_name = full_text:match("^([%w_]+)")
if var_name and known_vars[var_name] then
local after = full_text:sub(#var_name + 2)
-- Remove leading dot if present
if after:sub(1, 1) == "." then
after = after:sub(2)
end
-- Remove trailing dot if present
if after:sub(-1) == "." then
after = after:sub(1, -2)
end
return var_name, after
end
return nil, nil
end
-- Find the LAST occurrence of a member access pattern (handles "AND var.member" cases)
-- First try # prefixed patterns - handle both "#var.member" and "#var.member."
local search_pos = 1
while search_pos <= #trim_line do
local hash_pos = trim_line:find("#", search_pos)
if not hash_pos then break end
-- Find the variable name after #
local var_end = hash_pos + 1
while var_end <= #trim_line and trim_line:sub(var_end, var_end):match("[%w_]") do
var_end = var_end + 1
end
var_end = var_end - 1
if var_end > hash_pos then
local var_name = trim_line:sub(hash_pos + 1, var_end)
-- Check if followed by a dot
if var_end < #trim_line and trim_line:sub(var_end + 1, var_end + 1) == "." then
-- Extract the member path after the dot
local path_start = var_end + 2
local path_end = path_start - 1
while path_end < #trim_line and trim_line:sub(path_end + 1, path_end + 1):match("[%w_.]") do
path_end = path_end + 1
end
local path = trim_line:sub(path_start, path_end)
-- Remove trailing dot if present
if path:sub(-1) == "." then
path = path:sub(1, -2)
end
base_var = var_name
member_path = path
elseif var_end == #trim_line or trim_line:sub(var_end + 1, var_end + 1):match("[%s%)]") then
-- Variable at end of line or followed by space/paren
base_var = var_name
member_path = ""
end
end
search_pos = hash_pos + 1
end
-- Then try non-prefixed patterns - search backwards to find LAST occurrence
if not base_var then
local search_pos = 1
while search_pos <= #trim_line do
-- Find word boundaries for potential variable names
local word_start, word_end = trim_line:find("([%w_]+)", search_pos)
if not word_start then break end
local var_name = trim_line:sub(word_start, word_end)
if known_vars[var_name] then
-- Check if followed by a dot
if word_end < #trim_line and trim_line:sub(word_end + 1, word_end + 1) == "." then
local path_start = word_end + 2
local path_end = word_end + 1
while path_end < #trim_line and trim_line:sub(path_end + 1, path_end + 1):match("[%w_.]") do
path_end = path_end + 1
end
local path = trim_line:sub(path_start, path_end)
if path:sub(-1) == "." then
path = path:sub(1, -2)
end
base_var = var_name
member_path = path
elseif word_end == #trim_line then
base_var = var_name
member_path = ""
end
end
search_pos = word_end + 1
end
end
-- Fallback: just var at end of line or var followed by ( or ()
if not base_var then
local var_name = trim_line:match("([%w_]+)%s*%(%)$")
if var_name and known_vars[var_name] then
base_var = var_name
member_path = ""
else
var_name = trim_line:match("([%w_]+)%s*%($")
if var_name and known_vars[var_name] then
base_var = var_name
member_path = ""
else
var_name = trim_line:match("([%w_]+)%s*$")
if var_name and known_vars[var_name] then
base_var = var_name
member_path = ""
end
end
end
end
-- Also check for #varName() pattern (after auto-bracket completion)
if not base_var then
local hash_var = trim_line:match("#([%w_]+)%s*%(%)$")
if hash_var and known_vars[hash_var] then
base_var = hash_var
member_path = ""
end
end
-- Also check for #varName( pattern
if not base_var then
local hash_var = trim_line:match("#([%w_]+)%s*%($")
if hash_var and known_vars[hash_var] then
base_var = hash_var
member_path = ""
end
end
local wrapped_callback = vim.schedule_wrap(function(response)
callback({
items = response.items,
is_incomplete_backward = false,
is_incomplete_forward = false,
})
end)
vim.schedule(function()
local udt_parser = require("scl.udt_parser")
-- No member access = show local variables, UDT types, and basic types
if not base_var then
local items = {}
-- Add local variables from VAR sections
local in_var_section = is_in_var_section(bufnr)
for _, v in ipairs(variables) do
if in_var_section then
table.insert(items, {
label = v.name,
kind = vim.lsp.protocol.CompletionItemKind.Variable,
detail = v.type and (v.type .. " - Local variable") or "Local variable",
})
else
table.insert(items, {
label = "#" .. v.name,
insertText = "#" .. v.name,
kind = vim.lsp.protocol.CompletionItemKind.Variable,
detail = v.type and (v.type .. " - Local variable") or "Local variable",
})
end
end
-- Add UDT types
for _, name in ipairs(udt_parser.get_all_udt_names()) do
table.insert(items, { label = '"' .. name .. '"', kind = 14 })
end
-- Add basic types
for _, t in ipairs({ "Bool", "Int", "DInt", "Real", "Word", "DWord", "Byte", "Time", "String", "SINT", "USINT", "UINT", "LREAL" }) do
table.insert(items, { label = t, kind = 19 })
end
wrapped_callback({ items = items })
return
end
-- Member completion
local var_type = scl_vars.get_variable_type(base_var, bufnr)
-- If base_var is not a known variable, show all local variables instead
if not var_type then
local items = {}
local in_var_section = is_in_var_section(bufnr)
for _, v in ipairs(variables) do
if in_var_section then
table.insert(items, {
label = v.name,
kind = vim.lsp.protocol.CompletionItemKind.Variable,
detail = v.type and (v.type .. " - Local variable") or "Local variable",
})
else
table.insert(items, {
label = "#" .. v.name,
insertText = "#" .. v.name,
kind = vim.lsp.protocol.CompletionItemKind.Variable,
detail = v.type and (v.type .. " - Local variable") or "Local variable",
})
end
end
for _, name in ipairs(udt_parser.get_all_udt_names()) do
table.insert(items, { label = '"' .. name .. '"', kind = 14 })
end
for _, t in ipairs({ "Bool", "Int", "DInt", "Real", "Word", "DWord", "Byte", "Time", "String", "SINT", "USINT", "UINT", "LREAL" }) do
table.insert(items, { label = t, kind = 19 })
end
wrapped_callback({ items = items })
return
end
var_type = var_type:gsub('"', '')
local is_udt = udt_parser.is_udt_type(var_type)
local is_fb = is_fb_type(var_type)
local fb_interface = nil
if is_fb then
fb_interface = get_fb_interface(var_type)
end
if not is_udt and not is_fb then
wrapped_callback({ items = {} })
return
end
-- Handle FB parameter completion (when user types instanceName() or instanceName(
local line_before_cursor = line:sub(1, col)
local is_fb_call = line_before_cursor:match("%(#?[%w_]+%s*%(%s*$") ~= nil or
line_before_cursor:match("%(#?[%w_]+%s*%)%s*$") ~= nil
-- Check if there's a ( somewhere after the variable (parameter call mode)
local has_param_paren = line:match("%(") ~= nil
if is_fb and fb_interface and (is_fb_call or (member_path == "" and has_param_paren)) then
local items = {}
for _, inp in ipairs(fb_interface.inputs or {}) do
table.insert(items, {
label = inp.name .. " :=",
kind = vim.lsp.protocol.CompletionItemKind.Property,
detail = inp.type .. " (Input)",
insertText = inp.name .. " := ",
})
end
for _, inout in ipairs(fb_interface.inouts or {}) do
table.insert(items, {
label = inout.name .. " :=",
kind = vim.lsp.protocol.CompletionItemKind.Property,
detail = inout.type .. " (InOut)",
insertText = inout.name .. " := ",
})
end
for _, out in ipairs(fb_interface.outputs or {}) do
table.insert(items, {
label = out.name .. " =>",
kind = vim.lsp.protocol.CompletionItemKind.Property,
detail = out.type .. " (Output)",
insertText = out.name .. " => ",
})
end
wrapped_callback({ items = items })
return
end
-- Fallback: if base_var is an FB and we're after BEGIN (in code section), provide parameter completions
if is_fb and fb_interface and member_path == "" and is_after_begin(bufnr) then
local items = {}
for _, inp in ipairs(fb_interface.inputs or {}) do
table.insert(items, {
label = inp.name .. " :=",
kind = vim.lsp.protocol.CompletionItemKind.Property,
detail = inp.type .. " (Input)",
insertText = inp.name .. " := ",
})
end
for _, inout in ipairs(fb_interface.inouts or {}) do
table.insert(items, {
label = inout.name .. " :=",
kind = vim.lsp.protocol.CompletionItemKind.Property,
detail = inout.type .. " (InOut)",
insertText = inout.name .. " := ",
})
end
for _, out in ipairs(fb_interface.outputs or {}) do
table.insert(items, {
label = out.name .. " =>",
kind = vim.lsp.protocol.CompletionItemKind.Property,
detail = out.type .. " (Output)",
insertText = out.name .. " => ",
})
end
wrapped_callback({ items = items })
return
end
-- If it's an FB but we're accessing members with dot, treat as UDT-like completion
if is_fb and fb_interface and member_path == "" then
local items = {}
for _, inp in ipairs(fb_interface.inputs or {}) do
table.insert(items, {
label = inp.name,
kind = vim.lsp.protocol.CompletionItemKind.Field,
detail = inp.type .. " (Input)",
})
end
for _, inout in ipairs(fb_interface.inouts or {}) do
table.insert(items, {
label = inout.name,
kind = vim.lsp.protocol.CompletionItemKind.Field,
detail = inout.type .. " (InOut)",
})
end
for _, out in ipairs(fb_interface.outputs or {}) do
table.insert(items, {
label = out.name,
kind = vim.lsp.protocol.CompletionItemKind.Field,
detail = out.type .. " (Output)",
})
end
wrapped_callback({ items = items })
return
end
if not is_udt then
wrapped_callback({ items = {} })
return
end
-- Resolve nested path - iterate through each part to find the target type
local target_type = var_type
if member_path and member_path ~= "" then
local line_ends_with_dot = trim_line:match("%.$") ~= nil
local path_parts = {}
for part in member_path:gmatch("[^.]+") do
table.insert(path_parts, part)
end
local current_type = var_type
local resolved_parts = 0
for i, part in ipairs(path_parts) do
local udt = udt_parser.get_udt(current_type)
if not udt then
break
end
local found = false
for _, member in ipairs(udt.members) do
if member.name == part then
resolved_parts = i
if member.is_udt then
current_type = member.type
found = true
else
-- Not a UDT - if this is the last part, we found the target type
-- Otherwise, we can't continue the path
if i < #path_parts then
found = false
else
found = true
current_type = member.type
end
end
break
end
end
if not found then
break
end
end
if line_ends_with_dot or resolved_parts == #path_parts then
target_type = current_type
else
wrapped_callback({ items = {} })
return
end
end
local members = udt_parser.get_udt_members(target_type)
local items = {}
for _, m in ipairs(members) do
table.insert(items, {
label = m.name,
kind = m.is_udt and vim.lsp.protocol.CompletionItemKind.Struct or vim.lsp.protocol.CompletionItemKind.Field,
detail = m.raw_type,
})
end
wrapped_callback({ items = items })
end)
return function() end
end
-- Resolve callback to add # prefix for variables
function M:resolve(context, callback)
local item = context.item
local bufnr = context.bufnr
-- Check if we should add # prefix
local should_prefix = false
if item and item.label then
-- Only add # for variable kinds
local var_kinds = {
vim.lsp.protocol.CompletionItemKind.Variable,
vim.lsp.protocol.CompletionItemKind.Parameter,
}
local is_var = false
for _, kind in ipairs(var_kinds) do
if item.kind == kind then
is_var = true
break
end
end
if is_var then
-- Check if we're after BEGIN and not in VAR section
if is_after_begin(bufnr) and not is_in_var_section(bufnr) then
-- Check if variable is in known variables list
local known_vars = get_known_variables(bufnr)
local var_name = item.label:gsub("^#", "")
if known_vars[var_name] and not item.label:match("^#") then
should_prefix = true
end
end
end
end
if should_prefix then
item.label = "#" .. item.label
item.insertText = "#" .. (item.insertText or item.label)
if item.textEdit and item.textEdit.newText then
item.textEdit.newText = "#" .. item.textEdit.newText
end
end
callback({ item = item })
end
return M