Files
tia-lsp/src/main.lua
T
lazar 37f8d475f0 docs: encourage adding comments; fix nested IF/ELSIF indentation
- Update AGENTS.md to encourage comments instead of forbidding them
- Add docstrings to all public functions in src/ modules
- Add module headers and function comments to lua/scl/ modules
- Fix formatter to properly indent multi-line IF/ELSIF conditions
- Fix FB call detection to not match IF statements with parentheses
2026-02-23 17:40:44 +01:00

1737 lines
48 KiB
Lua

#!/usr/bin/env lua
-- SCL Language Server
-- Language Server Protocol implementation for Siemens SCL
local script_path = debug.getinfo(1, "S").source:gsub("^@", ""):match("(.*/)") or ""
if script_path == "" then
script_path = "."
end
local cwd = io.popen("pwd"):read("*l")
if script_path:sub(1, 1) == "/" then
script_path = script_path:gsub("/+$", "")
elseif cwd then
script_path = cwd .. "/" .. script_path
script_path = script_path:gsub("/+", "/"):gsub("/$", "")
else
script_path = "."
end
package.path = package.path .. ";" .. script_path .. "/?.lua"
local json = dofile(script_path .. "/json.lua")
local parser = dofile(script_path .. "/parser.lua")
local treesitter = dofile(script_path .. "/treesitter.lua")
local plc_json = dofile(script_path .. "/plc_json.lua")
local diagnostics = dofile(script_path .. "/diagnostics.lua")
local formatter = dofile(script_path .. "/formatter.lua")
local builtin = dofile(script_path .. "/builtin_instructions.lua")
local documents = {}
local semanticTokenModifiers = {}
local semanticTokenTypes = {
"namespace",
"type",
"class",
"enum",
"interface",
"struct",
"typeParameter",
"parameter",
"variable",
"property",
"enumMember",
"event",
"func",
"method",
"macro",
"keyword",
"modifier",
"comment",
"string",
"number",
"operator",
}
local capabilities = {
hoverProvider = true,
completionProvider = {
triggerCharacters = { ".", "#", "(", " ", ":", ";", "\n" },
resolveProvider = false,
},
definitionProvider = true,
referencesProvider = true,
declarationProvider = true,
documentSymbolProvider = true,
diagnosticProvider = { relatedDocuments = {} },
textDocumentSync = 1,
semanticTokensProvider = {
full = false, -- Disabled: was causing "Invalid line: out of range" errors
legend = { tokenTypes = semanticTokenTypes, tokenModifiers = semanticTokenModifiers },
range = false,
},
inlayHintProvider = { resolveProvider = false },
workspaceSymbolProvider = true,
documentFormattingProvider = true,
documentRangeFormattingProvider = false,
}
local function uri_to_path(uri)
return uri:gsub("^file://", ""):gsub("%%20", " ")
end
local function path_to_uri(path)
return "file://" .. path:gsub(" ", "%%20")
end
local function run_command(cmd)
local handle = io.popen(cmd)
if not handle then
return nil
end
local result = handle:read("*a")
handle:close()
return result
end
local function scan_scl_files(root_dir)
local files = {}
if not root_dir or root_dir == "" then
return files
end
local result = run_command('find "' .. root_dir .. '" -type f -name "*.scl" 2>/dev/null')
if result then
for f in result:gmatch("[^\n]+") do
if f and f ~= "" then
table.insert(files, f)
end
end
end
return files
end
local function parse_scl_file(filepath)
local handle = io.open(filepath, "r")
if not handle then
return nil, nil, nil
end
local content = handle:read("*a")
handle:close()
local variables = parser.extract_variables(content)
local functions = parser.extract_functions(content)
local types = parser.extract_types(content)
return variables, functions, types
end
local workspace_symbol_cache = {}
local function clear_symbol_cache()
workspace_symbol_cache = {}
end
local function fuzzy_match(text, pattern)
if not text or not pattern then
return false
end
text = text:lower()
pattern = pattern:lower()
local text_len = #text
local pattern_len = #pattern
local pattern_idx = 1
for i = 1, text_len do
if pattern_idx <= pattern_len and text:sub(i, i) == pattern:sub(pattern_idx, pattern_idx) then
pattern_idx = pattern_idx + 1
end
end
return pattern_idx > pattern_len
end
local handlers = {}
--- Handle LSP initialize request
--- @param params table Initialization parameters from client
--- @return table Server capabilities and info
function handlers.initialize(params)
return {
capabilities = capabilities,
serverInfo = { name = "scl-language-server", version = "1.0.0" },
}
end
--- Handle initialized notification (no response needed)
--- @param params table Parameters (usually empty)
function handlers.initialized(params) end
--- Handle shutdown request
--- @param params table Parameters (usually empty)
--- @return nil
function handlers.shutdown(params)
return nil
end
local project_markers = {
"Program blocks",
"PLC data types",
"PLC tags",
"program blocks",
"plc data types",
"plc tags",
".git",
}
local function find_project_root(start_path)
if not start_path or start_path == "" then
return nil
end
local current = start_path
local depth = 0
while current and current ~= "/" and current ~= "" and depth < 20 do
local handle = io.popen('ls -a "' .. current .. '" 2>/dev/null')
if handle then
local entries = {}
for line in handle:lines() do
table.insert(entries, line)
end
handle:close()
local found_marker = false
for _, marker in ipairs(project_markers) do
for _, entry in ipairs(entries) do
if entry == marker then
return current
end
end
end
end
current = current:match("(.*)/") or ""
depth = depth + 1
end
return nil
end
--- Handle exit notification - terminates the server
--- @param params table Parameters (usually empty)
function handlers.exit(params)
os.exit(0)
end
--- Handle textDocument/didOpen notification
--- Parses document content, extracts symbols, and stores in documents cache
--- @param params table Contains textDocument with uri, text, version
function handlers.textDocument_didOpen(params)
local uri = params.textDocument.uri
local content = params.textDocument.text
local path = uri_to_path(uri)
local file_dir = path:match("(.*/)") or "."
local root_dir = find_project_root(file_dir) or file_dir
local variables, var_positions = parser.extract_variables(content)
local functions = parser.extract_functions(content)
local types = parser.extract_types(content)
local external_types = plc_json.get_types(root_dir)
for name, ext_type in pairs(external_types) do
if not types[name] then
types[name] = ext_type
end
end
local tree = nil
local ts_ok = treesitter.init()
if ts_ok then
tree, _ = treesitter.parse_scl(content)
end
documents[uri] = {
content = content,
version = params.textDocument.version,
path = path,
root_dir = root_dir,
variables = variables,
variable_positions = var_positions,
functions = functions,
types = types,
tree = tree,
}
end
--- Handle textDocument/didChange notification
--- Re-parses document content after edits
--- @param params table Contains textDocument with uri and contentChanges array
function handlers.textDocument_didChange(params)
local uri = params.textDocument.uri
local content = params.contentChanges[1].text
local variables, var_positions = parser.extract_variables(content)
local functions = parser.extract_functions(content)
local types = parser.extract_types(content)
local doc = documents[uri]
local root_dir = doc and doc.root_dir or ""
local external_types = plc_json.get_types(root_dir)
for name, ext_type in pairs(external_types) do
if not types[name] then
types[name] = ext_type
end
end
local tree = nil
local ts_ok = treesitter.init()
if ts_ok then
tree, _ = treesitter.parse_scl(content)
end
documents[uri] = {
content = content,
version = params.textDocument.version,
path = doc and doc.path or "",
root_dir = root_dir,
variables = variables,
variable_positions = var_positions,
functions = functions,
types = types,
tree = tree,
}
clear_symbol_cache()
end
--- Handle textDocument/didClose notification
--- Removes document from cache
--- @param params table Contains textDocument with uri
function handlers.textDocument_didClose(params)
documents[params.textDocument.uri] = nil
clear_symbol_cache()
end
--- Handle textDocument/hover request
--- Provides hover information for variables, types, and built-in instructions
--- @param params table Contains textDocument with uri and position
--- @return table|nil Hover result with contents and range, or nil
function handlers.textDocument_hover(params)
local uri = params.textDocument.uri
local doc = documents[uri]
if not doc then
return nil
end
local lines = {}
for line in doc.content:gmatch("([^\n]*)\n") do
table.insert(lines, line)
end
if #lines == 0 then
return nil
end
local line_idx = params.position.line + 1
if line_idx > #lines then
return nil
end
local line = lines[line_idx]
local col = params.position.character + 1
-- Helper to resolve member path and get member info with comments
local function resolve_member_path(root_type, member_path, types, external_types_lookup)
if not root_type or not member_path or member_path == "" then
return nil, nil
end
local parts = {}
for part in member_path:gmatch("[^.]+") do
table.insert(parts, part)
end
if #parts == 0 then
return nil, nil
end
local current_type = root_type:gsub('"', '')
local current_member = nil
for i, part in ipairs(parts) do
-- Get type info from combined sources
local type_info = types[current_type]
-- Also check external types if not found
if not type_info and external_types_lookup then
type_info = external_types_lookup[current_type]
end
-- Try built-in types as fallback
if not type_info then
local builtin_params = builtin.get_parameters(current_type)
if builtin_params then
type_info = { fields = builtin_params.inputs or {}, inputs = builtin_params.inputs or {}, outputs = builtin_params.outputs or {} }
end
end
if not type_info then
return nil, current_type
end
local found = false
local fields = type_info.fields or type_info.inputs or {}
for _, field in ipairs(fields) do
if field.name == part then
current_member = field
if field.type then
current_type = field.type:gsub('"', '')
end
found = true
break
end
end
if not found then
return nil, current_type
end
end
return current_member, current_type
end
-- Check if we're in a member access chain (e.g., #var.member1.member2)
local function find_member_access_chain()
local chain_start = col
while chain_start > 1 do
local ch = line:sub(chain_start - 1, chain_start - 1)
if ch:match("[%w_.#]") then
chain_start = chain_start - 1
else
break
end
end
local chain_end = col
while chain_end <= #line do
local ch = line:sub(chain_end, chain_end)
if ch:match("[%w_.]") then
chain_end = chain_end + 1
else
break
end
end
local chain = line:sub(chain_start, chain_end - 1)
-- Check if this is a member access (contains .)
if not chain:match("%.") then
return nil, nil
end
-- Parse the chain: #var.member1.member2 or var.member1.member2
local root_var = nil
local member_path = nil
-- Try to find #var pattern anywhere in the chain
root_var = chain:match("#([%w_]+)")
if root_var then
-- Find everything after the first #var.
member_path = chain:match("#" .. root_var .. "%.(.+)$")
end
-- If no # found, try global DB access (starts with identifier)
if not root_var then
root_var = chain:match("^([%w_]+)%.")
if root_var then
member_path = chain:match("%.(.+)$")
end
end
if root_var and member_path then
return root_var, member_path
end
return nil, nil
end
-- Try member access first
local root_var, member_path = find_member_access_chain()
-- If we have a member access chain, try to resolve it
if root_var and member_path then
-- Check if it's a local variable (with #) or global
local var_info = nil
if doc.variables and doc.variables[root_var] then
var_info = doc.variables[root_var]
end
if var_info and var_info.data_type then
local root_type = var_info.data_type:gsub('"', '')
local all_types = {}
-- Combine all type sources
if doc.types then
for k, v in pairs(doc.types) do
all_types[k] = v
end
end
local external_types = plc_json.get_types(doc.root_dir or "")
local member, failed_at = resolve_member_path(root_type, member_path, all_types, external_types)
if member then
local detail = string.format("**Member**: `%s`\nType: `%s`",
member.name, member.type or "unknown")
if member.comment and member.comment ~= "" then
detail = detail .. "\n\n" .. member.comment
end
detail = detail .. string.format("\n\n*From: `#%s.%s`*", root_var, member_path)
-- Find the actual word range for highlighting
local member_word_start = col
while member_word_start > 1 and line:sub(member_word_start - 1, member_word_start - 1):match("[%w_]") do
member_word_start = member_word_start - 1
end
local member_word_end = col
while member_word_end <= #line and line:sub(member_word_end, member_word_end):match("[%w_]") do
member_word_end = member_word_end + 1
end
return {
contents = { kind = "markdown", value = detail },
range = {
start = { line = params.position.line, character = member_word_start - 1 },
["end"] = { line = params.position.line, character = member_word_end - 1 },
},
}
elseif failed_at then
-- Type resolution failed - show what we know
local detail = string.format("**Unresolved Member**\nVariable: `#%s`\nType: `%s`\nPath: `%s`\n\n*Type not found: `%s`*",
root_var, root_type, member_path, failed_at)
return {
contents = { kind = "markdown", value = detail },
}
end
else
-- Variable not found
local detail = string.format("**Unknown Variable**: `#%s`\n\nVariable not declared in scope.", root_var)
return {
contents = { kind = "markdown", value = detail },
}
end
end
local word_start = col
while word_start > 1 and line:sub(word_start - 1, word_start - 1):match("[%w_]") do
word_start = word_start - 1
end
local word_end = col
while word_end <= #line and line:sub(word_end, word_end):match("[%w_]") do
word_end = word_end + 1
end
local word = line:sub(word_start, word_end - 1)
local var_name = word:gsub("^#", "")
if var_name ~= "" and doc.variables and doc.variables[var_name] then
local var_info = doc.variables[var_name]
local detail = string.format(
"**Local Variable** `#%s`\nScope: `%s`\nType: `%s`\nDeclared at line %d",
var_name,
var_info.type,
var_info.data_type or "unknown",
var_info.line + 1
)
if var_info.comment and var_info.comment ~= "" then
detail = detail .. "\n\n" .. var_info.comment
end
if var_info.data_type then
local builtin_params = builtin.get_parameters(var_info.data_type)
if builtin_params then
detail = detail .. "\n\n**Members:**\n"
if builtin_params.inputs and #builtin_params.inputs > 0 then
detail = detail .. "*Inputs:*\n"
for _, inp in ipairs(builtin_params.inputs) do
detail = detail .. string.format(" - `%s`: %s\n", inp.name, inp.type or "ANY")
end
end
if builtin_params.outputs and #builtin_params.outputs > 0 then
detail = detail .. "*Outputs:*\n"
for _, out in ipairs(builtin_params.outputs) do
detail = detail .. string.format(" - `%s`: %s\n", out.name, out.type or "ANY")
end
end
end
end
return {
contents = { kind = "markdown", value = detail },
range = {
start = { line = params.position.line, character = word_start - 1 },
["end"] = { line = params.position.line, character = word_end - 1 },
},
}
end
if doc.types then
for type_name, type_info in pairs(doc.types) do
if type_name == word then
local detail = string.format("**Type**: `%s`\nKind: %s", type_name, type_info.kind)
if type_info.comment and type_info.comment ~= "" then
detail = detail .. "\n\n" .. type_info.comment
end
if type_info.kind == "struct" and type_info.fields then
detail = detail .. "\n\n**Fields:**\n"
for _, field in ipairs(type_info.fields) do
if field.comment and field.comment ~= "" then
detail = detail .. string.format("- `%s`: %s // %s\n", field.name, field.type, field.comment)
else
detail = detail .. string.format("- `%s`: %s\n", field.name, field.type)
end
end
end
return {
contents = { kind = "markdown", value = detail },
range = {
start = { line = params.position.line, character = word_start - 1 },
["end"] = { line = params.position.line, character = word_end - 1 },
},
}
end
end
end
if builtin.is_known_function_block(word) or builtin.is_known_function(word) then
local params_info = builtin.get_parameters(word)
local kind = builtin.is_known_function_block(word) and "Function Block" or "Function"
local detail = string.format("**%s**: `%s`\n", kind, word)
if params_info then
detail = detail .. "\n**Parameters:**\n"
if params_info.inputs and #params_info.inputs > 0 then
detail = detail .. "*Inputs:*\n"
for _, inp in ipairs(params_info.inputs) do
detail = detail .. string.format(" - `%s`: %s\n", inp.name, inp.type or "ANY")
end
end
if params_info.outputs and #params_info.outputs > 0 then
detail = detail .. "*Outputs:*\n"
for _, out in ipairs(params_info.outputs) do
detail = detail .. string.format(" - `%s`: %s\n", out.name, out.type or "ANY")
end
end
else
detail = detail .. "\n_(No parameter info available)_"
end
return {
contents = { kind = "markdown", value = detail },
range = {
start = { line = params.position.line, character = word_start - 1 },
["end"] = { line = params.position.line, character = word_end - 1 },
},
}
end
local fb_param_info = nil
local function find_fb_call_start(lines, current_line_idx)
for i = current_line_idx, 1, -1 do
local l = lines[i]
local fb_name = l:match("#([%w_]+)%s*%(") or l:match("([%w_]+)%s*%(")
if fb_name and doc.variables and doc.variables[fb_name] then
return fb_name, i
end
if l:match("%)%s*;%s*$") then
break
end
end
return nil, nil
end
local fb_name_on_line, _ = find_fb_call_start(lines, line_idx)
if fb_name_on_line then
local fb_var = doc.variables[fb_name_on_line]
if fb_var then
local fb_type = fb_var.data_type
if fb_type then
local clean_type = fb_type:gsub('"', ""):gsub("#", "")
local params_info = builtin.get_parameters(clean_type)
if not params_info then
local workspace_types = plc_json.get_types(doc.root_dir)
if workspace_types and workspace_types[clean_type] then
local ws_type = workspace_types[clean_type]
if ws_type.kind == "function_block" or ws_type.kind == "function" then
local fields = {}
for _, f in ipairs(ws_type.inputs or {}) do
table.insert(fields, { name = f.name, type = f.type, comment = f.comment })
end
params_info = { inputs = fields, outputs = ws_type.outputs or {} }
elseif ws_type.fields then
local fields = {}
for _, f in ipairs(ws_type.fields) do
table.insert(fields, { name = f.name, type = f.type, comment = f.comment })
end
params_info = { inputs = fields, outputs = {} }
end
end
end
if not params_info and doc.types and doc.types[clean_type] and doc.types[clean_type].fields then
local fields = {}
for _, f in ipairs(doc.types[clean_type].fields) do
table.insert(fields, { name = f.name, type = f.type, comment = f.comment })
end
params_info = { inputs = fields, outputs = {} }
end
if params_info then
for _, inp in ipairs(params_info.inputs or {}) do
if inp.name:upper() == word:upper() then
fb_param_info = { name = inp.name, type = inp.type, direction = "input", comment = inp.comment }
break
end
end
if not fb_param_info then
for _, out in ipairs(params_info.outputs or {}) do
if out.name:upper() == word:upper() then
fb_param_info = { name = out.name, type = out.type, direction = "output", comment = out.comment }
break
end
end
end
end
end
end
end
if fb_param_info then
local detail = string.format("**Parameter**: `%s`\nType: `%s`\nDirection: %s",
fb_param_info.name, fb_param_info.type or "ANY", fb_param_info.direction)
if fb_param_info.comment and fb_param_info.comment ~= "" then
detail = detail .. "\n\n" .. fb_param_info.comment
end
return {
contents = { kind = "markdown", value = detail },
range = {
start = { line = params.position.line, character = word_start - 1 },
["end"] = { line = params.position.line, character = word_end - 1 },
},
}
end
return nil
end
--- Handle textDocument/completion request
--- Provides completion items based on context (VAR section, member access, etc.)
--- @param params table Contains textDocument with uri and position
--- @return table Completion list with items array
function handlers.textDocument_completion(params)
local uri = params.textDocument.uri
local doc = documents[uri]
if not doc then
return { items = {} }
end
local items = {}
local lines = {}
for line in doc.content:gmatch("([^\n]*)\n") do
table.insert(lines, line)
end
local line_idx = params.position.line + 1
if line_idx <= #lines then
local line = lines[line_idx]
local col = params.position.character
local trigger = col > 0 and line:sub(col, col) or ""
local line_before = col > 1 and line:sub(1, col - 1) or ""
-- Get word prefix being typed (for filtering)
local prefix_start = col - 1
while prefix_start > 0 and line:sub(prefix_start, prefix_start):match("[%w_]") do
prefix_start = prefix_start - 1
end
local word_prefix = line:sub(prefix_start + 1, col - 1)
-- Check if we're in VAR section
local in_var_section = false
for i = line_idx - 1, 1, -1 do
local prev_line = lines[i]:gsub("^%s+", ""):gsub("%s+$", "")
if prev_line:match("^END_VAR") or prev_line:match("^VAR_TEMP") or prev_line:match("^VAR_IN_OUT") or prev_line:match("^VAR_OUTPUT") or prev_line:match("^VAR_INPUT") then
break
end
if prev_line:match("^VAR%s*$") or prev_line:match("^VAR$") then
in_var_section = true
break
end
end
if trigger == "#" then
if doc.variables then
for name, info in pairs(doc.variables) do
table.insert(items, {
label = "#" .. name,
kind = 13,
detail = "Local variable (" .. (info.type or "unknown") .. ")",
insertText = "#" .. name,
insertTextFormat = 1,
})
end
end
elseif trigger == "." then
local prefix_start = col - 1
while prefix_start > 0 and line:sub(prefix_start, prefix_start):match("[%w_]") do
prefix_start = prefix_start - 1
end
local prefix = line:sub(prefix_start + 1, col - 1)
if prefix ~= "" then
local var_name = prefix:gsub("^#", "")
local var_info = doc.variables and doc.variables[var_name]
local data_type = var_info and var_info.data_type
if data_type then
local struct_name = data_type
if doc.types and doc.types[struct_name] and doc.types[struct_name].fields then
for _, field in ipairs(doc.types[struct_name].fields) do
table.insert(items, {
label = field.name,
kind = 13,
detail = struct_name .. "." .. field.name .. ": " .. field.type,
insertText = field.name,
insertTextFormat = 1,
})
end
else
local builtin_params = builtin.get_parameters(struct_name)
if builtin_params then
for _, inp in ipairs(builtin_params.inputs or {}) do
table.insert(items, {
label = inp.name,
kind = 13,
detail = struct_name .. "." .. inp.name .. ": " .. (inp.type or "ANY"),
insertText = inp.name,
insertTextFormat = 1,
})
end
for _, out in ipairs(builtin_params.outputs or {}) do
table.insert(items, {
label = out.name,
kind = 13,
detail = struct_name .. "." .. out.name .. ": " .. (out.type or "ANY"),
insertText = out.name,
insertTextFormat = 1,
})
end
end
end
end
end
if #items == 0 then
local keywords = {
{ label = "AND", detail = "Logical AND", kind = 14 },
{ label = "OR", detail = "Logical OR", kind = 14 },
{ label = "XOR", detail = "Logical XOR", kind = 14 },
{ label = "NOT", detail = "Logical NOT", kind = 14 },
{ label = "MOD", detail = "Modulo", kind = 14 },
}
for _, kw in ipairs(keywords) do
table.insert(items, kw)
end
end
elseif trigger == "(" then
if doc.functions then
for _, func in ipairs(doc.functions) do
table.insert(items, {
label = func.name,
kind = 3,
detail = func.kind:upper(),
insertText = func.name .. "()",
insertTextFormat = 2,
})
end
end
else
-- Check context before cursor
local is_var_decl = line_before:match(":%s*$")
-- Helper to add item with prefix filtering
local function add_item(name, kind_val, detail_text)
if word_prefix == "" or name:upper():find("^" .. word_prefix:upper()) then
table.insert(items, {
label = name,
kind = kind_val,
detail = detail_text,
insertText = name,
insertTextFormat = 1,
})
end
end
if in_var_section or is_var_decl then
-- In VAR section: show data types and function blocks
for type_name, _ in pairs(builtin.DATA_TYPES) do
add_item(type_name, 7, "Data type")
end
for fb_name, _ in pairs(builtin.FUNCTION_BLOCKS) do
add_item(fb_name, 6, "Function block")
end
-- Also add workspace types if available
if doc.types then
for type_name, type_info in pairs(doc.types) do
add_item(type_name, 7, type_info.kind or "Type")
end
end
else
-- General context: show all
for type_name, _ in pairs(builtin.DATA_TYPES) do
add_item(type_name, 7, "Data type")
end
for fb_name, _ in pairs(builtin.FUNCTION_BLOCKS) do
add_item(fb_name, 6, "Function block")
end
for func_name, _ in pairs(builtin.FUNCTIONS) do
add_item(func_name, 3, "Function")
end
end
end
end
return { isIncomplete = false, items = items }
end
--- Handle textDocument/definition request
--- Finds definition location for symbol at position
--- @param params table Contains textDocument with uri and position
--- @return table|nil Location with uri and range, or nil
function handlers.textDocument_definition(params)
local uri = params.textDocument.uri
local doc = documents[uri]
if not doc then
return nil
end
local lines = {}
for line in doc.content:gmatch("([^\n]*)\n") do
table.insert(lines, line)
end
if params.position.line + 1 > #lines then
return nil
end
local line = lines[params.position.line + 1]
local col = params.position.character + 1
local word_start = col
while word_start > 1 and line:sub(word_start - 1, word_start - 1):match("[%w_]") do
word_start = word_start - 1
end
local word_end = col
while word_end <= #line and line:sub(word_end, word_end):match("[%w_]") do
word_end = word_end + 1
end
local word = line:sub(word_start, word_end - 1):gsub("^#", "")
if doc.variable_positions and doc.variable_positions[word] then
local pos = doc.variable_positions[word]
return {
uri = uri,
range = {
start = { line = pos.line, character = pos.character },
["end"] = { line = pos.line, character = pos.character + #word },
},
}
end
return nil
end
--- Handle textDocument/declaration request
--- Finds declaration locations for symbol at position across workspace
--- @param params table Contains textDocument with uri and position
--- @return table|nil Array of locations, or nil
function handlers.textDocument_declaration(params)
local uri = params.textDocument.uri
local doc = documents[uri]
if not doc then
return nil
end
local lines = {}
for line in doc.content:gmatch("([^\n]*)\n") do
table.insert(lines, line)
end
if params.position.line + 1 > #lines then
return nil
end
local line = lines[params.position.line + 1]
local col = params.position.character + 1
local word_start = col
while word_start > 1 and line:sub(word_start - 1, word_start - 1):match("[%w_]") do
word_start = word_start - 1
end
local word_end = col
while word_end <= #line and line:sub(word_end, word_end):match("[%w_]") do
word_end = word_end + 1
end
local word = line:sub(word_start, word_end - 1):gsub("^#", "")
if not word or word == "" then
return nil
end
local function make_location(doc_uri, doc_line, doc_col, name_len)
name_len = name_len or 0
return {
uri = doc_uri,
range = {
start = { line = doc_line, character = doc_col },
["end"] = { line = doc_line, character = doc_col + name_len },
},
}
end
local locations = {}
local seen_locations = {}
local search_names = { word }
local function add_location(loc)
local key = loc.uri .. ":" .. tostring(loc.range.start.line) .. ":" .. tostring(loc.range.start.character)
if not seen_locations[key] then
seen_locations[key] = true
table.insert(locations, loc)
end
end
if doc.variables and doc.variables[word] then
local var_type = doc.variables[word].data_type
if var_type then
var_type = var_type:gsub("%s*;.*$", ""):gsub("%s*:=.*$", ""):gsub("^%s+", ""):gsub("%s+$", "")
local type_name = var_type:match('"([^"]+)"') or var_type:match("([%w_]+)")
if type_name and type_name ~= word then
table.insert(search_names, type_name)
end
end
end
for check_uri, check_doc in pairs(documents) do
if check_doc.functions then
for _, func in ipairs(check_doc.functions) do
for _, search_name in ipairs(search_names) do
if func.name == search_name then
add_location(make_location(check_uri, func.start, 0, #search_name))
end
end
end
end
end
local root_dirs = {}
for _, d in pairs(documents) do
if d.root_dir and d.root_dir ~= "" then
root_dirs[d.root_dir] = true
end
end
for root_dir, _ in pairs(root_dirs) do
local scl_files = scan_scl_files(root_dir)
for _, filepath in ipairs(scl_files) do
local cached = workspace_symbol_cache[":file:" .. filepath]
if not cached then
local variables, functions, types = parse_scl_file(filepath)
cached = { functions = functions, types = types }
workspace_symbol_cache[":file:" .. filepath] = cached
end
if cached.functions then
for _, func in ipairs(cached.functions) do
for _, search_name in ipairs(search_names) do
if func.name == search_name then
local file_uri = path_to_uri(filepath)
add_location(make_location(file_uri, func.start, 0, #search_name))
end
end
end
end
local handle = io.open(filepath, "r")
if handle then
local content = handle:read("*a")
handle:close()
for _, search_name in ipairs(search_names) do
local fb_pattern = 'FUNCTION_BLOCK%s+"' .. search_name .. '"'
local fb_plain = "^FUNCTION_BLOCK%s+" .. search_name .. "$"
if content:match(fb_pattern) or content:match(fb_plain) then
for line in content:gmatch("([^\n]*)\n") do
if line:match(fb_pattern) or line:match(fb_plain) then
local file_uri = path_to_uri(filepath)
add_location(make_location(file_uri, 0, 0, #search_name))
break
end
end
end
local db_pattern = 'DATA_BLOCK%s+"' .. search_name .. '"'
local db_plain = "^DATA_BLOCK%s+" .. search_name .. "$"
if content:match(db_pattern) or content:match(db_plain) then
for line in content:gmatch("([^\n]*)\n") do
if line:match(db_pattern) or line:match(db_plain) then
local file_uri = path_to_uri(filepath)
add_location(make_location(file_uri, 0, 0, #search_name))
break
end
end
end
end
end
if cached.types then
for type_name, type_info in pairs(cached.types) do
for _, search_name in ipairs(search_names) do
if type_name == search_name then
local file_uri = path_to_uri(filepath)
local start_line = type_info.start_line or 0
add_location(make_location(file_uri, start_line, 0, #search_name))
end
end
end
end
end
end
for root_dir, _ in pairs(root_dirs) do
local udt_result = run_command('find "' .. root_dir .. '" -type f -name "*.udt" 2>/dev/null')
if udt_result then
for filepath in udt_result:gmatch("[^\n]+") do
if filepath and filepath ~= "" then
local handle = io.open(filepath, "r")
if handle then
local file_content = handle:read("*a")
handle:close()
for _, search_name in ipairs(search_names) do
local type_line = file_content:match('TYPE%s+"' .. search_name .. '"')
if not type_line then
type_line = file_content:match("TYPE%s+" .. search_name)
end
if type_line then
local line_num = 1
for line in file_content:gmatch("([^\n]*)\n?") do
if line:match('TYPE%s+"' .. search_name .. '"') or line:match("TYPE%s+" .. search_name) then
local file_uri = path_to_uri(filepath)
add_location(make_location(file_uri, line_num - 1, 0, #search_name))
break
end
line_num = line_num + 1
end
end
end
end
end
end
end
local db_result = run_command('find "' .. root_dir .. '" -type f -name "*.db" 2>/dev/null')
if db_result then
for filepath in db_result:gmatch("[^\n]+") do
if filepath and filepath ~= "" then
local handle = io.open(filepath, "r")
if handle then
local file_content = handle:read("*a")
handle:close()
for _, search_name in ipairs(search_names) do
local db_line = file_content:match('DATA_BLOCK%s+"' .. search_name .. '"')
if not db_line then
db_line = file_content:match("DATA_BLOCK%s+" .. search_name)
end
if db_line then
local line_num = 1
for line in file_content:gmatch("([^\n]*)\n?") do
if line:match('DATA_BLOCK%s+"' .. search_name .. '"') or line:match("DATA_BLOCK%s+" .. search_name) then
local file_uri = path_to_uri(filepath)
add_location(make_location(file_uri, line_num - 1, 0, #search_name))
break
end
line_num = line_num + 1
end
end
end
end
end
end
end
end
if #locations > 0 then
return locations
end
return nil
end
--- Handle textDocument/references request
--- Finds all references to symbol at position in current document
--- @param params table Contains textDocument with uri and position
--- @return table|nil Array of reference locations, or nil
function handlers.textDocument_references(params)
local uri = params.textDocument.uri
local doc = documents[uri]
if not doc then
return nil
end
local lines = {}
for line in doc.content:gmatch("([^\n]*)\n") do
table.insert(lines, line)
end
if params.position.line + 1 > #lines then
return nil
end
local line = lines[params.position.line + 1]
local col = params.position.character + 1
local word_start = col
while word_start > 1 and line:sub(word_start - 1, word_start - 1):match("[%w_]") do
word_start = word_start - 1
end
local word_end = col
while word_end <= #line and line:sub(word_end, word_end):match("[%w_]") do
word_end = word_end + 1
end
local word = line:sub(word_start, word_end - 1):gsub("^#", "")
if not word or word == "" then
return nil
end
local references = {}
for line_num, line_content in ipairs(lines) do
local search_col = 1
while search_col <= #line_content do
local match_start, match_end = line_content:find("[%w_]+", search_col)
if not match_start then
break
end
local matched_word = line_content:sub(match_start, match_end)
if matched_word == word then
table.insert(references, {
uri = uri,
range = {
start = { line = line_num - 1, character = match_start - 1 },
["end"] = { line = line_num - 1, character = match_end },
},
})
end
search_col = match_end + 1
end
end
if #references > 0 then
return references
end
return nil
end
--- Handle textDocument/documentSymbol request
--- Returns all symbols in document for outline/navigation
--- @param params table Contains textDocument with uri
--- @return table Array of DocumentSymbol objects
function handlers.textDocument_documentSymbol(params)
local uri = params.textDocument.uri
local doc = documents[uri]
if not doc then
return {}
end
local symbols = {}
local kind_map = {
["function"] = 14,
["function_block"] = 11,
["organization_block"] = 25,
["struct"] = 23,
["alias"] = 7,
["array"] = 24,
}
if doc.functions then
for _, func in ipairs(doc.functions) do
table.insert(symbols, {
name = func.kind:upper() .. " " .. func.name,
kind = kind_map[func.kind] or 14,
range = { start = { line = func.start, character = 0 }, ["end"] = { line = func.final, character = 0 } },
selectionRange = {
start = { line = func.start, character = 0 },
["end"] = { line = func.start, character = #func.name },
},
containerName = nil,
})
end
end
if doc.variables then
for var_name, var_info in pairs(doc.variables) do
local var_kind = 13
if var_info.type and (var_info.type:find("CONSTANT") or var_info.type:find("RETAIN")) then
var_kind = 26
end
table.insert(symbols, {
name = "#" .. var_name,
kind = var_kind,
detail = var_info.type or "VAR",
range = {
start = { line = var_info.line, character = 0 },
["end"] = { line = var_info.line, character = #var_name },
},
selectionRange = {
start = { line = var_info.line, character = var_info.character },
["end"] = { line = var_info.line, character = var_info.character + #var_name },
},
containerName = nil,
})
end
end
if doc.types then
for type_name, type_info in pairs(doc.types) do
local kind = kind_map[type_info.kind] or 7
local detail = type_info.kind:upper()
if type_info.kind == "alias" and type_info.base_type then
detail = detail .. " (" .. type_info.base_type .. ")"
elseif type_info.kind == "struct" and type_info.fields and #type_info.fields > 0 then
detail = detail .. " (" .. #type_info.fields .. " fields)"
end
table.insert(symbols, {
name = "TYPE " .. type_name,
kind = kind,
detail = detail,
range = {
start = { line = type_info.start_line or 0, character = 0 },
["end"] = { line = type_info.start_line or 0, character = #type_name },
},
selectionRange = {
start = { line = type_info.start_line or 0, character = 0 },
["end"] = { line = type_info.start_line or 0, character = #type_name },
},
containerName = nil,
})
end
end
return symbols
end
--- Handle textDocument/diagnostic request
--- Validates document and returns diagnostics
--- @param params table Contains textDocument with uri
--- @return table Diagnostic report with items array
function handlers.textDocument_diagnostic(params)
local uri = params.textDocument.uri
local doc = documents[uri]
if not doc then
return { items = {} }
end
local external_types = {}
if doc.root_dir then
external_types = plc_json.get_types(doc.root_dir)
end
local diag_results = diagnostics.validate_diagnostics(doc.content, external_types, doc.root_dir)
return { items = diag_results }
end
--- Handle textDocument/semanticTokens/full request
--- Returns semantic tokens for syntax highlighting (currently disabled)
--- @param params table Contains textDocument with uri
--- @return table Semantic tokens with data array
function handlers.textDocument_semanticTokensFull(params)
local uri = params.textDocument.uri
local doc = documents[uri]
if not doc then
return { data = {} }
end
-- Protected token collection
local tokens = {}
local ok, err = pcall(function()
local lines = {}
for line in doc.content:gmatch("([^\n]*)\n") do
table.insert(lines, line)
end
local total_lines = #lines
local function add_token(line, startChar, length)
-- Validate all parameters
if not line or not startChar or not length then
return
end
if line < 0 or line >= total_lines then
return
end
if startChar < 0 or length <= 0 then
return
end
if startChar + length > 10000 then
return
end
local deltaLine = line
local deltaStart = startChar
if #tokens >= 4 then
local prevLine = tokens[#tokens - 3]
local prevStart = tokens[#tokens - 2]
if line == prevLine then
deltaLine = 0
deltaStart = startChar - prevStart
else
deltaLine = line - prevLine
end
-- Validate deltaStart is non-negative
if deltaStart < 0 then
return
end
end
table.insert(tokens, deltaLine)
table.insert(tokens, deltaStart)
table.insert(tokens, length)
table.insert(tokens, 0)
table.insert(tokens, 0)
end
for i, line in ipairs(lines) do
local lineNum = i - 1
local trimmed = line:gsub("^%s+", "")
if doc.variables then
for var_name, info in pairs(doc.variables) do
if info and info.line and info.line >= 0 and info.line < total_lines and info.line == lineNum then
local startChar = line:find(var_name, 1, true)
if startChar then
add_token(lineNum, startChar - 1, #var_name)
end
end
end
end
local keywords = {
"IF", "THEN", "ELSIF", "ELSE", "END_IF",
"FOR", "TO", "BY", "DO", "END_FOR",
"WHILE", "END_WHILE",
"REPEAT", "UNTIL", "END_REPEAT",
"CASE", "OF", "END_CASE",
"VAR_INPUT", "VAR_OUTPUT", "VAR_IN_OUT", "VAR_TEMP", "VAR", "END_VAR",
"BEGIN", "EXIT", "CONTINUE", "RETURN",
"TRUE", "FALSE",
}
for _, kw in ipairs(keywords) do
local startPos = line:find("%f[%a]" .. kw .. "%f[%A]")
if startPos then
add_token(lineNum, startPos - 1, #kw)
end
end
end
end)
-- If any error occurred, return empty tokens
if not ok then
return { data = {} }
end
return { data = tokens }
end
--- Handle textDocument/semanticTokens/range request
--- Returns semantic tokens for a range (delegates to full)
--- @param params table Contains textDocument with uri and range
--- @return table Semantic tokens with data array
function handlers.textDocument_semanticTokensRange(params)
local uri = params.textDocument.uri
local doc = documents[uri]
if not doc then
return { data = {} }
end
return handlers.textDocument_semanticTokensFull(params)
end
-- Aliases for method names with slashes converted to underscores
handlers["textDocument_semanticTokens_full"] = handlers.textDocument_semanticTokensFull
handlers["textDocument_semanticTokens_range"] = handlers.textDocument_semanticTokensRange
--- Handle textDocument/inlayHint request
--- Provides inlay hints for variable types in VAR sections
--- @param params table Contains textDocument with uri and range
--- @return table Array of InlayHint objects
function handlers.textDocument_inlayHint(params)
local uri = params.textDocument.uri
local doc = documents[uri]
if not doc then
return {}
end
local hints = {}
local lines = {}
for line in doc.content:gmatch("([^\n]*)\n") do
table.insert(lines, line)
end
local total_lines = #lines
for i, line in ipairs(lines) do
local lineNum = i - 1
if doc.variables then
for var_name, info in pairs(doc.variables) do
-- Validate line number is within bounds
if info.line and info.line >= 0 and info.line < total_lines and info.line == lineNum and info.data_type and info.data_type ~= "unknown" then
local colonPos = line:find(":", 1, true)
if colonPos then
table.insert(hints, {
position = { line = lineNum, character = #line },
label = ": " .. info.data_type,
kind = 1,
paddingLeft = true,
})
end
end
end
end
end
return hints
end
--- Handle workspace/symbol request
--- Searches workspace for symbols matching query
--- @param params table Contains query string and optional maxResults
--- @return table Array of SymbolInformation objects
function handlers.workspace_symbol(params)
local query = params.query or ""
local max_results = params.maxResults or 50
if query == "" then
return {}
end
local results = {}
local function add_result(name, kind, uri, line, col, length)
table.insert(results, {
name = name,
kind = kind,
location = {
uri = uri,
range = {
start = { line = line, character = col },
["end"] = { line = line, character = col + length },
},
},
})
end
for uri, doc in pairs(documents) do
if doc.functions then
for _, func in ipairs(doc.functions) do
if fuzzy_match(func.name, query) or fuzzy_match(func.kind, query) then
add_result(
func.kind:upper() .. " " .. func.name,
func.kind == "function" and 14 or (func.kind == "function_block" and 11 or 25),
uri,
func.start,
0,
#func.name
)
end
end
end
if doc.variables then
for var_name, var_info in pairs(doc.variables) do
if fuzzy_match(var_name, query) then
add_result("#" .. var_name, 13, uri, var_info.line, var_info.character, #var_name)
end
end
end
if doc.types then
for type_name, type_info in pairs(doc.types) do
if fuzzy_match(type_name, query) then
local kind = 7
if type_info.kind == "struct" then
kind = 23
elseif type_info.kind == "array" then
kind = 24
end
add_result("TYPE " .. type_name, kind, uri, type_info.start_line or 0, 0, #type_name)
end
end
end
end
local scanned_dirs = {}
for _, doc in pairs(documents) do
if doc.root_dir and doc.root_dir ~= "" then
scanned_dirs[doc.root_dir] = true
end
end
local function search_file(filepath)
local cached_file = filepath
if not workspace_symbol_cache[":file:" .. cached_file] then
local variables, functions, types = parse_scl_file(filepath)
workspace_symbol_cache[":file:" .. cached_file] = {
variables = variables,
functions = functions,
types = types,
}
end
local cached_data = workspace_symbol_cache[":file:" .. cached_file]
local file_uri = path_to_uri(filepath)
if cached_data.functions then
for _, func in ipairs(cached_data.functions) do
if fuzzy_match(func.name, query) or fuzzy_match(func.kind, query) then
add_result(
func.kind:upper() .. " " .. func.name,
func.kind == "function" and 14 or (func.kind == "function_block" and 11 or 25),
file_uri,
func.start,
0,
#func.name
)
end
end
end
if cached_data.variables then
for var_name, var_info in pairs(cached_data.variables) do
if fuzzy_match(var_name, query) then
add_result("#" .. var_name, 13, file_uri, var_info.line, var_info.character, #var_name)
end
end
end
if cached_data.types then
for type_name, type_info in pairs(cached_data.types) do
if fuzzy_match(type_name, query) then
local kind = 7
if type_info.kind == "struct" then
kind = 23
elseif type_info.kind == "array" then
kind = 24
end
add_result("TYPE " .. type_name, kind, file_uri, type_info.start_line or 0, 0, #type_name)
end
end
end
end
for root_dir, _ in pairs(scanned_dirs) do
local scl_files = scan_scl_files(root_dir)
for _, filepath in ipairs(scl_files) do
search_file(filepath)
end
end
local final_results = {}
for i = 1, math.min(#results, max_results) do
table.insert(final_results, results[i])
end
return final_results
end
--- Handle textDocument/formatting request
--- Formats entire document using SCL formatter
--- @param params table Contains textDocument with uri and options
--- @return table|nil Array of TextEdit objects, or nil
function handlers.textDocument_formatting(params)
local uri = params.textDocument.uri
local doc = documents[uri]
if not doc then
return nil
end
local options = params.options or {}
local format_options = {
insertSpaces = options.insertSpaces,
tabSize = options.tabSize,
indentSize = options.tabSize or 4,
}
return formatter.format_document(doc.content, format_options)
end
local function handle_message(message)
if message.method == "$/cancelRequest" then
return nil
end
-- Convert method name from "textDocument/didOpen" to "textDocument_didOpen"
local method_name = message.method:gsub("/", "_")
local handler = handlers[method_name]
if handler then
local ok, result = pcall(handler, message.params)
-- Only return response for requests (have id), not notifications
if message.id then
if ok then
return { id = message.id, result = result }
else
return { id = message.id, error = { code = -32603, message = tostring(result) } }
end
end
else
-- Only return error for requests (have id)
if message.id then
return { id = message.id, error = { code = -32601, message = "Method not found: " .. message.method } }
end
end
return nil
end
local function main()
local stdin = io.input()
local stdout = io.output()
local content_length = nil
while true do
local line = stdin:read("*l")
if not line then
break
end
-- Strip trailing \r (Windows line endings)
line = line:gsub("\r$", "")
if line:match("^Content%-Length:%s*(%d+)") then
content_length = tonumber(line:match("^Content%-Length:%s*(%d+)"))
elseif line == "" then
if content_length then
local body = stdin:read(content_length)
local message = json.decode(body)
if message.method == "exit" then
break
end
local response = handle_message(message)
if response then
local response_body = json.encode(response)
stdout:write("Content-Length: " .. #response_body .. "\r\n")
stdout:write("Content-Type: application/json\r\n\r\n")
stdout:write(response_body)
stdout:flush()
end
content_length = nil
end
end
end
end
main()