From e9b039445b4339e553e25a90a7ba1675e98da8d6 Mon Sep 17 00:00:00 2001 From: Loris Leiva Date: Fri, 8 May 2026 16:44:03 +0100 Subject: [PATCH] Introduce `@codama/fragments` with JavaScript and Rust subpaths This PR introduces `@codama/fragments`, a public package for composing generated source code. A `Fragment` is a snippet of code paired with the imports it depends on; fragments compose through tagged templates that propagate both content and imports, so generators can build code top-down without threading imports manually. The package consolidates what the existing renderers (`@codama/renderers-js`, `@codama/renderers-rust`, `@codama/renderers-rust-cpi`, `@codama/renderers-demo`) have each been duplicating in their own `src/utils/`. Three subpaths are exposed: the root entrypoint ships only the language-agnostic primitives (`BaseFragment`, `createFragmentTemplate`, `mapFragmentContent`, `setFragmentContent`); `@codama/fragments/javascript` and `@codama/fragments/rust` each layer on a concrete `Fragment` type, an `ImportMap` shape suited to the language, and the helpers needed to build, merge, resolve, and stringify imports. Both flavors are immutable, frozen, and free-function-based. As part of this change, `@codama/renderers-core` gets a patch bump: its `src/fragment.ts` becomes a named re-export from `@codama/fragments`, so existing importers see the same names at the same path with no behavior change. Sister renderers keep their own `importMap.ts` and `fragment.ts` for now and can migrate to `@codama/fragments` in subsequent PRs. --- .changeset/public-falcons-joke.md | 9 + .changeset/whole-bobcats-dance.md | 5 + packages/fragments/.gitignore | 1 + packages/fragments/.prettierignore | 5 + packages/fragments/README.md | 542 ++++++++++++++++++ packages/fragments/package.json | 106 ++++ packages/fragments/src/core/BaseFragment.ts | 18 + packages/fragments/src/core/casing.ts | 77 +++ .../src/core/createFragmentTemplate.ts | 52 ++ .../src => fragments/src/core}/fs.ts | 21 + packages/fragments/src/core/index.ts | 8 + .../fragments/src/core/mapFragmentContent.ts | 55 ++ packages/fragments/src/core/path.ts | 76 +++ packages/fragments/src/core/renderMap.ts | 168 ++++++ .../fragments/src/core/setFragmentContent.ts | 25 + packages/fragments/src/index.ts | 17 + .../fragments/src/javascript/ImportMap.ts | 84 +++ .../src/javascript/addToImportMap.ts | 38 ++ packages/fragments/src/javascript/fragment.ts | 177 ++++++ .../src/javascript/getDocblockFragment.ts | 67 +++ .../src/javascript/getExportAllFragment.ts | 24 + .../src/javascript/getExternalDependencies.ts | 40 ++ .../src/javascript/importMapToString.ts | 69 +++ packages/fragments/src/javascript/index.ts | 23 + .../src/javascript/mergeImportMaps.ts | 58 ++ .../src/javascript/removeFromImportMap.ts | 35 ++ .../src/javascript/resolveImportMap.ts | 43 ++ packages/fragments/src/rust/ImportMap.ts | 66 +++ .../fragments/src/rust/addAliasToImportMap.ts | 28 + packages/fragments/src/rust/addToImportMap.ts | 42 ++ packages/fragments/src/rust/fragment.ts | 167 ++++++ .../fragments/src/rust/getDocblockFragment.ts | 60 ++ .../src/rust/getExternalDependencies.ts | 40 ++ .../fragments/src/rust/importMapToString.ts | 37 ++ packages/fragments/src/rust/index.ts | 23 + .../fragments/src/rust/mergeImportMaps.ts | 34 ++ .../fragments/src/rust/removeFromImportMap.ts | 29 + .../fragments/src/rust/resolveImportMap.ts | 54 ++ packages/fragments/src/types/global.d.ts | 6 + packages/fragments/test/core/casing.test.ts | 234 ++++++++ .../test/core/createFragmentTemplate.test.ts | 56 ++ .../test => fragments/test/core}/fs.test.json | 0 .../test => fragments/test/core}/fs.test.ts | 6 +- packages/fragments/test/core/path.test.ts | 41 ++ .../test/core}/renderMap.test.ts | 68 ++- .../test/javascript/ImportMap.test.ts | 117 ++++ .../test/javascript/fragment.test.ts | 123 ++++ .../javascript/getDocblockFragment.test.ts | 50 ++ .../javascript/getExportAllFragment.test.ts | 20 + .../getExternalDependencies.test.ts | 44 ++ .../test/javascript/importMapToString.test.ts | 71 +++ .../test/javascript/resolveImportMap.test.ts | 58 ++ .../fragments/test/rust/ImportMap.test.ts | 196 +++++++ packages/fragments/test/rust/fragment.test.ts | 124 ++++ .../test/rust/getDocblockFragment.test.ts | 53 ++ packages/fragments/test/types/global.d.ts | 6 + packages/fragments/tsconfig.declarations.json | 10 + packages/fragments/tsconfig.json | 6 + packages/fragments/tsup.config.ts | 21 + packages/fragments/vitest.config.mts | 8 + packages/renderers-core/README.md | 49 +- packages/renderers-core/package.json | 1 + packages/renderers-core/src/fragment.ts | 35 -- packages/renderers-core/src/index.ts | 16 +- packages/renderers-core/src/path.ts | 19 - packages/renderers-core/src/renderMap.ts | 137 +---- packages/renderers-core/test/index.test.ts | 15 + packages/renderers-core/test/path.test.ts | 13 - pnpm-lock.yaml | 9 + 69 files changed, 3789 insertions(+), 246 deletions(-) create mode 100644 .changeset/public-falcons-joke.md create mode 100644 .changeset/whole-bobcats-dance.md create mode 100644 packages/fragments/.gitignore create mode 100644 packages/fragments/.prettierignore create mode 100644 packages/fragments/README.md create mode 100644 packages/fragments/package.json create mode 100644 packages/fragments/src/core/BaseFragment.ts create mode 100644 packages/fragments/src/core/casing.ts create mode 100644 packages/fragments/src/core/createFragmentTemplate.ts rename packages/{renderers-core/src => fragments/src/core}/fs.ts (66%) create mode 100644 packages/fragments/src/core/index.ts create mode 100644 packages/fragments/src/core/mapFragmentContent.ts create mode 100644 packages/fragments/src/core/path.ts create mode 100644 packages/fragments/src/core/renderMap.ts create mode 100644 packages/fragments/src/core/setFragmentContent.ts create mode 100644 packages/fragments/src/index.ts create mode 100644 packages/fragments/src/javascript/ImportMap.ts create mode 100644 packages/fragments/src/javascript/addToImportMap.ts create mode 100644 packages/fragments/src/javascript/fragment.ts create mode 100644 packages/fragments/src/javascript/getDocblockFragment.ts create mode 100644 packages/fragments/src/javascript/getExportAllFragment.ts create mode 100644 packages/fragments/src/javascript/getExternalDependencies.ts create mode 100644 packages/fragments/src/javascript/importMapToString.ts create mode 100644 packages/fragments/src/javascript/index.ts create mode 100644 packages/fragments/src/javascript/mergeImportMaps.ts create mode 100644 packages/fragments/src/javascript/removeFromImportMap.ts create mode 100644 packages/fragments/src/javascript/resolveImportMap.ts create mode 100644 packages/fragments/src/rust/ImportMap.ts create mode 100644 packages/fragments/src/rust/addAliasToImportMap.ts create mode 100644 packages/fragments/src/rust/addToImportMap.ts create mode 100644 packages/fragments/src/rust/fragment.ts create mode 100644 packages/fragments/src/rust/getDocblockFragment.ts create mode 100644 packages/fragments/src/rust/getExternalDependencies.ts create mode 100644 packages/fragments/src/rust/importMapToString.ts create mode 100644 packages/fragments/src/rust/index.ts create mode 100644 packages/fragments/src/rust/mergeImportMaps.ts create mode 100644 packages/fragments/src/rust/removeFromImportMap.ts create mode 100644 packages/fragments/src/rust/resolveImportMap.ts create mode 100644 packages/fragments/src/types/global.d.ts create mode 100644 packages/fragments/test/core/casing.test.ts create mode 100644 packages/fragments/test/core/createFragmentTemplate.test.ts rename packages/{renderers-core/test => fragments/test/core}/fs.test.json (100%) rename packages/{renderers-core/test => fragments/test/core}/fs.test.ts (90%) create mode 100644 packages/fragments/test/core/path.test.ts rename packages/{renderers-core/test => fragments/test/core}/renderMap.test.ts (75%) create mode 100644 packages/fragments/test/javascript/ImportMap.test.ts create mode 100644 packages/fragments/test/javascript/fragment.test.ts create mode 100644 packages/fragments/test/javascript/getDocblockFragment.test.ts create mode 100644 packages/fragments/test/javascript/getExportAllFragment.test.ts create mode 100644 packages/fragments/test/javascript/getExternalDependencies.test.ts create mode 100644 packages/fragments/test/javascript/importMapToString.test.ts create mode 100644 packages/fragments/test/javascript/resolveImportMap.test.ts create mode 100644 packages/fragments/test/rust/ImportMap.test.ts create mode 100644 packages/fragments/test/rust/fragment.test.ts create mode 100644 packages/fragments/test/rust/getDocblockFragment.test.ts create mode 100644 packages/fragments/test/types/global.d.ts create mode 100644 packages/fragments/tsconfig.declarations.json create mode 100644 packages/fragments/tsconfig.json create mode 100644 packages/fragments/tsup.config.ts create mode 100644 packages/fragments/vitest.config.mts delete mode 100644 packages/renderers-core/src/fragment.ts delete mode 100644 packages/renderers-core/src/path.ts create mode 100644 packages/renderers-core/test/index.test.ts delete mode 100644 packages/renderers-core/test/path.test.ts diff --git a/.changeset/public-falcons-joke.md b/.changeset/public-falcons-joke.md new file mode 100644 index 000000000..59d9266e6 --- /dev/null +++ b/.changeset/public-falcons-joke.md @@ -0,0 +1,9 @@ +--- +'@codama/fragments': minor +--- + +Introduce `@codama/fragments`, a public package that bundles Codama's composable code-generation primitives. The root entrypoint exposes the language-agnostic core (`BaseFragment`, `createFragmentTemplate`, `mapFragmentContent`, `setFragmentContent`) plus a small framework of shared helpers: casing utilities (`camelCase`, `pascalCase`, `snakeCase`, `kebabCase`, `titleCase`, `capitalize`), filesystem and path helpers (`writeFile`, `readFile`, `joinPath`, `pathDirectory`, `relativePath`, …), and the `RenderMap` data structure with its pure data operations (`createRenderMap`, `addToRenderMap`, `mergeRenderMaps`, `mapRenderMapContent`, `writeRenderMap`, …). The casing helpers return plain `string`; the branded `CamelCaseString` / `PascalCaseString` / … types stay in `@codama/node-types` for spec-validation purposes. + +JavaScript- and Rust-flavored `Fragment`, `ImportMap`, and `fragment` tagged-template helpers live under the `@codama/fragments/javascript` and `@codama/fragments/rust` subpaths. Both subpaths also expose `getDocblockFragment`, which now accepts `undefined` (in addition to `readonly string[]`) so generators can thread a node's optional `docs` attribute straight through without a ternary guard; the helper still returns `undefined` for empty or absent input, composing naturally with the `fragment` tagged template's optional-interpolation behaviour. + +The package ships at `0.1.0` to signal pre-stability while the renderer stack settles around it. diff --git a/.changeset/whole-bobcats-dance.md b/.changeset/whole-bobcats-dance.md new file mode 100644 index 000000000..254118b18 --- /dev/null +++ b/.changeset/whole-bobcats-dance.md @@ -0,0 +1,5 @@ +--- +'@codama/renderers-core': patch +--- + +Re-export the language-agnostic fragment primitives, path helpers, filesystem helpers, and `RenderMap` data operations from `@codama/fragments`. The implementations have moved to that package so they can be shared with code generators and other consumers outside the renderers stack; existing importers of `@codama/renderers-core` continue to see every public name at the same import path with no behaviour change. `writeRenderMapVisitor` stays in `renderers-core` since it depends on the visitor + node infrastructure that `@codama/fragments` deliberately does not pull in. diff --git a/packages/fragments/.gitignore b/packages/fragments/.gitignore new file mode 100644 index 000000000..849ddff3b --- /dev/null +++ b/packages/fragments/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/packages/fragments/.prettierignore b/packages/fragments/.prettierignore new file mode 100644 index 000000000..ebf37de71 --- /dev/null +++ b/packages/fragments/.prettierignore @@ -0,0 +1,5 @@ +dist/ +test/e2e/ +test-ledger/ +target/ +CHANGELOG.md diff --git a/packages/fragments/README.md b/packages/fragments/README.md new file mode 100644 index 000000000..9f8d9a7d6 --- /dev/null +++ b/packages/fragments/README.md @@ -0,0 +1,542 @@ +# Codama ➤ Fragments + +[![npm][npm-image]][npm-url] +[![npm-downloads][npm-downloads-image]][npm-url] + +[npm-downloads-image]: https://img.shields.io/npm/dm/@codama/fragments.svg?style=flat +[npm-image]: https://img.shields.io/npm/v/@codama/fragments.svg?style=flat&label=%40codama%2Ffragments +[npm-url]: https://www.npmjs.com/package/@codama/fragments + +This package provides the building blocks Codama renderers and code generators use to compose generated source. The core idea is the `Fragment` — a snippet of code paired with the imports it depends on — and the `fragment` tagged template that lets you stitch fragments together while propagating imports automatically. + +## Installation + +```sh +pnpm install @codama/fragments +``` + +> [!NOTE] +> This package is **not** included in the main [`codama`](../library) package. The language-agnostic core primitives are also re-exported from [`@codama/renderers-core`](../renderers-core) for backward compatibility with existing renderers; new code should import from `@codama/fragments` directly. + +## What's a fragment? + +When you generate code, you usually need to track two things in parallel: the source string itself, and the imports that source depends on. Threading imports through every helper that builds a piece of code becomes painful very quickly. Fragments solve this by carrying both pieces together. + +A fragment, at minimum, is just an object with a `content` string. Each language flavor — JavaScript or Rust — extends this with an `ImportMap`, a symbolic record of what the content imports. + +```ts +import { fragment, use } from '@codama/fragments/javascript'; + +const pubkey = use('type Address', '@solana/kit'); +const interfaceFragment = fragment` +export interface Account { + readonly owner: ${pubkey}; +} +`; + +console.log(interfaceFragment.content); +// export interface Account { +// readonly owner: Address; +// } + +console.log(interfaceFragment.imports); +// Map { '@solana/kit' => Map { 'Address' => { ..., isType: true } } } +``` + +Notice that `pubkey` was a small fragment carrying both the identifier `Address` and an import for it. When `pubkey` was interpolated into `interfaceFragment`, both pieces propagated upwards — the outer fragment ended up with the right content **and** with the import already attached. + +That propagation is the whole point. You can build a fragment for a single field, compose it into a fragment for a struct, compose that into a fragment for a file, and only at the very end stringify the imports into actual `import { … } from '…';` (or `use foo::Bar;`) lines. + +## Subpaths + +The package exposes three subpaths so the JavaScript and Rust flavors don't clash on names like `Fragment`, `ImportMap`, or `fragment`. + +```ts +// Root: language-agnostic core primitives only. +import type { BaseFragment } from '@codama/fragments'; +import { createFragmentTemplate } from '@codama/fragments'; + +// JavaScript / TypeScript flavor. +import { fragment, use } from '@codama/fragments/javascript'; + +// Rust flavor. +import { addFragmentImports, fragment } from '@codama/fragments/rust'; +``` + +If you're writing a renderer that emits JavaScript or TypeScript, you'll spend most of your time in `@codama/fragments/javascript`. If you're emitting Rust, in `@codama/fragments/rust`. Each subpath re-exports the core too, so a single import per file is enough. + +## Core primitives + +The root entrypoint contains a handful of language-agnostic primitives. Most renderers won't import from here directly — the language subpaths re-export everything — but if you're building your own fragment flavor (for a new target language, for example), this is where you start. + +### `BaseFragment` + +The minimal shape every flavored fragment extends. It only requires a `content` string; flavored fragments layer on additional fields like an import map. + +```ts +import type { BaseFragment } from '@codama/fragments'; + +type MyFragment = BaseFragment & Readonly<{ tags: ReadonlySet }>; +``` + +### `createFragmentTemplate` + +The generic engine behind every flavor's `fragment` tagged template. You only need this when defining a new flavor: pass it the template parts, the items, an `isFragment` predicate, and a `mergeFragments` function. Everything else is handled for you. + +```ts +import { createFragmentTemplate } from '@codama/fragments'; + +function fragment(template: TemplateStringsArray, ...items: unknown[]): MyFragment { + return createFragmentTemplate(template, items, isMyFragment, mergeMyFragments); +} +``` + +### `mapFragmentContent` and `mapFragmentContentAsync` + +Apply a content transformation while preserving every other field on the fragment. + +```ts +import { mapFragmentContent } from '@codama/fragments'; + +const upper = mapFragmentContent(myFragment, content => content.toUpperCase()); +``` + +An async variant is available for transformations that return promises (e.g. running Prettier on the content). + +```ts +import { mapFragmentContentAsync } from '@codama/fragments'; + +const formatted = await mapFragmentContentAsync(myFragment, formatWithPrettier); +``` + +### `setFragmentContent` + +Replace a fragment's content outright. + +```ts +import { setFragmentContent } from '@codama/fragments'; + +const replaced = setFragmentContent(myFragment, '/* removed */'); +``` + +## JavaScript flavor + +`@codama/fragments/javascript` provides the concrete machinery for emitting TypeScript and JavaScript source. + +### Composing JavaScript fragments + +The `fragment` tagged template builds a fragment from a string template. Interpolated values that are themselves fragments get inlined and contribute their imports; primitives are coerced to strings. + +```ts +import { fragment } from '@codama/fragments/javascript'; + +const greeting = fragment`hello ${'world'}`; +// greeting.content: 'hello world' +// greeting.imports.size: 0 +``` + +When you need a fragment that references an imported identifier, the `use` helper is the shortcut. It accepts the same shorthand TypeScript accepts inside an `import { … }` block — bare names, `type` prefixes, and `as` aliases all work. + +```ts +import { fragment, use } from '@codama/fragments/javascript'; + +const address = use('type Address', '@solana/kit'); +const owner = use('PublicKey as PK', '@solana/kit'); + +const struct = fragment` +export interface Owner { + readonly address: ${address}; + readonly pubkey: ${owner}; +} +`; +``` + +Both interpolated fragments propagated their imports into `struct`. The outer fragment's import map will now have one entry for `@solana/kit` containing both `Address` (as a type-only import) and `PublicKey as PK`. + +### Common helpers + +Two small helpers cover the most repetitive bits of code generators. + +`getDocblockFragment(lines)` builds a JSDoc block from an array of lines. Empty input returns `undefined`, which composes naturally with the `fragment` tag (interpolated `undefined` renders as the empty string), so optional documentation can be threaded through without explicit checks. Any `*/` sequences inside the lines are silently defanged so user-supplied content cannot accidentally close the comment early. + +```ts +import { fragment, getDocblockFragment } from '@codama/fragments/javascript'; + +const docs = getDocblockFragment(['Greets the user.', '', 'Returns nothing.']); +const fn = fragment`${docs}\nexport function greet(name: string): void {}`; +// /** +// * Greets the user. +// * +// * Returns nothing. +// */ +// export function greet(name: string): void {} +``` + +`getExportAllFragment(module)` builds a re-export line. The fragment carries no imports — `export * from` only forwards bindings out, it does not bring `module` into local scope. + +```ts +import { getExportAllFragment } from '@codama/fragments/javascript'; + +getExportAllFragment('./accounts').content; +// export * from './accounts'; +``` + +### The JavaScript `ImportMap` + +The JavaScript `ImportMap` is a frozen, immutable `ReadonlyMap>`. The outer key is the source module (e.g. `'@solana/kit'`), and the inner key is the identifier as it appears in the consuming code (after aliasing). Every operation returns a new map — there are no methods or mutation. + +The shorthand strings the API accepts follow TypeScript's own: + +| Input | Imported identifier | Used identifier | Type-only? | +| ------------------- | ------------------- | --------------- | ---------- | +| `'Foo'` | `Foo` | `Foo` | no | +| `'type Foo'` | `Foo` | `Foo` | yes | +| `'Foo as Bar'` | `Foo` | `Bar` | no | +| `'type Foo as Bar'` | `Foo` | `Bar` | yes | + +### Building import maps + +#### `createImportMap` + +Returns an empty frozen map. + +```ts +import { createImportMap } from '@codama/fragments/javascript'; + +const map = createImportMap(); +``` + +#### `addToImportMap` + +Returns a new map with extra identifiers attached to a module. When the same identifier appears as both a value and a type-only import — anywhere in a single batch or across merged maps — the value form wins. + +```ts +import { addToImportMap, createImportMap } from '@codama/fragments/javascript'; + +let map = createImportMap(); +map = addToImportMap(map, '@solana/kit', ['Address', 'type SignerKey']); +map = addToImportMap(map, '../shared', ['CamelCaseString']); +``` + +#### `removeFromImportMap` + +Drops identifiers from a module entry. If no identifiers remain, the module entry itself is removed. + +```ts +import { removeFromImportMap } from '@codama/fragments/javascript'; + +const trimmed = removeFromImportMap(map, '@solana/kit', ['SignerKey']); +``` + +#### `mergeImportMaps` + +Combines multiple maps into one, applying the same value-wins rule on identifier conflicts. + +```ts +import { mergeImportMaps } from '@codama/fragments/javascript'; + +const merged = mergeImportMaps([mapA, mapB, mapC]); +``` + +#### `parseImportInput` + +If you ever need to inspect the parsed shape of a shorthand string without putting it in a map, this is the underlying parser. + +```ts +import { parseImportInput } from '@codama/fragments/javascript'; + +parseImportInput('type Foo as Bar'); +// { importedIdentifier: 'Foo', usedIdentifier: 'Bar', isType: true } +``` + +### Attaching imports to a fragment + +The fragment helpers mirror the import-map helpers, except they take a fragment and update its `imports` field in place (returning a new frozen fragment). + +#### `addFragmentImports` + +```ts +import { addFragmentImports, fragment } from '@codama/fragments/javascript'; + +let f = fragment`hello`; +f = addFragmentImports(f, '@solana/kit', ['type Address']); +``` + +#### `mergeFragmentImports` + +Useful when you have a standalone import map (e.g. from a `getExternalDependencies` result) you want to fold into a fragment. + +```ts +import { mergeFragmentImports } from '@codama/fragments/javascript'; + +const updated = mergeFragmentImports(fragment, [extraMapA, extraMapB]); +``` + +#### `removeFragmentImports` + +```ts +import { removeFragmentImports } from '@codama/fragments/javascript'; + +const trimmed = removeFragmentImports(fragment, '@solana/kit', ['SignerKey']); +``` + +### Resolving symbolic modules + +Renderers usually don't write the final module path everywhere. Instead, they accumulate imports under symbolic names like `'solanaAddresses'` or `'generatedAccounts'` and resolve those names to real specifiers at the very end. This keeps the rendering code decoupled from the consumer's package layout. + +#### `resolveImportMap` + +Replaces the keys of a map according to the entries of a dependency record. Keys not present in the record are kept as-is. When two source modules resolve to the same target, their inner identifier maps are merged automatically. + +```ts +import { resolveImportMap } from '@codama/fragments/javascript'; + +const resolved = resolveImportMap(map, { + solanaAddresses: '@solana/kit', + generatedAccounts: '../accounts', +}); +``` + +#### `getExternalDependencies` + +Returns the set of root package names referenced by an import map (after resolution). For scoped packages, the scope is included; for relative imports, nothing is reported. Useful when a renderer needs to sync a generated `package.json` with the imports it actually emitted. + +```ts +import { getExternalDependencies } from '@codama/fragments/javascript'; + +const externals = getExternalDependencies(map, dependencyMap); +// Set { '@solana/kit', '@codama/spec' } +``` + +#### `importMapToString` + +Renders the map as a block of `import { … } from '…';` lines. The dependency record is applied first, then non-relative paths are listed before relative ones, identifiers are alphabetized within each module, and a block-level `import type { … }` form is used when every identifier on the line is type-only. + +```ts +import { importMapToString } from '@codama/fragments/javascript'; + +console.log(importMapToString(map, { solanaAddresses: '@solana/kit' })); +// import type { Address } from '@solana/kit'; +// import { CamelCaseString } from '../shared'; +``` + +## Rust flavor + +`@codama/fragments/rust` mirrors the JavaScript flavor for emitting Rust source. The shape of the import map is different to match Rust's `use` syntax, but the operations follow the same naming and immutability discipline. + +### Composing Rust fragments + +The `fragment` tagged template behaves the same way as on the JavaScript side: interpolated fragments propagate their imports, primitives are coerced to strings. + +```ts +import { fragment } from '@codama/fragments/rust'; + +const struct = fragment` +pub struct AccountNode { + pub pubkey: Pubkey, +} +`; +``` + +Unlike the JavaScript flavor, there is no `use(input, module)` helper. Rust references identifiers inline by their full `::`-qualified path, so building content and attaching imports happen in two steps: write the content with `fragment`, then attach imports with `addFragmentImports`. + +```ts +import { addFragmentImports, fragment } from '@codama/fragments/rust'; + +let body = fragment` +pub struct AccountNode { + pub pubkey: Pubkey, +} +`; +body = addFragmentImports(body, ['solana_program::pubkey::Pubkey']); +``` + +When you need an alias, use `addFragmentImportAlias`. + +```ts +import { addFragmentImportAlias, addFragmentImports, fragment } from '@codama/fragments/rust'; + +let body = fragment`pub fn fail() -> ProgError { ProgError::InvalidArgument }`; +body = addFragmentImports(body, ['solana_program::program_error::ProgramError']); +body = addFragmentImportAlias(body, 'solana_program::program_error::ProgramError', 'ProgError'); +``` + +### Common helpers + +`getDocblockFragment(lines)` builds a Rust doc-comment block. Empty input returns `undefined`, which composes naturally with the `fragment` tag (interpolated `undefined` renders as the empty string), so optional documentation can be threaded through without explicit checks. Pass `{ internal: true }` to emit inner doc comments (`//!`) instead of outer ones (`///`). + +```ts +import { fragment, getDocblockFragment } from '@codama/fragments/rust'; + +const docs = getDocblockFragment(['Greets the user.', '', 'Returns nothing.']); +const fn = fragment`${docs}\npub fn greet(name: &str) {}`; +// /// Greets the user. +// /// +// /// Returns nothing. +// pub fn greet(name: &str) {} + +const moduleDocs = getDocblockFragment(['Crate-level docs.'], { internal: true }); +// //! Crate-level docs. +``` + +### The Rust `ImportMap` + +The Rust `ImportMap` is a flat, frozen `ReadonlyMap`. The key is the fully-qualified `::`-separated path; the value is `{ importedPath, alias? }`. There is no per-module grouping the way JavaScript imports have, because each Rust `use` statement names a single path. + +Like the JavaScript flavor, every operation returns a new map. There are no methods, no classes, no mutation. + +### Building import maps + +#### `createImportMap` + +```ts +import { createImportMap } from '@codama/fragments/rust'; + +const map = createImportMap(); +``` + +#### `addToImportMap` + +Appends one or more paths. Accepts a single string, an array, or a `Set`. Paths already present in the map are skipped, so any existing aliases are preserved. + +```ts +import { addToImportMap, createImportMap } from '@codama/fragments/rust'; + +let map = createImportMap(); +map = addToImportMap(map, ['borsh::BorshDeserialize', 'borsh::BorshSerialize']); +map = addToImportMap(map, 'solana_program::pubkey::Pubkey'); +``` + +#### `addAliasToImportMap` + +Records an alias for a path. If the path isn't yet imported, it is added; if it's already present, the alias replaces any existing one. + +```ts +import { addAliasToImportMap } from '@codama/fragments/rust'; + +const aliased = addAliasToImportMap(map, 'solana_program::program_error::ProgramError', 'ProgError'); +``` + +#### `removeFromImportMap` + +```ts +import { removeFromImportMap } from '@codama/fragments/rust'; + +const trimmed = removeFromImportMap(map, 'borsh::BorshDeserialize'); +``` + +#### `mergeImportMaps` + +Combines multiple maps. When the same path appears in more than one map, the latest occurrence wins on alias conflicts. + +```ts +import { mergeImportMaps } from '@codama/fragments/rust'; + +const merged = mergeImportMaps([mapA, mapB]); +``` + +### Attaching imports to a fragment + +#### `addFragmentImports` + +```ts +import { addFragmentImports, fragment } from '@codama/fragments/rust'; + +let f = fragment`Pubkey`; +f = addFragmentImports(f, 'solana_program::pubkey::Pubkey'); +``` + +#### `addFragmentImportAlias` + +```ts +import { addFragmentImportAlias } from '@codama/fragments/rust'; + +const aliased = addFragmentImportAlias(f, 'solana_program::pubkey::Pubkey', 'Pk'); +``` + +#### `mergeFragmentImports` + +```ts +import { mergeFragmentImports } from '@codama/fragments/rust'; + +const updated = mergeFragmentImports(f, [extraMap]); +``` + +#### `removeFragmentImports` + +```ts +import { removeFragmentImports } from '@codama/fragments/rust'; + +const trimmed = removeFragmentImports(f, 'borsh::BorshDeserialize'); +``` + +### Resolving symbolic prefixes + +Where the JavaScript flavor resolves entire module names, the Rust flavor resolves `::`-prefixes. This matches how Rust crate paths compose: a renderer might accumulate imports under `'generated::accounts::Foo'` and resolve `generated` to `crate::generated` at the end. + +#### `resolveImportMap` + +```ts +import { resolveImportMap } from '@codama/fragments/rust'; + +const resolved = resolveImportMap(map, { generated: 'crate::generated' }); +// 'generated::accounts::Foo' -> 'crate::generated::accounts::Foo' +``` + +#### `getExternalDependencies` + +Returns the set of top-level crate names referenced by the map after resolution. Rust's standard-library crates and crate-local prefixes are filtered out via `RUST_CORE_IMPORTS` (`std`, `crate`, `self`, `super`, `core`, `alloc`, `clippy`). + +```ts +import { getExternalDependencies } from '@codama/fragments/rust'; + +const externals = getExternalDependencies(map, { generated: 'crate::generated' }); +// Set { 'borsh', 'solana_program' } +``` + +#### `importMapToString` + +Renders the map as a block of `use foo::Bar;` (and `use foo::Bar as Baz;`) lines, alphabetized for stable diffs and resolved against the dependency record. + +```ts +import { importMapToString } from '@codama/fragments/rust'; + +console.log(importMapToString(map, { generated: 'crate::generated' })); +// use borsh::BorshSerialize; +// use crate::generated::accounts::Foo; +// use solana_program::pubkey::Pubkey; +``` + +## Putting it all together + +Here's a tiny end-to-end example: a generator that emits a TypeScript interface for an account, given the account's name and the type of its `owner` field. + +```ts +import { fragment, importMapToString, use } from '@codama/fragments/javascript'; + +function emitAccountInterface(name: string, ownerModule: string): string { + const owner = use(`type ${name}OwnerKey`, ownerModule); + const body = fragment` +export interface ${name}Account { + readonly owner: ${owner}; +} +`; + const importBlock = importMapToString(body.imports); + return `${importBlock}\n\n${body.content.trim()}\n`; +} + +console.log(emitAccountInterface('Mint', '@solana/kit')); +// import type { MintOwnerKey } from '@solana/kit'; +// +// export interface MintAccount { +// readonly owner: MintOwnerKey; +// } +``` + +The interesting bit is what we _didn't_ have to do: at no point did `emitAccountInterface` keep a list of imports next to the content. The `use` helper attached the import to a small fragment; interpolation propagated it; `importMapToString` rendered it at the end. + +Real generators take this further. They build dozens of small fragments per file, compose them into pages, and use `resolveImportMap` to translate symbolic module names into the consumer's actual package layout. The same patterns apply on the Rust side — replace `use` with explicit `addFragmentImports` calls and `importMapToString` will emit `use foo::Bar;` lines instead. + +## Related packages + +- [`@codama/renderers-core`](../renderers-core) — re-exports the language-agnostic core for backward compatibility with existing renderers. diff --git a/packages/fragments/package.json b/packages/fragments/package.json new file mode 100644 index 000000000..a2702a19c --- /dev/null +++ b/packages/fragments/package.json @@ -0,0 +1,106 @@ +{ + "name": "@codama/fragments", + "version": "0.0.0", + "description": "Composable code-generation fragments and language-aware import maps for Codama renderers", + "exports": { + ".": { + "types": "./dist/types/index.d.ts", + "react-native": "./dist/index.react-native.mjs", + "browser": { + "import": "./dist/index.browser.mjs", + "require": "./dist/index.browser.cjs" + }, + "node": { + "import": "./dist/index.node.mjs", + "require": "./dist/index.node.cjs" + } + }, + "./javascript": { + "types": "./dist/types/javascript/index.d.ts", + "react-native": "./dist/javascript.react-native.mjs", + "browser": { + "import": "./dist/javascript.browser.mjs", + "require": "./dist/javascript.browser.cjs" + }, + "node": { + "import": "./dist/javascript.node.mjs", + "require": "./dist/javascript.node.cjs" + } + }, + "./rust": { + "types": "./dist/types/rust/index.d.ts", + "react-native": "./dist/rust.react-native.mjs", + "browser": { + "import": "./dist/rust.browser.mjs", + "require": "./dist/rust.browser.cjs" + }, + "node": { + "import": "./dist/rust.node.mjs", + "require": "./dist/rust.node.cjs" + } + } + }, + "browser": { + "./dist/index.node.cjs": "./dist/index.browser.cjs", + "./dist/index.node.mjs": "./dist/index.browser.mjs", + "./dist/javascript.node.cjs": "./dist/javascript.browser.cjs", + "./dist/javascript.node.mjs": "./dist/javascript.browser.mjs", + "./dist/rust.node.cjs": "./dist/rust.browser.cjs", + "./dist/rust.node.mjs": "./dist/rust.browser.mjs" + }, + "main": "./dist/index.node.cjs", + "module": "./dist/index.node.mjs", + "react-native": "./dist/index.react-native.mjs", + "types": "./dist/types/index.d.ts", + "type": "commonjs", + "files": [ + "./dist/types", + "./dist/index.*", + "./dist/javascript.*", + "./dist/rust.*" + ], + "typesVersions": { + "*": { + "javascript": [ + "./dist/types/javascript/index.d.ts" + ], + "rust": [ + "./dist/types/rust/index.d.ts" + ] + } + }, + "sideEffects": false, + "keywords": [ + "codama", + "code-generation", + "fragments", + "framework", + "import-maps", + "solana" + ], + "scripts": { + "build": "rimraf dist && tsup && tsc -p ./tsconfig.declarations.json", + "dev": "vitest --project node", + "lint": "eslint . && prettier --check .", + "lint:fix": "eslint --fix . && prettier --write .", + "test": "pnpm test:types && pnpm test:treeshakability && pnpm test:unit", + "test:treeshakability": "for file in dist/index.*.mjs dist/javascript.*.mjs dist/rust.*.mjs; do agadoo $file; done", + "test:types": "tsc --noEmit", + "test:unit": "vitest run" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/codama-idl/codama" + }, + "bugs": { + "url": "http://github.com/codama-idl/codama/issues" + }, + "browserslist": [ + "supports bigint and not dead", + "maintained node versions" + ], + "dependencies": { + "@codama/errors": "workspace:*" + } +} diff --git a/packages/fragments/src/core/BaseFragment.ts b/packages/fragments/src/core/BaseFragment.ts new file mode 100644 index 000000000..8262d74b9 --- /dev/null +++ b/packages/fragments/src/core/BaseFragment.ts @@ -0,0 +1,18 @@ +/** + * The minimal shape every renderer's `Fragment` extends. + * + * A fragment is the unit of generated source — a string of code along with + * any metadata (e.g. imports, features) the renderer wants to attach. The + * `content` field is the only thing every flavor agrees on; everything else + * is layered on top by the language-specific subpaths + * (`@codama/fragments/javascript`, `@codama/fragments/rust`) or by the + * renderers themselves. + * + * @example + * ```ts + * import type { BaseFragment } from '@codama/fragments'; + * + * type MyFragment = BaseFragment & Readonly<{ tags: ReadonlySet }>; + * ``` + */ +export type BaseFragment = Readonly<{ content: string }>; diff --git a/packages/fragments/src/core/casing.ts b/packages/fragments/src/core/casing.ts new file mode 100644 index 000000000..0bebdd82b --- /dev/null +++ b/packages/fragments/src/core/casing.ts @@ -0,0 +1,77 @@ +/** + * String-casing helpers used by code generators when emitting + * identifiers. They normalise an arbitrary input string into a + * conventional shape (camelCase, PascalCase, kebab-case, snake_case, + * Title Case) by inserting word boundaries before uppercase letters and + * splitting on any sequence of non-alphanumeric characters. + * + * Returned values are plain `string`. This package deliberately does + * not depend on `@codama/node-types`, so the branded `CamelCaseString` + * / `PascalCaseString` / … types are not applied here. Consumers that + * want the brand (e.g. `@codama/nodes`) can wrap these helpers and + * apply the cast at their own boundary. + * + * The implementations all run through {@link titleCase} as a common + * intermediate form, so a single deterministic word-splitting policy + * is shared across every output shape. + */ + +/** + * Uppercase the first character and lowercase the rest. Returns the + * input unchanged when it is empty. + */ +export function capitalize(str: string): string { + if (str.length === 0) return str; + return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); +} + +/** + * Normalise an arbitrary string into Title Case — a space-separated + * sequence of {@link capitalize}d words. Inserts a space before each + * uppercase letter, then splits on any run of non-alphanumeric + * characters and re-joins with single spaces. + */ +export function titleCase(str: string): string { + return str + .replace(/([A-Z])/g, ' $1') + .split(/[^a-zA-Z0-9]+/) + .filter(word => word.length > 0) + .map(capitalize) + .join(' '); +} + +/** + * Normalise an arbitrary string into PascalCase by stripping the + * spaces from its {@link titleCase} form. + */ +export function pascalCase(str: string): string { + return titleCase(str).split(' ').join(''); +} + +/** + * Normalise an arbitrary string into camelCase by lowercasing the + * first character of its {@link pascalCase} form. + */ +export function camelCase(str: string): string { + if (str.length === 0) return str; + const pascalStr = pascalCase(str); + return pascalStr.charAt(0).toLowerCase() + pascalStr.slice(1); +} + +/** + * Normalise an arbitrary string into kebab-case — lowercase words + * joined with `-` — by replacing the spaces in its {@link titleCase} + * form. + */ +export function kebabCase(str: string): string { + return titleCase(str).split(' ').join('-').toLowerCase(); +} + +/** + * Normalise an arbitrary string into snake_case — lowercase words + * joined with `_` — by replacing the spaces in its {@link titleCase} + * form. + */ +export function snakeCase(str: string): string { + return titleCase(str).split(' ').join('_').toLowerCase(); +} diff --git a/packages/fragments/src/core/createFragmentTemplate.ts b/packages/fragments/src/core/createFragmentTemplate.ts new file mode 100644 index 000000000..778b1c0a1 --- /dev/null +++ b/packages/fragments/src/core/createFragmentTemplate.ts @@ -0,0 +1,52 @@ +import type { BaseFragment } from './BaseFragment'; + +/** + * Generic template-tag implementation used by every flavor's `fragment` + * tagged template. + * + * Walks the template/items pair, interpolating fragments verbatim and + * coercing other values to strings, then defers to the caller-provided + * `mergeFragments` for combining the surviving sub-fragments. This keeps the + * fragment shape (imports, features, etc.) opaque to the core layer; each + * flavor plugs in its own merge logic. + * + * Most consumers never call this directly — they use the `fragment` tag + * exported by `@codama/fragments/javascript` or `@codama/fragments/rust`, + * which both wrap this helper. + * + * @typeParam TFragment - The concrete fragment type. Must extend {@link BaseFragment}. + * @param template - The template-strings array supplied by the tag call site. + * @param items - The interpolated values, in order. May be fragments, + * strings, numbers, booleans, `undefined`, or anything coercible to a string. + * @param isFragment - A predicate that identifies values of the concrete + * fragment type so they can be inlined and forwarded to the merger. + * @param mergeFragments - The flavor-specific merger that knows how to + * combine fragments' non-content fields (e.g. imports, features). Receives + * only the sub-fragments found in the template, plus a callback that + * produces the final merged content string from each sub-fragment's content. + * @return The fragment produced by `mergeFragments`. + * + * @example + * ```ts + * import { createFragmentTemplate } from '@codama/fragments'; + * + * function fragment(template: TemplateStringsArray, ...items: unknown[]) { + * return createFragmentTemplate(template, items, isFragment, mergeFragments); + * } + * ``` + */ +export function createFragmentTemplate( + template: TemplateStringsArray, + items: unknown[], + isFragment: (value: unknown) => value is TFragment, + mergeFragments: (fragments: TFragment[], mergeContent: (contents: string[]) => string) => TFragment, +): TFragment { + const fragments = items.filter(isFragment); + const zippedItems = items.map((item, i) => { + const itemPrefix = template[i]; + if (typeof item === 'undefined') return itemPrefix; + if (isFragment(item)) return itemPrefix + item.content; + return itemPrefix + String(item as string); + }); + return mergeFragments(fragments, () => zippedItems.join('') + template[template.length - 1]); +} diff --git a/packages/renderers-core/src/fs.ts b/packages/fragments/src/core/fs.ts similarity index 66% rename from packages/renderers-core/src/fs.ts rename to packages/fragments/src/core/fs.ts index d9230b2f3..793ea3fbe 100644 --- a/packages/renderers-core/src/fs.ts +++ b/packages/fragments/src/core/fs.ts @@ -1,9 +1,18 @@ +/** + * Node-only filesystem helpers used by code generators to write their + * output to disk. Each function checks the `__NODEJS__` build flag and + * throws a structured {@link CodamaError} on non-Node platforms so + * accidental calls from a browser bundle fail loudly rather than + * silently no-oping. + */ + import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { CODAMA_ERROR__NODE_FILESYSTEM_FUNCTION_UNAVAILABLE, CodamaError } from '@codama/errors'; import { Path, pathDirectory } from './path'; +/** Create a directory (and any missing parents) at the given path. */ export function createDirectory(path: Path): void { if (!__NODEJS__) { throw new CodamaError(CODAMA_ERROR__NODE_FILESYSTEM_FUNCTION_UNAVAILABLE, { fsFunction: 'mkdirSync' }); @@ -12,6 +21,7 @@ export function createDirectory(path: Path): void { mkdirSync(path, { recursive: true }); } +/** Recursively delete the directory at the given path, if it exists. */ export function deleteDirectory(path: Path): void { if (!__NODEJS__) { throw new CodamaError(CODAMA_ERROR__NODE_FILESYSTEM_FUNCTION_UNAVAILABLE, { fsFunction: 'rmSync' }); @@ -22,6 +32,10 @@ export function deleteDirectory(path: Path): void { } } +/** + * Write `content` to a file at `path`, creating intermediate + * directories as needed. + */ export function writeFile(path: Path, content: string): void { if (!__NODEJS__) { throw new CodamaError(CODAMA_ERROR__NODE_FILESYSTEM_FUNCTION_UNAVAILABLE, { fsFunction: 'writeFileSync' }); @@ -34,6 +48,7 @@ export function writeFile(path: Path, content: string): void { writeFileSync(path, content); } +/** Check whether a file or directory exists at the given path. */ export function fileExists(path: Path): boolean { if (!__NODEJS__) { throw new CodamaError(CODAMA_ERROR__NODE_FILESYSTEM_FUNCTION_UNAVAILABLE, { fsFunction: 'existsSync' }); @@ -42,6 +57,7 @@ export function fileExists(path: Path): boolean { return existsSync(path); } +/** Read the file at the given path as a UTF-8 string. */ export function readFile(path: Path): string { if (!__NODEJS__) { throw new CodamaError(CODAMA_ERROR__NODE_FILESYSTEM_FUNCTION_UNAVAILABLE, { fsFunction: 'readFileSync' }); @@ -50,6 +66,11 @@ export function readFile(path: Path): string { return readFileSync(path, 'utf-8'); } +/** + * Read the file at the given path as a UTF-8 string and parse it as + * JSON. The result is typed as the caller-supplied `T`; no runtime + * validation is performed. + */ export function readJson(path: Path): T { return JSON.parse(readFile(path)) as T; } diff --git a/packages/fragments/src/core/index.ts b/packages/fragments/src/core/index.ts new file mode 100644 index 000000000..aed344dda --- /dev/null +++ b/packages/fragments/src/core/index.ts @@ -0,0 +1,8 @@ +export * from './BaseFragment'; +export * from './casing'; +export * from './createFragmentTemplate'; +export * from './fs'; +export * from './mapFragmentContent'; +export * from './path'; +export * from './renderMap'; +export * from './setFragmentContent'; diff --git a/packages/fragments/src/core/mapFragmentContent.ts b/packages/fragments/src/core/mapFragmentContent.ts new file mode 100644 index 000000000..200808cc8 --- /dev/null +++ b/packages/fragments/src/core/mapFragmentContent.ts @@ -0,0 +1,55 @@ +import type { BaseFragment } from './BaseFragment'; +import { setFragmentContent } from './setFragmentContent'; + +/** + * Apply a synchronous transformation to a fragment's `content`, returning a + * new fragment with every other field preserved. + * + * @typeParam TFragment - The concrete fragment type. Must extend {@link BaseFragment}. + * @param fragment - The source fragment. + * @param mapContent - A function that receives the current content and + * returns the new content. + * @return A frozen fragment with the transformed content. + * + * @example + * ```ts + * import { mapFragmentContent } from '@codama/fragments'; + * + * const trimmed = mapFragmentContent(fragment, c => c.trimEnd()); + * ``` + * + * @see {@link mapFragmentContentAsync} for the async variant. + */ +export function mapFragmentContent( + fragment: TFragment, + mapContent: (content: string) => string, +): TFragment { + return setFragmentContent(fragment, mapContent(fragment.content)); +} + +/** + * Async variant of {@link mapFragmentContent}: apply an async transformation + * to a fragment's `content`. + * + * @typeParam TFragment - The concrete fragment type. Must extend {@link BaseFragment}. + * @param fragment - The source fragment. + * @param mapContent - An async function that receives the current content + * and returns a promise resolving to the new content. + * @return A promise that resolves to a frozen fragment with the transformed + * content. + * + * @example + * ```ts + * import { mapFragmentContentAsync } from '@codama/fragments'; + * + * const formatted = await mapFragmentContentAsync(fragment, formatWithPrettier); + * ``` + * + * @see {@link mapFragmentContent} for the sync variant. + */ +export async function mapFragmentContentAsync( + fragment: TFragment, + mapContent: (content: string) => Promise, +): Promise { + return setFragmentContent(fragment, await mapContent(fragment.content)); +} diff --git a/packages/fragments/src/core/path.ts b/packages/fragments/src/core/path.ts new file mode 100644 index 000000000..73dbc1cb2 --- /dev/null +++ b/packages/fragments/src/core/path.ts @@ -0,0 +1,76 @@ +/** + * Path manipulation helpers used by code generators to assemble output + * file paths. + * + * The {@link Path} type is a thin documentation alias for `string` — + * any place a generator stores or threads a filesystem-relative path, + * use this name to communicate intent. The {@link joinPath} and + * {@link pathDirectory} helpers delegate to `node:path` on Node and + * fall back to lightweight string manipulation on non-Node platforms. + */ + +import { basename, dirname, join, posix } from 'node:path'; + +import { CODAMA_ERROR__NODE_FILESYSTEM_FUNCTION_UNAVAILABLE, CodamaError } from '@codama/errors'; + +/** A filesystem path inside the generator's output tree. */ +export type Path = string; + +/** + * Join two or more path segments together. Uses `node:path`'s + * platform-aware {@link join} on Node; on other platforms (browser, + * react-native) falls back to a `/`-joined form with consecutive + * slashes collapsed. + */ +export function joinPath(...paths: Path[]): string { + if (!__NODEJS__) { + return paths.join('/').replace(/\/+/g, '/'); + } + + return join(...paths); +} + +/** + * Return the directory portion of a path (i.e. everything up to the + * last `/` segment). Uses `node:path`'s {@link dirname} on Node and a + * plain `lastIndexOf` fallback on other platforms. + */ +export function pathDirectory(path: Path): Path { + if (!__NODEJS__) { + return path.substring(0, path.lastIndexOf('/')); + } + + return dirname(path); +} + +/** + * Return the trailing segment of a path (everything after the last + * `/`). Uses `node:path`'s {@link basename} on Node and a plain + * `lastIndexOf` fallback on other platforms. + */ +export function pathBasename(path: Path): Path { + if (!__NODEJS__) { + const slash = path.lastIndexOf('/'); + return slash >= 0 ? path.substring(slash + 1) : path; + } + + return basename(path); +} + +/** + * Compute the POSIX-style relative path from `from` to `to`. Both + * arguments are treated as `/`-separated logical paths regardless of + * platform, so the result is consistent across operating systems — + * suitable for emitting into source code as an import specifier. + * + * Node only: non-Node platforms throw {@link CodamaError} because + * implementing a correct POSIX relative-path algorithm without + * `node:path` is non-trivial and no current consumer needs it. + */ +export function relativePath(from: Path, to: Path): string { + if (!__NODEJS__) { + throw new CodamaError(CODAMA_ERROR__NODE_FILESYSTEM_FUNCTION_UNAVAILABLE, { fsFunction: 'relative' }); + } + + return posix.relative(from, to); +} diff --git a/packages/fragments/src/core/renderMap.ts b/packages/fragments/src/core/renderMap.ts new file mode 100644 index 000000000..30d4c477b --- /dev/null +++ b/packages/fragments/src/core/renderMap.ts @@ -0,0 +1,168 @@ +/** + * A `RenderMap` is the in-memory data structure a code generator builds + * up before writing anything to disk: a frozen `ReadonlyMap` keyed by + * output path, with a `BaseFragment` (or a concrete subtype carrying + * imports / features / …) as the value. The helpers in this module are + * pure data operations — they construct, merge, transform, and query + * render maps without touching the filesystem. + * + * {@link writeRenderMap} is the single filesystem-touching entry point + * here: it walks a finished map and writes every entry. Renderers that + * tie a render map to a `Visitor` (see `@codama/renderers-core`) layer + * that on top. + */ + +import { CODAMA_ERROR__VISITORS__RENDER_MAP_KEY_NOT_FOUND, CodamaError } from '@codama/errors'; + +import type { BaseFragment } from './BaseFragment'; +import { writeFile } from './fs'; +import { mapFragmentContent, mapFragmentContentAsync } from './mapFragmentContent'; +import { joinPath, type Path } from './path'; + +/** + * A frozen map keyed by output {@link Path}, with each entry holding a + * fragment that will be written to that path. `TFragment` defaults to + * {@link BaseFragment} but generators typically pass a richer flavor + * (e.g. `Fragment` from `@codama/fragments/javascript`) to carry + * imports and other per-file metadata. + */ +export type RenderMap = ReadonlyMap; + +export function createRenderMap(): RenderMap; +export function createRenderMap(path: Path, content: TFragment): RenderMap; +export function createRenderMap( + entries: Record, +): RenderMap; +export function createRenderMap( + pathOrEntries?: Path | Record, + content?: TFragment, +): RenderMap { + let entries: [Path, TFragment][] = []; + if (typeof pathOrEntries === 'string' && content !== undefined) { + entries = [[pathOrEntries, content]]; + } else if (typeof pathOrEntries === 'object' && pathOrEntries !== null) { + entries = Object.entries(pathOrEntries).flatMap(([key, value]) => + value === undefined ? [] : ([[key, value]] as const), + ); + } + return Object.freeze(new Map(entries)); +} + +/** Add or overwrite a single `(path, fragment)` entry. */ +export function addToRenderMap( + renderMap: RenderMap, + path: Path, + content: TFragment, +): RenderMap { + return mergeRenderMaps([renderMap, createRenderMap(path, content)]); +} + +/** Remove the entry at `path`, returning a new frozen map. */ +export function removeFromRenderMap( + renderMap: RenderMap, + path: Path, +): RenderMap { + const newMap = new Map(renderMap); + newMap.delete(path); + return Object.freeze(newMap); +} + +/** + * Combine multiple render maps into one. Later maps overwrite earlier + * entries at the same path. + */ +export function mergeRenderMaps( + renderMaps: RenderMap[], +): RenderMap { + if (renderMaps.length === 0) return createRenderMap(); + if (renderMaps.length === 1) return renderMaps[0]; + const merged = new Map(renderMaps[0]); + for (const map of renderMaps.slice(1)) { + for (const [key, value] of map) { + merged.set(key, value); + } + } + return Object.freeze(merged); +} + +/** Transform every fragment in the map, preserving the keys. */ +export function mapRenderMapFragment( + renderMap: RenderMap, + fn: (fragment: TFragment, path: Path) => TFragment, +): RenderMap { + return Object.freeze(new Map([...[...renderMap.entries()].map(([key, value]) => [key, fn(value, key)] as const)])); +} + +/** Async variant of {@link mapRenderMapFragment}. */ +export async function mapRenderMapFragmentAsync( + renderMap: RenderMap, + fn: (fragment: TFragment, path: Path) => Promise, +): Promise> { + return Object.freeze( + new Map( + await Promise.all([ + ...[...renderMap.entries()].map(async ([key, value]) => [key, await fn(value, key)] as const), + ]), + ), + ); +} + +/** Transform the `content` of every fragment in the map. */ +export function mapRenderMapContent( + renderMap: RenderMap, + fn: (content: string, path: Path) => string, +): RenderMap { + return mapRenderMapFragment(renderMap, (fragment, path) => + mapFragmentContent(fragment, content => fn(content, path)), + ); +} + +/** Async variant of {@link mapRenderMapContent}. */ +export async function mapRenderMapContentAsync( + renderMap: RenderMap, + fn: (content: string, path: Path) => Promise, +): Promise> { + return await mapRenderMapFragmentAsync(renderMap, (fragment, path) => + mapFragmentContentAsync(fragment, content => fn(content, path)), + ); +} + +/** + * Look up the fragment at `path`, throwing a structured + * {@link CodamaError} when the key is missing. + */ +export function getFromRenderMap( + renderMap: RenderMap, + path: Path, +): TFragment { + const value = renderMap.get(path); + if (value === undefined) { + throw new CodamaError(CODAMA_ERROR__VISITORS__RENDER_MAP_KEY_NOT_FOUND, { key: path }); + } + return value; +} + +/** + * Test whether the fragment at `path` contains `value`. Accepts either + * a plain substring or a regular expression. + */ +export function renderMapContains( + renderMap: RenderMap, + path: Path, + value: RegExp | string, +): boolean { + const { content } = getFromRenderMap(renderMap, path); + return typeof value === 'string' ? content.includes(value) : value.test(content); +} + +/** + * Walk the render map and write every entry to disk, rooted at + * `basePath`. Each path is joined with `basePath` via {@link joinPath} + * and written via {@link writeFile}; the directory structure is + * created on demand. + */ +export function writeRenderMap(renderMap: RenderMap, basePath: Path): void { + renderMap.forEach(({ content }, relativePath) => { + writeFile(joinPath(basePath, relativePath), content); + }); +} diff --git a/packages/fragments/src/core/setFragmentContent.ts b/packages/fragments/src/core/setFragmentContent.ts new file mode 100644 index 000000000..6ce8e23f4 --- /dev/null +++ b/packages/fragments/src/core/setFragmentContent.ts @@ -0,0 +1,25 @@ +import type { BaseFragment } from './BaseFragment'; + +/** + * Return a new frozen fragment whose `content` field has been replaced with + * `content`, preserving every other field of the input fragment. + * + * The output keeps the input's exact concrete type (`TFragment`) so callers + * never lose any extra fields a flavored fragment may carry (imports, + * features, etc.). + * + * @typeParam TFragment - The concrete fragment type. Must extend {@link BaseFragment}. + * @param fragment - The source fragment to copy. + * @param content - The new code string. + * @return A frozen fragment of the same shape as `fragment` with `content` replaced. + * + * @example + * ```ts + * import { setFragmentContent } from '@codama/fragments'; + * + * const next = setFragmentContent(prev, prev.content.toUpperCase()); + * ``` + */ +export function setFragmentContent(fragment: TFragment, content: string): TFragment { + return Object.freeze({ ...fragment, content }); +} diff --git a/packages/fragments/src/index.ts b/packages/fragments/src/index.ts new file mode 100644 index 000000000..14a9754c9 --- /dev/null +++ b/packages/fragments/src/index.ts @@ -0,0 +1,17 @@ +/** + * `@codama/fragments` + * + * The root entrypoint exposes only the language-agnostic core primitives + * (`BaseFragment`, `createFragmentTemplate`, `mapFragmentContent`, etc.). + * Language-aware code — concrete `Fragment` types, `ImportMap` shapes, and + * `fragment` tagged templates — lives under language subpaths: + * + * import { fragment, use, renderPage } from '@codama/fragments/javascript'; + * import { fragment, addFragmentImports } from '@codama/fragments/rust'; + * + * Generators that don't need language-specific behavior — for example, + * because they only manipulate the shared `BaseFragment` shape — should + * import from this root entrypoint. + */ + +export * from './core'; diff --git a/packages/fragments/src/javascript/ImportMap.ts b/packages/fragments/src/javascript/ImportMap.ts new file mode 100644 index 000000000..ad682f18b --- /dev/null +++ b/packages/fragments/src/javascript/ImportMap.ts @@ -0,0 +1,84 @@ +/** + * The data model used by `@codama/fragments/javascript` to track imports + * symbolically until they are stringified into actual `import { … } from '…';` + * lines. + * + * Imports are accumulated as a `ReadonlyMap>`: + * outer key is the source module, inner key is the identifier used in the + * consuming code (which may differ from the imported name via aliasing). + * + * The format accepted by {@link parseImportInput} is a single string with + * the same shorthand TypeScript itself uses inside an `import { … }` block: + * + * `Foo` — value import; used as `Foo` + * `type Foo` — type-only import; used as `Foo` + * `Foo as Bar` — value import aliased; used as `Bar` + * `type Foo as Bar` — type-only import aliased; used as `Bar` + */ + +/** A single import shorthand string. See the file-level docblock for the accepted forms. */ +export type ImportInput = string; + +/** A module path: either an absolute (`@scope/pkg`, `pkg`, `pkg/sub`) or a relative (`./foo`, `../bar`) specifier. */ +export type Module = string; + +/** The identifier as it appears in the consuming code (after aliasing). */ +export type UsedIdentifier = string; + +/** A parsed import shorthand. */ +export interface ImportInfo { + readonly importedIdentifier: string; + readonly isType: boolean; + readonly usedIdentifier: UsedIdentifier; +} + +/** A symbolic import map keyed by module, then by used identifier. */ +export type ImportMap = ReadonlyMap>; + +/** + * Construct an empty, frozen import map. + * + * @return A new {@link ImportMap} with no entries. + * + * @example + * ```ts + * import { createImportMap } from '@codama/fragments/javascript'; + * + * const empty = createImportMap(); + * ``` + */ +export function createImportMap(): ImportMap { + return Object.freeze(new Map()); +} + +/** + * Parse a single import shorthand (e.g. `'type Foo as Bar'`) into a + * structured {@link ImportInfo}. + * + * @param input - The shorthand string to parse. + * @return The parsed info. If the shorthand can't be matched (e.g. it + * contains illegal whitespace), the entire string is returned unchanged as + * both the imported and used identifier, with `isType: false`. + * + * @example + * ```ts + * parseImportInput('Foo'); // { importedIdentifier: 'Foo', usedIdentifier: 'Foo', isType: false } + * parseImportInput('type Foo as Bar'); // { importedIdentifier: 'Foo', usedIdentifier: 'Bar', isType: true } + * ``` + */ +export function parseImportInput(input: ImportInput): ImportInfo { + const matches = /^(type )?([^ ]+)(?: as (.+))?$/.exec(input); + if (!matches) { + return Object.freeze({ + importedIdentifier: input, + isType: false, + usedIdentifier: input, + }); + } + const [, isType, name, alias] = matches; + return Object.freeze({ + importedIdentifier: name, + isType: !!isType, + usedIdentifier: alias ?? name, + }); +} diff --git a/packages/fragments/src/javascript/addToImportMap.ts b/packages/fragments/src/javascript/addToImportMap.ts new file mode 100644 index 000000000..d8ab6ab93 --- /dev/null +++ b/packages/fragments/src/javascript/addToImportMap.ts @@ -0,0 +1,38 @@ +import type { ImportInfo, ImportInput, ImportMap, Module, UsedIdentifier } from './ImportMap'; +import { parseImportInput } from './ImportMap'; +import { mergeImportMaps, preferIncoming } from './mergeImportMaps'; + +/** + * Append imports to a module entry, returning a new frozen import map. The + * input map is not mutated. + * + * Within a single batch, the same conflict-resolution rule that + * {@link mergeImportMaps} uses is applied — a value import always wins over + * a type-only import of the same identifier — so callers don't have to + * worry about input order when passing both. + * + * @param importMap - The import map to extend. + * @param module - The source module the imports come from. + * @param imports - The shorthand strings to add. Empty array short-circuits + * and returns `importMap` unchanged. + * @return A frozen import map that includes the new entries. + * + * @example + * ```ts + * import { addToImportMap, createImportMap } from '@codama/fragments/javascript'; + * + * const map = addToImportMap(createImportMap(), './foo', ['type Foo', 'Bar']); + * ``` + */ +export function addToImportMap(importMap: ImportMap, module: Module, imports: ImportInput[]): ImportMap { + if (imports.length === 0) return importMap; + const moduleMap = new Map(); + for (const input of imports) { + const info = parseImportInput(input); + const existing = moduleMap.get(info.usedIdentifier); + if (preferIncoming(existing, info)) { + moduleMap.set(info.usedIdentifier, info); + } + } + return mergeImportMaps([importMap, new Map([[module, moduleMap]])]); +} diff --git a/packages/fragments/src/javascript/fragment.ts b/packages/fragments/src/javascript/fragment.ts new file mode 100644 index 000000000..d8181df67 --- /dev/null +++ b/packages/fragments/src/javascript/fragment.ts @@ -0,0 +1,177 @@ +import type { BaseFragment } from '../core/BaseFragment'; +import { createFragmentTemplate } from '../core/createFragmentTemplate'; +import { addToImportMap } from './addToImportMap'; +import type { ImportInput, ImportMap, Module, UsedIdentifier } from './ImportMap'; +import { createImportMap, parseImportInput } from './ImportMap'; +import { mergeImportMaps } from './mergeImportMaps'; +import { removeFromImportMap } from './removeFromImportMap'; + +/** + * The JavaScript-flavored fragment shape: {@link BaseFragment} plus a + * symbolic {@link ImportMap} carrying the imports the content depends on. + * + * Fragments are frozen and composable — interpolating one into another + * (via the {@link fragment} tag) propagates both content and imports, so + * generators can build code top-down without threading import bookkeeping + * through every helper. + */ +export type Fragment = BaseFragment & Readonly<{ imports: ImportMap }>; + +/** + * Type guard for the JavaScript-flavored {@link Fragment} shape. + * + * @param value - The value to test. + * @return `true` when `value` is an object carrying both `content` and + * `imports` fields. The check is structural; downstream code that layers + * extra fields on top (e.g. a renderer's `features` set) will still match. + */ +export function isFragment(value: unknown): value is Fragment { + return typeof value === 'object' && value !== null && 'content' in value && 'imports' in value; +} + +/** + * Tagged-template helper for composing JavaScript-flavored fragments. + * Interpolated values may be: + * + * - A {@link Fragment} — content is inlined and imports propagate. + * - `undefined` — rendered as the empty string (handy for optional + * sub-fragments). + * - Anything else — coerced to a string via `String(value)`. + * + * @param template - The template-strings array supplied by the tag call site. + * @param items - The interpolated values, in order. + * @return A frozen {@link Fragment} with the merged content and imports. + * + * @example + * ```ts + * import { fragment, use } from '@codama/fragments/javascript'; + * + * const pdaLink = use('type PdaLinkNode', '../linkNodes/PdaLinkNode'); + * const body = fragment` + * export interface AccountNode { + * readonly pda?: ${pdaLink}; + * } + * `; + * ``` + */ +export function fragment(template: TemplateStringsArray, ...items: unknown[]): Fragment { + return createFragmentTemplate(template, items, isFragment, mergeFragments); +} + +/** + * Combine multiple fragments into one. The merge strategy for content is + * supplied by the caller (`mergeContent`); imports are merged automatically + * via {@link mergeImportMaps}. Undefined inputs are skipped. + * + * @param fragments - The fragments to merge, in order. + * @param mergeContent - A function that produces the final content string + * from each surviving fragment's content. + * @return A frozen merged {@link Fragment}. + * + * @example + * ```ts + * import { fragment, mergeFragments } from '@codama/fragments/javascript'; + * + * const merged = mergeFragments( + * [fragment`a`, fragment`b`, fragment`c`], + * parts => parts.join(', '), + * ); + * ``` + */ +export function mergeFragments( + fragments: readonly (Fragment | undefined)[], + mergeContent: (contents: string[]) => string, +): Fragment { + const filtered = fragments.filter((f): f is Fragment => f !== undefined); + return Object.freeze({ + content: mergeContent(filtered.map(f => f.content)), + imports: mergeImportMaps(filtered.map(f => f.imports)), + }); +} + +/** + * Construct a fragment whose content is a single imported identifier and + * whose import map carries the corresponding import statement. + * + * The shorthand accepted in `importInput` is the same as + * {@link parseImportInput}'s — bare names, `type`-prefixed names, and + * `as`-aliased names are all supported. + * + * @param importInput - The import shorthand (e.g. `'Foo'`, `'type Foo'`, + * `'Foo as Bar'`). + * @param module - The module the identifier comes from. + * @return A frozen {@link Fragment} ready to be interpolated into other + * fragments. + * + * @example + * ```ts + * import { use } from '@codama/fragments/javascript'; + * + * use('PdaLinkNode', '../linkNodes/PdaLinkNode'); + * // → content: 'PdaLinkNode' + * // imports: { '../linkNodes/PdaLinkNode' → PdaLinkNode (value) } + * + * use('type Foo as Bar', './foo'); + * // → content: 'Bar' + * // imports: { './foo' → Foo as Bar (type-only) } + * ``` + */ +export function use(importInput: ImportInput, module: Module): Fragment { + const info = parseImportInput(importInput); + const empty: Fragment = Object.freeze({ content: info.usedIdentifier, imports: createImportMap() }); + return addFragmentImports(empty, module, [importInput]); +} + +/** + * Append imports to an existing fragment's import map. The fragment's + * content and any other fields are preserved. + * + * @param fragment - The source fragment. + * @param module - The module the new imports come from. + * @param imports - The import shorthand strings to add. + * @return A new frozen fragment with the extended import map. + * + * @example + * ```ts + * import { addFragmentImports, fragment } from '@codama/fragments/javascript'; + * + * const f = addFragmentImports(fragment`hello`, './foo', ['Foo']); + * ``` + */ +export function addFragmentImports(fragment: Fragment, module: Module, imports: ImportInput[]): Fragment { + return Object.freeze({ + ...fragment, + imports: addToImportMap(fragment.imports, module, imports), + }); +} + +/** + * Merge additional import maps into an existing fragment's import map. The + * fragment's content and any other fields are preserved. + * + * @param fragment - The source fragment. + * @param importMaps - The additional maps to merge in. + * @return A new frozen fragment with the merged import map. + */ +export function mergeFragmentImports(fragment: Fragment, importMaps: readonly ImportMap[]): Fragment { + return Object.freeze({ + ...fragment, + imports: mergeImportMaps([fragment.imports, ...importMaps]), + }); +} + +/** + * Drop identifiers from a fragment's import map. The fragment's content and + * any other fields are preserved. + * + * @param fragment - The source fragment. + * @param module - The module to remove identifiers from. + * @param usedIdentifiers - The used-identifier names to drop. + * @return A new frozen fragment with the trimmed import map. + */ +export function removeFragmentImports(fragment: Fragment, module: Module, usedIdentifiers: UsedIdentifier[]): Fragment { + return Object.freeze({ + ...fragment, + imports: removeFromImportMap(fragment.imports, module, usedIdentifiers), + }); +} diff --git a/packages/fragments/src/javascript/getDocblockFragment.ts b/packages/fragments/src/javascript/getDocblockFragment.ts new file mode 100644 index 000000000..fbf3eb59e --- /dev/null +++ b/packages/fragments/src/javascript/getDocblockFragment.ts @@ -0,0 +1,67 @@ +import type { Fragment } from './fragment'; +import { fragment } from './fragment'; + +/** + * Build a JSDoc-style docblock fragment from an array of lines. + * + * Empty or `undefined` input returns `undefined` so the helper composes + * naturally with the {@link fragment} tag's optional-interpolation + * behavior — a node's `docs` attribute can be threaded straight in + * without a ternary guard: + * + * ```ts + * fragment`${getDocblockFragment(node.docs)}\nexport interface X {}`; + * ``` + * + * Single-line input renders as a one-line block (`/** line *\/`); + * multi-line input renders as a standard multi-line JSDoc block. Empty + * elements in the array render as bare ` *` lines, useful for paragraph + * breaks inside a docblock. + * + * The helper defangs any literal `*\/` sequences inside the lines (they are + * rewritten as `*\\/`) so that user-supplied content cannot accidentally + * close the docblock early. + * + * @param lines - The lines of the docblock, or `undefined`. Empty array + * and `undefined` both return `undefined`. + * @param options - Optional settings. + * @param options.withLineJump - When `true`, appends a trailing `\n` after + * the closing `*\/`. Useful when the docblock is followed by a same-line + * item like an enum variant. + * @return A {@link Fragment} carrying the rendered docblock, or `undefined` + * when `lines` is empty or `undefined`. + * + * @example + * ```ts + * import { getDocblockFragment } from '@codama/fragments/javascript'; + * + * getDocblockFragment(['Greets the user.'])?.content; + * // /** Greets the user. *\/ + * + * getDocblockFragment(['First line.', '', 'Second paragraph.'])?.content; + * // /** + * // * First line. + * // * + * // * Second paragraph. + * // *\/ + * + * getDocblockFragment(undefined); + * // undefined + * ``` + */ +export function getDocblockFragment( + lines: readonly string[] | undefined, + options: { withLineJump?: boolean } = {}, +): Fragment | undefined { + if (!lines || lines.length === 0) return undefined; + const lineJump = options.withLineJump ? '\n' : ''; + const safeLines = lines.map(defang); + if (safeLines.length === 1) return fragment`/** ${safeLines[0]} */${lineJump}`; + const prefixedLines = safeLines.map(line => (line ? ` * ${line}` : ' *')); + return fragment`/**\n${prefixedLines.join('\n')}\n */${lineJump}`; +} + +/** Escape any `*\/` sequences in a docblock line so user input can't close the comment. */ +function defang(line: string): string { + return line.replace(/\*\//g, '*\\/'); +} diff --git a/packages/fragments/src/javascript/getExportAllFragment.ts b/packages/fragments/src/javascript/getExportAllFragment.ts new file mode 100644 index 000000000..a36542ae5 --- /dev/null +++ b/packages/fragments/src/javascript/getExportAllFragment.ts @@ -0,0 +1,24 @@ +import type { Fragment } from './fragment'; +import { fragment } from './fragment'; + +/** + * Build a fragment that re-exports every binding from a module: + * `export * from '';`. + * + * The fragment carries no imports — `export * from` only forwards bindings + * out, it does not bring `module` into local scope. + * + * @param module - The module specifier being re-exported. + * @return A {@link Fragment} whose content is `export * from '';`. + * + * @example + * ```ts + * import { getExportAllFragment } from '@codama/fragments/javascript'; + * + * getExportAllFragment('./accounts').content; + * // export * from './accounts'; + * ``` + */ +export function getExportAllFragment(module: string): Fragment { + return fragment`export * from '${module}';`; +} diff --git a/packages/fragments/src/javascript/getExternalDependencies.ts b/packages/fragments/src/javascript/getExternalDependencies.ts new file mode 100644 index 000000000..ab9215ac8 --- /dev/null +++ b/packages/fragments/src/javascript/getExternalDependencies.ts @@ -0,0 +1,40 @@ +import type { ImportMap } from './ImportMap'; +import { resolveImportMap } from './resolveImportMap'; + +/** + * Compute the set of external (non-relative) module specifiers an import + * map references, with dependency-map resolution applied first. The + * returned values are *root* package names — for `'@scope/pkg/sub'` the + * value is `'@scope/pkg'`, and for `'pkg/sub'` it is `'pkg'`. + * + * Useful for syncing a renderer's generated `package.json` from the + * imports it ends up emitting. + * + * @param importMap - The import map to inspect. + * @param dependencies - The dependency map to apply before extracting + * names. Defaults to no resolution. + * @return A {@link Set} of external root package names. + * + * @example + * ```ts + * import { addToImportMap, createImportMap, getExternalDependencies } from '@codama/fragments/javascript'; + * + * let map = createImportMap(); + * map = addToImportMap(map, '@solana/kit', ['Address']); + * map = addToImportMap(map, '@solana/kit/program-client-core', ['ProgramClient']); + * map = addToImportMap(map, '../shared', ['Local']); + * getExternalDependencies(map); + * // → Set { '@solana/kit' } + * ``` + */ +export function getExternalDependencies(importMap: ImportMap, dependencies: Record = {}): Set { + const resolved = resolveImportMap(importMap, dependencies); + const roots = new Set(); + for (const module of resolved.keys()) { + if (module.startsWith('.')) continue; + const segments = module.split('/'); + const rootSegmentCount = module.startsWith('@') ? 2 : 1; + roots.add(segments.slice(0, rootSegmentCount).join('/')); + } + return roots; +} diff --git a/packages/fragments/src/javascript/importMapToString.ts b/packages/fragments/src/javascript/importMapToString.ts new file mode 100644 index 000000000..cb0abeff7 --- /dev/null +++ b/packages/fragments/src/javascript/importMapToString.ts @@ -0,0 +1,69 @@ +import type { ImportInfo, ImportMap } from './ImportMap'; +import { resolveImportMap } from './resolveImportMap'; + +/** + * Render an import map as a block of TypeScript `import { … } from '…';` + * statements. + * + * The map is first resolved against `dependencies` so symbolic module + * names (e.g. `'solanaAddresses'`, `'generatedAccounts'`) expand to real + * specifiers; renderers that don't use symbolic names can omit the + * argument. + * + * Output rules: + * - Modules are sorted with non-relative paths first, then relative; + * within each group, alphabetical. + * - Identifiers within each module are alphabetical. + * - When every import from a given module is type-only, the line is + * emitted as `import type { … } from '…';` rather than per-identifier + * `type` — matching the `@solana/eslint-config-solana` + * consolidate-type-imports convention used across the Codama + * published surface. + * + * @param importMap - The import map to render. + * @param dependencies - The dependency map to apply before rendering. + * Defaults to no resolution. + * @return The block of import lines, or the empty string if the map is + * empty. + * + * @example + * ```ts + * import { addToImportMap, createImportMap, importMapToString } from '@codama/fragments/javascript'; + * + * let map = createImportMap(); + * map = addToImportMap(map, '@codama/spec', ['type Spec']); + * map = addToImportMap(map, '../shared', ['CamelCaseString']); + * importMapToString(map); + * // import type { Spec } from '@codama/spec'; + * // import { CamelCaseString } from '../shared'; + * ``` + */ +export function importMapToString(importMap: ImportMap, dependencies: Record = {}): string { + const resolved = resolveImportMap(importMap, dependencies); + return [...resolved.entries()] + .sort(([a], [b]) => { + const aRelative = a.startsWith('.') ? 1 : 0; + const bRelative = b.startsWith('.') ? 1 : 0; + if (aRelative !== bRelative) return aRelative - bRelative; + return a.localeCompare(b); + }) + .map(([module, imports]) => { + const infos = [...imports.values()]; + const allTypeOnly = infos.length > 0 && infos.every(info => info.isType); + const renderedIds = infos + .map(info => formatImportInfo(info, allTypeOnly)) + .sort((a, b) => a.localeCompare(b)) + .join(', '); + const prefix = allTypeOnly ? 'import type ' : 'import '; + return `${prefix}{ ${renderedIds} } from '${module}';`; + }) + .join('\n'); +} + +function formatImportInfo(info: ImportInfo, blockIsTypeOnly: boolean): string { + const alias = info.importedIdentifier !== info.usedIdentifier ? ` as ${info.usedIdentifier}` : ''; + // Drop the per-identifier `type` prefix when the whole block is already + // declared as a type-only import. + const typePrefix = info.isType && !blockIsTypeOnly ? 'type ' : ''; + return `${typePrefix}${info.importedIdentifier}${alias}`; +} diff --git a/packages/fragments/src/javascript/index.ts b/packages/fragments/src/javascript/index.ts new file mode 100644 index 000000000..d628343e7 --- /dev/null +++ b/packages/fragments/src/javascript/index.ts @@ -0,0 +1,23 @@ +/** + * `@codama/fragments/javascript` + * + * The JavaScript / TypeScript flavor of the fragment library: a concrete + * `Fragment` type carrying a symbolic `ImportMap`, a `fragment` tagged + * template that propagates imports through interpolation, and helpers + * for building, merging, resolving, and rendering imports. + * + * Re-exports the language-agnostic core too, so consumers only need a + * single import in the typical case. + */ + +export * from '../core'; +export * from './ImportMap'; +export * from './addToImportMap'; +export * from './mergeImportMaps'; +export * from './removeFromImportMap'; +export * from './resolveImportMap'; +export * from './getExternalDependencies'; +export * from './importMapToString'; +export * from './fragment'; +export * from './getDocblockFragment'; +export * from './getExportAllFragment'; diff --git a/packages/fragments/src/javascript/mergeImportMaps.ts b/packages/fragments/src/javascript/mergeImportMaps.ts new file mode 100644 index 000000000..7896ad851 --- /dev/null +++ b/packages/fragments/src/javascript/mergeImportMaps.ts @@ -0,0 +1,58 @@ +import type { ImportInfo, ImportMap, Module, UsedIdentifier } from './ImportMap'; +import { createImportMap } from './ImportMap'; + +/** + * Merge multiple import maps into one. Modules and identifiers from later + * maps are layered over earlier ones; collisions on the same `usedIdentifier` + * are resolved by {@link preferIncoming}. + * + * The merge is a pure function: input maps are not mutated. The returned map + * is frozen. + * + * @param importMaps - The import maps to merge, in priority order. + * @return A frozen import map that contains every entry from every input. + * + * @example + * ```ts + * import { addToImportMap, createImportMap, mergeImportMaps } from '@codama/fragments/javascript'; + * + * const a = addToImportMap(createImportMap(), './foo', ['Foo']); + * const b = addToImportMap(createImportMap(), './bar', ['Bar']); + * const merged = mergeImportMaps([a, b]); + * ``` + */ +export function mergeImportMaps(importMaps: readonly ImportMap[]): ImportMap { + if (importMaps.length === 0) return createImportMap(); + if (importMaps.length === 1) return importMaps[0]; + const merged = new Map(importMaps[0]); + for (const map of importMaps.slice(1)) { + for (const [module, imports] of map) { + const moduleMap = new Map( + merged.get(module) ?? new Map(), + ); + for (const [usedIdentifier, info] of imports) { + const existing = moduleMap.get(usedIdentifier); + if (preferIncoming(existing, info)) { + moduleMap.set(usedIdentifier, info); + } + } + merged.set(module, moduleMap); + } + } + return Object.freeze(merged) as ReadonlyMap>; +} + +/** + * Decide whether an incoming `ImportInfo` should replace an existing entry + * for the same `usedIdentifier`. + * + * The single rule we apply: if both refer to the same source identifier and + * one is type-only while the other is a value import, the value import wins. + * In every other "tied" case the existing entry stays. This keeps a value + * import from being silently downgraded to type-only when both are + * encountered. + */ +export function preferIncoming(existing: ImportInfo | undefined, incoming: ImportInfo): boolean { + if (!existing) return true; + return existing.importedIdentifier === incoming.importedIdentifier && existing.isType && !incoming.isType; +} diff --git a/packages/fragments/src/javascript/removeFromImportMap.ts b/packages/fragments/src/javascript/removeFromImportMap.ts new file mode 100644 index 000000000..ba80ab975 --- /dev/null +++ b/packages/fragments/src/javascript/removeFromImportMap.ts @@ -0,0 +1,35 @@ +import type { ImportMap, Module, UsedIdentifier } from './ImportMap'; + +/** + * Remove identifiers from a module entry. If the module ends up with no + * remaining identifiers, the module entry itself is dropped from the map. + * + * @param importMap - The import map to modify. + * @param module - The source module to remove identifiers from. + * @param usedIdentifiers - The used-identifier names to drop. Names that + * aren't present in the module entry are silently ignored. + * @return A new frozen import map without those identifiers. + * + * @example + * ```ts + * import { addToImportMap, createImportMap, removeFromImportMap } from '@codama/fragments/javascript'; + * + * let map = addToImportMap(createImportMap(), './foo', ['Foo', 'Bar']); + * map = removeFromImportMap(map, './foo', ['Foo']); + * ``` + */ +export function removeFromImportMap( + importMap: ImportMap, + module: Module, + usedIdentifiers: UsedIdentifier[], +): ImportMap { + const next = new Map(importMap); + const moduleMap = new Map(next.get(module)); + for (const id of usedIdentifiers) moduleMap.delete(id); + if (moduleMap.size === 0) { + next.delete(module); + } else { + next.set(module, moduleMap); + } + return Object.freeze(next); +} diff --git a/packages/fragments/src/javascript/resolveImportMap.ts b/packages/fragments/src/javascript/resolveImportMap.ts new file mode 100644 index 000000000..42713b540 --- /dev/null +++ b/packages/fragments/src/javascript/resolveImportMap.ts @@ -0,0 +1,43 @@ +import type { ImportInfo, ImportMap, Module, UsedIdentifier } from './ImportMap'; +import { mergeImportMaps } from './mergeImportMaps'; + +/** + * Rewrite a JavaScript import map's module keys against a dependency map. + * + * Resolution is by exact module-name lookup: a module key `'solanaAddresses'` + * against `{ solanaAddresses: '@solana/kit' }` becomes `'@solana/kit'`. + * Keys that aren't present in `dependencies` are kept unchanged. The + * inner `UsedIdentifier → ImportInfo` map for each module is preserved + * exactly. When two source modules resolve to the same target, their + * inner maps are merged via {@link mergeImportMaps}. + * + * Renderers typically pre-define a base set of symbolic modules + * (`solanaAddresses → @solana/kit`, `generated → ..`, etc.) and pass + * them through this function just before calling + * {@link importMapToString} or {@link getExternalDependencies}. + * + * @param importMap - The import map to resolve. + * @param dependencies - A record mapping symbolic module names to + * resolved module specifiers. + * @return A new frozen import map with all module keys resolved. + * + * @example + * ```ts + * import { addToImportMap, createImportMap, resolveImportMap } from '@codama/fragments/javascript'; + * + * let map = createImportMap(); + * map = addToImportMap(map, 'solanaAddresses', ['Address']); + * const resolved = resolveImportMap(map, { solanaAddresses: '@solana/kit' }); + * // resolved keys: ['@solana/kit'] + * ``` + */ +export function resolveImportMap(importMap: ImportMap, dependencies: Record): ImportMap { + if (importMap.size === 0) return importMap; + const perModule: ReadonlyMap>[] = [...importMap.entries()].map( + ([module, identifiers]) => { + const resolvedModule = dependencies[module] ?? module; + return new Map([[resolvedModule, identifiers]]); + }, + ); + return mergeImportMaps(perModule); +} diff --git a/packages/fragments/src/rust/ImportMap.ts b/packages/fragments/src/rust/ImportMap.ts new file mode 100644 index 000000000..1b985dc2a --- /dev/null +++ b/packages/fragments/src/rust/ImportMap.ts @@ -0,0 +1,66 @@ +/** + * Rust crate keywords that should never be reported as external dependencies. + * + * {@link getExternalDependencies} filters these out when computing the + * package's Cargo dependencies from the imports actually used. They + * correspond to paths that resolve inside the crate or to the standard + * library. + */ +export const RUST_CORE_IMPORTS: ReadonlySet = new Set([ + 'alloc', + 'clippy', + 'core', + 'crate', + 'self', + 'std', + 'super', +]); + +/** + * The fully-qualified Rust path of an imported item — e.g. + * `'solana_program::pubkey::Pubkey'` or `'crate::generated::accounts::AccountNode'`. + * + * Symbolic prefixes (e.g. `'generated::accounts::Foo'`) are accepted too; + * they are resolved into concrete paths by {@link resolveImportMap} just + * before stringification. + */ +export type ImportPath = string; + +/** The local name an imported path is bound to (the right-hand side of `as`). */ +export type Alias = string; + +/** A parsed Rust import. */ +export interface ImportInfo { + readonly alias?: Alias; + readonly importedPath: ImportPath; +} + +/** + * The data model used by `@codama/fragments/rust` to track imports + * symbolically until they are stringified into actual `use foo::Bar;` lines. + * + * Unlike the JavaScript flavor, Rust's `use` statement always references a + * single fully-qualified path. There is no per-module identifier list, so + * the map is flat: keyed by the imported path, with optional alias info as + * the value. + * + * The map is frozen and its operations are pure functions (no methods, no + * mutation), matching the JavaScript subpath's paradigm. + */ +export type ImportMap = ReadonlyMap; + +/** + * Construct an empty, frozen import map. + * + * @return A new {@link ImportMap} with no entries. + * + * @example + * ```ts + * import { createImportMap } from '@codama/fragments/rust'; + * + * const empty = createImportMap(); + * ``` + */ +export function createImportMap(): ImportMap { + return Object.freeze(new Map()); +} diff --git a/packages/fragments/src/rust/addAliasToImportMap.ts b/packages/fragments/src/rust/addAliasToImportMap.ts new file mode 100644 index 000000000..6748ac05f --- /dev/null +++ b/packages/fragments/src/rust/addAliasToImportMap.ts @@ -0,0 +1,28 @@ +import type { Alias, ImportMap, ImportPath } from './ImportMap'; + +/** + * Record an alias (`use foo::Bar as Baz;`) for an imported path. If the + * path isn't yet in the map, it is added; if it's already present, the + * alias is set or replaced. The input map is not mutated. + * + * @param importMap - The import map to extend. + * @param path - The full Rust path being aliased (e.g. `'foo::Bar'`). + * @param alias - The local name to use (e.g. `'Baz'`). + * @return A new frozen import map with the alias recorded. + * + * @example + * ```ts + * import { addAliasToImportMap, createImportMap } from '@codama/fragments/rust'; + * + * const map = addAliasToImportMap( + * createImportMap(), + * 'solana_program::program_error::ProgramError', + * 'ProgError', + * ); + * ``` + */ +export function addAliasToImportMap(importMap: ImportMap, path: ImportPath, alias: Alias): ImportMap { + const next = new Map(importMap); + next.set(path, Object.freeze({ alias, importedPath: path })); + return Object.freeze(next); +} diff --git a/packages/fragments/src/rust/addToImportMap.ts b/packages/fragments/src/rust/addToImportMap.ts new file mode 100644 index 000000000..888d025d5 --- /dev/null +++ b/packages/fragments/src/rust/addToImportMap.ts @@ -0,0 +1,42 @@ +import type { ImportMap, ImportPath } from './ImportMap'; +import { mergeImportMaps } from './mergeImportMaps'; + +/** + * Append imports to an import map, returning a new frozen map. The input + * map is not mutated. + * + * Aliases are not added by this function — call + * {@link addAliasToImportMap} separately when the import needs an `as` + * clause. + * + * @param importMap - The import map to extend. + * @param paths - The Rust paths to add. May be a single string, an array, + * or a {@link Set}. An empty array short-circuits and returns `importMap` + * unchanged. + * @return A frozen import map that includes the new entries. + * + * @example + * ```ts + * import { addToImportMap, createImportMap } from '@codama/fragments/rust'; + * + * const map = addToImportMap(createImportMap(), [ + * 'borsh::BorshDeserialize', + * 'borsh::BorshSerialize', + * ]); + * ``` + */ +export function addToImportMap( + importMap: ImportMap, + paths: ReadonlySet | string | readonly string[], +): ImportMap { + const inputs = typeof paths === 'string' ? [paths] : [...paths]; + if (inputs.length === 0) return importMap; + const additions = new Map(); + for (const path of inputs) { + if (!additions.has(path) && !importMap.has(path)) { + additions.set(path, Object.freeze({ importedPath: path })); + } + } + if (additions.size === 0) return importMap; + return mergeImportMaps([importMap, additions]); +} diff --git a/packages/fragments/src/rust/fragment.ts b/packages/fragments/src/rust/fragment.ts new file mode 100644 index 000000000..c49c67988 --- /dev/null +++ b/packages/fragments/src/rust/fragment.ts @@ -0,0 +1,167 @@ +import type { BaseFragment } from '../core/BaseFragment'; +import { createFragmentTemplate } from '../core/createFragmentTemplate'; +import { addAliasToImportMap } from './addAliasToImportMap'; +import { addToImportMap } from './addToImportMap'; +import type { Alias, ImportMap, ImportPath } from './ImportMap'; +import { mergeImportMaps } from './mergeImportMaps'; +import { removeFromImportMap } from './removeFromImportMap'; + +/** + * The Rust-flavored fragment shape: {@link BaseFragment} plus a frozen + * {@link ImportMap} carrying the `use` paths the content depends on. + * + * Both the fragment and its import map are immutable. Every operation in + * this subpath that returns a fragment produces a new frozen wrapper, so + * fragments compose without aliasing risks. + */ +export type Fragment = BaseFragment & Readonly<{ imports: ImportMap }>; + +/** + * Type guard for the Rust-flavored {@link Fragment} shape. + * + * @param value - The value to test. + * @return `true` when `value` is an object carrying both `content` and + * `imports` fields. + */ +export function isFragment(value: unknown): value is Fragment { + return typeof value === 'object' && value !== null && 'content' in value && 'imports' in value; +} + +/** + * Tagged-template helper for composing Rust-flavored fragments. + * Interpolated values may be: + * + * - A {@link Fragment} — content is inlined and imports propagate. + * - `undefined` — rendered as the empty string. + * - Anything else — coerced to a string via `String(value)`. + * + * Rust does not have a `use(input, module)` shorthand because identifiers + * can be referenced inline by their full `::`-qualified path; build + * content with the tag and attach imports separately via + * {@link addFragmentImports}. + * + * @param template - The template-strings array supplied by the tag call site. + * @param items - The interpolated values, in order. + * @return A frozen {@link Fragment} with merged content and imports. + * + * @example + * ```ts + * import { addFragmentImports, fragment } from '@codama/fragments/rust'; + * + * const body = fragment`pub struct AccountNode { pubkey: Pubkey }`; + * const withImports = addFragmentImports(body, ['solana_program::pubkey::Pubkey']); + * ``` + */ +export function fragment(template: TemplateStringsArray, ...items: unknown[]): Fragment { + return createFragmentTemplate(template, items, isFragment, mergeFragments); +} + +/** + * Combine multiple fragments into one. The merge strategy for content is + * supplied by the caller (`mergeContent`); imports are merged + * automatically via {@link mergeImportMaps}. Undefined inputs are skipped. + * + * @param fragments - The fragments to merge, in order. + * @param mergeContent - A function that produces the final content string + * from each surviving fragment's content. + * @return A frozen merged {@link Fragment}. + */ +export function mergeFragments( + fragments: readonly (Fragment | undefined)[], + mergeContent: (contents: string[]) => string, +): Fragment { + const filtered = fragments.filter((f): f is Fragment => f !== undefined); + return Object.freeze({ + content: mergeContent(filtered.map(f => f.content)), + imports: mergeImportMaps(filtered.map(f => f.imports)), + }); +} + +/** + * Append imports to an existing fragment's import map. The fragment's + * content and any other fields are preserved. + * + * @param fragment - The source fragment. + * @param paths - The Rust paths to add. May be a single string, an array, + * or a {@link Set}. + * @return A new frozen fragment with the extended import map. + * + * @example + * ```ts + * import { addFragmentImports, fragment } from '@codama/fragments/rust'; + * + * const f = addFragmentImports(fragment`Pubkey`, ['solana_program::pubkey::Pubkey']); + * ``` + */ +export function addFragmentImports( + fragment: Fragment, + paths: ImportPath | ReadonlySet | readonly ImportPath[], +): Fragment { + return Object.freeze({ + ...fragment, + imports: addToImportMap(fragment.imports, paths), + }); +} + +/** + * Record an alias for an imported path on the fragment's import map. If + * the path isn't yet imported, it is added; if it's already present, the + * alias replaces any existing one. The fragment's content and any other + * fields are preserved. + * + * @param fragment - The source fragment. + * @param path - The full Rust path being aliased. + * @param alias - The local name to use. + * @return A new frozen fragment with the alias recorded. + * + * @example + * ```ts + * import { addFragmentImportAlias, fragment } from '@codama/fragments/rust'; + * + * const f = addFragmentImportAlias( + * fragment`ProgError::InvalidArgument`, + * 'solana_program::program_error::ProgramError', + * 'ProgError', + * ); + * ``` + */ +export function addFragmentImportAlias(fragment: Fragment, path: ImportPath, alias: Alias): Fragment { + return Object.freeze({ + ...fragment, + imports: addAliasToImportMap(fragment.imports, path, alias), + }); +} + +/** + * Merge additional import maps into an existing fragment's import map. + * The fragment's content and any other fields are preserved. + * + * @param fragment - The source fragment. + * @param importMaps - The maps to merge in. + * @return A new frozen fragment with the merged import map. + */ +export function mergeFragmentImports(fragment: Fragment, importMaps: readonly ImportMap[]): Fragment { + return Object.freeze({ + ...fragment, + imports: mergeImportMaps([fragment.imports, ...importMaps]), + }); +} + +/** + * Drop paths from a fragment's import map. The fragment's content and any + * other fields are preserved. + * + * @param fragment - The source fragment. + * @param paths - The Rust paths to remove. May be a single string, an + * array, or a {@link Set}. + * @return A new frozen fragment with the trimmed import map. + */ +export function removeFragmentImports( + fragment: Fragment, + paths: ImportPath | ReadonlySet | readonly ImportPath[], +): Fragment { + return Object.freeze({ + ...fragment, + imports: removeFromImportMap(fragment.imports, paths), + }); +} diff --git a/packages/fragments/src/rust/getDocblockFragment.ts b/packages/fragments/src/rust/getDocblockFragment.ts new file mode 100644 index 000000000..0f773a5cb --- /dev/null +++ b/packages/fragments/src/rust/getDocblockFragment.ts @@ -0,0 +1,60 @@ +import type { Fragment } from './fragment'; +import { fragment } from './fragment'; + +/** + * Build a Rust doc-comment fragment from an array of lines. + * + * Empty or `undefined` input returns `undefined` so the helper composes + * naturally with the {@link fragment} tag's optional-interpolation + * behavior — a node's `docs` attribute can be threaded straight in + * without a ternary guard: + * + * ```ts + * fragment`${getDocblockFragment(node.docs)}\npub struct X;`; + * ``` + * + * Each line is prefixed with `///` (outer doc) by default, or `//!` + * (inner doc) when `internal` is `true`. Empty elements in the array + * render as a bare prefix line (`///` or `//!` with no trailing space), + * useful for paragraph breaks inside a doc comment. + * + * @param lines - The lines of the doc comment, or `undefined`. Empty + * array and `undefined` both return `undefined`. + * @param options - Optional settings. + * @param options.internal - When `true`, emit inner doc comments (`//!`) + * instead of outer doc comments (`///`). Useful for module- or crate-level + * documentation. + * @param options.withLineJump - When `true`, appends a trailing `\n` after + * the last line. + * @return A {@link Fragment} carrying the rendered doc comment, or + * `undefined` when `lines` is empty or `undefined`. + * + * @example + * ```ts + * import { getDocblockFragment } from '@codama/fragments/rust'; + * + * getDocblockFragment(['Greets the user.'])?.content; + * // /// Greets the user. + * + * getDocblockFragment(['First line.', '', 'Second paragraph.'])?.content; + * // /// First line. + * // /// + * // /// Second paragraph. + * + * getDocblockFragment(['Module docs.'], { internal: true })?.content; + * // //! Module docs. + * + * getDocblockFragment(undefined); + * // undefined + * ``` + */ +export function getDocblockFragment( + lines: readonly string[] | undefined, + options: { internal?: boolean; withLineJump?: boolean } = {}, +): Fragment | undefined { + if (!lines || lines.length === 0) return undefined; + const prefix = options.internal ? '//!' : '///'; + const lineJump = options.withLineJump ? '\n' : ''; + const prefixedLines = lines.map(line => (line ? `${prefix} ${line}` : prefix)); + return fragment`${prefixedLines.join('\n')}${lineJump}`; +} diff --git a/packages/fragments/src/rust/getExternalDependencies.ts b/packages/fragments/src/rust/getExternalDependencies.ts new file mode 100644 index 000000000..f46ec5d5c --- /dev/null +++ b/packages/fragments/src/rust/getExternalDependencies.ts @@ -0,0 +1,40 @@ +import type { ImportMap } from './ImportMap'; +import { RUST_CORE_IMPORTS } from './ImportMap'; +import { resolveImportMap } from './resolveImportMap'; + +/** + * Compute the top-level crate names actually imported, with + * {@link RUST_CORE_IMPORTS} excluded. Useful for syncing a renderer's + * generated `Cargo.toml` from the imports it ends up emitting. + * + * Imports are first resolved against the dependency map (so symbolic + * prefixes like `'generated::…'` are expanded before crate names are + * extracted), then the leading `::` segment of each path is collected. + * + * @param importMap - The import map to inspect. + * @param dependencies - The dependency map to apply before extracting + * crate names. Defaults to no resolution. + * @return A {@link Set} of external crate names. + * + * @example + * ```ts + * import { addToImportMap, createImportMap, getExternalDependencies } from '@codama/fragments/rust'; + * + * const map = addToImportMap(createImportMap(), [ + * 'borsh::BorshSerialize', + * 'std::collections::HashMap', + * 'generated::accounts::A', + * ]); + * getExternalDependencies(map, { generated: 'crate::generated' }); + * // → Set { 'borsh' } + * ``` + */ +export function getExternalDependencies(importMap: ImportMap, dependencies: Record = {}): Set { + const resolved = resolveImportMap(importMap, dependencies); + const crates = new Set(); + for (const path of resolved.keys()) { + const root = path.split('::')[0]; + if (!RUST_CORE_IMPORTS.has(root)) crates.add(root); + } + return crates; +} diff --git a/packages/fragments/src/rust/importMapToString.ts b/packages/fragments/src/rust/importMapToString.ts new file mode 100644 index 000000000..ddf3bdbb2 --- /dev/null +++ b/packages/fragments/src/rust/importMapToString.ts @@ -0,0 +1,37 @@ +import type { ImportMap } from './ImportMap'; +import { resolveImportMap } from './resolveImportMap'; + +/** + * Render an import map as a block of `use foo::Bar;` (and `use foo::Bar as Baz;`) + * statements. + * + * The map is first resolved against `dependencies` so symbolic prefixes + * like `'generated::…'` expand to concrete crate paths. The output is + * sorted alphabetically by path for stable, reviewable diffs. + * + * @param importMap - The import map to render. + * @param dependencies - The dependency map to apply before rendering. + * Defaults to no resolution. + * @return The block of `use` statements joined by newlines, or the empty + * string if the map is empty. + * + * @example + * ```ts + * import { addAliasToImportMap, addToImportMap, createImportMap, importMapToString } from '@codama/fragments/rust'; + * + * let map = createImportMap(); + * map = addToImportMap(map, ['borsh::BorshSerialize', 'solana_program::pubkey::Pubkey']); + * map = addAliasToImportMap(map, 'solana_program::program_error::ProgramError', 'ProgError'); + * importMapToString(map); + * // use borsh::BorshSerialize; + * // use solana_program::program_error::ProgramError as ProgError; + * // use solana_program::pubkey::Pubkey; + * ``` + */ +export function importMapToString(importMap: ImportMap, dependencies: Record = {}): string { + const resolved = resolveImportMap(importMap, dependencies); + return [...resolved.values()] + .map(info => (info.alias ? `use ${info.importedPath} as ${info.alias};` : `use ${info.importedPath};`)) + .sort((a, b) => a.localeCompare(b)) + .join('\n'); +} diff --git a/packages/fragments/src/rust/index.ts b/packages/fragments/src/rust/index.ts new file mode 100644 index 000000000..aeda3c162 --- /dev/null +++ b/packages/fragments/src/rust/index.ts @@ -0,0 +1,23 @@ +/** + * `@codama/fragments/rust` + * + * The Rust flavor of the fragment library: a concrete `Fragment` type + * carrying a frozen, functional `ImportMap`, a `fragment` tagged template + * that propagates imports through interpolation, and helpers for + * building, merging, resolving, and rendering. + * + * Re-exports the language-agnostic core too, so consumers only need a + * single import in the typical case. + */ + +export * from '../core'; +export * from './ImportMap'; +export * from './addToImportMap'; +export * from './addAliasToImportMap'; +export * from './mergeImportMaps'; +export * from './removeFromImportMap'; +export * from './resolveImportMap'; +export * from './getExternalDependencies'; +export * from './importMapToString'; +export * from './fragment'; +export * from './getDocblockFragment'; diff --git a/packages/fragments/src/rust/mergeImportMaps.ts b/packages/fragments/src/rust/mergeImportMaps.ts new file mode 100644 index 000000000..d08e5a43f --- /dev/null +++ b/packages/fragments/src/rust/mergeImportMaps.ts @@ -0,0 +1,34 @@ +import type { ImportMap } from './ImportMap'; +import { createImportMap } from './ImportMap'; + +/** + * Merge multiple import maps into one. Paths from later maps are layered + * over earlier ones; if the same path appears in multiple maps, the + * latest occurrence's alias info wins. + * + * The merge is a pure function: input maps are not mutated. The returned + * map is frozen. + * + * @param importMaps - The import maps to merge, in priority order. + * @return A frozen import map containing every entry from every input. + * + * @example + * ```ts + * import { addToImportMap, createImportMap, mergeImportMaps } from '@codama/fragments/rust'; + * + * const a = addToImportMap(createImportMap(), ['borsh::BorshDeserialize']); + * const b = addToImportMap(createImportMap(), ['solana_program::pubkey::Pubkey']); + * const merged = mergeImportMaps([a, b]); + * ``` + */ +export function mergeImportMaps(importMaps: readonly ImportMap[]): ImportMap { + if (importMaps.length === 0) return createImportMap(); + if (importMaps.length === 1) return importMaps[0]; + const merged = new Map(importMaps[0]); + for (const map of importMaps.slice(1)) { + for (const [path, info] of map) { + merged.set(path, info); + } + } + return Object.freeze(merged); +} diff --git a/packages/fragments/src/rust/removeFromImportMap.ts b/packages/fragments/src/rust/removeFromImportMap.ts new file mode 100644 index 000000000..53dc6aba9 --- /dev/null +++ b/packages/fragments/src/rust/removeFromImportMap.ts @@ -0,0 +1,29 @@ +import type { ImportMap, ImportPath } from './ImportMap'; + +/** + * Drop one or more imported paths from an import map. Paths that aren't + * present are silently ignored. The input map is not mutated. + * + * @param importMap - The import map to trim. + * @param paths - The Rust paths to remove. May be a single string, an + * array, or a {@link Set}. + * @return A new frozen import map without those entries. + * + * @example + * ```ts + * import { addToImportMap, createImportMap, removeFromImportMap } from '@codama/fragments/rust'; + * + * let map = addToImportMap(createImportMap(), ['foo::A', 'foo::B']); + * map = removeFromImportMap(map, 'foo::A'); + * ``` + */ +export function removeFromImportMap( + importMap: ImportMap, + paths: ImportPath | ReadonlySet | readonly ImportPath[], +): ImportMap { + const targets = typeof paths === 'string' ? [paths] : [...paths]; + if (targets.length === 0) return importMap; + const next = new Map(importMap); + for (const path of targets) next.delete(path); + return Object.freeze(next); +} diff --git a/packages/fragments/src/rust/resolveImportMap.ts b/packages/fragments/src/rust/resolveImportMap.ts new file mode 100644 index 000000000..3a70d3455 --- /dev/null +++ b/packages/fragments/src/rust/resolveImportMap.ts @@ -0,0 +1,54 @@ +import type { ImportMap, ImportPath } from './ImportMap'; + +/** + * Rewrite the symbolic prefixes of an import map's paths against a + * dependency map. + * + * Resolution is by prefix match on the `::` separator: an import + * `'generated::accounts::AccountNode'` against + * `{ generated: 'crate::generated' }` becomes + * `'crate::generated::accounts::AccountNode'`. Paths that don't match any + * prefix are kept unchanged. Aliases follow their path. + * + * Renderers typically pre-define a base set of symbolic modules + * (`generated → crate::generated`, `mplToolbox → mpl_toolbox`, etc.) and + * pass them through this function just before calling + * {@link importMapToString} or {@link getExternalDependencies}. + * + * @param importMap - The import map to resolve. + * @param dependencies - A record mapping symbolic prefixes to resolved + * Rust paths. + * @return A new frozen import map with all paths resolved. + * + * @example + * ```ts + * import { addToImportMap, createImportMap, resolveImportMap } from '@codama/fragments/rust'; + * + * const map = addToImportMap(createImportMap(), 'generated::accounts::AccountNode'); + * const resolved = resolveImportMap(map, { generated: 'crate::generated' }); + * // resolved has 'crate::generated::accounts::AccountNode' + * ``` + */ +export function resolveImportMap(importMap: ImportMap, dependencies: Record): ImportMap { + const prefixes = Object.keys(dependencies); + if (prefixes.length === 0 || importMap.size === 0) return importMap; + const next = new Map(importMap); + let mutated = false; + for (const [path, info] of importMap) { + const resolvedPath = resolvePath(path, dependencies, prefixes); + if (resolvedPath === path) continue; + next.delete(path); + next.set(resolvedPath, Object.freeze({ ...info, importedPath: resolvedPath })); + mutated = true; + } + return mutated ? Object.freeze(next) : importMap; +} + +function resolvePath(path: ImportPath, dependencies: Record, prefixes: readonly string[]): ImportPath { + for (const prefix of prefixes) { + if (path.startsWith(`${prefix}::`)) { + return dependencies[prefix] + path.slice(prefix.length); + } + } + return path; +} diff --git a/packages/fragments/src/types/global.d.ts b/packages/fragments/src/types/global.d.ts new file mode 100644 index 000000000..13de8a7ce --- /dev/null +++ b/packages/fragments/src/types/global.d.ts @@ -0,0 +1,6 @@ +declare const __BROWSER__: boolean; +declare const __ESM__: boolean; +declare const __NODEJS__: boolean; +declare const __REACTNATIVE__: boolean; +declare const __TEST__: boolean; +declare const __VERSION__: string; diff --git a/packages/fragments/test/core/casing.test.ts b/packages/fragments/test/core/casing.test.ts new file mode 100644 index 000000000..d944a9631 --- /dev/null +++ b/packages/fragments/test/core/casing.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, test } from 'vitest'; + +import { camelCase, capitalize, kebabCase, pascalCase, snakeCase, titleCase } from '../../src/core/casing'; + +describe('capitalize', () => { + test('capitalises the first letter and lowercases the rest', () => { + expect(capitalize('lowercased')).toBe('Lowercased'); + expect(capitalize('UPPERCASED')).toBe('Uppercased'); + expect(capitalize('Capitalized')).toBe('Capitalized'); + expect(capitalize('mIxEd')).toBe('Mixed'); + }); + test('returns the input unchanged when empty', () => { + expect(capitalize('')).toBe(''); + }); + test('handles single characters', () => { + expect(capitalize('a')).toBe('A'); + expect(capitalize('A')).toBe('A'); + expect(capitalize('1')).toBe('1'); + }); +}); + +describe('snakeCase', () => { + test('casing', () => { + expect(snakeCase('lowercased')).toBe('lowercased'); + expect(snakeCase('UPPERCASED')).toBe('u_p_p_e_r_c_a_s_e_d'); + expect(snakeCase('Capitalized')).toBe('capitalized'); + }); + test('numbers', () => { + expect(snakeCase('1before after2 bet3ween')).toBe('1before_after2_bet3ween'); + expect(snakeCase('50m3 1 2 3 numb3rs 8 3v3rywh3r3 9')).toBe('50m3_1_2_3_numb3rs_8_3v3rywh3r3_9'); + expect(snakeCase(snakeCase('50m3 1 2 3 numb3rs 8 3v3rywh3r3 9'))).toBe('50m3_1_2_3_numb3rs_8_3v3rywh3r3_9'); + }); + test('special characters', () => { + expect(snakeCase('some::special\\\\chars+++in=between')).toBe('some_special_chars_in_between'); + expect(snakeCase('$peçia!::ch*rs')).toBe('pe_ia_ch_rs'); + expect(snakeCase('multiple.........dots')).toBe('multiple_dots'); + expect(snakeCase('multiple---------dashes')).toBe('multiple_dashes'); + expect(snakeCase('multiple_________underscores')).toBe('multiple_underscores'); + }); + test('from snake case', () => { + expect(snakeCase('from_lowercased_snake_case')).toBe('from_lowercased_snake_case'); + expect(snakeCase('From_Capitalized_Snake_Case')).toBe('from_capitalized_snake_case'); + expect(snakeCase('FROM_UPPERCASED_SNAKE_CASE')).toBe('f_r_o_m_u_p_p_e_r_c_a_s_e_d_s_n_a_k_e_c_a_s_e'); + expect(snakeCase('fr0m_5nak3_c4s3_w1th_42n_numb3r5')).toBe('fr0m_5nak3_c4s3_w1th_42n_numb3r5'); + expect(snakeCase('frøm_snake_case_w:th_$peçia!_ch*rs')).toBe('fr_m_snake_case_w_th_pe_ia_ch_rs'); + expect(snakeCase(snakeCase('frøm_d0ubl3_Snake_c*se'))).toBe('fr_m_d0ubl3_snake_c_se'); + }); + test('from title case', () => { + expect(snakeCase('from lowercased title case')).toBe('from_lowercased_title_case'); + expect(snakeCase('From Capitalized Title Case')).toBe('from_capitalized_title_case'); + expect(snakeCase('FROM UPPERCASED TITLE CASE')).toBe('f_r_o_m_u_p_p_e_r_c_a_s_e_d_t_i_t_l_e_c_a_s_e'); + expect(snakeCase('Fr0m T1tl3 C4s3 W1th 42n Numb3r5')).toBe('fr0m_t1tl3_c4s3_w1th_42n_numb3r5'); + expect(snakeCase('Frøm Title Case W:th $peçia! Ch*rs')).toBe('fr_m_title_case_w_th_pe_ia_ch_rs'); + expect(snakeCase(snakeCase('Frøm D0ubl3 Title C*se'))).toBe('fr_m_d0ubl3_title_c_se'); + }); + test('from pascal case', () => { + expect(snakeCase('FromPascaleCase')).toBe('from_pascale_case'); + expect(snakeCase('Fr0mP45c4l3C4s3W1th42nNumb3r5')).toBe('fr0m_p45c4l3_c4s3_w1th42n_numb3r5'); + expect(snakeCase('FrømPascaleCaseW:th$peçia!Ch*rs')).toBe('fr_m_pascale_case_w_th_pe_ia_ch_rs'); + expect(snakeCase(snakeCase('FrømD0ubl3PascaleC*se'))).toBe('fr_m_d0ubl3_pascale_c_se'); + }); + test('from camel case', () => { + expect(snakeCase('FromCamelCase')).toBe('from_camel_case'); + expect(snakeCase('Fr0mC4m3lC4s3W1th42nNumb3r5')).toBe('fr0m_c4m3l_c4s3_w1th42n_numb3r5'); + expect(snakeCase('FrømCamelCaseW:th$peçia!Ch*rs')).toBe('fr_m_camel_case_w_th_pe_ia_ch_rs'); + expect(snakeCase(snakeCase('FrømD0ubl3CamelC*se'))).toBe('fr_m_d0ubl3_camel_c_se'); + }); + test('from paths', () => { + expect(snakeCase('crate::my_module::my_type')).toBe('crate_my_module_my_type'); + expect(snakeCase('/Users/username/My File.txt')).toBe('users_username_my_file_txt'); + expect(snakeCase('C:\\Users\\username\\My\\ File.txt')).toBe('c_users_username_my_file_txt'); + }); +}); + +describe('titleCase', () => { + test('casing', () => { + expect(titleCase('lowercased')).toBe('Lowercased'); + expect(titleCase('UPPERCASED')).toBe('U P P E R C A S E D'); + expect(titleCase('Capitalized')).toBe('Capitalized'); + }); + test('numbers', () => { + expect(titleCase('1before after2 bet3ween')).toBe('1before After2 Bet3ween'); + expect(titleCase('50m3 1 2 3 numb3rs 8 3v3rywh3r3 9')).toBe('50m3 1 2 3 Numb3rs 8 3v3rywh3r3 9'); + expect(titleCase(titleCase('50m3 1 2 3 numb3rs 8 3v3rywh3r3 9'))).toBe('50m3 1 2 3 Numb3rs 8 3v3rywh3r3 9'); + }); + test('special characters', () => { + expect(titleCase('some::special\\\\chars+++in=between')).toBe('Some Special Chars In Between'); + expect(titleCase('$peçia!::ch*rs')).toBe('Pe Ia Ch Rs'); + expect(titleCase('multiple.........dots')).toBe('Multiple Dots'); + expect(titleCase('multiple---------dashes')).toBe('Multiple Dashes'); + expect(titleCase('multiple_________underscores')).toBe('Multiple Underscores'); + }); + test('from snake case', () => { + expect(titleCase('from_lowercased_snake_case')).toBe('From Lowercased Snake Case'); + expect(titleCase('From_Capitalized_Snake_Case')).toBe('From Capitalized Snake Case'); + expect(titleCase('FROM_UPPERCASED_SNAKE_CASE')).toBe('F R O M U P P E R C A S E D S N A K E C A S E'); + expect(titleCase('fr0m_5nak3_c4s3_w1th_42n_numb3r5')).toBe('Fr0m 5nak3 C4s3 W1th 42n Numb3r5'); + expect(titleCase('frøm_snake_case_w:th_$peçia!_ch*rs')).toBe('Fr M Snake Case W Th Pe Ia Ch Rs'); + expect(titleCase(titleCase('frøm_d0ubl3_Snake_c*se'))).toBe('Fr M D0ubl3 Snake C Se'); + }); + test('from title case', () => { + expect(titleCase('from lowercased title case')).toBe('From Lowercased Title Case'); + expect(titleCase('From Capitalized Title Case')).toBe('From Capitalized Title Case'); + expect(titleCase('FROM UPPERCASED TITLE CASE')).toBe('F R O M U P P E R C A S E D T I T L E C A S E'); + expect(titleCase('Fr0m T1tl3 C4s3 W1th 42n Numb3r5')).toBe('Fr0m T1tl3 C4s3 W1th 42n Numb3r5'); + expect(titleCase('Frøm Title Case W:th $peçia! Ch*rs')).toBe('Fr M Title Case W Th Pe Ia Ch Rs'); + expect(titleCase(titleCase('Frøm D0ubl3 Title C*se'))).toBe('Fr M D0ubl3 Title C Se'); + }); + test('from pascal case', () => { + expect(titleCase('FromPascaleCase')).toBe('From Pascale Case'); + expect(titleCase('Fr0mP45c4l3C4s3W1th42nNumb3r5')).toBe('Fr0m P45c4l3 C4s3 W1th42n Numb3r5'); + expect(titleCase('FrømPascaleCaseW:th$peçia!Ch*rs')).toBe('Fr M Pascale Case W Th Pe Ia Ch Rs'); + expect(titleCase(titleCase('FrømD0ubl3PascaleC*se'))).toBe('Fr M D0ubl3 Pascale C Se'); + }); + test('from camel case', () => { + expect(titleCase('FromCamelCase')).toBe('From Camel Case'); + expect(titleCase('Fr0mC4m3lC4s3W1th42nNumb3r5')).toBe('Fr0m C4m3l C4s3 W1th42n Numb3r5'); + expect(titleCase('FrømCamelCaseW:th$peçia!Ch*rs')).toBe('Fr M Camel Case W Th Pe Ia Ch Rs'); + expect(titleCase(titleCase('FrømD0ubl3CamelC*se'))).toBe('Fr M D0ubl3 Camel C Se'); + }); + test('from paths', () => { + expect(titleCase('crate::my_module::my_type')).toBe('Crate My Module My Type'); + expect(titleCase('/Users/username/My File.txt')).toBe('Users Username My File Txt'); + expect(titleCase('C:\\Users\\username\\My\\ File.txt')).toBe('C Users Username My File Txt'); + }); +}); + +describe('camelCase', () => { + test('casing', () => { + expect(camelCase('lowercased')).toBe('lowercased'); + expect(camelCase('UPPERCASED')).toBe('uPPERCASED'); + expect(camelCase('Capitalized')).toBe('capitalized'); + }); + test('numbers', () => { + expect(camelCase('1before after2 bet3ween')).toBe('1beforeAfter2Bet3ween'); + expect(camelCase('50m3 1 2 3 numb3rs 8 3v3rywh3r3 9')).toBe('50m3123Numb3rs83v3rywh3r39'); + expect(titleCase(camelCase('50m3 1 2 3 numb3rs 8 3v3rywh3r3 9'))).toBe('50m3123 Numb3rs83v3rywh3r39'); + }); + test('special characters', () => { + expect(camelCase('some::special\\\\chars+++in=between')).toBe('someSpecialCharsInBetween'); + expect(camelCase('$peçia!::ch*rs')).toBe('peIaChRs'); + expect(camelCase('multiple.........dots')).toBe('multipleDots'); + expect(camelCase('multiple---------dashes')).toBe('multipleDashes'); + expect(camelCase('multiple_________underscores')).toBe('multipleUnderscores'); + }); + test('from snake case', () => { + expect(camelCase('from_lowercased_snake_case')).toBe('fromLowercasedSnakeCase'); + expect(camelCase('From_Capitalized_Snake_Case')).toBe('fromCapitalizedSnakeCase'); + expect(camelCase('FROM_UPPERCASED_SNAKE_CASE')).toBe('fROMUPPERCASEDSNAKECASE'); + expect(camelCase('fr0m_5nak3_c4s3_w1th_42n_numb3r5')).toBe('fr0m5nak3C4s3W1th42nNumb3r5'); + expect(camelCase('frøm_snake_case_w:th_$peçia!_ch*rs')).toBe('frMSnakeCaseWThPeIaChRs'); + expect(camelCase(camelCase('frøm_d0ubl3_Snake_c*se'))).toBe('frMD0ubl3SnakeCSe'); + }); + test('from title case', () => { + expect(camelCase('from lowercased title case')).toBe('fromLowercasedTitleCase'); + expect(camelCase('From Capitalized Title Case')).toBe('fromCapitalizedTitleCase'); + expect(camelCase('FROM UPPERCASED TITLE CASE')).toBe('fROMUPPERCASEDTITLECASE'); + expect(camelCase('Fr0m T1tl3 C4s3 W1th 42n Numb3r5')).toBe('fr0mT1tl3C4s3W1th42nNumb3r5'); + expect(camelCase('Frøm Title Case W:th $peçia! Ch*rs')).toBe('frMTitleCaseWThPeIaChRs'); + expect(camelCase(camelCase('Frøm D0ubl3 Title C*se'))).toBe('frMD0ubl3TitleCSe'); + }); + test('from pascal case', () => { + expect(camelCase('FromPascaleCase')).toBe('fromPascaleCase'); + expect(camelCase('Fr0mP45c4l3C4s3W1th42nNumb3r5')).toBe('fr0mP45c4l3C4s3W1th42nNumb3r5'); + expect(camelCase('FrømPascaleCaseW:th$peçia!Ch*rs')).toBe('frMPascaleCaseWThPeIaChRs'); + expect(camelCase(camelCase('FrømD0ubl3PascaleC*se'))).toBe('frMD0ubl3PascaleCSe'); + }); + test('from camel case', () => { + expect(camelCase('FromCamelCase')).toBe('fromCamelCase'); + expect(camelCase('Fr0mC4m3lC4s3W1th42nNumb3r5')).toBe('fr0mC4m3lC4s3W1th42nNumb3r5'); + expect(camelCase('FrømCamelCaseW:th$peçia!Ch*rs')).toBe('frMCamelCaseWThPeIaChRs'); + expect(camelCase(camelCase('FrømD0ubl3CamelC*se'))).toBe('frMD0ubl3CamelCSe'); + }); + test('from paths', () => { + expect(camelCase('crate::my_module::my_type')).toBe('crateMyModuleMyType'); + expect(camelCase('/Users/username/My File.txt')).toBe('usersUsernameMyFileTxt'); + expect(camelCase('C:\\Users\\username\\My\\ File.txt')).toBe('cUsersUsernameMyFileTxt'); + }); + test('returns empty string for empty input', () => { + expect(camelCase('')).toBe(''); + }); +}); + +describe('pascalCase', () => { + test('casing', () => { + expect(pascalCase('lowercased')).toBe('Lowercased'); + expect(pascalCase('UPPERCASED')).toBe('UPPERCASED'); + expect(pascalCase('Capitalized')).toBe('Capitalized'); + }); + test('joins title-cased words without separators', () => { + expect(pascalCase('from snake case')).toBe('FromSnakeCase'); + expect(pascalCase('from_snake_case')).toBe('FromSnakeCase'); + expect(pascalCase('from-kebab-case')).toBe('FromKebabCase'); + expect(pascalCase('fromCamelCase')).toBe('FromCamelCase'); + expect(pascalCase('AlreadyPascal')).toBe('AlreadyPascal'); + }); + test('numbers', () => { + expect(pascalCase('1before after2 bet3ween')).toBe('1beforeAfter2Bet3ween'); + expect(pascalCase('Fr0mP45c4l3C4s3W1th42nNumb3r5')).toBe('Fr0mP45c4l3C4s3W1th42nNumb3r5'); + }); + test('special characters', () => { + expect(pascalCase('some::special\\\\chars+++in=between')).toBe('SomeSpecialCharsInBetween'); + expect(pascalCase('multiple.........dots')).toBe('MultipleDots'); + }); + test('returns empty string for empty input', () => { + expect(pascalCase('')).toBe(''); + }); +}); + +describe('kebabCase', () => { + test('casing', () => { + expect(kebabCase('lowercased')).toBe('lowercased'); + expect(kebabCase('UPPERCASED')).toBe('u-p-p-e-r-c-a-s-e-d'); + expect(kebabCase('Capitalized')).toBe('capitalized'); + }); + test('joins title-cased words with dashes and lowercases', () => { + expect(kebabCase('from snake case')).toBe('from-snake-case'); + expect(kebabCase('from_snake_case')).toBe('from-snake-case'); + expect(kebabCase('FromCamelCase')).toBe('from-camel-case'); + expect(kebabCase('FromPascalCase')).toBe('from-pascal-case'); + }); + test('numbers', () => { + expect(kebabCase('1before after2 bet3ween')).toBe('1before-after2-bet3ween'); + expect(kebabCase('Fr0mP45c4l3C4s3W1th42nNumb3r5')).toBe('fr0m-p45c4l3-c4s3-w1th42n-numb3r5'); + }); + test('special characters', () => { + expect(kebabCase('some::special\\\\chars+++in=between')).toBe('some-special-chars-in-between'); + expect(kebabCase('multiple.........dots')).toBe('multiple-dots'); + expect(kebabCase('multiple---------dashes')).toBe('multiple-dashes'); + }); + test('idempotent on its own output', () => { + expect(kebabCase(kebabCase('FromCamelCase'))).toBe('from-camel-case'); + }); +}); diff --git a/packages/fragments/test/core/createFragmentTemplate.test.ts b/packages/fragments/test/core/createFragmentTemplate.test.ts new file mode 100644 index 000000000..b5cc97aba --- /dev/null +++ b/packages/fragments/test/core/createFragmentTemplate.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import type { BaseFragment } from '../../src/core/BaseFragment'; +import { createFragmentTemplate } from '../../src/core/createFragmentTemplate'; + +type TestFragment = BaseFragment & Readonly<{ tags: ReadonlySet }>; + +const isTestFragment = (value: unknown): value is TestFragment => + typeof value === 'object' && value !== null && 'content' in value && 'tags' in value; + +const mergeTestFragments = (fragments: TestFragment[], mergeContent: (contents: string[]) => string): TestFragment => { + const tags = new Set(); + for (const f of fragments) for (const t of f.tags) tags.add(t); + return Object.freeze({ content: mergeContent(fragments.map(f => f.content)), tags }); +}; + +const f = (content: string, ...tags: string[]): TestFragment => Object.freeze({ content, tags: new Set(tags) }); + +const tag = (template: TemplateStringsArray, ...items: unknown[]): TestFragment => + createFragmentTemplate(template, items, isTestFragment, mergeTestFragments); + +describe('createFragmentTemplate', () => { + it('returns a fragment whose content matches a string-only template', () => { + const out = tag`hello world`; + expect(out.content).toBe('hello world'); + expect(out.tags.size).toBe(0); + }); + + it('inlines fragment interpolations and merges their non-content fields', () => { + const a = f('A', 'red'); + const b = f('B', 'blue'); + const out = tag`${a} & ${b}`; + expect(out.content).toBe('A & B'); + expect([...out.tags].sort()).toEqual(['blue', 'red']); + }); + + it('coerces non-fragment values to strings', () => { + expect(tag`v=${42}`.content).toBe('v=42'); + expect(tag`v=${true}`.content).toBe('v=true'); + }); + + it('elides undefined interpolations as the empty string', () => { + expect(tag`hello ${undefined}world`.content).toBe('hello world'); + }); + + it('only forwards fragments to the merger, not other values', () => { + // The merger only sees actual fragments; primitives are coerced + // inline, not passed through. This means non-fragment values can't + // contribute non-content fields — important for fields like + // imports/features that would otherwise be silently dropped. + const a = f('A', 'red'); + const out = tag`${'plain string'}|${a}|${42}`; + expect(out.content).toBe('plain string|A|42'); + expect([...out.tags]).toEqual(['red']); + }); +}); diff --git a/packages/renderers-core/test/fs.test.json b/packages/fragments/test/core/fs.test.json similarity index 100% rename from packages/renderers-core/test/fs.test.json rename to packages/fragments/test/core/fs.test.json diff --git a/packages/renderers-core/test/fs.test.ts b/packages/fragments/test/core/fs.test.ts similarity index 90% rename from packages/renderers-core/test/fs.test.ts rename to packages/fragments/test/core/fs.test.ts index 4e93ca37a..cccff4c97 100644 --- a/packages/renderers-core/test/fs.test.ts +++ b/packages/fragments/test/core/fs.test.ts @@ -1,15 +1,15 @@ import { CODAMA_ERROR__NODE_FILESYSTEM_FUNCTION_UNAVAILABLE, CodamaError } from '@codama/errors'; import { expect, test } from 'vitest'; -import { createDirectory, deleteDirectory, fileExists, readJson, writeFile } from '../src'; +import { createDirectory, deleteDirectory, fileExists, readJson, writeFile } from '../../src/core/fs'; if (__NODEJS__) { test('it reads JSON objects from files', () => { - const result = readJson('./test/fs.test.json'); + const result = readJson('./test/core/fs.test.json'); expect(result).toEqual({ key: 'value' }); }); test('it checks if a file exists', () => { - const result = fileExists('./test/fs.test.json'); + const result = fileExists('./test/core/fs.test.json'); expect(result).toBe(true); }); } else { diff --git a/packages/fragments/test/core/path.test.ts b/packages/fragments/test/core/path.test.ts new file mode 100644 index 000000000..35f2b9044 --- /dev/null +++ b/packages/fragments/test/core/path.test.ts @@ -0,0 +1,41 @@ +import { CODAMA_ERROR__NODE_FILESYSTEM_FUNCTION_UNAVAILABLE, CodamaError } from '@codama/errors'; +import { expect, test } from 'vitest'; + +import { joinPath, pathBasename, pathDirectory, relativePath } from '../../src/core/path'; + +test('it joins path together', () => { + const result = joinPath('foo', 'bar', 'baz'); + expect(result).toEqual('foo/bar/baz'); +}); + +test('it gets the directory of a path', () => { + const result = pathDirectory('foo/bar/baz'); + expect(result).toEqual('foo/bar'); +}); + +test('it gets the trailing segment of a path', () => { + expect(pathBasename('foo/bar/baz')).toEqual('baz'); +}); + +test('it returns the whole path as the basename when there is no slash', () => { + expect(pathBasename('AccountNode')).toEqual('AccountNode'); +}); + +if (__NODEJS__) { + test('it computes a relative path between two POSIX paths', () => { + expect(relativePath('typeNodes', 'shared/numberFormat')).toBe('../shared/numberFormat'); + }); + test('it returns the bare target when the two paths share a directory', () => { + expect(relativePath('typeNodes', 'typeNodes/StructTypeNode')).toBe('StructTypeNode'); + }); + test('it handles `../`-prefixed targets pointing outside the from directory', () => { + // `from=typeNodes`, `to=../Docs` → `../../Docs` (up out of `generated/`). + expect(relativePath('typeNodes', '../Docs')).toBe('../../Docs'); + }); +} else { + test('it throws on non-Node platforms', () => { + expect(() => relativePath('a', 'b')).toThrow( + new CodamaError(CODAMA_ERROR__NODE_FILESYSTEM_FUNCTION_UNAVAILABLE, { fsFunction: 'relative' }), + ); + }); +} diff --git a/packages/renderers-core/test/renderMap.test.ts b/packages/fragments/test/core/renderMap.test.ts similarity index 75% rename from packages/renderers-core/test/renderMap.test.ts rename to packages/fragments/test/core/renderMap.test.ts index 15a09841d..34e92fd72 100644 --- a/packages/renderers-core/test/renderMap.test.ts +++ b/packages/fragments/test/core/renderMap.test.ts @@ -1,15 +1,21 @@ -import { assert, describe, expect, expectTypeOf, test } from 'vitest'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { CODAMA_ERROR__NODE_FILESYSTEM_FUNCTION_UNAVAILABLE, CodamaError } from '@codama/errors'; +import { afterEach, assert, beforeEach, describe, expect, expectTypeOf, test } from 'vitest'; + +import type { BaseFragment } from '../../src/core/BaseFragment'; import { addToRenderMap, - BaseFragment, createRenderMap, mapRenderMapContent, mapRenderMapContentAsync, mergeRenderMaps, removeFromRenderMap, - RenderMap, -} from '../src'; + type RenderMap, + writeRenderMap, +} from '../../src/core/renderMap'; describe('createRenderMap', () => { test('it creates an empty render map', () => { @@ -227,3 +233,57 @@ describe('mapRenderMapContentAsync', () => { assert.isFrozen(await mapRenderMapContentAsync(createRenderMap(), c => Promise.resolve(c))); }); }); + +if (__NODEJS__) { + describe('writeRenderMap (Node)', () => { + let tmp: string; + + beforeEach(() => { + tmp = mkdtempSync(path.join(tmpdir(), 'fragments-writeRenderMap-')); + }); + + afterEach(() => { + rmSync(tmp, { force: true, recursive: true }); + }); + + test('it writes every entry to a file under basePath', () => { + writeRenderMap( + createRenderMap({ + 'a.txt': { content: 'A' }, + 'b.txt': { content: 'B' }, + }), + tmp, + ); + expect(readFileSync(path.join(tmp, 'a.txt'), 'utf-8')).toBe('A'); + expect(readFileSync(path.join(tmp, 'b.txt'), 'utf-8')).toBe('B'); + }); + + test('it auto-creates intermediate directories for nested paths', () => { + writeRenderMap(createRenderMap('sub/nested/deep.txt', { content: 'deep' }), tmp); + expect(readFileSync(path.join(tmp, 'sub/nested/deep.txt'), 'utf-8')).toBe('deep'); + }); + + test('it is a no-op for an empty render map', () => { + expect(() => writeRenderMap(createRenderMap(), tmp)).not.toThrow(); + }); + + test('it overwrites an existing file at the same path', () => { + writeRenderMap(createRenderMap('a.txt', { content: 'first' }), tmp); + writeRenderMap(createRenderMap('a.txt', { content: 'second' }), tmp); + expect(readFileSync(path.join(tmp, 'a.txt'), 'utf-8')).toBe('second'); + }); + }); +} else { + describe('writeRenderMap (non-Node)', () => { + test('it throws when called with at least one entry on a non-Node platform', () => { + const map = createRenderMap('a.txt', { content: 'A' }); + expect(() => writeRenderMap(map, '/tmp/unused')).toThrow( + new CodamaError(CODAMA_ERROR__NODE_FILESYSTEM_FUNCTION_UNAVAILABLE, { fsFunction: 'writeFileSync' }), + ); + }); + + test('it does not throw for an empty render map (writeFile never called)', () => { + expect(() => writeRenderMap(createRenderMap(), '/tmp/unused')).not.toThrow(); + }); + }); +} diff --git a/packages/fragments/test/javascript/ImportMap.test.ts b/packages/fragments/test/javascript/ImportMap.test.ts new file mode 100644 index 000000000..f2f9cf3ce --- /dev/null +++ b/packages/fragments/test/javascript/ImportMap.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; + +import { addToImportMap } from '../../src/javascript/addToImportMap'; +import { createImportMap, parseImportInput } from '../../src/javascript/ImportMap'; +import { mergeImportMaps } from '../../src/javascript/mergeImportMaps'; +import { removeFromImportMap } from '../../src/javascript/removeFromImportMap'; + +describe('parseImportInput', () => { + it('parses a plain identifier', () => { + expect(parseImportInput('Foo')).toEqual({ + importedIdentifier: 'Foo', + isType: false, + usedIdentifier: 'Foo', + }); + }); + it('parses a type-only import', () => { + expect(parseImportInput('type Foo')).toEqual({ + importedIdentifier: 'Foo', + isType: true, + usedIdentifier: 'Foo', + }); + }); + it('parses an aliased import', () => { + expect(parseImportInput('Foo as Bar')).toEqual({ + importedIdentifier: 'Foo', + isType: false, + usedIdentifier: 'Bar', + }); + }); + it('parses a type-only aliased import', () => { + expect(parseImportInput('type Foo as Bar')).toEqual({ + importedIdentifier: 'Foo', + isType: true, + usedIdentifier: 'Bar', + }); + }); +}); + +describe('createImportMap', () => { + it('returns an empty frozen map', () => { + const map = createImportMap(); + expect(map.size).toBe(0); + expect(Object.isFrozen(map)).toBe(true); + }); +}); + +describe('addToImportMap', () => { + it('adds imports to an empty map', () => { + const map = addToImportMap(createImportMap(), './foo', ['Foo', 'Bar']); + expect(map.size).toBe(1); + expect(map.get('./foo')?.size).toBe(2); + }); + it('returns the same map when called with no imports', () => { + const empty = createImportMap(); + expect(addToImportMap(empty, './foo', [])).toBe(empty); + }); + it('merges into an existing module', () => { + let map = addToImportMap(createImportMap(), './foo', ['A']); + map = addToImportMap(map, './foo', ['B']); + expect(map.get('./foo')?.size).toBe(2); + }); + it('promotes a value import over a type-only import within the same batch', () => { + // Type-only first, value second. + const a = addToImportMap(createImportMap(), './foo', ['type Foo', 'Foo']); + expect(a.get('./foo')?.get('Foo')?.isType).toBe(false); + // Value first, type-only second — should not be downgraded. + const b = addToImportMap(createImportMap(), './foo', ['Foo', 'type Foo']); + expect(b.get('./foo')?.get('Foo')?.isType).toBe(false); + }); +}); + +describe('removeFromImportMap', () => { + it('removes named identifiers', () => { + const map = addToImportMap(createImportMap(), './foo', ['A', 'B']); + const after = removeFromImportMap(map, './foo', ['A']); + expect(after.get('./foo')?.size).toBe(1); + expect(after.get('./foo')?.has('A')).toBe(false); + }); + it('drops the module entirely when no identifiers remain', () => { + const map = addToImportMap(createImportMap(), './foo', ['A']); + const after = removeFromImportMap(map, './foo', ['A']); + expect(after.has('./foo')).toBe(false); + }); + it('silently ignores names that are not present', () => { + const map = addToImportMap(createImportMap(), './foo', ['A']); + const after = removeFromImportMap(map, './foo', ['NotThere']); + expect(after.get('./foo')?.size).toBe(1); + }); +}); + +describe('mergeImportMaps', () => { + it('returns the empty map for an empty input', () => { + expect(mergeImportMaps([]).size).toBe(0); + }); + it('returns the single map when given one input', () => { + const map = addToImportMap(createImportMap(), './foo', ['A']); + expect(mergeImportMaps([map])).toBe(map); + }); + it('merges across modules', () => { + const a = addToImportMap(createImportMap(), './foo', ['A']); + const b = addToImportMap(createImportMap(), './bar', ['B']); + const merged = mergeImportMaps([a, b]); + expect(merged.size).toBe(2); + }); + it('promotes a value import over a type-only import of the same name', () => { + const a = addToImportMap(createImportMap(), './foo', ['type Foo']); + const b = addToImportMap(createImportMap(), './foo', ['Foo']); + const merged = mergeImportMaps([a, b]); + expect(merged.get('./foo')?.get('Foo')?.isType).toBe(false); + }); + it('does not downgrade a value import to type-only when the type-only entry comes second', () => { + const a = addToImportMap(createImportMap(), './foo', ['Foo']); + const b = addToImportMap(createImportMap(), './foo', ['type Foo']); + const merged = mergeImportMaps([a, b]); + expect(merged.get('./foo')?.get('Foo')?.isType).toBe(false); + }); +}); diff --git a/packages/fragments/test/javascript/fragment.test.ts b/packages/fragments/test/javascript/fragment.test.ts new file mode 100644 index 000000000..9f7b009f6 --- /dev/null +++ b/packages/fragments/test/javascript/fragment.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; + +import { addToImportMap } from '../../src/javascript/addToImportMap'; +import { + addFragmentImports, + fragment, + isFragment, + mergeFragmentImports, + mergeFragments, + removeFragmentImports, + use, +} from '../../src/javascript/fragment'; +import { createImportMap } from '../../src/javascript/ImportMap'; + +describe('fragment template tag', () => { + it('produces a frozen fragment with no imports', () => { + const f = fragment`hello`; + expect(Object.isFrozen(f)).toBe(true); + expect(f.content).toBe('hello'); + expect(f.imports.size).toBe(0); + }); + it('joins content with no interpolations', () => { + expect(fragment`hello`.content).toBe('hello'); + }); + it('inlines string interpolations verbatim', () => { + expect(fragment`hello ${'world'}`.content).toBe('hello world'); + }); + it('inlines fragments and propagates their imports', () => { + const inner = use('Foo', './foo'); + const outer = fragment`type X = ${inner};`; + expect(outer.content).toBe('type X = Foo;'); + expect(outer.imports.get('./foo')?.has('Foo')).toBe(true); + }); + it('elides undefined interpolations', () => { + expect(fragment`hello ${undefined}world`.content).toBe('hello world'); + }); + it('coerces non-fragment values to strings', () => { + expect(fragment`v=${42}`.content).toBe('v=42'); + expect(fragment`v=${true}`.content).toBe('v=true'); + }); + it('merges imports across multiple interpolated fragments', () => { + const a = use('A', './a'); + const b = use('B', './b'); + const merged = fragment`${a} & ${b}`; + expect(merged.content).toBe('A & B'); + expect(merged.imports.size).toBe(2); + }); +}); + +describe('isFragment', () => { + it('returns true for fragments', () => { + expect(isFragment(fragment`x`)).toBe(true); + expect(isFragment(use('Foo', './foo'))).toBe(true); + }); + it('returns false for non-fragments', () => { + expect(isFragment('x')).toBe(false); + expect(isFragment(null)).toBe(false); + expect(isFragment(undefined)).toBe(false); + expect(isFragment(42)).toBe(false); + expect(isFragment({ content: 'x' })).toBe(false); // missing imports + }); +}); + +describe('mergeFragments', () => { + it('combines content via the merge function', () => { + const merged = mergeFragments([fragment`a`, fragment`b`], parts => parts.join('+')); + expect(merged.content).toBe('a+b'); + }); + it('merges imports automatically', () => { + const merged = mergeFragments([use('A', './a'), use('B', './b')], parts => parts.join('')); + expect(merged.imports.size).toBe(2); + }); + it('skips undefined fragments', () => { + const merged = mergeFragments([fragment`a`, undefined, fragment`c`], parts => parts.join('|')); + expect(merged.content).toBe('a|c'); + }); +}); + +describe('use', () => { + it('produces a fragment whose content is the used identifier', () => { + expect(use('Foo', './foo').content).toBe('Foo'); + expect(use('Foo as Bar', './foo').content).toBe('Bar'); + }); + it('records the import on the resulting fragment', () => { + const f = use('type Foo', './foo'); + expect(f.imports.get('./foo')?.get('Foo')?.isType).toBe(true); + }); + it('preserves the alias on type-only imports', () => { + const f = use('type Foo as Bar', './foo'); + expect(f.content).toBe('Bar'); + expect(f.imports.get('./foo')?.get('Bar')).toEqual({ + importedIdentifier: 'Foo', + isType: true, + usedIdentifier: 'Bar', + }); + }); +}); + +describe('addFragmentImports', () => { + it('adds imports without changing content', () => { + const f = addFragmentImports(fragment`hello`, './foo', ['Foo']); + expect(f.content).toBe('hello'); + expect(f.imports.get('./foo')?.has('Foo')).toBe(true); + }); +}); + +describe('mergeFragmentImports', () => { + it('merges additional import maps into a fragment', () => { + const extra = addToImportMap(createImportMap(), './foo', ['Foo']); + const f = mergeFragmentImports(fragment`hello`, [extra]); + expect(f.content).toBe('hello'); + expect(f.imports.get('./foo')?.has('Foo')).toBe(true); + }); +}); + +describe('removeFragmentImports', () => { + it('removes named identifiers from a fragment', () => { + let f = addFragmentImports(fragment`hello`, './foo', ['A', 'B']); + f = removeFragmentImports(f, './foo', ['A']); + expect(f.imports.get('./foo')?.has('A')).toBe(false); + expect(f.imports.get('./foo')?.has('B')).toBe(true); + }); +}); diff --git a/packages/fragments/test/javascript/getDocblockFragment.test.ts b/packages/fragments/test/javascript/getDocblockFragment.test.ts new file mode 100644 index 000000000..01a3ca3db --- /dev/null +++ b/packages/fragments/test/javascript/getDocblockFragment.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import { getDocblockFragment } from '../../src/javascript/getDocblockFragment'; + +describe('getDocblockFragment', () => { + it('returns undefined for an empty input', () => { + expect(getDocblockFragment([])).toBeUndefined(); + }); + + it('returns undefined for an undefined input', () => { + expect(getDocblockFragment(undefined)).toBeUndefined(); + }); + + it('renders a single-line docblock', () => { + const f = getDocblockFragment(['Greets the user.']); + expect(f?.content).toBe('/** Greets the user. */'); + }); + + it('renders a multi-line docblock with star-prefixed lines', () => { + const f = getDocblockFragment(['First line.', 'Second line.']); + expect(f?.content).toBe('/**\n * First line.\n * Second line.\n */'); + }); + + it('renders empty lines as bare ` *` separators', () => { + const f = getDocblockFragment(['First line.', '', 'Second paragraph.']); + expect(f?.content).toBe('/**\n * First line.\n *\n * Second paragraph.\n */'); + }); + + it('appends a trailing newline when withLineJump is true', () => { + const single = getDocblockFragment(['One line.'], { withLineJump: true }); + expect(single?.content).toBe('/** One line. */\n'); + const multi = getDocblockFragment(['A.', 'B.'], { withLineJump: true }); + expect(multi?.content).toBe('/**\n * A.\n * B.\n */\n'); + }); + + it('defangs `*/` sequences in input lines so they cannot close the docblock early', () => { + const f = getDocblockFragment(['Naive */ injection attempt.']); + expect(f?.content).toBe('/** Naive *\\/ injection attempt. */'); + }); + + it('defangs `*/` in multi-line docblocks too', () => { + const f = getDocblockFragment(['Line one.', 'Line */ two.', 'Line three.']); + expect(f?.content).toBe('/**\n * Line one.\n * Line *\\/ two.\n * Line three.\n */'); + }); + + it('returns a fragment carrying no imports', () => { + const f = getDocblockFragment(['Hello.']); + expect(f?.imports.size).toBe(0); + }); +}); diff --git a/packages/fragments/test/javascript/getExportAllFragment.test.ts b/packages/fragments/test/javascript/getExportAllFragment.test.ts new file mode 100644 index 000000000..d8c589061 --- /dev/null +++ b/packages/fragments/test/javascript/getExportAllFragment.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { getExportAllFragment } from '../../src/javascript/getExportAllFragment'; + +describe('getExportAllFragment', () => { + it('renders an export-all statement for a relative module', () => { + const f = getExportAllFragment('./accounts'); + expect(f.content).toBe(`export * from './accounts';`); + }); + + it('renders an export-all statement for a non-relative module', () => { + const f = getExportAllFragment('@codama/spec'); + expect(f.content).toBe(`export * from '@codama/spec';`); + }); + + it('does not add anything to the import map', () => { + const f = getExportAllFragment('./accounts'); + expect(f.imports.size).toBe(0); + }); +}); diff --git a/packages/fragments/test/javascript/getExternalDependencies.test.ts b/packages/fragments/test/javascript/getExternalDependencies.test.ts new file mode 100644 index 000000000..c34f7cd3b --- /dev/null +++ b/packages/fragments/test/javascript/getExternalDependencies.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import { addToImportMap } from '../../src/javascript/addToImportMap'; +import { getExternalDependencies } from '../../src/javascript/getExternalDependencies'; +import { createImportMap } from '../../src/javascript/ImportMap'; + +describe('getExternalDependencies', () => { + it('returns the empty set for an empty map', () => { + expect(getExternalDependencies(createImportMap()).size).toBe(0); + }); + + it('returns root names for unscoped packages', () => { + let map = createImportMap(); + map = addToImportMap(map, 'react', ['useState']); + map = addToImportMap(map, 'lodash/get', ['get']); + expect(getExternalDependencies(map)).toEqual(new Set(['react', 'lodash'])); + }); + + it('returns scoped root names for scoped packages', () => { + let map = createImportMap(); + map = addToImportMap(map, '@solana/kit', ['Address']); + map = addToImportMap(map, '@solana/kit/program-client-core', ['ProgramClient']); + expect(getExternalDependencies(map)).toEqual(new Set(['@solana/kit'])); + }); + + it('excludes relative imports', () => { + let map = createImportMap(); + map = addToImportMap(map, '../shared', ['Local']); + map = addToImportMap(map, './sibling', ['Sibling']); + map = addToImportMap(map, '@codama/spec', ['Spec']); + expect(getExternalDependencies(map)).toEqual(new Set(['@codama/spec'])); + }); + + it('uses resolved module names when a dependency map is provided', () => { + let map = createImportMap(); + map = addToImportMap(map, 'solanaAddresses', ['Address']); + map = addToImportMap(map, 'generatedAccounts', ['MyAccount']); + const deps = getExternalDependencies(map, { + generatedAccounts: '../accounts', + solanaAddresses: '@solana/kit', + }); + expect(deps).toEqual(new Set(['@solana/kit'])); + }); +}); diff --git a/packages/fragments/test/javascript/importMapToString.test.ts b/packages/fragments/test/javascript/importMapToString.test.ts new file mode 100644 index 000000000..eea3aa73c --- /dev/null +++ b/packages/fragments/test/javascript/importMapToString.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; + +import { addToImportMap } from '../../src/javascript/addToImportMap'; +import { createImportMap } from '../../src/javascript/ImportMap'; +import { importMapToString } from '../../src/javascript/importMapToString'; + +describe('importMapToString', () => { + it('emits empty string for an empty map', () => { + expect(importMapToString(createImportMap())).toBe(''); + }); + + it('sorts non-relative paths first, then relative; alphabetical within each group', () => { + let map = createImportMap(); + map = addToImportMap(map, './local', ['Local']); + map = addToImportMap(map, '@codama/spec', ['Spec']); + map = addToImportMap(map, '../shared', ['Shared']); + const out = importMapToString(map); + // Within the relative group, alphabetical: '../shared' < './local' + // because '.' (46) < '/' (47) at position 1. + expect(out).toBe( + "import { Spec } from '@codama/spec';\n" + + "import { Shared } from '../shared';\n" + + "import { Local } from './local';", + ); + }); + + it('renders mixed-form imports with per-identifier type prefix', () => { + const map = addToImportMap(createImportMap(), './foo', ['type Foo as Bar', 'Baz']); + expect(importMapToString(map)).toBe("import { Baz, type Foo as Bar } from './foo';"); + }); + + it('promotes to block-level `import type` when all imports are type-only', () => { + const map = addToImportMap(createImportMap(), './foo', ['type Foo', 'type Bar']); + expect(importMapToString(map)).toBe("import type { Bar, Foo } from './foo';"); + }); + + it('preserves aliasing under a block-level `import type`', () => { + const map = addToImportMap(createImportMap(), './foo', ['type Foo as Bar']); + expect(importMapToString(map)).toBe("import type { Foo as Bar } from './foo';"); + }); + + it('alphabetizes identifiers across multiple modules', () => { + let map = createImportMap(); + map = addToImportMap(map, './foo', ['Zebra', 'Apple']); + map = addToImportMap(map, './bar', ['Mango', 'Banana']); + const out = importMapToString(map); + expect(out).toBe("import { Banana, Mango } from './bar';\nimport { Apple, Zebra } from './foo';"); + }); + + it('resolves symbolic module names through the dependency map', () => { + let map = createImportMap(); + map = addToImportMap(map, 'solanaAddresses', ['Address']); + map = addToImportMap(map, 'generatedAccounts', ['MyAccount']); + const out = importMapToString(map, { + generatedAccounts: '../accounts', + solanaAddresses: '@solana/kit', + }); + expect(out).toBe("import { Address } from '@solana/kit';\nimport { MyAccount } from '../accounts';"); + }); + + it('merges identifiers when two source modules resolve to the same target', () => { + let map = createImportMap(); + map = addToImportMap(map, 'solanaAddresses', ['Address']); + map = addToImportMap(map, 'solanaCodecsCore', ['Codec']); + const out = importMapToString(map, { + solanaAddresses: '@solana/kit', + solanaCodecsCore: '@solana/kit', + }); + expect(out).toBe("import { Address, Codec } from '@solana/kit';"); + }); +}); diff --git a/packages/fragments/test/javascript/resolveImportMap.test.ts b/packages/fragments/test/javascript/resolveImportMap.test.ts new file mode 100644 index 000000000..90d2bc0dd --- /dev/null +++ b/packages/fragments/test/javascript/resolveImportMap.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { addToImportMap } from '../../src/javascript/addToImportMap'; +import { createImportMap } from '../../src/javascript/ImportMap'; +import { resolveImportMap } from '../../src/javascript/resolveImportMap'; + +describe('resolveImportMap', () => { + it('rewrites the module key when the dependency map has a matching entry', () => { + let map = createImportMap(); + map = addToImportMap(map, 'solanaAddresses', ['Address']); + const resolved = resolveImportMap(map, { solanaAddresses: '@solana/kit' }); + expect(resolved.has('@solana/kit')).toBe(true); + expect(resolved.has('solanaAddresses')).toBe(false); + }); + + it('preserves the inner identifier map of a resolved module', () => { + let map = createImportMap(); + map = addToImportMap(map, 'solanaAddresses', ['type Address', 'getAddressFromPublicKey']); + const resolved = resolveImportMap(map, { solanaAddresses: '@solana/kit' }); + const inner = resolved.get('@solana/kit'); + expect(inner?.size).toBe(2); + expect(inner?.get('Address')?.isType).toBe(true); + }); + + it('leaves unmapped modules unchanged', () => { + let map = createImportMap(); + map = addToImportMap(map, '@codama/spec', ['Spec']); + map = addToImportMap(map, '../shared', ['Local']); + const resolved = resolveImportMap(map, { solanaAddresses: '@solana/kit' }); + expect(resolved.has('@codama/spec')).toBe(true); + expect(resolved.has('../shared')).toBe(true); + }); + + it('merges inner maps when two source modules resolve to the same target', () => { + let map = createImportMap(); + map = addToImportMap(map, 'solanaAddresses', ['Address']); + map = addToImportMap(map, 'solanaCodecsCore', ['Codec']); + const resolved = resolveImportMap(map, { + solanaAddresses: '@solana/kit', + solanaCodecsCore: '@solana/kit', + }); + const inner = resolved.get('@solana/kit'); + expect(inner?.size).toBe(2); + expect(inner?.has('Address')).toBe(true); + expect(inner?.has('Codec')).toBe(true); + }); + + it('returns the input map unchanged when the dependency map is empty', () => { + const map = addToImportMap(createImportMap(), './foo', ['Foo']); + // The function may return the input directly when there's nothing to do. + expect(resolveImportMap(map, {})).toEqual(map); + }); + + it('returns the input map unchanged when the input is empty', () => { + const map = createImportMap(); + expect(resolveImportMap(map, { solanaAddresses: '@solana/kit' })).toBe(map); + }); +}); diff --git a/packages/fragments/test/rust/ImportMap.test.ts b/packages/fragments/test/rust/ImportMap.test.ts new file mode 100644 index 000000000..238dfe5b4 --- /dev/null +++ b/packages/fragments/test/rust/ImportMap.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'vitest'; + +import { addAliasToImportMap } from '../../src/rust/addAliasToImportMap'; +import { addToImportMap } from '../../src/rust/addToImportMap'; +import { getExternalDependencies } from '../../src/rust/getExternalDependencies'; +import { createImportMap, RUST_CORE_IMPORTS } from '../../src/rust/ImportMap'; +import { importMapToString } from '../../src/rust/importMapToString'; +import { mergeImportMaps } from '../../src/rust/mergeImportMaps'; +import { removeFromImportMap } from '../../src/rust/removeFromImportMap'; +import { resolveImportMap } from '../../src/rust/resolveImportMap'; + +describe('createImportMap', () => { + it('returns an empty frozen map', () => { + const map = createImportMap(); + expect(map.size).toBe(0); + expect(Object.isFrozen(map)).toBe(true); + }); +}); + +describe('addToImportMap', () => { + it('accepts a single string', () => { + const map = addToImportMap(createImportMap(), 'foo::Bar'); + expect(map.has('foo::Bar')).toBe(true); + expect(map.size).toBe(1); + }); + it('accepts an array', () => { + const map = addToImportMap(createImportMap(), ['foo::A', 'foo::B']); + expect(map.size).toBe(2); + }); + it('accepts a Set', () => { + const map = addToImportMap(createImportMap(), new Set(['foo::A', 'foo::B'])); + expect(map.size).toBe(2); + }); + it('returns the input map unchanged when called with no paths', () => { + const empty = createImportMap(); + expect(addToImportMap(empty, [])).toBe(empty); + }); + it('returns the input map unchanged when every path is already present', () => { + const map = addToImportMap(createImportMap(), ['foo::A']); + expect(addToImportMap(map, 'foo::A')).toBe(map); + }); + it('preserves an existing alias when the same path is re-added', () => { + let map = addAliasToImportMap(createImportMap(), 'foo::Bar', 'Baz'); + map = addToImportMap(map, 'foo::Bar'); + expect(map.get('foo::Bar')?.alias).toBe('Baz'); + }); + it('does not mutate the input map', () => { + const map = createImportMap(); + addToImportMap(map, 'foo::Bar'); + expect(map.size).toBe(0); + }); +}); + +describe('addAliasToImportMap', () => { + it('records an alias for a previously unimported path', () => { + const map = addAliasToImportMap(createImportMap(), 'foo::Bar', 'Baz'); + expect(map.get('foo::Bar')?.alias).toBe('Baz'); + expect(map.get('foo::Bar')?.importedPath).toBe('foo::Bar'); + }); + it('replaces an existing alias for the same path', () => { + let map = addAliasToImportMap(createImportMap(), 'foo::Bar', 'A'); + map = addAliasToImportMap(map, 'foo::Bar', 'B'); + expect(map.get('foo::Bar')?.alias).toBe('B'); + }); + it('does not mutate the input map', () => { + const map = createImportMap(); + addAliasToImportMap(map, 'foo::Bar', 'Baz'); + expect(map.size).toBe(0); + }); +}); + +describe('removeFromImportMap', () => { + it('drops a single path', () => { + let map = addToImportMap(createImportMap(), ['foo::A', 'foo::B']); + map = removeFromImportMap(map, 'foo::A'); + expect(map.has('foo::A')).toBe(false); + expect(map.has('foo::B')).toBe(true); + }); + it('drops an array of paths', () => { + let map = addToImportMap(createImportMap(), ['foo::A', 'foo::B', 'foo::C']); + map = removeFromImportMap(map, ['foo::A', 'foo::B']); + expect(map.size).toBe(1); + }); + it('silently ignores missing paths', () => { + const map = addToImportMap(createImportMap(), 'foo::A'); + const after = removeFromImportMap(map, 'foo::NotThere'); + expect(after.size).toBe(1); + }); + it('returns the input map unchanged when called with no paths', () => { + const map = addToImportMap(createImportMap(), 'foo::A'); + expect(removeFromImportMap(map, [])).toBe(map); + }); +}); + +describe('mergeImportMaps', () => { + it('returns the empty map for an empty input', () => { + expect(mergeImportMaps([]).size).toBe(0); + }); + it('returns the single map when given one input', () => { + const map = addToImportMap(createImportMap(), 'foo::A'); + expect(mergeImportMaps([map])).toBe(map); + }); + it('combines paths from multiple maps', () => { + const a = addToImportMap(createImportMap(), 'foo::A'); + const b = addToImportMap(createImportMap(), 'bar::B'); + expect(mergeImportMaps([a, b]).size).toBe(2); + }); + it('combines aliases from multiple maps', () => { + const a = addAliasToImportMap(createImportMap(), 'foo::A', 'AlphaA'); + const b = addAliasToImportMap(createImportMap(), 'bar::B', 'BravoB'); + const merged = mergeImportMaps([a, b]); + expect(merged.get('foo::A')?.alias).toBe('AlphaA'); + expect(merged.get('bar::B')?.alias).toBe('BravoB'); + }); + it('lets the latest map win on alias conflicts for the same path', () => { + const a = addAliasToImportMap(createImportMap(), 'foo::A', 'Old'); + const b = addAliasToImportMap(createImportMap(), 'foo::A', 'New'); + expect(mergeImportMaps([a, b]).get('foo::A')?.alias).toBe('New'); + }); +}); + +describe('resolveImportMap', () => { + it('rewrites the leading prefix when a matching dependency is provided', () => { + const map = addToImportMap(createImportMap(), 'generated::accounts::AccountNode'); + const resolved = resolveImportMap(map, { generated: 'crate::generated' }); + expect(resolved.has('crate::generated::accounts::AccountNode')).toBe(true); + expect(resolved.has('generated::accounts::AccountNode')).toBe(false); + }); + it('preserves aliases under prefix resolution', () => { + let map = addToImportMap(createImportMap(), 'generated::accounts::AccountNode'); + map = addAliasToImportMap(map, 'generated::accounts::AccountNode', 'AcctNode'); + const resolved = resolveImportMap(map, { generated: 'crate::generated' }); + expect(resolved.get('crate::generated::accounts::AccountNode')?.alias).toBe('AcctNode'); + }); + it('leaves unmatched paths unchanged', () => { + const map = addToImportMap(createImportMap(), 'foo::Bar'); + const resolved = resolveImportMap(map, { generated: 'crate::generated' }); + expect(resolved.has('foo::Bar')).toBe(true); + }); + it('returns the input map unchanged when nothing matches', () => { + const map = addToImportMap(createImportMap(), 'foo::Bar'); + expect(resolveImportMap(map, { generated: 'crate::generated' })).toBe(map); + }); + it('returns the input map unchanged when dependencies is empty', () => { + const map = addToImportMap(createImportMap(), 'foo::Bar'); + expect(resolveImportMap(map, {})).toBe(map); + }); + it('does not mutate the input map', () => { + const map = addToImportMap(createImportMap(), 'generated::Bar'); + resolveImportMap(map, { generated: 'crate::generated' }); + expect(map.has('generated::Bar')).toBe(true); + }); +}); + +describe('getExternalDependencies', () => { + it('returns top-level crate names', () => { + const map = addToImportMap(createImportMap(), [ + 'borsh::BorshDeserialize', + 'solana_program::pubkey::Pubkey', + 'std::collections::HashMap', + ]); + expect(getExternalDependencies(map)).toEqual(new Set(['borsh', 'solana_program'])); + }); + it('excludes Rust core crates', () => { + let map = createImportMap(); + for (const core of RUST_CORE_IMPORTS) map = addToImportMap(map, `${core}::Foo`); + expect(getExternalDependencies(map).size).toBe(0); + }); + it('uses resolved paths so symbolic prefixes are excluded too', () => { + const map = addToImportMap(createImportMap(), ['generated::accounts::A', 'borsh::BorshSerialize']); + const deps = getExternalDependencies(map, { generated: 'crate::generated' }); + expect(deps).toEqual(new Set(['borsh'])); + }); +}); + +describe('importMapToString', () => { + it('emits one use statement per import', () => { + const map = addToImportMap(createImportMap(), ['borsh::BorshDeserialize', 'borsh::BorshSerialize']); + expect(importMapToString(map)).toBe('use borsh::BorshDeserialize;\nuse borsh::BorshSerialize;'); + }); + it('emits use foo::Bar as Baz; for aliased imports', () => { + const map = addAliasToImportMap(createImportMap(), 'solana_program::program_error::ProgramError', 'ProgError'); + expect(importMapToString(map)).toBe('use solana_program::program_error::ProgramError as ProgError;'); + }); + it('applies dependency-map resolution before rendering', () => { + const map = addToImportMap(createImportMap(), 'generated::accounts::A'); + expect(importMapToString(map, { generated: 'crate::generated' })).toBe('use crate::generated::accounts::A;'); + }); + it('renders the empty string for an empty map', () => { + expect(importMapToString(createImportMap())).toBe(''); + }); + it('sorts the rendered statements alphabetically for stable diffs', () => { + const map = addToImportMap(createImportMap(), ['zeta::Z', 'alpha::A', 'beta::B']); + expect(importMapToString(map)).toBe('use alpha::A;\nuse beta::B;\nuse zeta::Z;'); + }); +}); diff --git a/packages/fragments/test/rust/fragment.test.ts b/packages/fragments/test/rust/fragment.test.ts new file mode 100644 index 000000000..64649ed9f --- /dev/null +++ b/packages/fragments/test/rust/fragment.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; + +import { addToImportMap } from '../../src/rust/addToImportMap'; +import { + addFragmentImportAlias, + addFragmentImports, + fragment, + isFragment, + mergeFragmentImports, + mergeFragments, + removeFragmentImports, +} from '../../src/rust/fragment'; +import { createImportMap } from '../../src/rust/ImportMap'; + +describe('fragment template tag', () => { + it('produces a frozen fragment with an empty import map', () => { + const f = fragment`pub struct AccountNode {}`; + expect(Object.isFrozen(f)).toBe(true); + expect(f.content).toBe('pub struct AccountNode {}'); + expect(f.imports.size).toBe(0); + }); + it('inlines string interpolations verbatim', () => { + expect(fragment`name = ${'Account'}`.content).toBe('name = Account'); + }); + it('inlines fragments and propagates their imports', () => { + const inner = addFragmentImports(fragment`Pubkey`, ['solana_program::pubkey::Pubkey']); + const outer = fragment`pubkey: ${inner}`; + expect(outer.content).toBe('pubkey: Pubkey'); + expect(outer.imports.has('solana_program::pubkey::Pubkey')).toBe(true); + }); + it('elides undefined interpolations', () => { + expect(fragment`hello ${undefined}world`.content).toBe('hello world'); + }); + it('coerces non-fragment values to strings', () => { + expect(fragment`v=${42}`.content).toBe('v=42'); + expect(fragment`v=${true}`.content).toBe('v=true'); + }); + it('merges imports across multiple interpolated fragments', () => { + const a = addFragmentImports(fragment`A`, ['foo::A']); + const b = addFragmentImports(fragment`B`, ['bar::B']); + const merged = fragment`${a} & ${b}`; + expect(merged.content).toBe('A & B'); + expect(merged.imports.size).toBe(2); + }); +}); + +describe('isFragment', () => { + it('returns true for fragments', () => { + expect(isFragment(fragment`x`)).toBe(true); + }); + it('returns false for non-fragments', () => { + expect(isFragment('x')).toBe(false); + expect(isFragment(null)).toBe(false); + expect(isFragment({ content: 'x' })).toBe(false); + }); +}); + +describe('mergeFragments', () => { + it('combines content via the merge function', () => { + const merged = mergeFragments([fragment`a`, fragment`b`], parts => parts.join('+')); + expect(merged.content).toBe('a+b'); + }); + it('merges imports automatically', () => { + const a = addFragmentImports(fragment`a`, ['foo::A']); + const b = addFragmentImports(fragment`b`, ['bar::B']); + const merged = mergeFragments([a, b], parts => parts.join('')); + expect(merged.imports.size).toBe(2); + }); + it('does not mutate input fragments imports when merging', () => { + const a = addFragmentImports(fragment`a`, ['foo::A']); + mergeFragments([a, addFragmentImports(fragment`b`, ['bar::B'])], parts => parts.join('')); + expect(a.imports.size).toBe(1); + }); + it('skips undefined fragments', () => { + const merged = mergeFragments([fragment`a`, undefined, fragment`c`], parts => parts.join('|')); + expect(merged.content).toBe('a|c'); + }); +}); + +describe('addFragmentImports', () => { + it('adds a single import without changing content', () => { + const f = addFragmentImports(fragment`Pubkey`, 'solana_program::pubkey::Pubkey'); + expect(f.content).toBe('Pubkey'); + expect(f.imports.has('solana_program::pubkey::Pubkey')).toBe(true); + }); + it('adds an array of imports', () => { + const f = addFragmentImports(fragment`x`, ['foo::A', 'foo::B']); + expect(f.imports.size).toBe(2); + }); + it('does not mutate the source fragment', () => { + const f = fragment`x`; + addFragmentImports(f, ['foo::A']); + expect(f.imports.size).toBe(0); + }); +}); + +describe('addFragmentImportAlias', () => { + it('records an alias for an imported path', () => { + const f = addFragmentImportAlias(fragment`x`, 'foo::Bar', 'Baz'); + expect(f.imports.get('foo::Bar')?.alias).toBe('Baz'); + }); + it('does not mutate the source fragment', () => { + const f = fragment`x`; + addFragmentImportAlias(f, 'foo::Bar', 'Baz'); + expect(f.imports.size).toBe(0); + }); +}); + +describe('mergeFragmentImports', () => { + it('merges additional import maps into a fragment', () => { + const extra = addToImportMap(createImportMap(), 'foo::Bar'); + const f = mergeFragmentImports(fragment`x`, [extra]); + expect(f.imports.has('foo::Bar')).toBe(true); + }); +}); + +describe('removeFragmentImports', () => { + it('drops paths from a fragment import map', () => { + let f = addFragmentImports(fragment`x`, ['foo::A', 'foo::B']); + f = removeFragmentImports(f, 'foo::A'); + expect(f.imports.has('foo::A')).toBe(false); + expect(f.imports.has('foo::B')).toBe(true); + }); +}); diff --git a/packages/fragments/test/rust/getDocblockFragment.test.ts b/packages/fragments/test/rust/getDocblockFragment.test.ts new file mode 100644 index 000000000..af26dcad7 --- /dev/null +++ b/packages/fragments/test/rust/getDocblockFragment.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { getDocblockFragment } from '../../src/rust/getDocblockFragment'; + +describe('getDocblockFragment', () => { + it('returns undefined for an empty input', () => { + expect(getDocblockFragment([])).toBeUndefined(); + }); + + it('returns undefined for an undefined input', () => { + expect(getDocblockFragment(undefined)).toBeUndefined(); + }); + + it('renders a single-line outer doc comment', () => { + const f = getDocblockFragment(['Greets the user.']); + expect(f?.content).toBe('/// Greets the user.'); + }); + + it('renders a multi-line outer doc comment', () => { + const f = getDocblockFragment(['First line.', 'Second line.']); + expect(f?.content).toBe('/// First line.\n/// Second line.'); + }); + + it('renders empty lines as a bare prefix line', () => { + const f = getDocblockFragment(['First line.', '', 'Second paragraph.']); + expect(f?.content).toBe('/// First line.\n///\n/// Second paragraph.'); + }); + + it('renders inner doc comments when internal is true', () => { + const f = getDocblockFragment(['Module docs.'], { internal: true }); + expect(f?.content).toBe('//! Module docs.'); + }); + + it('renders multi-line inner doc comments', () => { + const f = getDocblockFragment(['First line.', '', 'Second.'], { internal: true }); + expect(f?.content).toBe('//! First line.\n//!\n//! Second.'); + }); + + it('appends a trailing newline when withLineJump is true', () => { + const f = getDocblockFragment(['One.', 'Two.'], { withLineJump: true }); + expect(f?.content).toBe('/// One.\n/// Two.\n'); + }); + + it('combines internal and withLineJump', () => { + const f = getDocblockFragment(['Crate-level docs.'], { internal: true, withLineJump: true }); + expect(f?.content).toBe('//! Crate-level docs.\n'); + }); + + it('returns a fragment carrying no imports', () => { + const f = getDocblockFragment(['Hello.']); + expect(f?.imports.size).toBe(0); + }); +}); diff --git a/packages/fragments/test/types/global.d.ts b/packages/fragments/test/types/global.d.ts new file mode 100644 index 000000000..13de8a7ce --- /dev/null +++ b/packages/fragments/test/types/global.d.ts @@ -0,0 +1,6 @@ +declare const __BROWSER__: boolean; +declare const __ESM__: boolean; +declare const __NODEJS__: boolean; +declare const __REACTNATIVE__: boolean; +declare const __TEST__: boolean; +declare const __VERSION__: string; diff --git a/packages/fragments/tsconfig.declarations.json b/packages/fragments/tsconfig.declarations.json new file mode 100644 index 000000000..2141817ba --- /dev/null +++ b/packages/fragments/tsconfig.declarations.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "./dist/types" + }, + "extends": "./tsconfig.json", + "include": ["src/index.ts", "src/javascript/index.ts", "src/rust/index.ts", "src/types"] +} diff --git a/packages/fragments/tsconfig.json b/packages/fragments/tsconfig.json new file mode 100644 index 000000000..5cde7d554 --- /dev/null +++ b/packages/fragments/tsconfig.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "@codama/fragments", + "extends": "../../tsconfig.json", + "include": ["src", "test"] +} diff --git a/packages/fragments/tsup.config.ts b/packages/fragments/tsup.config.ts new file mode 100644 index 000000000..de5bb9f89 --- /dev/null +++ b/packages/fragments/tsup.config.ts @@ -0,0 +1,21 @@ +import { defineConfig, Options as TsupConfig } from 'tsup'; + +import { getPackageBuildConfigs } from '../../tsup.config.base'; + +// `getPackageBuildConfigs()` produces five build variants (CJS/ESM × Node/Browser +// + ESM React-Native), each using a single `./src/index.ts` entry. This package +// also exposes `./javascript` and `./rust` subpaths, so we override `entry` +// on every variant to emit one output per subpath. We also disable code +// splitting on the ESM builds so each entry is fully inlined and the +// published `files` field doesn't have to chase per-build chunk filenames. +const ENTRY: Record = { + index: './src/index.ts', + javascript: './src/javascript/index.ts', + rust: './src/rust/index.ts', +}; + +function withMultipleEntries(config: TsupConfig): TsupConfig { + return { ...config, entry: ENTRY, splitting: false }; +} + +export default defineConfig(getPackageBuildConfigs().map(withMultipleEntries)); diff --git a/packages/fragments/vitest.config.mts b/packages/fragments/vitest.config.mts new file mode 100644 index 000000000..8fd0137cc --- /dev/null +++ b/packages/fragments/vitest.config.mts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; +import { getVitestConfig } from '../../vitest.config.base.mjs'; + +export default defineConfig({ + test: { + projects: [getVitestConfig('browser'), getVitestConfig('node'), getVitestConfig('react-native')], + }, +}); diff --git a/packages/renderers-core/README.md b/packages/renderers-core/README.md index 4a81ee1d2..d1aa88448 100644 --- a/packages/renderers-core/README.md +++ b/packages/renderers-core/README.md @@ -84,54 +84,9 @@ const parentPath = pathDirectory(path); ## Fragments -The concept of fragments is commonly used in Codama renderers as a way to combine a piece of code with any context that is relevant to that piece of code. For instance, a fragment may include a dependency map that lists all the module imports required by that piece of code. +The fragment primitives that used to live in this package — `BaseFragment`, `createFragmentTemplate`, `mapFragmentContent`, `mapFragmentContentAsync`, and `setFragmentContent` — have moved to [`@codama/fragments`](../fragments) so they can be shared with code generators outside the renderers stack. They are still re-exported here under the same names for backward compatibility, but new code should import them from `@codama/fragments` directly. -Since fragments vary from one renderer to another, this package cannot provide a one-size-fits-all `Fragment` type. Instead, it provides some base types and utility functions that can be used to build more specific fragment types. - -### `BaseFragment` - -The `BaseFragment` type is an object that includes a `content` string. Renderers may extend this type to include any additional context they need. - -```ts -type Fragment = BaseFragment & Readonly<{ importMap: ImportMap }>; -``` - -### `mapFragmentContent` - -The `mapFragmentContent` helper can be used to transform the `content` of a fragment while preserving the rest of its context. - -```ts -const updatedFragment = mapFragmentContent(fragment, c => `/** This is a fragment. */\n${c}`); -``` - -### `mapFragmentContentAsync` - -The `mapFragmentContentAsync` helper can be used to transform the `content` of a fragment asynchronously while preserving the rest of its context. - -```ts -const updatedFragment = mapFragmentContentAsync(fragment, async c => `${await getDocs(c)}\n${c}`); -``` - -### `setFragmentContent` - -The `setFragmentContent` helper can be used to replace the `content` of a fragment while preserving the rest of its context. - -```ts -const updatedFragment = setFragmentContent(fragment, '[redacted]'); -``` - -### `createFragmentTemplate` - -The `createFragmentTemplate` helper can be used to create [tagged template literal](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#tagged_templates) functions. For this, you need to provide a function that can merge multiple fragments together and a function that can identify fragments from other values. - -```ts -function fragment(template: TemplateStringsArray, ...items: unknown[]): Fragment { - return createFragmentTemplate(template, items, isFragment, mergeFragments); -} -const apple = fragment`apple`; -const banana = fragment`banana`; -const fruits = fragment`${apple}, ${banana}`; -``` +For documentation, examples, and the language-specific `Fragment` and `ImportMap` types provided alongside the core primitives, see the [`@codama/fragments` README](../fragments/README.md). ## Render maps diff --git a/packages/renderers-core/package.json b/packages/renderers-core/package.json index c87b9be70..7c0478026 100644 --- a/packages/renderers-core/package.json +++ b/packages/renderers-core/package.json @@ -46,6 +46,7 @@ }, "dependencies": { "@codama/errors": "workspace:*", + "@codama/fragments": "workspace:*", "@codama/nodes": "workspace:*", "@codama/visitors-core": "workspace:*" }, diff --git a/packages/renderers-core/src/fragment.ts b/packages/renderers-core/src/fragment.ts deleted file mode 100644 index cc7cd0ff6..000000000 --- a/packages/renderers-core/src/fragment.ts +++ /dev/null @@ -1,35 +0,0 @@ -export type BaseFragment = Readonly<{ content: string }>; - -export function mapFragmentContent( - fragment: TFragment, - mapContent: (content: string) => string, -): TFragment { - return setFragmentContent(fragment, mapContent(fragment.content)); -} - -export async function mapFragmentContentAsync( - fragment: TFragment, - mapContent: (content: string) => Promise, -): Promise { - return setFragmentContent(fragment, await mapContent(fragment.content)); -} - -export function setFragmentContent(fragment: TFragment, content: string): TFragment { - return Object.freeze({ ...fragment, content }); -} - -export function createFragmentTemplate( - template: TemplateStringsArray, - items: unknown[], - isFragment: (value: unknown) => value is TFragment, - mergeFragments: (fragments: TFragment[], mergeContent: (contents: string[]) => string) => TFragment, -): TFragment { - const fragments = items.filter(isFragment); - const zippedItems = items.map((item, i) => { - const itemPrefix = template[i]; - if (typeof item === 'undefined') return itemPrefix; - if (isFragment(item)) return itemPrefix + item.content; - return itemPrefix + String(item as string); - }); - return mergeFragments(fragments, () => zippedItems.join('') + template[template.length - 1]); -} diff --git a/packages/renderers-core/src/index.ts b/packages/renderers-core/src/index.ts index 8b9e82015..3ae38695a 100644 --- a/packages/renderers-core/src/index.ts +++ b/packages/renderers-core/src/index.ts @@ -1,4 +1,14 @@ -export * from './fragment'; -export * from './fs'; -export * from './path'; +/** + * `@codama/renderers-core` — public API. + * + * The bulk of the surface is re-exported from `@codama/fragments`: + * fragment primitives, casing helpers, path / filesystem helpers, and + * the `RenderMap` data operations all live there so they can be shared + * with code generators and other consumers outside the renderers + * stack. This package layers the renderer-specific + * {@link writeRenderMapVisitor} on top — the one piece that pulls in + * the visitor + node infrastructure. + */ + +export * from '@codama/fragments'; export * from './renderMap'; diff --git a/packages/renderers-core/src/path.ts b/packages/renderers-core/src/path.ts deleted file mode 100644 index a1249f6b3..000000000 --- a/packages/renderers-core/src/path.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { dirname, join } from 'node:path'; - -export type Path = string; - -export function joinPath(...paths: Path[]): string { - if (!__NODEJS__) { - return paths.join('/').replace(/\/+/g, '/'); - } - - return join(...paths); -} - -export function pathDirectory(path: Path): Path { - if (!__NODEJS__) { - return path.substring(0, path.lastIndexOf('/')); - } - - return dirname(path); -} diff --git a/packages/renderers-core/src/renderMap.ts b/packages/renderers-core/src/renderMap.ts index 8ea6720e4..9ba0810c4 100644 --- a/packages/renderers-core/src/renderMap.ts +++ b/packages/renderers-core/src/renderMap.ts @@ -1,128 +1,21 @@ -import { CODAMA_ERROR__VISITORS__RENDER_MAP_KEY_NOT_FOUND, CodamaError } from '@codama/errors'; +/** + * Renderer-specific helper layered on top of `@codama/fragments`'s + * {@link RenderMap} data structure. The pure data operations live in + * `@codama/fragments` so they can be shared with consumers outside the + * renderers stack; this file adds the one piece that fragments cannot + * pull in — the visitor wrapper, which depends on the visitor + node + * infrastructure. + */ + +import { type BaseFragment, type Path, type RenderMap, writeRenderMap } from '@codama/fragments'; import { NodeKind } from '@codama/nodes'; import { mapVisitor, Visitor } from '@codama/visitors-core'; -import { BaseFragment, mapFragmentContent, mapFragmentContentAsync } from './fragment'; -import { writeFile } from './fs'; -import { joinPath, Path } from './path'; - -export type RenderMap = ReadonlyMap; - -export function createRenderMap(): RenderMap; -export function createRenderMap(path: Path, content: TFragment): RenderMap; -export function createRenderMap( - entries: Record, -): RenderMap; -export function createRenderMap( - pathOrEntries?: Path | Record, - content?: TFragment, -): RenderMap { - let entries: [Path, TFragment][] = []; - if (typeof pathOrEntries === 'string' && pathOrEntries !== undefined && content !== undefined) { - entries = [[pathOrEntries, content]]; - } else if (typeof pathOrEntries === 'object' && pathOrEntries !== null) { - entries = Object.entries(pathOrEntries).flatMap(([key, value]) => - value === undefined ? [] : ([[key, value]] as const), - ); - } - return Object.freeze(new Map(entries)); -} - -export function addToRenderMap( - renderMap: RenderMap, - path: Path, - content: TFragment, -): RenderMap { - return mergeRenderMaps([renderMap, createRenderMap(path, content)]); -} - -export function removeFromRenderMap( - renderMap: RenderMap, - path: Path, -): RenderMap { - const newMap = new Map(renderMap); - newMap.delete(path); - return Object.freeze(newMap); -} - -export function mergeRenderMaps( - renderMaps: RenderMap[], -): RenderMap { - if (renderMaps.length === 0) return createRenderMap(); - if (renderMaps.length === 1) return renderMaps[0]; - const merged = new Map(renderMaps[0]); - for (const map of renderMaps.slice(1)) { - for (const [key, value] of map) { - merged.set(key, value); - } - } - return Object.freeze(merged); -} - -export function mapRenderMapFragment( - renderMap: RenderMap, - fn: (fragment: TFragment, path: Path) => TFragment, -): RenderMap { - return Object.freeze(new Map([...[...renderMap.entries()].map(([key, value]) => [key, fn(value, key)] as const)])); -} - -export async function mapRenderMapFragmentAsync( - renderMap: RenderMap, - fn: (fragment: TFragment, path: Path) => Promise, -): Promise> { - return Object.freeze( - new Map( - await Promise.all([ - ...[...renderMap.entries()].map(async ([key, value]) => [key, await fn(value, key)] as const), - ]), - ), - ); -} - -export function mapRenderMapContent( - renderMap: RenderMap, - fn: (content: string, path: Path) => string, -): RenderMap { - return mapRenderMapFragment(renderMap, (fragment, path) => - mapFragmentContent(fragment, content => fn(content, path)), - ); -} - -export async function mapRenderMapContentAsync( - renderMap: RenderMap, - fn: (content: string, path: Path) => Promise, -): Promise> { - return await mapRenderMapFragmentAsync(renderMap, (fragment, path) => - mapFragmentContentAsync(fragment, content => fn(content, path)), - ); -} - -export function getFromRenderMap( - renderMap: RenderMap, - path: Path, -): TFragment { - const value = renderMap.get(path); - if (value === undefined) { - throw new CodamaError(CODAMA_ERROR__VISITORS__RENDER_MAP_KEY_NOT_FOUND, { key: path }); - } - return value; -} - -export function renderMapContains( - renderMap: RenderMap, - path: Path, - value: RegExp | string, -): boolean { - const { content } = getFromRenderMap(renderMap, path); - return typeof value === 'string' ? content.includes(value) : value.test(content); -} - -export function writeRenderMap(renderMap: RenderMap, basePath: Path): void { - renderMap.forEach(({ content }, relativePath) => { - writeFile(joinPath(basePath, relativePath), content); - }); -} - +/** + * Wrap a {@link Visitor} that produces a {@link RenderMap} so the + * resulting map is written to disk under `basePath` once the visit + * completes. + */ export function writeRenderMapVisitor< TFragment extends BaseFragment = BaseFragment, TNodeKind extends NodeKind = NodeKind, diff --git a/packages/renderers-core/test/index.test.ts b/packages/renderers-core/test/index.test.ts new file mode 100644 index 000000000..35cd7fcc9 --- /dev/null +++ b/packages/renderers-core/test/index.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from 'vitest'; + +import { writeRenderMapVisitor } from '../src'; + +/** + * `@codama/renderers-core` is now a thin layer over `@codama/fragments` + * — every name except {@link writeRenderMapVisitor} is forwarded + * straight through via `export *`. The fragments-side tests cover the + * underlying primitives; this single assertion is the smoke check + * that the renderer-specific addition is reachable through the + * package entry point. + */ +test('it exports writeRenderMapVisitor as a function', () => { + expect(typeof writeRenderMapVisitor).toBe('function'); +}); diff --git a/packages/renderers-core/test/path.test.ts b/packages/renderers-core/test/path.test.ts deleted file mode 100644 index 4fb1cf09f..000000000 --- a/packages/renderers-core/test/path.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { expect, test } from 'vitest'; - -import { joinPath, pathDirectory } from '../src'; - -test('it joins path together', () => { - const result = joinPath('foo', 'bar', 'baz'); - expect(result).toEqual('foo/bar/baz'); -}); - -test('it gets the directory of a path', () => { - const result = pathDirectory('foo/bar/baz'); - expect(result).toEqual('foo/bar'); -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27a99956a..138cde353 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -242,6 +242,12 @@ importers: specifier: ^1.1.1 version: 1.1.1 + packages/fragments: + dependencies: + '@codama/errors': + specifier: workspace:* + version: link:../errors + packages/library: dependencies: '@codama/cli': @@ -294,6 +300,9 @@ importers: '@codama/errors': specifier: workspace:* version: link:../errors + '@codama/fragments': + specifier: workspace:* + version: link:../fragments '@codama/nodes': specifier: workspace:* version: link:../nodes