@napi-rs/cli
Version:
Cli tools for napi-rs
301 lines (278 loc) • 8.28 kB
text/typescript
import { spawn } from 'node:child_process'
import fs from 'node:fs'
import { dirname, resolve } from 'node:path'
import { debugFactory } from './log.js'
const debug = debugFactory('metadata')
export type CrateTargetKind =
| 'bin'
| 'example'
| 'test'
| 'bench'
| 'lib'
| 'rlib'
| 'cdylib'
| 'custom-build'
export interface CrateTarget {
name: string
kind: CrateTargetKind[]
crate_types: CrateTargetKind[]
}
export interface Crate {
id: string
name: string
src_path: string
version: string
edition: string
targets: CrateTarget[]
features: Record<string, string[]>
manifest_path: string
dependencies: Array<{
name: string
source: string | null
req: string
kind: string | null
rename: string | null
optional: boolean
uses_default_features: boolean
features: string[]
target: string | null
registry: string | null
}>
}
export interface CargoResolveNode {
id: string
dependencies: string[]
deps: Array<{
name: string
pkg: string
dep_kinds: Array<{
kind: string | null
target: string | null
}>
}>
features: string[]
}
export interface CargoWorkspaceMetadata {
version: number
packages: Crate[]
workspace_members: string[]
target_directory: string
workspace_root: string
resolve: {
root: string | null
nodes: CargoResolveNode[]
} | null
}
// Crates that will actually compile `napi-derive` in this build, and are therefore
// expected to emit a type-def intermediate file. Prefer the resolved dependency graph:
// a crate whose `napi-derive` dependency is optional and stays disabled in the current
// build never emits a type-def file, so force-building it would invalidate cargo's
// fingerprint on every build without ever satisfying the type-def check. Fall back
// to declared dependencies when the resolve graph is unavailable.
export function getNapiDeriveDependentCrates(
metadata: CargoWorkspaceMetadata,
): Crate[] {
const resolveNodes = metadata.resolve?.nodes
if (!resolveNodes) {
return metadata.packages.filter((crate) =>
crate.dependencies.some((d) => d.name === 'napi-derive'),
)
}
const napiDerivePackageIds = new Set(
metadata.packages.filter((p) => p.name === 'napi-derive').map((p) => p.id),
)
const dependentPackageIds = new Set(
resolveNodes
.filter((node) =>
node.deps.some((dep) => napiDerivePackageIds.has(dep.pkg)),
)
.map((node) => node.id),
)
return metadata.packages.filter((crate) => dependentPackageIds.has(crate.id))
}
interface ParseMetadataOptions {
allFeatures?: boolean
cargoOptions?: string[]
cwd?: string
featurePackage?: string
features?: string[]
filterPlatform?: string
noDefaultFeatures?: boolean
}
export function createCargoMetadataInvocation(
manifestPath: string,
options: ParseMetadataOptions = {},
) {
const globalArgs: string[] = []
const metadataArgs = [
'metadata',
'--manifest-path',
manifestPath,
'--format-version',
'1',
]
const features = [
...new Set(
(options.features ?? []).flatMap((feature) =>
feature.split(/[,\s]+/).filter(Boolean),
),
),
]
let allFeatures = options.allFeatures === true
let noDefaultFeatures = options.noDefaultFeatures === true
const cargoOptions = options.cargoOptions ?? []
for (let index = 0; index < cargoOptions.length; index += 1) {
const option = cargoOptions[index]
if (option === '--config' || option === '-Z') {
const value = cargoOptions[index + 1]
if (value !== undefined) {
globalArgs.push(option, value)
index += 1
}
} else if (option.startsWith('--config=') || option.startsWith('-Z')) {
globalArgs.push(option)
} else if (option === '--features' || option === '-F') {
const value = cargoOptions[index + 1]
if (value !== undefined) {
features.push(...value.split(/[,\s]+/).filter(Boolean))
index += 1
}
} else if (option.startsWith('--features=') || option.startsWith('-F=')) {
features.push(
...option
.slice(option.indexOf('=') + 1)
.split(/[,\s]+/)
.filter(Boolean),
)
} else if (option.startsWith('-F') && option.length > 2) {
features.push(
...option
.slice(2)
.split(/[,\s]+/)
.filter(Boolean),
)
} else if (option === '--all-features') {
allFeatures = true
} else if (option === '--no-default-features') {
noDefaultFeatures = true
} else if (
option === '--locked' ||
option === '--offline' ||
option === '--frozen'
) {
metadataArgs.push(option)
}
}
const selectedFeatures = [
...new Set(
features.map((feature) =>
options.featurePackage && !feature.includes('/')
? `${options.featurePackage}/${feature}`
: feature,
),
),
].sort()
if (selectedFeatures.length > 0) {
metadataArgs.push('--features', selectedFeatures.join(','))
}
if (allFeatures) {
metadataArgs.push('--all-features')
}
if (noDefaultFeatures) {
metadataArgs.push('--no-default-features')
}
if (options.filterPlatform) {
metadataArgs.push('--filter-platform', options.filterPlatform)
}
return {
// Deliberately NOT `process.env.CARGO ?? 'cargo'`: `$CARGO` only overrides
// the cargo/cross binary spawned for the actual build (see the Builder in
// api/build.ts) — tests poison `$CARGO` with a nonexistent path to assert
// no real build is spawned, and `cross metadata` is not a thing either.
// Metadata always comes from the host `cargo`.
command: 'cargo',
args: [...globalArgs, ...metadataArgs],
cwd: resolve(options.cwd ?? dirname(manifestPath)),
}
}
export async function parseMetadata(
manifestPath: string,
options: ParseMetadataOptions = {},
) {
if (!fs.existsSync(manifestPath)) {
throw new Error(`No crate found in manifest: ${manifestPath}`)
}
const invocation = createCargoMetadataInvocation(manifestPath, options)
// Resolving with the build's feature flags keeps `metadata.resolve` in sync
// with what `cargo build` will actually compile. Feature flags are rejected
// in some setups (e.g. a virtual workspace root manifest), so fall back to a
// plain invocation without them.
const featurelessArgs = stripFeatureArgs(invocation.args)
if (featurelessArgs.length !== invocation.args.length) {
try {
return await execCargoMetadata(invocation)
} catch (e) {
debug.warn(
`cargo metadata failed with feature flags, retrying without them: ${e}`,
)
}
return execCargoMetadata({ ...invocation, args: featurelessArgs })
}
return execCargoMetadata(invocation)
}
function stripFeatureArgs(args: string[]): string[] {
const stripped: string[] = []
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--features') {
// also skip the flag's value
index += 1
} else if (arg !== '--all-features' && arg !== '--no-default-features') {
stripped.push(arg)
}
}
return stripped
}
async function execCargoMetadata(invocation: {
command: string
args: string[]
cwd: string
}) {
const childProcess = spawn(invocation.command, invocation.args, {
cwd: invocation.cwd,
stdio: 'pipe',
})
let stdout = ''
let stderr = ''
let status = 0
let error: Error | null = null
childProcess.stdout.on('data', (data) => {
stdout += data
})
childProcess.stderr.on('data', (data) => {
stderr += data
})
childProcess.on('error', (err) => {
error = err
})
await new Promise<void>((resolve) => {
childProcess.on('close', (code) => {
status = code ?? 0
resolve()
})
})
if (error) {
throw new Error('cargo metadata failed to run', { cause: error })
}
if (status !== 0) {
const simpleMessage = `cargo metadata exited with code ${status}`
throw new Error(`${simpleMessage} and error message:\n\n${stderr}`, {
cause: new Error(simpleMessage),
})
}
try {
return JSON.parse(stdout) as CargoWorkspaceMetadata
} catch (e) {
throw new Error('Failed to parse cargo metadata JSON', { cause: e })
}
}