From 5eb678a8bf89cd5d1c215ae99187febd0df9a6cb Mon Sep 17 00:00:00 2001 From: acheron <98934430+acheroncrypto@users.noreply.github.com> Date: Tue, 25 Jul 2023 23:52:26 +0200 Subject: [PATCH] ts: Lazy load workspace programs and improve program name accessor (#2579) --- CHANGELOG.md | 2 + tests/idl/Anchor.toml | 2 + tests/idl/tests/idl.ts | 42 +++++++- ts/packages/anchor/src/workspace.ts | 142 ++++++++++++---------------- 4 files changed, 102 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb17588328..7db9defdf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ The minor version will be incremented upon a breaking change and the patch versi - lang: Allow CPI calls matching an interface without pinning program ID ([#2559](https://github.com/coral-xyz/anchor/pull/2559)). - cli, lang: Add IDL generation through compilation. `anchor build` still uses parsing method to generate IDLs, use `anchor idl build` to generate IDLs with the build method ([#2011](https://github.com/coral-xyz/anchor/pull/2011)). - avm: Add support for the `.anchorversion` file to facilitate switching between different versions of the `anchor-cli` ([#2553](https://github.com/coral-xyz/anchor/pull/2553)). +- ts: Add ability to access workspace programs independent of the casing used, e.g. `anchor.workspace.myProgram`, `anchor.workspace.MyProgram`... ([#2579](https://github.com/coral-xyz/anchor/pull/2579)). ### Fixes @@ -25,6 +26,7 @@ The minor version will be incremented upon a breaking change and the patch versi - cli: Support workspace inheritence ([#2570](https://github.com/coral-xyz/anchor/pull/2570)). - client: Compile with Solana `1.14` ([#2572](https://github.com/coral-xyz/anchor/pull/2572)). - cli: Fix `anchor build --no-docs` adding docs to the IDL ([#2575](https://github.com/coral-xyz/anchor/pull/2575)). +- ts: Load workspace programs on-demand rather than loading all of them at once ([#2579](https://github.com/coral-xyz/anchor/pull/2579)). ### Breaking diff --git a/tests/idl/Anchor.toml b/tests/idl/Anchor.toml index 07b8d63bb0..c1485f8a5e 100644 --- a/tests/idl/Anchor.toml +++ b/tests/idl/Anchor.toml @@ -6,6 +6,8 @@ external = "Externa1111111111111111111111111111111111111" generics = "Generics111111111111111111111111111111111111" idl = "id11111111111111111111111111111111111111111" relations_derivation = "Re1ationsDerivation111111111111111111111111" +non_existent = { address = "NonExistent11111111111111111111111111111111", idl = "non-existent.json" } +numbers_123 = { address = "Numbers111111111111111111111111111111111111", idl = "idls/relations_build_exp.json" } [provider] cluster = "localnet" diff --git a/tests/idl/tests/idl.ts b/tests/idl/tests/idl.ts index ae485335f3..b456f66718 100644 --- a/tests/idl/tests/idl.ts +++ b/tests/idl/tests/idl.ts @@ -1,9 +1,43 @@ import * as anchor from "@coral-xyz/anchor"; +import { assert } from "chai"; -import { IDL } from "../target/types/idl"; - -describe(IDL.name, () => { +describe("IDL", () => { anchor.setProvider(anchor.AnchorProvider.env()); - it("Builds", () => {}); + it("Can lazy load workspace programs", () => { + assert.doesNotThrow(() => { + // Program exists, should not throw + anchor.workspace.relationsDerivation; + }); + + assert.throws(() => { + // IDL path in Anchor.toml doesn't exist but other tests still run + // successfully because workspace programs are getting loaded on-demand + anchor.workspace.nonExistent; + }, /non-existent\.json/); + }); + + it("Can get workspace programs by their name independent of casing", () => { + const camel = anchor.workspace.relationsDerivation; + const pascal = anchor.workspace.RelationsDerivation; + const kebab = anchor.workspace["relations-derivation"]; + const snake = anchor.workspace["relations_derivation"]; + + const compareProgramNames = (...programs: anchor.Program[]) => { + return programs.every( + (program) => program.idl.name === "relations_derivation" + ); + }; + + assert(compareProgramNames(camel, pascal, kebab, snake)); + }); + + it("Can use numbers in program names", () => { + assert.doesNotThrow(() => { + anchor.workspace.numbers123; + anchor.workspace.Numbers123; + anchor.workspace["numbers-123"]; + anchor.workspace["numbers_123"]; + }); + }); }); diff --git a/ts/packages/anchor/src/workspace.ts b/ts/packages/anchor/src/workspace.ts index 3537d4a43a..fe890ce655 100644 --- a/ts/packages/anchor/src/workspace.ts +++ b/ts/packages/anchor/src/workspace.ts @@ -1,12 +1,8 @@ -import camelCase from "camelcase"; import * as toml from "toml"; -import { PublicKey } from "@solana/web3.js"; +import { snakeCase } from "snake-case"; import { Program } from "./program/index.js"; -import { Idl } from "./idl.js"; import { isBrowser } from "./utils/common.js"; -let _populatedWorkspace = false; - /** * The `workspace` namespace provides a convenience API to automatically * search for and deserialize [[Program]] objects defined by compiled IDLs @@ -14,95 +10,77 @@ let _populatedWorkspace = false; * * This API is for Node only. */ -const workspace = new Proxy({} as any, { - get(workspaceCache: { [key: string]: Program }, programName: string) { - if (isBrowser) { - throw new Error("Workspaces aren't available in the browser"); - } +const workspace = new Proxy( + {}, + { + get(workspaceCache: { [key: string]: Program }, programName: string) { + if (isBrowser) { + throw new Error("Workspaces aren't available in the browser"); + } + + // Converting `programName` to snake_case enables the ability to use any + // of the following to access the workspace program: + // `workspace.myProgram`, `workspace.MyProgram`, `workspace["my-program"]`... + programName = snakeCase(programName); + + // Check whether the program name contains any digits + if (/\d/.test(programName)) { + // Numbers cannot be properly converted from camelCase to snake_case, + // e.g. if the `programName` is `myProgram2`, the actual program name could + // be `my_program2` or `my_program_2`. This implementation assumes the + // latter as the default and always converts to `_numbers`. + // + // A solution to the conversion of program names with numbers in them + // would be to always convert the `programName` to camelCase instead of + // snake_case. The problem with this approach is that it would require + // converting everything else e.g. program names in Anchor.toml and IDL + // file names which are both snake_case. + programName = programName + .replace(/\d+/g, (match) => "_" + match) + .replace("__", "_"); + } - const fs = require("fs"); - const process = require("process"); + // Return early if the program is in cache + if (workspaceCache[programName]) return workspaceCache[programName]; - if (!_populatedWorkspace) { + const fs = require("fs"); const path = require("path"); - let projectRoot = process.cwd(); - while (!fs.existsSync(path.join(projectRoot, "Anchor.toml"))) { - const parentDir = path.dirname(projectRoot); - if (parentDir === projectRoot) { - projectRoot = undefined; - } - projectRoot = parentDir; - } + // Override the workspace programs if the user put them in the config. + const anchorToml = toml.parse(fs.readFileSync("Anchor.toml")); + const clusterId = anchorToml.provider.cluster; + const programEntry = anchorToml.programs?.[clusterId]?.[programName]; - if (projectRoot === undefined) { - throw new Error("Could not find workspace root."); + let idlPath: string; + let programId; + if (typeof programEntry === "object" && programEntry.idl) { + idlPath = programEntry.idl; + programId = programEntry.address; + } else { + idlPath = path.join("target", "idl", `${programName}.json`); } - const idlFolder = `${projectRoot}/target/idl`; - if (!fs.existsSync(idlFolder)) { + if (!fs.existsSync(idlPath)) { throw new Error( - `${idlFolder} doesn't exist. Did you use "anchor build"?` + `${idlPath} doesn't exist. Did you run \`anchor build\`?` ); } - const idlMap = new Map(); - fs.readdirSync(idlFolder) - .filter((file) => file.endsWith(".json")) - .forEach((file) => { - const filePath = `${idlFolder}/${file}`; - const idlStr = fs.readFileSync(filePath); - const idl = JSON.parse(idlStr); - idlMap.set(idl.name, idl); - const name = camelCase(idl.name, { pascalCase: true }); - if (idl.metadata && idl.metadata.address) { - workspaceCache[name] = new Program( - idl, - new PublicKey(idl.metadata.address) - ); - } - }); - - // Override the workspace programs if the user put them in the config. - const anchorToml = toml.parse( - fs.readFileSync(path.join(projectRoot, "Anchor.toml"), "utf-8") - ); - const clusterId = anchorToml.provider.cluster; - if (anchorToml.programs && anchorToml.programs[clusterId]) { - attachWorkspaceOverride( - workspaceCache, - anchorToml.programs[clusterId], - idlMap - ); + const idl = JSON.parse(fs.readFileSync(idlPath)); + if (!programId) { + if (!idl.metadata?.address) { + throw new Error( + `IDL for program \`${programName}\` does not have \`metadata.address\` field.\n` + + "To add the missing field, run `anchor deploy` or `anchor test`." + ); + } + programId = idl.metadata.address; } + workspaceCache[programName] = new Program(idl, programId); - _populatedWorkspace = true; - } - - return workspaceCache[programName]; - }, -}); - -function attachWorkspaceOverride( - workspaceCache: { [key: string]: Program }, - overrideConfig: { [key: string]: string | { address: string; idl?: string } }, - idlMap: Map -) { - Object.keys(overrideConfig).forEach((programName) => { - const wsProgramName = camelCase(programName, { pascalCase: true }); - const entry = overrideConfig[programName]; - const overrideAddress = new PublicKey( - typeof entry === "string" ? entry : entry.address - ); - let idl = idlMap.get(programName); - if (typeof entry !== "string" && entry.idl) { - idl = JSON.parse(require("fs").readFileSync(entry.idl, "utf-8")); - } - if (!idl) { - throw new Error(`Error loading workspace IDL for ${programName}`); - } - workspaceCache[wsProgramName] = new Program(idl, overrideAddress); - }); -} + return workspaceCache[programName]; + }, + } +); export default workspace;