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