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
19 changes: 11 additions & 8 deletions src/Spectre.Console.Ansi/AnsiMarkup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ public static IEnumerable<AnsiMarkupSegment> Parse(string markup, Style? style =
using var tokenizer = new MarkupTokenizer(markup);

var result = new List<AnsiMarkupSegment>();
var stack = new Stack<Style>();
var styleStack = new Stack<Style>();
var linkStack = new Stack<Link?>();
var link = default(Link?);

while (tokenizer.MoveNext())
Expand All @@ -64,25 +65,27 @@ public static IEnumerable<AnsiMarkupSegment> Parse(string markup, Style? style =
if (token.Kind == MarkupTokenKind.Open)
{
var parsed = AnsiMarkupTagParser.Parse(token.Value);
link ??= parsed.Link;
stack.Push(style.Value);
linkStack.Push(link);
link = parsed.Link ?? link;
styleStack.Push(style.Value);
style = style.Value.Combine(parsed.Style);
}
else if (token.Kind == MarkupTokenKind.Close)
{
if (stack.Count == 0)
if (styleStack.Count == 0)
{
throw new InvalidOperationException(
$"Encountered closing tag when none was expected near position {token.Position}.");
}

style = stack.Pop();
style = styleStack.Pop();
link = linkStack.Pop();
}
else if (token.Kind == MarkupTokenKind.Text)
{
if (result.Count > 0 && result[^1].Style.Equals(style))
if (result.Count > 0 && result[^1].Style.Equals(style) && Equals(result[^1].Link, link))
{
// Merge segments
// Merge segments with same style and link
result[^1].Text += token.Value;
}
else
Expand All @@ -98,7 +101,7 @@ public static IEnumerable<AnsiMarkupSegment> Parse(string markup, Style? style =
}
}

if (stack.Count > 0)
if (styleStack.Count > 0)
{
throw new InvalidOperationException("Unbalanced markup stack. Did you forget to close a tag?");
}
Expand Down
36 changes: 36 additions & 0 deletions src/Spectre.Console.Tests/Unit/AnsiConsoleTests.Markup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -223,5 +223,41 @@ public void Should_Preserve_Links_Over_Line_Breaks_When_Multiple_Segments_Are_Me
"\e]8;id=[0-9]*;https:\\/\\/example\\.com\\/readme.md\e\\\\Foo and \e]8;;\e\\\\\n" +
" \e]8;id=[0-9]*;https:\\/\\/example\\.com\\/readme.md\e\\\\Bar\e]8;;\e\\\\ ");
}

[Fact]
public void Should_Not_Apply_Link_To_Text_After_Link_Close_Tag()
{
// Given
var console = new TestConsole()
.EmitAnsiSequences();

// When - text after the [/] closing tag should NOT have the link
console.Markup("Before [link=https://example.com]LINK[/] After");

// Then
// The link should only wrap "LINK", not " After"
var output = console.Output;
output.ShouldMatch(@"Before \e\]8;id=\d+;https://example\.com\e\\LINK\e\]8;;\e\\ After");
}

