-- SCL Diagnostics - Linter functionality for the LSP server -- Validates SCL code for undefined variables, unknown types, and syntax errors 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 builtin = dofile(script_path .. "/builtin_instructions.lua") -- LSP diagnostic severity levels local DiagnosticSeverity = { Error = 1, Warning = 2, Information = 3, Hint = 4, } --- Convert line/character to LSP position format --- @param line number Zero-based line number --- @param character number Zero-based character offset --- @return table LSP Position object local function position_to_lsp(line, character) return { line = line, character = character, } end --- Create LSP Range object from start and end positions --- @param start_line number Start line (zero-based) --- @param start_char number Start character (zero-based) --- @param end_line number End line (zero-based) --- @param end_char number End character (zero-based) --- @return table LSP Range object 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 --- Validate SCL content and generate diagnostics --- Checks for: undefined variables, invalid # prefix, unknown data types --- @param content string The SCL source code to validate --- @param workspace_types table Known types from workspace scanning --- @param root_dir string Project root directory for additional type lookup --- @return table Array of LSP Diagnostic objects 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: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: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, EXTERNALWRITABLE = true, -- all-caps VCI export variant EXTERNALVISIBLE = true, -- all-caps VCI export variant EXTERNALACCESSIBLE = true,-- all-caps VCI export variant InstructionName = true, LibVersion = true, LibName = 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 if match_start <= 1 then return false end local prefix = line:sub(match_start - 1, match_start - 1):upper() return prefix == "T" or prefix == "D" or prefix == "S" end -- Check if hash is part of a base-specific number literal like 16#FFFF, 2#1010 local function is_number_literal_at(line, match_start) if not line or not match_start then return false end if match_start <= 1 then return false end local before = line:sub(match_start - 1, match_start - 1) return before:match("%d") ~= nil end if not in_var_section then local hash_vars = {} for var in line:gmatch("#[%w_]+") do local var_name = var:sub(2) local match_start = line:find(var, 1, true) if not is_iec_literal_at(line, match_start) and not is_number_literal_at(line, match_start) then hash_vars[var_name] = true end 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 = "tia_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 = "tia_lsp", }) end local type_part = nil local brace_depth = 0 local comment_depth = 0 local line_comment = false local type_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 line_comment = true elseif line_comment then -- Skip everything after // elseif 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 and next_c ~= "=" then -- Only capture colon that's NOT followed by = (not := assignment) type_colon = i end end if type_colon then type_part = line:sub(type_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*//.*$", "") -- Remove line comments // ... 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+", "") -- Handle quoted type names like "MyUDT" local base_type = type_part:match('^"([^"]+)"') or type_part:match("^([%w_]+)") if type_part:match("%s+OF%s+") then base_type = type_part:match('.*%s+OF%s+"([^"]+)"') or type_part:match(".*%s+OF%s+([%w_]+)") end local is_array_keyword = base_type and base_type:upper() == "ARRAY" and type_part:match("^[Aa][Rr][Rr][Aa][Yy]%[") local upper_type = base_type and base_type:upper() or nil if base_type and base_type ~= "" and not is_array_keyword 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), severity = DiagnosticSeverity.Warning, message = string.format("Unknown data type: %s", base_type), code = "SCL003", source = "tia_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 = "tia_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