diff --git a/CHANGELOG.md b/CHANGELOG.md index 3034ec59..ae499519 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixes +- Parse `exec` options and shell words like direct commands. Bind built-in options normally and pass option-shaped words to functions and script files as positional text. - `for` and `do` loops now reject misspelled `in` and `while` keywords before any statements in the input execute. They previously accepted any identifier in those positions. - `filter` now rejects array index literals larger than `2147483647`, including optional access, instead of silently selecting element zero. Representable indexes beyond the array length still return `null`. - Decimal arithmetic and comparisons involving JSON numeric strings now use the same culture-invariant `double` conversion as shell strings instead of failing with a `Decimal`-to-`Double` cast error. String concatenation with `+` is unchanged. diff --git a/CosmosDBShell.Tests/Parser/CommandExecutionTests.cs b/CosmosDBShell.Tests/Parser/CommandExecutionTests.cs index 6bb63f94..e0bf4d14 100644 --- a/CosmosDBShell.Tests/Parser/CommandExecutionTests.cs +++ b/CosmosDBShell.Tests/Parser/CommandExecutionTests.cs @@ -8,8 +8,10 @@ namespace CosmosShell.Tests.Parser; using System.Threading; using System.Threading.Tasks; +using Azure.Data.Cosmos.Shell.Commands; using Azure.Data.Cosmos.Shell.Core; using Azure.Data.Cosmos.Shell.Parser; +using Azure.Data.Cosmos.Shell.Util; /// /// Drives execution of and : @@ -92,6 +94,137 @@ public async Task Exec_VariableCommand_RunsResolvedCommand() Assert.Equal("hello world", Assert.IsType(state.Result).Text); } + [Theory] + [InlineData("--text value --flag --max 5", "value", true)] + [InlineData("-t value -f -m 5", "value", true)] + [InlineData("--text=value --flag=true --max=5", "value", true)] + [InlineData("--text:value --flag:false --max:5", "value", false)] + [InlineData("--text=\"two words\" --flag --max=(2 + 3)", "two words", true)] + [InlineData("--text=$text --flag --max=$max", "two words", true)] + public async Task Exec_BindsOptionsLikeDirectCommands(string arguments, string expectedText, bool expectedFlag) + { + Assert.True(CommandFactory.TryCreateFactory(typeof(ExecBindingCommand), out var factory)); + Shell.App.Commands["execbinding"] = factory; + SetVariable("cmd", new ShellText("execbinding")); + SetVariable("text", new ShellText("two words")); + SetVariable("max", new ShellNumber(5)); + try + { + var direct = await Shell.RunCommandAsync(new(), $"execbinding {arguments}", CancellationToken.None); + var dynamic = await Shell.RunCommandAsync(new(), $"exec $cmd {arguments}", CancellationToken.None); + + Assert.False(direct.IsError); + Assert.False(dynamic.IsError); + var value = Assert.IsType(dynamic.Result).Value; + Assert.Equal(Assert.IsType(direct.Result).Value.GetRawText(), value.GetRawText()); + Assert.Equal(expectedText, value.GetProperty("text").GetString()); + Assert.Equal(expectedFlag, value.GetProperty("flag").GetBoolean()); + Assert.Equal(5, value.GetProperty("max").GetInt32()); + } + finally + { + Shell.App.Commands.Remove("execbinding"); + } + } + + [Fact] + public async Task Exec_HelpOption_ShowsHelp() + { + var state = await Shell.RunCommandAsync(new(), "exec \"echo\" --help", CancellationToken.None); + Assert.False(state.IsError); + } + + [Theory] + [InlineData("--bogus=1", typeof(UnknownOptionException))] + [InlineData("--db", typeof(CommandException))] + public async Task Exec_InvalidOptions_ReportCommandErrors(string arguments, Type exceptionType) + { + var exception = await Assert.ThrowsAnyAsync(() => + Shell.RunCommandAsync(new(), $"exec \"info\" {arguments}", CancellationToken.None)); + Assert.IsType(exceptionType, exception); + } + + [Theory] + [InlineData("exec \"identity\" --name=$text", "--name=two words")] + [InlineData("identity --name:$text", "--name:two words")] + [InlineData("$result = (identity --name=$text)", "--name=two words")] + [InlineData("exec \"identity\" --help", "--help")] + [InlineData("exec \"identity\" -m", "-m")] + public async Task Function_OptionWords_ArePositionalText(string invocation, string expected) + { + SetVariable("text", new ShellText("two words")); + var state = await Shell.RunCommandAsync( + new(), $"def identity [value] {{ return $value }}; {invocation}", CancellationToken.None); + + Assert.False(state.IsError); + var result = invocation.StartsWith("$result", StringComparison.Ordinal) ? GetVariable("result") : state.Result; + Assert.Equal(expected, Assert.IsType(result).Text); + } + + [Theory] + [InlineData("=")] + [InlineData(":")] + public async Task Function_CommandExpressionMissingInlineOptionValue_ReportsError(string separator) + { + var exception = await Assert.ThrowsAsync(() => + Shell.RunCommandAsync( + new(), + $"def identity [value] {{ return $value }}; $result = (identity --name{separator})", + CancellationToken.None)); + + Assert.Equal(MessageService.GetArgsString("statement_error_invalid_option_value", "option", "name"), exception.Message); + } + + [Theory] + [InlineData("exec")] + [InlineData("command")] + [InlineData("expression")] + public async Task Script_PassesOptionWordsAndRestoresScope(string callKind) + { + var path = Path.GetTempFileName(); + SetVariable("cmd", new ShellText(path)); + SetVariable("text", new ShellText("two words")); + var scopeCount = Shell.VariableContainers.Count; + try + { + await File.WriteAllTextAsync(path, "return [$1, $2, $3]", TestContext.Current.CancellationToken); + const string invocation = "exec $cmd --name=$text -m 5"; + var parsed = StatementParser.ScriptParseResult.Parse(invocation); + Assert.False(parsed.Errors.HasErrors); + var arguments = Assert.IsType(Assert.Single(parsed.Statements)).Arguments; + var command = new CommandStatement(new Token(TokenType.Identifier, path, 0, path.Length)); + command.Arguments.AddRange(arguments); + var expression = new CommandExpression(command.CommandToken); + expression.Arguments.AddRange(arguments); + var state = callKind switch + { + "command" => await command.RunAsync(Shell, new(), CancellationToken.None), + "expression" => await expression.ExecuteCommandAsync(Shell, new(), CancellationToken.None), + _ => await Shell.RunCommandAsync(new(), invocation, CancellationToken.None), + }; + + Assert.False(state.IsError); + var values = Assert.IsType(state.Result).Value; + Assert.Equal("--name=two words", values[0].GetString()); + Assert.Equal("-m", values[1].GetString()); + Assert.Equal("5", values[2].GetString()); + Assert.Equal(scopeCount, Shell.VariableContainers.Count); + Assert.Null(Shell.CurrentScriptFileName); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task Exec_ShellWords_PreservePathsAndNegativeArguments() + { + var state = await Shell.RunCommandAsync(new(), "exec \"echo\" C:\\temp\\file.txt https://example.test/a -5", CancellationToken.None); + Assert.False(state.IsError); + Assert.Equal("C:\\temp\\file.txt https://example.test/a -5", Assert.IsType(state.Result).Text); + } + [Theory] [InlineData("-5", "-5")] [InlineData("-1.5 +2", "-1.5 +2")] @@ -183,3 +316,22 @@ public async Task Exec_FailingCommandWithScriptContext_WrapsInPositionalExceptio Assert.Equal("script.csh", ex.FileName); } } + +[CosmosCommand("execbinding")] +internal class ExecBindingCommand : CosmosCommand +{ + [CosmosOption("text", "t")] + public string? Text { get; init; } + + [CosmosOption("flag", "f")] + public bool Flag { get; init; } + + [CosmosOption("max", "m")] + public int? Max { get; init; } + + public override Task ExecuteAsync(ShellInterpreter shell, CommandState commandState, string commandText, CancellationToken token) + { + commandState.Result = new ShellJson(System.Text.Json.JsonSerializer.SerializeToElement(new { text = this.Text, flag = this.Flag, max = this.Max })); + return Task.FromResult(commandState); + } +} diff --git a/CosmosDBShell.Tests/Parser/StatementParserEdgeTests.cs b/CosmosDBShell.Tests/Parser/StatementParserEdgeTests.cs index aca8ffb5..e781efaa 100644 --- a/CosmosDBShell.Tests/Parser/StatementParserEdgeTests.cs +++ b/CosmosDBShell.Tests/Parser/StatementParserEdgeTests.cs @@ -49,6 +49,43 @@ public void Exec_WithArguments_ProducesExecStatement() Assert.IsType(Assert.Single(statements)); } + [Theory] + [InlineData("exec $cmd --flag; echo next")] + [InlineData("exec $cmd --flag\necho next")] + [InlineData("{ exec $cmd --flag }; echo next")] + [InlineData("exec $cmd --flag | echo next")] + public void Exec_Options_StopAtStatementBoundaries(string input) + { + var result = StatementParser.ScriptParseResult.Parse(input); + Assert.False(result.Errors.HasErrors); + var statement = result.Statements[0] switch + { + BlockStatement block => block.Statements[0], + PipeStatement pipe => pipe.Statements[0], + var first => first, + }; + var executed = Assert.IsType(statement); + Assert.Equal("flag", Assert.IsType(Assert.Single(executed.Arguments)).Name); + } + + [Theory] + [InlineData("exec $cmd --name=")] + [InlineData("exec $cmd --name:; echo next")] + public void Exec_MissingInlineOptionValue_ReportsParseError(string input) + { + var result = StatementParser.ScriptParseResult.Parse(input); + Assert.True(result.Errors.HasErrors); + } + + [Fact] + public void Exec_ToString_PreservesOptionsAndQuotedValues() + { + const string input = "exec $cmd --name='two words' -m 5"; + var result = StatementParser.ScriptParseResult.Parse(input); + Assert.False(result.Errors.HasErrors); + Assert.Equal("exec $cmd --name=\"two words\" -m 5", Assert.Single(result.Statements).ToString()); + } + [Theory] [InlineData("for $x typo [1,2] {}", "typo", "statement_error_expected_in")] [InlineData("do {} typo false", "typo", "statement_error_expected_while")] diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/CommandArgumentFormatter.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/CommandArgumentFormatter.cs index 35097efa..a98b787a 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/CommandArgumentFormatter.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/CommandArgumentFormatter.cs @@ -22,8 +22,7 @@ public static string Format(Expression argument) // remain separate AST arguments and are bound later using command metadata. if (argument is CommandOption option) { - var dashCount = Math.Max(1, option.NameToken.Start - option.MinusToken.Start); - var text = new string('-', dashCount) + option.Name; + var text = FormatOptionName(option); return option.Value == null ? text : $"{text}{option.SeparatorToken?.Value ?? "="}{Format(option.Value)}"; } @@ -41,4 +40,10 @@ public static string Format(Expression argument) return argument.ToString() ?? string.Empty; } + + internal static string FormatOptionName(CommandOption option) + { + var dashCount = Math.Max(1, option.NameToken.Start - option.MinusToken.Start); + return new string('-', dashCount) + option.Name; + } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/Statement/CommandOption.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/Statement/CommandOption.cs index ef8113ac..3e26bd14 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/Statement/CommandOption.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/Statement/CommandOption.cs @@ -6,6 +6,7 @@ namespace Azure.Data.Cosmos.Shell.Parser; using System; using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.Util; internal class CommandOption : Expression { @@ -31,10 +32,26 @@ public CommandOption(Token minusToken, Token nameToken, Token? separatorToken = public override int Length => (this.Value != null ? this.Value.Start + this.Value.Length : this.NameToken.Start + this.NameToken.Length) - this.MinusToken.Start; - public override Task EvaluateAsync(ShellInterpreter interpreter, CommandState currentState, CancellationToken cancellationToken) + public override async Task EvaluateAsync(ShellInterpreter interpreter, CommandState currentState, CancellationToken cancellationToken) { - // should never be called directly, only used in CommandStatement - throw new NotImplementedException(); + var text = CommandArgumentFormatter.FormatOptionName(this); + if (this.SeparatorToken != null && this.Value == null) + { + throw new InvalidOperationException(MessageService.GetArgsString("statement_error_invalid_option_value", "option", this.Name)); + } + + if (this.Value != null) + { + var value = await this.Value.EvaluateAsync(interpreter, currentState, cancellationToken); + if (value.ConvertShellObject(DataType.Text) is not string valueText) + { + throw new InvalidOperationException(MessageService.GetArgsString("statement_error_invalid_option_value", "option", this.Name)); + } + + text += (this.SeparatorToken?.Value ?? "=") + valueText; + } + + return new ShellText(text); } public override void Accept(IAstVisitor visitor) diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/Statement/ExecStatement.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/Statement/ExecStatement.cs index 6125a355..73f15ff3 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/Statement/ExecStatement.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/Statement/ExecStatement.cs @@ -138,10 +138,11 @@ public override async Task RunAsync(ShellInterpreter shell, Comman public override string ToString() { - var args = string.Join(" ", this.Arguments.Select(a => a.ToString())); + var args = string.Join(" ", this.Arguments.Select(CommandArgumentFormatter.Format)); + var command = CommandArgumentFormatter.Format(this.CommandExpression); return string.IsNullOrEmpty(args) - ? $"exec {this.CommandExpression}" - : $"exec {this.CommandExpression} {args}"; + ? $"exec {command}" + : $"exec {command} {args}"; } /// diff --git a/README.md b/README.md index c29368c3..fabd135d 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,8 @@ Loop headers reject misspelled `in` and `while` keywords before execution, and ` Script diagnostics preserve source files, runtime failure categories, and function/script call sites. The language server shares the runtime's control-flow and duplicate-parameter checks, recognizes document-local functions, and checks nested commands and options. Incorrect function argument counts produce usage exit code `2`. See [validation and errors](docs/programming.md#validation-and-errors). +Dynamic `exec` calls accept the same options and shell words as direct commands. Built-in commands bind options normally; functions and script files receive option-shaped arguments as positional text. See [exec](docs/programming.md#exec). + Loops and functions preserve JSON `null` values. Numeric conditions use the same zero/nonzero rule for shell values and JSON properties, including fractional numbers. See [value conversion rules](docs/programming.md#numbers). JSON strings use the same `+` concatenation rules as shell strings. A bare `return` is valid immediately before a closing block brace. Editor variable symbols distinguish case-sensitive names such as `$value` and `$Value`. diff --git a/docs/commands.md b/docs/commands.md index 1c4c6f13..749d5a70 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -795,6 +795,7 @@ Arguments: Notes: - If `expression` evaluates to an existing file path, it is executed as a `.csh` script. +- Arguments use direct-command shell-word and option syntax, including short options, `--name=value`, `--name:value`, and quoted or computed values. Built-in commands bind options normally; functions and scripts receive option-shaped words as positional text. Examples: @@ -802,6 +803,8 @@ Examples: $script = {path: "myscript.csh"} exec $script.path arg1 arg2 +exec "info" --db=mydb --con=mycontainer + for $file in (dir "*.csh") { exec $file.path } ``` diff --git a/docs/programming.md b/docs/programming.md index 2a87fc13..db632b46 100644 --- a/docs/programming.md +++ b/docs/programming.md @@ -309,6 +309,7 @@ Notes: - If the evaluated value is a file path that exists, the shell runs it as a `.csh` script. - Argument parsing stops at `;`, newline, `}`, or `|` (so you can chain with pipes). - Arguments use normal command syntax, including `-name` / `--name` options with space-separated, `=`, or `:` values, negative arguments such as `-5`, and unquoted shell words such as URLs and file patterns. Variables, JSON values, and parenthesized expressions retain their normal evaluation behavior. Quote a dash-prefixed word when it should be a positional string rather than an option. +- Built-in commands bind options using their normal metadata. Functions and script files receive option-shaped words as positional text, such as `--name=value`; values are evaluated once in the caller. Examples: