Skip to content

Commit

Permalink
feat(cli): add store config parser and basic schema generator (#402)
Browse files Browse the repository at this point in the history
* build(store): add store config to store for testing

* feat(cli): add store config and basic schema generator

* refactor(store): use some autogenerated schemas

* refactor(cli): simplify shebang

* build: update some typescript versions

* refactor(store): use local mud.js for tablegen

* build(cli): change dependencies

* feat(cli): use fancy custom errors for config

* feat(cli): use tagged template for conditional rendering

* refactor(cli): rename config to storeConfig

* chore(cli): add tablegen description

Co-authored-by: alvarius <[email protected]>

* feat(cli): separate config logically, improve storeConfig validation

* fix(cli): make shebang work on mac

---------

Co-authored-by: alvarius <[email protected]>
  • Loading branch information
dk1a and alvrs authored Feb 24, 2023
1 parent c4ea97b commit 78c197d
Show file tree
Hide file tree
Showing 23 changed files with 1,044 additions and 194 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
"retypeapp": "^2.4.0",
"rimraf": "^3.0.2",
"run-pty": "^3.0.0",
"typescript": ">=3.0.0"
"typescript": "^4.9.5"
},
"config": {
"commitizen": {
Expand Down
8 changes: 5 additions & 3 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,12 @@
"nodemon": "^2.0.16",
"pkg": "^5.7.0",
"rimraf": "^3.0.2",
"ts-node": "^10.7.0",
"tsup": "^6.6.3",
"typescript": "^4.6.4"
"tsup": "^6.6.3"
},
"dependencies": {
"@improbable-eng/grpc-web": "^0.15.0",
"@improbable-eng/grpc-web-node-http-transport": "^0.15.0",
"@latticexyz/schema-type": "^1.34.0",
"@latticexyz/services": "^1.37.1",
"@latticexyz/solecs": "^1.37.1",
"@latticexyz/std-contracts": "^1.37.1",
Expand All @@ -58,6 +57,7 @@
"ethers": "^5.7.2",
"execa": "^6.1.0",
"figlet": "^1.5.2",
"find-up": "^6.3.0",
"forge-std": "https://github.com/foundry-rs/forge-std.git#b4f121555729b3afb3c5ffccb62ff4b6e2818fd3",
"glob": "^8.0.3",
"inquirer": "^8.2.4",
Expand All @@ -70,7 +70,9 @@
"path": "^0.12.7",
"solmate": "https://github.com/Rari-Capital/solmate.git#9cf1428245074e39090dceacb0c28b1f684f584c",
"table": "^6.8.1",
"ts-node": "^10.9.1",
"typechain": "^8.1.1",
"typescript": "^4.9.5",
"uuid": "^8.3.2",
"yargs": "^17.5.1"
},
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import faucet from "./faucet.js";
import gasReport from "./gas-report.js";
import hello from "./hello.js";
import systemTypes from "./system-types.js";
import tablegen from "./tablegen.js";
import test from "./test.js";
import trace from "./trace.js";
import types from "./types.js";
Expand All @@ -24,6 +25,7 @@ export const commands: CommandModule<any, any>[] = [
gasReport,
hello,
systemTypes,
tablegen,
test,
trace,
types,
Expand Down
40 changes: 40 additions & 0 deletions packages/cli/src/commands/tablegen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { CommandModule } from "yargs";
import { writeFileSync } from "fs";
import path from "path";
import { loadStoreConfig } from "../config/loadStoreConfig.js";
import { renderTables } from "../utils/tablegen.js";
import { getSrcDirectory } from "../utils/forgeConfig.js";

type Options = {
configPath?: string;
};

const commandModule: CommandModule<Options, Options> = {
command: "tablegen",

describe: "Autogenerate MUD Store table libraries based on the config file",

builder(yargs) {
return yargs.options({
configPath: { type: "string", desc: "Path to the config file" },
});
},

async handler({ configPath }) {
const srcDir = await getSrcDirectory();

const config = await loadStoreConfig(configPath);
const renderedTables = renderTables(config);

for (const { output, tableName } of renderedTables) {
const basePath = config.tables[tableName].path;
const outputPath = path.join(srcDir, basePath, `${tableName}.sol`);
writeFileSync(outputPath, output);
console.log(`Generated schema: ${outputPath}`);
}

process.exit(0);
},
};

export default commandModule;
41 changes: 41 additions & 0 deletions packages/cli/src/config/loadConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { findUpSync } from "find-up";
import path from "path";
import { ERRORS, MUDError } from "../utils/errors.js";

// TODO require may or may not be needed, finish exploring the various configurations
import { createRequire } from "module";
import { fileURLToPath } from "url";
const require = createRequire(fileURLToPath(import.meta.url));

// Based on hardhat's config (MIT)
// https://github.com/NomicFoundation/hardhat/tree/main/packages/hardhat-core

const TS_CONFIG_FILENAME = "mud.config.mts";

export async function loadConfig(configPath?: string): Promise<unknown> {
configPath = resolveConfigPath(configPath);

const config = (await import(configPath)).default;
console.log("Config loaded:", config);
return config;
}

function resolveConfigPath(configPath: string | undefined) {
if (configPath === undefined) {
configPath = getUserConfigPath();
} else {
if (!path.isAbsolute(configPath)) {
configPath = path.join(process.cwd(), configPath);
configPath = path.normalize(configPath);
}
}
return configPath;
}

function getUserConfigPath() {
const tsConfigPath = findUpSync(TS_CONFIG_FILENAME);
if (tsConfigPath === undefined) {
throw new MUDError(ERRORS.NOT_INSIDE_PROJECT);
}
return tsConfigPath;
}
142 changes: 142 additions & 0 deletions packages/cli/src/config/loadStoreConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import path from "path";
import { SchemaType } from "@latticexyz/schema-type";
import { defaultKeyTuple, defaultStoreImportPath, defaultTablePath } from "../utils/constants.js";
import { ERRORS, MUDError } from "../utils/errors.js";
import { loadConfig } from "./loadConfig.js";
import { isArray, isRecord, isString } from "./utils.js";

// Based on hardhat's config (MIT)
// https://github.com/NomicFoundation/hardhat/tree/main/packages/hardhat-core

export interface StoreUserConfig {
/** Path for store package imports. Default is "@latticexyz/store/src/" */
storeImportPath?: string;
/** Configuration for each table. The keys are table names, 1st letter should be uppercase. */
tables: {
[tableName: string]: {
/** Output path for the file. Default is "tables/" */
path?: string;
/** List of names for the table's keys. Default is ["key"] */
keyTuple?: string[];
/** Table's columns. The keys are column names, 1st letter should be lowercase. */
schema: {
[columnName: string]: SchemaType;
};
};
};
}

export interface StoreConfig extends StoreUserConfig {
storeImportPath: string;
tables: {
[tableName: string]: {
path: string;
keyTuple: string[];
schema: {
[columnName: string]: SchemaType;
};
};
};
}

export async function loadStoreConfig(configPath?: string) {
const config = await loadConfig(configPath);

const validatedConfig = validateConfig(config);

return resolveConfig(validatedConfig);
}

function validateConfig(config: unknown) {
if (!isRecord(config)) {
throw new MUDError(ERRORS.INVALID_CONFIG, ["Config file does not default export an object"]);
}
if (!isRecord(config.tables)) {
throw new MUDError(ERRORS.INVALID_CONFIG, ['Config does not have a "tables" property object']);
}

// Collect all table-related config errors
let errors: string[] = [];

for (const tableName of Object.keys(config.tables)) {
// validate table name
if (!/^\w+$/.test(tableName)) {
errors.push(`Table name "${tableName}" must contain only alphanumeric & underscore characters`);
}
if (!/^[A-Z]/.test(tableName)) {
errors.push(`Table name "${tableName}" must start with a capital letter`);
}

const tableData = config.tables[tableName];
if (!isRecord(tableData)) {
errors.push(`Table "${tableName}" is not a valid object`);
continue;
}

const { keyTuple, schema } = tableData;
if (!isRecord(schema)) {
errors.push(`Table "${tableName}" must have a "schema" property object`);
continue;
}

// validate schema
for (const [columnName] of Object.entries(schema)) {
if (!/^\w+$/.test(columnName)) {
errors.push(
`In table "${tableName}" schema column "${columnName}" must contain only alphanumeric & underscore characters`
);
}
if (!/^[a-z]/.test(columnName)) {
errors.push(`In table "${tableName}" schema column "${columnName}" must start with a lowercase letter`);
}
}
// validate key names
errors = errors.concat(validateKeyTuple(tableName, keyTuple));
}

// Display all collected errors at once
if (errors.length > 0) {
throw new MUDError(ERRORS.INVALID_CONFIG, errors);
}

return config as unknown as StoreUserConfig;
}

function validateKeyTuple(tableName: string, keyTuple: unknown) {
const errors: string[] = [];
// keyTuple is optional, absence is valid
if (keyTuple === undefined) return errors;
// if present, it must be an array of strings
if (!isArray(keyTuple)) {
errors.push(`In table "${tableName}" property "keyTuple" must be an array of strings`);
return errors;
}
for (const key of keyTuple) {
// check that keys are strings
if (!isString(key)) {
errors.push(`In table "${tableName}" property "keyTuple" must be an array of strings`);
continue;
}
// and that they are correctly formatted
if (!/^\w+$/.test(key)) {
errors.push(`In table "${tableName}" key "${key}" must contain only alphanumeric & underscore characters`);
}
if (!/^[a-z]/.test(key)) {
errors.push(`In table "${tableName}" key "${key}" must start with a lowercase letter`);
}
}
return errors;
}

function resolveConfig(config: StoreUserConfig) {
config.storeImportPath ??= defaultStoreImportPath;
// ensure the path has a trailing slash
config.storeImportPath = path.join(config.storeImportPath, "/");

for (const tableName of Object.keys(config.tables)) {
const table = config.tables[tableName];
table.path ??= defaultTablePath;
table.keyTuple ??= defaultKeyTuple;
}
return config as StoreConfig;
}
11 changes: 11 additions & 0 deletions packages/cli/src/config/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

export function isArray(value: unknown): value is Array<unknown> {
return Array.isArray(value);
}

export function isString(value: unknown): value is string {
return typeof value === "string";
}
Loading

0 comments on commit 78c197d

Please sign in to comment.