Skip to content

Commit

Permalink
Snake case options (#324)
Browse files Browse the repository at this point in the history
Co-authored-by: Christian Georgi <[email protected]>
  • Loading branch information
daogrady and chgeo authored Sep 18, 2024
1 parent e02eb90 commit 43d6269
Show file tree
Hide file tree
Showing 15 changed files with 405 additions and 200 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/).

## Version 0.27.0 - TBD
### Changed
- Any configuration variable (via CLI or `cds.env`) can now be passed in snake_case in addition to camelCase
- Action parameters are now generated as optional by default, which is how the runtime treats them. Mandatory parameters have to be marked as `not null` in CDS/CDL, or `notNull` in CSN.

## Version 0.26.0 - 2024-09-11
Expand Down
199 changes: 161 additions & 38 deletions lib/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,139 @@
/* eslint-disable no-console */
'use strict'

/**
* @typedef {import('./typedefs').config.cli.Parameter} Parameter
*/

const cds = require('@sap/cds')
const { compileFromFile } = require('./compile')
const { parseCommandlineArgs } = require('./util')
const { camelToSnake } = require('./util')
const { deprecated, _keyFor } = require('./logging')
const path = require('path')
const { EOL } = require('node:os')
const { camelSnakeHybrid, configuration } = require('./config')

const EOL2 = EOL + EOL
const toolName = 'cds-typer'

// @ts-expect-error - nope, it is actually there. Types just seem to be out of sync.
const lls = cds.log.levels
const parameterTypes = {
boolean:
/**
* @param {Parameter} props - additional parameter properties
* @returns {Parameter}
*/
props => ({...{
allowed: ['true', 'false'],
type: 'boolean',
postprocess: (/** @type {string} */ value) => value === 'true'
},
...props})
}

/**
* Adds additional properties to the CLI parameter schema.
* @param {import('./typedefs').config.cli.ParameterSchema } flags - The CLI parameter schema.
* @returns {import('./typedefs').config.cli.ParameterSchema} - The enriched schema.
*/
const enrichFlagSchema = flags => {
for (const [key, value] of Object.entries(flags)) {
/** @type {Parameter} */(value).camel = key;
/** @type {Parameter} */(value).snake = camelToSnake(key)
}
// @ts-expect-error
flags.hasFlag = flag => Object.values(flags).some(f => f.snake === flag || f.camel === flag)
return camelSnakeHybrid(flags)
}

