Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
152 changes: 152 additions & 0 deletions CosmosDBShell.Tests/Parser/CommandExecutionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>
/// Drives execution of <see cref="CommandStatement"/> and <see cref="ExecStatement"/>:
Expand Down Expand Up @@ -92,6 +94,137 @@ public async Task Exec_VariableCommand_RunsResolvedCommand()
Assert.Equal("hello world", Assert.IsType<ShellText>(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<ShellJson>(dynamic.Result).Value;
Assert.Equal(Assert.IsType<ShellJson>(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<Exception>(() =>
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<ShellText>(result).Text);
}

[Theory]
[InlineData("=")]
[InlineData(":")]
public async Task Function_CommandExpressionMissingInlineOptionValue_ReportsError(string separator)
{
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
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<ExecStatement>(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<ShellJson>(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<ShellText>(state.Result).Text);
}

[Theory]
[InlineData("-5", "-5")]
[InlineData("-1.5 +2", "-1.5 +2")]
Expand Down Expand Up @@ -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<CommandState> 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);
}
}
37 changes: 37 additions & 0 deletions CosmosDBShell.Tests/Parser/StatementParserEdgeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,43 @@ public void Exec_WithArguments_ProducesExecStatement()
Assert.IsType<ExecStatement>(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<ExecStatement>(statement);
Assert.Equal("flag", Assert.IsType<CommandOption>(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")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)}";
}

Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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<ShellObject> EvaluateAsync(ShellInterpreter interpreter, CommandState currentState, CancellationToken cancellationToken)
public override async Task<ShellObject> 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)
Comment thread
mkrueger marked this conversation as resolved.
{
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,11 @@ public override async Task<CommandState> 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}";
}

/// <summary>
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
3 changes: 3 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -795,13 +795,16 @@ 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:

```bash
$script = {path: "myscript.csh"}
exec $script.path arg1 arg2

exec "info" --db=mydb --con=mycontainer

for $file in (dir "*.csh") { exec $file.path }
```

Expand Down
1 change: 1 addition & 0 deletions docs/programming.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Loading