using System;
using System.Collections.Generic;
using System.Globalization;
using Newtonsoft.Json.Linq;
using Unity.Pipeline.Commands;
namespace Unity.Pipeline
{
///
/// Binds argv tokens (the tokens AFTER the command name) to a command's declared
/// parameters, producing the same a client would have sent as
/// structured parameters.
///
/// Values are stored as the structured form would carry them. For a parameter with a
/// declared type that means a string JValue, never pre-converted — the single most important
/// constraint here, because it makes {"commandLine":"log_editor hi"} and
/// {"command":"log_editor","parameters":{"message":"hi"}} indistinguishable from
/// ExtractCommandParameters downwards, so there is exactly one execution path and
/// one place where coercion can differ. An UNTYPED parameter has no type to coerce toward, so
/// there the token's shape decides — see StoreValue.
///
/// Every defect is accumulated rather than bailing on the first, so a user fixing a command
/// line sees all of its problems at once. Required-parameter checking deliberately stays
/// OUT of here — ValidateCommandParameters owns it, so the message and error type are
/// byte-identical across both request forms.
///
/// Grammar: --key value, --key=value, bare --key. A follower is
/// consumed unless it starts with --, which is what makes --path -foo work.
/// -- ends flag parsing. No short flags, no -abc bundling, no --no-x,
/// no repeated-flag-becomes-array.
///
internal static class CommandLineBinder
{
private static readonly CommandParameterInfo[] k_NoParameters = new CommandParameterInfo[0];
///
/// Binds against . Returns true only
/// when no problems were found; is always populated as far
/// as binding got, so a caller may inspect a partial bind.
///
public static bool TryBind(CommandInfo command, IReadOnlyList args,
out JObject parameters, out List problems)
{
if (command == null)
throw new ArgumentNullException(nameof(command));
parameters = new JObject();
problems = new List();
var declared = command.Parameters ?? (IReadOnlyList)k_NoParameters;
if (args == null || args.Count == 0)
return true;
// Pass 1 - classify flags. Positionals are collected with a flag recording whether
// they appeared after "--", which exempts them from the -foo and key=value
// heuristics below.
var flagFilled = new HashSet(StringComparer.OrdinalIgnoreCase);
// Which parameters were set by a BARE flag. Tracked rather than inferred from the
// stored value being boolean: an untyped parameter given the literal token `true`
// also stores a boolean, and conflating the two reported a phantom missing value.
// The token as typed, per parameter, so a type error can quote the user verbatim even
// when the stored value was literal-parsed into a non-string.
var rawTokens = new Dictionary(StringComparer.OrdinalIgnoreCase);
var bareFlagged = new HashSet(StringComparer.OrdinalIgnoreCase);
var positionals = new List();
var positionalExempt = new List();
var endOfFlags = false;
for (var i = 0; i < args.Count; i++)
{
var arg = args[i] ?? string.Empty;
if (!endOfFlags && arg == "--")
{
endOfFlags = true;
continue;
}
if (endOfFlags || !arg.StartsWith("--", StringComparison.Ordinal))
{
positionals.Add(arg);
positionalExempt.Add(endOfFlags);
continue;
}
var body = arg.Substring(2);
var equals = body.IndexOf('=');
var name = equals >= 0 ? body.Substring(0, equals) : body;
var inlineValue = equals >= 0 ? body.Substring(equals + 1) : null;
if (name.Length == 0)
{
problems.Add(new ArgProblem { Kind = ArgProblemKind.EmptyName, Token = arg });
continue;
}
var parameter = FindParameter(declared, name);
if (parameter == null)
{
problems.Add(new ArgProblem
{
Kind = ArgProblemKind.UnknownName,
Name = name,
Suggestion = SuggestParameterName(name, declared),
});
// Swallow what was clearly meant as this flag's value, so it does not
// resurface as a spurious excess positional.
if (inlineValue == null && HasConsumableFollower(args, i))
i++;
continue;
}
// Checked before the empty-value bail below: a second `--key=` is BOTH a
// duplicate and an empty value, and reporting only the second would let a user
// fix what they were told about and still be duplicating the flag.
if (flagFilled.Contains(parameter.Name))
problems.Add(new ArgProblem { Kind = ArgProblemKind.Duplicate, Name = parameter.Name });
if (inlineValue != null && inlineValue.Length == 0)
{
problems.Add(new ArgProblem { Kind = ArgProblemKind.EmptyValue, Name = parameter.Name });
continue;
}
JToken value;
if (inlineValue != null)
{
value = inlineValue;
}
else if (HasConsumableFollower(args, i))
{
// Absence and emptiness are different: `--message ""` is the empty string,
// not the boolean true.
value = args[++i] ?? string.Empty;
}
else
{
value = new JValue(true);
bareFlagged.Add(parameter.Name);
}
if (value.Type == JTokenType.String)
{
var text = value.Value();
parameters[parameter.Name] = StoreValue(parameter, text);
rawTokens[parameter.Name] = text;
}
else
{
parameters[parameter.Name] = value;
}
flagFilled.Add(parameter.Name);
}
// Pass 2 - positional slots: required parameters in declaration order, then
// optional in declaration order. A slot already filled by a flag is SKIPPED, not
// overwritten, so the explicit flag always wins its own slot.
var slots = new List(declared.Count);
for (var i = 0; i < declared.Count; i++)
if (declared[i].Required)
slots.Add(declared[i]);
for (var i = 0; i < declared.Count; i++)
if (!declared[i].Required)
slots.Add(declared[i]);
var free = new List(slots.Count);
for (var i = 0; i < slots.Count; i++)
if (!flagFilled.Contains(slots[i].Name))
free.Add(slots[i]);
var next = 0;
var firstOverflow = -1;
for (var i = 0; i < positionals.Count; i++)
{
var token = positionals[i];
if (!positionalExempt[i])
{
// Both of these are reported but STILL consume a slot: the token was
// clearly meant as a positional, and pretending otherwise would cascade
// into misleading downstream problems.
if (IsSingleDashFlag(token))
{
problems.Add(new ArgProblem { Kind = ArgProblemKind.SingleDash, Token = token });
}
else if (next < free.Count && IsBareAssignmentNamingItsOwnSlot(token, free[next]))
{
problems.Add(new ArgProblem { Kind = ArgProblemKind.BareAssignment, Token = token });
}
}
if (next < free.Count)
{
var slot = free[next++];
parameters[slot.Name] = StoreValue(slot, token);
rawTokens[slot.Name] = token;
}
else if (firstOverflow < 0)
{
firstOverflow = i;
}
}
// Pass 3 - coerce, as a DRY RUN only. We never rewrite what we stored: the executor
// re-converts from the same string, so binding and execution cannot diverge.
for (var i = 0; i < declared.Count; i++)
{
var parameter = declared[i];
var value = parameters[parameter.Name];
if (value == null)
continue;
rawTokens.TryGetValue(parameter.Name, out var rawToken);
var problem = CheckCoercible(
value, parameter, bareFlagged.Contains(parameter.Name), rawToken);
if (problem != null)
problems.Add(problem);
}
if (firstOverflow >= 0)
{
// Which of the two overflow kinds this is depends on the slot the token WOULD
// have taken, not on whether the request used a flag anywhere. Positionals fill
// `free` in order, so the token at index n would have taken slots[n]: if that
// slot exists and a flag claimed it, the token collided with that flag and the
// parameter can be named. Otherwise the command simply received more values than
// it declares parameters.
var collided = firstOverflow < slots.Count && flagFilled.Contains(slots[firstOverflow].Name)
? slots[firstOverflow]
: null;
problems.Add(collided != null
? new ArgProblem
{
Kind = ArgProblemKind.PositionalConflict,
Name = collided.Name,
Token = positionals[firstOverflow],
}
: new ArgProblem
{
Kind = ArgProblemKind.ExcessPositional,
Token = positionals[firstOverflow],
// Capacity and given must be commensurable, because they are rendered as
// one sentence: slots still FREE after the flags took theirs, against
// positionals supplied. Pairing the declared total with the positional
// count compares two different things and reads as nonsense whenever a
// flag is involved ("takes 2 but got 2").
Capacity = free.Count,
Given = positionals.Count,
});
}
return problems.Count == 0;
}
///
/// Decides what JSON type a token is stored as.
///
/// For a parameter with a DECLARED type the answer is always a string: the type
/// itself disambiguates, and ConvertParameterToken widens it downstream. That is
/// also the fix over the CLI's old client-side guessing, which turned 007 into the
/// number 7 even for a declared string.
///
/// An UNTYPED parameter (JToken/JObject/JArray/object —
/// 14 of them across 12 shipped commands, e.g. set_serialized_field.value) has
/// nothing to coerce toward, so the token's shape is the only signal there is. Those keep
/// the literal rules the CLI used, or a working --value 5 would silently start
/// sending the string "5".
///
private static JToken StoreValue(CommandParameterInfo parameter, string token)
{
return IsUntypedTarget(parameter.ParameterType) && TryParseLiteral(token, out var literal)
? literal
: token;
}
/// A declared type that can hold any JSON value, so it constrains nothing.
private static bool IsUntypedTarget(Type type)
{
return type == typeof(object) || typeof(JToken).IsAssignableFrom(type);
}
///
/// Whether a bare flag's implied true is a value this parameter could hold.
/// JObject and JArray are untyped but still cannot, so this is narrower
/// than .
///
private static bool CanHoldBoolean(Type type)
{
var underlying = Nullable.GetUnderlyingType(type) ?? type;
return underlying == typeof(bool)
|| underlying == typeof(object)
|| underlying == typeof(JToken)
|| underlying == typeof(JValue);
}
///
/// The CLI's own literal rules, reproduced exactly: true/false
/// case-insensitively, an all-digit integer with an optional sign (so 07 is 7 — no
/// radix handling), and a single-dot decimal. Everything else stays a string, which is why
/// 1.2.3, a bare - and asset paths pass through untouched.
///
private static bool TryParseLiteral(string token, out JToken value)
{
value = null;
if (string.IsNullOrEmpty(token))
return false;
var lowered = token.ToLowerInvariant();
if (lowered == "true") { value = new JValue(true); return true; }
if (lowered == "false") { value = new JValue(false); return true; }
var body = token[0] == '-' ? token.Substring(1) : token;
if (body.Length == 0)
return false;
var dot = body.IndexOf('.');
if (dot < 0)
{
if (!AllAsciiDigits(body))
return false;
if (long.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out var whole))
{
value = new JValue(whole);
return true;
}
return false;
}
// Exactly one dot, digits on both sides.
if (dot == 0 || dot == body.Length - 1
|| body.IndexOf('.', dot + 1) >= 0
|| !AllAsciiDigits(body.Substring(0, dot))
|| !AllAsciiDigits(body.Substring(dot + 1)))
{
return false;
}
if (double.TryParse(token, NumberStyles.Float, CultureInfo.InvariantCulture, out var real))
{
value = new JValue(real);
return true;
}
return false;
}
private static bool AllAsciiDigits(string s)
{
for (var i = 0; i < s.Length; i++)
if (s[i] < '0' || s[i] > '9')
return false;
return s.Length > 0;
}
///
/// Dry-runs the executor's own converter against a bound value and reports the defect,
/// or null when the value is acceptable.
///
/// Three distinct failures fold into one report: the converter throwing; the converter
/// returning NULL for a non-empty token, which would otherwise make the parameter vanish
/// and let the command run with a default; and a bare flag on a non-bool parameter, which
/// JValue(true).ToObject<string>() would happily turn into the word "True".
///
private static ArgProblem CheckCoercible(
JToken value, CommandParameterInfo parameter, bool wasBareFlag, string rawToken)
{
var targetType = parameter.ParameterType;
// A bare flag implied `true`. On a parameter that cannot hold a boolean at all the
// user simply omitted the value, which reads better as EmptyValue than as a type
// error. Untyped is not the same as bool-capable: `object`, `JToken` and `JValue`
// hold `true` happily, while `JObject` and `JArray` cannot, and letting the implied
// `true` reach the converter reported those as a type error instead.
if (wasBareFlag && !CanHoldBoolean(targetType))
{
return new ArgProblem { Kind = ArgProblemKind.EmptyValue, Name = parameter.Name };
}
// The token as the user typed it. An untyped target stores a literal-parsed JValue,
// so the stored value is not always a string and its text is not always the token
// (`007` parses to 7) — reading it back from the value dropped or altered the quote
// the diagnostic exists to show.
var token = rawToken ?? (value.Type == JTokenType.String ? value.Value() : null);
// Enums are NOT pre-matched against Enum.GetNames. The converter accepts a [Flags]
// comma combination and a numeric value; a hand-rolled name match rejects both, which
// would make the raw form refuse what the structured form binds. TypeMismatch already
// unwraps Nullable for the type name and the prose appends the valid values, so
// deferring costs the message nothing.
try
{
var converted = BasePipelineServer.ConvertParameterToken(value, targetType);
if (converted == null && !string.IsNullOrEmpty(token))
return TypeMismatch(parameter, token, targetType);
}
catch (Exception)
{
return TypeMismatch(parameter, token, targetType);
}
return null;
}
private static ArgProblem TypeMismatch(CommandParameterInfo parameter, string token, Type targetType)
{
return new ArgProblem
{
Kind = ArgProblemKind.TypeMismatch,
Name = parameter.Name,
Token = token,
ExpectedType = (Nullable.GetUnderlyingType(targetType) ?? targetType).Name,
};
}
///
/// True when the token after should be taken as the current
/// flag's value. Anything starting with -- is the next flag, not a value; a
/// single-dash token IS consumable, which is the escape hatch for values like
/// --message -foo.
///
private static bool HasConsumableFollower(IReadOnlyList args, int index)
{
if (index + 1 >= args.Count)
return false;
var follower = args[index + 1];
return follower == null || !follower.StartsWith("--", StringComparison.Ordinal);
}
private static CommandParameterInfo FindParameter(IReadOnlyList declared, string name)
{
for (var i = 0; i < declared.Count; i++)
if (string.Equals(declared[i].Name, name, StringComparison.OrdinalIgnoreCase))
return declared[i];
return null;
}
///
/// A -x token in positional position: a single dash followed by a LETTER.
///
/// Letter-anchored, deliberately matching the `unity` CLI's own rule byte for byte.
/// The CLI keeps this check client-side so a typo costs nothing (no Editor boot, no CI
/// license seat), which only stays safe while the local check is a strict SUBSET of this
/// one — any divergence would surface as the server rejecting what the CLI let through.
/// A looser test such as "not parseable as a number" would do exactly that for tokens
/// like -5x. Negative numbers and a bare - remain valid positionals.
///
private static bool IsSingleDashFlag(string token)
{
return token != null && token.Length >= 2 && token[0] == '-' && IsAsciiLetter(token[1]);
}
private static bool IsAsciiLetter(char c)
{
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
}
///
/// A bare key=value positional whose left side names — the
/// very parameter the token is about to fill, which is what makes it a misremembered
/// --key value rather than a value.
///
/// Testing the token against its OWN slot, rather than against every declared name,
/// is what keeps legal code clean: eval "timeout=5;" lands on code, and
/// timeout is a different parameter, so the token is a statement. Matching any
/// declared name rejected it while the equivalent structured request succeeded.
///
/// A token that assigns its own slot (eval "code=1;") stays ambiguous by
/// nature and is still reported; -- is the documented way through.
///
private static bool IsBareAssignmentNamingItsOwnSlot(string token, CommandParameterInfo slot)
{
if (string.IsNullOrEmpty(token) || token[0] == '-')
return false;
var equals = token.IndexOf('=');
if (equals <= 0)
return false;
return string.Equals(slot.Name, token.Substring(0, equals), StringComparison.OrdinalIgnoreCase);
}
///
/// Closest declared parameter name within an edit-distance budget that scales with
/// length, or null. Budget is min(3, max(1, longest/3)): tight enough that an
/// unrelated name gets no suggestion at all, loose enough to catch real typos.
///
private static string SuggestParameterName(string name, IReadOnlyList declared)
{
string best = null;
var bestDistance = int.MaxValue;
for (var i = 0; i < declared.Count; i++)
{
var candidate = declared[i].Name;
var budget = Math.Min(3, Math.Max(1, Math.Max(name.Length, candidate.Length) / 3));
var distance = EditDistance(name, candidate);
if (distance <= budget && distance < bestDistance)
{
bestDistance = distance;
best = candidate;
}
}
return best;
}
/// Case-insensitive Levenshtein distance.
private static int EditDistance(string a, string b)
{
a = a.ToLowerInvariant();
b = b.ToLowerInvariant();
var previous = new int[b.Length + 1];
var current = new int[b.Length + 1];
for (var j = 0; j <= b.Length; j++)
previous[j] = j;
for (var i = 1; i <= a.Length; i++)
{
current[0] = i;
for (var j = 1; j <= b.Length; j++)
{
var substitution = previous[j - 1] + (a[i - 1] == b[j - 1] ? 0 : 1);
var deletion = previous[j] + 1;
var insertion = current[j - 1] + 1;
current[j] = Math.Min(substitution, Math.Min(deletion, insertion));
}
var swap = previous;
previous = current;
current = swap;
}
return previous[b.Length];
}
}
}