const flags = {
/**
* Parses command line arguments into named and positional parameters.
* Named parameters are expected to start with a double dash (--).
* If the next argument `B` after a named parameter `A` is not a named parameter itself,
* `B` is used as value for `A`.
* If `A` and `B` are both named parameters, `A` is just treated as a flag (and may receive a default value).
* Only named parameters that occur in validFlags are allowed. Specifying named flags that are not listed there
* will cause an error.
* Named parameters that are either not specified or do not have a value assigned to them may draw a default value
* from their definition in validFlags.
* @param {string[]} argv - list of command line arguments
* @param {import('./typedefs').config.cli.ParameterSchema} schema - allowed flags. May specify default values.
* @returns {import('./typedefs').config.cli.ParsedParameters} - parsed arguments
*/
const parseCommandlineArgs = (argv, schema) => {
const isFlag = (/** @type {string} */ arg) => arg.startsWith('--')
const positional = []
/** @type {import('./typedefs').config.cli.ParsedParameters['named']} */
const named = {}

let i = 0
while (i < argv.length) {
const originalArgName = argv[i] // so our feedback to the user is less confusing
let arg = originalArgName
if (isFlag(arg)) {
arg = camelToSnake(arg.slice(2))
// @ts-expect-error - cba to add hasFlag to the general dictionary
if (!schema.hasFlag(arg)) {
throw new Error(`invalid named flag '${originalArgName}'`)
}
const next = argv[i + 1]
if (next && !isFlag(next)) {
named[arg] = { value: next, isDefault: false }
i++
} else {
named[arg] = { value: schema[arg].default, isDefault: true }
}

const { allowed, allowedHint } = schema[arg]
if (allowed && !allowed.includes(named[arg].value)) {
throw new Error(`invalid value '${named[arg]?.value ?? named[arg]}' for flag ${originalArgName}. Must be one of ${(allowedHint ?? allowed.join(', '))}`)
}
} else {
positional.push(arg)
}
i++
}

// enrich with defaults
/** @type {import('./typedefs').config.cli.ParameterSchema} */
const defaults = Object.entries(schema)
.filter(e => !!e[1].default)
.reduce((dict, [k, v]) => {
// @ts-expect-error - can't infer type of initial {}
dict[camelToSnake(k)] = { value: v.default, isDefault: true }
return dict
}, {})

const namedWithDefaults = {...defaults, ...named}

// apply postprocessing
for (const [key, {value}] of Object.entries(namedWithDefaults)) {
const { postprocess } = schema[key]
if (typeof postprocess === 'function') {
namedWithDefaults[key].value = postprocess(value)
}
}

return {
named: namedWithDefaults,
positional,
}
}

/**
* Adds CLI parameters to the configuration object.
* Precedence: CLI > env > default.
* @param {ReturnType<parseCommandlineArgs>['named']} params - CLI parameters.
*/
const addCLIParamsToConfig = params => {
for (const [key, value] of Object.entries(params)) {
if (!value.isDefault || Object.hasOwn(configuration, key)) {
configuration[key] = value.value
}
}
}

const flags = enrichFlagSchema({
outputDirectory: {
desc: 'Root directory to write the generated files to.',
default: './',
Expand All @@ -30,16 +149,24 @@ const flags = {
allowedHint: Object.keys(lls).join(' | '), // FIXME: remove once old levels are faded out
defaultHint: _keyFor(lls.ERROR),
default: cds?.env?.log?.levels?.['cds-typer'] ?? _keyFor(lls.ERROR),
postprocess: level => {
const newLogLevel = deprecated[level]
if (newLogLevel) {
console.warn(`deprecated log level '${level}', use '${newLogLevel}' instead (changing this automatically for now).`)
return newLogLevel
}
return level
}
},
jsConfigPath: {
desc: `Path to where the jsconfig.json should be written.${EOL}If specified, ${toolName} will create a jsconfig.json file and${EOL}set it up to restrict property usage in types entities to${EOL}existing properties only.`,
type: 'string'
type: 'string',
postprocess: file => file && !file.endsWith('jsconfig.json') ? path.join(file, 'jsconfig.json') : path
},
useEntitiesProxy: {
useEntitiesProxy: parameterTypes.boolean({
desc: `If set to true the 'cds.entities' exports in the generated 'index.js'${EOL}files will be wrapped in 'Proxy' objects\nso static import/require calls can be used everywhere.${EOL}${EOL}WARNING: entity properties can still only be accessed after${EOL}'cds.entities' has been loaded`,
allowed: ['true', 'false'],
default: 'false'
},
}),
version: {
desc: 'Prints the version of this tool.'
},
Expand All @@ -48,17 +175,15 @@ const flags = {
allowed: ['flat', 'structured'],
default: 'structured'
},
propertiesOptional: {
propertiesOptional: parameterTypes.boolean({
desc: `If set to true, properties in entities are${EOL}always generated as optional (a?: T).`,
allowed: ['true', 'false'],
default: 'true'
},
IEEE754Compatible: {
}),
IEEE754Compatible: parameterTypes.boolean({
desc: `If set to true, floating point properties are generated${EOL}as IEEE754 compatible '(number | string)' instead of 'number'.`,
allowed: ['true', 'false'],
default: 'false'
}
}
})
})

const hint = () => 'Missing or invalid parameter(s). Call with --help for more details.'
/**
Expand All @@ -78,15 +203,17 @@ const help = () => `SYNOPSIS${EOL2}` +
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => {
let s = indent(`--${key}`, ' ')
// @ts-expect-error - not going to check presence of each property. Same for the following expect-errors.
const snake = camelToSnake(key)
if (key !== snake) s += EOL + indent(`--${snake}`, ' ')
// ts-expect-error - not going to check presence of each property. Same for the following expect-errors.
if (value.allowedHint) s += ` ${value.allowedHint}`
// @ts-expect-error
// ts-expect-error
else if (value.allowed) s += `: <${value.allowed.join(' | ')}>`
else if ('type' in value && value.type) s += `: <${value.type}>`
// @ts-expect-error
// ts-expect-error
if (value.defaultHint || value.default) {
s += EOL
// @ts-expect-error
// ts-expect-error
s += indent(`(default: ${value.defaultHint ?? value.default})`, ' ')
}
s += `${EOL2}${indent(value.desc, ' ')}`
Expand All @@ -96,7 +223,9 @@ const help = () => `SYNOPSIS${EOL2}` +

const version = () => require('../package.json').version

const main = async (/** @type {any} */ args) => {
const prepareParameters = (/** @type {any[]} */ argv) => {
const args = parseCommandlineArgs(argv, flags)

if ('help' in args.named) {
console.log(help())
process.exit(0)
Expand All @@ -109,27 +238,21 @@ const main = async (/** @type {any} */ args) => {
console.log(hint())
process.exit(1)
}
if (args.named.jsConfigPath && !args.named.jsConfigPath.endsWith('jsconfig.json')) {
args.named.jsConfigPath = path.join(args.named.jsConfigPath, 'jsconfig.json')
}
const newLogLevel = deprecated[args.named.logLevel]
if (newLogLevel) {
console.warn(`deprecated log level '${args.named.logLevel}', use '${newLogLevel}' instead (changing this automatically for now).`)
args.named.logLevel = newLogLevel
}

compileFromFile(args.positional, {
// temporary fix until rootDir is faded out
outputDirectory: [args.named.outputDirectory, args.named.rootDir].find(d => d !== './') ?? './',
logLevel: args.named.logLevel,
useEntitiesProxy: args.named.useEntitiesProxy === 'true',
jsConfigPath: args.named.jsConfigPath,
inlineDeclarations: args.named.inlineDeclarations,
propertiesOptional: args.named.propertiesOptional === 'true',
IEEE754Compatible: args.named.IEEE754Compatible === 'true'
})
addCLIParamsToConfig(args.named)
return args
}

const main = async (/** @type {any[]} */ argv) => {
const { positional } = prepareParameters(argv)
compileFromFile(positional)
}

if (require.main === module) {
main(parseCommandlineArgs(process.argv.slice(2), flags))
main(process.argv.slice(2))
}

module.exports = {
flags,
prepareParameters
}
26 changes: 10 additions & 16 deletions lib/compile.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,7 @@ const util = require('./util')
const { writeout } = require('./file')
const { Visitor } = require('./visitor')
const { LOG, setLevel } = require('./logging')

/**
* @typedef {import('./typedefs').visitor.CompileParameters} CompileParameters
*/
const { configuration } = require('./config')

/**
* Writes the accompanying jsconfig.json file to the specified paths.
Expand Down Expand Up @@ -40,31 +37,28 @@ const writeJsConfig = path => {
/**
* Compiles a CSN object to Typescript types.
* @param {{xtended: import('./typedefs').resolver.CSN, inferred: import('./typedefs').resolver.CSN}} csn - csn tuple
* @param {CompileParameters} parameters - path to root directory for all generated files, min log level
*/
const compileFromCSN = async (csn, parameters) => {
const envSettings = cds.env?.typer ?? {}
parameters = { ...envSettings, ...parameters }
setLevel(parameters.logLevel)
if (parameters.jsConfigPath) {
writeJsConfig(parameters.jsConfigPath)
const compileFromCSN = async csn => {

setLevel(configuration.logLevel)
if (configuration.jsConfigPath) {
writeJsConfig(configuration.jsConfigPath)
}
return writeout(
parameters.outputDirectory,
Object.values(new Visitor(csn, parameters).getWriteoutFiles())
configuration.outputDirectory,
Object.values(new Visitor(csn).getWriteoutFiles())
)
}

/**
* Compiles a .cds file to Typescript types.
* @param {string | string[]} inputFile - path to input .cds file
* @param {CompileParameters} parameters - path to root directory for all generated files, min log level, etc.
*/
const compileFromFile = async (inputFile, parameters) => {
const compileFromFile = async inputFile => {
const paths = typeof inputFile === 'string' ? normalize(inputFile) : inputFile.map(f => normalize(f))
const xtended = await cds.linked(await cds.load(paths, { docs: true, flavor: 'xtended' }))
const inferred = await cds.linked(await cds.load(paths, { docs: true }))
return compileFromCSN({xtended, inferred}, parameters)
return compileFromCSN({xtended, inferred})
}

module.exports = {
Expand Down
3 changes: 2 additions & 1 deletion lib/components/inline.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const { configuration } = require('../config')
const { SourceFile, Buffer } = require('../file')
const { normalise } = require('./identifier')
const { docify } = require('./wrappers')
Expand Down Expand Up @@ -94,7 +95,7 @@ class InlineDeclarationResolver {
* @returns {'?:'|':'}
*/
getPropertyTypeSeparator() {
return this.visitor.options.propertiesOptional ? '?:' : ':'
return configuration.propertiesOptional ? '?:' : ':'
}

/**
Expand Down
Loading

0 comments on commit 43d6269

Please sign in to comment.