using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
namespace ZB.MOM.WW.ScadaBridge.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.
/// Connection names that support alarm subscriptions; used to validate native alarm source bindings.
/// A containing all semantic errors and warnings found.
public ValidationResult Validate(
FlattenedConfiguration configuration,
IReadOnlyList? sharedScripts = null,
IReadOnlySet? alarmCapableConnectionNames = null)
{
var errors = new List();
var warnings = new List();
var scriptNames = new HashSet(
configuration.Scripts.Select(s => s.CanonicalName), StringComparer.Ordinal);
// Composition-delegated CallScript: a machine script may invoke a composed
// child's script via Children["X"].CallScript("Y") (often with a DYNAMIC child
// name, e.g. Children[side + "MESReceiver"]). ExtractCalls captures only the
// literal leaf "Y"; the actual flattened script is the composed canonical
// "X.Y". Since the child segment is dynamic it cannot be statically resolved,
// so we accept the call as existing when ANY composed script has that leaf
// name. The positional arg-count/type checks already self-skip for these
// (their canonical name "X.Y" is not the literal key "Y" in the param map).
var composedLeafNames = new HashSet(
configuration.Scripts
.Select(s => s.CanonicalName)
.Where(n => n.Contains('.'))
.Select(n => n[(n.LastIndexOf('.') + 1)..]),
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);
}
// List-attribute type semantics (MV-5): element-type cardinality + default
// value parseability. Trigger-operand rejection (rule 3) is handled below
// by the existing NumericDataTypes guard (List is never numeric).
ValidateListAttributes(configuration, errors);
// 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);
ValidateCallReturnType(script.CanonicalName, call, sharedReturnMap, errors);
}
}
else
{
// CallScript targets must reference an existing instance script —
// either a same-scope sibling (canonical name) or a composition-
// delegated child script (leaf-name match; see composedLeafNames).
if (!scriptNames.Contains(call.TargetName)
&& !composedLeafNames.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);
ValidateCallReturnType(script.CanonicalName, call, scriptReturnMap, 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 Call-type scripts have parameter definitions
foreach (var script in configuration.Scripts)
{
if (string.Equals(script.TriggerType, "Call", StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(script.ParameterDefinitions))
{
warnings.Add(ValidationEntry.Warning(ValidationCategory.MissingMetadata,
$"Call-type script '{script.CanonicalName}' has no parameter definitions.",
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));
}
}
}
// HiLo requires numeric attribute + ordered setpoints
if (alarm.TriggerType == "HiLo" && !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 HiLo trigger on attribute '{attrName}' which has non-numeric type '{attr.DataType}'.",
alarm.CanonicalName));
}
}
var setpoints = ValidationService.ExtractHiLoSetpoints(alarm.TriggerConfiguration);
// At least one setpoint must be configured — otherwise the alarm
// can never fire.
if (!setpoints.LoLo.HasValue && !setpoints.Lo.HasValue
&& !setpoints.Hi.HasValue && !setpoints.HiHi.HasValue)
{
warnings.Add(ValidationEntry.Warning(ValidationCategory.TriggerOperandType,
$"Alarm '{alarm.CanonicalName}' is HiLo but no setpoints (LoLo/Lo/Hi/HiHi) are configured — it will never fire.",
alarm.CanonicalName));
}
// Ordering: LoLo ≤ Lo, Hi ≤ HiHi, and the highest Lo-side band
// must sit strictly below the lowest Hi-side band — otherwise the
// bands overlap and the evaluator's behavior is ambiguous.
if (setpoints.LoLo is { } loLo && setpoints.Lo is { } lo && loLo > lo)
{
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
$"Alarm '{alarm.CanonicalName}' HiLo setpoints out of order: LoLo ({loLo}) must be ≤ Lo ({lo}).",
alarm.CanonicalName));
}
if (setpoints.Hi is { } hi && setpoints.HiHi is { } hiHi && hi > hiHi)
{
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
$"Alarm '{alarm.CanonicalName}' HiLo setpoints out of order: Hi ({hi}) must be ≤ HiHi ({hiHi}).",
alarm.CanonicalName));
}
var highestLowSide = setpoints.Lo ?? setpoints.LoLo;
var lowestHighSide = setpoints.Hi ?? setpoints.HiHi;
if (highestLowSide is { } lowSide && lowestHighSide is { } highSide
&& lowSide >= highSide)
{
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
$"Alarm '{alarm.CanonicalName}' HiLo bands overlap: low-side setpoint ({lowSide}) must be strictly less than high-side setpoint ({highSide}).",
alarm.CanonicalName));
}
// Deadbands must be non-negative — negative deadband would invert
// the hysteresis (alarm could escape faster than it entered).
foreach (var (name, value) in new (string, double?)[] {
("LoLo deadband", setpoints.LoLoDeadband),
("Lo deadband", setpoints.LoDeadband),
("Hi deadband", setpoints.HiDeadband),
("HiHi deadband", setpoints.HiHiDeadband)
})
{
if (value is { } d && d < 0)
{
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
$"Alarm '{alarm.CanonicalName}' {name} ({d}) must be non-negative.",
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));
}
}
// Native alarm source bindings: connection + source reference must be
// present, and (when the alarm-capable connection set is supplied) the
// connection must resolve to an alarm-capable site data connection.
foreach (var nativeSource in configuration.NativeAlarmSources)
{
if (string.IsNullOrWhiteSpace(nativeSource.SourceReference))
{
errors.Add(ValidationEntry.Error(ValidationCategory.NativeAlarmSourceInvalid,
$"Native alarm source '{nativeSource.CanonicalName}' has an empty source reference.",
nativeSource.CanonicalName));
}
if (string.IsNullOrWhiteSpace(nativeSource.ConnectionName))
{
errors.Add(ValidationEntry.Error(ValidationCategory.NativeAlarmSourceInvalid,
$"Native alarm source '{nativeSource.CanonicalName}' has no data connection.",
nativeSource.CanonicalName));
}
else if (alarmCapableConnectionNames is not null &&
!alarmCapableConnectionNames.Contains(nativeSource.ConnectionName))
{
errors.Add(ValidationEntry.Error(ValidationCategory.NativeAlarmSourceInvalid,
$"Native alarm source '{nativeSource.CanonicalName}' references connection '{nativeSource.ConnectionName}' which is not an alarm-capable data connection on this site.",
nativeSource.CanonicalName));
}
}
return new ValidationResult { Errors = errors, Warnings = warnings };
}
///
/// MV-5 — semantic validation of List-attribute type configuration. Two rules:
///
/// - Element-type cardinality. A attribute
/// must carry a non-empty that is
/// a valid element scalar (see );
/// a non-List attribute must NOT carry an element type.
/// - Default-value parseability. A non-empty authored default
/// on a List attribute must
/// without throwing.
///
/// Attributes whose doesn't parse to a
/// known are skipped here (their data type is not "List",
/// so only the "no element type" half could apply, and an unparseable type is a
/// separate concern not introduced by this feature).
///
private static void ValidateListAttributes(
FlattenedConfiguration configuration,
List errors)
{
foreach (var attr in configuration.Attributes)
{
var isList = string.Equals(attr.DataType, nameof(DataType.List), StringComparison.OrdinalIgnoreCase);
var hasElementType = !string.IsNullOrWhiteSpace(attr.ElementDataType);
// ── Rule 1: element-type cardinality ─────────────────────────────
if (!isList)
{
if (hasElementType)
{
errors.Add(ValidationEntry.Error(ValidationCategory.MissingMetadata,
$"Attribute '{attr.CanonicalName}' has data type '{attr.DataType}' but declares an element type '{attr.ElementDataType}'; element types are only valid on List attributes.",
attr.CanonicalName));
}
continue; // Non-List attributes have no list-specific value to check.
}
if (!hasElementType)
{
errors.Add(ValidationEntry.Error(ValidationCategory.MissingMetadata,
$"List attribute '{attr.CanonicalName}' must declare an element type (one of String, Int32, Float, Double, Boolean, DateTime).",
attr.CanonicalName));
continue; // Without an element type we can't validate the default value.
}
if (!Enum.TryParse(attr.ElementDataType, ignoreCase: true, out var elementType)
|| !AttributeValueCodec.IsValidElementType(elementType))
{
errors.Add(ValidationEntry.Error(ValidationCategory.MissingMetadata,
$"List attribute '{attr.CanonicalName}' has element type '{attr.ElementDataType}', which is not a valid element scalar (one of String, Int32, Float, Double, Boolean, DateTime).",
attr.CanonicalName));
continue; // A bad element type makes the default-value check meaningless.
}
// ── Rule 2: default-value parseability ───────────────────────────
if (!string.IsNullOrWhiteSpace(attr.Value))
{
try
{
AttributeValueCodec.Decode(attr.Value, DataType.List, elementType);
}
catch (FormatException ex)
{
errors.Add(ValidationEntry.Error(ValidationCategory.MissingMetadata,
$"List attribute '{attr.CanonicalName}' has a default value that is not a valid list of '{elementType}': {ex.Message}",
attr.CanonicalName));
}
}
}
}
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));
// Count mismatch already reported — positional type matching below
// would be misaligned, so don't compound the noise.
return;
}
ValidateArgumentTypes(callerName, call, expectedParams, errors);
}
///
/// #21 — Argument-type validation. Compares each positionally-matched call
/// argument expression against the target's declared parameter type and
/// flags only CLEAR cross-category mismatches.
///
/// Conservatism (false-positive avoidance) — a parameter is checked only
/// when BOTH sides are confidently known:
///
/// - Declared type must normalize to a known primitive (String, Integer,
/// Float, Boolean). Object/List/unknown declarations accept
/// anything — never flagged.
/// - Argument expression type must be inferable from a literal
/// (string/char, integer, decimal, true/false). Variables,
/// member access, method/await chains, null, casts, object/array
/// initializers, and anything else infer to Unknown and are never flagged.
/// - Integer⇄Float is treated as compatible (numeric widening) — never
/// flagged.
///
///
private static void ValidateArgumentTypes(
string callerName,
CallTarget call,
List expectedParams,
List errors)
{
// Argument expressions are aligned 1:1 with parameters here (count was
// verified equal by the caller). If the argument text couldn't be split
// (e.g. it wasn't captured), skip silently.
if (call.ArgumentExpressions.Count != expectedParams.Count)
return;
for (var i = 0; i < expectedParams.Count; i++)
{
var declared = NormalizeType(expectedParams[i]);
if (declared is null)
continue; // Object/List/unknown declaration accepts anything.
var actual = InferLiteralType(call.ArgumentExpressions[i]);
if (actual is null)
continue; // Can't confidently infer the argument's type.
if (!IsAssignable(actual.Value, declared.Value))
{
errors.Add(ValidationEntry.Error(ValidationCategory.ParameterMismatch,
$"Script '{callerName}' calls '{call.TargetName}' argument {i + 1} with type '{actual}' but parameter '{expectedParams[i]}' expects '{declared}'.",
callerName));
}
}
}
///
/// #20 — Return-type validation. When a call result is assigned directly
/// into a typed local declaration (int x = CallScript(...),
/// bool b = await CallShared(...)), compares the LHS declared type
/// against the target's declared return type and flags clear mismatches.
///
/// Conservatism (false-positive avoidance) — flagged only when ALL hold:
///
/// - The call result is captured by a typed local whose type is a known
/// primitive (so var, object, dynamic, and untyped
/// reuse are never flagged).
/// - The call is the WHOLE initializer (optionally preceded by
/// await). If the result feeds an expression / method chain
/// (e.g. (int)(await CallScript(...)), CallScript(...).X)
/// the assigned-type is not captured and nothing is flagged.
/// - The target declares a known-primitive return type. Missing/Object/
/// List/unknown returns are never flagged.
/// - Integer⇄Float is compatible (numeric widening) — never flagged.
///
///
private static void ValidateCallReturnType(
string callerName,
CallTarget call,
Dictionary returnMap,
List errors)
{
if (call.AssignedToType is null)
return; // Result not captured by a typed local (var/untyped/unused).
var expected = NormalizeType(call.AssignedToType);
if (expected is null)
return; // LHS isn't a known primitive — don't guess.
if (!returnMap.TryGetValue(call.TargetName, out var returnDef))
return;
var actual = NormalizeType(ParseReturnDefinitionType(returnDef));
if (actual is null)
return; // Target's return type unknown/non-primitive.
if (!IsAssignable(actual.Value, expected.Value))
{
errors.Add(ValidationEntry.Error(ValidationCategory.ReturnTypeMismatch,
$"Script '{callerName}' assigns the '{actual}' return value of '{call.TargetName}' to a '{expected}' variable.",
callerName));
}
}
private static Dictionary> BuildParameterMap(IReadOnlyList scripts)
{
var result = new Dictionary>(StringComparer.Ordinal);
foreach (var script in scripts)
{
// Per-parameter declared TYPE in declared order (raw type strings).
// One entry per parameter, so the existing count check is preserved
// while #21 also has the types it needs for positional matching.
var parameters = ParseParameterTypes(script.ParameterDefinitions);
result[script.CanonicalName] = parameters;
}
return result;
}
///
/// Parses a parameter definitions JSON string (JSON Schema or legacy flat
/// array) and returns the declared parameter TYPE for each parameter, in
/// declared order. Names are not needed for positional call validation; the
/// returned count equals the parameter count (preserving the count check).
///
/// JSON Schema or legacy flat-array string; null/empty returns an empty list.
/// The per-parameter raw type strings (e.g. "Int32", "string", "List").
internal static List ParseParameterTypes(string? parameterDefinitionsJson)
{
if (string.IsNullOrWhiteSpace(parameterDefinitionsJson))
return [];
try
{
using var doc = JsonDocument.Parse(parameterDefinitionsJson);
// JSON Schema: { type:"object", properties:{ name:{ type:"integer" }, ... } }
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
if (doc.RootElement.TryGetProperty("properties", out var props)
&& props.ValueKind == JsonValueKind.Object)
{
return props.EnumerateObject()
.Select(p => p.Value.ValueKind == JsonValueKind.Object
&& p.Value.TryGetProperty("type", out var t)
&& t.ValueKind == JsonValueKind.String
? t.GetString() ?? "unknown"
: "unknown")
.ToList();
}
}
// Legacy flat form: [{ name, type, required? }]
else 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 the declared return type from a ReturnDefinition JSON string
/// (JSON Schema {type:"..."} or legacy {type:"..."}). Returns
/// null when absent or unparseable.
///
/// JSON return definition; null/empty returns null.
/// The raw return type string (e.g. "boolean", "Int32"), or null.
internal static string? ParseReturnDefinitionType(string? returnDefinitionJson)
{
if (string.IsNullOrWhiteSpace(returnDefinitionJson))
return null;
try
{
using var doc = JsonDocument.Parse(returnDefinitionJson);
if (doc.RootElement.ValueKind == JsonValueKind.Object
&& doc.RootElement.TryGetProperty("type", out var t)
&& t.ValueKind == JsonValueKind.String)
{
return t.GetString();
}
}
catch (JsonException)
{
}
return null;
}
private static Dictionary BuildReturnMap(IReadOnlyList scripts)
{
var result = new Dictionary(StringComparer.Ordinal);
foreach (var script in scripts)
{
result[script.CanonicalName] = script.ReturnDefinition;
}
return result;
}
///
/// Extracts call targets from script code by simple pattern matching.
/// Looks for CallScript("name", ...) and CallShared("name", ...) patterns.
///
/// The script source code to scan.
/// The list of call targets found (both CallScript and CallShared invocations).
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)
{
// First argument is the script name; the rest are the call's
// positional arguments.
var args = SplitCallArguments(code, argsStart);
var argExpressions = args.Count > 1
? args.GetRange(1, args.Count - 1)
: new List();
results.Add(new CallTarget
{
TargetName = target,
IsShared = isShared,
ArgumentCount = argExpressions.Count,
ArgumentExpressions = argExpressions,
// #20: the declared type the result is assigned into, if the
// call is the whole initializer of a typed local declaration.
AssignedToType = ExtractAssignedToType(code, idx)
});
}
pos = argsStart;
}
}
///
/// Splits a call's argument list (starting just after the opening paren)
/// into top-level argument expressions, trimmed. Tracks parenthesis, brace,
/// and bracket nesting plus string/char literals so object initializers,
/// nested calls, collection expressions, and commas inside literals don't
/// produce spurious splits. Element 0 is the script-name argument.
///
private static List SplitCallArguments(string code, int startPos)
{
var args = new List();
var depthParen = 1; // we start inside the call's own '('
var depthBraceBracket = 0;
var pos = startPos;
var argStart = startPos;
while (pos < code.Length)
{
var c = code[pos];
switch (c)
{
case '(':
depthParen++;
break;
case ')':
depthParen--;
if (depthParen == 0)
{
AddArg(code, argStart, pos, args);
return args;
}
break;
case '{':
case '[':
depthBraceBracket++;
break;
case '}':
case ']':
if (depthBraceBracket > 0) depthBraceBracket--;
break;
case ',' when depthParen == 1 && depthBraceBracket == 0:
AddArg(code, argStart, pos, args);
argStart = pos + 1;
break;
case '"':
case '\'':
// Skip the literal body so its delimiters/commas are ignored.
pos++;
while (pos < code.Length && code[pos] != c)
{
if (code[pos] == '\\') pos++; // skip escaped char
pos++;
}
break;
case '/':
// Skip C# line and block comments so commas inside them are ignored.
// A `/` inside a string literal is already consumed above, so we only
// reach here for real `/` tokens in code.
if (pos + 1 < code.Length)
{
if (code[pos + 1] == '/')
{
// Line comment: skip to end-of-line.
pos += 2;
while (pos < code.Length && code[pos] != '\n') pos++;
}
else if (code[pos + 1] == '*')
{
// Block comment: skip to closing `*/`.
pos += 2;
while (pos + 1 < code.Length && !(code[pos] == '*' && code[pos + 1] == '/'))
pos++;
if (pos + 1 < code.Length) pos++; // step over the `/`
}
}
break;
}
pos++;
}
// Unterminated call (shouldn't happen for compilable code) — best effort.
AddArg(code, argStart, code.Length, args);
return args;
static void AddArg(string code, int start, int end, List acc)
{
var text = code[start..end].Trim();
// Only the trailing empty slice after a lone name (e.g. "foo",) is
// dropped; an empty arg list ("foo") still yields just the name.
if (text.Length > 0 || acc.Count == 0)
acc.Add(text);
}
}
///
/// #20 inference — looks backwards from the call's start index for a typed
/// local declaration whose initializer is exactly this call (optionally
/// preceded by await). The call may be qualified by a simple receiver
/// (Instance., Scripts., Parent.,
/// Children["x"].) which is skipped. Returns the declared LHS type
/// token, or null when the result isn't captured by a simple typed local
/// (e.g. var, no assignment, reassignment to an existing variable, or
/// the call is part of a larger expression such as a cast or longer
/// member-access chain).
///
private static string? ExtractAssignedToType(string code, int callIndex)
{
// Walk back over a simple dotted receiver immediately before the call —
// e.g. the "Instance." / "Scripts." / "Children[\"x\"]." prefix on a
// qualified call. Only identifier chars, '.', and bracketed indexers
// (with string/identifier contents) are skipped; anything else (a ')',
// an operator, another call's '(') means the call is embedded in a
// larger expression and we must not infer.
var receiverStart = SkipReceiverBackwards(code, callIndex);
// Walk back over whitespace immediately before the receiver/call.
var i = receiverStart - 1;
while (i >= 0 && char.IsWhiteSpace(code[i])) i--;
if (i < 0) return null;
// The call must be the entire RHS: the char before it (after optional
// 'await') must be '='. Anything else (')', '.', '(', operators) means
// the result is consumed by a larger expression — don't infer.
var beforeCall = code[..(i + 1)];
// Strip a trailing 'await' so "= await CallScript(...)" is handled.
var awaitTrimmed = beforeCall.TrimEnd();
if (awaitTrimmed.EndsWith("await", StringComparison.Ordinal)
&& (awaitTrimmed.Length == 5 || !IsIdentifierChar(awaitTrimmed[^6])))
{
beforeCall = awaitTrimmed[..^5];
}
beforeCall = beforeCall.TrimEnd();
if (!beforeCall.EndsWith('=')) return null;
// Exclude '==', '<=', '>=', '!=' etc. — comparisons, not assignment.
if (beforeCall.Length >= 2)
{
var prev = beforeCall[^2];
if (prev is '=' or '!' or '<' or '>' or '+' or '-' or '*' or '/' or '%' or '&' or '|' or '^')
return null;
}
// Now parse the " " declaration that precedes the '='.
var decl = beforeCall[..^1].TrimEnd();
// Identifier (the variable name).
var end = decl.Length;
var nameEnd = end;
while (nameEnd > 0 && IsIdentifierChar(decl[nameEnd - 1])) nameEnd--;
if (nameEnd == end) return null; // no identifier
var nameStart = nameEnd;
// Whitespace between type and name.
var ws = nameStart;
while (ws > 0 && char.IsWhiteSpace(decl[ws - 1])) ws--;
if (ws == nameStart) return null; // need separating whitespace → "type name"
// The type token (single identifier/keyword — no generics/arrays here;
// those normalize to unknown anyway and stay unflagged).
var typeEnd = ws;
var typeStart = typeEnd;
while (typeStart > 0 && IsIdentifierChar(decl[typeStart - 1])) typeStart--;
if (typeStart == typeEnd) return null;
// Guard against picking up a keyword that isn't a type in this position
// (e.g. "return x = ..."). A real declaration's type token is preceded
// by a statement boundary or open brace, not by another identifier.
if (typeStart > 0)
{
var b = typeStart - 1;
while (b >= 0 && char.IsWhiteSpace(decl[b])) b--;
if (b >= 0 && IsIdentifierChar(decl[b]))
return null; // preceded by another word → not a clean declaration
}
return decl[typeStart..typeEnd];
}
private static bool IsIdentifierChar(char c) => char.IsLetterOrDigit(c) || c == '_';
///
/// Given the index of a CallScript/CallShared token, walks
/// backwards over a leading receiver expression composed only of identifier
/// chars, '.', and bracketed indexers (["x"]), and returns the index
/// where that receiver begins. If there is no '.' immediately before the
/// token (an unqualified call) the original index is returned unchanged.
/// Stops at the first character that can't be part of such a simple
/// receiver, so casts/parenthesised/chained-method receivers aren't
/// mistaken for a clean assignment target.
///
private static int SkipReceiverBackwards(string code, int callIndex)
{
var i = callIndex - 1;
// Optional whitespace then must be a '.' for there to be a receiver.
while (i >= 0 && char.IsWhiteSpace(code[i])) i--;
if (i < 0 || code[i] != '.') return callIndex;
var start = callIndex;
while (i >= 0)
{
var c = code[i];
if (c == '.' || IsIdentifierChar(c) || char.IsWhiteSpace(c))
{
start = i;
i--;
continue;
}
if (c == ']')
{
// Skip a single (non-nested) indexer "[ ... ]" with string or
// identifier contents — e.g. Children["pump"].
var j = i - 1;
while (j >= 0 && code[j] != '[' && code[j] != '(' && code[j] != ')')
j--;
if (j < 0 || code[j] != '[') return start;
start = j;
i = j - 1;
continue;
}
break;
}
return start;
}
// ── Script-level type vocabulary (#20/#21) ──────────────────────────────
//
// The template scripting "type system" exposed in ParameterDefinitions /
// ReturnDefinition is a small set: String, Integer, Float, Boolean, plus
// Object / List (and arbitrary unrecognised names). Only the four scalar
// primitives below are matched; everything else maps to null ("unknown"),
// which the validators treat as "accept anything / don't flag".
private enum ScriptType { String, Integer, Float, Boolean }
///
/// Maps a declared type token (JSON-Schema name, legacy name, or a C# type
/// keyword used on a call-site LHS) onto a , or null
/// when the type isn't one of the confidently-checkable primitives.
///
private static ScriptType? NormalizeType(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
return raw.Trim().ToLowerInvariant() switch
{
"string" or "datetime" => ScriptType.String,
"integer" or "int" or "int32" or "int64" or "long" or "short" or "byte" => ScriptType.Integer,
"float" or "double" or "decimal" or "number" or "single" => ScriptType.Float,
"boolean" or "bool" => ScriptType.Boolean,
// Object, List, array, var, dynamic, and anything else → unknown.
_ => null,
};
}
///
/// Infers the of a call-site argument expression,
/// but ONLY for unambiguous literals. Returns null for variables, member
/// access, method/await chains, null, casts, parenthesised/compound
/// expressions, and object/array/collection initializers — those can't be
/// statically typed here and must never be flagged.
///
private static ScriptType? InferLiteralType(string expr)
{
expr = expr.Trim();
if (expr.Length == 0) return null;
// String / char literal — but only if the WHOLE expression is the
// literal (so "a" + x or x + "b" stays unknown).
if ((expr[0] == '"' || expr[0] == '\'') && IsWholeStringLiteral(expr))
return ScriptType.String;
if (expr.StartsWith('@') && expr.Length > 1 && expr[1] == '"' && IsWholeStringLiteral(expr[1..]))
return ScriptType.String;
if (expr.StartsWith('$'))
return null; // interpolated string — string-ish, but be conservative.
if (expr is "true" or "false")
return ScriptType.Boolean;
// Numeric literal (optionally signed). Float if it has a '.', 'e'/'E'
// exponent, or a float/double/decimal suffix; otherwise Integer.
if (IsNumericLiteral(expr, out var isFloat))
return isFloat ? ScriptType.Float : ScriptType.Integer;
return null; // Not a literal we can confidently classify.
}
private static bool IsWholeStringLiteral(string expr)
{
if (expr.Length < 2) return false;
var quote = expr[0];
if (quote != '"' && quote != '\'') return false;
var i = 1;
while (i < expr.Length)
{
if (expr[i] == '\\') { i += 2; continue; }
if (expr[i] == quote) return i == expr.Length - 1; // closing quote must be last char
i++;
}
return false;
}
private static bool IsNumericLiteral(string expr, out bool isFloat)
{
isFloat = false;
var i = 0;
if (expr.Length == 0) return false;
if (expr[0] == '+' || expr[0] == '-') i++;
// A genuine numeric literal must start with a digit or a `.` followed by a
// digit. Identifiers that start with `_` or a letter (e.g. `_2`, `count`)
// are explicitly rejected here so they are inferred as Unknown, not Integer.
if (i >= expr.Length) return false;
var first = expr[i];
if (first == '.')
{
if (i + 1 >= expr.Length || !char.IsDigit(expr[i + 1])) return false;
}
else if (!char.IsDigit(first))
{
return false; // starts with `_`, letter, or anything else → not a literal
}
var sawDigit = false;
var sawDot = false;
var sawExp = false;
for (; i < expr.Length; i++)
{
var c = expr[i];
if (char.IsDigit(c)) { sawDigit = true; continue; }
if (c == '_' && sawDigit) continue; // digit separator — only valid between digits
if (c == '.' && !sawDot && !sawExp) { sawDot = true; isFloat = true; continue; }
if ((c == 'e' || c == 'E') && !sawExp && sawDigit)
{
sawExp = true; isFloat = true;
if (i + 1 < expr.Length && (expr[i + 1] == '+' || expr[i + 1] == '-')) i++;
continue;
}
// Numeric suffix terminates the literal.
if (i == expr.Length - 1 || (i == expr.Length - 2))
{
var suffix = expr[i..].ToLowerInvariant();
switch (suffix)
{
case "f": case "d": case "m": isFloat = true; return sawDigit;
case "l": case "u": case "ul": case "lu": return sawDigit; // integer suffixes
}
}
return false; // any other char → not a plain numeric literal
}
return sawDigit;
}
///
/// Whether an argument/return of type is
/// acceptable where is declared. Exact match, or
/// Integer⇄Float numeric widening. All other cross-category pairings
/// (String↔number, String↔Boolean, Boolean↔number) are mismatches.
///
private static bool IsAssignable(ScriptType actual, ScriptType expected)
{
if (actual == expected) return true;
// Numeric widening / narrowing between Integer and Float is tolerated —
// the scripting runtime coerces these and flagging them is noisy.
return (actual == ScriptType.Integer && expected == ScriptType.Float)
|| (actual == ScriptType.Float && expected == ScriptType.Integer);
}
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];
}
internal record CallTarget
{
/// Name of the script being called.
public string TargetName { get; init; } = string.Empty;
/// True when the call is to a shared script via CallShared.
public bool IsShared { get; init; }
/// Number of non-name arguments passed to the call.
public int ArgumentCount { get; init; }
/// The trimmed text of each non-name positional argument expression, in order.
public IReadOnlyList ArgumentExpressions { get; init; } = [];
///
/// The declared type token the call result is assigned into, when the
/// call is the whole initializer of a typed local declaration; otherwise
/// null (var/untyped/unused/expression-embedded). Used by #20.
///
public string? AssignedToType { get; init; }
}
}