Skip to content

Commit

Permalink
feat(command/compile): first version of the compile command
Browse files Browse the repository at this point in the history
  • Loading branch information
EmileRolley committed Oct 2, 2024
1 parent bf5e641 commit 9566a25
Show file tree
Hide file tree
Showing 5 changed files with 261 additions and 3 deletions.
239 changes: 239 additions & 0 deletions src/commands/compile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import { Args, Command, Flags } from '@oclif/core'
import * as p from '@clack/prompts'
import chalk from 'chalk'
import path from 'path'
import fs from 'fs'
import { getModelFromSource, GetModelFromSourceOptions } from '../compilation'
import { RawRules } from '../commons'
import { exitWithError, runWithSpinner } from '../utils/cli'
import { resolveRuleTypes, RuleType } from '../compilation/ruleTypes'
import Engine from 'publicodes'
import { getPackageJson } from '../utils/pjson'

export default class Compile extends Command {
static override args = {
files: Args.file({ description: 'Files to compile.' }),
}

static override strict = false

static override summary = 'Compile publicodes files.'

static override description = `
This command will compile all the specified publicodes files into standalone
JSON file importable in any JavaScript along with the TypeScript types
corresponding to the rules.
To avoid passing arguments and flags every time, you can set their values in
the package.json file under the \`publicodes\` key. For example:
{
// ...
"publicodes": {
"files": ["src/"],
"output": "build"
}
}
`

static override examples = [
{
command: '<%= config.bin %> <%= command.id %>',
description: `Compile all publicodes files in the src/ directory into the build/ directory.`,
},
{
command: '<%= config.bin %> <%= command.id %> src/**/*.publicodes',
description: 'Compile all publicodes files in the src/ directory.',
},
{
command:
'<%= config.bin %> <%= command.id %> src/file1.publicodes src/file2.publicodes -o ../dist',
description:
'Compile only the specified files into the ../dist/ directory.',
},
]

static override flags = {
output: Flags.string({
char: 'o',
default: 'build',
summary: 'Specify the output directory.',
}),
}

public async run(): Promise<void> {
const { argv, flags } = await this.parse(Compile)

p.intro(chalk.bgHex('#2975d1')(' publicodes compile '))
const filesToCompile: string[] =
argv.length === 0
? this.config.pjson?.publicodes?.files ?? ['src/']
: argv

const outputDir = path.resolve(
flags.output ?? this.config.pjson?.publicodes?.output ?? 'build',
)

const rawRules = await parseFiles(filesToCompile, { verbose: false })
const engine = await initEngine(rawRules)
const pkgName = getPackageJson()?.name ?? path.basename(process.cwd())

// Create output directory if it doesn't exist
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true })
}

await this.generateDTS(engine, outputDir)

await generateBaseFiles(rawRules, outputDir, pkgName)

p.outro('Compilation complete! 🎉')
}

async generateDTS(engine: Engine, outputDir: string): Promise<void> {
return runWithSpinner('Generating types', 'Types generated.', () => {
const ruleTypes = resolveRuleTypes(engine)
const serializedRuleTypes = Object.entries(ruleTypes)
.map(([name, type]) => ` "${name}": ${serializeType(type)}`)
.join(',\n')

const dts = `/** THIS FILE WAS GENERATED BY ${this.config.pjson.name} (v${this.config.pjson.version}). PLEASE, DO NOT EDIT IT MANUALLY. */
import { Rule } from 'publicodes'
/**
* String representing a date in the format 'DD/MM/YYYY' or 'MM/YYYY'.
*/
export type PDate = string
/**
* Publicodes boolean type.
*/
export type PBoolean = 'oui' | 'non'
/**
* String constant are enclosed in single quotes to differentiate them from
* references.
*/
export type PString = \`'\${string}'\`
/**
* Corresponding Publicodes situation with types inferred for each rule.
*/
export type TypedSituation = {
${serializedRuleTypes}
}
/**
* All rule names available in the model.
*/
export type RuleName = keyof TypedSituation
declare let rules: Record<RuleName, Rule>
export default rules
`
fs.writeFileSync(path.join(outputDir, 'index.d.ts'), dts)
})
}
}

