-- 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 (including space for := assignments) if char_at == "." or char_at == "(" or char_at == "#" or char_at == '"' or char_at == " " or char_at == ":" or prev_char == "." or prev_char == "(" or prev_char == "#" or prev_char == '"' or prev_char == " " or prev_char == ":" then return true end -- Also trigger when typing a word character if we're in VAR section (type declaration) if char_at:match("[%w_]") or prev_char:match("[%w_]") then -- Check if we're in VAR section local in_var = false local lines = vim.api.nvim_buf_get_lines(context.bufnr, 0, row, false) for i = 1, #lines do local l = lines[i]:gsub("^%s+", ""):gsub("%s+$", "") if l:match("^END_VAR") then in_var = false elseif l:match("^VAR%s*$") or l:match("^VAR$") or l:match("^VAR_INPUT") or l:match("^VAR_OUTPUT") or l:match("^VAR_IN_OUT") or l:match("^VAR_TEMP") then in_var = true end end -- Also check current line - if it has ":" followed by space or word, we're declaring a type local line_before = col > 1 and line:sub(1, col - 1) or "" if in_var and (line_before:match(":%s*$") or line_before:match(":%s+$")) then return true end 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("tia.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("tia.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("tia.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("tia.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("tia.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 -- Check for Global DB completion (quoted DB names like "HmiData") local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "") -- Find the "active portion" of the line - after the last operator if present -- Operators: :=, =>, =, <>, >=, <=, >, <, AND, OR, NOT -- This ensures we match DB patterns nearest to the cursor, not the first in line local active_portion = line_before_cursor local function find_last_operator_pos(text) local last_pos = 0 local operators = {":=", "=>", "<>", ">=", "<=", "=", ">", "<"} for _, op in ipairs(operators) do local search_start = 1 while true do local pos = text:find(op, search_start, true) if not pos then break end if pos > last_pos then last_pos = pos + #op end search_start = pos + 1 end end -- Also check for AND/OR/NOT keywords (case insensitive) local keywords = {"%sAND%s", "%sOR%s", "%sNOT%s"} for _, kw in ipairs(keywords) do local search_start = 1 while true do local pos = text:upper():find(kw, search_start) if not pos then break end if pos > last_pos then last_pos = pos + #kw - 1 end search_start = pos + 1 end end return last_pos end local last_op_pos = find_last_operator_pos(line_before_cursor) if last_op_pos > 0 then active_portion = line_before_cursor:sub(last_op_pos):gsub("^%s+", "") end -- Check for "DBName" pattern (quoted DB name access with dot) - in active portion only local db_member_pattern = active_portion:match('"([%w_]+)"%.([%w_.]*)') -- Check if we're typing a quoted DB name (after " but before closing ") - in active portion local is_in_db_name = active_portion:match('"[%w_]*$') ~= nil -- Check for partial DB name like "HmiData (unclosed quote) - in active portion local partial_db_match = active_portion:match('"([%w_]+)$') local is_partial_db = partial_db_match ~= nil and active_portion:match('"[^"]*$') ~= nil -- Check if after a colon followed by quote (type declaration context) - use full trimmed line local is_after_colon_quote = trimmed:match(':%s*"$') ~= nil -- Check if cursor is right after a closed quoted DB name (before dot) - in active portion local after_closed_db = active_portion:match('"([%w_]+)"%s*%.') ~= nil if db_member_pattern or after_closed_db then local db_parser = require("tia.db_parser") local db_name = active_portion:match('"([%w_]+)"') -- Ensure cache is populated if db_name and db_parser.get_cache_count() == 0 then local ws = require("tia.workspace_types") ws.scan_project_dbs(bufnr) end if db_name then local db = db_parser.get_db(db_name) if db then local path = active_portion:match('"[^"]+"%.([%w_.]*)$') -- Resolve nested path through DB members local current_type = nil local current_members = db_parser.get_db_members(db_name) local resolved_path = "" if path and path ~= "" then -- Parse the path parts local path_parts = {} for part in path:gmatch("[^.]+") do table.insert(path_parts, part) end -- Resolve each part of the path local found = true for i, part in ipairs(path_parts) do local found_member = nil for _, m in ipairs(current_members) do if m.name == part then found_member = m break end end if found_member then resolved_path = part local nested_type = nil if found_member.is_udt then nested_type = found_member.type else -- Check for array of UDT: Array[..] of "TypeName" local array_udt = found_member.raw_type:match('Array%b[]%s*of%s*"([^"]+)"') if array_udt then nested_type = array_udt end end if nested_type then local udt_parser = require("tia.udt_parser") current_members = udt_parser.get_udt_members(nested_type) current_type = nested_type else -- This is a basic type, can't continue if i < #path_parts then found = false break end current_type = found_member.type current_members = {} end else found = false break end end if not found then callback({ items = {}, is_incomplete_backward = false, is_incomplete_forward = false }) return end end -- If we have a resolved path and it's not complete (ends with .), show next level members if path and path ~= "" and path:match("%.$") then -- User wants members of the last type elseif path and path ~= "" and not path:match("%.$") then callback({ items = {}, is_incomplete_backward = false, is_incomplete_forward = false }) return end -- Get members to show (either from DB or from resolved UDT type) if current_type then local udt_parser = require("tia.udt_parser") current_members = udt_parser.get_udt_members(current_type) end if #current_members > 0 then local items = {} for _, m in ipairs(current_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 callback({ items = items, is_incomplete_backward = false, is_incomplete_forward = false, }) return end end end end -- Check for just " pattern (start of DB name completion or partial DB name) if is_in_db_name or is_partial_db or is_after_colon_quote then local db_parser = require("tia.db_parser") -- If cache is empty, try to scan workspace for DBs if db_parser.get_cache_count() == 0 then local ws = require("tia.workspace_types") ws.scan_project_dbs(bufnr) end local db_names = db_parser.get_all_db_names() if #db_names > 0 then local items = {} local partial_name = partial_db_match or "" for _, name in ipairs(db_names) do -- Filter by partial match if user is typing if partial_name == "" or name:lower():find(partial_name:lower(), 1, true) == 1 then table.insert(items, { label = name, -- Show without quotes in popup insertText = name, -- Don't include quotes - user already typed opening " kind = vim.lsp.protocol.CompletionItemKind.Struct, detail = "Global DB", -- Custom field to identify this as a DB that needs quote wrapping custom = { is_global_db = true }, }) end end if #items > 0 then local wrapped_callback = vim.schedule_wrap(function(response) callback({ items = response.items, is_incomplete_backward = false, is_incomplete_forward = false, }) end) wrapped_callback({ items = items }) return end end end local scl_vars = require("tia.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("tia.udt_parser") local db_parser = require("tia.db_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 Global DB names (without quotes - will be wrapped in resolve) local db_parser = require("tia.db_parser") if db_parser.get_cache_count() == 0 then local ws = require("tia.workspace_types") ws.scan_project_dbs(bufnr) end -- Check if cursor is after a quote (user typed " first) local line_before_cursor = line:sub(1, col) local is_after_quote = line_before_cursor:match('"%s*$') ~= nil for _, name in ipairs(db_parser.get_all_db_names()) do -- If user already typed opening quote, don't add quotes. Otherwise wrap in quotes. local insert_name = is_after_quote and name or '"' .. name .. '"' table.insert(items, { label = name, -- No quotes in label insertText = insert_name, kind = vim.lsp.protocol.CompletionItemKind.Struct, detail = "Global DB", }) end -- Add basic types - expanded list local basic_types = { -- Elementary types "BOOL", "BYTE", "WORD", "DWORD", "LWORD", "CHAR", "WCHAR", "STRING", "WSTRING", "INT", "DINT", "LINT", "USINT", "SINT", "UINT", "UDINT", "ULINT", "REAL", "LREAL", "TIME", "DATE", "TOD", "TIME_OF_DAY", "DATE_AND_TIME", "DT", "S5TIME", "LTIME", "DTL", -- Timer and counter types "IEC_TIMER", "TON_TIME", "TOF_TIME", "TONR_TIME", "TP_TIME", "IEC_TON", "IEC_TOF", "IEC_TP", "IEC_COUNTER", "CTU", "CTD", "CTUD", } for _, t in ipairs(basic_types) do table.insert(items, { label = t, kind = 19 }) end -- Add built-in function blocks local builtin_fbs = { -- Timers and counters "TON", "TOF", "TP", "TONR", "CTU", "CTD", "CTUD", "IEC_TIMER", "IEC_TON", "IEC_TOF", "IEC_TP", "IEC_COUNTER", "IEC_CTU", "IEC_CTD", "IEC_CTUD", -- Bit logic "R_TRIG", "F_TRIG", "SR", "RS", -- Other "SCHEDULE", "BPM", "LOG", "DATALOG", } for _, fb in ipairs(builtin_fbs) do table.insert(items, { label = fb, kind = 6, detail = "Function block" }) end -- Add built-in functions local builtin_fcs = { -- Math "ADD", "SUB", "MUL", "DIV", "MOD", "ABS", "NEG", "SQR", "SQRT", "EXP", "LN", "LOG", "SIN", "COS", "TAN", "ASIN", "ACOS", "ATAN", "MIN", "MAX", "LIMIT", "CALCULATE", -- Conversion "CONVERT", "ROUND", "TRUNC", "CEIL", "FLOOR", "SCALE_X", "NORM_X", "BCD_I", "I_BCD", "DI_BCD", "DI_R", "I_DI", "R_DI", "R_DB", -- Move/String "MOVE", "FILL", "SWAP", "PEEK", "POKE", "LEN", "CONCAT", "LEFT", "RIGHT", "MID", "INSERT", "DELETE", "REPLACE", "FIND", -- Bit logic "AND", "OR", "XOR", "NOT", "SHL", "SHR", "ROL", "ROR", "DECO", "ENCO", -- Time/Date "T_CONV", "T_ADD", "T_SUB", "T_DIFF", "T_COMBINE", "DT_DATE", "DT_TOD", "DATE_TO_DTL", "DTL_TO_DATE", "TOD_TO_DTL", "DTL_TO_TOD", "TimeToTicks", "TicksToTime", -- Error handling "GET_ERROR", "GET_ERR_ID", "RESET_ERR", "GET_DIAG", "DPRINT", -- Communication "RDREC", "WRREC", "RALRM", "TSEND", "TRCV", "TCON", "TDISCON", "TSEND_C", "TRCV_C", "TUSEND", "TURCV", "TCONNECT", "TDISCONNECT", "MODBUSPN", "MODBUSCP", } for _, fc in ipairs(builtin_fcs) do table.insert(items, { label = fc, kind = 3, detail = "Function" }) 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 -- Add Global DB names (without quotes - will be wrapped in resolve) local line_before_cursor_fallback = line:sub(1, col) local is_after_quote_fallback = line_before_cursor_fallback:match('"%s*$') ~= nil for _, name in ipairs(db_parser.get_all_db_names()) do local insert_name = is_after_quote_fallback and name or '"' .. name .. '"' table.insert(items, { label = name, insertText = insert_name, kind = vim.lsp.protocol.CompletionItemKind.Struct, detail = "Global DB", }) 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 -- Add more basic types local more_types = { "BOOL", "DINT", "WORD", "DWORD", "LWORD", "CHAR", "WSTRING", "UDINT", "LINT", "ULINT", "USINT", "SINT", "DATE", "TOD", "DTL", "LTIME", "S5TIME", "TIME_OF_DAY", "DATE_AND_TIME" } for _, t in ipairs(more_types) do table.insert(items, { label = t, kind = 19 }) end -- Add built-in FBs local builtin_fbs = { "TON", "TOF", "TP", "TONR", "CTU", "CTD", "CTUD", "IEC_TIMER", "IEC_TON", "IEC_TOF", "IEC_TP", "IEC_COUNTER", "IEC_CTU", "IEC_CTD", "IEC_CTUD", "R_TRIG", "F_TRIG", "SR", "RS", "SCHEDULE", "BPM", "LOG", "DATALOG", } for _, fb in ipairs(builtin_fbs) do table.insert(items, { label = fb, kind = 6, detail = "Function block" }) end -- Add built-in functions local builtin_fcs = { "ADD", "SUB", "MUL", "DIV", "MOD", "ABS", "NEG", "SQR", "SQRT", "EXP", "LN", "LOG", "SIN", "COS", "TAN", "ASIN", "ACOS", "ATAN", "MIN", "MAX", "LIMIT", "CALCULATE", "CONVERT", "ROUND", "TRUNC", "CEIL", "FLOOR", "SCALE_X", "NORM_X", "BCD_I", "I_BCD", "DI_BCD", "DI_R", "I_DI", "R_DI", "R_DB", "MOVE", "FILL", "SWAP", "PEEK", "POKE", "LEN", "CONCAT", "LEFT", "RIGHT", "MID", "INSERT", "DELETE", "REPLACE", "FIND", "AND", "OR", "XOR", "NOT", "SHL", "SHR", "ROL", "ROR", "DECO", "ENCO", "T_CONV", "T_ADD", "T_SUB", "T_DIFF", "T_COMBINE", "DT_DATE", "DT_TOD", "DATE_TO_DTL", "DTL_TO_DATE", "TOD_TO_DTL", "DTL_TO_TOD", "GET_ERROR", "GET_ERR_ID", "RESET_ERR", "GET_DIAG", "DPRINT", "RDREC", "WRREC", "RALRM", "TSEND", "TRCV", "TCON", "TDISCON", "TSEND_C", "TRCV_C", "TUSEND", "TURCV", "MODBUSPN", "MODBUSCP", } for _, fc in ipairs(builtin_fcs) do table.insert(items, { label = fc, kind = 3, detail = "Function" }) 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 -- Check built-in instructions (IEC_TIMER, TON, etc.) local builtin = require("tia.builtin_instructions") local builtin_params = builtin.get_parameters(var_type) if builtin_params then local items = {} for _, inp in ipairs(builtin_params.inputs or {}) do table.insert(items, { label = inp.name, kind = vim.lsp.protocol.CompletionItemKind.Field, detail = (inp.type or "ANY") .. " (Input)", }) end for _, out in ipairs(builtin_params.outputs or {}) do table.insert(items, { label = out.name, kind = vim.lsp.protocol.CompletionItemKind.Field, detail = (out.type or "ANY") .. " (Output)", }) end wrapped_callback({ items = items }) return end 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