Skip to content

Commit 20b4315

Browse files
committed
Detect quoted file paths with spaces as implicit links
Implicit link detection uses a Ghostty-style regex that stops at spaces unless the following segment ends in '/' or '.', so a path such as /Users/me/Screenshot 2026-07-15 at 09.58.24.png is only matched up to 'Screenshot' even when the shell has quoted it unambiguously. Add a pre-pass over the implicit line map: when the lookup target sits inside a '...' or "..." pair whose content starts with /, ~/, ./ or ../, return the whole quoted content (quotes excluded) as the link. Openers are anchored on a path-looking prefix rather than strict sequential pairing, so apostrophes in surrounding prose don't confuse it, and the innermost candidate wins for nested quotes. The cell-offset mapping is factored out of implicitLinkMatch into implicitMatch(in:text:startOffset:endOffset:) and shared by both paths, so hover highlighting and click both see the full quoted range.
1 parent f02e34b commit 20b4315

2 files changed

Lines changed: 179 additions & 47 deletions

File tree

Sources/SwiftTerm/Terminal.swift

Lines changed: 111 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -6074,6 +6074,9 @@ open class Terminal {
60746074
guard let lineMap = buildGhosttyImplicitLineMap(at: position, in: buffer) else {
60756075
return nil
60766076
}
6077+
if let quoted = quotedPathMatch(in: lineMap) {
6078+
return quoted
6079+
}
60776080
guard let regex = Self.ghosttyImplicitLinkRegex else {
60786081
return nil
60796082
}
@@ -6092,65 +6095,126 @@ open class Terminal {
60926095

60936096
let startOffset = lineMap.text.distance(from: lineMap.text.startIndex, to: textRange.lowerBound)
60946097
let endOffset = lineMap.text.distance(from: lineMap.text.startIndex, to: textRange.upperBound)
6095-
guard startOffset < lineMap.cells.count else {
6096-
continue
6097-
}
6098-
let boundedEndOffset = min(endOffset, lineMap.cells.count)
6099-
guard boundedEndOffset > startOffset else {
6100-
continue
6098+
if let match = implicitMatch(in: lineMap,
6099+
text: String(lineMap.text[textRange]),
6100+
startOffset: startOffset,
6101+
endOffset: endOffset) {
6102+
return match
61016103
}
6104+
}
6105+
return nil
6106+
}
6107+
6108+
/// Maps a character-offset range in the line map's text back to buffer
6109+
/// cells, and produces a match when the lookup target falls inside it.
6110+
private func implicitMatch(in lineMap: GhosttyImplicitLineMap, text: String, startOffset: Int, endOffset: Int) -> LinkMatch?
6111+
{
6112+
guard startOffset < lineMap.cells.count else {
6113+
return nil
6114+
}
6115+
let boundedEndOffset = min(endOffset, lineMap.cells.count)
6116+
guard boundedEndOffset > startOffset else {
6117+
return nil
6118+
}
61026119

6103-
var containsTarget = false
6104-
var rowStart: Int?
6105-
var rowEnd: Int?
6106-
var rowBounds: [Int: (start: Int, end: Int)] = [:]
6107-
for idx in startOffset..<boundedEndOffset {
6108-
let cell = lineMap.cells[idx]
6109-
let cellEnd = cell.col + max(1, cell.width)
6120+
var containsTarget = false
6121+
var rowStart: Int?
6122+
var rowEnd: Int?
6123+
var rowBounds: [Int: (start: Int, end: Int)] = [:]
6124+
for idx in startOffset..<boundedEndOffset {
6125+
let cell = lineMap.cells[idx]
6126+
let cellEnd = cell.col + max(1, cell.width)
61106127

6111-
if var bounds = rowBounds[cell.row] {
6112-
bounds.start = min(bounds.start, cell.col)
6113-
bounds.end = max(bounds.end, cellEnd)
6114-
rowBounds[cell.row] = bounds
6115-
} else {
6116-
rowBounds[cell.row] = (start: cell.col, end: cellEnd)
6128+
if var bounds = rowBounds[cell.row] {
6129+
bounds.start = min(bounds.start, cell.col)
6130+
bounds.end = max(bounds.end, cellEnd)
6131+
rowBounds[cell.row] = bounds
6132+
} else {
6133+
rowBounds[cell.row] = (start: cell.col, end: cellEnd)
6134+
}
6135+
6136+
if cell.row == lineMap.targetRow {
6137+
rowStart = min(rowStart ?? cell.col, cell.col)
6138+
rowEnd = max(rowEnd ?? cellEnd, cellEnd)
6139+
if lineMap.targetCol >= cell.col && lineMap.targetCol < cellEnd {
6140+
containsTarget = true
61176141
}
6142+
}
6143+
}
6144+
guard containsTarget,
6145+
let rowStart,
6146+
let rowEnd,
6147+
rowStart < rowEnd
6148+
else {
6149+
return nil
6150+
}
61186151

6119-
if cell.row == lineMap.targetRow {
6120-
rowStart = min(rowStart ?? cell.col, cell.col)
6121-
rowEnd = max(rowEnd ?? cellEnd, cellEnd)
6122-
if lineMap.targetCol >= cell.col && lineMap.targetCol < cellEnd {
6123-
containsTarget = true
6124-
}
6152+
let rowRanges = rowBounds
6153+
.keys
6154+
.sorted()
6155+
.compactMap { row -> LinkMatch.RowRange? in
6156+
guard let bounds = rowBounds[row], bounds.start < bounds.end else {
6157+
return nil
61256158
}
6159+
return .init(row: row, range: bounds.start..<bounds.end)
6160+
}
6161+
6162+
return LinkMatch(
6163+
text: text,
6164+
row: lineMap.targetRow,
6165+
range: rowStart..<rowEnd,
6166+
isExplicit: false,
6167+
rowRanges: rowRanges
6168+
)
6169+
}
6170+
6171+
/// Paths that contain spaces defeat the Ghostty-style regex, but
6172+
/// shell-quoted output gives an unambiguous boundary: when the lookup
6173+
/// target sits inside a '...' or "..." pair whose content looks like a
6174+
/// filesystem path, the whole quoted content (quotes excluded) is the
6175+
/// link. Innermost wins so "'/a b.png'" resolves to /a b.png.
6176+
private func quotedPathMatch(in lineMap: GhosttyImplicitLineMap) -> LinkMatch?
6177+
{
6178+
let chars = Array(lineMap.text)
6179+
var best: LinkMatch?
6180+
var bestLength = Int.max
6181+
for (offset, ch) in chars.enumerated() {
6182+
guard ch == "'" || ch == "\"" else {
6183+
continue
6184+
}
6185+
// Treat any quote directly followed by a path-looking prefix as
6186+
// an opener, closed by the nearest quote of the same kind. This
6187+
// stays robust against apostrophes in surrounding prose, which
6188+
// would confuse strict sequential pairing.
6189+
let contentStart = offset + 1
6190+
guard let contentEnd = (contentStart..<chars.count).first(where: { chars[$0] == ch }) else {
6191+
continue
6192+
}
6193+
let length = contentEnd - contentStart
6194+
guard length > 1 && length < bestLength else {
6195+
continue
61266196
}
6127-
guard containsTarget,
6128-
let rowStart,
6129-
let rowEnd,
6130-
rowStart < rowEnd
6197+
let content = String(chars[contentStart..<contentEnd])
6198+
guard looksLikeQuotedPath(content),
6199+
let match = implicitMatch(in: lineMap,
6200+
text: content,
6201+
startOffset: contentStart,
6202+
endOffset: contentEnd)
61316203
else {
61326204
continue
61336205
}
6206+
best = match
6207+
bestLength = length
6208+
}
6209+
return best
6210+
}
61346211

6135-
let rowRanges = rowBounds
6136-
.keys
6137-
.sorted()
6138-
.compactMap { row -> LinkMatch.RowRange? in
6139-
guard let bounds = rowBounds[row], bounds.start < bounds.end else {
6140-
return nil
6141-
}
6142-
return .init(row: row, range: bounds.start..<bounds.end)
6143-
}
6144-
6145-
return LinkMatch(
6146-
text: String(lineMap.text[textRange]),
6147-
row: lineMap.targetRow,
6148-
range: rowStart..<rowEnd,
6149-
isExplicit: false,
6150-
rowRanges: rowRanges
6151-
)
6212+
private func looksLikeQuotedPath(_ text: String) -> Bool
6213+
{
6214+
if text.hasPrefix("//") {
6215+
return false
61526216
}
6153-
return nil
6217+
return text.hasPrefix("/") || text.hasPrefix("~/") || text.hasPrefix("./") || text.hasPrefix("../")
61546218
}
61556219

61566220
private func payloadCode(at position: Position, in buffer: Buffer) -> UInt16?

Tests/SwiftTermTests/LinkLookupTests.swift

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,74 @@ final class LinkLookupTests: TerminalDelegate {
121121
#expect(nextRowLink == nil)
122122
}
123123

124+
@Test func testQuotedPathWithSpaces() {
125+
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 60, rows: 1))
126+
terminal.feed(text: "'/Users/me/Screenshot 2026-07-15 at 09.58.24.png'")
127+
128+
let link = terminal.link(at: .buffer(Position(col: 25, row: 0)), mode: .explicitAndImplicit)
129+
#expect(link == "/Users/me/Screenshot 2026-07-15 at 09.58.24.png")
130+
}
131+
132+
@Test func testDoubleQuotedPathWithSpaces() {
133+
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 40, rows: 1))
134+
terminal.feed(text: "saved to \"/tmp/my file.txt\" ok")
135+
136+
let link = terminal.link(at: .buffer(Position(col: 16, row: 0)), mode: .explicitAndImplicit)
137+
#expect(link == "/tmp/my file.txt")
138+
}
139+
140+
@Test func testQuotedTildePathWithSpaces() {
141+
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 40, rows: 1))
142+
terminal.feed(text: "'~/Documents/my notes.md'")
143+
144+
let link = terminal.link(at: .buffer(Position(col: 5, row: 0)), mode: .explicitAndImplicit)
145+
#expect(link == "~/Documents/my notes.md")
146+
}
147+
148+
@Test func testNestedQuotedPathResolvesInnermost() {
149+
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 60, rows: 1))
150+
terminal.feed(text: "\"'/Users/me/Screenshot 2026-07-15.png'\"")
151+
152+
let link = terminal.link(at: .buffer(Position(col: 10, row: 0)), mode: .explicitAndImplicit)
153+
#expect(link == "/Users/me/Screenshot 2026-07-15.png")
154+
}
155+
156+
@Test func testQuotedPathAcrossWrappedLines() {
157+
let path = "/tmp/dir with space/file.txt"
158+
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 12, rows: 4))
159+
terminal.feed(text: "'" + path + "'")
160+
161+
let topRowLink = terminal.link(at: .buffer(Position(col: 4, row: 0)), mode: .explicitAndImplicit)
162+
#expect(topRowLink == path)
163+
164+
let wrappedRowLink = terminal.link(at: .buffer(Position(col: 3, row: 1)), mode: .explicitAndImplicit)
165+
#expect(wrappedRowLink == path)
166+
}
167+
168+
@Test func testQuotedNonPathDoesNotMatch() {
169+
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 30, rows: 1))
170+
terminal.feed(text: "'hello world'")
171+
172+
let link = terminal.link(at: .buffer(Position(col: 4, row: 0)), mode: .explicitAndImplicit)
173+
#expect(link == nil)
174+
}
175+
176+
@Test func testApostrophesInProseDoNotBreakPathDetection() {
177+
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 60, rows: 1))
178+
terminal.feed(text: "don't miss '/tmp/a b.png' it's here")
179+
180+
let link = terminal.link(at: .buffer(Position(col: 18, row: 0)), mode: .explicitAndImplicit)
181+
#expect(link == "/tmp/a b.png")
182+
}
183+
184+
@Test func testClickOutsideQuotedPathDoesNotMatch() {
185+
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 40, rows: 1))
186+
terminal.feed(text: "see '/tmp/a b.png' now")
187+
188+
let link = terminal.link(at: .buffer(Position(col: 20, row: 0)), mode: .explicitAndImplicit)
189+
#expect(link == nil)
190+
}
191+
124192
@Test func testImplicitBareDomainDoesNotMatch() {
125193
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 30, rows: 1))
126194
terminal.feed(text: "example.com")

0 commit comments

Comments
 (0)