diagnostics.lua: - Add VAR DB_SPECIFIC to section detection - Make all VAR/END_VAR detection case-insensitive (matches grammar) - Replace 20-line hardcoded type check list with builtin.is_known_data_type() and builtin.is_known_type_or_instruction() — now uses the expanded DATA_TYPES table from Phase 4 instead of inline string comparisons - Add attribute validation: warn on unknown per-variable attributes (SCL005). Valid attributes: ExternalWritable, ExternalVisible, ExternalAccessible, S7_SetPoint, S7_Optimized_Access - Allow optional semicolon after END_VAR parser.lua: - Add VAR DB_SPECIFIC to section detection and var_type classification - Make all block detection case-insensitive (FUNCTION_BLOCK, FUNCTION, ORGANIZATION_BLOCK, END_FUNCTION_BLOCK, etc.) - Make TYPE/END_TYPE, STRUCT/END_STRUCT, VAR/END_VAR case-insensitive - Fix VAR RETAIN vs VAR NON_RETAIN classification (NON_RETAIN was matching the RETAIN branch first) - Allow optional semicolon after END_VAR and END_STRUCT formatter.lua: - Add VAR DB_SPECIFIC and VAR_DB_SPECIFIC to BLOCKS and VAR_KEYWORDS - Fix get_keyword() to handle multi-word VAR keywords (VAR NON_RETAIN, VAR DB_SPECIFIC, VAR CONSTANT, VAR RETAIN) — normalizes spaces to underscores so the keyword lookup succeeds - Fix is_new_statement detection for space-form VAR keywords All 38 formatter tests pass. Parser and diagnostics load without errors.
367 lines
12 KiB
Lua
367 lines
12 KiB
Lua
-- SCL Parser utilities for the LSP server
|
|
-- Extracts variables, types, and function definitions from SCL source code
|
|
|
|
local M = {}
|
|
|
|
--- Extract variables from SCL content
|
|
--- @param content string The SCL source code
|
|
--- @return table variables Table of variable definitions keyed by name
|
|
--- @return table variable_positions Table of variable positions keyed by name
|
|
function M.extract_variables(content)
|
|
local variables = {}
|
|
local variable_positions = {}
|
|
|
|
local lines = {}
|
|
for line in content:gmatch("([^\n]*)\n") do
|
|
table.insert(lines, line)
|
|
end
|
|
|
|
local in_var_section = false
|
|
local in_struct_section = false
|
|
local var_type = "unknown"
|
|
local current_block = nil
|
|
|
|
local function process_line(line, line_num)
|
|
local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "")
|
|
|
|
-- Detect block start (with or without quotes) — case-insensitive
|
|
local fb_name = trimmed:match('[Ff][Uu][Nn][Cc][Tt][Ii][Oo][Nn]_[Bb][Ll][Oo][Cc][Kk]%s+"([^"]+)"') or trimmed:match("^[Ff][Uu][Nn][Cc][Tt][Ii][Oo][Nn]_[Bb][Ll][Oo][Cc][Kk]%s+([%w_]+)")
|
|
if fb_name then
|
|
current_block = { name = fb_name, kind = "function_block" }
|
|
in_var_section = false
|
|
in_struct_section = false
|
|
return
|
|
end
|
|
|
|
local ob_name = trimmed:match('[Oo][Rr][Gg][Aa][Nn][Ii][Zz][Aa][Tt][Ii][Oo][Nn]_[Bb][Ll][Oo][Cc][Kk]%s+"([^"]+)"') or trimmed:match("^[Oo][Rr][Gg][Aa][Nn][Ii][Zz][Aa][Tt][Ii][Oo][Nn]_[Bb][Ll][Oo][Cc][Kk]%s+([%w_]+)")
|
|
if ob_name then
|
|
current_block = { name = ob_name, kind = "organization_block" }
|
|
in_var_section = false
|
|
in_struct_section = false
|
|
return
|
|
end
|
|
|
|
local fn_name = trimmed:match('[Ff][Uu][Nn][Cc][Tt][Ii][Oo][Nn]%s+"([^"]+)"') or trimmed:match("^[Ff][Uu][Nn][Cc][Tt][Ii][Oo][Nn]%s+([%w_]+)")
|
|
if fn_name then
|
|
current_block = { name = fn_name, kind = "function" }
|
|
in_var_section = false
|
|
in_struct_section = false
|
|
return
|
|
end
|
|
|
|
if trimmed:upper():match("^END_FUNCTION_BLOCK") or
|
|
trimmed:upper():match("^END_ORGANIZATION_BLOCK") or
|
|
trimmed:upper():match("^END_FUNCTION") then
|
|
current_block = nil
|
|
in_var_section = false
|
|
in_struct_section = false
|
|
return
|
|
end
|
|
|
|
-- Handle TYPE sections (for .udt files with STRUCT) — case-insensitive
|
|
if trimmed:upper():match("^TYPE") and (trimmed:upper():match("^TYPE%s") or trimmed:upper():match('^TYPE"') or trimmed:upper() == "TYPE") then
|
|
in_struct_section = true
|
|
var_type = "STRUCT_FIELD"
|
|
return
|
|
end
|
|
|
|
if trimmed:upper():match("^END_TYPE") then
|
|
in_struct_section = false
|
|
return
|
|
end
|
|
|
|
if trimmed:upper():match("^VAR[_A-Z]*%s*$") or
|
|
trimmed:upper():match("^VAR_INPUT%s*$") or
|
|
trimmed:upper():match("^VAR_OUTPUT%s*$") or
|
|
trimmed:upper():match("^VAR_IN_OUT%s*$") or
|
|
trimmed:upper():match("^VAR_TEMP%s*$") or
|
|
trimmed:upper():match("^VAR[_ ]*CONSTANT%s*$") or
|
|
trimmed:upper():match("^VAR[_ ]*RETAIN%s*$") or
|
|
trimmed:upper():match("^VAR[_ ]*NON_RETAIN%s*$") or
|
|
trimmed:upper():match("^VAR[_ ]*DB_SPECIFIC%s*$") then
|
|
in_var_section = true
|
|
in_struct_section = false
|
|
local upper_trimmed = trimmed:upper()
|
|
if upper_trimmed:match("INPUT") then var_type = "VAR_INPUT"
|
|
elseif upper_trimmed:match("OUTPUT") then var_type = "VAR_OUTPUT"
|
|
elseif upper_trimmed:match("IN_OUT") then var_type = "VAR_IN_OUT"
|
|
elseif upper_trimmed:match("TEMP") then var_type = "VAR_TEMP"
|
|
elseif upper_trimmed:match("CONSTANT") then var_type = "VAR_CONSTANT"
|
|
elseif upper_trimmed:match("RETAIN") and not upper_trimmed:match("NON_RETAIN") then var_type = "VAR_RETAIN"
|
|
elseif upper_trimmed:match("NON_RETAIN") then var_type = "VAR_NON_RETAIN"
|
|
elseif upper_trimmed:match("DB_SPECIFIC") then var_type = "VAR_DB_SPECIFIC"
|
|
else var_type = "VAR" end
|
|
return
|
|
end
|
|
|
|
if trimmed:upper():match("^END_VAR%s*;?%s*$") then
|
|
in_var_section = false
|
|
return
|
|
end
|
|
|
|
if trimmed:upper():match("^STRUCT%s*$") then
|
|
in_struct_section = true
|
|
var_type = "STRUCT_FIELD"
|
|
return
|
|
end
|
|
|
|
if trimmed:upper():match("^END_STRUCT%s*;?%s*$") then
|
|
in_struct_section = false
|
|
return
|
|
end
|
|
|
|
if not in_var_section and not in_struct_section then return end
|
|
|
|
if trimmed:match("^%(%*") or trimmed:match("^%s*//") then
|
|
return
|
|
end
|
|
|
|
local var_name = trimmed:match("^([%w_]+)%s*%b{}%s*:") or trimmed:match("^([%w_]+)%s*:")
|
|
if not var_name then
|
|
var_name = trimmed:match("^([%w_]+)%s*%b{}%s*,") or trimmed:match("^([%w_]+)%s*,")
|
|
end
|
|
|
|
if var_name and not variables[var_name] then
|
|
local col = line:find(var_name, 1, true)
|
|
if col then
|
|
local type_start = nil
|
|
local brace_depth = 0
|
|
for i = 1, #line do
|
|
local c = line:sub(i, i)
|
|
if c == "{" then
|
|
brace_depth = brace_depth + 1
|
|
elseif c == "}" then
|
|
brace_depth = brace_depth - 1
|
|
elseif c == ":" and brace_depth == 0 then
|
|
type_start = i
|
|
break
|
|
end
|
|
end
|
|
local var_data_type = "unknown"
|
|
local var_comment = line:match("//%s*(.+)$")
|
|
if not var_comment then
|
|
var_comment = line:match("%(%*(.-)%*%)")
|
|
end
|
|
if type_start then
|
|
local type_part = line:sub(type_start + 1)
|
|
type_part = type_part:gsub("%s*:=.*$", "")
|
|
type_part = type_part:gsub("%s*;.*$", "")
|
|
type_part = type_part:gsub("^%s+", "")
|
|
type_part = type_part:gsub("%s+$", "")
|
|
var_data_type = type_part
|
|
end
|
|
|
|
variables[var_name] = {
|
|
type = var_type,
|
|
data_type = var_data_type,
|
|
line = line_num,
|
|
character = col - 1,
|
|
comment = var_comment,
|
|
}
|
|
variable_positions[var_name] = {
|
|
line = line_num,
|
|
character = col - 1,
|
|
}
|
|
end
|
|
end
|
|
end
|
|
|
|
for i, line in ipairs(lines) do
|
|
process_line(line, i - 1)
|
|
end
|
|
|
|
return variables, variable_positions
|
|
end
|
|
|
|
--- Extract type definitions from SCL content
|
|
--- @param content string The SCL source code
|
|
--- @return table types Table of type definitions keyed by name
|
|
function M.extract_types(content)
|
|
local types = {}
|
|
|
|
local lines = {}
|
|
for line in content:gmatch("([^\n]*)\n") do
|
|
table.insert(lines, line)
|
|
end
|
|
|
|
local in_type_section = false
|
|
local current_type = nil
|
|
local brace_depth = 0
|
|
local in_struct = false
|
|
local pending_type_name = nil
|
|
local pending_comment = nil
|
|
|
|
local function extract_type_name(after_colon)
|
|
if not after_colon then return nil end
|
|
local quoted = after_colon:match('^%s*"([^"]+)"')
|
|
if quoted then return quoted end
|
|
return after_colon:match("^%s*(%w+)")
|
|
end
|
|
|
|
for i, line in ipairs(lines) do
|
|
local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "")
|
|
|
|
local is_comment_only = trimmed:match("^//")
|
|
if is_comment_only then
|
|
pending_comment = line:match("//%s*(.+)$")
|
|
end
|
|
|
|
local is_type_line = trimmed:match("^TYPE") and (trimmed:match("^TYPE%s") or trimmed:match('^TYPE"') or trimmed == "TYPE")
|
|
|
|
if is_type_line then
|
|
in_type_section = true
|
|
in_struct = false
|
|
brace_depth = 0
|
|
pending_type_name = trimmed:match('^TYPE%s+"([^"]+)') or trimmed:match("^TYPE%s+(%w+)")
|
|
if not pending_comment then
|
|
pending_comment = trimmed:match("//%s*(.+)$")
|
|
end
|
|
elseif in_type_section and trimmed:match("^END_TYPE") then
|
|
in_type_section = false
|
|
in_struct = false
|
|
current_type = nil
|
|
pending_type_name = nil
|
|
elseif in_type_section then
|
|
local type_name_with_struct = trimmed:match("^(%w+)%s+.-:%s+STRUCT")
|
|
if type_name_with_struct then
|
|
current_type = {
|
|
name = type_name_with_struct,
|
|
kind = "struct",
|
|
fields = {},
|
|
start_line = i - 1,
|
|
comment = pending_comment,
|
|
}
|
|
types[type_name_with_struct] = current_type
|
|
in_struct = true
|
|
pending_type_name = nil
|
|
pending_comment = nil
|
|
elseif trimmed:match("^STRUCT%s*$") then
|
|
if pending_type_name then
|
|
current_type = {
|
|
name = pending_type_name,
|
|
kind = "struct",
|
|
fields = {},
|
|
start_line = i - 1,
|
|
comment = pending_comment,
|
|
}
|
|
types[pending_type_name] = current_type
|
|
pending_type_name = nil
|
|
pending_comment = nil
|
|
end
|
|
in_struct = true
|
|
elseif trimmed:match("^END_STRUCT%s*$") then
|
|
in_struct = false
|
|
elseif in_struct and current_type and current_type.fields then
|
|
local field_name = line:match("^%s*(%w+)%s*:%s*")
|
|
if field_name then
|
|
local after_colon = line:match(":%s*(.+)")
|
|
local field_type = after_colon and extract_type_name(after_colon)
|
|
local field_comment = line:match("//%s*(.+)$")
|
|
if not field_comment then
|
|
field_comment = line:match("%(%*(.-)%*%)")
|
|
end
|
|
table.insert(current_type.fields, {
|
|
name = field_name,
|
|
type = field_type or "unknown",
|
|
comment = field_comment,
|
|
})
|
|
end
|
|
elseif not in_struct and not pending_type_name then
|
|
local array_match = trimmed:match("^(%w+)%s+:%s+ARRAY")
|
|
if array_match then
|
|
local of_type = trimmed:match("OF%s+(%w+)")
|
|
current_type = {
|
|
name = array_match,
|
|
kind = "array",
|
|
element_type = of_type,
|
|
start_line = i - 1,
|
|
}
|
|
types[array_match] = current_type
|
|
else
|
|
local alias_match = trimmed:match("^(%w+)%s+:%s+(%w+)")
|
|
if alias_match then
|
|
local base_type = extract_type_name(trimmed:match(":%s+(.+)$"))
|
|
current_type = {
|
|
name = alias_match,
|
|
kind = "alias",
|
|
base_type = base_type,
|
|
start_line = i - 1,
|
|
}
|
|
types[alias_match] = current_type
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
return types
|
|
end
|
|
|
|
--- Extract function/block definitions from SCL content
|
|
--- @param content string The SCL source code
|
|
--- @return table functions Array of function definitions with name, kind, start, final
|
|
function M.extract_functions(content)
|
|
local functions = {}
|
|
|
|
local lines = {}
|
|
for line in content:gmatch("([^\n]*)\n") do
|
|
table.insert(lines, line)
|
|
end
|
|
|
|
local function find_block_end(start_line, block_name, end_markers)
|
|
local depth = 1
|
|
for i = start_line + 1, #lines do
|
|
local line = lines[i]
|
|
if line:match("^%s*" .. block_name) then
|
|
depth = depth + 1
|
|
end
|
|
for _, marker in ipairs(end_markers) do
|
|
if line:match("^%s*" .. marker) then
|
|
depth = depth - 1
|
|
if depth == 0 then return i end
|
|
end
|
|
end
|
|
end
|
|
return #lines
|
|
end
|
|
|
|
for i, line in ipairs(lines) do
|
|
local func_name = line:match('FUNCTION%s+"([^"]+)"') or line:match("^FUNCTION%s+([%w_]+)")
|
|
if func_name then
|
|
local end_line = find_block_end(i, "END_FUNCTION", { "END_FUNCTION" })
|
|
table.insert(functions, {
|
|
name = func_name,
|
|
kind = "function",
|
|
start = i - 1,
|
|
final = end_line - 1,
|
|
})
|
|
end
|
|
|
|
local fb_name = line:match('FUNCTION_BLOCK%s+"([^"]+)"') or line:match("^FUNCTION_BLOCK%s+([%w_]+)")
|
|
if fb_name then
|
|
local end_line = find_block_end(i, "END_FUNCTION_BLOCK", { "END_FUNCTION_BLOCK" })
|
|
table.insert(functions, {
|
|
name = fb_name,
|
|
kind = "function_block",
|
|
start = i - 1,
|
|
final = end_line - 1,
|
|
})
|
|
end
|
|
|
|
local ob_name = line:match('ORGANIZATION_BLOCK%s+"([^"]+)"') or line:match("^ORGANIZATION_BLOCK%s+([%w_]+)")
|
|
if ob_name then
|
|
local end_line = find_block_end(i, "END_ORGANIZATION_BLOCK", { "END_ORGANIZATION_BLOCK" })
|
|
table.insert(functions, {
|
|
name = ob_name,
|
|
kind = "organization_block",
|
|
start = i - 1,
|
|
final = end_line - 1,
|
|
})
|
|
end
|
|
end
|
|
|
|
return functions
|
|
end
|
|
|
|
return M
|