| 1 | import { isWindowsAbsolutePath, stripSlashPrefixedWindowsDrive } from "./path.ts"; |
| 2 | import { |
| 3 | type FilePathPosition, |
| 4 | formatFilePathPosition, |
| 5 | parseFileUrlHref, |
| 6 | resolvePathLinkTarget, |
| 7 | splitFilePathPosition, |
| 8 | } from "./fileLinks.ts"; |
| 9 | |
| 10 | const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; |
| 11 | |
| 12 | const RELATIVE_FILE_PATH_PATTERN = |
| 13 | /^(?:[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\/)+[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*(?::\d+){0,2}$/; |
| 14 | |
| 15 | const RELATIVE_FILE_NAME_PATTERN = |
| 16 | /^[A-Za-z0-9._-]+(?: +[A-Za-z0-9._-]+)*\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; |
| 17 | |
| 18 | const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/; |
| 19 | |
| 20 | const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; |
| 21 | |
| 22 | const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/; |
| 23 | |
| 24 | const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/; |
| 25 | |
| 26 | const PATH_SEPARATOR_PATTERN = /[\\/]/; |
| 27 | |
| 28 | const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/; |
| 29 | // A final dot between digits marks a version or model id (`glm-5.3`, |
| 30 | // `Qwen2.5-Coder`), not an extension. `ls.1` and `libfoo.so.1` stay files. |
| 31 | const VERSION_SUFFIX_PATTERN = /\d\.\d[^.]*$/; |
| 32 | const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/; |
| 33 | |
| 34 | // Standard OS and dev-container roots; deliberately excludes app-route-ish |
| 35 | // prefixes like /app/ or /chat/ so SPA routes never read as files. |
| 36 | const POSIX_FILE_ROOT_PREFIXES = [ |
| 37 | "/Users/", |
| 38 | "/home/", |
| 39 | "/tmp/", |
| 40 | "/var/", |
| 41 | "/etc/", |
| 42 | "/opt/", |
| 43 | "/mnt/", |
| 44 | "/Volumes/", |
| 45 | "/private/", |
| 46 | "/root/", |
| 47 | "/usr/", |
| 48 | "/bin/", |
| 49 | "/sbin/", |
| 50 | "/lib/", |
| 51 | "/lib64/", |
| 52 | "/srv/", |
| 53 | "/dev/", |
| 54 | "/proc/", |
| 55 | "/sys/", |
| 56 | "/run/", |
| 57 | "/boot/", |
| 58 | "/media/", |
| 59 | "/workspace/", |
| 60 | "/workspaces/", |
| 61 | ] as const; |
| 62 | |
| 63 | // `Name:digits` also matches `error:1`, `port:3000`, and `TODO:12`. |
| 64 | const EXTENSIONLESS_FILE_NAMES = new Set([ |
| 65 | "Makefile", |
| 66 | "makefile", |
| 67 | "GNUmakefile", |
| 68 | "Dockerfile", |
| 69 | "Containerfile", |
| 70 | "Justfile", |
| 71 | "justfile", |
| 72 | "Rakefile", |
| 73 | "Gemfile", |
| 74 | "Procfile", |
| 75 | "Brewfile", |
| 76 | "Caddyfile", |
| 77 | "Vagrantfile", |
| 78 | "Jenkinsfile", |
| 79 | "Podfile", |
| 80 | "Fastfile", |
| 81 | "BUILD", |
| 82 | "WORKSPACE", |
| 83 | "LICENSE", |
| 84 | "LICENCE", |
| 85 | "COPYING", |
| 86 | "NOTICE", |
| 87 | "AUTHORS", |
| 88 | "CONTRIBUTORS", |
| 89 | "CHANGELOG", |
| 90 | "README", |
| 91 | "CODEOWNERS", |
| 92 | ]); |
| 93 | |
| 94 | const SINGLE_LABEL_HOSTNAMES = new Set(["localhost"]); |
| 95 | |
| 96 | // These allowlists avoid classifying dotted directories such as `conf.d/` |
| 97 | // or filenames such as `Makefile.in:12` as hosts. |
| 98 | const GENERIC_HOSTNAME_TLDS = new Set([ |
| 99 | "com", |
| 100 | "net", |
| 101 | "org", |
| 102 | "io", |
| 103 | "dev", |
| 104 | "app", |
| 105 | "ai", |
| 106 | "co", |
| 107 | "edu", |
| 108 | "gov", |
| 109 | "mil", |
| 110 | "info", |
| 111 | "biz", |
| 112 | "xyz", |
| 113 | "me", |
| 114 | "tv", |
| 115 | "cc", |
| 116 | "gg", |
| 117 | "chat", |
| 118 | "cloud", |
| 119 | "site", |
| 120 | "online", |
| 121 | "tech", |
| 122 | "store", |
| 123 | "link", |
| 124 | ]); |
| 125 | |
| 126 | // Country codes also name file extensions. A :line suffix makes `.pl` |
| 127 | // and `.pt` files more likely than hostnames. |
| 128 | const COUNTRY_HOSTNAME_TLDS = new Set([ |
| 129 | "uk", |
| 130 | "de", |
| 131 | "fr", |
| 132 | "nl", |
| 133 | "se", |
| 134 | "no", |
| 135 | "fi", |
| 136 | "dk", |
| 137 | "pl", |
| 138 | "ch", |
| 139 | "at", |
| 140 | "be", |
| 141 | "es", |
| 142 | "it", |
| 143 | "pt", |
| 144 | "eu", |
| 145 | "us", |
| 146 | "ca", |
| 147 | "au", |
| 148 | "nz", |
| 149 | "jp", |
| 150 | "kr", |
| 151 | "cn", |
| 152 | "br", |
| 153 | "ru", |
| 154 | "mx", |
| 155 | "ie", |
| 156 | "cz", |
| 157 | "tr", |
| 158 | "sg", |
| 159 | "hk", |
| 160 | ]); |
| 161 | |
| 162 | function looksLikeHostname(segment: string, hasPosition: boolean): boolean { |
| 163 | if (segment.startsWith(".")) return false; |
| 164 | const lowered = segment.toLowerCase(); |
| 165 | if (SINGLE_LABEL_HOSTNAMES.has(lowered)) return true; |
| 166 | if (NUMERIC_DOTTED_PATTERN.test(segment)) return true; |
| 167 | const labels = lowered.split("."); |
| 168 | const lastLabel = labels.at(-1); |
| 169 | if (labels.length < 2 || lastLabel === undefined) return false; |
| 170 | if (GENERIC_HOSTNAME_TLDS.has(lastLabel)) return true; |
| 171 | return !hasPosition && COUNTRY_HOSTNAME_TLDS.has(lastLabel); |
| 172 | } |
| 173 | |
| 174 | /** |
| 175 | * Picks path-shaped inline code for the client's markdown file-link resolver. |
| 176 | * It does not resolve paths or turn plain prose and fenced code into links. |
| 177 | */ |
| 178 | export function inlineCodeFilePathCandidate(codeText: string): string | null { |
| 179 | const trimmed = codeText.trim(); |
| 180 | if (trimmed.length === 0 || INLINE_CODE_DISQUALIFIER_PATTERN.test(trimmed)) return null; |
| 181 | |
| 182 | const candidate = isWindowsAbsolutePath(trimmed) ? trimmed : trimmed.replaceAll("\\", "/"); |
| 183 | const hasPosition = POSITION_SUFFIX_PATTERN.test(candidate); |
| 184 | if (!hasPosition && !PATH_SEPARATOR_PATTERN.test(candidate)) return null; |
| 185 | |
| 186 | const hasExplicitPathShape = |
| 187 | RELATIVE_PATH_PREFIX_PATTERN.test(candidate) || |
| 188 | candidate.startsWith("/") || |
| 189 | isWindowsAbsolutePath(candidate); |
| 190 | if (!hasExplicitPathShape) { |
| 191 | const withoutPosition = candidate.replace(POSITION_SUFFIX_PATTERN, ""); |
| 192 | const firstSegment = withoutPosition.split("/")[0] ?? withoutPosition; |
| 193 | if (looksLikeHostname(firstSegment, hasPosition)) return null; |
| 194 | const basename = |
| 195 | withoutPosition |
| 196 | .replace(/[/\\]+$/, "") |
| 197 | .split(/[\\/]/) |
| 198 | .at(-1) ?? ""; |
| 199 | if (VERSION_SUFFIX_PATTERN.test(basename)) return null; |
| 200 | if (!hasPosition && !FILE_EXTENSION_PATTERN.test(basename)) return null; |
| 201 | } |
| 202 | return candidate; |
| 203 | } |
| 204 | |
| 205 | export function safeDecodeURIComponent(value: string): string { |
| 206 | try { |
| 207 | return decodeURIComponent(value); |
| 208 | } catch { |
| 209 | return value; |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | export function normalizeMarkdownLinkDestination(value: string): string { |
| 214 | const trimmed = value.trim(); |
| 215 | return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; |
| 216 | } |
| 217 | |
| 218 | export function splitMarkdownLinkSearchAndHash(value: string): { |
| 219 | readonly path: string; |
| 220 | readonly hash: string; |
| 221 | } { |
| 222 | const hashIndex = value.indexOf("#"); |
| 223 | const pathWithSearch = hashIndex >= 0 ? value.slice(0, hashIndex) : value; |
| 224 | const hash = hashIndex >= 0 ? value.slice(hashIndex) : ""; |
| 225 | const queryIndex = pathWithSearch.indexOf("?"); |
| 226 | return { |
| 227 | path: queryIndex >= 0 ? pathWithSearch.slice(0, queryIndex) : pathWithSearch, |
| 228 | hash, |
| 229 | }; |
| 230 | } |
| 231 | |
| 232 | /** Keeps filename and destination-path labels compact without discarding prose. */ |
| 233 | export function isMarkdownFileLinkLabel(label: string, href: string): boolean { |
| 234 | const destination = parseMarkdownFileLink(href); |
| 235 | if (!destination) return false; |
| 236 | const normalize = (path: string) => |
| 237 | path.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/+$/, ""); |
| 238 | const labelPosition = splitFilePathPosition(label.trim()); |
| 239 | if ( |
| 240 | (labelPosition.line !== undefined && labelPosition.line !== destination.line) || |
| 241 | (labelPosition.column !== undefined && labelPosition.column !== destination.column) |
| 242 | ) { |
| 243 | return false; |
| 244 | } |
| 245 | let labelPath = normalize(labelPosition.path); |
| 246 | let destinationPath = normalize(destination.path); |
| 247 | if (labelPath.length === 0) return true; |
| 248 | if (isWindowsAbsolutePath(destination.path)) { |
| 249 | labelPath = labelPath.toLowerCase(); |
| 250 | destinationPath = destinationPath.toLowerCase(); |
| 251 | } |
| 252 | return destinationPath === labelPath || destinationPath.endsWith(`/${labelPath}`); |
| 253 | } |
| 254 | |
| 255 | export function isRelativeFilePath(path: string): boolean { |
| 256 | return ( |
| 257 | RELATIVE_PATH_PREFIX_PATTERN.test(path) || |
| 258 | (!path.startsWith("/") && !isWindowsAbsolutePath(path)) |
| 259 | ); |
| 260 | } |
| 261 | |
| 262 | function looksLikePosixFilesystemPath(path: string): boolean { |
| 263 | if (!path.startsWith("/")) return false; |
| 264 | if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) return true; |
| 265 | if (POSITION_SUFFIX_PATTERN.test(path)) return true; |
| 266 | const basename = path.slice(path.lastIndexOf("/") + 1); |
| 267 | return EXTENSIONLESS_FILE_NAMES.has(basename) || FILE_EXTENSION_PATTERN.test(basename); |
| 268 | } |
| 269 | |
| 270 | /** |
| 271 | * Decides whether a decoded link destination is a file path rather than a route |
| 272 | * or prose. Only a `:line` suffix the author wrote counts as evidence; a `#L` |
| 273 | * anchor never turns `/chat/settings` into a file. |
| 274 | */ |
| 275 | function looksLikeFilePath(path: string, authoredPath: string): boolean { |
| 276 | if (isWindowsAbsolutePath(path) || RELATIVE_PATH_PREFIX_PATTERN.test(path)) return true; |
| 277 | if (path.startsWith("/")) return looksLikePosixFilesystemPath(authoredPath); |
| 278 | if (EXTENSIONLESS_FILE_NAMES.has(path)) return true; |
| 279 | return RELATIVE_FILE_PATH_PATTERN.test(authoredPath) || RELATIVE_FILE_NAME_PATTERN.test(path); |
| 280 | } |
| 281 | |
| 282 | function hasExternalScheme(path: string): boolean { |
| 283 | if (isWindowsAbsolutePath(path)) return false; |
| 284 | const match = path.match(EXTERNAL_SCHEME_PATTERN); |
| 285 | if (!match) return false; |
| 286 | const rest = match[2] ?? ""; |
| 287 | if (rest.startsWith("//")) return true; |
| 288 | return !POSITION_ONLY_PATTERN.test(rest); |
| 289 | } |
| 290 | |
| 291 | export function parseMarkdownFileLink(href: string): FilePathPosition | null { |
| 292 | const normalized = normalizeMarkdownLinkDestination(href); |
| 293 | if (normalized.length === 0 || normalized.startsWith("#") || normalized.startsWith("//")) { |
| 294 | return null; |
| 295 | } |
| 296 | |
| 297 | const source = |
| 298 | (normalized.toLowerCase().startsWith("file:") ? parseFileUrlHref(normalized) : null) ?? |
| 299 | splitMarkdownLinkSearchAndHash(normalized); |
| 300 | // A percent-encoded drive colon (`/c%3A/`) only becomes strippable once decoded. |
| 301 | const path = stripSlashPrefixedWindowsDrive(safeDecodeURIComponent(source.path.trim())); |
| 302 | const hash = safeDecodeURIComponent(source.hash.trim()); |
| 303 | if (path.length === 0 || hasExternalScheme(path)) return null; |
| 304 | |
| 305 | const position = splitFilePathPosition(path, hash); |
| 306 | return looksLikeFilePath(position.path, path) ? position : null; |
| 307 | } |
| 308 | |
| 309 | const FENCED_CODE_SEGMENT_PATTERN = /(```[\s\S]*?(?:```|$))/; |
| 310 | |
| 311 | const INLINE_CODE_SPAN_PATTERN = /`([^`\n]+)`/g; |
| 312 | |
| 313 | export function extractInlineCodeSpans(text: string): string[] { |
| 314 | const spans: string[] = []; |
| 315 | const segments = text.split(FENCED_CODE_SEGMENT_PATTERN); |
| 316 | for (let index = 0; index < segments.length; index += 2) { |
| 317 | for (const match of (segments[index] ?? "").matchAll(INLINE_CODE_SPAN_PATTERN)) { |
| 318 | const span = match[1]?.trim(); |
| 319 | if (span) spans.push(span); |
| 320 | } |
| 321 | } |
| 322 | return spans; |
| 323 | } |
| 324 | |
| 325 | /** Reuses delimiter searches when successive candidates fall in the same run. */ |
| 326 | function markdownRunEndFinder(markdown: string, delimiter: RegExp) { |
| 327 | let runStart = -1; |
| 328 | let runEnd = -1; |
| 329 | return (start: number) => { |
| 330 | if (start >= runStart && start <= runEnd) return runEnd; |
| 331 | delimiter.lastIndex = start; |
| 332 | runStart = start; |
| 333 | runEnd = delimiter.exec(markdown)?.index ?? markdown.length; |
| 334 | return runEnd; |
| 335 | }; |
| 336 | } |
| 337 | |
| 338 | function markdownLinkTailScanner(markdown: string) { |
| 339 | const whitespaceEnd = markdownRunEndFinder(markdown, /\S/g); |
| 340 | const titleEnd = markdownRunEndFinder(markdown, /["']/g); |
| 341 | const titleWhitespaceEnd = markdownRunEndFinder(markdown, /\S/g); |
| 342 | return (start: number) => { |
| 343 | const end = whitespaceEnd(start); |
| 344 | if (markdown[end] === ")") return end + 1; |
| 345 | if (end === start || (markdown[end] !== '"' && markdown[end] !== "'")) return null; |
| 346 | const quoteEnd = titleEnd(end + 1); |
| 347 | if (quoteEnd === markdown.length) return null; |
| 348 | const close = titleWhitespaceEnd(quoteEnd + 1); |
| 349 | return markdown[close] === ")" ? close + 1 : null; |
| 350 | }; |
| 351 | } |
| 352 | |
| 353 | function markdownLinkDestinationScanner(markdown: string) { |
| 354 | const whitespaceEnd = markdownRunEndFinder(markdown, /\S/g); |
| 355 | const angleEnd = markdownRunEndFinder(markdown, /[>\n]/g); |
| 356 | const bareEnd = markdownRunEndFinder(markdown, /[\s)]/g); |
| 357 | // Separate caches keep each branch's searches moving forward even when an |
| 358 | // angle destination fails and falls back to an ordinary destination. |
| 359 | const angleTail = markdownLinkTailScanner(markdown); |
| 360 | const bareTail = markdownLinkTailScanner(markdown); |
| 361 | return (start: number) => { |
| 362 | if (markdown[start] !== "(") return null; |
| 363 | const hrefStart = whitespaceEnd(start + 1); |
| 364 | if (markdown[hrefStart] === "<") { |
| 365 | const hrefEnd = angleEnd(hrefStart + 1); |
| 366 | if (hrefEnd > hrefStart + 1 && markdown[hrefEnd] === ">") { |
| 367 | const end = angleTail(hrefEnd + 1); |
| 368 | if (end !== null) return { hrefStart: hrefStart + 1, hrefEnd, end }; |
| 369 | } |
| 370 | } |
| 371 | const hrefEnd = bareEnd(hrefStart); |
| 372 | if (hrefEnd === hrefStart) return null; |
| 373 | const end = bareTail(hrefEnd); |
| 374 | return end === null ? null : { hrefStart, hrefEnd, end }; |
| 375 | }; |
| 376 | } |
| 377 | |
| 378 | export function extractMarkdownLinkHrefs(markdown: string): string[] { |
| 379 | const hrefs: string[] = []; |
| 380 | const scanDestination = markdownLinkDestinationScanner(markdown); |
| 381 | let cursor = 0; |
| 382 | while (cursor < markdown.length) { |
| 383 | const labelStart = markdown.indexOf("[", cursor); |
| 384 | if (labelStart === -1) break; |
| 385 | const labelEnd = markdown.indexOf("]", labelStart + 1); |
| 386 | if (labelEnd === -1) break; |
| 387 | |
| 388 | // Nested opening brackets share this closing bracket. On failure, skip the |
| 389 | // whole label instead of rescanning its suffix from each opening bracket. |
| 390 | cursor = labelEnd + 1; |
| 391 | const destination = scanDestination(cursor); |
| 392 | if (!destination) continue; |
| 393 | cursor = destination.end; |
| 394 | const href = markdown.slice(destination.hrefStart, destination.hrefEnd).trim(); |
| 395 | if (href) hrefs.push(href); |
| 396 | } |
| 397 | return hrefs; |
| 398 | } |
| 399 | |
| 400 | /** |
| 401 | * `baseDir` anchors relative links; it defaults to the workspace root and is the |
| 402 | * file's own directory when rendering a markdown file. `cwd` stays the workspace |
| 403 | * root so the result still knows whether the target is inside it. |
| 404 | */ |
| 405 | export function resolveMarkdownFileLinkTarget( |
| 406 | href: string | undefined, |
| 407 | cwd?: string, |
| 408 | baseDir: string | undefined = cwd, |
| 409 | ): string | null { |
| 410 | if (!href) return null; |
| 411 | const target = parseMarkdownFileLink(href); |
| 412 | if (!target) return null; |
| 413 | |
| 414 | const pathWithPosition = formatFilePathPosition(target); |
| 415 | if (!isRelativeFilePath(pathWithPosition)) return pathWithPosition; |
| 416 | if (!baseDir) return null; |
| 417 | return resolvePathLinkTarget(pathWithPosition, baseDir); |
| 418 | } |
| 419 | |
| 420 | export function isWindowsDrivePathHref(href: string): boolean { |
| 421 | return /^[A-Za-z]:[\\/]/.test(safeDecodeURIComponent(href)); |
| 422 | } |