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
80 changes: 80 additions & 0 deletions gui/src/components/chat/utils/chatLinks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, expect, test } from "bun:test";

import { getChatLinkMatches } from "./chatLinks";

function firstUrl(text: string): string | undefined {
return getChatLinkMatches(text).find((match) => match.kind === "url")?.value;
}

describe("getChatLinkMatches URL vs Markdown delimiters", () => {
test("strips trailing ** from a bold-wrapped bare URL", () => {
// Regression for #197: Cmd-hover underlined the closing ** and opened
// http://localhost:3003** instead of http://localhost:3003.
expect(firstUrl("**http://localhost:3003**")).toBe("http://localhost:3003");
});

test("strips a single trailing * from an italic-wrapped URL", () => {
expect(firstUrl("*https://example.com*")).toBe("https://example.com");
});

test("strips trailing ~~ from a strikethrough-wrapped URL", () => {
expect(firstUrl("~~https://example.com~~")).toBe("https://example.com");
});

test("strips Markdown delimiters interleaved with trailing punctuation", () => {
expect(firstUrl("**http://localhost:3003.**")).toBe("http://localhost:3003");
});

test("preserves a legitimate interior asterisk in the URL path", () => {
expect(firstUrl("see http://a.com/x*y here")).toBe("http://a.com/x*y");
});

test("preserves legitimate trailing asterisks on unwrapped URLs", () => {
expect(firstUrl("https://example.com/path*")).toBe("https://example.com/path*");
expect(firstUrl("https://example.com/?q=*")).toBe("https://example.com/?q=*");
});

test("preserves legitimate tildes in unwrapped URLs", () => {
expect(firstUrl("https://example.com/~user")).toBe("https://example.com/~user");
expect(firstUrl("https://example.com/~")).toBe("https://example.com/~");
});

test("removes only the matching closing delimiter", () => {
expect(firstUrl("*https://example.com/path**")).toBe("https://example.com/path*");
expect(firstUrl("~~https://example.com/path~~~")).toBe("https://example.com/path~");
});

test("still balances closing parens in a Wikipedia-style URL", () => {
expect(firstUrl("https://en.wikipedia.org/wiki/Foo_(bar)")).toBe(
"https://en.wikipedia.org/wiki/Foo_(bar)",
);
});

test("still trims a wrapping paren and trailing sentence punctuation", () => {
expect(firstUrl("(https://example.com)")).toBe("https://example.com");
expect(firstUrl("visit https://example.com.")).toBe("https://example.com");
});

test("reports the correct span end after trimming the delimiters", () => {
const [match] = getChatLinkMatches("**http://localhost:3003**");
expect(match).toMatchObject({
kind: "url",
start: 2,
end: 2 + "http://localhost:3003".length,
value: "http://localhost:3003",
});
});

test("strips stacked and mixed trailing emphasis delimiters", () => {
expect(firstUrl("***https://example.com***")).toBe("https://example.com");
expect(firstUrl("~~*https://example.com*~~")).toBe("https://example.com");
});

test("never splits a multi-byte trailing character (surrogate-safe)", () => {
// Trimming only removes ASCII markers, so a URL that legitimately ends in a
// multi-byte glyph (accents, emoji) is preserved byte-for-byte, never cut
// mid-codepoint.
expect(firstUrl("**https://example.com/éé**")).toBe("https://example.com/éé");
expect(firstUrl("see https://example.com/p\u{1F600}")).toBe("https://example.com/p\u{1F600}");
});
});
67 changes: 55 additions & 12 deletions gui/src/components/chat/utils/chatLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,29 +58,72 @@ export function isMailtoUrl(href: string): boolean {
}

function trimLinkCandidate(candidate: string): string {
let value = candidate.replace(TRAILING_PUNCTUATION_PATTERN, "");
let value = candidate;

let trimmed = true;
while (trimmed && value.length > 0) {
trimmed = false;

const withoutPunctuation = value.replace(TRAILING_PUNCTUATION_PATTERN, "");
if (withoutPunctuation.length !== value.length) {
value = withoutPunctuation;
trimmed = true;
continue;
}

while (value.length > 0) {
const lastChar = value.at(-1);
if (lastChar == null || !TRAILING_CLOSERS.has(lastChar)) {
if (lastChar == null) {
break;
}

const opener = lastChar === ")" ? "(" : lastChar === "]" ? "[" : lastChar === "}" ? "{" : null;
if (opener != null) {
const openerCount = value.split(opener).length - 1;
const closerCount = value.split(lastChar).length - 1;
if (closerCount <= openerCount) {
break;
if (TRAILING_CLOSERS.has(lastChar)) {
const opener =
lastChar === ")" ? "(" : lastChar === "]" ? "[" : lastChar === "}" ? "{" : null;
if (opener != null) {
const openerCount = value.split(opener).length - 1;
const closerCount = value.split(lastChar).length - 1;
if (closerCount <= openerCount) {
break;
}
}
}

value = value.slice(0, -1);
value = value.slice(0, -1);
trimmed = true;
}
}

return value;
}

function markdownClosingDelimiterBefore(text: string, start: number): string | null {
const opening = text.slice(0, start).match(/[~*]+$/u)?.[0];
if (opening == null) {
return null;
}

for (let index = 0; index < opening.length; index += 1) {
if (opening[index] === "*") {
continue;
}
if (opening[index] !== "~" || opening[index + 1] !== "~") {
return null;
}
index += 1;
}

return [...opening].reverse().join("");
}

function trimMarkdownWrappedLinkCandidate(text: string, start: number, candidate: string): string {
const value = trimLinkCandidate(candidate);
const closingDelimiter = markdownClosingDelimiterBefore(text, start);
if (closingDelimiter == null || !value.endsWith(closingDelimiter)) {
return value;
}

return trimLinkCandidate(value.slice(0, -closingDelimiter.length));
}

function safeDecode(value: string): string {
try {
return decodeURIComponent(value);
Expand Down Expand Up @@ -402,7 +445,7 @@ export function getChatLinkMatches(
URL_PATTERN.lastIndex = 0;
let urlMatch: RegExpExecArray | null;
while ((urlMatch = URL_PATTERN.exec(text)) !== null) {
const value = trimLinkCandidate(urlMatch[0]);
const value = trimMarkdownWrappedLinkCandidate(text, urlMatch.index, urlMatch[0]);
if (value.length === 0) {
continue;
}
Expand Down
Loading