475 lines
15 KiB
Lua
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
|