Skip to content
Open
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
177 changes: 129 additions & 48 deletions Sources/SwiftTerm/Terminal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7220,6 +7220,9 @@ open class Terminal {
guard let lineMap = buildGhosttyImplicitLineMap(at: position, in: buffer) else {
return nil
}
if let quoted = quotedPathMatch(in: lineMap) {
return quoted
}
guard let regex = Self.ghosttyImplicitLinkRegex else {
return nil
}
Expand All @@ -7238,65 +7241,139 @@ open class Terminal {

let startOffset = lineMap.text.distance(from: lineMap.text.startIndex, to: textRange.lowerBound)
let endOffset = lineMap.text.distance(from: lineMap.text.startIndex, to: textRange.upperBound)
guard startOffset < lineMap.cells.count else {
continue
}
let boundedEndOffset = min(endOffset, lineMap.cells.count)
guard boundedEndOffset > startOffset else {
continue
if let match = implicitMatch(in: lineMap,
text: String(lineMap.text[textRange]),
startOffset: startOffset,
endOffset: endOffset) {
return match
}
}
return nil
}

var containsTarget = false
var rowStart: Int?
var rowEnd: Int?
var rowBounds: [Int: (start: Int, end: Int)] = [:]
for idx in startOffset..<boundedEndOffset {
let cell = lineMap.cells[idx]
let cellEnd = cell.col + max(1, cell.width)
/// Maps a character-offset range in the line map's text back to buffer
/// cells, and produces a match when the lookup target falls inside it.
private func implicitMatch(in lineMap: GhosttyImplicitLineMap, text: String, startOffset: Int, endOffset: Int) -> LinkMatch?
{
guard startOffset < lineMap.cells.count else {
return nil
}
let boundedEndOffset = min(endOffset, lineMap.cells.count)
guard boundedEndOffset > startOffset else {
return nil
}

if var bounds = rowBounds[cell.row] {
bounds.start = min(bounds.start, cell.col)
bounds.end = max(bounds.end, cellEnd)
rowBounds[cell.row] = bounds
} else {
rowBounds[cell.row] = (start: cell.col, end: cellEnd)
var containsTarget = false
var rowStart: Int?
var rowEnd: Int?
var rowBounds: [Int: (start: Int, end: Int)] = [:]
for idx in startOffset..<boundedEndOffset {
let cell = lineMap.cells[idx]
let cellEnd = cell.col + max(1, cell.width)

if var bounds = rowBounds[cell.row] {
bounds.start = min(bounds.start, cell.col)
bounds.end = max(bounds.end, cellEnd)
rowBounds[cell.row] = bounds
} else {
rowBounds[cell.row] = (start: cell.col, end: cellEnd)
}

if cell.row == lineMap.targetRow {
rowStart = min(rowStart ?? cell.col, cell.col)
rowEnd = max(rowEnd ?? cellEnd, cellEnd)
if lineMap.targetCol >= cell.col && lineMap.targetCol < cellEnd {
containsTarget = true
}
}
}
guard containsTarget,
let rowStart,
let rowEnd,
rowStart < rowEnd
else {
return nil
}

if cell.row == lineMap.targetRow {
rowStart = min(rowStart ?? cell.col, cell.col)
rowEnd = max(rowEnd ?? cellEnd, cellEnd)
if lineMap.targetCol >= cell.col && lineMap.targetCol < cellEnd {
containsTarget = true
}
let rowRanges = rowBounds
.keys
.sorted()
.compactMap { row -> LinkMatch.RowRange? in
guard let bounds = rowBounds[row], bounds.start < bounds.end else {
return nil
}
return .init(row: row, range: bounds.start..<bounds.end)
}
guard containsTarget,
let rowStart,
let rowEnd,
rowStart < rowEnd

return LinkMatch(
text: text,
row: lineMap.targetRow,
range: rowStart..<rowEnd,
isExplicit: false,
rowRanges: rowRanges
)
}

/// Quote pairs that delimit a path in program output, as the opener mapped
/// to the closers that can end it. Shells and most tools use the straight
/// pairs; GNU tools quote as `like this'; markdown-flavored output (and the
/// AI agents that emit it) uses `like this`; anything that has been through
/// typographic substitution uses the curly pairs.
private static let pathQuoteClosers: [Character: Set<Character>] = [
"'": ["'"],
"\"": ["\""],
"`": ["`", "'"],
"\u{2018}": ["\u{2019}"],
"\u{201C}": ["\u{201D}"]
]

/// Paths that contain spaces defeat the Ghostty-style regex, but quoted
/// output gives an unambiguous boundary: when the lookup target sits
/// inside a quote pair whose content looks like a filesystem path, the
/// whole quoted content (quotes excluded) is the link. Innermost wins so
/// "'/a b.png'" resolves to /a b.png.
private func quotedPathMatch(in lineMap: GhosttyImplicitLineMap) -> LinkMatch?
{
let chars = Array(lineMap.text)
var best: LinkMatch?
var bestLength = Int.max
for (offset, ch) in chars.enumerated() {
guard let closers = Terminal.pathQuoteClosers[ch] else {
continue
}
// Treat any opener directly followed by a path-looking prefix as
// the start, closed by the nearest matching closer. This stays
// robust against apostrophes in surrounding prose, which would
// confuse strict sequential pairing.
let contentStart = offset + 1
guard let contentEnd = (contentStart..<chars.count).first(where: { closers.contains(chars[$0]) }) else {
continue
}
let length = contentEnd - contentStart
guard length > 1 && length < bestLength else {
continue
}
let content = String(chars[contentStart..<contentEnd])
guard looksLikeQuotedPath(content),
let match = implicitMatch(in: lineMap,
text: content,
startOffset: contentStart,
endOffset: contentEnd)
else {
continue
}
best = match
bestLength = length
}
return best
}

let rowRanges = rowBounds
.keys
.sorted()
.compactMap { row -> LinkMatch.RowRange? in
guard let bounds = rowBounds[row], bounds.start < bounds.end else {
return nil
}
return .init(row: row, range: bounds.start..<bounds.end)
}

return LinkMatch(
text: String(lineMap.text[textRange]),
row: lineMap.targetRow,
range: rowStart..<rowEnd,
isExplicit: false,
rowRanges: rowRanges
)
private func looksLikeQuotedPath(_ text: String) -> Bool
{
if text.hasPrefix("//") {
return false
}
return nil
return text.hasPrefix("/") || text.hasPrefix("~/") || text.hasPrefix("./") || text.hasPrefix("../")
}

private func payloadCode(at position: Position, in buffer: Buffer) -> UInt16?
Expand Down Expand Up @@ -7363,7 +7440,11 @@ open class Terminal {
let trailingSpacesAtEOL = #"(?: +(?= *$))?"#
let dottedPathLookahead = #"(?=[\w\-.~:\/?#@!$&*+;=%]*\.)"#
let nonDottedPathLookahead = #"(?![\w\-.~:\/?#@!$&*+;=%]*\.)"#
let dottedPathSpaceSegments = #"(?:(?<!:) (?!\w+:\/\/)[\w\-.~:\/?#@!$&*+;=%]*[\/.])*"#
// A space-joined segment must contain '/' or '.' to count as part of
// the path, but may keep trailing word characters after the last one,
// so "face cropped.png" absorbs the full extension instead of the
// greedy match cutting the segment at "cropped.".
let dottedPathSpaceSegments = #"(?:(?<!:) (?!\w+:\/\/)[\w\-.~:\/?#@!$&*+;=%]*[\/.]\w*)*"#
let anyPathSpaceSegments = #"(?:(?<!:) (?!\w+:\/\/)[\w\-.~:\/?#@!$&*+;=%]+)*"#

// The body used to be `(?:IPV6|CHARS+SUFFIX?)+`: a `+` nested directly inside a `+`, so a
Expand Down
154 changes: 154 additions & 0 deletions Tests/SwiftTermTests/LinkLookupTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,160 @@ final class LinkLookupTests: TerminalDelegate {
#expect(nextRowLink == nil)
}

@Test func testQuotedPathWithSpaces() {
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 60, rows: 1))
terminal.feed(text: "'/Users/me/Screenshot 2026-07-15 at 09.58.24.png'")

let link = terminal.link(at: .buffer(Position(col: 25, row: 0)), mode: .explicitAndImplicit)
#expect(link == "/Users/me/Screenshot 2026-07-15 at 09.58.24.png")
}

@Test func testDoubleQuotedPathWithSpaces() {
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 40, rows: 1))
terminal.feed(text: "saved to \"/tmp/my file.txt\" ok")

let link = terminal.link(at: .buffer(Position(col: 16, row: 0)), mode: .explicitAndImplicit)
#expect(link == "/tmp/my file.txt")
}

@Test func testQuotedTildePathWithSpaces() {
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 40, rows: 1))
terminal.feed(text: "'~/Documents/my notes.md'")

