Files
superwork/vscode/src/codeReader/pathMap.ts
T

155 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 按文件 / 目录记东西的一份表(文件注释、代码问答):每个工作区文件夹一份,落盘到 `.spx` 里的一个 JSON 文件,
* 形状是 `{ "相对路径": 值 }`。键相对这个工作区文件夹、`/` 分隔;改名、挪位置、删除时整棵子树跟着走。
* 代码问答还记工作区外的库文件,键是 `~/…` 或绝对路径(见 [outsideKey])。
*/
import { promises as fsp } from 'node:fs'
import * as path from 'node:path'
export type PathMap<T> = Record<string, T>
/** 表落在哪、叫什么、每条的值怎么认。 */
export interface PathMapFile<T> {
/** 相对工作区文件夹的位置(`/` 分隔),文件监听也用它 */
path: string
/** 读写出错时提示里怎么称呼它 */
label: string
/** 认一条的值:不合法的返回 undefined,这条就跳过;合法的可以顺手规范成统一写法 */
parse: (value: unknown) => T | undefined
/** 一条值带着的文件(相对工作区文件夹,`/` 分隔,例如问答的图片):改表后不再有哪条带着的,跟着删掉 */
attachments?: (value: T) => string[]
}
/** 规范成表的键:去掉 `./`、重复和结尾的 `/`;工作区文件夹本身、文件夹外面的路径没有键。 */
export function normalizeKey(relativePath: string): string | undefined {
const key = path.posix.normalize(relativePath).replace(/\/+$/, '')
return key === '' || key === '.' || key === '..' || key.startsWith('../') || key.startsWith('/') ? undefined : key
}
/** @param rootPath 工作区文件夹的 `Uri.path`;@param targetPath 目录 / 文件的 `Uri.path` */
export function toPathKey(rootPath: string, targetPath: string): string | undefined {
return normalizeKey(path.posix.relative(rootPath, targetPath))
}
/**
* 工作区外的文件 / 目录(比如库源码)的键:在家目录下写成 `~/…`,别处写绝对路径。
* 工作区里的键是相对路径,开头不会是 `~/` 或 `/`,两边撞不上。
*
* @param targetPath 文件 / 目录的 `Uri.path`
* @param homePath 家目录的 `Uri.path`
*/
export function outsideKey(targetPath: string, homePath: string): string {
const underHome = toPathKey(homePath, targetPath)
return underHome === undefined ? path.posix.normalize(targetPath).replace(/\/+$/, '') : `~/${underHome}`
}
/** 工作区外的键(见 [outsideKey])在磁盘上的 `Uri.path`;工作区里的相对路径键返回 `undefined`。 */
export function outsidePath(key: string, homePath: string): string | undefined {
if (key.startsWith('~/'))
return path.posix.join(homePath, key.slice(2))
return key.startsWith('/') ? key : undefined
}
/** 读表时把一个键规范成统一写法:相对路径同 [normalizeKey],工作区外的 `~/…`、绝对路径(见 [outsideKey])也认;跑出家目录的、空的返回 `undefined`。 */
export function tableKey(raw: string): string | undefined {
if (raw.startsWith('~/')) {
const underHome = normalizeKey(raw.slice(2))
return underHome === undefined ? undefined : `~/${underHome}`
}
if (raw.startsWith('/')) {
const absolute = path.posix.normalize(raw).replace(/\/+$/, '')
return absolute === '' ? undefined : absolute
}
return normalizeKey(raw)
}
function isUnder(candidate: string, key: string): boolean {
return candidate === key || candidate.startsWith(`${key}/`)
}
/** key 自己和它下面各层的条目,键换成相对 key 的路径(key 自己是 `''`)。 */
export function entriesUnder<T>(map: PathMap<T>, key: string): PathMap<T> {
return Object.fromEntries(Object.entries(map)
.filter(([candidate]) => isUnder(candidate, key))
.map(([candidate, value]) => [candidate.slice(key.length + 1), value]))
}
/** 删掉 key 自己和它下面各层的条目(目录被删时连带里面的)。 */
export function dropEntries<T>(map: PathMap<T>, key: string): PathMap<T> {
return Object.fromEntries(Object.entries(map).filter(([candidate]) => !isUnder(candidate, key)))
}
/** 把 entriesUnder 取出的条目放到 key 下面。 */
export function placeEntries<T>(map: PathMap<T>, key: string, under: PathMap<T>): PathMap<T> {
const placed = Object.entries(under).map(([rest, value]) => [rest === '' ? key : `${key}/${rest}`, value])
return { ...map, ...Object.fromEntries(placed) }
}
/** 改名 / 挪位置:条目跟着走,目录连带里面各层的条目一起走。 */
export function moveEntries<T>(map: PathMap<T>, from: string, to: string): PathMap<T> {
return placeEntries(dropEntries(map, from), to, entriesUnder(map, from))
}
/** 两份表里有增、删、改的键;值按 JSON 写法比。 */
export function changedKeys<T>(before: PathMap<T>, after: PathMap<T>): string[] {
const keys = new Set([...Object.keys(before), ...Object.keys(after)])
return [...keys].filter(key => JSON.stringify(before[key]) !== JSON.stringify(after[key]))
}
function fileIn<T>(workspaceRoot: string, file: PathMapFile<T>): string {
return path.join(workspaceRoot, ...file.path.split('/'))
}
/** 读工作区文件夹里的这份表。文件缺失 / 解析失败 / 不是对象都返回 `{}`;键认不出(见 [tableKey])、值认不出的条目跳过。 */
export async function readPathMap<T>(workspaceRoot: string, file: PathMapFile<T>): Promise<PathMap<T>> {
let raw: string
try {
raw = await fsp.readFile(fileIn(workspaceRoot, file), 'utf8')
}
catch {
return {}
}
try {
const parsed = JSON.parse(raw) as unknown
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
return {}
const map: PathMap<T> = {}
for (const [rawKey, rawValue] of Object.entries(parsed as Record<string, unknown>)) {
const key = tableKey(rawKey)
const value = file.parse(rawValue)
if (key !== undefined && value !== undefined)
map[key] = value
}
return map
}
catch {
return {}
}
}
/** 写工作区文件夹里的这份表(空表也写,文件留着):父目录按需创建,键排好序、两格缩进、末尾换行,git diff 稳定。 */
export async function writePathMap<T>(workspaceRoot: string, file: PathMapFile<T>, map: PathMap<T>): Promise<void> {
const target = fileIn(workspaceRoot, file)
await fsp.mkdir(path.dirname(target), { recursive: true })
const sorted = Object.fromEntries(Object.entries(map).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0))
await fsp.writeFile(target, `${JSON.stringify(sorted, null, 2)}\n`, 'utf8')
}
/** 改表前带着、改表后没有哪条再带着的文件(见 PathMapFile.attachments)。 */
export function detachedFiles<T>(file: PathMapFile<T>, before: PathMap<T>, after: PathMap<T>): string[] {
const { attachments } = file
if (!attachments)
return []
const kept = new Set(Object.values(after).flatMap(attachments))
return [...new Set(Object.values(before).flatMap(attachments))].filter(attachment => !kept.has(attachment))
}
/** 删掉工作区文件夹里的这些文件(相对路径);已经没了的不算错,跑到文件夹外面的路径不碰。 */
export async function removeFiles(workspaceRoot: string, relativePaths: readonly string[]): Promise<void> {
await Promise.all(relativePaths.flatMap((relativePath) => {
const key = normalizeKey(relativePath)
return key === undefined ? [] : [fsp.rm(path.join(workspaceRoot, ...key.split('/')), { force: true })]
}))
}