diff --git a/CosmosDBShell.Tests/Shell/MultiLineInputTests.cs b/CosmosDBShell.Tests/Shell/MultiLineInputTests.cs new file mode 100644 index 0000000..56e63cc --- /dev/null +++ b/CosmosDBShell.Tests/Shell/MultiLineInputTests.cs @@ -0,0 +1,338 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.Shell; + +using System.Text; + +using Azure.Data.Cosmos.Shell.Core; + +public class MultiLineInputTests +{ + [Fact] + public void IsIncompleteInput_EmptyString_ReturnsFalse() + { + Assert.False(ShellInterpreter.IsIncompleteInput(string.Empty)); + Assert.False(ShellInterpreter.IsIncompleteInput(" ")); + } + + [Fact] + public void IsIncompleteInput_CompleteCommand_ReturnsFalse() + { + Assert.False(ShellInterpreter.IsIncompleteInput("ls")); + Assert.False(ShellInterpreter.IsIncompleteInput("help")); + } + + [Fact] + public void IsIncompleteInput_UnclosedBrace_ReturnsTrue() + { + Assert.True(ShellInterpreter.IsIncompleteInput("if true {")); + } + + [Fact] + public void IsIncompleteInput_UnterminatedDoubleQuotedString_ReturnsTrue() + { + Assert.True(ShellInterpreter.IsIncompleteInput("echo \"hello")); + } + + [Fact] + public void IsIncompleteInput_UnterminatedSingleQuotedString_ReturnsTrue() + { + Assert.True(ShellInterpreter.IsIncompleteInput("echo 'hello")); + } + + [Fact] + public void IsIncompleteInput_BalancedBraces_ReturnsFalse() + { + Assert.False(ShellInterpreter.IsIncompleteInput("if true { echo hi }")); + } + + [Fact] + public void IsIncompleteInput_ClosedString_ReturnsFalse() + { + Assert.False(ShellInterpreter.IsIncompleteInput("echo \"hello\"")); + } + + [Fact] + public void TryRemoveLineContinuation_OddTrailingBackslash_RemovesOneBackslash() + { + var line = "echo hello\\"; + + Assert.True(ShellInterpreter.TryRemoveLineContinuation(ref line)); + Assert.Equal("echo hello", line); + } + + [Fact] + public void TryRemoveLineContinuation_EvenTrailingBackslashes_KeepsLiteralBackslashes() + { + var line = "echo hello\\\\"; + + Assert.False(ShellInterpreter.TryRemoveLineContinuation(ref line)); + Assert.Equal("echo hello\\\\", line); + } + + [Fact] + public void AppendMultiLineFragment_BackslashContinuation_SplicesPhysicalLines() + { + var buffer = new StringBuilder("echo hello"); + + ShellInterpreter.AppendMultiLineFragment(buffer, " world", suppressNewline: true); + + Assert.Equal("echo hello world", buffer.ToString()); + } + + [Fact] + public void AppendMultiLineFragment_ParseContinuation_PreservesNewline() + { + var buffer = new StringBuilder("if true {"); + + ShellInterpreter.AppendMultiLineFragment(buffer, "echo hello", suppressNewline: false); + + Assert.Equal("if true {\necho hello", buffer.ToString()); + } + + [Fact] + public void DecodeHistoryLine_PreviousRawBackslashNEntry_RemainsLiteral() + { + Assert.Equal("echo \\n", ShellInterpreter.DecodeHistoryLine("echo \\n")); + } + + [Fact] + public void EncodeHistoryLine_MultiLineEntry_RoundTrips() + { + var command = "if true {\necho \\n\r}"; + + Assert.Equal(command, ShellInterpreter.DecodeHistoryLine(ShellInterpreter.EncodeHistoryLine(command))); + } + + [Fact] + public void EncodeHistoryLine_SingleLineBackslashEntry_StaysReadable() + { + Assert.Equal("echo \\n", ShellInterpreter.EncodeHistoryLine("echo \\n")); + } + + [Fact] + public void ProcessInteractiveLine_CompleteSingleLine_ReturnsItImmediately() + { + StringBuilder? buffer = null; + var suppress = false; + + var command = ShellInterpreter.ProcessInteractiveLine("ls", ref buffer, ref suppress); + + Assert.Equal("ls", command); + Assert.Null(buffer); + Assert.False(suppress); + } + + [Fact] + public void ProcessInteractiveLine_UnclosedBraceThenClose_AccumulatesAndJoinsWithNewlines() + { + StringBuilder? buffer = null; + var suppress = false; + + var first = ShellInterpreter.ProcessInteractiveLine("if true {", ref buffer, ref suppress); + Assert.Null(first); + Assert.NotNull(buffer); + + var second = ShellInterpreter.ProcessInteractiveLine(" echo hello", ref buffer, ref suppress); + Assert.Null(second); + + var third = ShellInterpreter.ProcessInteractiveLine("}", ref buffer, ref suppress); + Assert.Equal("if true {\n echo hello\n}", third); + Assert.Null(buffer); + Assert.False(suppress); + } + + [Fact] + public void ProcessInteractiveLine_BackslashContinuation_SplicesWithoutInsertingNewlines() + { + StringBuilder? buffer = null; + var suppress = false; + + var first = ShellInterpreter.ProcessInteractiveLine("echo hello\\", ref buffer, ref suppress); + Assert.Null(first); + Assert.NotNull(buffer); + Assert.True(suppress); + + var second = ShellInterpreter.ProcessInteractiveLine(" world", ref buffer, ref suppress); + Assert.Equal("echo hello world", second); + Assert.Null(buffer); + Assert.False(suppress); + } + + [Fact] + public void ProcessInteractiveLine_MixedBackslashThenParseContinuation_JoinsBothStyles() + { + StringBuilder? buffer = null; + var suppress = false; + + // Backslash continuation: next fragment splices without a newline. + Assert.Null(ShellInterpreter.ProcessInteractiveLine("if true \\", ref buffer, ref suppress)); + Assert.True(suppress); + + // The next line opens a block; from here on the input is incomplete because of + // the parser, not the backslash, so newlines must be preserved between fragments. + Assert.Null(ShellInterpreter.ProcessInteractiveLine("{", ref buffer, ref suppress)); + Assert.False(suppress); + + Assert.Null(ShellInterpreter.ProcessInteractiveLine(" echo hi", ref buffer, ref suppress)); + var command = ShellInterpreter.ProcessInteractiveLine("}", ref buffer, ref suppress); + Assert.Equal("if true {\n echo hi\n}", command); + Assert.Null(buffer); + Assert.False(suppress); + } + + [Fact] + public void ProcessInteractiveLine_UnterminatedStringSpanningTwoLines_JoinsWithNewline() + { + StringBuilder? buffer = null; + var suppress = false; + + Assert.Null(ShellInterpreter.ProcessInteractiveLine("echo \"hello", ref buffer, ref suppress)); + var command = ShellInterpreter.ProcessInteractiveLine("world\"", ref buffer, ref suppress); + + Assert.Equal("echo \"hello\nworld\"", command); + Assert.Null(buffer); + } + + [Fact] + public void ProcessInteractiveLine_NullInputMidBuffer_DiscardsBuffer() + { + StringBuilder? buffer = null; + var suppress = false; + + Assert.Null(ShellInterpreter.ProcessInteractiveLine("if true {", ref buffer, ref suppress)); + Assert.NotNull(buffer); + + // Cancelled ReadLine (Ctrl+C) is signalled by a null input. + var result = ShellInterpreter.ProcessInteractiveLine(null, ref buffer, ref suppress); + + Assert.Null(result); + Assert.Null(buffer); + Assert.False(suppress); + } + + [Fact] + public void ProcessInteractiveLine_NullInputWithEmptyBuffer_IsNoOp() + { + StringBuilder? buffer = null; + var suppress = false; + + var result = ShellInterpreter.ProcessInteractiveLine(null, ref buffer, ref suppress); + + Assert.Null(result); + Assert.Null(buffer); + Assert.False(suppress); + } + + [Fact] + public void ProcessInteractiveLine_EmptyLine_ReturnsEmptyAndDoesNotStartBuffer() + { + StringBuilder? buffer = null; + var suppress = false; + + var result = ShellInterpreter.ProcessInteractiveLine(string.Empty, ref buffer, ref suppress); + + Assert.Equal(string.Empty, result); + Assert.Null(buffer); + Assert.False(suppress); + } + + [Fact] + public void EncodeDecodeHistoryLine_MultiLineEntry_SurvivesDiskRoundTrip() + { + var original = new[] + { + "ls", + "if true {\n echo hello\n}", + "echo \\n", + "echo \"hello\nworld\"", + }; + + var path = Path.Combine(Path.GetTempPath(), $"cosmosshell-history-{Guid.NewGuid():N}.txt"); + try + { + File.WriteAllLines(path, original.Select(ShellInterpreter.EncodeHistoryLine)); + var decoded = File.ReadAllLines(path).Select(ShellInterpreter.DecodeHistoryLine).ToArray(); + + Assert.Equal(original, decoded); + } + finally + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + } + + [Fact] + public void DecodeHistoryLine_PrefixedLineWithInvalidEscape_ReturnsRawLine() + { + // A pre-existing history entry that literally begins with the prefix + // and contains a backslash sequence we never emit (\x). Decoding must + // leave it untouched rather than mangling the user's data. + var raw = "CosmosDBShellHistoryV1:hello \\x world"; + + Assert.Equal(raw, ShellInterpreter.DecodeHistoryLine(raw)); + } + + [Fact] + public void DecodeHistoryLine_PrefixedLineEndingInLoneBackslash_ReturnsRawLine() + { + var raw = "CosmosDBShellHistoryV1:trailing\\"; + + Assert.Equal(raw, ShellInterpreter.DecodeHistoryLine(raw)); + } + + [Fact] + public void DecodeHistoryLine_PrefixedLineWithOnlyValidEscapes_DecodesNormally() + { + // The encoder emits the prefix plus the "E:" marker before the escaped + // payload; lines that lack the marker are treated as user data and + // returned untouched. + var encoded = "CosmosDBShellHistoryV1:E:line1\\nline2\\\\end"; + + Assert.Equal("line1\nline2\\end", ShellInterpreter.DecodeHistoryLine(encoded)); + } + + [Fact] + public void DecodeHistoryLine_PrefixedLineWithoutMarker_ReturnsRawLine() + { + // A pre-existing history entry that literally begins with the prefix + // but lacks the encoder-only "E:" marker must be preserved verbatim, + // even when the payload would otherwise look like valid escapes. + var raw = "CosmosDBShellHistoryV1:line1\\nline2"; + + Assert.Equal(raw, ShellInterpreter.DecodeHistoryLine(raw)); + } + + [Fact] + public void EncodeHistoryLine_LineStartingWithPrefix_RoundTrips() + { + // A user command that literally starts with the prefix string must + // survive a round trip through the encoder/decoder. + var command = "CosmosDBShellHistoryV1:hello"; + + var encoded = ShellInterpreter.EncodeHistoryLine(command); + Assert.NotEqual(command, encoded); + Assert.Equal(command, ShellInterpreter.DecodeHistoryLine(encoded)); + } + + [Fact] + public void IsIncompleteInput_IncompleteExpression_ReturnsTrue() + { + // Inputs that trail off mid-expression must be recognized as + // incomplete so the REPL prompts for another line, regardless of + // whether the unexpected-end is raised by the statement parser or + // the expression parser. These cases exercise the expression-parser + // AbortUnexpectedEnd path (without `ParseErrorKind.UnexpectedEnd` + // they would all be misclassified as definitive syntax errors and + // the REPL would execute them instead of prompting for more). + Assert.True(ShellInterpreter.IsIncompleteInput("if (1 +")); + Assert.True(ShellInterpreter.IsIncompleteInput("while (1 + 2")); + Assert.True(ShellInterpreter.IsIncompleteInput("if a +")); + Assert.True(ShellInterpreter.IsIncompleteInput("echo (1+")); + } +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosShellPrompt.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosShellPrompt.cs index 96f03f8..a4af0c5 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosShellPrompt.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosShellPrompt.cs @@ -13,12 +13,27 @@ namespace Azure.Data.Cosmos.Shell.Core; internal class CosmosShellPrompt(ShellInterpreter shell) : ILineEditorPrompt, IStateVisitor { internal const string PromptText = "CS "; + private static readonly (Markup Markup, int Margin) ContinuationPrompt = (new Markup("[grey]...[/]"), 1); private readonly ShellInterpreter shell = shell ?? throw new ArgumentNullException(nameof(shell)); private Markup prompt = new(string.Empty); private State? oldState; + internal bool InContinuation { get; set; } + (Markup Markup, int Margin) ILineEditorPrompt.GetPrompt(ILineEditorState state, int line) { + // Show the continuation marker on any non-first row of the editor buffer. + // This covers two cases: (1) the user is typing a parse-driven continuation + // line that RadLine renders as row > 0, and (2) a multi-line entry was + // recalled from history and RadLine is rendering its later rows. The + // explicit InContinuation flag handles the third case where we start a + // fresh ReadLine for the next line of a multi-line entry (so the new + // row 0 still gets the continuation marker). + if (line > 0 || this.InContinuation) + { + return ContinuationPrompt; + } + if (this.oldState != this.shell.State) { this.oldState = this.shell.State; diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs index c339615..9f19e92 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs @@ -32,12 +32,25 @@ public partial class ShellInterpreter : IDisposable private const int OptionalArmDiscoveryTimeoutSeconds = 3; + private const string EncodedHistoryLinePrefix = "CosmosDBShellHistoryV1:"; + + // Sentinel written immediately after the prefix by EncodeHistoryLine so that + // DecodeHistoryLine can unambiguously tell a value it produced apart from a + // user command that just happens to start with the prefix string. + private const string EncodedHistoryLineMarker = "E:"; + private static CancellationTokenSource? currentTokenSource; private readonly string cfgPath; private LineEditor? lineEditor; + private CosmosShellPrompt? cosmosShellPrompt; + + private System.Text.StringBuilder? pendingMultiLineBuffer; + + private bool pendingMultiLineSuppressesNewline; + private CancellationTokenSource editorCancelTokenSource; private bool disposedValue; @@ -63,8 +76,9 @@ internal ShellInterpreter() { foreach (var line in File.ReadAllLines(this.HistoryFile)) { - this.history.Remove(line); - this.history.Add(line); + var decoded = DecodeHistoryLine(line); + this.history.Remove(decoded); + this.history.Add(decoded); } } @@ -483,7 +497,12 @@ internal async Task RunAsync() { this.ClearHighlightStatement(); var input = this.Editor != null ? await this.Editor.ReadLine(this.editorCancelTokenSource.Token) : PromptFallback(); - if (input is not { } command) + var command = ProcessInteractiveLine( + input, + ref this.pendingMultiLineBuffer, + ref this.pendingMultiLineSuppressesNewline, + this.cosmosShellPrompt); + if (command == null) { continue; } @@ -499,6 +518,12 @@ internal async Task RunAsync() } catch (TaskCanceledException) { + this.pendingMultiLineBuffer = null; + this.pendingMultiLineSuppressesNewline = false; + if (this.cosmosShellPrompt != null) + { + this.cosmosShellPrompt.InContinuation = false; + } } } @@ -1285,9 +1310,10 @@ private LineEditor CreateLineEditor() { try { + this.cosmosShellPrompt = new CosmosShellPrompt(this); var lineEditor = new LineEditor() { - Prompt = new CosmosShellPrompt(this), + Prompt = this.cosmosShellPrompt, LineDecorationRenderer = new CosmosCompletionRenderer(this), Highlighter = this, }; @@ -1340,6 +1366,13 @@ private LineEditor CreateLineEditor() private void Console_CancelKeyPress(object? sender, ConsoleCancelEventArgs e) { e.Cancel = true; + this.pendingMultiLineBuffer = null; + this.pendingMultiLineSuppressesNewline = false; + if (this.cosmosShellPrompt != null) + { + this.cosmosShellPrompt.InContinuation = false; + } + this.CancelPrompt(); WriteLine("̂C"); } @@ -1351,7 +1384,241 @@ private void SaveHistory() this.history = [.. this.history.Skip(this.history.Count - MAXHISTORYITEMS)]; } - File.WriteAllLines(this.HistoryFile, this.history); + File.WriteAllLines(this.HistoryFile, this.history.Select(EncodeHistoryLine)); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1204", Justification = "History helpers are grouped with SaveHistory for cohesion.")] + internal static string EncodeHistoryLine(string line) + { + if (line.IndexOfAny(['\n', '\r']) < 0 && !line.StartsWith(EncodedHistoryLinePrefix, StringComparison.Ordinal)) + { + return line; + } + + var sb = new System.Text.StringBuilder(line.Length + 8); + foreach (var ch in line) + { + switch (ch) + { + case '\\': sb.Append("\\\\"); break; + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + default: sb.Append(ch); break; + } + } + + return EncodedHistoryLinePrefix + EncodedHistoryLineMarker + sb.ToString(); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1204", Justification = "History helpers are grouped with SaveHistory for cohesion.")] + internal static string DecodeHistoryLine(string line) + { + // Require both the prefix and the encoder-only marker so a user command + // that happens to start with the prefix string is never silently rewritten. + if (!line.StartsWith(EncodedHistoryLinePrefix + EncodedHistoryLineMarker, StringComparison.Ordinal)) + { + return line; + } + + var payload = line.Substring(EncodedHistoryLinePrefix.Length + EncodedHistoryLineMarker.Length); + + // Defensive: validate that the payload only contains escape sequences + // we emit (\\, \n, \r). If a line was hand-edited and broke the format, + // fall back to returning it untouched rather than mangling the data. + for (int i = 0; i < payload.Length; i++) + { + if (payload[i] != '\\') + { + continue; + } + + if (i + 1 >= payload.Length) + { + return line; + } + + var next = payload[i + 1]; + if (next != '\\' && next != 'n' && next != 'r') + { + return line; + } + + i++; + } + + var sb = new System.Text.StringBuilder(payload.Length); + for (int i = 0; i < payload.Length; i++) + { + var ch = payload[i]; + if (ch == '\\' && i + 1 < payload.Length) + { + var next = payload[i + 1]; + switch (next) + { + case '\\': sb.Append('\\'); i++; continue; + case 'n': sb.Append('\n'); i++; continue; + case 'r': sb.Append('\r'); i++; continue; + } + } + + sb.Append(ch); + } + + return sb.ToString(); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1204", Justification = "Grouped with REPL helpers.")] + internal static bool TryRemoveLineContinuation(ref string line) + { + if (line.Length == 0 || line[^1] != '\\') + { + return false; + } + + int trailing = 0; + for (int i = line.Length - 1; i >= 0 && line[i] == '\\'; i--) + { + trailing++; + } + + if ((trailing & 1) == 0) + { + return false; + } + + line = line.Substring(0, line.Length - 1); + return true; + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1204", Justification = "Grouped with REPL helpers.")] + internal static void AppendMultiLineFragment(System.Text.StringBuilder buffer, string line, bool suppressNewline) + { + if (!suppressNewline) + { + buffer.Append('\n'); + } + + buffer.Append(line); + } + + /// + /// Processes one physical input line through the REPL multi-line accumulation state + /// machine. Returns the joined command text when execution should proceed, or + /// null when the loop should keep reading additional continuation lines (or + /// when the input itself was cancelled and the pending buffer was discarded). + /// + /// The raw line returned by ReadLine, or null if ReadLine was cancelled. + /// The in-flight multi-line buffer; replaced or cleared in place. + /// Tracks whether the next appended fragment must splice without a newline (backslash continuation). + /// Optional prompt whose InContinuation flag is kept in sync; may be null in non-interactive callers and tests. + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1204", Justification = "Grouped with REPL helpers.")] + internal static string? ProcessInteractiveLine( + string? input, + ref System.Text.StringBuilder? pendingBuffer, + ref bool pendingSuppressesNewline, + CosmosShellPrompt? prompt = null) + { + if (input is not { } line) + { + // ReadLine cancelled (Ctrl+C). Discard any in-progress multi-line buffer. + pendingBuffer = null; + pendingSuppressesNewline = false; + if (prompt != null) + { + prompt.InContinuation = false; + } + + return null; + } + + // Detect explicit backslash-at-end-of-line continuation (bash-style). + bool backslashContinuation = TryRemoveLineContinuation(ref line); + + // Compute the "incomplete?" decision exactly once per Enter press: parsing is + // not free, and the previous shape evaluated the same text twice on the line + // that starts a multi-line buffer. + bool incompleteAggregated; + if (pendingBuffer != null) + { + AppendMultiLineFragment(pendingBuffer, line, pendingSuppressesNewline); + incompleteAggregated = backslashContinuation || IsIncompleteInput(pendingBuffer.ToString()); + } + else + { + bool lineIncomplete = backslashContinuation || IsIncompleteInput(line); + if (!lineIncomplete) + { + return line; + } + + pendingBuffer = new System.Text.StringBuilder(line); + incompleteAggregated = true; // aggregated == line on the first iteration + } + + if (incompleteAggregated) + { + if (prompt != null) + { + prompt.InContinuation = true; + } + + pendingSuppressesNewline = backslashContinuation; + return null; + } + + var aggregated = pendingBuffer.ToString(); + pendingBuffer = null; + pendingSuppressesNewline = false; + if (prompt != null) + { + prompt.InContinuation = false; + } + + return aggregated; + } + + /// + /// Returns true if the given input text appears to be an incomplete shell command — + /// either because the lexer flagged an unterminated string or the parser ran off the + /// end of input. Used by the REPL to decide whether to prompt for a continuation line. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1204", Justification = "Grouped with REPL helpers.")] + internal static bool IsIncompleteInput(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return false; + } + + try + { + var lexer = new Lexer(text); + var parser = new StatementParser(lexer); + + // ParseStatements() runs the parser to end-of-input and returns the + // full statement list eagerly; the result is discarded because we + // only care about whether parsing flagged the input as incomplete. + _ = parser.ParseStatements(); + + foreach (var err in parser.Errors) + { + if (err.ErrorLevel != ErrorLevel.Error) + { + continue; + } + + if (err.Kind == ParseErrorKind.UnexpectedEnd || err.Kind == ParseErrorKind.UnterminatedString) + { + return true; + } + } + + return false; + } + catch (Exception) + { + return false; + } } private void ReportExecutionError(Exception e) diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/ExpressionParser.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/ExpressionParser.cs index bc2b154..714b2d0 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/ExpressionParser.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/ExpressionParser.cs @@ -1210,11 +1210,11 @@ private Expression ParseJsonArray() return new JsonArrayExpression(lbracket, synthetic, elements); } - private void ReportError(string message, Token? token, TokenType? expected = null, int? position = null) + private void ReportError(string message, Token? token, TokenType? expected = null, int? position = null, ParseErrorKind kind = ParseErrorKind.Generic) { int start = position ?? token?.Start ?? (this.lastNonNullToken?.Start ?? 0); int length = token?.Length ?? 0; - this.lexer.Errors.Add(ParseError.CreateError(start, length, message)); + this.lexer.Errors.Add(new ParseError(start, length, message, ErrorLevel.Error, kind)); } private void AbortUnexpectedEnd() @@ -1225,7 +1225,7 @@ private void AbortUnexpectedEnd() } int pos = this.GetErrorPosition(); - this.ReportError(MessageService.GetString("expression_error_unexpected_end"), null, position: pos); + this.ReportError(MessageService.GetString("expression_error_unexpected_end"), null, position: pos, kind: ParseErrorKind.UnexpectedEnd); this.aborted = true; } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/Lexer.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/Lexer.cs index fe10198..aef09c2 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/Lexer.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/Lexer.cs @@ -10,6 +10,8 @@ namespace Azure.Data.Cosmos.Shell.Parser; using System.Text; using System.Threading.Tasks; +using Azure.Data.Cosmos.Shell.Util; + /// /// Represents the different types of tokens that can be recognized by the lexer. /// @@ -619,6 +621,7 @@ private Token ReadDoubleQuotedString(int startPosition) { var sb = new StringBuilder(); bool hasInterpolation = false; + bool terminated = false; // Mirrors the source-position tracking in ReadInterpolatedString so callers can // map cooked content indices back to absolute outer-source positions when the @@ -637,6 +640,7 @@ private Token ReadDoubleQuotedString(int startPosition) { // Skip closing quote this.Advance(); + terminated = true; break; } else if (ch == '\\' && this.position + 1 < this.input.Length) @@ -679,12 +683,23 @@ private Token ReadDoubleQuotedString(int startPosition) this.interpolatedStringSourceMaps[token] = sourcePositions.ToArray(); } + if (!terminated) + { + this.Errors.Add(new ParseError( + startPosition, + Math.Max(1, this.position - startPosition), + MessageService.GetString("lexer_error_unterminated_string") ?? "Unterminated string literal", + ErrorLevel.Error, + ParseErrorKind.UnterminatedString)); + } + return token; } private Token ReadSingleQuotedString(int startPosition) { var sb = new StringBuilder(); + bool terminated = false; // Skip opening quote this.Advance(); @@ -707,6 +722,7 @@ private Token ReadSingleQuotedString(int startPosition) { // It's the closing quote this.Advance(); + terminated = true; break; } } @@ -718,6 +734,16 @@ private Token ReadSingleQuotedString(int startPosition) } } + if (!terminated) + { + this.Errors.Add(new ParseError( + startPosition, + Math.Max(1, this.position - startPosition), + MessageService.GetString("lexer_error_unterminated_string") ?? "Unterminated string literal", + ErrorLevel.Error, + ParseErrorKind.UnterminatedString)); + } + return this.MakeToken(TokenType.String, sb.ToString(), startPosition, this.position - startPosition); } @@ -850,6 +876,7 @@ private bool LookAhead(string text) private Token ReadInterpolatedString(int startPosition) { var sb = new StringBuilder(); + bool terminated = false; // Records the absolute outer-source position of the source character that // produced each cooked character appended to sb. Used by callers @@ -872,6 +899,7 @@ private Token ReadInterpolatedString(int startPosition) { // Skip closing quote this.Advance(); + terminated = true; break; } else if (ch == '\\' && this.position + 1 < this.input.Length) @@ -921,6 +949,17 @@ private Token ReadInterpolatedString(int startPosition) var token = this.MakeToken(TokenType.InterpolatedString, sb.ToString(), startPosition, this.position - startPosition); this.interpolatedStringSourceMaps[token] = sourcePositions.ToArray(); + + if (!terminated) + { + this.Errors.Add(new ParseError( + startPosition, + Math.Max(1, this.position - startPosition), + MessageService.GetString("lexer_error_unterminated_string") ?? "Unterminated string literal", + ErrorLevel.Error, + ParseErrorKind.UnterminatedString)); + } + return token; } } \ No newline at end of file diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/ParseError.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/ParseError.cs index 5200a84..d4cf486 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/ParseError.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/ParseError.cs @@ -23,6 +23,33 @@ public enum ErrorLevel Error, } +/// +/// Categorizes a parse error so the REPL can distinguish "input is incomplete" +/// (the parser ran off the end while expecting more) from a definitive +/// syntax error. Continuation-prompt logic only treats incomplete-input +/// kinds as a signal to keep reading more lines. +/// +public enum ParseErrorKind +{ + /// + /// An ordinary syntax error. Reporting it does not imply the input + /// would be accepted if the user typed more characters. + /// + Generic, + + /// + /// The parser hit end-of-input while expecting another token + /// (unclosed brace, missing right-hand side of an operator, etc.). + /// + UnexpectedEnd, + + /// + /// The lexer reached end-of-input inside a string literal without + /// seeing the closing quote. + /// + UnterminatedString, +} + /// /// Represents a parsing error with location, message, and severity. /// @@ -35,12 +62,14 @@ public class ParseError /// The length of the error span. /// The error message. /// The severity level of the error. - public ParseError(int start, int length, string message, ErrorLevel errorLevel = ErrorLevel.Error) + /// The category of error. + public ParseError(int start, int length, string message, ErrorLevel errorLevel = ErrorLevel.Error, ParseErrorKind kind = ParseErrorKind.Generic) { this.Start = start; this.Length = length; this.Message = message ?? throw new ArgumentNullException(nameof(message)); this.ErrorLevel = errorLevel; + this.Kind = kind; } /// @@ -63,6 +92,11 @@ public ParseError(int start, int length, string message, ErrorLevel errorLevel = /// public ErrorLevel ErrorLevel { get; set; } + /// + /// Gets or sets the category of the error. + /// + public ParseErrorKind Kind { get; set; } + /// /// Creates a new instance with . /// diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/StatementParser.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/StatementParser.cs index 7cbf9d6..5603850 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/StatementParser.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/StatementParser.cs @@ -215,7 +215,7 @@ private void SkipWs() var current = this.expressionParser.Current; if (current == null) { - this.ReportError(MessageService.GetString("statement_error_unexpected_end") ?? "Unexpected end of input", null); + this.ReportError(MessageService.GetString("statement_error_unexpected_end") ?? "Unexpected end of input", null, ParseErrorKind.UnexpectedEnd); return null; } @@ -265,7 +265,7 @@ private void SkipWs() var ifToken = this.expressionParser.Current; if (ifToken == null) { - this.ReportError(MessageService.GetString("statement_error_unexpected_end") ?? "Unexpected end of input", null); + this.ReportError(MessageService.GetString("statement_error_unexpected_end") ?? "Unexpected end of input", null, ParseErrorKind.UnexpectedEnd); return null; } @@ -309,7 +309,7 @@ private void SkipWs() var whileToken = this.expressionParser.Current; if (whileToken == null) { - this.ReportError(MessageService.GetString("statement_error_unexpected_end") ?? "Unexpected end of input", null); + this.ReportError(MessageService.GetString("statement_error_unexpected_end") ?? "Unexpected end of input", null, ParseErrorKind.UnexpectedEnd); return null; } @@ -338,7 +338,7 @@ private void SkipWs() var forToken = this.expressionParser.Current; if (forToken == null) { - this.ReportError(MessageService.GetString("statement_error_unexpected_end") ?? "Unexpected end of input", null); + this.ReportError(MessageService.GetString("statement_error_unexpected_end") ?? "Unexpected end of input", null, ParseErrorKind.UnexpectedEnd); return null; } @@ -357,7 +357,7 @@ private void SkipWs() if (this.expressionParser.Current == null) { - this.ReportError(MessageService.GetString("statement_error_unexpected_end") ?? "Unexpected end of input", null); + this.ReportError(MessageService.GetString("statement_error_unexpected_end") ?? "Unexpected end of input", null, ParseErrorKind.UnexpectedEnd); return null; } @@ -388,7 +388,7 @@ private void SkipWs() var doToken = this.expressionParser.Current; if (doToken == null) { - this.ReportError(MessageService.GetString("statement_error_unexpected_end") ?? "Unexpected end of input", null); + this.ReportError(MessageService.GetString("statement_error_unexpected_end") ?? "Unexpected end of input", null, ParseErrorKind.UnexpectedEnd); return null; } @@ -405,7 +405,7 @@ private void SkipWs() if (this.expressionParser.Current == null) { - this.ReportError(MessageService.GetString("statement_error_unexpected_end") ?? "Unexpected end of input", null); + this.ReportError(MessageService.GetString("statement_error_unexpected_end") ?? "Unexpected end of input", null, ParseErrorKind.UnexpectedEnd); return null; } @@ -429,7 +429,7 @@ private void SkipWs() var loopToken = this.expressionParser.Current; if (loopToken == null) { - this.ReportError(MessageService.GetString("statement_error_unexpected_end") ?? "Unexpected end of input", null); + this.ReportError(MessageService.GetString("statement_error_unexpected_end") ?? "Unexpected end of input", null, ParseErrorKind.UnexpectedEnd); return null; } @@ -457,7 +457,7 @@ private void SkipWs() var execToken = this.expressionParser.Current; if (execToken == null) { - this.ReportError(MessageService.GetString("statement_error_unexpected_end") ?? "Unexpected end of input", null); + this.ReportError(MessageService.GetString("statement_error_unexpected_end") ?? "Unexpected end of input", null, ParseErrorKind.UnexpectedEnd); return null; } @@ -501,7 +501,7 @@ private void SkipWs() var defToken = this.expressionParser.Current; if (defToken == null) { - this.ReportError(MessageService.GetString("statement_error_unexpected_end") ?? "Unexpected end of input", null); + this.ReportError(MessageService.GetString("statement_error_unexpected_end") ?? "Unexpected end of input", null, ParseErrorKind.UnexpectedEnd); return null; } @@ -509,7 +509,7 @@ private void SkipWs() if (this.expressionParser.Current == null) { - this.ReportError(MessageService.GetString("statement_error_unexpected_end") ?? "Unexpected end of input", null); + this.ReportError(MessageService.GetString("statement_error_unexpected_end") ?? "Unexpected end of input", null, ParseErrorKind.UnexpectedEnd); return null; } @@ -683,7 +683,7 @@ private void SkipWs() var token = this.expressionParser.Current; if (token == null) { - this.ReportError(MessageService.GetString("statement_error_unexpected_end") ?? "Unexpected end of input", null); + this.ReportError(MessageService.GetString("statement_error_unexpected_end") ?? "Unexpected end of input", null, ParseErrorKind.UnexpectedEnd); return null; } @@ -714,7 +714,7 @@ private void SkipWs() var token = this.expressionParser.Current; if (token == null) { - this.ReportError(MessageService.GetString("statement_error_unexpected_end") ?? "Unexpected end of input", null); + this.ReportError(MessageService.GetString("statement_error_unexpected_end") ?? "Unexpected end of input", null, ParseErrorKind.UnexpectedEnd); return null; } @@ -735,7 +735,7 @@ private void SkipWs() var token = this.expressionParser.Current; if (token == null) { - this.ReportError(MessageService.GetString("statement_error_unexpected_end") ?? "Unexpected end of input", null); + this.ReportError(MessageService.GetString("statement_error_unexpected_end") ?? "Unexpected end of input", null, ParseErrorKind.UnexpectedEnd); return null; } @@ -756,7 +756,7 @@ private void SkipWs() var openBrace = this.expressionParser.Current; if (openBrace == null) { - this.ReportError(MessageService.GetString("statement_error_unexpected_end") ?? "Unexpected end of input", null); + this.ReportError(MessageService.GetString("statement_error_unexpected_end") ?? "Unexpected end of input", null, ParseErrorKind.UnexpectedEnd); return null; } @@ -799,7 +799,13 @@ private void SkipWs() if (this.expressionParser.Current == null || this.expressionParser.Current.Type != TokenType.CloseBrace) { - this.ReportError(MessageService.GetString("statement_error_expected_close_brace"), this.expressionParser.Current); + // If we ran out of input entirely while waiting for '}', this is the same + // signal as any other "ran off the end" error — surface it as UnexpectedEnd + // so the REPL can prompt for a continuation line. + var kind = this.expressionParser.Current == null + ? ParseErrorKind.UnexpectedEnd + : ParseErrorKind.Generic; + this.ReportError(MessageService.GetString("statement_error_expected_close_brace"), this.expressionParser.Current, kind); return null; } @@ -857,7 +863,7 @@ private void SkipWs() { if (optToken == null && this.expressionParser.IsAtEnd) { - this.ReportError(MessageService.GetString("statement_error_unexpected_end_parsing_command"), this.expressionParser.Current); + this.ReportError(MessageService.GetString("statement_error_unexpected_end_parsing_command"), this.expressionParser.Current, ParseErrorKind.UnexpectedEnd); return null; } @@ -872,7 +878,7 @@ private void SkipWs() } else { - this.ReportError(MessageService.GetString("statement_error_unexpected_end") ?? "Unexpected end of input", null); + this.ReportError(MessageService.GetString("statement_error_unexpected_end") ?? "Unexpected end of input", null, ParseErrorKind.UnexpectedEnd); return null; } @@ -1125,16 +1131,16 @@ private void SkipWs() } } - private void ReportError(string message, Token? token) + private void ReportError(string message, Token? token, ParseErrorKind kind = ParseErrorKind.Generic) { var t = token ?? this.expressionParser.Current; if (t == null) { - this.lexer.Errors.Add(new ParseError(0, 1, message, ErrorLevel.Error)); + this.lexer.Errors.Add(new ParseError(0, 1, message, ErrorLevel.Error, kind)); } else { - this.lexer.Errors.Add(new ParseError(t.Start, Math.Max(1, t.Length), message, ErrorLevel.Error)); + this.lexer.Errors.Add(new ParseError(t.Start, Math.Max(1, t.Length), message, ErrorLevel.Error, kind)); } } diff --git a/CosmosDBShell/lang/en.ftl b/CosmosDBShell/lang/en.ftl index 16570ff..a8ef57b 100644 --- a/CosmosDBShell/lang/en.ftl +++ b/CosmosDBShell/lang/en.ftl @@ -564,6 +564,7 @@ statement_error_expected_close_brace = Expected '\u007D' statement_error_unexpected_close_brace = Unexpected '\u007D' statement_error_unexpected_end = Unexpected end of input statement_error_unexpected_end_parsing_command = Unexpected end of input when parsing command +lexer_error_unterminated_string = Unterminated string literal statement_error_expected_command_name = Expected command name statement_error_expected_option_name = Expected option name after '{$prefix}' statement_error_invalid_option_value = Invalid value for option '{ $option }' diff --git a/README.md b/README.md index c0232eb..a5b37c9 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Lightweight CLI for Azure Cosmos DB. - Create, query, replace, patch, delete: `mkdb`, `mkcon`, `mkitem`, `query`, `replace`, `patch`, `rm` - Database and container management commands prefer Azure Resource Manager when connected with Entra ID, with data-plane fallback for key, emulator, and static-token connections - Pipelines and scripting with variables, loops, functions +- Multi-line input at the prompt — automatic continuation for unclosed blocks/strings, plus explicit `\` line continuation ([docs](docs/navigation.md#multi-line-input)) - MCP server for AI/tool integration ## Quick Start diff --git a/docs/navigation.md b/docs/navigation.md index d9ee453..d796e74 100644 --- a/docs/navigation.md +++ b/docs/navigation.md @@ -180,6 +180,56 @@ These commands accept and process piped JSON: | `jq` | Filters/transforms piped JSON | | `ftab` | Formats piped JSON as table | +## Multi-line Input + +The interactive prompt accepts commands that span more than one physical line. There are two ways to enter multi-line input — they can be mixed freely. + +### Automatic (parse-driven) continuation + +The shell inspects what you've typed when you press Enter. If the input is *syntactically incomplete*, the prompt switches to a grey `...` continuation prompt and keeps reading instead of executing. Input is considered incomplete when: + +- a block is left open: `{`, `(`, or `[` without its matching close +- a quoted string has no closing quote (`"`, `'`, or interpolated `` ` ``) +- a statement ends part-way through (for example, after `if`, `for`, `function`, or a trailing operator) + +Example — paste or type, pressing Enter at the end of each line: + +```cosmosdb +> if ($x > 0) { +... echo "positive" +... } else { +... echo "non-positive" +... } +``` + +The command runs as soon as the final `}` closes the outer block. + +### Explicit backslash continuation + +End any line with a single backslash (`\`) and press Enter to continue on the next line, bash-style. This works even when the input *would* parse on its own, which is useful for breaking long single-statement commands across lines: + +```cosmosdb +> query "SELECT c.id, c.status FROM c \ +... WHERE c.priority = 1" \ +... --db ToDoList --con Items +``` + +Backslash runs follow normal escaping rules: an even number of trailing backslashes (`\\`, `\\\\`, …) is a literal and does **not** continue the line. An odd number (`\`, `\\\`, …) continues, with the final backslash consumed as the continuation marker. + +### Cancelling a multi-line entry + +While the `...` prompt is active: + +- Press **Ctrl+C** to discard everything you've typed so far and return to the regular prompt. +- Press **Esc** to clear the current line; this only affects the line you're editing, not the buffered earlier lines. +- An EOF / cancelled read (for example, Ctrl+D on an empty line) also discards the pending buffer. + +There is no separate "enter multi-line mode" command — the shell enters and leaves continuation mode automatically based on the rules above. + +### History + +Multi-line commands are saved to history as a single entry. When you recall one with `Up` / `Ctrl+P` or reverse-search (`Ctrl+R`), the full multi-line text is restored. History files written by older versions of the shell continue to load unchanged. + ## Keyboard Shortcuts Available at the interactive prompt: