Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/ferric-init-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"ferric-cli": minor
---

Add a `ferric init [path]` command, scaffolding a package building a Rust Node-API module.

It writes the `package.json`, `Cargo.toml`, `build.rs`, `src/lib.rs` and `.gitignore` a package needs, and adds it to the workspace of the mono-repo it is initialized into. Run against a package that already exists, it bumps outdated dependencies and crates in place, and prints a diff for every update. A differing entrypoint or source file is skipped with a diff to merge by hand, unless `--force` is passed. `--dry-run` prints all of this without writing anything.

A new `Cargo.toml` can be configured with `--node-api-version`, `--napi-crate-version`, `--napi-derive-crate-version`, `--napi-build-crate-version` and `--release-profile`.
6 changes: 6 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ jobs:
# weak-node-api's codegen it can't be reproduced here without reintroducing a
# native build into the fastest-feedback job. See #414.
- run: pnpm --filter weak-node-api run prebuild:prepare
- run: pnpm run init-ferric-example
- name: Verify ferric-example matches what ferric init generates
run: |
git status --porcelain packages/ferric-example
git diff --exit-code packages/ferric-example
test -z "$(git status --porcelain packages/ferric-example)"
- run: pnpm run lint
env:
DEBUG: eslint:eslint
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"changeset": "changeset",
"release": "node --run prerelease && changeset publish",
"prerelease": "node --run build && pnpm --recursive run prerelease && node --run publint",
"init-macos-test-app": "node scripts/init-macos-test-app.ts"
"init-macos-test-app": "node scripts/init-macos-test-app.ts",
"init-ferric-example": "node scripts/init-ferric-example.ts"
},
"author": {
"name": "Callstack",
Expand Down
2 changes: 1 addition & 1 deletion packages/ferric-example/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/ferric-example/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "ferric-example"
version = "1.0.0"
version = "0.1.0"
edition = "2021"
license = "MIT"

Expand Down
6 changes: 3 additions & 3 deletions packages/ferric-example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
"name": "@react-native-node-api/ferric-example",
"version": "0.1.3",
"private": true,
"type": "commonjs",
"homepage": "https://github.com/callstackincubator/react-native-node-api",
"repository": {
"type": "git",
"url": "git+https://github.com/callstackincubator/react-native-node-api.git",
"directory": "packages/ferric-example"
},
"type": "commonjs",
"main": "ferric_example.js",
"types": "ferric_example.d.ts",
"files": [
Expand All @@ -19,8 +19,8 @@
],
"scripts": {
"build": "ferric build",
"bootstrap": "node --run build",
"build:types": "ferric build --dts-only"
"build:types": "ferric build --dts-only",
"bootstrap": "node --run build"
},
"dependencies": {
"react-native-node-api": "workspace:*"
Expand Down
39 changes: 39 additions & 0 deletions packages/ferric/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,42 @@ features = ["napi3"]
```

Pass `--skip-feature-check` to bypass the check.

## `ferric init [path]`

Scaffold a package building a Rust Node-API module, or bring an existing package
up to what `ferric build` expects of it:

```
npx ferric init packages/my-addon
```

The command creates whatever is missing — `package.json`, `Cargo.toml`,
`build.rs`, `src/lib.rs` and `.gitignore` — and adds the package to the
`pnpm-workspace.yaml` or `"workspaces"` of the mono-repo it is initialized into.

Running it against a package which already exists updates rather than overwrites:

- `package.json` gains the scripts, dependencies and prebuilt artifacts it lacks, and outdated dependencies are bumped.
- An existing `Cargo.toml` keeps its comments and formatting. Outdated `napi`, `napi-derive` and `napi-build` versions are bumped in place, and the command points out anything else `ferric build` needs.
- A `main`, `types`, `src/lib.rs` or `build.rs` that differs is skipped, printing a diff to merge by hand. Pass `--force` to overwrite them instead.

A newly created `Cargo.toml` can be configured with:

- `--node-api-version <version>`: the `napiN` feature to enable, defaulting to 3.
- `--napi-crate-version`, `--napi-derive-crate-version` and `--napi-build-crate-version`: the crate versions, defaulting to those tested with `ferric-example`. These also set what an existing `Cargo.toml` is bumped to.
- `--release-profile <speed|size|none>`: `speed` (the default) enables `lto` and a single codegen unit, `size` also optimizes for size and strips symbols, and `none` leaves `[profile.release]` out.

The name of the crate defaults to the package name without its npm scope
(`@my-org/my-addon` becomes `my-addon`) and can be set with `--name`. Cargo
normalizes it to `my_addon` for the artifacts it produces, which is the basename
`ferric build` writes its outputs and entrypoint with.

Every file it updates is printed as a diff, and `--dry-run` prints the whole
plan without writing anything:

```
npx ferric init packages/my-addon --dry-run
```

The [`ferric-example`](../ferric-example) package is generated by this command: run `pnpm run init-ferric-example` from the root of the repository after changing the templates, which CI asserts leaves no diff.
13 changes: 12 additions & 1 deletion packages/ferric/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
"type": "module",
"files": [
"bin",
"dist"
"dist",
"!dist/**/*.test.d.ts",
"!dist/**/*.test.d.ts.map",
"templates"
],
"bin": {
"ferric": "./bin/ferric.js"
Expand All @@ -23,8 +26,16 @@
"dependencies": {
"@napi-rs/cli": "~3.10.8",
"@react-native-node-api/cli-utils": "workspace:*",
"diff": "^9.0.0",
"picomatch": "^4.0.5",
"react-native-node-api": "workspace:*",
"semver": "^7.8.5",
"smol-toml": "^1.8.0",
"weak-node-api": "workspace:*",
"zod": "^4.1.11"
},
"devDependencies": {
"@types/picomatch": "^4.0.3",
"@types/semver": "^7.8.0"
}
}
211 changes: 211 additions & 0 deletions packages/ferric/src/cargo-manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { chalk } from "@react-native-node-api/cli-utils";
import semver from "semver";
import { parse as parseToml } from "smol-toml";

import { MAX_NODE_API_VERSION, getNodeApiVersion } from "./features.js";
import { isRecord } from "./json.js";

export type CrateVersions = {
napi: string;
"napi-derive": string;
"napi-build": string;
};

const CRATES = [
{ crate: "napi", table: "dependencies" },
{ crate: "napi-derive", table: "dependencies" },
{ crate: "napi-build", table: "build-dependencies" },
] satisfies { crate: keyof CrateVersions; table: string }[];

/**
* Cargo separates comparators with commas, which semver expects as spaces.
*/
function toSemverRange(requirement: string) {
return semver.validRange(requirement.replaceAll(",", " "));
}

export function isCrateVersion(requirement: string) {
return toSemverRange(requirement) !== null;
}

/**
* Whether a declared version requirement allows only versions below the wanted
* one. Requirements which aren't versions at all are never considered outdated.
*/
export function isOutdated(declared: string, wanted: string) {
const declaredRange = toSemverRange(declared);
const wantedRange = toSemverRange(wanted);
if (!declaredRange || !wantedRange || declaredRange === "*") {
return false;
}
return semver.gt(
semver.minVersion(wantedRange) ?? "0.0.0",
semver.minVersion(declaredRange) ?? "0.0.0",
);
}

/**
* Replace a version in the text of a manifest, as serializing a parsed manifest
* would lose its comments and formatting.
*/
function replaceCrateVersion({
contents,
table,
crate,
version,
}: {
contents: string;
table: string;
crate: string;
version: string;
}) {
let section = "";
const lines = contents.split("\n").map((line) => {
const header = line.match(/^\s*\[([^[\]]+)\]/);
if (header) {
section = header[1].trim();
return line;
}
const inTable = section === table && line.match(`^\\s*"?${crate}"?\\s*=`);
const inCrateTable =
section === `${table}.${crate}` && line.match(/^\s*version\s*=/);
if (!inTable && !inCrateTable) {
return line;
}
const pattern = inTable
? /(=\s*(?:\{[^}]*?\bversion\s*=\s*)?)(["'])[^"']*\2/
: /(=\s*)(["'])[^"']*\2/;
return line.replace(pattern, `$1$2${version}$2`);
});
return lines.join("\n");
}

function getCrateDeclaration(
manifest: Record<string, unknown>,
table: string,
crate: string,
) {
const dependencies = manifest[table];
return isRecord(dependencies) ? dependencies[crate] : undefined;
}

function getStringArray(value: unknown) {
return Array.isArray(value)
? value.filter((item) => typeof item === "string")
: [];
}

function inspectFeatures(dependencies: Record<string, unknown>) {
const result: string[] = [];
const napi = dependencies["napi"];
if (isRecord(napi) || typeof napi === "string") {
if (!isRecord(napi) || napi["default-features"] !== false) {
result.push(
`Cargo.toml doesn't declare ${chalk.blue("default-features = false")} on ${chalk.blue("napi")}, which ferric build rejects`,
);
}
const features = isRecord(napi) ? getStringArray(napi["features"]) : [];
if (getNodeApiVersion(features) > MAX_NODE_API_VERSION) {
result.push(
`Cargo.toml enables a ${chalk.blue("napi")} feature above the Node-API version ${MAX_NODE_API_VERSION} supported by the host`,
);
}
}
const napiSys = dependencies["napi-sys"];
if (isRecord(napiSys) || typeof napiSys === "string") {
const version = isRecord(napiSys) ? napiSys["version"] : napiSys;
if (typeof version === "string" && version.startsWith("=")) {
result.push(
`Cargo.toml pins ${chalk.blue(`napi-sys ${version}`)}, which is likely to conflict with newer ${chalk.blue("napi")} versions: remove the pin`,
);
}
const features = isRecord(napiSys)
? getStringArray(napiSys["features"])
: [];
if (features.includes("dyn-symbols")) {
result.push(
`Cargo.toml enables the ${chalk.blue("dyn-symbols")} feature of ${chalk.blue("napi-sys")}, which ferric build rejects`,
);
}
}
return result;
}

