From d1d3c0d8ff3919bfe38aa102516584615cd0fe7c Mon Sep 17 00:00:00 2001 From: lihbr Date: Wed, 2 Jun 2021 14:38:04 +0200 Subject: [PATCH] feat: add dico build command --- package.json | 1 + src/cli.ts | 5 ++ src/commands/build.ts | 198 ++++++++++++++++++++++++++++++++++++++++++ src/commands/index.ts | 1 + src/commands/init.ts | 9 +- src/const.ts | 1 + src/core/dicojson.ts | 66 ++++++++++++++ src/core/project.ts | 26 ++++++ src/messages.ts | 8 ++ src/types.ts | 1 + yarn.lock | 2 +- 11 files changed, 315 insertions(+), 3 deletions(-) create mode 100644 src/commands/build.ts create mode 100644 src/core/project.ts diff --git a/package.json b/package.json index 0db182b..2bdc711 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "debug": "4.3.1", "detect-indent": "6.1.0", "exit": "0.1.2", + "globby": "11.0.3", "inquirer": "8.1.0", "latest-version": "5.1.0", "listr": "0.14.3", diff --git a/src/cli.ts b/src/cli.ts index cb1c514..41ee0b3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -34,6 +34,11 @@ cli await commands.init(cli, options); }); +cli.command("build", "Build current project dico").action(async options => { + await middlewares.signedInOnly(); + await commands.build(cli, options); +}); + cli.version(VERSION); cli.help(commands.help); diff --git a/src/commands/build.ts b/src/commands/build.ts new file mode 100644 index 0000000..2fc6b1b --- /dev/null +++ b/src/commands/build.ts @@ -0,0 +1,198 @@ +import chalk from "chalk"; +import { CAC } from "cac"; +// @ts-expect-error it's our fork and we don't care +import UpdateRenderer from "@lihbr/listr-update-renderer"; +import Listr, { ListrTaskWrapper } from "listr"; +import { Observable } from "rxjs"; + +import { lineBreak, logger } from "../lib"; +import * as dicojson from "../core/dicojson"; +import * as project from "../core/project"; +import * as messages from "../messages"; +import exit from "exit"; +import { DEFAULT_TIMEOUT } from "../const"; +import { ConfigJSON } from "../types"; + +class BuildError extends Error {} + +interface BuildDicoContext { + paths: string[]; + keys: { path: string; key: string }[]; + schema: ConfigJSON["schema"]; + conflicts: { path: string; key: string }[][]; +} + +const buildDico = new Listr( + [ + { + title: "Looking for source files...", + // @ts-expect-error listr types are broken + task: (ctx: BuildDicoContext, task: ListrTaskWrapper) => + new Observable(observer => { + Promise.all([ + project.findSources(), + new Promise(resolve => setTimeout(resolve, DEFAULT_TIMEOUT * 0.5)) + ]) + .then(([paths, _]) => { + if (paths.length === 0) { + observer.error(new BuildError(messages.NoSourceFilesFound)); + } + + if (paths.length === 1) { + task.title = "One source file found"; + } else { + task.title = `${paths.length} source files found`; + } + + ctx.paths = paths; + observer.complete(); + }) + .catch(error => { + observer.error(error); + }); + }) + }, + { + title: "Looking for keys in source files...", + // @ts-expect-error listr types are broken + task: (ctx: BuildDicoContext, task: ListrTaskWrapper) => + new Observable(observer => { + const promises = []; + for (let i = 0; i < ctx.paths.length; i++) { + const path = ctx.paths[i]; + + promises.push( + new Promise<{ path: string; key: string }[]>((res, rej) => { + setTimeout(() => { + observer.next(`(${i + 1}/${ctx.paths.length}) ${path}`); + try { + const keys = project.crawlFile(path); + res(keys.map(key => ({ path, key }))); + } catch (error) { + rej(error); + } + }, (DEFAULT_TIMEOUT / Math.max(ctx.paths.length, 24)) * i); + }) + ); + } + + Promise.all(promises) + .then(keys => { + const flatKeys = keys.flat(); + + if (flatKeys.length === 1) { + task.title = "One key found"; + } else { + task.title = `${flatKeys.length} keys found`; + } + + ctx.keys = flatKeys; + observer.complete(); + }) + .catch(error => { + observer.error(error); + }); + }) + }, + { + title: "Updating schema...", + // @ts-expect-error listr types are broken + task: (ctx: BuildDicoContext, task: ListrTaskWrapper) => + new Observable(observer => { + Promise.all([ + new Promise(resolve => setTimeout(resolve, DEFAULT_TIMEOUT * 0.5)) + ]).then(_ => { + try { + const { schema, conflicts } = dicojson.createSchema(ctx.keys); + + ctx.schema = schema; + ctx.conflicts = conflicts; + + if (conflicts.length) { + if (conflicts.length === 1) { + throw new BuildError( + `1 conflict detected, fix it and try again` + ); + } else { + throw new BuildError( + `${conflicts.length} conflicts detected, fix them and try again` + ); + } + } + + // Update config file + const config = dicojson.read(); + config.schema = schema; + dicojson.write(config); + + task.title = "Schema updated"; + observer.complete(); + } catch (error) { + observer.error(error); + } + }); + }) + } + ], + { renderer: UpdateRenderer } +); + +export const build = async ( + _: CAC, + __: { [key: string]: never } +): Promise => { + if (!dicojson.exists()) { + logger.error(messages.DicoJSONNotFound, dicojson.getPath()); + exit(1); + } + + lineBreak(); + + try { + await buildDico.run(); + } catch (error) { + // Handle conflicts error + if (error instanceof BuildError && error.message.includes("conflict")) { + // @ts-expect-error listr types are broken + const context: BuildDicoContext = error.context; + + if (!context.conflicts.length) { + throw error; + } + + logger.error(`${error.message}:`); + context.conflicts.forEach(conflict => { + if (conflict.length === 1) { + console.log( + ` Key ${chalk.cyan(conflict[0].key)} (${chalk.cyan( + conflict[0].path + )}) is conflicting` + ); + } else { + console.log( + ` Key ${chalk.cyan(conflict[0].key)} (${chalk.cyan( + conflict[0].path + )}) is conflicting with key ${chalk.cyan( + conflict[1].key + )} (${chalk.cyan(conflict[1].path)})${ + conflict.length > 2 ? ` and ${conflict.length - 2} more...` : "" + }` + ); + } + }); + + lineBreak(); + logger.info( + "Conflicts happen when you have a key declared at the same location of a collection" + ); + lineBreak(); + + exit(1); + } else { + throw error; + } + } + + logger.success(messages.CommandSuccessful, "build"); + lineBreak(); +}; diff --git a/src/commands/index.ts b/src/commands/index.ts index d11d20b..794c2a6 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -3,6 +3,7 @@ export * from "./signout"; export * from "./whoami"; export * from "./init"; +export * from "./build"; export * from "./help"; export * from "./default"; diff --git a/src/commands/init.ts b/src/commands/init.ts index 8fdef61..5679935 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -97,7 +97,7 @@ const initConfig = { observer.error(error); } - task.title = "`dico.config.json` created!"; + task.title = "`dico.config.json` created"; ctx.config = config; observer.complete(); }) @@ -120,9 +120,14 @@ export const init = async ( exit(1); } - lineBreak(); + if (options.force) { + logger.warn(messages.CommandWithForce, "init"); + } else { + lineBreak(); + } const { dicos } = await getDicos.run(); const { dico } = await pickDico.run(dicos); await initConfig.run(dico); + logger.success(messages.CommandSuccessful, "init"); lineBreak(); }; diff --git a/src/const.ts b/src/const.ts index 9c33a52..6bfc139 100644 --- a/src/const.ts +++ b/src/const.ts @@ -12,4 +12,5 @@ export const DEFAULT_SOURCES_PATTERN = [ "src/**/*.(ts|tsx)", "src/**/*.vue" ]; +export const SOURCES_EXTRACT_REGEX = /\$dico(\.[\w\d]+)+/gm; export const DEFAULT_TIMEOUT = 1000; diff --git a/src/core/dicojson.ts b/src/core/dicojson.ts index d7adb07..d1371be 100644 --- a/src/core/dicojson.ts +++ b/src/core/dicojson.ts @@ -44,3 +44,69 @@ export const write = (config: ConfigJSON): void => { "utf8" ); }; + +// TODO: Better test this +export const createSchema = ( + keys: { path: string; key: string }[] +): { + schema: ConfigJSON["schema"]; + conflicts: { path: string; key: string }[][]; +} => { + const schema: ConfigJSON["schema"] = {}; + const conflicts: { path: string; key: string }[][] = []; + + const uniqueKeys = keys.filter((keyObj, i, keys) => { + return keys.findIndex(i => i.key === keyObj.key) === i; + }); + + keyloop: for (const keyObject of uniqueKeys) { + const parents = keyObject.key.replace(/^\$dico\./, "").split("."); + let pointer = schema; + const key = parents.pop() as string; + + const traversedPath: string[] = []; + for (const parent of parents) { + traversedPath.push(parent); + + if (!(parent in pointer)) { + pointer[parent] = {}; + } else if ( + // Conflict with a shorter key + typeof pointer[parent] === "string" + ) { + const conflictPath = ["$dico", ...traversedPath].join("."); + const conflictKeyObj = uniqueKeys.find(i => i.key === conflictPath); + if (conflictKeyObj) { + const existing = conflicts.find(i => i[0].key === conflictKeyObj.key); + if (existing) { + existing.push(keyObject); + } else { + conflicts.push([conflictKeyObj, keyObject]); + } + } else { + conflicts.push([keyObject]); + } + continue keyloop; + } else if ( + // Conflict with a longer key + traversedPath.length === parents.length && + typeof pointer[parent] === "object" && + typeof (pointer[parent] as ConfigJSON["schema"])[key] !== "undefined" + ) { + const conflictKeyObj = uniqueKeys.filter( + i => i.key !== keyObject.key && i.key.startsWith(keyObject.key) + ); + conflicts.push([keyObject, ...conflictKeyObj]); + continue keyloop; + } + + pointer = pointer[parent] as ConfigJSON["schema"]; + } + + if (!(key in pointer)) { + pointer[key] = ""; + } + } + + return { schema, conflicts }; +}; diff --git a/src/core/project.ts b/src/core/project.ts new file mode 100644 index 0000000..c9de64c --- /dev/null +++ b/src/core/project.ts @@ -0,0 +1,26 @@ +import fs from "fs"; +import path from "path"; + +import * as dicojson from "./dicojson"; +import globby from "globby"; +import { SOURCES_EXTRACT_REGEX } from "../const"; + +export const findSources = async (sources?: string[]): Promise => { + if (!sources) { + sources = dicojson.read().sources; + } + + const paths = await globby(sources); + + return paths; +}; + +export const crawlFile = (relativePath: string): string[] => { + const absolutePath = path.join(process.cwd(), relativePath); + + const blob = fs.readFileSync(absolutePath, "utf8"); + + const matches = blob.match(SOURCES_EXTRACT_REGEX); + + return matches ? matches : []; +}; diff --git a/src/messages.ts b/src/messages.ts index 9da9dd8..7d6def2 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -1,3 +1,5 @@ +import { NAME } from "./const"; + export const InvalidCommand = "Invalid command: `%s`, run `%s --help` for all commands"; @@ -14,3 +16,9 @@ export const NoDicoFoundDeveloper = export const DicoJSONNotFound = "`dico.config.json` not found at `%s`"; export const DicoJSONAlreadyExists = "`dico.config.json` already exists at `%s`!"; + +export const NoSourceFilesFound = + "No source files found with current `sources` option. Double-check your `dico.config.json` and try again!"; + +export const CommandSuccessful = `\`${NAME} %s\` successful!`; +export const CommandWithForce = `Running \`${NAME} %s\` with \`force\` flag`; diff --git a/src/types.ts b/src/types.ts index f1d089e..41ba35c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,6 +9,7 @@ export interface ConfigRC { export interface ConfigJSON { dico: string; + sources: string[]; schema: Structure["schema"]; updated_at: string; } diff --git a/yarn.lock b/yarn.lock index 263ef69..f53485c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2431,7 +2431,7 @@ globby@10.0.0: merge2 "^1.2.3" slash "^3.0.0" -globby@^11.0.1, globby@^11.0.3: +globby@11.0.3, globby@^11.0.1, globby@^11.0.3: version "11.0.3" resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.3.tgz#9b1f0cb523e171dd1ad8c7b2a9fb4b644b9593cb" integrity sha512-ffdmosjA807y7+lA1NM0jELARVmYul/715xiILEjo3hBLPTcirgQNnXECn5g3mtR8TOLCVbkfua1Hpen25/Xcg==