async function parseFiles(
files: string[],
// TODO: manage options
options: GetModelFromSourceOptions,
): Promise<RawRules> {
return runWithSpinner('Resolving imports', 'Imports resolved.', (spinner) => {
try {
return getModelFromSource(files, {
...options,
logger: {
log: p.log.info,
error: p.log.error,
warn: p.log.warn,
},
})
} catch (error) {
exitWithError({
ctx: 'An error occurred while parsing files:',
msg: error.message,
spinner,
})
}
})
}

async function initEngine(rawRules: RawRules): Promise<Engine> {
return runWithSpinner(
'Checking rules',
`No errors found in ${chalk.bold(Object.keys(rawRules).length)} rules.`,
(spinner) => {
try {
return new Engine(rawRules)
} catch (error) {
exitWithError({
ctx: 'Parsing rules failed:',
msg: error.message,
spinner,
})
}
},
)
}

async function generateBaseFiles(
rawRules: RawRules,
outputDir: string,
pkgName: string,
): Promise<void> {
return runWithSpinner('Emitting files', 'Files emitted.', async (spinner) => {
try {
// Extract package name without scope
const basePkgName = pkgName.replace(/@.*\//, '')

// Generate JSON file
const jsonPath = path.join(outputDir, `${basePkgName}.model.json`)
fs.writeFileSync(jsonPath, JSON.stringify(rawRules))

generateIndexFile(outputDir, jsonPath)
} catch (error) {
exitWithError({
ctx: 'An error occurred while generating files:',
msg: error.message,
spinner,
})
}
})
}

function generateIndexFile(outputDir: string, jsonPath: string): void {
fs.writeFileSync(
path.join(outputDir, 'index.js'),
`import rules from './${path.basename(jsonPath)}' assert { type: 'json' }
export default rules`,
)
}

function serializeType(type: RuleType): string {
const nullable = type.isNullable ? ' | null' : ''
switch (type.type) {
case 'string': {
return `PString${nullable}`
}
case 'number': {
return `number${nullable}`
}
case 'boolean': {
return `PBoolean${nullable}`
}
case 'date': {
return `PDate${nullable}`
}
case 'enum': {
return (
type.options.map((option) => `"'${option}'"`).join(' | ') + nullable
)
}
}
}
2 changes: 1 addition & 1 deletion src/compilation/resolveImports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ importer!:
})

if (verbose) {
console.debug(`📦 ${packageName} loaded`)
logger.log(`📦 ${packageName} loaded`)
}
enginesCache[modelPath] = engine
} catch (e) {
Expand Down
2 changes: 1 addition & 1 deletion src/utils/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export function exitWithError({
} else {
p.log.error(ctx)
}
p.log.message(chalk.dim(msg))
p.log.message(chalk.dim(msg.trim()))
p.outro('Exiting due to an error.')
process.exit(code)
}
Expand Down
3 changes: 2 additions & 1 deletion test/commands/base.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ describe('publicodes --help', () => {

runInDir('tmp', async () => {
const { stdout } = await cli.execCommand('--help')
expect(stdout).toContain('init Initialize a new project')
expect(stdout).toContain('init')
expect(stdout).toContain('compile')
})
})
})
18 changes: 18 additions & 0 deletions test/commands/compile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { CLIExecutor, runInDir } from '../cli-utils'
import fs from 'fs'
import path from 'path'

const cli = new CLIExecutor()

describe('publicodes compile', () => {
it('should compile with no arguments/flags', async () => {
runInDir('tmp', async (cwd) => {
const { stdout } = await cli.execCommand('compile')
expect(stdout).toContain('Compilation complete!')
expect(fs.existsSync('build')).toBe(true)
expect(fs.existsSync('build/index.js')).toBe(true)
expect(fs.existsSync(`build/${path.basename(cwd)}.model.json`)).toBe(true)
expect(fs.existsSync(`build/index.d.ts`)).toBe(true)
})
})
})

0 comments on commit 9566a25

Please sign in to comment.