let link = terminal.link(at: .buffer(Position(col: 5, row: 0)), mode: .explicitAndImplicit)
#expect(link == "~/Documents/my notes.md")
}

@Test func testNestedQuotedPathResolvesInnermost() {
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 60, rows: 1))
terminal.feed(text: "\"'/Users/me/Screenshot 2026-07-15.png'\"")

let link = terminal.link(at: .buffer(Position(col: 10, row: 0)), mode: .explicitAndImplicit)
#expect(link == "/Users/me/Screenshot 2026-07-15.png")
}

@Test func testQuotedPathAcrossWrappedLines() {
let path = "/tmp/dir with space/file.txt"
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 12, rows: 4))
terminal.feed(text: "'" + path + "'")

let topRowLink = terminal.link(at: .buffer(Position(col: 4, row: 0)), mode: .explicitAndImplicit)
#expect(topRowLink == path)

let wrappedRowLink = terminal.link(at: .buffer(Position(col: 3, row: 1)), mode: .explicitAndImplicit)
#expect(wrappedRowLink == path)
}

@Test func testUnquotedSpacePathKeepsExtension() {
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 80, rows: 1))
terminal.feed(text: "/Users/me/Assets.xcassets/face.imageset/face cropped.png")

let link = terminal.link(at: .buffer(Position(col: 10, row: 0)), mode: .explicitAndImplicit)
#expect(link == "/Users/me/Assets.xcassets/face.imageset/face cropped.png")
}

