import type { Stats } from 'node:fs' import { realpath, stat } from 'node:fs/promises' import path from 'node:path' import { runGit } from '../../git/worktree' /** 文档符号树上的一个名字。只用来按名字找,不解析源码。 */ export interface NamedSymbol { name: string children?: readonly NamedSymbol[] } /** 仓库里的一个文件或目录。 */ export interface RepoEntry { fsPath: string isDirectory: boolean } /** * 从仓库根起的相对路径。空段、`.`、`..`、绝对路径、盘符、反斜杠都不是。 * 页面解析过一次,这里再查一遍:消息里的路径不能跑出仓库。 */ export function isSafeRepoPath(rel: string): boolean { if (rel === '' || rel.startsWith('/') || rel.startsWith('./') || /^[a-z]:/i.test(rel) || hasControl(rel)) return false if (rel.includes('\\') || rel.includes('?') || rel.includes('#')) return false const segments = rel.split('/') if (segments.at(-1) === '') segments.pop() return segments.length > 0 && segments.every(segment => segment !== '' && segment !== '.' && segment !== '..') } /** 注释里当图片显示的文件后缀。webview 的 repoImage.ts 用同一份。 */ const IMAGE_FILE = /\.(?:avif|bmp|gif|ico|jpe?g|png|svg|webp)$/i /** 从仓库根起、能在注释里当图片显示的路径。别的仓库文件不给预览地址。 */ export function isRepoImagePath(rel: string): boolean { return isSafeRepoPath(rel) && IMAGE_FILE.test(rel) } /** target 解析之后还在 root 里面(root 自己不算)。 */ export function staysInside(root: string, target: string): boolean { const back = path.relative(path.resolve(root), path.resolve(target)) return back !== '' && back !== '..' && !back.startsWith(`..${path.sep}`) && !path.isAbsolute(back) } /** 把相对路径拼到仓库根上。跑出去了就没有。不看磁盘。 */ export function pathInsideRoot(root: string, rel: string): string | undefined { if (!isSafeRepoPath(rel)) return undefined const resolved = path.resolve(root, rel) return staysInside(root, resolved) ? resolved : undefined } /** 被预览的文件所在的 git 仓库根。不在仓库里就没有。 */ export async function repoRootOf(filePath: string): Promise { try { const root = (await runGit(path.dirname(filePath), ['rev-parse', '--show-toplevel'])).trim() return root === '' ? undefined : root } catch { return undefined } } /** * 仓库相对路径在磁盘上的文件或目录。 * 解析之后(含符号链接的真实位置)必须仍在仓库根里面;没有这个路径就没有。 */ export async function resolveRepoEntry(root: string, rel: string): Promise { const lexical = pathInsideRoot(root, rel) if (!lexical) return undefined let rootReal: string let info: Stats let fileReal: string try { rootReal = await realpath(root) info = await stat(lexical) fileReal = await realpath(lexical) } catch { return undefined } if (!staysInside(rootReal, fileReal)) return undefined return { fsPath: fileReal, isDirectory: info.isDirectory() } } /** * 在符号树里按名字找。`Agent` 找这个类或函数;`Agent.owner_id` 先找 `Agent`,再在它里面找 `owner_id`,点得更多的一层层往里找。 * 同名的有几个时(Rust 的 `struct Order` 和 `impl Order`)挨个试,取第一个里面找得到后面几段的。 * 名字对的是符号自己的 name,不看源码怎么写;有一段是空的(`Agent.`、`a..b`)找不到。 */ export function findRepoSymbol(symbols: readonly T[], symbol: string): T | undefined { const names = symbol.split('.') return names.includes('') ? undefined : findPath(symbols, names) } /** 第一段按名字找(照先后、先往里再往后),每个同名的都在它里面接着找后面几段。 */ function findPath(symbols: readonly T[], [name, ...rest]: readonly string[]): T | undefined { for (const found of named(symbols, name)) { const target = rest.length === 0 ? found : findPath((found.children ?? []) as readonly T[], rest) if (target) return target } return undefined } function* named(symbols: readonly T[], name: string): Generator { for (const symbol of symbols) { if (symbol.name === name) yield symbol yield* named((symbol.children ?? []) as readonly T[], name) } } function hasControl(text: string): boolean { for (const char of text) { if (char.charCodeAt(0) <= 31) return true } return false }