Skip to content
Closed
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,45 @@ extension String {
return String(output)
}

/// Splits a trailing editor line reference (`:line` or `:line:column`) off
/// the receiver, returning the base path plus the parsed positive integers.
///
/// This is the `path:line[:column]` convention that agents (Claude Code,
/// Codex) print for click-to-open. The suffix parses only when the segment
/// after the last colon is a positive integer; a leading second colon group
/// that is also a positive integer becomes the column. Returns `nil` when
/// there is no such trailing group, so a literal path is left untouched and
/// resolved as-is by the caller.
///
/// - Returns: The base path and its line/optional column, or `nil`.
func splitTerminalPathLineSuffix() -> (path: String, line: Int, column: Int?)? {
guard let lastColon = lastIndex(of: ":") else { return nil }
let lastSuffix = self[index(after: lastColon)...]
guard !lastSuffix.isEmpty,
lastSuffix.allSatisfy(\.isASCIIDigit),
let lastNumber = Int(lastSuffix),
lastNumber > 0 else {
return nil
}
Comment on lines +131 to +136

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 isNumber includes non-ASCII numeral characters

Character.isNumber returns true for Unicode numeric glyphs beyond ASCII digits — vulgar fractions (½), superscripts (²), Devanagari digits (५), etc. The guard still behaves correctly overall because Int(lastSuffix) returns nil for any non-ASCII numeral and the outer guard fails, but the predicate passes for a moment before Int() rejects the value, making the intent harder to read. Use allSatisfy({ $0.isASCII && $0.isNumber }) (or the range comparison "0"..."9") to express "only decimal digits" precisely in both calls in this function.


let beforeLastColon = self[..<lastColon]
if let lineColon = beforeLastColon.lastIndex(of: ":") {
let lineSuffix = beforeLastColon[beforeLastColon.index(after: lineColon)...]
if !lineSuffix.isEmpty,
lineSuffix.allSatisfy(\.isASCIIDigit),
let lineNumber = Int(lineSuffix),
lineNumber > 0 {
let path = String(beforeLastColon[..<lineColon])
guard !path.isEmpty else { return nil }
return (path, lineNumber, lastNumber)
}
}

let path = String(beforeLastColon)
guard !path.isEmpty else { return nil }
return (path, lastNumber, nil)
}

/// Candidate path spellings derived from the receiver: the raw text, its
/// shell-unescaped and shell-unquoted variants, each with and without
/// trailing terminal punctuation. Order is probe order; duplicates are
Expand Down Expand Up @@ -255,6 +294,14 @@ extension ArraySlice<Character> {
}
}

extension Character {
/// Whether the character is an ASCII digit `0`–`9`, excluding the Unicode
/// numerals (`²`, `½`, Devanagari digits, …) that `isNumber` also accepts.
fileprivate var isASCIIDigit: Bool {
isASCII && isNumber
}
}