[Fact]
public void Should_Properly_Handle_Nested_Link_With_Styles()
{
// Given
var console = new TestConsole()
.EmitAnsiSequences();

// When - link with styled text inside
console.Markup("[link=https://example.com][bold]Bold Link[/][/] Plain");

// Then
// The link should only wrap "Bold Link", not " Plain"
var output = console.Output;

// Check that "Plain" is NOT inside a link
var linkEndIndex = output.LastIndexOf("\u001b]8;;\u001b\\");
var plainIndex = output.IndexOf("Plain");
plainIndex.ShouldBeGreaterThan(linkEndIndex, "Plain should appear after the link ends");
}
}
}
215 changes: 215 additions & 0 deletions src/Spectre.Console.Tests/Unit/Prompts/SelectionPromptTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,221 @@ public sealed class SelectionPromptTests
{
private const string ESC = "\u001b";

[Fact]
public void Should_Not_Apply_Link_To_Text_After_Link_Close_Tag()
{
// Given
var console = new TestConsole()
.SupportsAnsi(true)
.EmitAnsiSequences();

// When - text after the [/] closing tag should NOT have the link
console.Markup("Before [link=https://example.com]LINK[/] After");

// Then
var output = console.Output;

// Link pattern: \e]8;id=xxx;url\e\\ text \e]8;;\e\\
// "Before " should not be in a link
// "LINK" should be in a link
// " After" should not be in a link

// The output should have exactly one link start and one link end
var linkStartRegex = new System.Text.RegularExpressions.Regex(@"\x1b\]8;id=\d+;[^\x1b]+\x1b\\");
var linkEndRegex = new System.Text.RegularExpressions.Regex(@"\x1b\]8;;\x1b\\");

var linkStarts = linkStartRegex.Matches(output);
var linkEnds = linkEndRegex.Matches(output);

linkStarts.Count.ShouldBe(1, $"Expected 1 link start. Output:\n{output.Replace(ESC, "\\e")}");
linkEnds.Count.ShouldBe(1, $"Expected 1 link end. Output:\n{output.Replace(ESC, "\\e")}");

// Verify " After" appears AFTER the link end
var linkEndIndex = linkEnds[0].Index;
var afterIndex = output.IndexOf(" After");

afterIndex.ShouldBeGreaterThan(linkEndIndex, $"' After' should appear after link end. Output:\n{output.Replace(ESC, "\\e")}");
}

[Fact]
public void Should_Properly_Close_Links_In_Selection_Items()
{
// Given
var console = new TestConsole();
console.Profile.Capabilities.Interactive = true;
console.Profile.Capabilities.Links = true;
console.EmitAnsiSequences();
// Navigate down to second item and then select it
console.Input.PushKey(ConsoleKey.DownArrow);
console.Input.PushKey(ConsoleKey.Enter);

// When
var prompt = new SelectionPrompt<string>()
.Title("Select one")
.AddChoices(
"[link=https://example.com]Link 1[/]",
"[link=https://example.org]Link 2[/]");
prompt.Show(console);

// Then
// Each link should be properly closed with the OSC 8 terminator (ESC]8;;ESC\)
// Count occurrences of link starts vs link ends
var output = console.Output;
var linkStartPattern = $"{ESC}]8;id=";
var linkEndPattern = $"{ESC}]8;;{ESC}\\";

var linkStarts = System.Text.RegularExpressions.Regex.Matches(output, System.Text.RegularExpressions.Regex.Escape(linkStartPattern)).Count;
var linkEnds = System.Text.RegularExpressions.Regex.Matches(output, System.Text.RegularExpressions.Regex.Escape(linkEndPattern)).Count;

// Every link start should have a corresponding link end
linkStarts.ShouldBe(linkEnds, $"Link starts ({linkStarts}) should equal link ends ({linkEnds}). Output:\n{output.Replace(ESC, "\\e")}");
}

[Fact]
public void Should_Close_Links_Before_Line_Clear_Operations()
{
// Given
var console = new TestConsole();
console.Profile.Capabilities.Interactive = true;
console.Profile.Capabilities.Links = true;
console.EmitAnsiSequences();
// Navigate down to second item and then select it
console.Input.PushKey(ConsoleKey.DownArrow);
console.Input.PushKey(ConsoleKey.Enter);

// When
var prompt = new SelectionPrompt<string>()
.Title("Select one")
.AddChoices(
"[link=https://example.com]Link 1[/]",
"[link=https://example.org]Link 2[/]");
prompt.Show(console);

// Then
// The output should never have a link start followed by erase-in-line (CSI K) without
// first closing the link. This pattern causes the link to "bleed" into erased content.
var output = console.Output;

// Pattern: link start ... erase-in-line without intervening link end
// Link start: ESC]8;id=...;url ESC\
// Erase in line: ESC[K or ESC[0K or ESC[1K or ESC[2K
// Link end: ESC]8;;ESC\
var linkStartRegex = new System.Text.RegularExpressions.Regex($@"\x1b\]8;id=\d+;[^\x1b]+\x1b\\");
var linkEndRegex = new System.Text.RegularExpressions.Regex($@"\x1b\]8;;\x1b\\");
var eraseInLineRegex = new System.Text.RegularExpressions.Regex($@"\x1b\[\d?K");

var allMatches = new List<(int Index, string Type, string Value)>();
foreach (System.Text.RegularExpressions.Match m in linkStartRegex.Matches(output))
{
allMatches.Add((m.Index, "START", m.Value));
}

foreach (System.Text.RegularExpressions.Match m in linkEndRegex.Matches(output))
{
allMatches.Add((m.Index, "END", m.Value));
}

foreach (System.Text.RegularExpressions.Match m in eraseInLineRegex.Matches(output))
{
allMatches.Add((m.Index, "ERASE", m.Value));
}

allMatches.Sort((a, b) => a.Index.CompareTo(b.Index));

// Check that no ERASE comes after START without END in between
var linkOpen = false;
foreach (var (index, type, value) in allMatches)
{
if (type == "START")
{
linkOpen = true;
}
else if (type == "END")
{
linkOpen = false;
}
else if (type == "ERASE" && linkOpen)
{
Assert.Fail($"Found erase-in-line operation while link was still open at index {index}. " +
$"This can cause links to 'bleed' into subsequent content.\n" +
$"Output (escaped): {output.Replace(ESC, "\\e")}");
}
}
}

[Fact]
public void Should_Terminate_Links_At_End_Of_Line()
{
// Given
var console = new TestConsole();
console.Profile.Capabilities.Interactive = true;
console.Profile.Capabilities.Links = true;
console.EmitAnsiSequences();
console.Input.PushKey(ConsoleKey.Enter);

// When
var prompt = new SelectionPrompt<string>()
.Title("Select one")
.AddChoices(
"[link=https://example.com]Link 1[/]",
"[link=https://example.org]Link 2[/]");
prompt.Show(console);

// Then
// Verify that links don't cross line boundaries - each line's link should be
// closed before the newline or before any cursor movement
var output = console.Output;

// Find all link sequences and line terminators
var linkStartRegex = new System.Text.RegularExpressions.Regex($@"\x1b\]8;id=\d+;[^\x1b]+\x1b\\");
var linkEndRegex = new System.Text.RegularExpressions.Regex($@"\x1b\]8;;\x1b\\");
var newlineRegex = new System.Text.RegularExpressions.Regex($@"\r?\n");
var cursorUpRegex = new System.Text.RegularExpressions.Regex($@"\x1b\[\d*A");

var allMatches = new List<(int Index, string Type, string Value)>();
foreach (System.Text.RegularExpressions.Match m in linkStartRegex.Matches(output))
{
allMatches.Add((m.Index, "START", m.Value));
}

foreach (System.Text.RegularExpressions.Match m in linkEndRegex.Matches(output))
{
allMatches.Add((m.Index, "END", m.Value));
}

foreach (System.Text.RegularExpressions.Match m in newlineRegex.Matches(output))
{
allMatches.Add((m.Index, "NEWLINE", m.Value));
}

foreach (System.Text.RegularExpressions.Match m in cursorUpRegex.Matches(output))
{
allMatches.Add((m.Index, "CURSOR_UP", m.Value));
}

allMatches.Sort((a, b) => a.Index.CompareTo(b.Index));

// Check that no NEWLINE or CURSOR_UP comes after START without END in between
var linkOpen = false;
foreach (var (index, type, value) in allMatches)
{
if (type == "START")
{
linkOpen = true;
}
else if (type == "END")
{
linkOpen = false;
}
else if ((type == "NEWLINE" || type == "CURSOR_UP") && linkOpen)
{
Assert.Fail($"Found {type} while link was still open at index {index}. " +
$"Links should be closed before line/cursor changes.\n" +
$"Output (escaped): {output.Replace(ESC, "\\e")}");
}
}
}

[Fact]
public void Should_Not_Throw_When_Selecting_An_Item_With_Escaped_Markup()
{
Expand Down