using System.Text.Json; using ScadaLink.Commons.Types.Flattening; namespace ScadaLink.TemplateEngine.Validation; /// /// Semantic validation rules for a FlattenedConfiguration: /// - CallScript/CallShared targets must reference existing scripts /// - Parameter count and types must match /// - Return type compatibility /// - Trigger operand types: RangeViolation requires numeric attribute /// - On-trigger script must exist /// - Instance scripts cannot call alarm on-trigger scripts /// public class SemanticValidator { // Known numeric data types for RangeViolation trigger type validation private static readonly HashSet NumericDataTypes = new(StringComparer.OrdinalIgnoreCase) { "Int32", "Float", "Double" }; /// /// Runs all semantic validation rules. /// /// The flattened configuration to validate. /// Shared scripts available for CallShared references. public ValidationResult Validate( FlattenedConfiguration configuration, IReadOnlyList? sharedScripts = null) { var errors = new List(); var warnings = new List(); var scriptNames = new HashSet( configuration.Scripts.Select(s => s.CanonicalName), StringComparer.Ordinal); var sharedScriptNames = new HashSet( (sharedScripts ?? []).Select(s => s.CanonicalName), StringComparer.Ordinal); var attributeMap = new Dictionary(StringComparer.Ordinal); foreach (var a in configuration.Attributes) { // Skip duplicates — naming collisions are reported separately attributeMap.TryAdd(a.CanonicalName, a); } // Collect alarm on-trigger script names for cross-call violation checks var alarmOnTriggerScripts = new HashSet(StringComparer.Ordinal); foreach (var alarm in configuration.Alarms) { if (!string.IsNullOrWhiteSpace(alarm.OnTriggerScriptCanonicalName)) alarmOnTriggerScripts.Add(alarm.OnTriggerScriptCanonicalName); } // Build parameter maps for call target validation var scriptParamMap = BuildParameterMap(configuration.Scripts); var sharedParamMap = BuildParameterMap(sharedScripts ?? []); var scriptReturnMap = BuildReturnMap(configuration.Scripts); var sharedReturnMap = BuildReturnMap(sharedScripts ?? []); foreach (var script in configuration.Scripts) { var callTargets = ExtractCallTargets(script.Code); foreach (var call in callTargets) { if (call.IsShared) { // CallShared targets must reference existing shared scripts if (!sharedScriptNames.Contains(call.TargetName)) { errors.Add(ValidationEntry.Error(ValidationCategory.CallTargetNotFound, $"Script '{script.CanonicalName}' calls shared script '{call.TargetName}' which does not exist.", script.CanonicalName)); } else { ValidateCallParameters(script.CanonicalName, call, sharedParamMap, errors); } } else { // CallScript targets must reference existing instance scripts if (!scriptNames.Contains(call.TargetName)) { errors.Add(ValidationEntry.Error(ValidationCategory.CallTargetNotFound, $"Script '{script.CanonicalName}' calls script '{call.TargetName}' which does not exist.", script.CanonicalName)); } else { ValidateCallParameters(script.CanonicalName, call, scriptParamMap, errors); // Instance scripts cannot call alarm on-trigger scripts if (alarmOnTriggerScripts.Contains(call.TargetName)) { errors.Add(ValidationEntry.Error(ValidationCategory.CrossCallViolation, $"Script '{script.CanonicalName}' calls alarm on-trigger script '{call.TargetName}' which is not allowed.", script.CanonicalName)); } } } } } // Validate alarm trigger operand types foreach (var alarm in configuration.Alarms) { // RangeViolation requires numeric attribute if (alarm.TriggerType == "RangeViolation" && !string.IsNullOrWhiteSpace(alarm.TriggerConfiguration)) { var attrName = ValidationService.ExtractAttributeNameFromTriggerConfig(alarm.TriggerConfiguration); if (attrName != null && attributeMap.TryGetValue(attrName, out var attr)) { if (!NumericDataTypes.Contains(attr.DataType)) { errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType, $"Alarm '{alarm.CanonicalName}' uses RangeViolation trigger on attribute '{attrName}' which has non-numeric type '{attr.DataType}'.", alarm.CanonicalName)); } } } // On-trigger script must exist if (!string.IsNullOrWhiteSpace(alarm.OnTriggerScriptCanonicalName) && !scriptNames.Contains(alarm.OnTriggerScriptCanonicalName)) { errors.Add(ValidationEntry.Error(ValidationCategory.OnTriggerScriptNotFound, $"Alarm '{alarm.CanonicalName}' references on-trigger script '{alarm.OnTriggerScriptCanonicalName}' which does not exist.", alarm.CanonicalName)); } } return new ValidationResult { Errors = errors, Warnings = warnings }; } private static void ValidateCallParameters( string callerName, CallTarget call, Dictionary> paramMap, List errors) { if (!paramMap.TryGetValue(call.TargetName, out var expectedParams)) return; if (call.ArgumentCount != expectedParams.Count) { errors.Add(ValidationEntry.Error(ValidationCategory.ParameterMismatch, $"Script '{callerName}' calls '{call.TargetName}' with {call.ArgumentCount} arguments but {expectedParams.Count} are expected.", callerName)); } } private static Dictionary> BuildParameterMap(IReadOnlyList scripts) { var result = new Dictionary>(StringComparer.Ordinal); foreach (var script in scripts) { var parameters = ParseParameterDefinitions(script.ParameterDefinitions); result[script.CanonicalName] = parameters; } return result; } private static Dictionary BuildReturnMap(IReadOnlyList scripts) { var result = new Dictionary(StringComparer.Ordinal); foreach (var script in scripts) { result[script.CanonicalName] = script.ReturnDefinition; } return result; } internal static List ParseParameterDefinitions(string? parameterDefinitionsJson) { if (string.IsNullOrWhiteSpace(parameterDefinitionsJson)) return []; try { using var doc = JsonDocument.Parse(parameterDefinitionsJson); if (doc.RootElement.ValueKind == JsonValueKind.Array) { return doc.RootElement.EnumerateArray() .Select(e => e.TryGetProperty("type", out var t) ? t.GetString() ?? "unknown" : "unknown") .ToList(); } } catch (JsonException) { } return []; } /// /// Extracts call targets from script code by simple pattern matching. /// Looks for CallScript("name", ...) and CallShared("name", ...) patterns. /// internal static List ExtractCallTargets(string code) { var results = new List(); ExtractCallsOfType(code, "CallScript", false, results); ExtractCallsOfType(code, "CallShared", true, results); return results; } private static void ExtractCallsOfType(string code, string methodName, bool isShared, List results) { var searchPattern = methodName + "("; int pos = 0; while (pos < code.Length) { var idx = code.IndexOf(searchPattern, pos, StringComparison.Ordinal); if (idx < 0) break; var argsStart = idx + searchPattern.Length; var target = ExtractStringArgument(code, argsStart); if (target != null) { var argCount = CountArguments(code, argsStart); results.Add(new CallTarget { TargetName = target, IsShared = isShared, ArgumentCount = Math.Max(0, argCount - 1) // First arg is the name, rest are parameters }); } pos = argsStart; } } private static string? ExtractStringArgument(string code, int startPos) { // Skip whitespace var pos = startPos; while (pos < code.Length && char.IsWhiteSpace(code[pos])) pos++; if (pos >= code.Length) return null; // Expect a quote var quote = code[pos]; if (quote != '"' && quote != '\'') return null; pos++; var nameStart = pos; while (pos < code.Length && code[pos] != quote) pos++; if (pos >= code.Length) return null; return code[nameStart..pos]; } private static int CountArguments(string code, int startPos) { var depth = 1; var count = 1; // At least one argument (the name) var pos = startPos; while (pos < code.Length && depth > 0) { switch (code[pos]) { case '(': depth++; break; case ')': depth--; break; case ',' when depth == 1: count++; break; case '"': case '\'': // Skip string literals var quote = code[pos]; pos++; while (pos < code.Length && code[pos] != quote) { if (code[pos] == '\\') pos++; // Skip escaped chars pos++; } break; } pos++; } return count; } internal record CallTarget { public string TargetName { get; init; } = string.Empty; public bool IsShared { get; init; } public int ArgumentCount { get; init; } } }