Files
tia-lsp.nvim/lua/tia/blink_cmp_source.lua
T
lazar 8c6a050efb refactor: rename scl_lsp → tia_lsp (project umbrella for future STL/LAD/FBD)
Rename the project from scl_lsp to tia_lsp to future-proof for additional
TIA Portal languages (STL/AWL, LAD, FBD). The 'scl' language name is kept
as-is for the SCL filetype and tree-sitter parser; future languages get
their own names (stl, lad, fbd).

Project-wide changes:
- lua/scl_lsp/ → lua/tia_lsp/  (plugin entry point)
- lua/scl/     → lua/tia/      (language modules, shared across TIA langs)
- require('scl.*')    → require('tia.*')
- require('scl_lsp')  → require('tia_lsp')
- LSP client name:    'scl_lsp' → 'tia_lsp'
- Diagnostic source:  'scl_lsp' → 'tia_lsp'  (src/diagnostics.lua)
- package.json name:  'scl-language-server' → 'tia-lsp'

lua/tia_lsp/init.lua:
- Add vim.fn.exepath('tia-lsp') detection so the plugin prefers the
  mason-installed executable and falls back to { 'lua', server_path }
  for manual installs.
- Add opts.ts_parser_url to configure the tree-sitter parser source.
- Remove hardcoded ~/dev/scl_lsp paths in favor of ~/dev/tia-lsp and
  the new ts_parser_url option.

Cleanup:
- Delete lua/tia/init.lua (dead code, unreferenced duplicate of tia_lsp).
- Delete scl_lsp.sh (replaced by the mason-installed bin/tia-lsp wrapper).
- Update README.md and AGENTS.md to reflect the two-repo split
  (tia-lsp server + tia-lsp.nvim plugin) and document Mason installation.

Unchanged (intentional):
- grammar.js, src/grammar.json, src/parser.c: tree-sitter language name
  remains 'scl' (it's the language, not the project).
- Filetype patterns ('scl', 'udt', 'db') in autocmds.
- :SCL* command names (per-filetype prefix; future :STL* etc.).

Tests pass with the same pre-existing failures as before the rename
(formatter VAR_INPUT test, udt_parser line 199 const-variable bug,
plc_json reference-project-missing). No new failures introduced.
2026-07-18 11:30:55 +02:00

1184 lines
41 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 (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_]+)"')
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
if found_member.is_udt then
-- This member is a UDT type, continue resolving
local udt_parser = require("tia.udt_parser")
current_members = udt_parser.get_udt_members(found_member.type)
current_type = found_member.type
else
-- This is a basic type, can't continue
if i < #path_parts then
-- There are more parts but this isn't a UDT
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
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
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
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