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
2 changes: 1 addition & 1 deletion CSharpRepl.Services/CSharpRepl.Services.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
<PackageReference Include="Microsoft.SymbolStore" Version="1.0.731401" />
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" Version="10.7.0" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.7.0" />
<PackageReference Include="PrettyPrompt" Version="6.0.2" />
<PackageReference Include="PrettyPrompt" Version="6.0.3" />
<PackageReference Include="Spectre.Console.Cli" Version="0.55.0" />
<PackageReference Include="Spectre.Console.Ansi" Version="0.57.0" />
<PackageReference Include="System.IO.Abstractions" Version="22.1.1" />
Expand Down
7 changes: 7 additions & 0 deletions CSharpRepl.Services/Roslyn/RoslynServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,13 @@ public async Task<bool> IsTextCompleteStatementAsync(string text)
return root is null || SyntaxFactory.IsCompleteSubmission(root.SyntaxTree); // if something's wrong and we can't get the syntax tree, we don't want to prevent evaluation.
}

/// <summary>
/// When <paramref name="text"/> is a single-line statement the user didn't terminate (e.g. <c>int i = 0</c>),
/// returns it with the missing semicolon appended; otherwise <see langword="null"/>.
/// It is deliberately editor-only; piped input is not auto-terminated.
/// </summary>
public string? TryAutoInsertSemicolon(string text) => AutoInsertSemicolon.TryAppend(text, parseOptions);

