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]; } } }