feat: diagnostics & parser spec conformance (Phase 5)
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.
This commit is contained in:
+41
-26
@@ -80,21 +80,50 @@ function M.validate_diagnostics(content, workspace_types, root_dir)
|
|||||||
local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "")
|
local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "")
|
||||||
local line_num = line_idx - 1
|
local line_num = line_idx - 1
|
||||||
|
|
||||||
if trimmed:match("^VAR[_A-Z]*%s*$") or
|
if trimmed:upper():match("^VAR[_A-Z]*%s*$") or
|
||||||
trimmed:match("^VAR_INPUT%s*$") or
|
trimmed:upper():match("^VAR_INPUT%s*$") or
|
||||||
trimmed:match("^VAR_OUTPUT%s*$") or
|
trimmed:upper():match("^VAR_OUTPUT%s*$") or
|
||||||
trimmed:match("^VAR_IN_OUT%s*$") or
|
trimmed:upper():match("^VAR_IN_OUT%s*$") or
|
||||||
trimmed:match("^VAR_TEMP%s*$") or
|
trimmed:upper():match("^VAR_TEMP%s*$") or
|
||||||
trimmed:match("^VAR[_ ]*CONSTANT%s*$") or
|
trimmed:upper():match("^VAR[_ ]*CONSTANT%s*$") or
|
||||||
trimmed:match("^VAR[_ ]*RETAIN%s*$") or
|
trimmed:upper():match("^VAR[_ ]*RETAIN%s*$") or
|
||||||
trimmed:match("^VAR[_ ]*NON_RETAIN%s*$") then
|
trimmed:upper():match("^VAR[_ ]*NON_RETAIN%s*$") or
|
||||||
|
trimmed:upper():match("^VAR[_ ]*DB_SPECIFIC%s*$") then
|
||||||
in_var_section = true
|
in_var_section = true
|
||||||
var_section_start = line_num
|
var_section_start = line_num
|
||||||
elseif trimmed:match("^END_VAR%s*$") then
|
elseif trimmed:upper():match("^END_VAR%s*;?%s*$") then
|
||||||
in_var_section = false
|
in_var_section = false
|
||||||
var_section_start = nil
|
var_section_start = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Validate VAR access attributes ({ ExternalWritable, ExternalVisible, ... })
|
||||||
|
-- Spec: only ExternalWritable, ExternalVisible, ExternalAccessible, S7_SetPoint
|
||||||
|
-- are valid per-variable attributes in VAR sections.
|
||||||
|
local ALLOWED_VAR_ATTRIBUTES = {
|
||||||
|
ExternalWritable = true,
|
||||||
|
ExternalVisible = true,
|
||||||
|
ExternalAccessible = true,
|
||||||
|
ExternalWRITABLE = true, -- case variant
|
||||||
|
ExternalVISIBLE = true,
|
||||||
|
ExternalACCESSIBLE = true,
|
||||||
|
S7_SetPoint = true,
|
||||||
|
S7_Optimized_Access = true,
|
||||||
|
}
|
||||||
|
for attr_name in line:gmatch("{%s*(%w+)%s*:=") do
|
||||||
|
if not ALLOWED_VAR_ATTRIBUTES[attr_name] then
|
||||||
|
local attr_start = line:find(attr_name, 1, true)
|
||||||
|
if attr_start then
|
||||||
|
table.insert(diagnostics, {
|
||||||
|
range = range_to_lsp(line_num, attr_start - 1, line_num, attr_start + #attr_name - 1),
|
||||||
|
severity = DiagnosticSeverity.Warning,
|
||||||
|
message = string.format("Unknown variable attribute: %s", attr_name),
|
||||||
|
code = "SCL005",
|
||||||
|
source = "tia_lsp",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- Check if a hash-prefixed match is part of an IEC literal (TIME, S5TIME, etc.)
|
-- Check if a hash-prefixed match is part of an IEC literal (TIME, S5TIME, etc.)
|
||||||
local function is_iec_literal_at(line, match_start)
|
local function is_iec_literal_at(line, match_start)
|
||||||
if not line or not match_start then return false end
|
if not line or not match_start then return false end
|
||||||
@@ -187,23 +216,9 @@ function M.validate_diagnostics(content, workspace_types, root_dir)
|
|||||||
base_type = type_part:match('.*%s+OF%s+"([^"]+)"') or type_part:match(".*%s+OF%s+([%w_]+)")
|
base_type = type_part:match('.*%s+OF%s+"([^"]+)"') or type_part:match(".*%s+OF%s+([%w_]+)")
|
||||||
end
|
end
|
||||||
local upper_type = base_type and base_type:upper() or nil
|
local upper_type = base_type and base_type:upper() or nil
|
||||||
if base_type and base_type ~= "" and not all_types[base_type] and
|
if base_type and base_type ~= "" and
|
||||||
upper_type ~= "BOOL" and upper_type ~= "INT" and upper_type ~= "DINT" and
|
not all_types[base_type] and
|
||||||
upper_type ~= "REAL" and upper_type ~= "LREAL" and upper_type ~= "BYTE" and
|
not builtin.is_known_data_type(upper_type) 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 ~= "USINT" and upper_type ~= "SINT" and upper_type ~= "UINT" and
|
|
||||||
upper_type ~= "UDINT" and upper_type ~= "LINT" and upper_type ~= "ULINT" and
|
|
||||||
upper_type ~= "TOD" and upper_type ~= "DTL" 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" and upper_type ~= "IEC_TON" and
|
|
||||||
upper_type ~= "IEC_TOF" and upper_type ~= "IEC_TP" and
|
|
||||||
upper_type ~= "IEC_COUNTER" and upper_type ~= "CTU" and
|
|
||||||
upper_type ~= "CTD" and upper_type ~= "CTUD" and
|
|
||||||
not builtin.is_known_type_or_instruction(upper_type) then
|
not builtin.is_known_type_or_instruction(upper_type) then
|
||||||
table.insert(diagnostics, {
|
table.insert(diagnostics, {
|
||||||
range = range_to_lsp(line_num, 0, line_num, #line),
|
range = range_to_lsp(line_num, 0, line_num, #line),
|
||||||
|
|||||||
+15
-2
@@ -53,6 +53,8 @@ local BLOCKS = {
|
|||||||
["VAR_CONSTANT"] = "END_VAR",
|
["VAR_CONSTANT"] = "END_VAR",
|
||||||
["VAR_RETAIN"] = "END_VAR",
|
["VAR_RETAIN"] = "END_VAR",
|
||||||
["VAR_NON_RETAIN"] = "END_VAR",
|
["VAR_NON_RETAIN"] = "END_VAR",
|
||||||
|
["VAR DB_SPECIFIC"] = "END_VAR",
|
||||||
|
["VAR_DB_SPECIFIC"] = "END_VAR",
|
||||||
}
|
}
|
||||||
|
|
||||||
local SAME_LEVEL = {
|
local SAME_LEVEL = {
|
||||||
@@ -82,6 +84,8 @@ local VAR_KEYWORDS = {
|
|||||||
["VAR_CONSTANT"] = true,
|
["VAR_CONSTANT"] = true,
|
||||||
["VAR_RETAIN"] = true,
|
["VAR_RETAIN"] = true,
|
||||||
["VAR_NON_RETAIN"] = true,
|
["VAR_NON_RETAIN"] = true,
|
||||||
|
["VAR DB_SPECIFIC"] = true,
|
||||||
|
["VAR_DB_SPECIFIC"] = true,
|
||||||
}
|
}
|
||||||
|
|
||||||
local DECLARATIONS = {
|
local DECLARATIONS = {
|
||||||
@@ -325,6 +329,12 @@ function M.format_document(content, options)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function get_keyword(trimmed)
|
local function get_keyword(trimmed)
|
||||||
|
-- Handle multi-word VAR keywords: "VAR NON_RETAIN", "VAR DB_SPECIFIC", "VAR CONSTANT", "VAR RETAIN"
|
||||||
|
local var_multi = trimmed:upper():match("^(VAR%s+[%w_]+)")
|
||||||
|
if var_multi then
|
||||||
|
-- Normalize to underscore form: "VAR NON_RETAIN" -> "VAR_NON_RETAIN"
|
||||||
|
return var_multi:gsub("%s+", "_")
|
||||||
|
end
|
||||||
return trimmed:match("^([%w_]+)") or trimmed:match("^(%w+)")
|
return trimmed:match("^([%w_]+)") or trimmed:match("^(%w+)")
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -418,15 +428,18 @@ function M.format_document(content, options)
|
|||||||
if in_assignment and not ends_assignment then
|
if in_assignment and not ends_assignment then
|
||||||
local starts_with_operator = is_assignment_continuation(trimmed)
|
local starts_with_operator = is_assignment_continuation(trimmed)
|
||||||
-- New statement if: starts with keyword OR contains := (new assignment) OR starts with block ender
|
-- New statement if: starts with keyword OR contains := (new assignment) OR starts with block ender
|
||||||
local first_word = trimmed:match("^([%w_]+)")
|
local first_word = trimmed:match("^([%w_]+)") or trimmed:match("^([%w_]+%s+[%w_]+)")
|
||||||
local starts_block_ender = first_word and (
|
local starts_block_ender = first_word and (
|
||||||
trimmed:match("^END_") or
|
trimmed:match("^END_") or
|
||||||
BLOCKS[first_word] ~= nil
|
BLOCKS[first_word] ~= nil
|
||||||
)
|
)
|
||||||
|
-- Normalize VAR forms: "VAR NON_RETAIN" -> "VAR_NON_RETAIN", "VAR DB_SPECIFIC" -> "VAR_DB_SPECIFIC"
|
||||||
|
local normalized_var = trimmed:upper():gsub("VAR%s+", "VAR_"):gsub("^VAR_NON_RETAIN$", "VAR_NON_RETAIN")
|
||||||
local is_new_statement = trimmed:match(":=") or starts_block_ender or (
|
local is_new_statement = trimmed:match(":=") or starts_block_ender or (
|
||||||
trimmed:match("^%w+") and (
|
trimmed:match("^%w+") and (
|
||||||
SAME_LEVEL[trimmed:match("^([%w_]+)")] or
|
SAME_LEVEL[trimmed:match("^([%w_]+)")] or
|
||||||
VAR_KEYWORDS[trimmed:match("^([%w_]+)")]
|
VAR_KEYWORDS[trimmed:match("^([%w_]+)")] or
|
||||||
|
VAR_KEYWORDS[normalized_var:match("^([%w_]+[_%w]+)")]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if starts_with_operator or not is_new_statement then
|
if starts_with_operator or not is_new_statement then
|
||||||
|
|||||||
+31
-28
@@ -24,8 +24,8 @@ function M.extract_variables(content)
|
|||||||
local function process_line(line, line_num)
|
local function process_line(line, line_num)
|
||||||
local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "")
|
local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "")
|
||||||
|
|
||||||
-- Detect block start (with or without quotes)
|
-- Detect block start (with or without quotes) — case-insensitive
|
||||||
local fb_name = trimmed:match('FUNCTION_BLOCK%s+"([^"]+)"') or trimmed:match("^FUNCTION_BLOCK%s+([%w_]+)")
|
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
|
if fb_name then
|
||||||
current_block = { name = fb_name, kind = "function_block" }
|
current_block = { name = fb_name, kind = "function_block" }
|
||||||
in_var_section = false
|
in_var_section = false
|
||||||
@@ -33,7 +33,7 @@ function M.extract_variables(content)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
local ob_name = trimmed:match('ORGANIZATION_BLOCK%s+"([^"]+)"') or trimmed:match("^ORGANIZATION_BLOCK%s+([%w_]+)")
|
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
|
if ob_name then
|
||||||
current_block = { name = ob_name, kind = "organization_block" }
|
current_block = { name = ob_name, kind = "organization_block" }
|
||||||
in_var_section = false
|
in_var_section = false
|
||||||
@@ -41,7 +41,7 @@ function M.extract_variables(content)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
local fn_name = trimmed:match('FUNCTION%s+"([^"]+)"') or trimmed:match("^FUNCTION%s+([%w_]+)")
|
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
|
if fn_name then
|
||||||
current_block = { name = fn_name, kind = "function" }
|
current_block = { name = fn_name, kind = "function" }
|
||||||
in_var_section = false
|
in_var_section = false
|
||||||
@@ -49,60 +49,63 @@ function M.extract_variables(content)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if trimmed:match("^END_FUNCTION_BLOCK") or
|
if trimmed:upper():match("^END_FUNCTION_BLOCK") or
|
||||||
trimmed:match("^END_ORGANIZATION_BLOCK") or
|
trimmed:upper():match("^END_ORGANIZATION_BLOCK") or
|
||||||
trimmed:match("^END_FUNCTION") then
|
trimmed:upper():match("^END_FUNCTION") then
|
||||||
current_block = nil
|
current_block = nil
|
||||||
in_var_section = false
|
in_var_section = false
|
||||||
in_struct_section = false
|
in_struct_section = false
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Handle TYPE sections (for .udt files with STRUCT)
|
-- Handle TYPE sections (for .udt files with STRUCT) — case-insensitive
|
||||||
if trimmed:match("^TYPE") and (trimmed:match("^TYPE%s") or trimmed:match('^TYPE"') or trimmed == "TYPE") then
|
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
|
in_struct_section = true
|
||||||
var_type = "STRUCT_FIELD"
|
var_type = "STRUCT_FIELD"
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if trimmed:match("^END_TYPE") then
|
if trimmed:upper():match("^END_TYPE") then
|
||||||
in_struct_section = false
|
in_struct_section = false
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if trimmed:match("^VAR[_A-Z]*%s*$") or
|
if trimmed:upper():match("^VAR[_A-Z]*%s*$") or
|
||||||
trimmed:match("^VAR_INPUT%s*$") or
|
trimmed:upper():match("^VAR_INPUT%s*$") or
|
||||||
trimmed:match("^VAR_OUTPUT%s*$") or
|
trimmed:upper():match("^VAR_OUTPUT%s*$") or
|
||||||
trimmed:match("^VAR_IN_OUT%s*$") or
|
trimmed:upper():match("^VAR_IN_OUT%s*$") or
|
||||||
trimmed:match("^VAR_TEMP%s*$") or
|
trimmed:upper():match("^VAR_TEMP%s*$") or
|
||||||
trimmed:match("^VAR[_ ]*CONSTANT%s*$") or
|
trimmed:upper():match("^VAR[_ ]*CONSTANT%s*$") or
|
||||||
trimmed:match("^VAR[_ ]*RETAIN%s*$") or
|
trimmed:upper():match("^VAR[_ ]*RETAIN%s*$") or
|
||||||
trimmed:match("^VAR[_ ]*NON_RETAIN%s*$") then
|
trimmed:upper():match("^VAR[_ ]*NON_RETAIN%s*$") or
|
||||||
|
trimmed:upper():match("^VAR[_ ]*DB_SPECIFIC%s*$") then
|
||||||
in_var_section = true
|
in_var_section = true
|
||||||
in_struct_section = false
|
in_struct_section = false
|
||||||
if trimmed:match("INPUT") then var_type = "VAR_INPUT"
|
local upper_trimmed = trimmed:upper()
|
||||||
elseif trimmed:match("OUTPUT") then var_type = "VAR_OUTPUT"
|
if upper_trimmed:match("INPUT") then var_type = "VAR_INPUT"
|
||||||
elseif trimmed:match("IN_OUT") then var_type = "VAR_IN_OUT"
|
elseif upper_trimmed:match("OUTPUT") then var_type = "VAR_OUTPUT"
|
||||||
elseif trimmed:match("TEMP") then var_type = "VAR_TEMP"
|
elseif upper_trimmed:match("IN_OUT") then var_type = "VAR_IN_OUT"
|
||||||
elseif trimmed:match("CONSTANT") then var_type = "VAR_CONSTANT"
|
elseif upper_trimmed:match("TEMP") then var_type = "VAR_TEMP"
|
||||||
elseif trimmed:match("RETAIN") then var_type = "VAR_RETAIN"
|
elseif upper_trimmed:match("CONSTANT") then var_type = "VAR_CONSTANT"
|
||||||
elseif trimmed:match("NON_RETAIN") then var_type = "VAR_NON_RETAIN"
|
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
|
else var_type = "VAR" end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if trimmed:match("^END_VAR%s*$") then
|
if trimmed:upper():match("^END_VAR%s*;?%s*$") then
|
||||||
in_var_section = false
|
in_var_section = false
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if trimmed:match("^STRUCT%s*$") then
|
if trimmed:upper():match("^STRUCT%s*$") then
|
||||||
in_struct_section = true
|
in_struct_section = true
|
||||||
var_type = "STRUCT_FIELD"
|
var_type = "STRUCT_FIELD"
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if trimmed:match("^END_STRUCT%s*$") then
|
if trimmed:upper():match("^END_STRUCT%s*;?%s*$") then
|
||||||
in_struct_section = false
|
in_struct_section = false
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user