- Add member access chain resolution in hover handler - Parse #var.member.submember notation and resolve type chain - Show member comments in hover tooltip - Extract comments from UDT and DB file fields - Combine all type sources (doc.types, external_types, builtin)
1647 lines
44 KiB
Lua
1647 lines
44 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 = {}
|
|
|
|
function handlers.initialize(params)
|
|
return {
|
|
capabilities = capabilities,
|
|
serverInfo = { name = "scl-language-server", version = "1.0.0" },
|
|
}
|
|
end
|
|
|
|
function handlers.initialized(params) end
|
|
|
|
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
|
|
|
|
function handlers.exit(params)
|
|
os.exit(0)
|
|
end
|
|
|
|
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
|
|
|
|
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
|
|
|
|
function handlers.textDocument_didClose(params)
|
|
documents[params.textDocument.uri] = nil
|
|
clear_symbol_cache()
|
|
end
|
|
|
|
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)
|
|
if not root_type or not member_path or member_path == "" then
|
|
return nil
|
|
end
|
|
|
|
local parts = {}
|
|
for part in member_path:gmatch("[^.]+") do
|
|
table.insert(parts, part)
|
|
end
|
|
|
|
if #parts == 0 then
|
|
return nil
|
|
end
|
|
|
|
local current_type = root_type:gsub('"', '')
|
|
local current_member = nil
|
|
|
|
for i, part in ipairs(parts) do
|
|
local type_info = types and types[current_type]
|
|
if not type_info then
|
|
-- Try built-in types
|
|
local builtin_params = builtin.get_parameters(current_type)
|
|
if builtin_params then
|
|
local fields = builtin_params.inputs or {}
|
|
for _, field in ipairs(fields) do
|
|
if field.name == part then
|
|
current_member = field
|
|
current_type = field.type and field.type:gsub('"', '') or current_type
|
|
break
|
|
end
|
|
end
|
|
if current_member then
|
|
-- Continue to next part
|
|
else
|
|
return nil
|
|
end
|
|
else
|
|
return nil
|
|
end
|
|
else
|
|
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
|
|
current_type = field.type and field.type:gsub('"', '') or current_type
|
|
found = true
|
|
break
|
|
end
|
|
end
|
|
|
|
if not found then
|
|
return nil
|
|
end
|
|
end
|
|
end
|
|
|
|
return current_member
|
|
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, chain_start, chain_end
|
|
end
|
|
|
|
-- Parse the chain: #var.member1.member2
|
|
local root_var = chain:match("#([%w_]+)")
|
|
if not root_var then
|
|
-- Try without # (for global DB access)
|
|
root_var = chain:match("^([%w_]+)%.")
|
|
end
|
|
|
|
if root_var then
|
|
local member_path = chain:match("%.(.+)$")
|
|
return root_var, member_path, chain_start, chain_end
|
|
end
|
|
|
|
return nil, nil, chain_start, chain_end
|
|
end
|
|
|
|
-- Try member access first
|
|
local root_var, member_path, chain_start, chain_end = find_member_access_chain()
|
|
if root_var and member_path and doc.variables and doc.variables[root_var] then
|
|
local var_info = doc.variables[root_var]
|
|
local root_type = var_info.data_type
|
|
|
|
if root_type then
|
|
root_type = root_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 "")
|
|
for k, v in pairs(external_types) do
|
|
if not all_types[k] then
|
|
all_types[k] = v
|
|
end
|
|
end
|
|
|
|
local member = resolve_member_path(root_type, member_path, all_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 },
|
|
},
|
|
}
|
|
end
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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()
|