-
Notifications
You must be signed in to change notification settings - Fork 119
perf(info,upgrade): use confbox rather than pnpm-workspace-yaml
#1425
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,8 +2,8 @@ import type { PackageJson } from 'pkg-types' | |
|
|
||
| import { existsSync, readFileSync, writeFileSync } from 'node:fs' | ||
|
|
||
| import { parseYAML } from 'confbox/yaml' | ||
| import { dirname, join, resolve } from 'pathe' | ||
| import { parsePnpmWorkspaceYaml } from 'pnpm-workspace-yaml' | ||
|
|
||
| const CATALOG_SPECIFIER_RE = /^catalog:(.*)$/ | ||
|
|
||
|
|
@@ -76,16 +76,20 @@ export function readCatalogConfig(cwd: string): CatalogConfig | undefined { | |
| return config | ||
| } | ||
|
|
||
| interface WorkspaceYaml { | ||
| catalog?: Record<string, string> | ||
| catalogs?: Record<string, Record<string, string>> | ||
| } | ||
|
|
||
| function parseCatalogConfig(filePath: string): CatalogConfig | undefined { | ||
| let workspace: ReturnType<typeof parsePnpmWorkspaceYaml> | ||
| let json: WorkspaceYaml | ||
| try { | ||
| workspace = parsePnpmWorkspaceYaml(readFileSync(filePath, 'utf-8')) | ||
| json = parseYAML<WorkspaceYaml>(readFileSync(filePath, 'utf-8')) || {} | ||
| } | ||
| catch { | ||
| return undefined | ||
| } | ||
|
|
||
| const json = workspace.toJSON() | ||
| const catalogs: CatalogConfig['catalogs'] = { ...json.catalogs } | ||
| if (json.catalog) { | ||
| catalogs[DEFAULT_CATALOG] = json.catalog | ||
|
|
@@ -133,20 +137,267 @@ export function updateCatalogEntries(cwd: string, updates: CatalogEntryUpdate[]) | |
| return 'failed' | ||
| } | ||
|
|
||
| let source: string | ||
| try { | ||
| const workspace = parsePnpmWorkspaceYaml(readFileSync(filePath, 'utf-8')) | ||
| for (const { catalog, pkg, specifier } of updates) { | ||
| workspace.setPackage(catalog, pkg, specifier) | ||
| source = readFileSync(filePath, 'utf-8') | ||
| // Reject anything we cannot understand before rewriting a line of it. | ||
| parseYAML(source) | ||
| } | ||
| catch { | ||
| return 'failed' | ||
| } | ||
|
|
||
| const lines = source.split('\n') | ||
| let changed = false | ||
|
|
||
| for (const { catalog, pkg, specifier } of updates) { | ||
| const result = setCatalogEntry(lines, catalog, pkg, specifier) | ||
| if (result === 'failed') { | ||
| return 'failed' | ||
| } | ||
| if (!workspace.hasChanged()) { | ||
| changed ||= result === 'updated' | ||
| } | ||
|
|
||
| if (!changed) { | ||
| return 'unchanged' | ||
| } | ||
|
|
||
| try { | ||
| writeFileSync(filePath, lines.join('\n'), 'utf-8') | ||
| } | ||
| catch { | ||
| return 'failed' | ||
| } | ||
|
|
||
| configCache.delete(filePath) | ||
| return 'updated' | ||
| } | ||
|
|
||
| /** | ||
| * A `key:` line, split into its indentation, raw (possibly quoted) key and the | ||
| * inline value that follows. Blank lines, comments and sequence items do not match. | ||
| */ | ||
| const KEY_LINE_RE = /^(\s*)(?:(?<quote>["'])(?<quoted>(?:\\.|(?!\k<quote>).)*)\k<quote>\s*|(?<plain>[^#\s"'][^:]*)):(?<rest>.*)$/ | ||
|
|
||
| interface KeyLine { | ||
| indent: number | ||
| key: string | ||
| /** The key exactly as written, including quotes. */ | ||
| raw: string | ||
| value: string | ||
| } | ||
|
|
||
| function parseKeyLine(line: string): KeyLine | undefined { | ||
| const match = line.match(KEY_LINE_RE) | ||
| if (!match?.groups) { | ||
| return undefined | ||
| } | ||
| const { quote, quoted, plain, rest } = match.groups | ||
| if (rest !== '' && !rest!.startsWith(' ')) { | ||
| return undefined | ||
| } | ||
| const key = quote ? quoted! : plain!.trimEnd() | ||
| return { | ||
| indent: match[1]!.length, | ||
| key: quote ? unescapeYAMLString(key, quote) : key, | ||
| raw: quote ? `${quote}${quoted}${quote}` : key, | ||
| value: rest!.trimStart(), | ||
| } | ||
| } | ||
|
|
||
| function unescapeYAMLString(value: string, quote: string): string { | ||
| return quote === '\'' ? value.replaceAll('\'\'', '\'') : JSON.parse(`"${value}"`) | ||
| } | ||
|
|
||
| function isBlankOrComment(line: string): boolean { | ||
| const trimmed = line.trim() | ||
| return trimmed === '' || trimmed.startsWith('#') | ||
| } | ||
|
|
||
| /** Split an inline value into the scalar itself and any trailing comment. */ | ||
| function splitTrailingComment(value: string): [scalar: string, comment: string] { | ||
| if (value.startsWith('#')) { | ||
| return ['', value] | ||
| } | ||
| const match = value.match(/\s+#.*$/) | ||
| if (!match) { | ||
| return [value, ''] | ||
| } | ||
| return [value.slice(0, match.index), match[0]!] | ||
| } | ||
|
|
||
| /** | ||
| * Rewrite (or add) `pkg: specifier` inside `catalog`, editing only the line that | ||
| * declares it so surrounding comments, anchors and formatting survive. | ||
| */ | ||
| function setCatalogEntry(lines: string[], catalog: string, pkg: string, specifier: string): UpdateCatalogEntriesResult { | ||
| const block = findBlock(lines, catalogPath(catalog)) | ||
| if (block === 'failed') { | ||
| return 'failed' | ||
| } | ||
|
|
||
| if (!block) { | ||
| return insertCatalogBlock(lines, catalog, pkg, specifier) | ||
| } | ||
|
|
||
| const { start, end, indent } = block | ||
| let entryIndent: number | undefined | ||
| let lastEntry = start | ||
|
|
||
| for (let index = start + 1; index < end; index++) { | ||
| const line = lines[index]! | ||
| if (isBlankOrComment(line)) { | ||
| continue | ||
| } | ||
| const entry = parseKeyLine(line) | ||
| if (!entry || entry.indent <= indent) { | ||
| return 'failed' | ||
| } | ||
| entryIndent ??= entry.indent | ||
| if (entry.indent !== entryIndent) { | ||
| continue | ||
| } | ||
| lastEntry = index | ||
| if (entry.key !== pkg) { | ||
| continue | ||
| } | ||
|
|
||
| const [scalar, comment] = splitTrailingComment(entry.value) | ||
| const anchor = scalar.match(/^&\S+\s+/)?.[0] ?? '' | ||
| const current = scalar.slice(anchor.length) | ||
| if (current.startsWith('*')) { | ||
| // Rewriting an alias would silently retarget every other use of the anchor. | ||
| return 'failed' | ||
| } | ||
| if (current === specifier || current === `"${specifier}"` || current === `'${specifier}'`) { | ||
| return 'unchanged' | ||
| } | ||
|
|
||
| writeFileSync(filePath, workspace.toString(), 'utf-8') | ||
| configCache.delete(filePath) | ||
| lines[index] = `${' '.repeat(entry.indent)}${entry.raw}: ${anchor}${quoteYAMLScalar(specifier)}${comment}` | ||
| return 'updated' | ||
| } | ||
| catch { | ||
|
|
||
| lines.splice(lastEntry + 1, 0, `${' '.repeat(entryIndent ?? indent + 2)}${quoteYAMLKey(pkg)}: ${quoteYAMLScalar(specifier)}`) | ||
| return 'updated' | ||
| } | ||
|
|
||
| interface CatalogBlock { | ||
| /** Index of the `catalog:` / `<name>:` line itself. */ | ||
| start: number | ||
| /** Index one past the last line belonging to the block. */ | ||
| end: number | ||
| indent: number | ||
| } | ||
|
|
||
| function catalogPath(catalog: string): string[] { | ||
| return catalog === DEFAULT_CATALOG ? ['catalog'] : ['catalogs', catalog] | ||
| } | ||
|
|
||
| function findBlock(lines: string[], path: string[]): CatalogBlock | undefined | 'failed' { | ||
| let start = -1 | ||
| let indent = 0 | ||
| let end = lines.length | ||
|
|
||
| for (const [depth, segment] of path.entries()) { | ||
| const from = start + 1 | ||
| const parentIndent = indent | ||
| let childIndent: number | undefined | ||
| start = -1 | ||
| for (let index = from; index < end; index++) { | ||
| const line = lines[index]! | ||
| if (isBlankOrComment(line)) { | ||
| continue | ||
| } | ||
| const key = parseKeyLine(line) | ||
| if (!key) { | ||
| continue | ||
| } | ||
| if (depth === 0) { | ||
| if (key.indent !== 0) { | ||
| continue | ||
| } | ||
| } | ||
| else { | ||
| if (key.indent <= parentIndent) { | ||
| break | ||
| } | ||
| childIndent ??= key.indent | ||
| if (key.indent !== childIndent) { | ||
| continue | ||
| } | ||
| } | ||
| if (key.key !== segment) { | ||
| continue | ||
| } | ||
| if (splitTrailingComment(key.value)[0] !== '') { | ||
| // A flow mapping, alias or anchored value is not safe to edit by line. | ||
| return 'failed' | ||
| } | ||
| start = index | ||
| indent = key.indent | ||
| end = findBlockEnd(lines, index, indent) | ||
| break | ||
| } | ||
| if (start === -1) { | ||
| return undefined | ||
| } | ||
| } | ||
|
|
||
| return { start, end, indent } | ||
| } | ||
|
|
||
| function findBlockEnd(lines: string[], start: number, indent: number): number { | ||
| let end = start + 1 | ||
| for (let index = start + 1; index < lines.length; index++) { | ||
| const line = lines[index]! | ||
| if (isBlankOrComment(line)) { | ||
| continue | ||
| } | ||
| if (line.search(/\S/) <= indent) { | ||
| break | ||
| } | ||
| end = index + 1 | ||
| } | ||
| return end | ||
| } | ||
|
|
||
| function insertCatalogBlock(lines: string[], catalog: string, pkg: string, specifier: string): UpdateCatalogEntriesResult { | ||
| const entry = `${quoteYAMLKey(pkg)}: ${quoteYAMLScalar(specifier)}` | ||
|
|
||
| if (catalog === DEFAULT_CATALOG) { | ||
| return appendLines(lines, ['catalog:', ` ${entry}`]) | ||
| } | ||
|
|
||
| const catalogs = findBlock(lines, ['catalogs']) | ||
| if (catalogs === 'failed') { | ||
| return 'failed' | ||
| } | ||
| if (!catalogs) { | ||
| return appendLines(lines, ['catalogs:', ` ${catalog}:`, ` ${entry}`]) | ||
| } | ||
|
|
||
| lines.splice(catalogs.end, 0, `${' '.repeat(catalogs.indent + 2)}${catalog}:`, `${' '.repeat(catalogs.indent + 4)}${entry}`) | ||
| return 'updated' | ||
|
Comment on lines
+371
to
+380
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Derive the child indent from the existing Line 379 hardcodes Reproduce with: catalogs:
dev:
typescript: ^5.9.0Return the child indent from 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| function appendLines(lines: string[], toAppend: string[]): UpdateCatalogEntriesResult { | ||
| while (lines.length > 0 && lines.at(-1)!.trim() === '') { | ||
| lines.pop() | ||
| } | ||
| lines.push(...toAppend, '') | ||
| return 'updated' | ||
| } | ||
|
|
||
| const PLAIN_KEY_RE = /^[\w.][\w.\-/]*$/ | ||
|
|
||
| function quoteYAMLKey(key: string): string { | ||
| return PLAIN_KEY_RE.test(key) ? key : JSON.stringify(key) | ||
| } | ||
|
|
||
| function quoteYAMLScalar(value: string): string { | ||
| const needsQuotes = value === '' | ||
| || /^[-?:,[\]{}#&*!|>'"%@`]/.test(value) | ||
| || /:\s|\s#|[\n\t]/.test(value) | ||
| || value.trim() !== value | ||
| return needsQuotes ? JSON.stringify(value) : value | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Guard
unescapeYAMLStringagainst aJSON.parsethrow.JSON.parserejects YAML-only escapes such as\x41,\e, or\_.parseKeyLineruns on every line of the workspace file, including lines the caller does not target. A double-quoted key with such an escape makesJSON.parsethrow. The throw escapesupdateCatalogEntries, because thetryblock there only wraps the read and the initial parse. The documented contract is to return'failed'instead.🛡️ Proposed fix
function unescapeYAMLString(value: string, quote: string): string { - return quote === '\'' ? value.replaceAll('\'\'', '\'') : JSON.parse(`"${value}"`) + if (quote === '\'') { + return value.replaceAll('\'\'', '\'') + } + try { + return JSON.parse(`"${value}"`) + } + catch { + return value + } }📝 Committable suggestion
🤖 Prompt for AI Agents