diff --git a/lib/compile.js b/lib/compile.js index a3d51c49..5a6c5bb9 100644 --- a/lib/compile.js +++ b/lib/compile.js @@ -9,7 +9,7 @@ const { Visitor } = require('./visitor') const { LOG, setLevel } = require('./logging') /** - * @typedef {import('./visitor').CompileParameters} CompileParameters + * @typedef {import('./typedefs').visitor.CompileParameters} CompileParameters */ /** @@ -39,7 +39,7 @@ const writeJsConfig = path => { /** * Compiles a CSN object to Typescript types. - * @param {{xtended: CSN, inferred: CSN}} csn + * @param {{xtended: CSN, inferred: CSN}} csn - csn tuple * @param {CompileParameters} parameters - path to root directory for all generated files, min log level */ const compileFromCSN = async (csn, parameters) => { diff --git a/lib/components/identifier.js b/lib/components/identifier.js index 14097f86..72ebd259 100644 --- a/lib/components/identifier.js +++ b/lib/components/identifier.js @@ -10,6 +10,14 @@ const normalise = ident => ident && !isValidIdent.test(ident) ? `"${ident}"` : ident +/** + * Returns the last part of a dot-separated identifier. + * @param {string} ident - the identifier to extract the last part from + * @returns {string} the last part of the identifier + */ +const last = ident => ident.split('.').at(-1) + module.exports = { - normalise + normalise, + last } \ No newline at end of file diff --git a/lib/components/inline.js b/lib/components/inline.js index 2e429f57..d662e201 100644 --- a/lib/components/inline.js +++ b/lib/components/inline.js @@ -2,6 +2,8 @@ const { SourceFile, Buffer } = require('../file') const { normalise } = require('./identifier') const { docify } = require('./wrappers') +/** @typedef {import('../resolution/resolver').TypeResolveInfo} TypeResolveInfo */ + /** * Inline declarations of types can come in different flavours. * The compiler can therefore be adjusted to print out one or the other @@ -10,20 +12,20 @@ const { docify } = require('./wrappers') */ class InlineDeclarationResolver { /** - * @param {string} name - * @param {import('./resolver').TypeResolveInfo} type - * @param {import('../file').Buffer} buffer - * @param {string} statementEnd + * @param {string} fq - full qualifier of the type + * @param {TypeResolveInfo} type - type info so far + * @param {import('../file').Buffer} buffer - the buffer to write into + * @param {string} statementEnd - statement ending character * @protected * @abstract */ // eslint-disable-next-line no-unused-vars - printInlineType(name, type, buffer, statementEnd) { /* abstract */ } + printInlineType(fq, type, buffer, statementEnd) { /* abstract */ } /** * Attempts to resolve a type that could reference another type. - * @param {any} items - * @param {import('./resolver').TypeResolveInfo} into - @see Visitor.resolveType + * @param {any} items - properties of the declaration we are resolving + * @param {TypeResolveInfo} into - @see Visitor.resolveType * @param {SourceFile} relativeTo - file to which the resolved type should be relative to * @public */ @@ -57,7 +59,7 @@ class InlineDeclarationResolver { /** * Visits a single element in an entity. * @param {string} name - name of the element - * @param {import('./resolver').CSN} element - CSN data belonging to the the element. + * @param {import('../resolution/resolver').CSN} element - CSN data belonging to the the element. * @param {SourceFile} file - the namespace file the surrounding entity is being printed into. * @param {Buffer} [buffer] - buffer to add the definition to. If no buffer is passed, the passed file's class buffer is used instead. * @public @@ -87,7 +89,7 @@ class InlineDeclarationResolver { /** * It returns TypeScript datatype for provided TS property - * @param {{typeName: string, typeInfo: TypeResolveInfo & { inflection: Inflection } }} type + * @param {{typeName: string, typeInfo: TypeResolveInfo & { inflection: Inflection } }} type - type of the property * @param {string} typeName - name of the TypeScript property * @returns {string} the datatype to be presented on TypeScript layer * @public @@ -96,7 +98,7 @@ class InlineDeclarationResolver { return type.typeInfo.isNotNull ? typeName : `${typeName} | null` } - /** @param {import('../visitor').Visitor} visitor */ + /** @param {import('../visitor').Visitor} visitor - the visitor */ constructor(visitor) { this.visitor = visitor // type resolution might recurse. This indicator is used to determine diff --git a/lib/components/reference.js b/lib/components/reference.js index d99bf40c..36201d54 100644 --- a/lib/components/reference.js +++ b/lib/components/reference.js @@ -12,7 +12,7 @@ * y: E.x // <- ref * } * ``` - * @param {{type: any}} element + * @param {{type: any}} element - the element * @returns boolean */ const isReferenceType = element => element.type && Object.hasOwn(element.type, 'ref') diff --git a/lib/csn.js b/lib/csn.js index e224c458..714237c1 100644 --- a/lib/csn.js +++ b/lib/csn.js @@ -5,15 +5,20 @@ const annotation = '@odata.draft.enabled' * i.e. ones that have a query, but are not a cds level projection. * Those are still not expanded and we have to retrieve their definition * with all properties from the inferred model. - * @param {any} entity + * @param {any} entity - the entity */ const isView = entity => entity.query && !entity.projection const isProjection = entity => entity.projection -const getViewTarget = entity => entity.query?.SELECT?.from?.ref?.[0] +/** + * @param {any} entity - the entity + * @see isView + * Unresolved entities have to be looked up from inferred csn. + */ +const isUnresolved = entity => entity._unresolved === true -const getProjectionTarget = entity => entity.projection?.from?.ref?.[0] +const isCsnAny = entity => entity?.constructor?.name === 'any' const isDraftEnabled = entity => entity['@odata.draft.enabled'] === true @@ -22,13 +27,20 @@ const isType = entity => entity?.kind === 'type' const isEntity = entity => entity?.kind === 'entity' /** - * @param {any} entity - * @see isView - * Unresolved entities have to be looked up from inferred csn. + * Attempts to retrieve the max cardinality of a CSN for an entity. + * @param {EntityCSN} element - csn of entity to retrieve cardinality for + * @returns {number} max cardinality of the element. + * If no cardinality is attached to the element, cardinality is 1. + * If it is set to '*', result is Infinity. */ -const isUnresolved = entity => entity._unresolved === true +const getMaxCardinality = element => { + const cardinality = element?.cardinality?.max ?? 1 + return cardinality === '*' ? Infinity : parseInt(cardinality) +} -const isCsnAny = entity => entity?.constructor?.name === 'any' +const getViewTarget = entity => entity.query?.SELECT?.from?.ref?.[0] + +const getProjectionTarget = entity => entity.projection?.from?.ref?.[0] class DraftUnroller { /** @type {Set} */ @@ -160,7 +172,7 @@ class DraftUnroller { * (a) aspects via `A: B`, where `B` is draft enabled. * Note that when an entity extends two other entities of which one has drafts enabled and * one has not, then the one that is later in the list of mixins "wins": - * @param {any} csn + * @param {any} csn - the entity * @example * ```ts * ļ¼ odata.draft.enabled true @@ -201,7 +213,7 @@ function unrollDraftability(csn) { * * This explicit propagation is required to add foreign key relations * to referring entities. - * @param {any} csn + * @param {any} csn - the entity * @example * ```cds * entity A: cuid { key name: String; } @@ -251,7 +263,7 @@ function propagateForeignKeys(csn) { /** * - * @param {any} csn + * @param {any} csn - complete csn */ function amendCSN(csn) { unrollDraftability(csn) @@ -282,6 +294,7 @@ module.exports = { isEntity, isUnresolved, isType, + getMaxCardinality, getProjectionTarget, getProjectionAliases, getViewTarget, diff --git a/lib/resolution/builtin.js b/lib/resolution/builtin.js new file mode 100644 index 00000000..ed1e5996 --- /dev/null +++ b/lib/resolution/builtin.js @@ -0,0 +1,64 @@ +class BuiltinResolver { + /** + * Builtin types defined by CDS. + */ + #builtins = { + UUID: 'string', + String: 'string', + Binary: 'string', + LargeString: 'string', + LargeBinary: 'Buffer | string | {value: import("stream").Readable, $mediaContentType: string, $mediaContentDispositionFilename?: string, $mediaContentDispositionType?: string}', + Vector: 'string', + Integer: 'number', + UInt8: 'number', + Int16: 'number', + Int32: 'number', + Int64: 'number', + Integer64: 'number', + Decimal: 'number', + DecimalFloat: 'number', + Float: 'number', + Double: 'number', + Boolean: 'boolean', + // note: the date-related types are strings on purpose, which reflects their runtime behaviour + Date: '__.CdsDate', // yyyy-mm-dd + DateTime: '__.CdsDateTime', // yyyy-mm-dd + time + TZ (precision: seconds) + Time: '__.CdsTime', // hh:mm:ss + Timestamp: '__.CdsTimestamp', // yyy-mm-dd + time + TZ (ms precision) + // + Composition: 'Array', + Association: 'Array' + } + + /** + * @param {object} options - additional resolution options + * @param {boolean} options.IEEE754Compatible - if true, the Decimal, DecimalFloat, Float, and Double types are also allowed to be strings + */ + constructor ({ IEEE754Compatible } = {}) { + if (IEEE754Compatible) { + this.#builtins.Decimal = '(number | string)' + this.#builtins.DecimalFloat = '(number | string)' + this.#builtins.Float = '(number | string)' + this.#builtins.Double = '(number | string)' + } + this.#builtins = Object.freeze(this.#builtins) + } + + /** + * @param {string | string[]} t - name or parts of the type name split on dots + * @returns {string | undefined | false} if t refers to a builtin, the name of the corresponding TS type is returned. + * If t _looks like_ a builtin (`cds.X`), undefined is returned. + * If t is obviously not a builtin, false is returned. + */ + resolveBuiltin (t) { + if (!Array.isArray(t) && typeof t !== 'string') return false + const path = Array.isArray(t) ? t : t.split('.') + return path.length === 2 && path[0] === 'cds' + ? this.#builtins[path[1]] + : false + } +} + +module.exports = { + BuiltinResolver +} \ No newline at end of file diff --git a/lib/resolution/entity.js b/lib/resolution/entity.js new file mode 100644 index 00000000..621278ed --- /dev/null +++ b/lib/resolution/entity.js @@ -0,0 +1,155 @@ +class EntityInfo { + /** + * @example + * ```ts + * 'n1.n2.A.B.p.q' + * // v + * Path(['n1', 'n2']) + * ``` + * @type {Path} + */ + namespace + + // FIXME: check if scope can actually be more than one entity deep + /** + * @example + * ```ts + * 'n1.n2.A.B.p.q' + * // v + * ['A'] + * ``` + * @type {string[]} + */ + scope + + /** + * @example + * ```ts + * 'n1.n2.A.B.p.q' + * // v + * 'B' + * ``` + * @type {string} + */ + entityName + + /** + * @example + * ```ts + * 'n1.n2.A.B.p.q' + * // v + * ['p', 'q'] + * ``` + * @type {string[]} + */ + propertyAccess + + /** @type {{singular: string, plural: string}} */ + #inflection + + /** @type {import('./resolver').Resolver} */ + #resolver + + /** @type {EntityRepository} */ + #repository + + /** @type {EntityInfo} */ + #parent + + /** @type {import('../typedefs').resolver.EntityCSN} */ + #csn + + get csn () { + return this.#csn ??= this.#resolver.csn.definitions[this.fullyQualifiedName] + } + + /** + * @example + * ```ts + * 'n1.n2.A.B.p.q' + * // v + * { singular: B, plural: Bs } + * ``` + */ + get inflection () { + if (!this.#inflection) { + const dummyTypeInfo = { + plainName: this.entityName, + csn: this.csn, + isInlineDeclaration: false + } + const { singular, plural } = this.#resolver.inflect(dummyTypeInfo, this.namespace) + this.#inflection = { singular, plural } + } + return this.#inflection + } + + /** + * @example + * ```ts + * 'n1.n2.A.B.p.q' + * // v + * 'A.B.p.q' + * ``` + * @type {string} + */ + get withoutNamespace () { + return [this.scope, this.entityName, this.propertyAccess].flat().join('.') + } + + /** + * @returns {EntityInfo | null} + */ + get parent () { + if (this.#parent !== undefined) return this.#parent + const parentFq = [this.namespace, this.scope].flat().join('.') + return this.#parent = this.#repository.getByFq(parentFq) + } + + /** + * @param {string} fullyQualifiedName - the fully qualified name of the entity + * @param {EntityRepository} repository - the repository this info is stored in + * @param {import('./resolver').Resolver} resolver - the resolver + */ + constructor (fullyQualifiedName, repository, resolver) { + const untangled = resolver.untangle(fullyQualifiedName) + this.#repository = repository + this.#resolver = resolver + this.fullyQualifiedName = fullyQualifiedName + this.namespace = untangled.namespace + this.scope = untangled.scope + this.entityName = untangled.name + this.propertyAccess = untangled.property + } +} + +class EntityRepository { + /** @type {{ [key: string]: EntityInfo }} */ + #cache = {} + + /** @type {import('./resolver').Resolver} */ + #resolver + + /** + * @param {string} fq - fully qualified name of the entity + * @returns {EntityInfo | null} + */ + getByFq (fq) { + if (this.#cache[fq] !== undefined) return this.#cache[fq] + this.#cache[fq] = this.#resolver.isPartOfModel(fq) + ? new EntityInfo(fq, this, this.#resolver) + : null + return this.#cache[fq] + } + + /** + * @param {import('./resolver').Resolver} resolver - the resolver + */ + constructor (resolver) { + this.#resolver = resolver + } +} + +module.exports = { + EntityRepository +} \ No newline at end of file diff --git a/lib/components/resolver.js b/lib/resolution/resolver.js similarity index 83% rename from lib/components/resolver.js rename to lib/resolution/resolver.js index 769bfef6..becff3b1 100644 --- a/lib/components/resolver.js +++ b/lib/resolution/resolver.js @@ -3,123 +3,25 @@ const util = require('../util') // eslint-disable-next-line no-unused-vars const { Buffer, SourceFile, Path, Library } = require('../file') -const { deepRequire, createToManyAssociation, createToOneAssociation, createArrayOf, createCompositionOfMany, createCompositionOfOne } = require('./wrappers') -const { StructuredInlineDeclarationResolver } = require('./inline') -const { isInlineEnumType, propertyToInlineEnumName } = require('./enum') -const { isReferenceType } = require('./reference') -const { isEntity } = require('../csn') -const { baseDefinitions } = require('./basedefs') +const { deepRequire, createToManyAssociation, createToOneAssociation, createArrayOf, createCompositionOfMany, createCompositionOfOne } = require('../components/wrappers') +const { StructuredInlineDeclarationResolver } = require('../components/inline') +const { isInlineEnumType, propertyToInlineEnumName } = require('../components/enum') +const { isReferenceType } = require('../components/reference') +const { isEntity, getMaxCardinality } = require('../csn') +const { baseDefinitions } = require('../components/basedefs') +const { BuiltinResolver } = require('./builtin') const { LOG } = require('../logging') +const { last } = require('../components/identifier') /** @typedef {import('../visitor').Visitor} Visitor */ /** @typedef {import('../typedefs').resolver.CSN} CSN */ /** @typedef {import('../typedefs').resolver.TypeResolveInfo} TypeResolveInfo */ - -class BuiltinResolver { - /** - * Builtin types defined by CDS. - */ - #builtins = { - UUID: 'string', - String: 'string', - Binary: 'string', - LargeString: 'string', - LargeBinary: 'Buffer | string | {value: import("stream").Readable, $mediaContentType: string, $mediaContentDispositionFilename?: string, $mediaContentDispositionType?: string}', - Vector: 'string', - Integer: 'number', - UInt8: 'number', - Int16: 'number', - Int32: 'number', - Int64: 'number', - Integer64: 'number', - Decimal: 'number', - DecimalFloat: 'number', - Float: 'number', - Double: 'number', - Boolean: 'boolean', - // note: the date-related types are strings on purpose, which reflects their runtime behaviour - Date: '__.CdsDate', // yyyy-mm-dd - DateTime: '__.CdsDateTime', // yyyy-mm-dd + time + TZ (precision: seconds) - Time: '__.CdsTime', // hh:mm:ss - Timestamp: '__.CdsTimestamp', // yyy-mm-dd + time + TZ (ms precision) - // - Composition: 'Array', - Association: 'Array' - } - - constructor ({ IEEE754Compatible } = {}) { - if (IEEE754Compatible) { - this.#builtins.Decimal = '(number | string)' - this.#builtins.DecimalFloat = '(number | string)' - this.#builtins.Float = '(number | string)' - this.#builtins.Double = '(number | string)' - } - this.#builtins = Object.freeze(this.#builtins) - } - - /** - * @param {string | string[]} t - name or parts of the type name split on dots - * @returns {string | undefined | false} if t refers to a builtin, the name of the corresponding TS type is returned. - * If t _looks like_ a builtin (`cds.X`), undefined is returned. - * If t is obviously not a builtin, false is returned. - */ - resolveBuiltin (t) { - if (!Array.isArray(t) && typeof t !== 'string') return false - const path = Array.isArray(t) ? t : t.split('.') - return path.length === 2 && path[0] === 'cds' - ? this.#builtins[path[1]] - : false - } -} +/** @typedef {import('../typedefs').visitor.Inflection} TypeResolveInfo */ class Resolver { - - #caches = { - /** - * @type {{ [qualifier: string]: string }} - */ - namespaces: {}, - /** - * @type {{ [qualifier: string]: string[] }} - */ - propertyAccesses: {} - } - - /** - * @param {string} qualifier - * @returns {string?} - */ - #getCachedNamespace (qualifier) { - return this.#caches.namespaces[qualifier] - } - - /** - * @param {string} qualifier - * @param {string} namespace - */ - #cacheNamespace (qualifier, namespace) { - this.#caches.namespaces[qualifier] = namespace - } - - /** - * @param {string} qualifier - * @returns {string[]?} - */ - #getCachedPropertyAccess (qualifier) { - return this.#caches.propertyAccesses[qualifier] - } - - /** - * @param {string} qualifier - * @param {string[]} propertyAccess - */ - #cachePropertyAccess (qualifier, propertyAccess) { - this.#caches.propertyAccesses[qualifier] = propertyAccess - } - get csn() { return this.visitor.csn.inferred } - /** @param {Visitor} visitor */ + /** @param {Visitor} visitor - the visitor */ constructor(visitor) { /** @type {Visitor} */ this.visitor = visitor @@ -135,7 +37,23 @@ class Resolver { * needed for inline declarations */ this.structuredInlineResolver = new StructuredInlineDeclarationResolver(this.visitor) - } + } + + /** + * @param {string} fq - fully qualified name of the entity + * @returns {boolean} true, iff the entity exists in the CSN (excluding builtins, see {@link isPartOfModel}) + */ + existsInCsn(fq) { + return Boolean(this.csn.definitions[fq]) + } + + /** + * @param {string} fq - fully qualified name of the entity or builtin + * @returns {boolean} true, iff the entity exists in the CSN or is identified as a builtin + */ + isPartOfModel(fq) { + return this.existsInCsn(fq) || Boolean(this.builtinResolver.resolveBuiltin(fq)) + } /** * Returns all libraries that have been referenced at least once. @@ -145,27 +63,19 @@ class Resolver { return this.libraries.filter(l => l.referenced) } - /** - * TODO: this should probably be a class where we can also cache the properties - * and only retrieve them on demand - * @typedef {object} Untangled - * @property {string[]} scope in case the entity is wrapped in another entity `a.b.C.D.E.f.g` -> `[C,D]` - * @property {string} name name of the leaf entity `a.b.C.D.E.f.g` -> `E` - * @property {string[]} property the property access path `a.b.C.D.E.f.g` -> `[f,g]` - * @property {Path} namespace the cds namespace of the entity `a.b.C.D.E.f.g` -> `a.b` - */ - /** * Conveniently combines resolveNamespace and trimNamespace * to end up with both the resolved Path of the namespace, * and the clean name of the class. * @param {string} fq - the fully qualified name of an entity. - * @returns {Untangled} untangled qualifier + * @returns {import('../typedefs').resolver.Untangled} untangled qualifier */ untangle(fq) { const builtin = this.builtinResolver.resolveBuiltin(fq) if (builtin) return { namespace: new Path([]), name: builtin, property: [], scope: [] } + // FIXME: if fq points to a service definition, ns will be the same as nameAndProperty + // this currently isn't a problem as we only use the its name, but should be addressed at some point const ns = this.resolveNamespace(fq) const nameAndProperty = this.trimNamespace(fq) const property = this.findPropertyAccess(fq) @@ -192,7 +102,6 @@ class Resolver { * @returns {string} the entity name without leading namespace. */ trimNamespace(p) { - //if (this.#getCachedNamespace(p)) return this.#getCachedNamespace(p) const parts = p.split('.') if (parts.length <= 1) return p @@ -234,7 +143,6 @@ class Resolver { * ``` */ findPropertyAccess(p) { - if (this.#getCachedPropertyAccess(p)) return this.#getCachedPropertyAccess(p) const parts = p.split('.') if (parts.length <= 1) return [] @@ -254,7 +162,6 @@ class Resolver { } // assuming Entity3 _does_ own a property "property1", return [property1, property2] const propertyAccess = isPropertyOf(parts[0], defs[qualifier]) ? parts : [] - this.#cachePropertyAccess(p, propertyAccess) return propertyAccess } @@ -345,7 +252,7 @@ class Resolver { */ resolveAndRequire(element, file) { const typeInfo = this.resolveType(element, file) - const cardinality = this.getMaxCardinality(element) + const cardinality = getMaxCardinality(element) let typeName = typeInfo.plainName ?? typeInfo.type @@ -373,7 +280,7 @@ class Resolver { // But we can't just fix it in inflection(...), as that would break several other things // So we bandaid-fix it back here, as it is the least intrusive place -- but this should get fixed asap! if (target.type) { - const untangled = this.untangle(target.type) + const untangled = this.visitor.entityRepository.getByFq(target.type) const scope = untangled.scope.join('.') if (scope && !singular.startsWith(scope)) { singular = `${scope}.${singular}` @@ -427,8 +334,8 @@ class Resolver { // handle typeof (unless it has already been handled above) const target = element.target?.name ?? element.type?.ref?.join('.') ?? element.type if (target && !typeInfo.isDeepRequire) { - const { property: propertyAccess } = this.untangle(target) - if (propertyAccess.length) { + const { propertyAccess } = this.visitor.entityRepository.getByFq(target) ?? {} + if (propertyAccess?.length) { const element = target.slice(0, -propertyAccess.join('.').length - 1) const access = this.visitor.inlineDeclarationResolver.getTypeLookup(propertyAccess) // singular, as we have to access the property of the entity @@ -448,18 +355,6 @@ class Resolver { return { typeName, typeInfo } } - /** - * Attempts to retrieve the max cardinality of a CSN for an entity. - * @param {EntityCSN} element - csn of entity to retrieve cardinality for - * @returns {number} max cardinality of the element. - * If no cardinality is attached to the element, cardinality is 1. - * If it is set to '*', result is Infinity. - */ - getMaxCardinality(element) { - const cardinality = element?.cardinality?.max ?? 1 - return cardinality === '*' ? Infinity : parseInt(cardinality) - } - /** * Resolves the fully qualified name of an entity to its parent entity. * resolveParent(a.b.c.D) -> CSN {a.b.c} @@ -481,8 +376,6 @@ class Resolver { */ resolveNamespace(pathParts) { if (typeof pathParts === 'string') pathParts = pathParts.split('.') - const fq = pathParts.join('.') - if (this.#getCachedNamespace(fq)) return this.#getCachedNamespace(fq) let result while (result === undefined) { const path = pathParts.join('.') @@ -495,7 +388,6 @@ class Resolver { pathParts = pathParts.slice(0, -1) } } - this.#cacheNamespace(fq, result) return result } @@ -511,7 +403,7 @@ class Resolver { // with an already resolved type. In that case, just return the type we have. if (element && Object.hasOwn(element, 'isBuiltin')) return element - const cardinality = this.getMaxCardinality(element) + const cardinality = getMaxCardinality(element) const result = { isBuiltin: false, // will be rectified in the corresponding handlers, if needed @@ -670,7 +562,7 @@ class Resolver { // class Book { title: _cds_hana.cds.hana.VARCHAR } // <- how it would be without discarding the namespace // class Book { title: _cds_hana.VARCHAR } // <- how we want it to look // ``` - const plain = t.split('.').at(-1) + const plain = last(t) lib.referenced = true result.type = plain result.isBuiltin = false @@ -686,6 +578,4 @@ class Resolver { } } -module.exports = { - Resolver -} \ No newline at end of file +module.exports = { Resolver } \ No newline at end of file diff --git a/lib/typedefs.d.ts b/lib/typedefs.d.ts index c531a102..95735e5f 100644 --- a/lib/typedefs.d.ts +++ b/lib/typedefs.d.ts @@ -31,6 +31,18 @@ export module resolver { imports: Path[] inner: TypeResolveInfo } + + // TODO: this will be completely replaced by EntityInfo + export type Untangled = { + // scope in case the entity is wrapped in another entity `a.b.C.D.E.f.g` -> `[C,D]` + scope: string[], + // name name of the leaf entity `a.b.C.D.E.f.g` -> `E` + name: string, + // property the property access path `a.b.C.D.E.f.g` -> `[f,g]` + property: string[], + // namespace the cds namespace of the entity `a.b.C.D.E.f.g` -> `a.b` + namespace: Path + } } export module util { @@ -59,7 +71,12 @@ export module visitor { } export type VisitorOptions = { + /** `propertiesOptional = true` -> all properties are generated as optional ?:. (standard CAP behaviour, where properties be unavailable) */ propertiesOptional: boolean, + /** + * `inlineDeclarations = 'structured'` -> @see {@link inline.StructuredInlineDeclarationResolver} + * `inlineDeclarations = 'flat'` -> @see {@link inline.FlatInlineDeclarationResolver} + */ inlineDeclarations: 'flat' | 'structured', } @@ -68,6 +85,10 @@ export module visitor { singular: string, plural: string } + + export type Context = { + entity: string + } } export module file { diff --git a/lib/visitor.js b/lib/visitor.js index 0f7f9d18..0fd5b9ed 100644 --- a/lib/visitor.js +++ b/lib/visitor.js @@ -2,17 +2,19 @@ const util = require('./util') -const { amendCSN, isView, isUnresolved, propagateForeignKeys, isDraftEnabled, isType, isProjection } = require('./csn') +const { amendCSN, isView, isUnresolved, propagateForeignKeys, isDraftEnabled, isType, isProjection, getMaxCardinality } = require('./csn') // eslint-disable-next-line no-unused-vars const { SourceFile, FileRepository, Buffer } = require('./file') const { FlatInlineDeclarationResolver, StructuredInlineDeclarationResolver } = require('./components/inline') -const { Resolver } = require('./components/resolver') +const { Resolver } = require('./resolution/resolver') const { LOG } = require('./logging') const { docify } = require('./components/wrappers') const { csnToEnumPairs, propertyToInlineEnumName, isInlineEnumType, stringifyEnumType } = require('./components/enum') const { isReferenceType } = require('./components/reference') const { empty } = require('./components/typescript') const { baseDefinitions } = require('./components/basedefs') +const { EntityRepository } = require('./resolution/entity') +const { last } = require('./components/identifier') /** @typedef {import('./file').File} File */ /** @typedef {import('./typedefs').visitor.Context} Context */ @@ -39,7 +41,7 @@ class Visitor { /** * @param {{xtended: CSN, inferred: CSN}} csn - root CSN - * @param {VisitorOptions} options + * @param {VisitorOptions} options - the options */ constructor(csn, options = {}) { amendCSN(csn.xtended) @@ -53,6 +55,9 @@ class Visitor { /** @type {Resolver} */ this.resolver = new Resolver(this) + /** @type {EntityRepository} */ + this.entityRepository = new EntityRepository(this.resolver) + /** @type {FileRepository} */ this.fileRepository = new FileRepository() this.fileRepository.add(baseDefinitions.path.asNamespace(), baseDefinitions) @@ -103,10 +108,10 @@ class Visitor { /** * Retrieves all the keys from an entity. * That is: all keys that are present in both inferred, as well as xtended flavour. - * @param {string} name + * @param {string} fq - fully qualified name of the entity * @returns {[string, object][]} array of key name and key element pairs */ - #keys(name) { + #keys(fq) { // FIXME: this is actually pretty bad, as not only have to propagate keys through // both flavours of CSN (see constructor), but we are now also collecting them from // both flavours and deduplicating them. @@ -114,8 +119,8 @@ class Visitor { // inferred contains keys from queried entities (thing `entity Foo as select from Bar`, where Bar has keys) // So we currently need them both. return Object.entries({ - ...this.csn.inferred.definitions[name]?.keys ?? {}, - ...this.csn.xtended.definitions[name]?.keys ?? {} + ...this.csn.inferred.definitions[fq]?.keys ?? {}, + ...this.csn.xtended.definitions[fq]?.keys ?? {} }) } @@ -125,27 +130,28 @@ class Visitor { * - the function A(B) to mix the aspect into another class B * - the const AXtended which represents the entity A with all of its aspects mixed in (this const is not exported) * - the type A to use for external typing and is derived from AXtended. - * @param {string} name - the name of the entity + * @param {string} fq - the name of the entity * @param {CSN} entity - the pointer into the CSN to extract the elements from * @param {Buffer} buffer - the buffer to write the resulting definitions into - * @param {{cleanName?: string}} options + * @param {{cleanName?: string}} options - additional options */ - #aspectify(name, entity, buffer, options = {}) { - const clean = options?.cleanName ?? this.resolver.trimNamespace(name) - const namespace = this.resolver.resolveNamespace(name.split('.')) + #aspectify(fq, entity, buffer, options = {}) { + const info = this.entityRepository.getByFq(fq) + const clean = options?.cleanName ?? info.withoutNamespace + const { namespace } = info const file = this.fileRepository.getNamespaceFile(namespace) const identSingular = name => name const identAspect = name => `_${name}Aspect` - this.contexts.push({ entity: name }) + this.contexts.push({ entity: fq }) // CLASS ASPECT - buffer.addIndentedBlock(`export function ${identAspect(clean)} object>(Base: TBase) {`, function () { - buffer.addIndentedBlock(`return class ${clean} extends Base {`, function () { + buffer.addIndentedBlock(`export function ${identAspect(clean)} object>(Base: TBase) {`, () => { + buffer.addIndentedBlock(`return class ${clean} extends Base {`, () => { const enums = [] for (let [ename, element] of Object.entries(entity.elements ?? {})) { if (element.target && /\.texts?/.test(element.target)) { - LOG.warn(`referring to .texts property in ${name}. This is currently not supported and will be ignored.`) + LOG.warn(`referring to .texts property in ${fq}. This is currently not supported and will be ignored.`) continue } this.visitElement(ename, element, file, buffer) @@ -156,10 +162,10 @@ class Visitor { // We don't really have to care for this case, as keys from such structs are _not_ propagated to // the containing entity. for (const [kname, originalKeyElement] of this.#keys(element.target)) { - if (this.resolver.getMaxCardinality(element) === 1 && typeof element.on !== 'object') { // FIXME: kelement? + if (getMaxCardinality(element) === 1 && typeof element.on !== 'object') { // FIXME: kelement? const foreignKey = `${ename}_${kname}` if (Object.hasOwn(entity.elements, foreignKey)) { - LOG.error(`Attempting to generate a foreign key reference called '${foreignKey}' in type definition for entity ${name}. But a property of that name is already defined explicitly. Consider renaming that property.`) + LOG.error(`Attempting to generate a foreign key reference called '${foreignKey}' in type definition for entity ${fq}. But a property of that name is already defined explicitly. Consider renaming that property.`) } else { const kelement = Object.assign(Object.create(originalKeyElement), { isRefNotNull: !!element.notNull || !!element.key @@ -176,10 +182,10 @@ class Visitor { } } - buffer.addIndented(function() { + buffer.addIndented(() => { for (const e of enums) { buffer.add(`static ${e.name} = ${propertyToInlineEnumName(clean, e.name)}`) - file.addInlineEnum(clean, name, e.name, csnToEnumPairs(e, {unwrapVals: true})) + file.addInlineEnum(clean, fq, e.name, csnToEnumPairs(e, {unwrapVals: true})) } const actions = Object.entries(entity.actions ?? {}) if (actions.length) { @@ -194,19 +200,19 @@ class Visitor { } else { buffer.add(`static readonly actions: ${empty}`) } - }.bind(this)) - }.bind(this), '};') // end of generated class - }.bind(this), '}') // end of aspect + }) + }, '};') // end of generated class + }, '}') // end of aspect // CLASS WITH ADDED ASPECTS file.addImport(baseDefinitions.path) const ancestors = (entity.includes ?? []) .map(parent => { - const { namespace, name } = this.resolver.untangle(parent) + const { namespace, entityName } = this.entityRepository.getByFq(parent) file.addImport(namespace) - return [namespace, name, parent] + return [namespace, entityName, parent] }) - .concat([[undefined, clean, name]]) // add own aspect without namespace AFTER imports were created + .concat([[undefined, clean, fq]]) // add own aspect without namespace AFTER imports were created //.concat([[undefined, clean, [namespace, clean].filter(Boolean).join('.')]]) // add own aspect without namespace AFTER imports were created .reverse() // reverse so that own aspect A is applied before extensions B,C: B(C(A(Entity))) .reduce((wrapped, [ns, n, fq]) => { @@ -232,37 +238,30 @@ class Visitor { return isDraftEnabled(entity) ? [`static drafts: typeof ${clean}`] : [] } - #printEntity(name, entity) { + #printEntity(fq, entity) { // static .name has to be defined more forcefully: https://github.com/microsoft/TypeScript/issues/442 const overrideNameProperty = (clazz, content) => `Object.defineProperty(${clazz}, 'name', { value: '${content}' })` - const { namespace: ns, name: clean } = this.resolver.untangle(name) + const { namespace: ns, entityName: clean, inflection } = this.entityRepository.getByFq(fq) const file = this.fileRepository.getNamespaceFile(ns) - // entities are expected to be in plural anyway, so we would favour the regular name. - // If the user decides to pass a @plural annotation, that gets precedence over the regular name. - - /* - let plural = this.resolver.trimNamespace(util.getPluralAnnotation(entity) ? util.plural4(entity, false) : name) - const singular = this.resolver.trimNamespace(util.singular4(entity, true)) - */ - let { singular, plural } = this.resolver.inflect({csn: entity, plainName: clean}, ns.asNamespace()) + let { singular, plural } = inflection // trimNamespace does not properly detect scoped entities, like A.B where both A and B are // entities. So to see if we would run into a naming collision, we forcefully take the last // part of the name, so "A.B" and "A.Bs" just become "B" and "Bs" to be compared. - // FIXME: put this in a util function - if (plural.split('.').at(-1) === `${singular.split('.').at(-1)}_`) { + if (last(plural) === `${last(singular)}_`) { LOG.warn( `Derived singular and plural forms for '${singular}' are the same. This usually happens when your CDS entities are named following singular flexion. Consider naming your entities in plural or providing '@singular:'/ '@plural:' annotations to have a clear distinction between the two. Plural form will be renamed to '${plural}' to avoid compilation errors within the output.` ) } + // as types are not inflected, their singular will always clash and there is also no plural for them anyway -> skip if (!isType(entity) && `${ns.asNamespace()}.${singular}` in this.csn.xtended.definitions) { LOG.error( - `Derived singular '${singular}' for your entity '${name}', already exists. The resulting types will be erronous. Consider using '@singular:'/ '@plural:' annotations in your model or move the offending declarations into different namespaces to resolve this collision.` + `Derived singular '${singular}' for your entity '${fq}', already exists. The resulting types will be erronous. Consider using '@singular:'/ '@plural:' annotations in your model or move the offending declarations into different namespaces to resolve this collision.` ) } - file.addClass(singular, name) - file.addClass(plural, name) + file.addClass(singular, fq) + file.addClass(plural, fq) const parent = this.resolver.resolveParent(entity.name) const buffer = parent && parent.kind === 'entity' @@ -280,13 +279,13 @@ class Visitor { // in case of projections `entity` is empty -> retrieve from inferred csn where the actual properties are rolled out const target = isProjection(entity) || isView(entity) - ? this.csn.inferred.definitions[name] + ? this.csn.inferred.definitions[fq] : entity // draft enablement is stored in csn.xtended. Iff we took the entity from csn.inferred, we have to carry the draft-enablement over at this point target['@odata.draft.enabled'] = isDraftEnabled(entity) - this.#aspectify(name, target, buffer, { cleanName: singular }) + this.#aspectify(fq, target, buffer, { cleanName: singular }) buffer.add(overrideNameProperty(singular, entity.name)) buffer.add(`Object.defineProperty(${singular}, 'is_singular', { value: true })`) @@ -297,7 +296,7 @@ class Visitor { if (!isType(entity)) { if (plural.includes('.')) { // Foo.text -> namespace Foo { class text { ... }} - plural = plural.split('.').at(-1) + plural = last(plural) } // plural can not be a type alias to $singular[] but needs to be a proper class instead, // so it can get passed as value to CQL functions. @@ -341,14 +340,14 @@ class Visitor { } /** - * @param {string} name - * @param {object} operation - * @param {'function' | 'action'} kind + * @param {string} fq - fully qualified name of the operation + * @param {object} operation - CSN + * @param {'function' | 'action'} kind - kind of operation */ - #printOperation(name, operation, kind) { - LOG.debug(`Printing operation ${name}:\n${JSON.stringify(operation, null, 2)}`) - const ns = this.resolver.resolveNamespace(name.split('.')) - const file = this.fileRepository.getNamespaceFile(ns) + #printOperation(fq, operation, kind) { + LOG.debug(`Printing operation ${fq}:\n${JSON.stringify(operation, null, 2)}`) + const { namespace } = this.entityRepository.getByFq(fq) + const file = this.fileRepository.getNamespaceFile(namespace) const params = this.#stringifyFunctionParams(operation.params, file) const returnType = operation.returns ? this.resolver.resolveAndRequire(operation.returns, file) @@ -357,59 +356,59 @@ class Visitor { returnType, returnType.typeInfo.isArray ? returnType.typeName : returnType.typeInfo.inflection.singular ) - file.addOperation(name.split('.').at(-1), params, returns, kind) + file.addOperation(last(fq), params, returns, kind) } - #printType(name, type) { - LOG.debug(`Printing type ${name}:\n${JSON.stringify(type, null, 2)}`) - const { namespace: ns, name: clean } = this.resolver.untangle(name) - const file = this.fileRepository.getNamespaceFile(ns) + #printType(fq, type) { + LOG.debug(`Printing type ${fq}:\n${JSON.stringify(type, null, 2)}`) + const { namespace, entityName } = this.entityRepository.getByFq(fq) + const file = this.fileRepository.getNamespaceFile(namespace) // skip references to enums. // "Base" enums will always have a builtin type (don't skip those). // A type referencing an enum E will be considered an enum itself and have .type === E (skip). if ('enum' in type && !isReferenceType(type) && this.resolver.builtinResolver.resolveBuiltin(type.type)) { - file.addEnum(name, clean, csnToEnumPairs(type)) + file.addEnum(fq, entityName, csnToEnumPairs(type)) } else { // alias - file.addType(name, clean, this.resolver.resolveAndRequire(type, file).typeName) + file.addType(fq, entityName, this.resolver.resolveAndRequire(type, file).typeName) } // TODO: annotations not handled yet } - #printAspect(name, aspect) { - LOG.debug(`Printing aspect ${name}`) - const { namespace: ns, name: clean } = this.resolver.untangle(name) - const file = this.fileRepository.getNamespaceFile(ns) + #printAspect(fq, aspect) { + LOG.debug(`Printing aspect ${fq}`) + const { namespace, entityName } = this.entityRepository.getByFq(fq) + const file = this.fileRepository.getNamespaceFile(namespace) // aspects are technically classes and can therefore be added to the list of defined classes. // Still, when using them as mixins for a class, they need to already be defined. // So we separate them into another buffer which is printed before the classes. - file.addClass(clean, name) - file.aspects.add(`// the following represents the CDS aspect '${clean}'`) - this.#aspectify(name, aspect, file.aspects, { cleanName: clean }) + file.addClass(entityName, fq) + file.aspects.add(`// the following represents the CDS aspect '${entityName}'`) + this.#aspectify(fq, aspect, file.aspects, { cleanName: entityName }) } - #printEvent(name, event) { - LOG.debug(`Printing event ${name}`) - const { namespace: ns, name: clean } = this.resolver.untangle(name) - const file = this.fileRepository.getNamespaceFile(ns) - file.addEvent(clean, name) + #printEvent(fq, event) { + LOG.debug(`Printing event ${fq}`) + const { namespace, entityName } = this.entityRepository.getByFq(fq) + const file = this.fileRepository.getNamespaceFile(namespace) + file.addEvent(entityName, fq) const buffer = file.events.buffer buffer.add('// event') // only declare classes, as their properties are not optional, so we don't have to do awkward initialisation thereof. - buffer.addIndentedBlock(`export declare class ${clean} {`, function() { + buffer.addIndentedBlock(`export declare class ${entityName} {`, () => { const propOpt = this.options.propertiesOptional this.options.propertiesOptional = false for (const [ename, element] of Object.entries(event.elements ?? {})) { this.visitElement(ename, element, file, buffer) } this.options.propertiesOptional = propOpt - }.bind(this), '}') + }, '}') } - #printService(name, service) { - LOG.debug(`Printing service ${name}:\n${JSON.stringify(service, null, 2)}`) - const ns = this.resolver.resolveNamespace(name) - const file = this.fileRepository.getNamespaceFile(ns) + #printService(fq, service) { + LOG.debug(`Printing service ${fq}:\n${JSON.stringify(service, null, 2)}`) + const { namespace } = this.entityRepository.getByFq(fq) + const file = this.fileRepository.getNamespaceFile(namespace) // service.name is clean of namespace file.services.buffer.add(`export default { name: '${service.name}' }`) file.addService(service.name) @@ -418,33 +417,33 @@ class Visitor { /** * Visits a single entity from the CSN's definition field. * Will call #printEntity or #printAction based on the entity's kind. - * @param {string} name - name of the entity, fully qualified as is used in the definition field. + * @param {string} fq - name of the entity, fully qualified as is used in the definition field. * @param {CSN} entity - CSN data belonging to the entity to perform lookups in. */ - visitEntity(name, entity) { + visitEntity(fq, entity) { switch (entity.kind) { case 'entity': - this.#printEntity(name, entity) + this.#printEntity(fq, entity) break case 'action': case 'function': - this.#printOperation(name, entity, entity.kind) + this.#printOperation(fq, entity, entity.kind) break case 'aspect': - this.#printAspect(name, entity) + this.#printAspect(fq, entity) break case 'type': { // types like inline definitions can be used very similarly to entities. // They can be extended, contain inline enums, etc., so we treat them as entities. const handler = entity.elements ? this.#printEntity : this.#printType - handler.call(this, name, entity) + handler.call(this, fq, entity) break } case 'event': - this.#printEvent(name, entity) + this.#printEvent(fq, entity) break case 'service': - this.#printService(name, entity) + this.#printService(fq, entity) break default: LOG.debug(`Unhandled entity kind '${entity.kind}'.`) @@ -457,7 +456,7 @@ class Visitor { * refer to types via their alias that hides the aspectification. * If we attempt to directly refer to this alias while it has not been fully created, * that will result in a TS error. - * @param {string} entityName + * @param {string} fq - fully qualified name of the entity * @returns {boolean} true, if `entityName` refers to the surrounding class * @example * ```ts @@ -467,14 +466,14 @@ class Visitor { * } * ``` */ - isSelfReference(entityName) { - return entityName === this.contexts.at(-1)?.entity + isSelfReference(fq) { + return fq === this.contexts.at(-1)?.entity } /** * Visits a single element in an entity. * @param {string} name - name of the element - * @param {import('./components/resolver').CSN} element - CSN data belonging to the the element. + * @param {import('./resolution/resolver').CSN} element - CSN data belonging to the the element. * @param {SourceFile} file - the namespace file the surrounding entity is being printed into. * @param {Buffer} buffer - buffer to add the definition to. If no buffer is passed, the passed file's class buffer is used instead. * @returns @see InlineDeclarationResolver.visitElement diff --git a/test/ast.js b/test/ast.js index 08502ed3..1c5490cd 100644 --- a/test/ast.js +++ b/test/ast.js @@ -276,13 +276,13 @@ function visitFunctionDeclaration(node) { return { name, body, nodeType: kinds.FunctionDeclaration } } -/** @param {ts.Node} node */ +/** @param {ts.Node} node - the node that was unsuccessfully handled */ function errorHandler(node) { // eslint-disable-next-line no-console console.error(`unhandled node type ${node.kind}`) } -/** @param {ts.Node} node */ +/** @param {ts.Node} node - the node to visit */ function visit(node) { if (!node) return const [,handler] = visitors.find(([cond,]) => cond(node)) ?? [null, errorHandler] @@ -335,7 +335,7 @@ class ASTWrapper { } /** - * @param {string} name + * @param {string} name - the name of the module to find * @returns {ModuleDeclaration | undefined} */ getModuleDeclaration(name) { @@ -466,8 +466,8 @@ const check = { isNumber: node => checkKeyword(node, 'number'), isBoolean: node => checkKeyword(node, 'boolean'), /** - * @param {any} node - * @param {[(args: object[]) => boolean]} of + * @param {any} node - the node to check + * @param {[(args: object[]) => boolean]} of - the predicates to check against */ isArray: (node, of = undefined) => node?.full === 'Array' && (!of || of(node.args)), isAny: node => checkKeyword(node, 'any'), @@ -494,8 +494,8 @@ const check = { const checkInheritance = (node, ancestors) => { /** * - * @param {string} fq - * @param {any} node + * @param {string} fq - fully qualified name to check for + * @param {any} node - the node to check */ function checkPropertyAccessExpression (fq, node) { if (check.isPropertyAccessExpression(node)) { @@ -508,8 +508,8 @@ const checkInheritance = (node, ancestors) => { /** * - * @param {string} name - * @param {object[]} [ancestor] + * @param {string} name - the name of the ancestor to check + * @param {object[]} [ancestor] - the ancestors to check against */ function inherits (name, [ancestor] = []) { if (!ancestor) return false diff --git a/test/integration/output.test.js b/test/integration/output.test.js index ba3b9cef..ca27797b 100644 --- a/test/integration/output.test.js +++ b/test/integration/output.test.js @@ -2,7 +2,7 @@ const fs = require('fs') const cds2ts = require('../../lib/compile') -const { toHaveAll, toOnlyHave, toExactlyHave, TSParser } = require('../util') +const { toHaveAll, toOnlyHave, toExactlyHave } = require('../util') const { locations } = require('../util') const dir = locations.testOutput('compilation') @@ -18,58 +18,4 @@ describe('Compilation', () => { //console.log('INFO', `Unable to unlink '${dir}' (${err}). This may not be an issue.`) } }) - - test.skip('Common', async () => { - // Note (1): certain entities are inflected as singular in the corresponding cds files. - // These collision are currently resolved by adding a dummy suffix. - await cds2ts - .compileFromFile(locations.integration.cloudCapSamples('common/index.cds'), { - rootDirectory: dir, - }) - // eslint-disable-next-line no-console - .catch((err) => console.error(err)) - const common = new TSParser().parse(dir + '/sap/common/index.ts') - expect(common).toStrictEqual({ - imports: [ - { - imports: '*', - alias: '__', - from: './../../_', - }, - { - imports: '*', - alias: '_sap_common_countries', - from: './countries', - }, - ], - namespaces: { - top: { - declarations: {}, - classes: { - CountryAspect: { - code: ['string'], - regions: ['__.Composition.of.many<_sap_common_countries.Regions>'], - }, - Countries: {}, - CurrencyAspect: { - code: ['string'], - symbol: ['string'], - numcode: ['number'], - exponent: ['number'], - minor: ['string'], - }, - Currencies: {}, - LanguageAspect: { - code: ['Locale'], - }, - Languages: {}, - CodeListAspect: { - descr: ['string'], - name: ['string'], - }, - }, - }, - }, - }) - }) }) diff --git a/test/tscheck.js b/test/tscheck.js index 02cb811b..ae18a0ed 100644 --- a/test/tscheck.js +++ b/test/tscheck.js @@ -2,7 +2,7 @@ const ts = require('typescript') /** * Checks a parsed TS program for error diagnostics. - * @param {any} program + * @param {any} program - the parsed TS program */ function checkProgram (program) { const emitResult = program.emit() @@ -25,8 +25,8 @@ function checkProgram (program) { /** * Parses a list of .ts files, and checks them for errors. - * @param {string[]} apiFiles - * @param {object} opts + * @param {string[]} apiFiles - the list of .ts files to check + * @param {object} opts - the options to pass to the TS compiler */ async function checkTranspilation (apiFiles, opts = {}) { const options = {...{ noEmit: true }, ...opts} diff --git a/test/util.js b/test/util.js index 4a71201b..75ff3b75 100644 --- a/test/util.js +++ b/test/util.js @@ -1,34 +1,10 @@ const fs = require('fs') -// const { unlink } = require('fs').promises const path = require('path') -const { LOG } = require('../lib/logging') -const { fail } = require('assert') const os = require('os') const typer = require('../lib/compile') const { ASTWrapper } = require('./ast') const { checkTranspilation } = require('./tscheck') -/** - * @typedef {{[key: string]: string[]}} ClassBody - */ - -/** - * @typedef {{ - * imports: string; - * from: string; - * alias: string; - * }} Import - */ - -/** - * @typedef {{ - * classes: {[key: string]: ClassBody}, - * declarations: {[key: string]: string}, - * imports: Import[] - * }} TSParseResult - */ - - /** * Hackish. When having code as string, we can either: * (1) write to file and require() that file again (meh). @@ -36,7 +12,7 @@ const { checkTranspilation } = require('./tscheck') * it will load all definitions into the exports variable, potentially * shadowing any previous content over multiple runs. Instead, we define * a local variable to which the results of eval() will be bound. - * @param {any} code + * @param {any} code - JS code to evaluate */ const loadModule = code => { const exports = {} @@ -103,182 +79,6 @@ const toHavePropertyOfType = (clazz, property, types) => { return { message: () => '', pass: true } } -const getTSSignatures = code => - [...code.matchAll(/(\w+)\((.*)\)(?::\s?.*)?;/g)].map(f => ({ - name: f[1], - args: f[2].split(',').filter(a => !!a) ?? [], - })) - -const getJSFunctions = code => - [] // for better readbility after linting... - .concat( - [...code.matchAll(/^\s*(\w+)\((.*)\)\s?\{/gm)], // methods - [...code.matchAll(/^\s*const (\w+)\s?=\s?(?:async )?\((.*)\)\s=>/gm)] // arrow functions - ) - .map(f => ({ - name: f[1], - args: f[2].split(',').filter(a => !!a) ?? [], - })) - -const validateDTSTypes = (base, ignores = {}) => { - ignores = Object.assign({ js: [], ts: [] }, ignores) - const jsPath = path.normalize(`${base}.js`) - const dtsPath = path.normalize(`${base}.d.ts`) - - if (!fs.existsSync(jsPath)) { - fail(`implementation file ${jsPath} missing.`) - } else if (!fs.existsSync(dtsPath)) { - fail(`declaration file ${dtsPath} missing.`) - } else { - const jsModule = fs.readFileSync(jsPath, { encoding: 'utf8', flag: 'r' }) - const dtsModule = fs.readFileSync(dtsPath, { encoding: 'utf8', flag: 'r' }) - const jsFunctions = getJSFunctions(jsModule) - const tsSignatures = getTSSignatures(dtsModule) - - // look for functions that neither - // (a) have a matching signature nor - // (b) are ignored via the ignore list - // (and vice versa for signatures) - const missingSignatures = jsFunctions.filter( - jsf => - !ignores.js.includes(jsf.name) && - !tsSignatures.find(tsf => tsf.name === jsf.name && tsf.args.length === jsf.args.length) - ) - const missingImplementations = tsSignatures.filter( - tsf => - !ignores.ts.includes(tsf.name) && - !jsFunctions.find(jsf => tsf.name === jsf.name && tsf.args.length === jsf.args.length) - ) - - const missing = [] - for (const ms of missingSignatures) { - missing.push( - `missing signature for function or method '${ms.name}' with ${ms.args.length} parameters in ${dtsPath}` - ) - } - - for (const ms of missingImplementations) { - missing.push( - `missing implementation for function or method '${ms.name}' with ${ms.args.length} parameters in ${jsPath}` - ) - } - - if (missing.length > 0) { - // eslint-disable-next-line no-console - console.log(jsPath, jsFunctions) - // eslint-disable-next-line no-console - console.log(dtsPath, tsSignatures) - fail(missing.join('\n')) - } - } -} - -/** - * Really hacky way of consuming TS source code, - * as using the native TS compiler turned out to - * be a major pain. As we expect a very manageable - * subset of TS, we can work with RegEx here. - */ -class TSParser { - - /** - * @param {string} line - */ - isComment(line) { - const trimmed = line.trim() - return trimmed.startsWith('*') || trimmed.startsWith('/*') || trimmed.startsWith('//') - } - - /** - * @param {string[]} lines - * @returns {ClassBody} - */ - #parseClassBody(lines) { - const props = {} - let line = lines.shift() - while (line && !line.match(/}/)) { - const [prop, type] = line.split(':').map(part => part.trim()) - if (type) { - // type can be undefined, e.g. for "static readonly fq = 'foo';" - props[prop.replace('?', '').trim()] = type // remove optional annotation - .replace(';', '') - .split(/[&|]/) - .map(p => p.trim()) - .filter(p => !!p) - } - line = lines.shift() - } - return props - } - - /** - * @param {string} file - * @returns TSParseResult - */ - parse(file) { - const newNS = () => ({ - classes: {}, - declarations: {}, - }) - - let openScopes = 0 - const imports = [] - const namespaces = { top: newNS() } - let currentNamespace = namespaces.top - - const lines = fs - .readFileSync(file, 'utf-8') - .split('\n') - .filter(l => !this.isComment(l)) - .filter(l => !!l.trim()) - - let match - while (lines.length > 0) { - let line = lines.shift() - - // handle scopes - openScopes += line.match(/\{/g)?.length ?? 0 - openScopes -= line.match(/\}/g)?.length ?? 0 - if (openScopes < 0) { - LOG.error('Detected dangling closing brace.') - } else if (openScopes === 0) { - currentNamespace = namespaces.top - } - - // look at line - if ((match = line.match(/(?:export )?class (\w+)( extends [.\w<>]+)?\s+\{/)) != null) { - currentNamespace.classes[match[1]] = this.#parseClassBody(lines) - // quirk: as parseClassBody will consume all lines up until and - // including the next "}", we have to manually decrease the number - // of open scopes here. - openScopes-- - } else if ((match = line.match(/^\s*import (.*) as (.*) from (.*);/)) != null) { - imports.push({ - imports: match[1], - alias: match[2], - from: match[3].replace(/['"]+/g, ''), - }) - } else if ((match = line.match(/^\s*declare const (.*): (.*);/)) != null) { - currentNamespace.declarations[match[1]] = match[2] - } else if ((match = line.match(/^\s*(?:export )?namespace (.*) \{/)) != null) { - currentNamespace = newNS() - namespaces[match[1]] = currentNamespace - // eslint-disable-next-line no-useless-assignment - } else if ((match = line.match(/^\}/)) != null) { - // Just a closing brace that is already handled above. - // Catch in own case anyway to avoid logging in else case. - // eslint-disable-next-line no-useless-assignment - } else if ((match = line.match(/^\s+/)) != null) { - // Empty line. - // Catch in own case anyway to avoid logging in else case. - } else { - LOG.warn(`unexpected line: ${line}`) - } - } - return { imports, namespaces } - } -} - /** * A bit hacky way to replace references to aliases with absolute paths. * More specifically, the cloud cap sample projects specify aliases to each other @@ -348,7 +148,7 @@ const cds2ts = async (cdsFile, options = {}) => typer.compileFromFile( /** * @param {string} model - the path to the model file to be processed * @param {string} outputDirectory - the path to the output directory - * @param {PrepareUnitTestParameters} parameters + * @param {PrepareUnitTestParameters} parameters - additional parameters */ async function prepareUnitTest(model, outputDirectory, parameters = {}) { const defaults = { @@ -376,9 +176,7 @@ module.exports = { toHaveAll, toOnlyHave, toExactlyHave, - TSParser, resolveAliases, - validateDTSTypes, toHavePropertyOfType, locations, cds2ts,