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
56 changes: 54 additions & 2 deletions docs/experimental/console-command.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ The `console` command provides an interactive Read-Eval-Print Loop (REPL) enviro
- **Syntax Highlighting**: Real-time syntax highlighting for input and output

## Usage
To launch the REPL, run the following Bicep command:

```sh
bicep console
```
Expand Down Expand Up @@ -53,6 +55,7 @@ true
```

### Complex Expressions
#### Lambdas
```bicep
> var users = [
{ name: 'Alice', age: 30 }
Expand All @@ -69,11 +72,60 @@ true
}
]
```
#### User-defined types and functions
```bicep
> type PersonType = {
name: string
age: int
}
> func sayHi(person PersonType) string => 'Hello ${person.name}, you are ${person.age} years old!'
> var alice = {
name: 'Alice'
age: 30
}
> [ sayHi(alice), sayHi({ name: 'Bob', age: 25 })]
[
'Hello Alice, you are 30 years old!'
'Hello Bob, you are 25 years old!'
]
```

### Loading content from files
- Bicep console also supports the [`load*()` functions](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/bicep-functions-files). Note: The directory from which the `bicep console` command is run is used as the _current directory_ when evaluating the `load*()` functions


### Piping and standard input/output redirection
The console command supports evaluating expressions provided through piping or redirected standard input, i.e.:

**Powershell**:
```pwsh
# piped input
"parseCidr('10.144.0.0/20')" | bicep console
```

**Bash**:
```sh
# piped input
echo "parseCidr('10.144.0.0/20')" | bicep console
# stdin redirection from file content
bicep console < test.txt
```

Multi-line input is also supported, i.e:
```pwsh
"{
> foo: 'bar'
> }.foo" | bicep console
# Output: bar
```

Output redirection is also supported:
```sh
"toObject([{name:'Evie', age:4},{name:'Casper', age:3}], x => x.name)" | bicep console > output.json
```

## Limitations
- No support for expressions requiring Azure context, e.g. `resourceGroup()`
- No file system access or external dependencies
- Limited to expression evaluation and variable declarations
- No support for for-loop expressions, e.g. `[for i in range(0, x): i]`
- No persistent state between console sessions
- No completions support
Expand Down
83 changes: 83 additions & 0 deletions src/Bicep.Cli.IntegrationTests/Commands/ConsoleCommandTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Bicep.Cli.UnitTests.Assertions;
using Bicep.Core.UnitTests.Assertions;
using FluentAssertions;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Bicep.Cli.IntegrationTests.Commands;

[TestClass]
public class ConsoleCommandTests : TestBase
{
[TestMethod]
public async Task Redirected_input_context_with_single_line_input_should_succeed()
{
// "concat('Hello', ' ', 'World', '!')" | bicep console
var input = "concat('Hello', ' ', 'World', '!')";
var result = await Bicep(
(@out, err) => new IOContext(
new(new StringReader(input), IsRedirected: true),
new(@out, IsRedirected: false),
new(err, IsRedirected: false)),
"console");

result.Should().Succeed();
result.WithoutAnsi().Stdout.Should().BeEquivalentToIgnoringNewlines("""
'Hello World!'

""");
}

[TestMethod]
public async Task Redirected_input_context_with_multi_line_input_should_succeed()
{
/*
var greeting = 'Hello'
var target = {
value: 'World'
}

'${greeting} ${target.value}!' | bicep console
*/
var input = """
var greeting = 'Hello'
var target = {
value: 'World'
}

'${greeting} ${target.value}!'
""";

var result = await Bicep(
(@out, err) => new IOContext(
new(new StringReader(input), IsRedirected: true),
new(@out, IsRedirected: false),
new(err, IsRedirected: false)),
"console");

result.Should().Succeed();
result.WithoutAnsi().Stdout.Should().BeEquivalentToIgnoringNewlines("""
'Hello World!'

""");
}

[TestMethod]
public async Task Redirected_output_context_should_not_have_ansi_codes()
{
// "concat('Hello', ' ', 'World', '!')" | bicep console >
var input = "concat('Hello', ' ', 'World', '!')";
var result = await Bicep(
(@out, err) => new IOContext(
new(new StringReader(input), IsRedirected: true),
new(@out, IsRedirected: true),
new(err, IsRedirected: false)),
"console");

result.Should().Succeed();
var withoutAnsi = result.WithoutAnsi();
result.Stdout.Should().Be(withoutAnsi.Stdout);
}
}
71 changes: 43 additions & 28 deletions src/Bicep.Cli.IntegrationTests/TestBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,34 +79,7 @@ this with
}

protected static Task<CliResult> Bicep(InvocationSettings settings, Action<IServiceCollection>? registerAction, CancellationToken cancellationToken, params string?[] args /*null args are ignored*/)
=> TextWriterHelper.InvokeWriterAction((@out, err)
=> new Program(
new(Output: @out, Error: err),
services =>
{
if (settings.FeatureOverrides is { })
{
services.WithFeatureOverrides(settings.FeatureOverrides);
}

IServiceCollectionExtensions.AddMockHttpClientIfNotNull(services, settings.ModuleMetadataClient);

services
.AddSingletonIfNotNull(settings.Environment ?? BicepTestConstants.EmptyEnvironment)
.AddSingletonIfNotNull(settings.ClientFactory)
.AddSingletonIfNotNull(settings.TemplateSpecRepositoryFactory)
.AddSingleton(AnsiConsole.Create(new()
{
Ansi = AnsiSupport.Yes,
ColorSystem = ColorSystemSupport.Standard,
Interactive = InteractionSupport.No,
Out = new AnsiConsoleOutput(@out),
}));

registerAction?.Invoke(services);
}
)
.RunAsync(args.ToArrayExcludingNull(), cancellationToken));
=> BicepInternal(settings, registerAction, null, cancellationToken, args);

protected static Task<CliResult> Bicep(params string[] args) => Bicep(InvocationSettings.Default, args);

Expand All @@ -119,6 +92,10 @@ protected static Task<CliResult> Bicep(Action<IServiceCollection> registerAction
protected static Task<CliResult> Bicep(InvocationSettings settings, params string?[] args /*null args are ignored*/)
=> Bicep(settings, null, CancellationToken.None, args);

protected static Task<CliResult> Bicep(
Func<TextWriter, TextWriter, IOContext> ioContextFactory, params string[] args)
=> BicepInternal(InvocationSettings.Default, null, ioContextFactory, CancellationToken.None, args);

protected static void AssertNoErrors(string error)
{
foreach (var line in error.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries))
Expand Down Expand Up @@ -188,5 +165,43 @@ protected static IEnvironment CreateDefaultEnvironment() => TestEnvironment.Defa
("intEnvVariableName", "100"),
("boolEnvironmentVariable", "true")
);

private static Task<CliResult> BicepInternal(
InvocationSettings settings,
Action<IServiceCollection>? registerAction,
Func<TextWriter, TextWriter, IOContext>? ioContextFactory,
CancellationToken cancellationToken, params string?[] args /*null args are ignored*/)
=> TextWriterHelper.InvokeWriterAction((@out, err) =>
{
var ioContext = ioContextFactory?.Invoke(@out, err) ?? new IOContext(
Input: new(new StringReader(string.Empty), false),
Output: new(@out, false),
Error: new(err, false));
return new Program(ioContext,
services =>
{
if (settings.FeatureOverrides is { })
{
services.WithFeatureOverrides(settings.FeatureOverrides);
}

IServiceCollectionExtensions.AddMockHttpClientIfNotNull(services, settings.ModuleMetadataClient);

services
.AddSingletonIfNotNull(settings.Environment ?? BicepTestConstants.EmptyEnvironment)
.AddSingletonIfNotNull(settings.ClientFactory)
.AddSingletonIfNotNull(settings.TemplateSpecRepositoryFactory)
.AddSingleton(AnsiConsole.Create(new()
{
Ansi = AnsiSupport.Yes,
ColorSystem = ColorSystemSupport.Standard,
Interactive = InteractionSupport.No,
Out = new AnsiConsoleOutput(@out),
}));

registerAction?.Invoke(services);
}
).RunAsync(args.ToArrayExcludingNull(), cancellationToken);
});
}
}
71 changes: 53 additions & 18 deletions src/Bicep.Cli/Commands/ConsoleCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ public class ConsoleCommand(
ILogger logger,
IOContext io,
IEnvironment environment,
ReplEnvironment replEnvironment,
IAnsiConsole console) : ICommand
ReplEnvironment replEnvironment) : ICommand
{
private const string FirstLinePrefix = "> ";

Expand Down Expand Up @@ -57,16 +56,52 @@ public async Task<int> RunAsync(ConsoleArguments args)
{
logger.LogWarning($"WARNING: The '{args.CommandName}' CLI command is an experimental feature. Experimental features should be used for testing purposes only, as there are no guarantees about the quality or stability of these features.");

if (!console.Profile.Capabilities.Interactive)
if (io.Input.IsRedirected)
{
logger.LogError($"The '{args.CommandName}' CLI command requires an interactive console.");
return 1;
// Read all input from stdin if redirected (via pipe or file redirection)
Copy link
Member

@anthony-c-martin anthony-c-martin Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we always disable ANSI color mode for output if output is being redirected?

Copy link
Contributor Author

@levimatheri levimatheri Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, otherwise writing to a file looks weird and could complicate downstream pipelines consuming the output.

echo "split('foo,bar,baz', ',')" | bicep console > bicep_output.txt
image

Will make the change.

var input = await io.Input.Reader.ReadToEndAsync();

if (string.IsNullOrWhiteSpace(input))
{
return 0;
}

// Handle input line by line (to support multi-line strings)
var outputBuilder = new StringBuilder();
var inputBuffer = new StringBuilder();

using var reader = new StringReader(input);
while (await reader.ReadLineAsync() is { } line)
{
inputBuffer.Append(line);
inputBuffer.Append('\n');

var current = inputBuffer.ToString();
if (ReplEnvironment.ShouldSubmitBuffer(current, line))
{
inputBuffer.Clear();
outputBuilder.Append(replEnvironment.EvaluateAndGetOutput(current));
}
}

if (inputBuffer.Length > 0)
{
outputBuilder.Append(replEnvironment.EvaluateAndGetOutput(inputBuffer.ToString()));
}

var output = outputBuilder.ToString();
if (io.Output.IsRedirected)
{
output = AnsiHelper.RemoveCodes(output);
}
await io.Output.Writer.WriteAsync(output);
return 0;
}

await io.Output.WriteLineAsync($"Bicep Console version {environment.GetVersionString()}");
await io.Output.WriteLineAsync("Type 'help' for available commands, press ESC to quit.");
await io.Output.WriteLineAsync("Multi-line input supported.");
await io.Output.WriteLineAsync(string.Empty);
await io.Output.Writer.WriteLineAsync($"Bicep Console version {environment.GetVersionString()}");
await io.Output.Writer.WriteLineAsync("Type 'help' for available commands, press ESC to quit.");
await io.Output.Writer.WriteLineAsync("Multi-line input supported.");
await io.Output.Writer.WriteLineAsync(string.Empty);

var buffer = new StringBuilder();

Expand All @@ -92,8 +127,8 @@ public async Task<int> RunAsync(ConsoleArguments args)

if (rawLine.Equals("help", StringComparison.OrdinalIgnoreCase))
{
await io.Output.WriteLineAsync("Enter expressions or 'var name = <expr>'. Multi-line supported until structure closes.");
await io.Output.WriteLineAsync("Commands: exit, clear");
await io.Output.Writer.WriteLineAsync("Enter expressions or 'var name = <expr>'. Multi-line supported until structure closes.");
await io.Output.Writer.WriteLineAsync("Commands: exit, clear");
continue;
}
}
Expand All @@ -109,7 +144,7 @@ public async Task<int> RunAsync(ConsoleArguments args)

// evaluate input
var output = replEnvironment.EvaluateAndGetOutput(current);
await io.Output.WriteAsync(output);
await io.Output.Writer.WriteAsync(output);
}
}

Expand All @@ -132,8 +167,8 @@ private async Task<int> PrintHistory(StringBuilder buffer, List<Rune> lineBuffer
cursorOffset = lineBuffer.Count;

var output2 = replEnvironment.HighlightInputLine(FirstLinePrefix, buffer.ToString(), lineBuffer, cursorOffset, printPrevLines: true);
await io.Output.WriteAsync(PrintHelper.MoveCursorUp(prevBufferLineCount));
await io.Output.WriteAsync(output2);
await io.Output.Writer.WriteAsync(PrintHelper.MoveCursorUp(prevBufferLineCount));
await io.Output.Writer.WriteAsync(output2);
return cursorOffset;
}

Expand All @@ -145,7 +180,7 @@ private string GetPrefix(StringBuilder buffer)

private async Task<string?> ReadLine(StringBuilder buffer)
{
await io.Output.WriteAsync(GetPrefix(buffer));
await io.Output.Writer.WriteAsync(GetPrefix(buffer));

var lineBuffer = new List<Rune>();
var cursorOffset = 0;
Expand Down Expand Up @@ -180,7 +215,7 @@ private string GetPrefix(StringBuilder buffer)
}
if (keyInfo.Key == ConsoleKey.Enter)
{
await io.Output.FlushAsync();
await io.Output.Writer.FlushAsync();
break;
}
else if (keyInfo.Key == ConsoleKey.Backspace)
Expand Down Expand Up @@ -209,10 +244,10 @@ private string GetPrefix(StringBuilder buffer)
}

var output = replEnvironment.HighlightInputLine(GetPrefix(buffer), buffer.ToString(), lineBuffer, cursorOffset, printPrevLines: false);
await io.Output.WriteAsync(output);
await io.Output.Writer.WriteAsync(output);
}

await io.Output.WriteAsync("\n");
await io.Output.Writer.WriteAsync("\n");

return string.Concat(lineBuffer);
}
Expand Down
2 changes: 1 addition & 1 deletion src/Bicep.Cli/Commands/DecompileCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ public async Task<int> RunAsync(DecompileArguments args)
}
catch (Exception exception)
{
await io.Error.WriteLineAsync(string.Format(CliResources.DecompilationFailedFormat, inputUri, exception.Message));
await io.Error.Writer.WriteLineAsync(string.Format(CliResources.DecompilationFailedFormat, inputUri, exception.Message));
return 1;
}
}
Expand Down
Loading