@Test func testUnquotedSpacePathDoesNotAbsorbProse() {
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 60, rows: 1))
terminal.feed(text: "see /tmp/foo.txt and more")

let link = terminal.link(at: .buffer(Position(col: 8, row: 0)), mode: .explicitAndImplicit)
#expect(link == "/tmp/foo.txt")
}

// The quote-pair cases below all use "/tmp/dir.d with prose.txt" and click
// inside "with". A space-joined segment counts as part of an unquoted path
// only when it carries a "/" or "." of its own, so "with" ends the unquoted
// match at "/tmp/dir.d" and a full match can only come from the quote pair
// — see testUnquotedPathStopsAtProseSegment right below.

@Test func testUnquotedPathStopsAtProseSegment() {
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 60, rows: 1))
terminal.feed(text: "saved /tmp/dir.d with prose.txt ok")

let link = terminal.link(at: .buffer(Position(col: 20, row: 0)), mode: .explicitAndImplicit)
#expect(link == nil)
}

/// GNU tools quote as `like this'.
@Test func testGnuStyleQuotedPathWithSpaces() {
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 60, rows: 1))
terminal.feed(text: "cannot stat `/tmp/dir.d with prose.txt': No such file")

let link = terminal.link(at: .buffer(Position(col: 25, row: 0)), mode: .explicitAndImplicit)
#expect(link == "/tmp/dir.d with prose.txt")
}

/// Markdown-flavored output (and the AI agents that emit it) uses `like this`.
@Test func testBacktickPairQuotedPathWithSpaces() {
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 60, rows: 1))
terminal.feed(text: "wrote `/tmp/dir.d with prose.txt` to disk")

let link = terminal.link(at: .buffer(Position(col: 19, row: 0)), mode: .explicitAndImplicit)
#expect(link == "/tmp/dir.d with prose.txt")
}

/// Typographic single quotes, as produced by smart-quote substitution.
@Test func testCurlySingleQuotedPathWithSpaces() {
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 60, rows: 1))
terminal.feed(text: "saved \u{2018}/tmp/dir.d with prose.txt\u{2019} ok")

let link = terminal.link(at: .buffer(Position(col: 20, row: 0)), mode: .explicitAndImplicit)
#expect(link == "/tmp/dir.d with prose.txt")
}

/// Typographic double quotes.
@Test func testCurlyDoubleQuotedPathWithSpaces() {
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 60, rows: 1))
terminal.feed(text: "saved \u{201C}/tmp/dir.d with prose.txt\u{201D} ok")

let link = terminal.link(at: .buffer(Position(col: 20, row: 0)), mode: .explicitAndImplicit)
#expect(link == "/tmp/dir.d with prose.txt")
}

/// An opener pairs only with its own closer, so a mismatched pair is not a
/// boundary and the spaced path stays undetected.
@Test func testMismatchedQuotePairDoesNotMatch() {
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 60, rows: 1))
terminal.feed(text: "saved \u{2018}/tmp/dir.d with prose.txt\u{201D} ok")

let link = terminal.link(at: .buffer(Position(col: 20, row: 0)), mode: .explicitAndImplicit)
#expect(link == nil)
}

/// Typographic apostrophes in prose are closers without an opener, so they
/// must not start a match of their own.
@Test func testCurlyApostropheInProseDoesNotBreakPathDetection() {
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 60, rows: 1))
terminal.feed(text: "don\u{2019}t miss '/tmp/dir.d with prose.txt' it\u{2019}s here")

let link = terminal.link(at: .buffer(Position(col: 24, row: 0)), mode: .explicitAndImplicit)
#expect(link == "/tmp/dir.d with prose.txt")
}

@Test func testQuotedNonPathDoesNotMatch() {
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 30, rows: 1))
terminal.feed(text: "'hello world'")

let link = terminal.link(at: .buffer(Position(col: 4, row: 0)), mode: .explicitAndImplicit)
#expect(link == nil)
}

@Test func testApostrophesInProseDoNotBreakPathDetection() {
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 60, rows: 1))
terminal.feed(text: "don't miss '/tmp/a b.png' it's here")

let link = terminal.link(at: .buffer(Position(col: 18, row: 0)), mode: .explicitAndImplicit)
#expect(link == "/tmp/a b.png")
}

@Test func testClickOutsideQuotedPathDoesNotMatch() {
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 40, rows: 1))
terminal.feed(text: "see '/tmp/a b.png' now")

let link = terminal.link(at: .buffer(Position(col: 20, row: 0)), mode: .explicitAndImplicit)
#expect(link == nil)
}

@Test func testImplicitBareDomainDoesNotMatch() {
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 30, rows: 1))
terminal.feed(text: "example.com")
Expand Down
Loading