Skip to content

Commit a108a1b

Browse files
committed
Recognize the other quoting conventions around paths
Straight single and double quotes were the only pairs treated as a path boundary, but output quotes paths several other ways: GNU tools use `like this', markdown-flavored output (and the AI agents that emit it) uses `like this`, and anything that went through smart-quote substitution uses the curly pairs. Replace the "closed by the same character" rule with an opener → closers map, so an opener pairs only with its own closer and a mismatched pair is not a boundary. The new tests use "/tmp/dir.d with prose.txt" and click inside "with": an unquoted space-joined segment counts only when it carries a "/" or "." of its own, so that click finds nothing unless a quote pair supplies the boundary — testUnquotedPathStopsAtProseSegment pins that down. It keeps the cases honest; a path like "/tmp/my file.txt" would pass them unquoted.
1 parent f91ebfc commit a108a1b

2 files changed

Lines changed: 94 additions & 11 deletions

File tree

Sources/SwiftTerm/Terminal.swift

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7314,26 +7314,39 @@ open class Terminal {
73147314
)
73157315
}
73167316

7317-
/// Paths that contain spaces defeat the Ghostty-style regex, but
7318-
/// shell-quoted output gives an unambiguous boundary: when the lookup
7319-
/// target sits inside a '...' or "..." pair whose content looks like a
7320-
/// filesystem path, the whole quoted content (quotes excluded) is the
7321-
/// link. Innermost wins so "'/a b.png'" resolves to /a b.png.
7317+
/// Quote pairs that delimit a path in program output, as the opener mapped
7318+
/// to the closers that can end it. Shells and most tools use the straight
7319+
/// pairs; GNU tools quote as `like this'; markdown-flavored output (and the
7320+
/// AI agents that emit it) uses `like this`; anything that has been through
7321+
/// typographic substitution uses the curly pairs.
7322+
private static let pathQuoteClosers: [Character: Set<Character>] = [
7323+
"'": ["'"],
7324+
"\"": ["\""],
7325+
"`": ["`", "'"],
7326+
"\u{2018}": ["\u{2019}"],
7327+
"\u{201C}": ["\u{201D}"]
7328+
]
7329+
7330+
/// Paths that contain spaces defeat the Ghostty-style regex, but quoted
7331+
/// output gives an unambiguous boundary: when the lookup target sits
7332+
/// inside a quote pair whose content looks like a filesystem path, the
7333+
/// whole quoted content (quotes excluded) is the link. Innermost wins so
7334+
/// "'/a b.png'" resolves to /a b.png.
73227335
private func quotedPathMatch(in lineMap: GhosttyImplicitLineMap) -> LinkMatch?
73237336
{
73247337
let chars = Array(lineMap.text)
73257338
var best: LinkMatch?
73267339
var bestLength = Int.max
73277340
for (offset, ch) in chars.enumerated() {
7328-
guard ch == "'" || ch == "\"" else {
7341+
guard let closers = Terminal.pathQuoteClosers[ch] else {
73297342
continue
73307343
}
7331-
// Treat any quote directly followed by a path-looking prefix as
7332-
// an opener, closed by the nearest quote of the same kind. This
7333-
// stays robust against apostrophes in surrounding prose, which
7334-
// would confuse strict sequential pairing.
7344+
// Treat any opener directly followed by a path-looking prefix as
7345+
// the start, closed by the nearest matching closer. This stays
7346+
// robust against apostrophes in surrounding prose, which would
7347+
// confuse strict sequential pairing.
73357348
let contentStart = offset + 1
7336-
guard let contentEnd = (contentStart..<chars.count).first(where: { chars[$0] == ch }) else {
7349+
guard let contentEnd = (contentStart..<chars.count).first(where: { closers.contains(chars[$0]) }) else {
73377350
continue
73387351
}
73397352
let length = contentEnd - contentStart

Tests/SwiftTermTests/LinkLookupTests.swift

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,76 @@ final class LinkLookupTests: TerminalDelegate {
247247
#expect(link == "/tmp/foo.txt")
248248
}
249249

250+
// The quote-pair cases below all use "/tmp/dir.d with prose.txt" and click
251+
// inside "with". A space-joined segment counts as part of an unquoted path
252+
// only when it carries a "/" or "." of its own, so "with" ends the unquoted
253+
// match at "/tmp/dir.d" and a full match can only come from the quote pair
254+
// — see testUnquotedPathStopsAtProseSegment right below.
255+
256+
@Test func testUnquotedPathStopsAtProseSegment() {
257+
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 60, rows: 1))
258+
terminal.feed(text: "saved /tmp/dir.d with prose.txt ok")
259+
260+
let link = terminal.link(at: .buffer(Position(col: 20, row: 0)), mode: .explicitAndImplicit)
261+
#expect(link == nil)
262+
}
263+
264+
/// GNU tools quote as `like this'.
265+
@Test func testGnuStyleQuotedPathWithSpaces() {
266+
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 60, rows: 1))
267+
terminal.feed(text: "cannot stat `/tmp/dir.d with prose.txt': No such file")
268+
269+
let link = terminal.link(at: .buffer(Position(col: 25, row: 0)), mode: .explicitAndImplicit)
270+
#expect(link == "/tmp/dir.d with prose.txt")
271+
}
272+
273+
/// Markdown-flavored output (and the AI agents that emit it) uses `like this`.
274+
@Test func testBacktickPairQuotedPathWithSpaces() {
275+
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 60, rows: 1))
276+
terminal.feed(text: "wrote `/tmp/dir.d with prose.txt` to disk")
277+
278+
let link = terminal.link(at: .buffer(Position(col: 19, row: 0)), mode: .explicitAndImplicit)
279+
#expect(link == "/tmp/dir.d with prose.txt")
280+
}
281+
282+
/// Typographic single quotes, as produced by smart-quote substitution.
283+
@Test func testCurlySingleQuotedPathWithSpaces() {
284+
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 60, rows: 1))
285+
terminal.feed(text: "saved \u{2018}/tmp/dir.d with prose.txt\u{2019} ok")
286+
287+
let link = terminal.link(at: .buffer(Position(col: 20, row: 0)), mode: .explicitAndImplicit)
288+
#expect(link == "/tmp/dir.d with prose.txt")
289+
}
290+
291+
/// Typographic double quotes.
292+
@Test func testCurlyDoubleQuotedPathWithSpaces() {
293+
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 60, rows: 1))
294+
terminal.feed(text: "saved \u{201C}/tmp/dir.d with prose.txt\u{201D} ok")
295+
296+
let link = terminal.link(at: .buffer(Position(col: 20, row: 0)), mode: .explicitAndImplicit)
297+
#expect(link == "/tmp/dir.d with prose.txt")
298+
}
299+
300+
/// An opener pairs only with its own closer, so a mismatched pair is not a
301+
/// boundary and the spaced path stays undetected.
302+
@Test func testMismatchedQuotePairDoesNotMatch() {
303+
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 60, rows: 1))
304+
terminal.feed(text: "saved \u{2018}/tmp/dir.d with prose.txt\u{201D} ok")
305+
306+
let link = terminal.link(at: .buffer(Position(col: 20, row: 0)), mode: .explicitAndImplicit)
307+
#expect(link == nil)
308+
}
309+
310+
/// Typographic apostrophes in prose are closers without an opener, so they
311+
/// must not start a match of their own.
312+
@Test func testCurlyApostropheInProseDoesNotBreakPathDetection() {
313+
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 60, rows: 1))
314+
terminal.feed(text: "don\u{2019}t miss '/tmp/dir.d with prose.txt' it\u{2019}s here")
315+
316+
let link = terminal.link(at: .buffer(Position(col: 24, row: 0)), mode: .explicitAndImplicit)
317+
#expect(link == "/tmp/dir.d with prose.txt")
318+
}
319+
250320
@Test func testQuotedNonPathDoesNotMatch() {
251321
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 30, rows: 1))
252322
terminal.feed(text: "'hello world'")

0 commit comments

Comments
 (0)