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:
2026-07-18 20:11:41 +02:00
parent d9338ca470
commit aecda669bd
3 changed files with 88 additions and 57 deletions
+41 -26
View File
@@ -80,21 +80,50 @@ function M.validate_diagnostics(content, workspace_types, root_dir)
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
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
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
var_section_start = nil
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.)
local function is_iec_literal_at(line, match_start)
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_]+)")
end
local upper_type = base_type and base_type:upper() or nil
if base_type and 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 ~= "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
if base_type and base_type ~= "" and
not all_types[base_type] and
not builtin.is_known_data_type(upper_type) and
not builtin.is_known_type_or_instruction(upper_type) then
table.insert(diagnostics, {
range = range_to_lsp(line_num, 0, line_num, #line),