Files
tia-lsp/src/diagnostics.lua
T
lazar ae5c9fd77d refactor: rename scl_lsp → tia_lsp (project umbrella for future STL/LAD/FBD)
Rename the project from scl_lsp to tia_lsp to future-proof for additional
TIA Portal languages (STL/AWL, LAD, FBD). The 'scl' language name is kept
as-is for the SCL filetype and tree-sitter parser; future languages get
their own names (stl, lad, fbd).

Project-wide changes:
- lua/scl_lsp/ → lua/tia_lsp/  (plugin entry point)
- lua/scl/     → lua/tia/      (language modules, shared across TIA langs)
- require('scl.*')    → require('tia.*')
- require('scl_lsp')  → require('tia_lsp')
- LSP client name:    'scl_lsp' → 'tia_lsp'
- Diagnostic source:  'scl_lsp' → 'tia_lsp'  (src/diagnostics.lua)
- package.json name:  'scl-language-server' → 'tia-lsp'

lua/tia_lsp/init.lua:
- Add vim.fn.exepath('tia-lsp') detection so the plugin prefers the
  mason-installed executable and falls back to { 'lua', server_path }
  for manual installs.
- Add opts.ts_parser_url to configure the tree-sitter parser source.
- Remove hardcoded ~/dev/scl_lsp paths in favor of ~/dev/tia-lsp and
  the new ts_parser_url option.

Cleanup:
- Delete lua/tia/init.lua (dead code, unreferenced duplicate of tia_lsp).
- Delete scl_lsp.sh (replaced by the mason-installed bin/tia-lsp wrapper).
- Update README.md and AGENTS.md to reflect the two-repo split
  (tia-lsp server + tia-lsp.nvim plugin) and document Mason installation.

Unchanged (intentional):
- grammar.js, src/grammar.json, src/parser.c: tree-sitter language name
  remains 'scl' (it's the language, not the project).
- Filetype patterns ('scl', 'udt', 'db') in autocmds.
- :SCL* command names (per-filetype prefix; future :STL* etc.).

Tests pass with the same pre-existing failures as before the rename
(formatter VAR_INPUT test, udt_parser line 199 const-variable bug,
plc_json reference-project-missing). No new failures introduced.
2026-07-18 11:30:55 +02:00

259 lines
9.9 KiB
Lua

-- 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: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
-- 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
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) 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 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
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