Files
tia-lsp/src/plc_json.lua
T

475 lines
15 KiB
Lua

-- Load UDT types from external .plc.json files
-- Auto-generate from data_types folder and .scl/.udt/.db files
local M = {}
-- Cache for loaded types: { [root_dir] = { [type_name] = type_info } }
local cached_types = {}
--- Execute shell command and capture output
--- @param cmd string Command to execute
--- @return string|nil Command output, or nil on error
local function run_command(cmd)
local handle = io.popen(cmd)
if not handle then return nil end
local result = handle:read("*a")
handle:close()
return result
end
local script_path = debug.getinfo(1, "S").source:gsub("^@", ""):match("(.*/)") or ""
if script_path == "" then script_path = "." end
local cwd = io.popen("pwd"):read("*l")
if script_path:sub(1, 1) == "/" then
script_path = script_path:gsub("/+$", "")
elseif cwd then
script_path = cwd .. "/" .. script_path
script_path = script_path:gsub("/+", "/"):gsub("/$", "")
else
script_path = "."
end
--- Load and parse JSON content
--- @param content string JSON string
--- @return table|nil Parsed data, or nil on error
local function load_json(content)
local json = dofile(script_path .. "/json.lua")
local ok, data = pcall(function() return json.decode(content) end)
if ok then return data end
return nil
end
--- Scan directory recursively for files matching pattern
--- @param dir string Directory to scan
--- @param pattern string Glob pattern for file names
--- @return table Array of matching file paths
local function scan_files_recursive(dir, pattern)
local files = {}
local result = run_command("find " .. dir .. " -type f -name \"" .. pattern .. "\" 2>/dev/null")
if result then
for f in result:gmatch("[^\n]+") do
if f ~= "" then table.insert(files, f) end
end
end
return files
end
--- Parse a PLC type definition from JSON structure
--- @param dt table Type definition from JSON
--- @return table|nil Parsed type info, or nil if invalid
local function parse_plc_type(dt)
local type_name = dt.name or dt.Name or dt.baseType or dt.dataTypeName
if not type_name then return nil end
local typ = {
name = type_name,
kind = "alias",
fields = {},
source = "external",
}
if dt.struct or dt.member or dt.members or dt.elements then
typ.kind = "struct"
local members = dt.struct or dt.member or dt.members or dt.elements
if type(members) == "table" then
for _, m in ipairs(members) do
local field_name = m.name or m.Name
local field_type = m.dataTypeName or m.baseType or m.type or m.dataType or "unknown"
if field_name then
table.insert(typ.fields, {
name = field_name,
type = field_type,
})
end
end
end
elseif dt.baseType or dt.baseTypeName then
typ.kind = "alias"
typ.base_type = dt.baseType or dt.baseTypeName
end
return typ
end
--- Parse SCL type file content (TYPE...END_TYPE, FUNCTION_BLOCK, etc.)
--- @param content string SCL source code
--- @return table types Parsed types keyed by name
local function parse_scl_type_file(content)
local types = {}
local in_type = false
local in_struct = false
local current_type_name = nil
local pending_type_name = nil
local pending_type_comment = nil
local in_fb = false
local fb_name = nil
local fb_inputs = {}
local fb_outputs = {}
local fb_inouts = {}
local current_fb_section = nil
content = content:gsub("^([\239\187\191]+)", "")
local lines = {}
for line in content:gmatch("[^\n]+") do table.insert(lines, line) end
for i, line in ipairs(lines) do
local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "")
local fb_match = trimmed:match('^FUNCTION_BLOCK%s+"([^"]+)"') or trimmed:match("^FUNCTION_BLOCK%s+(%w+)")
if fb_match then
in_fb = true
fb_name = fb_match
fb_inputs = {}
fb_outputs = {}
fb_inouts = {}
current_fb_section = nil
elseif in_fb and trimmed:match("^END_FUNCTION_BLOCK") then
if fb_name then
types[fb_name] = {
name = fb_name,
kind = "function_block",
inputs = fb_inputs,
outputs = fb_outputs,
inouts = fb_inouts,
source = "scl_file"
}
end
in_fb = false
fb_name = nil
fb_inputs = {}
fb_outputs = {}
fb_inouts = {}
current_fb_section = nil
elseif in_fb then
if trimmed:match("^VAR_INPUT") then
current_fb_section = "input"
elseif trimmed:match("^VAR_OUTPUT") then
current_fb_section = "output"
elseif trimmed:match("^VAR_IN_OUT") then
current_fb_section = "inout"
elseif trimmed:match("^VAR%s") or trimmed:match("^VAR%s$") then
current_fb_section = nil
elseif trimmed:match("^VAR_TEMP") then
current_fb_section = nil
elseif trimmed:match("^END_VAR") then
current_fb_section = nil
elseif current_fb_section then
local field_name = line:match("^%s*(%w+)")
if field_name and not trimmed:match("^%(%*") then
local field_type = line:match(":%s*([^;]+)")
if field_type then
local attr_match = field_type:match("%{%s*[^%}]+%%s*%}%s*:%s*(.+)$")
if attr_match then
field_type = attr_match
end
field_type = field_type:gsub("%s*;.*$", "")
field_type = field_type:gsub("^%s+", ""):gsub("%s+$", "")
local field_comment = line:match("//%s*(.+)$") or line:match("%(%*(.-)%*%)")
local field = { name = field_name, type = field_type, comment = field_comment }
if current_fb_section == "input" then
table.insert(fb_inputs, field)
elseif current_fb_section == "output" then
table.insert(fb_outputs, field)
elseif current_fb_section == "inout" then
table.insert(fb_inouts, field)
end
end
end
end
end
if not in_fb then
if trimmed:match("^TYPE%s") or trimmed:match('^TYPE"') or trimmed == "TYPE" then
in_type = true
in_struct = false
current_type_name = nil
pending_type_name = nil
pending_type_comment = nil
local type_name = trimmed:match('TYPE%s+"([^"]+)"') or trimmed:match("^TYPE%s+(%w+)")
if type_name then
pending_type_name = type_name
end
elseif trimmed:match("^END_TYPE") then
in_type = false
in_struct = false
current_type_name = nil
pending_type_name = nil
pending_type_comment = nil
elseif in_type then
-- Capture comments before STRUCT
if trimmed:match("^//") then
local comment = trimmed:match("^//%s*(.+)$")
if comment and pending_type_name then
pending_type_comment = comment
end
elseif trimmed:match("^END_STRUCT") or trimmed:match("^END_STRUCT;") then
in_struct = false
current_type_name = nil
pending_type_name = nil
pending_type_comment = nil
elseif trimmed:match("^STRUCT") then
if pending_type_name then
current_type_name = pending_type_name
in_struct = true
types[pending_type_name] = { name = pending_type_name, kind = "struct", fields = {}, source = "data_types_folder", comment = pending_type_comment }
pending_type_name = nil
pending_type_comment = nil
end
elseif not in_struct and trimmed:match("^%w+%s*:") and not trimmed:match("^STRUCT") then
-- This is an alias type declaration (TypeName : BaseType;)
-- NOT VERSION, TITLE, etc.
local type_name = trimmed:match("^(%w+)%s*:")
if type_name and type_name ~= "VERSION" and type_name ~= "TITLE" then
local next_line = lines[i + 1]
local next_trimmed = next_line and next_line:gsub("^%s+", ""):gsub("%s+$", "")
if next_trimmed and next_trimmed:match("^STRUCT") then
pending_type_name = type_name
else
pending_type_name = nil
current_type_name = nil
in_struct = false
local base_type = trimmed:match(":%s*([%w_]+)")
types[type_name] = { name = type_name, kind = "alias", base_type = base_type or "UNKNOWN", source = "data_types_folder" }
end
end
elseif in_struct and current_type_name and types[current_type_name] then
local field_name = line:match("^%s*(%w+)%s*{") or line:match("^%s*(%w+)%s*:")
if field_name and field_name ~= "END_STRUCT" then
-- Remove attribute blocks before extracting type
local clean_line = line:gsub("%b{}", "")
-- Extract field type - handle both quoted and unquoted types
local field_type = clean_line:match(':%s*"([^"]+)"') or clean_line:match(":%s*([%w_]+)")
-- Also handle array types
if not field_type then
field_type = clean_line:match(":%s*(ARRAY.+)")
end
local field_comment = line:match("//%s*(.+)$") or line:match("%(%*(.-)%*%)")
table.insert(types[current_type_name].fields, {
name = field_name,
type = field_type or "unknown",
comment = field_comment,
})
end
end
end
end
end
return types
end
--- Parse DB file content (DATA_BLOCK...END_DATA_BLOCK)
--- @param content string DB source code
--- @return table types Parsed DB types keyed by name
local function parse_db_file(content)
local types = {}
local in_var = false
local current_type_name = nil
content = content:gsub("^([\239\187\191]+)", "")
local lines = {}
for line in content:gmatch("[^\n]+") do table.insert(lines, line) end
for i, line in ipairs(lines) do
local trimmed = line:gsub("^%s+", ""):gsub("%s+$", "")
if trimmed:match('^DATA_BLOCK%s+"([^"]+)"') then
current_type_name = trimmed:match('^DATA_BLOCK%s+"([^"]+)"')
types[current_type_name] = {
name = current_type_name,
kind = "struct",
fields = {},
source = "db_file",
}
elseif trimmed:match("^END_VAR") then
in_var = false
elseif trimmed:match("^VAR%s*$") or trimmed:match("^VAR ") then
in_var = true
elseif in_var and current_type_name and types[current_type_name] and trimmed ~= "" and not trimmed:match("^//") then
local field_name = line:match("^%s*(%w+)%s*:")
if field_name then
local field_type = line:match(":%s*([%w_]+)")
local field_comment = line:match("//%s*(.+)$") or line:match("%(%*(.-)%*%)")
if field_type then
table.insert(types[current_type_name].fields, {
name = field_name,
type = field_type,
comment = field_comment,
})
end
end
end
end
return types
end
--- Load all types from workspace directory
--- Scans .plc.json, .scl, .udt, and .db files
--- @param root_dir string Project root directory
--- @return table Types keyed by name
function M.load_types_from_workspace(root_dir)
local types = {}
if not root_dir or root_dir == "" then return types end
-- Scan for .plc.json files
local plc_files = scan_files_recursive(root_dir, "*.plc.json")
for _, filepath in ipairs(plc_files) do
local content = run_command("cat " .. filepath:gsub(" ", "\\ "))
if content then
local data = load_json(content)
if data and (data.dataTypes or data.DataTypes or data.types or data.Types) then
local typeList = data.dataTypes or data.DataTypes or data.types or data.Types
for _, dt in ipairs(typeList) do
local typ = parse_plc_type(dt)
if typ and not types[typ.name] then
types[typ.name] = typ
end
end
end
end
end
-- Scan for plc.data.json files
local plc_data_files = scan_files_recursive(root_dir, "plc.data.json")
for _, filepath in ipairs(plc_data_files) do
local content = run_command("cat " .. filepath:gsub(" ", "\\ "))
if content then
local data = load_json(content)
if data and (data.dataTypes or data.DataTypes) then
for _, dt in ipairs(data.dataTypes or data.DataTypes) do
local typ = parse_plc_type(dt)
if typ and not types[typ.name] then
types[typ.name] = typ
end
end
end
end
end
-- Auto-generate from data_types folder (SCL files)
local data_types_files = scan_files_recursive(root_dir, "*.scl")
for _, filepath in ipairs(data_types_files) do
local content = run_command("cat " .. filepath:gsub(" ", "\\ "))
if content then
local scl_types = parse_scl_type_file(content)
for name, typ in pairs(scl_types) do
if not types[name] then
types[name] = typ
end
end
end
end
-- Also scan for .udt files
local udt_files = scan_files_recursive(root_dir, "*.udt")
for _, filepath in ipairs(udt_files) do
local content = run_command("cat " .. filepath:gsub(" ", "\\ "))
if content then
local udt_types = parse_scl_type_file(content)
for name, typ in pairs(udt_types) do
typ.source = "udt_file"
if not types[name] then
types[name] = typ
end
end
end
end
-- Also scan for .db files
local db_files = scan_files_recursive(root_dir, "*.db")
for _, filepath in ipairs(db_files) do
local content = run_command("cat " .. filepath:gsub(" ", "\\ "))
if content then
local db_types = parse_db_file(content)
for name, typ in pairs(db_types) do
typ.source = "db_file"
if not types[name] then
types[name] = typ
end
end
end
end
return types
end
--- Get types for a workspace (cached)
--- @param root_dir string Project root directory
--- @return table Types keyed by name
function M.get_types(root_dir)
if not root_dir or root_dir == "" then
root_dir = "workspace"
end
if cached_types[root_dir] then
return cached_types[root_dir]
end
local types = M.load_types_from_workspace(root_dir)
cached_types[root_dir] = types
return types
end
--- Generate .plc.json file from workspace types
--- @param root_dir string Project root directory
--- @param output_file string Output file path
--- @return boolean True if file was written successfully
function M.generate_plc_json(root_dir, output_file)
local json = dofile(script_path .. "/json.lua")
if not root_dir or root_dir == "" then return false end
local types = M.load_types_from_workspace(root_dir)
local dataTypes = {}
for _, typ in pairs(types) do
if typ.kind == "struct" then
local members = {}
for _, field in ipairs(typ.fields) do
table.insert(members, {
name = field.name,
dataTypeName = field.type,
})
end
table.insert(dataTypes, {
name = typ.name,
struct = members,
})
elseif typ.kind == "alias" then
table.insert(dataTypes, {
name = typ.name,
baseType = typ.base_type or "UNKNOWN",
})
end
end
local output = {
plcProject = {
dataTypes = dataTypes,
},
}
local content = json.encode(output)
local file = io.open(output_file, "w")
if file then
file:write(content)
file:close()
return true
end
return false
end
--- Clear all cached types
function M.clear_cache()
cached_types = {}
end
return M