extension [Character] {
/// Whether the character at `index` hard-delimits a path token: tabs and
/// newlines always, spaces only when doubled (cell-grid padding).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,20 +46,8 @@ public struct TerminalPathResolver: Sendable {
for token in trimmed.pathResolutionCandidates() {
let normalizedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedToken.isEmpty else { continue }

let expandedToken = (normalizedToken as NSString).expandingTildeInPath
let candidatePath: String
if expandedToken.hasPrefix("/") {
candidatePath = expandedToken
} else {
guard let cwd, !cwd.isEmpty else { continue }
candidatePath = (cwd as NSString).appendingPathComponent(expandedToken)
}

let standardizedPath = (candidatePath as NSString).standardizingPath
guard seenPaths.insert(standardizedPath).inserted else { continue }
if fileExists(standardizedPath) {
return standardizedPath
if let path = standardizedExistingPath(for: normalizedToken, cwd: cwd, seenPaths: &seenPaths) {
return path
}
}

Expand Down Expand Up @@ -105,4 +93,131 @@ public struct TerminalPathResolver: Sendable {
guard URL(string: trimmed)?.scheme == nil else { return nil }
return resolveQuicklookPath(trimmed, cwd: cwd)
}

/// Resolves an open-URL request payload to an existing file plus an optional
/// `line[:column]` reference.
///
/// Like ``resolveOpenURLFilePath(_:cwd:)`` but understands the
/// `path:line[:column]` convention: each candidate spelling is probed
/// as-is first (so a literal path that really contains a colon wins) and
/// then with a trailing line reference stripped. `http`/`https` text is
/// never treated as a file path, and a line-stripped candidate is only
/// probed when it is shaped like a path (has a separator or extension), so
/// a bare `host:port` that merely matches a same-named directory in `cwd`
/// is not mistaken for a file reference.
///
/// - Parameters:
/// - rawText: The raw open-URL text from the runtime.
/// - cwd: The surface's working directory used for relative candidates.
/// - Returns: The first existing file reference, or `nil`.
public func resolveOpenURLFileReference(_ rawText: String, cwd: String?) -> TerminalFileReference? {
let trimmed = rawText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
// Web URLs are never file paths. Every other spelling — scheme-less
// text, or a bogus scheme Foundation parses out of `name:line` — stays
// eligible and is gated by the file-existence probe below.
let scheme = URL(string: trimmed)?.scheme?.lowercased()
guard scheme != "http", scheme != "https" else { return nil }

var seenPaths: Set<String> = []
for token in trimmed.pathResolutionCandidates() {
let normalizedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedToken.isEmpty else { continue }

// Probe the literal token first so a path that really ends in a
// colon-number wins over the line-reference reading, then probe the
// line-stripped path.
if let reference = existingFileReference(
token: normalizedToken, line: nil, column: nil, cwd: cwd, seenPaths: &seenPaths
) {
return reference
}
if let split = normalizedToken.splitTerminalPathLineSuffix(),
looksLikeFilePath(split.path),
let reference = existingFileReference(
token: split.path, line: split.line, column: split.column, cwd: cwd, seenPaths: &seenPaths
) {
return reference
}
}

return nil
}

/// Whether a line-stripped token is shaped like a file path — it carries a
/// separator or a file extension — rather than a bare `host:port` /
/// `word:number` token that only happens to name an existing directory in
/// the working directory. Bare extension-less names (e.g. `Makefile:12`)
/// are intentionally excluded here; those arrive through the cmd-click
/// word-under-cursor path, not open-URL.
private func looksLikeFilePath(_ token: String) -> Bool {
token.contains("/") || !(token as NSString).pathExtension.isEmpty
}

/// Standardizes `token` against `cwd`, dedupes against `seenPaths`, and
/// returns a reference carrying `line`/`column` when the file exists.
private func existingFileReference(
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
token: String,
line: Int?,
column: Int?,
cwd: String?,
seenPaths: inout Set<String>
) -> TerminalFileReference? {
guard let path = standardizedExistingPath(for: token, cwd: cwd, seenPaths: &seenPaths) else {
return nil
}
return TerminalFileReference(path: path, line: line, column: column)
}

/// Expands `~`, resolves `token` against `cwd` when relative, standardizes,
/// and dedupes against `seenPaths`, returning the standardized path when the
/// file exists and has not already been probed. Shared by the QuickLook and
/// file-reference resolvers.
private func standardizedExistingPath(
for token: String,
cwd: String?,
seenPaths: inout Set<String>
) -> String? {
let expandedToken = (token as NSString).expandingTildeInPath
let candidatePath: String
if expandedToken.hasPrefix("/") {
candidatePath = expandedToken
} else {
guard let cwd, !cwd.isEmpty else { return nil }
candidatePath = (cwd as NSString).appendingPathComponent(expandedToken)
}

let standardizedPath = (candidatePath as NSString).standardizingPath
guard seenPaths.insert(standardizedPath).inserted else { return nil }
guard fileExists(standardizedPath) else { return nil }
return standardizedPath
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

/// An existing file plus an optional editor `line[:column]` target, carried
/// from terminal link resolution to the file opener.
///
/// The line/column travel to the editor as a URL fragment (`#L<line>` or
/// `#L<line>:<column>`) so a plain `URL` is enough to cross the package
/// boundary; ``fileURL`` is the single place that encoding lives.
public struct TerminalFileReference: Equatable, Sendable {
public let path: String
public let line: Int?
public let column: Int?

public init(path: String, line: Int?, column: Int?) {
self.path = path
self.line = line
self.column = column
}

/// A `file://` URL for ``path``, carrying the line/column as a `#L…`
/// fragment when a line is present.
public var fileURL: URL {
let base = URL(fileURLWithPath: path)
guard let line else { return base }
var components = URLComponents(url: base, resolvingAgainstBaseURL: false)
components?.fragment = column.map { "L\(line):\($0)" } ?? "L\(line)"
return components?.url ?? base
}
}
Loading