175 lines
5.7 KiB
TypeScript
175 lines
5.7 KiB
TypeScript
/* eslint-disable no-control-regex -- ANSI SGR sequences are raw PTY input. */
|
|
/**
|
|
* Chunk-boundary-safe GitHub PR URL scan over PTY output.
|
|
*
|
|
* Why shared: terminal-side-effect-authority.md (slice 3) makes main emit
|
|
* `pr-link` facts from its per-PTY tracker for local/SSH PTYs, while the
|
|
* renderer keeps byte-scanning for remote-runtime PTYs and the kill-switch-off
|
|
* path. Both paths must share the carry/dedupe semantics or links split across
|
|
* chunks would resolve differently per authority mode.
|
|
*/
|
|
import type { RepoSlug } from './github-links'
|
|
import { parseGitHubIssueOrPRLink } from './github-links'
|
|
|
|
const GITHUB_PR_PATH_MARKER = '/pull/'
|
|
const TERMINAL_SGR_PATTERN = /\x1b\[[0-?]*[ -/]*m/g
|
|
const TERMINAL_CURSOR_CONTROL_PATTERN = /[\x08\x0b\x0c]/g
|
|
const TERMINAL_CONTROL_GUARD = '\ufffd'
|
|
const HTTP_SCHEME_PREFIXES = ['https://', 'http://'] as const
|
|
const TRAILING_TERMINAL_PUNCTUATION_RE = /[),.;\]}]+$/
|
|
const MAX_CARRY_LENGTH = 512
|
|
const MAX_TERMINAL_GITHUB_PR_URL_LENGTH = 2048
|
|
|
|
export type TerminalGitHubPRLink = {
|
|
url: string
|
|
slug: RepoSlug
|
|
number: number
|
|
}
|
|
|
|
function trimTerminalUrl(candidate: string): string {
|
|
return candidate.replace(TRAILING_TERMINAL_PUNCTUATION_RE, '')
|
|
}
|
|
|
|
function parseTerminalGitHubPRUrl(candidate: string): TerminalGitHubPRLink | null {
|
|
if (candidate.includes('\x1b') || candidate.includes(TERMINAL_CONTROL_GUARD)) {
|
|
return null
|
|
}
|
|
const url = trimTerminalUrl(candidate)
|
|
const parsed = parseGitHubIssueOrPRLink(url)
|
|
if (!parsed || parsed.type !== 'pr') {
|
|
return null
|
|
}
|
|
return { url, slug: parsed.slug, number: parsed.number }
|
|
}
|
|
|
|
function endsWithHttpSchemePrefixFragment(value: string): string {
|
|
for (const prefix of HTTP_SCHEME_PREFIXES) {
|
|
for (let length = Math.min(prefix.length - 1, value.length); length > 0; length--) {
|
|
if (value.endsWith(prefix.slice(0, length))) {
|
|
return value.slice(value.length - length)
|
|
}
|
|
}
|
|
}
|
|
return ''
|
|
}
|
|
|
|
function getPotentialGitHubPRCarry(value: string): string {
|
|
const schemeIndex = Math.max(...HTTP_SCHEME_PREFIXES.map((prefix) => value.lastIndexOf(prefix)))
|
|
if (schemeIndex !== -1) {
|
|
const tailLength = value.length - schemeIndex
|
|
if (tailLength > MAX_CARRY_LENGTH) {
|
|
return ''
|
|
}
|
|
return hasTerminalUrlWhitespace(value, schemeIndex, value.length)
|
|
? ''
|
|
: value.slice(schemeIndex)
|
|
}
|
|
|
|
return endsWithHttpSchemePrefixFragment(value)
|
|
}
|
|
|
|
function hasTerminalUrlWhitespace(value: string, start: number, end: number): boolean {
|
|
for (let index = start; index < end; index += 1) {
|
|
if (/\s/.test(value.charAt(index))) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
type TerminalUrlCandidate = {
|
|
rawUrl: string
|
|
endIndex: number
|
|
}
|
|
|
|
function findNextHttpSchemeIndex(value: string, start: number): number {
|
|
let nextIndex = -1
|
|
for (const prefix of HTTP_SCHEME_PREFIXES) {
|
|
const candidate = value.indexOf(prefix, start)
|
|
if (candidate !== -1 && (nextIndex === -1 || candidate < nextIndex)) {
|
|
nextIndex = candidate
|
|
}
|
|
}
|
|
return nextIndex
|
|
}
|
|
|
|
function isTerminalUrlTerminator(char: string): boolean {
|
|
return char === '"' || char === "'" || char === '<' || char === '>' || /\s/.test(char)
|
|
}
|
|
|
|
function findTerminalUrlCandidateEnd(value: string, start: number): number {
|
|
const scanEnd = Math.min(value.length, start + MAX_TERMINAL_GITHUB_PR_URL_LENGTH + 1)
|
|
for (let index = start; index < scanEnd; index += 1) {
|
|
if (isTerminalUrlTerminator(value.charAt(index))) {
|
|
return index
|
|
}
|
|
}
|
|
return scanEnd
|
|
}
|
|
|
|
function* iterateTerminalUrlCandidates(value: string): Generator<TerminalUrlCandidate> {
|
|
let searchStart = 0
|
|
|
|
while (searchStart < value.length) {
|
|
const candidateStart = findNextHttpSchemeIndex(value, searchStart)
|
|
if (candidateStart === -1) {
|
|
return
|
|
}
|
|
|
|
const candidateEnd = findTerminalUrlCandidateEnd(value, candidateStart)
|
|
const rawUrl = value.slice(candidateStart, candidateEnd)
|
|
searchStart = Math.max(candidateEnd, candidateStart + 1)
|
|
if (
|
|
rawUrl.length > MAX_TERMINAL_GITHUB_PR_URL_LENGTH ||
|
|
!rawUrl.includes(GITHUB_PR_PATH_MARKER)
|
|
) {
|
|
continue
|
|
}
|
|
|
|
yield { rawUrl, endIndex: candidateEnd }
|
|
}
|
|
}
|
|
|
|
export function createTerminalGitHubPRLinkDetector(): (data: string) => TerminalGitHubPRLink[] {
|
|
let carry = ''
|
|
const seenUrls = new Set<string>()
|
|
|
|
return (data: string): TerminalGitHubPRLink[] => {
|
|
const rawCombined = carry ? carry + data : data
|
|
|
|
// Why: PTY output is a hot path; avoid multi-pass ANSI normalization for
|
|
// chunks that cannot contain a GitHub pull-request URL.
|
|
if (!rawCombined.includes(GITHUB_PR_PATH_MARKER)) {
|
|
carry = getPotentialGitHubPRCarry(rawCombined)
|
|
return []
|
|
}
|
|
// Why: SGR styling has no screen width, so removing it is safe. Cursor
|
|
// controls get a guard; other escape sequences remain URL-invalid.
|
|
const combined = rawCombined
|
|
.replace(TERMINAL_SGR_PATTERN, '')
|
|
.replace(TERMINAL_CURSOR_CONTROL_PATTERN, TERMINAL_CONTROL_GUARD)
|
|
|
|
const links: TerminalGitHubPRLink[] = []
|
|
// Why: PTY data may echo a huge pasted line. Scan URL candidates directly
|
|
// instead of running a global regex across the whole chunk when it contains
|
|
// a /pull/ substring.
|
|
for (const { rawUrl, endIndex } of iterateTerminalUrlCandidates(combined)) {
|
|
// Why: PTY chunks can split the PR number; wait for a boundary before
|
|
// treating a URL at chunk-end as complete.
|
|
if (endIndex === combined.length) {
|
|
continue
|
|
}
|
|
|
|
const parsed = parseTerminalGitHubPRUrl(rawUrl)
|
|
if (!parsed || seenUrls.has(parsed.url)) {
|
|
continue
|
|
}
|
|
seenUrls.add(parsed.url)
|
|
links.push(parsed)
|
|
}
|
|
|
|
carry = getPotentialGitHubPRCarry(rawCombined)
|
|
return links
|
|
}
|
|
}
|