Extract actual element type for array declarations like 'Array[0..3] OF IEC_TIMER' instead of incorrectly flagging 'Array' as unknown.
213 lines
7.3 KiB
Lua
213 lines
7.3 KiB
Lua
-- SCL Diagnostics - Linter functionality for the LSP server
|
|
local M = {}
|
|
|
|
-- Get script path for loading parser (same pattern as main.lua)
|
|
local script_path = debug.getinfo(1, "S").source:gsub("^@", ""):match("(.*/)") or ""
|
|
if script_path == "" then
|
|
script_path = "."
|
|
end
|
|
if script_path:sub(1, 1) ~= "/" then
|
|
local cwd = io.popen("pwd")
|
|
if cwd then
|
|
script_path = cwd:read("*l") .. "/" .. script_path
|
|
cwd:close()
|
|
end
|
|
end
|
|
package.path = package.path .. ";" .. script_path .. "/?.lua"
|
|
|
|
local parser = dofile(script_path .. "/parser.lua")
|
|
|
|
local DiagnosticSeverity = {
|
|
Error = 1,
|
|
Warning = 2,
|
|
Information = 3,
|
|
Hint = 4,
|
|
}
|
|
|
|
local function position_to_lsp(line, character)
|
|
return {
|
|
line = line,
|
|
character = character,
|
|
}
|
|
end
|
|
|
|
local function range_to_lsp(start_line, start_char, end_line, end_char)
|
|
return {
|
|
start = position_to_lsp(start_line, start_char),
|
|
["end"] = position_to_lsp(end_line, end_char),
|
|
}
|
|
end
|
|
|
|
function M.validate_diagnostics(content, workspace_types, root_dir)
|
|
local diagnostics = {}
|
|
local lines = {}
|
|
for line in content:gmatch("([^\n]*)\n") do
|
|
table.insert(lines, line)
|
|
end
|
|
|
|
local variables, _ = parser.extract_variables(content)
|
|
local types = parser.extract_types(content)
|
|
local functions = parser.extract_functions(content)
|
|
|
|
local all_types = {}
|
|
for k, v in pairs(types) do all_types[k] = v end
|
|
for k, v in pairs(workspace_types or {}) do all_types[k] = v end
|
|
|
|
local in_var_section = false
|
|
local var_section_start = nil
|
|
|
|
for line_idx, line in ipairs(lines) do
|
|
local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "")
|
|
local line_num = line_idx - 1
|
|
|
|
if trimmed:match("^VAR[_A-Z]*%s*$") or
|
|
trimmed:match("^VAR_INPUT%s*$") or
|
|
trimmed:match("^VAR_OUTPUT%s*$") or
|
|
trimmed:match("^VAR_IN_OUT%s*$") or
|
|
trimmed:match("^VAR_TEMP%s*$") or
|
|
trimmed:match("^VAR[_ ]*CONSTANT%s*$") or
|
|
trimmed:match("^VAR[_ ]*RETAIN%s*$") or
|
|
trimmed:match("^VAR[_ ]*NON_RETAIN%s*$") then
|
|
in_var_section = true
|
|
var_section_start = line_num
|
|
elseif trimmed:match("^END_VAR%s*$") then
|
|
in_var_section = false
|
|
var_section_start = nil
|
|
end
|
|
|
|
if not in_var_section then
|
|
local hash_vars = {}
|
|
for var in line:gmatch("#[%w_]+") do
|
|
local var_name = var:sub(2)
|
|
hash_vars[var_name] = true
|
|
end
|
|
|
|
for var_name, _ in pairs(hash_vars) do
|
|
if not variables[var_name] and not all_types[var_name] then
|
|
local var_start = line:find("#" .. var_name, 1, true)
|
|
if var_start then
|
|
table.insert(diagnostics, {
|
|
range = range_to_lsp(line_num, var_start - 1, line_num, var_start + #var_name - 1),
|
|
severity = DiagnosticSeverity.Error,
|
|
message = string.format("Undefined variable: %s", var_name),
|
|
code = "SCL001",
|
|
source = "scl_lsp",
|
|
})
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
if in_var_section and (trimmed:match("^[%w_]+%s*%b{}%s*:") or trimmed:match("^[%w_]+%s*:")) then
|
|
local var_name = trimmed:match("^([%w_]+)%s*%b{}%s*:") or trimmed:match("^([%w_]+)%s*:")
|
|
local var_start = line:find(var_name, 1, true)
|
|
|
|
if var_name and not var_name:match("^%d") then
|
|
local has_hash = line:match("#" .. var_name)
|
|
if has_hash then
|
|
table.insert(diagnostics, {
|
|
range = range_to_lsp(line_num, 0, line_num, #line),
|
|
severity = DiagnosticSeverity.Error,
|
|
message = "Variable declarations should NOT have '#' prefix in VAR section",
|
|
code = "SCL002",
|
|
source = "scl_lsp",
|
|
})
|
|
end
|
|
|
|
local type_part = nil
|
|
local brace_depth = 0
|
|
local comment_depth = 0
|
|
local last_colon = nil
|
|
for i = 1, #line do
|
|
local c = line:sub(i, i)
|
|
local next_c = line:sub(i+1, i+1)
|
|
if c == "(" and next_c == "*" then
|
|
comment_depth = comment_depth + 1
|
|
elseif c == "*" and next_c == ")" and comment_depth > 0 then
|
|
comment_depth = comment_depth - 1
|
|
elseif c == "{" and comment_depth == 0 then
|
|
brace_depth = brace_depth + 1
|
|
elseif c == "}" and comment_depth == 0 then
|
|
brace_depth = brace_depth - 1
|
|
elseif c == ":" and brace_depth == 0 and comment_depth == 0 then
|
|
last_colon = i
|
|
end
|
|
end
|
|
if last_colon then
|
|
type_part = line:sub(last_colon + 1)
|
|
end
|
|
if type_part then
|
|
type_part = type_part:gsub("%s*%(%*.-%*%)", "") -- Remove block comments (* ... *) with leading whitespace
|
|
type_part = type_part:gsub("%s*:=.*$", "")
|
|
type_part = type_part:gsub("%s*;.*$", "") -- Remove trailing semicolon
|
|
type_part = type_part:gsub("%s*$", "")
|
|
type_part = type_part:gsub("^%s+", "")
|
|
|
|
local base_type = type_part:match("^([%w_]+)")
|
|
if type_part:match("%s+OF%s+") then
|
|
base_type = type_part:match(".*%s+OF%s+([%w_]+)")
|
|
end
|
|
local upper_type = base_type and base_type:upper() or nil
|
|
if base_type and not all_types[base_type] and
|
|
upper_type ~= "BOOL" and upper_type ~= "INT" and upper_type ~= "DINT" and
|
|
upper_type ~= "REAL" and upper_type ~= "LREAL" and upper_type ~= "BYTE" and
|
|
upper_type ~= "WORD" and upper_type ~= "DWORD" and upper_type ~= "LWORD" and
|
|
upper_type ~= "CHAR" and upper_type ~= "STRING" and upper_type ~= "WCHAR" and
|
|
upper_type ~= "WSTRING" and upper_type ~= "TIME" and upper_type ~= "DATE" and
|
|
upper_type ~= "TIME_OF_DAY" and upper_type ~= "DATE_AND_TIME" and
|
|
upper_type ~= "S5TIME" and upper_type ~= "LTIME" and
|
|
upper_type ~= "IEC_TIMER" and upper_type ~= "TON_TIME" and
|
|
upper_type ~= "TOF_TIME" and upper_type ~= "TONR_TIME" and
|
|
upper_type ~= "TP_TIME" then
|
|
table.insert(diagnostics, {
|
|
range = range_to_lsp(line_num, 0, line_num, #line),
|
|
severity = DiagnosticSeverity.Warning,
|
|
message = string.format("Unknown data type: %s", base_type),
|
|
code = "SCL003",
|
|
source = "scl_lsp",
|
|
})
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
if trimmed:match("^%s*//") or trimmed:match("^%s*%(%*") then
|
|
-- Skip comments
|
|
else
|
|
for assignment in line:gmatch("#[%w_]+%s*:=") do
|
|
local var_name = assignment:match("#([%w_]+)")
|
|
if var_name and in_var_section then
|
|
local assign_start = line:find("#" .. var_name, 1, true)
|
|
table.insert(diagnostics, {
|
|
range = range_to_lsp(line_num, assign_start - 1, line_num, assign_start + #var_name),
|
|
severity = DiagnosticSeverity.Error,
|
|
message = "Cannot use '#' prefix for variables in VAR section",
|
|
code = "SCL004",
|
|
source = "scl_lsp",
|
|
})
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
for func_idx, func in ipairs(functions) do
|
|
local func_content = ""
|
|
for i = func.start + 1, func.final - 1 do
|
|
if lines[i + 1] then
|
|
func_content = func_content .. lines[i + 1] .. "\n"
|
|
end
|
|
end
|
|
|
|
local func_vars, _ = parser.extract_variables(func_content)
|
|
local all_vars = {}
|
|
for k, v in pairs(variables) do all_vars[k] = v end
|
|
for k, v in pairs(func_vars) do
|
|
all_vars[k] = v
|
|
end
|
|
end
|
|
|
|
return diagnostics
|
|
end
|
|
|
|
return M
|