public async Task<PrettyPromptTextSpan> GetSpanToReplaceByCompletionAsync(string text, int caret, CancellationToken cancellationToken)
{
await Initialization.ConfigureAwait(false);
Expand Down
54 changes: 54 additions & 0 deletions CSharpRepl.Services/Roslyn/Scripting/AutoInsertSemicolon.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

using System;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace CSharpRepl.Services.Roslyn.Scripting;

/// <summary>
/// If the user types "var x = 0" and submits, autoinsert the trailing semicolon.
///
/// This is an <em>interactive editor</em> concern: the prompt uses it to decide whether Enter submits and to
/// show the inserted <c>;</c> on the committed line. Evaluation itself is left untouched (piped input is not
/// auto-terminated).
/// </summary>
internal static class AutoInsertSemicolon
{
/// <summary>
/// When <paramref name="text"/> is a single-line statement the user didn't terminate, returns it (trimmed)
/// with the missing semicolon appended, otherwise return <see langword="null"/>
/// </summary>
public static string? TryAppend(string text, CSharpParseOptions parseOptions)
{
var trimmed = text.Trim();
var tree = CSharpSyntaxTree.ParseText(trimmed, parseOptions);
if (SyntaxFactory.IsCompleteSubmission(tree))
{
return null; // already complete — nothing to append (e.g. the expression `1 + 1`)
}

// Only single-line input is auto-semicoloned; a statement spread across lines (e.g. a fluent chain whose
// cursor is at the end of line 2) is left alone so it can keep being edited.
if (trimmed.AsSpan().IndexOfAny('\n', '\r') >= 0)
{
return null;
}

if (tree.GetRoot() is not CompilationUnitSyntax { Members: [.., FieldDeclarationSyntax { SemicolonToken.IsMissing: true }] })
{
return null;
}

var terminated = trimmed + ";";
var fixedTree = CSharpSyntaxTree.ParseText(terminated, parseOptions);
return SyntaxFactory.IsCompleteSubmission(fixedTree) &&
!fixedTree.GetDiagnostics().Any(d => d.Severity == DiagnosticSeverity.Error)
? terminated
: null;
}
}
2 changes: 1 addition & 1 deletion CSharpRepl/CSharpRepl.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="PrettyPrompt" Version="6.0.2" />
<PackageReference Include="PrettyPrompt" Version="6.0.3" />
<PackageReference Include="System.CommandLine" Version="3.0.0-preview.5.26302.115" />
<PackageReference Include="System.Reflection.MetadataLoadContext" Version="10.0.9" />
<!-- Keep MSBuild/StringTools assemblies out of the output dir; they're loaded from the SDK via
Expand Down
13 changes: 11 additions & 2 deletions CSharpRepl/CSharpReplPromptCallbacks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -214,11 +214,13 @@ FormatSpan FullSpanWithColor(AnsiColor color)

protected override async Task<KeyPress> TransformKeyPressAsync(string text, int caret, KeyPress keyPress, CancellationToken cancellationToken)
{
// user submitted the prompt but it's incomplete. Insert a newline automatically with the correct level of indentation.
// User submitted the prompt but it's incomplete. Insert a newline automatically with the correct level of indentation.
// A single-line statement the user didn't terminate (e.g. `int i = 0`) counts as submittable, we'll auto-insert the semicolon for them.
if (keyPress.ConsoleKeyInfo.Key == ConsoleKey.Enter &&
keyPress.ConsoleKeyInfo.Modifiers == default &&
configuration.KeyBindings.SubmitPrompt.Matches(keyPress.ConsoleKeyInfo) &&
!await roslyn.IsTextCompleteStatementAsync(text).ConfigureAwait(false))
!await roslyn.IsTextCompleteStatementAsync(text).ConfigureAwait(false) &&
roslyn.TryAutoInsertSemicolon(text) is null)
{
return NewLineWithIndentation(GetSmartIndentationLevel(text, caret));
}
Expand Down Expand Up @@ -301,6 +303,13 @@ protected override Task<bool> ConfirmCompletionCommit(string text, int caret, Ke
{
var keyChar = keyPress.ConsoleKeyInfo.KeyChar;

// user submitted the prompt but it's incomplete. Auto-insert a semicolon.
if (configuration.KeyBindings.SubmitPrompt.Matches(keyPress.ConsoleKeyInfo) &&
roslyn.TryAutoInsertSemicolon(text) is string textWithSemicolon)
{
return await roslyn.FormatInput(textWithSemicolon, textWithSemicolon.Length, formatParentNodeOnly: false, cancellationToken).ConfigureAwait(false);
}

if (caret > 0)
{
switch (keyChar)
Expand Down
49 changes: 49 additions & 0 deletions Tests/CSharpRepl.Tests/AutoInsertSemicolonTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

#nullable enable

using CSharpRepl.Services.Roslyn.Scripting;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Xunit;

namespace CSharpRepl.Tests;

/// <summary>
/// Unit tests for the issue #356 single-line auto-semicolon rule. These are pure (no Roslyn workspace/fixture):
/// the rule only needs the same Script <see cref="CSharpParseOptions"/> that <c>RoslynServices</c> uses.
/// </summary>
public class AutoInsertSemicolonTests
{
private static readonly CSharpParseOptions ParseOptions =
CSharpParseOptions.Default.WithKind(SourceCodeKind.Script).WithLanguageVersion(LanguageVersion.Latest);

[Theory]
// single-line declaration the user didn't terminate -> complete, and TryAppend adds the ';'
[InlineData("int i = 0", true)]
[InlineData("var x = 5", true)]
[InlineData("int j", true)]
[InlineData("int a = 1, b = 2", true)]
[InlineData("var x = Enumerable.Range(1, 5)", true)]
// already-complete submissions -> complete, but nothing to append
[InlineData("var x = 5;", false)]
[InlineData("1 + 1", false)]
[InlineData("if (x == 4) return;", false)]
// genuinely incomplete / not a single-line field declaration -> neither complete nor appended
[InlineData("var x = ", false)]
[InlineData("if (x == 4)", false)]
[InlineData("int Square(int x)", false)]
[InlineData("var x = Enumerable\n.Range(1, 5)", false)]
[InlineData("if you're happy and you know it, syntax error!", false)]
public void TryAppend_AppendsSemicolonOnlyForUnterminatedSingleLineDeclarations(string code, bool shouldAppend)
{
var appended = AutoInsertSemicolon.TryAppend(code, ParseOptions);

if (shouldAppend)
Assert.Equal(code.Trim() + ";", appended);
else
Assert.Null(appended);
}
}
2 changes: 1 addition & 1 deletion Tests/CSharpRepl.Tests/CSharpRepl.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

<ItemGroup>
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="PrettyPrompt" Version="6.0.2" />
<PackageReference Include="PrettyPrompt" Version="6.0.3" />
<PackageReference Include="Spectre.Console.Testing" Version="0.57.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="22.1.1" />
<PackageReference Include="xunit.v3.mtp-v2" Version="4.0.0-pre.128" />
Expand Down
53 changes: 52 additions & 1 deletion Tests/CSharpRepl.Tests/PromptConfigurationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -204,13 +204,64 @@ public async Task HighlightCallback_RegularCode_DelegatesToRoslynClassification(
Assert.NotEmpty(spans);
}

/// <summary>Exposes the protected highlight callback so the REPL-keyword highlighting can be tested.</summary>
[Fact]
public async Task TransformKeyPress_EnterOnUnterminatedDeclaration_Submits()
{
IPromptCallbacks configuration = new CSharpReplPromptCallbacks(console, services, new Configuration());
var enterKey = new KeyPress(new ConsoleKeyInfo('\r', ConsoleKey.Enter, shift: false, alt: false, control: false));

var transformed = await configuration.TransformKeyPressAsync("int i = 0", caret: 9, enterKey, CancellationToken.None);

// submittable -> Enter passes through unchanged (it is not turned into an indented newline)
Assert.Null(transformed.PastedText);
}

[Fact]
public async Task TransformKeyPress_EnterOnIncompleteStatement_InsertsNewline()
{
IPromptCallbacks configuration = new CSharpReplPromptCallbacks(console, services, new Configuration());
var enterKey = new KeyPress(new ConsoleKeyInfo('\r', ConsoleKey.Enter, shift: false, alt: false, control: false));

var transformed = await configuration.TransformKeyPressAsync("if (x == 4)", caret: 11, enterKey, CancellationToken.None);

Assert.Equal("\n", transformed.PastedText);
}

[Fact]
public async Task FormatInput_SubmitOnUnterminatedDeclaration_InsertsSemicolon()
{
var callbacks = new TestableCallbacks(console, services, new Configuration());
var enter = new KeyPress(new ConsoleKeyInfo('\r', ConsoleKey.Enter, shift: false, alt: false, control: false));

var (text, caret) = await callbacks.Format("int i = 0", caret: 9, enter);

Assert.Equal("int i = 0;", text);
Assert.Equal(text.Length, caret);
}

[Theory]
[InlineData("1 + 1")] // already complete - no semicolon to add
[InlineData("if (x == 4)")] // not a single-line declaration the user merely didn't terminate
public async Task FormatInput_SubmitOnNonAppendable_LeavesTextUnchanged(string code)
{
var callbacks = new TestableCallbacks(console, services, new Configuration());
var enter = new KeyPress(new ConsoleKeyInfo('\r', ConsoleKey.Enter, shift: false, alt: false, control: false));

var (text, _) = await callbacks.Format(code, caret: code.Length, enter);

Assert.Equal(code, text);
}

/// <summary>Exposes the protected highlight and format callbacks for testing.</summary>
private sealed class TestableCallbacks : CSharpReplPromptCallbacks
{
public TestableCallbacks(IConsoleService console, RoslynServices roslyn, Configuration configuration)
: base(console, roslyn, configuration) { }

public async Task<IReadOnlyCollection<FormatSpan>> Highlight(string text)
=> await HighlightCallbackAsync(text, default);

public Task<(string Text, int Caret)> Format(string text, int caret, KeyPress key)
=> FormatInput(text, caret, key, default);
}
}
1 change: 1 addition & 0 deletions Tests/CSharpRepl.Tests/RoslynServicesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public RoslynServicesTests(RoslynServicesFixture fixture)
[InlineData("if (x == 4)", false)]
[InlineData("if (x == 4) return;", true)]
[InlineData("if you're happy and you know it, syntax error!", false)]
[InlineData("int i = 0", false)] // A single-line declaration without a trailing semicolon is NOT a complete submission at this level. It's an editor-only convenience to automatically append the semicolon.
public async Task IsCompleteStatement(string code, bool shouldBeCompleteStatement)
{
bool isCompleteStatement = await services.IsTextCompleteStatementAsync(code);
Expand Down
Loading