/**
* Bump outdated crates of an existing manifest in place and report anything else
* it needs to build into a Node-API module. Everything but those versions is
* left to the developer.
*/
export function updateCargoManifest(
contents: string,
crateVersions: CrateVersions,
) {
let manifest: unknown;
try {
manifest = parseToml(contents);
} catch (error) {
return {
contents,
notices: [
`Cargo.toml could not be parsed: ${error instanceof Error ? error.message : String(error)}`,
],
};
}
if (!isRecord(manifest)) {
return { contents, notices: ["Cargo.toml doesn't declare a package"] };
}
const parsed = manifest;
const notices: string[] = [];
const lib = parsed["lib"];
const crateType = isRecord(lib) ? lib["crate-type"] : undefined;
if (!Array.isArray(crateType) || !crateType.includes("cdylib")) {
notices.push(
`Cargo.toml doesn't declare ${chalk.blue('crate-type = ["cdylib"]')} in its ${chalk.blue("[lib]")} section, which Node-API modules are built as`,
);
}

let updated = contents;
for (const { crate, table } of CRATES) {
const wanted = crateVersions[crate];
const declaration = getCrateDeclaration(parsed, table, crate);
if (declaration === undefined) {
notices.push(
`Cargo.toml is missing ${chalk.blue(crate)} from its ${chalk.blue(`[${table}]`)}`,
);
continue;
}
const declared = isRecord(declaration)
? declaration["version"]
: declaration;
if (typeof declared !== "string") {
notices.push(
`Cargo.toml doesn't declare ${chalk.blue(crate)} with a version: make sure it resolves to ${chalk.blue(wanted)} or newer`,
);
} else if (isOutdated(declared, wanted)) {
const replaced = replaceCrateVersion({
contents: updated,
table,
crate,
version: wanted,
});
const result: unknown = parseToml(replaced);
const resolved = isRecord(result)
? getCrateDeclaration(result, table, crate)
: undefined;
const version = isRecord(resolved) ? resolved["version"] : resolved;
if (version === wanted) {
updated = replaced;
} else {
notices.push(
`Couldn't update ${chalk.blue(crate)} in Cargo.toml: change ${chalk.blue(declared)} to ${chalk.blue(wanted)}`,
);
}
}
}

const dependencies = parsed["dependencies"];
if (isRecord(dependencies)) {
notices.push(...inspectFeatures(dependencies));
}
return { contents: updated, notices };
}
Loading
Loading