Browse Source

ts: Lazy load workspace programs and improve program name accessor (#2579)

acheron 2 years ago
parent
commit
5eb678a8bf
4 changed files with 102 additions and 86 deletions
  1. 2 0
      CHANGELOG.md
  2. 2 0
      tests/idl/Anchor.toml
  3. 38 4
      tests/idl/tests/idl.ts
  4. 60 82
      ts/packages/anchor/src/workspace.ts

+ 2 - 0
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
 

+ 2 - 0
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"

+ 38 - 4
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"];
+    });
+  });
 });

+ 60 - 82
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<string, Idl>();
-      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<string, Idl>
-) {
-  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;