using System.Collections.Generic; using NUnit.Framework; using Unity.Pipeline; namespace Unity.Pipeline.Tests.Editor { /// /// Unit tests for CommandLineTokenizer - the POSIX-ish lexer that splits a raw /// commandLine string into argv tokens before binding. /// /// These tests are the written form of the dialect contract: the dialect is the /// pipeline's OWN, identical on every OS, and documented in connectivity.md. Changing /// any expectation here is a wire-contract change, not a refactor. In particular the /// WindowsDoubledQuoteIsNotAnEscape case pins us to POSIX rules over /// CommandLineToArgvW. /// /// The dialect is a pure input-to-output function, so the cases are a table rather than /// ~18 near-identical methods. Each case keeps its own name and, where the rule is not /// self-evident, the reason it is that way. /// class CommandLineTokenizerTests { private static List Tokenize(string commandLine) { Assert.IsTrue(CommandLineTokenizer.TryTokenize(commandLine, out var tokens, out var error), $"Expected '{commandLine}' to tokenize, but failed with: {error}"); Assert.IsNull(error, "A successful tokenize must not report an error"); return tokens; } private static TestCaseData Case(string name, string commandLine, params string[] expected) { return new TestCaseData(commandLine, expected).SetName($"TryTokenize_{name}"); } private static IEnumerable TokenizeCases() { yield return Case("SimpleWordsSplitOnWhitespace", "eval return 2+2;", "eval", "return", "2+2;"); // Leading, trailing, tabs and repeated spaces all collapse. yield return Case("WhitespaceRunsProduceNoEmptyTokens", " a \t b ", "a", "b"); // A pure lexer has no opinion about needing a command name; the caller rejects an // empty token list so the message matches the structured path. yield return Case("EmptyStringYieldsNoTokens", ""); yield return Case("WhitespaceOnlyYieldsNoTokens", " "); yield return Case("NullYieldsNoTokens", null); // eval "return 2+2;" yield return Case("DoubleQuotesGroupWhitespaceIntoOneToken", "eval \"return 2+2;\"", "eval", "return 2+2;"); // eval 'obj.name = "x";' <- the reason single quotes are load-bearing yield return Case("SingleQuotesGroupWhitespaceAndPassDoubleQuotesThrough", "eval 'obj.name = \"x\";'", "eval", "obj.name = \"x\";"); // "a\"b" -> a"b yield return Case("BackslashInsideDoubleQuotesEscapesNextCharacter", "\"a\\\"b\"", "a\"b"); // 'a\b' -> a\b (single quotes have NO escapes at all) yield return Case("BackslashInsideSingleQuotesIsLiteral", "'a\\b'", "a\\b"); // a\ b -> one token "a b" yield return Case("BackslashOutsideQuotesEscapesNextCharacter", "a\\ b", "a b"); yield return Case("SingleQuoteInsideDoubleQuotesIsLiteral", "\"it's\"", "it's"); // a"b c"d -> ab cd yield return Case("AdjacentSpansConcatenateIntoOneToken", "a\"b c\"d", "ab cd"); // "" -> a single EMPTY token, not zero tokens. This is what lets `--message ""` // mean the empty string instead of the boolean true. yield return Case("EmptyQuotedSpanYieldsOneEmptyToken", "\"\"", string.Empty); // PINS THE DIALECT. Windows CommandLineToArgvW reads "a""b" as a"b. POSIX-ish closes // the span and opens a new one, so it concatenates to ab. yield return Case("WindowsDoubledQuoteIsNotAnEscape", "\"a\"\"b\"", "ab"); // No expansion of any kind: no operators, no $VAR, no globbing, no ~. yield return Case("ShellOperatorsAreLiteralTokens", "a && b | c > d $HOME *.cs ~", "a", "&&", "b", "|", "c", ">", "d", "$HOME", "*.cs", "~"); yield return Case("UnityAssetPathSurvivesUnchanged", "reload_file Assets/Scripts/Player.cs", "reload_file", "Assets/Scripts/Player.cs"); // The tokenizer does not interpret flags; the binder does. yield return Case("FlagsAndDoubleDashArePlainTokens", "log_editor --message hi -- -x", "log_editor", "--message", "hi", "--", "-x"); } [TestCaseSource(nameof(TokenizeCases))] public void TryTokenize_ProducesTheExpectedTokens(string commandLine, string[] expected) { CollectionAssert.AreEqual(expected, Tokenize(commandLine)); } [TestCase("eval \"unbalanced", "quote", TestName = "TryTokenize_UnbalancedDoubleQuote_IsRejected")] [TestCase("eval 'unbalanced", "quote", TestName = "TryTokenize_UnbalancedSingleQuote_IsRejected")] [TestCase("abc\\", "backslash", TestName = "TryTokenize_DanglingBackslash_IsRejected")] public void TryTokenize_MalformedInput_IsRejectedWithAnExplanation( string commandLine, string expectedInMessage) { Assert.IsFalse(CommandLineTokenizer.TryTokenize(commandLine, out _, out var error), $"Expected '{commandLine}' to be rejected"); Assert.IsNotEmpty(error, "A failed tokenize must explain why"); StringAssert.Contains(expectedInMessage, error.ToLowerInvariant()); } } }