import * as nodeJsPath from 'path' ; import { JSONPath } from 'jsonpath-plus' ; import { JsonSchema , JsonFile , PackageJsonLookup , Import , FileSystem , ITerminal } from '@rushstack/node-core-library' ; import { RigConfig } from '@rushstack/rig-package' ; interface IConfigurationJson { extends ? : string ; } beta export enum InheritanceType { /**
     * Append additional elements after elements from the parent file's property
     */ append = 'append' , /**
     * Discard elements from the parent file's property
     */ replace = 'replace' , /**
     * Custom inheritance functionality
     */ custom = 'custom' } beta export enum PathResolutionMethod { /**
     * Resolve a path relative to the configuration file
     */ resolvePathRelativeToConfigurationFile , /**
     * Resolve a path relative to the root of the project containing the configuration file
     */ resolvePathRelativeToProjectRoot , /**
     * Treat the property as a NodeJS-style require/import reference and resolve using standard
     * NodeJS filesystem resolution
     */ NodeResolve , /**
     * Resolve the property using a custom resolver.
     */ custom } const CONFIGURATION_FILE_FIELD_ANNOTATION = Symbol ( 'configuration-file-field-annotation' ) ; interface IAnnotatedField < TField > { [ CONFIGURATION_FILE_FIELD_ANNOTATION ] : IConfigurationFileFieldAnnotation < TField > ; } interface IConfigurationFileFieldAnnotation < TField > { configurationFilePath : string | undefined ; originalValues : { [ propertyName in keyof TField ] : unknown ; } ; } beta export interface IJsonPathMetadata { /**
     * If `IJsonPathMetadata.pathResolutionMethod` is set to `PathResolutionMethod.custom`,
     * this property be used to resolve the path.
     */ customResolver ? : ( configurationFilePath : string , propertyName : string , propertyValue : string ) => string ; /**
     * If this property describes a filesystem path, use this property to describe
     * how the path should be resolved.
     */ pathResolutionMethod ? : PathResolutionMethod ; } beta export type PropertyInheritanceCustomFunction < TObject > = ( currentObject : TObject , parentObject : TObject ) => TObject ; beta export interface IPropertyInheritance < TInheritanceType extends InheritanceType > { inheritanceType : TInheritanceType ; } beta export interface ICustomPropertyInheritance < TObject > extends IPropertyInheritance < InheritanceType . custom > { /**
     * Provides a custom inheritance function. This function takes two arguments: the first is the
     * child file's object, and the second is the parent file's object. The function should return
     * the resulting combined object.
     */ inheritanceFunction : PropertyInheritanceCustomFunction < TObject > ; } beta export type IPropertiesInheritance < TConfigurationFile > = { [ propertyName in keyof TConfigurationFile ] ? : IPropertyInheritance < InheritanceType . append | InheritanceType . replace > | ICustomPropertyInheritance < TConfigurationFile [ propertyName ] > ; } ; /**
 * Keys in this object are JSONPaths  https , and values are objects
 * that describe how node(s) selected by the JSONPath are processed after loading.
 *
 *  beta export interface IJsonPathsMetadata { [ jsonPath ] : IJsonPathMetadata ; } beta export interface IConfigurationFileOptions < TConfigurationFile > { /**
     * A project root-relative path to the configuration file that should be loaded.
     */ projectRelativeFilePath : string ; /**
     * The path to the schema for the configuration file.
     */ jsonSchemaPath : string ; /**
     * Use this property to specify how JSON nodes are postprocessed.
     */ jsonPathMetadata ? : IJsonPathsMetadata ; /**
     * Use this property to control how root-level properties are handled between parent and child
     * configuration files.
     */ propertyInheritance ? : IPropertiesInheritance < TConfigurationFile > ; } interface IJsonPathCallbackObject { path : string ; parent : object ; parentProperty : string ; value : string ; } beta export interface IOriginalValueOptions < TParentProperty > { parentObject : TParentProperty ; propertyName : keyof TParentProperty ; } beta export class ConfigurationFile < TConfigurationFile > { private readonly _schemaPath : string ; /** {@inheritDoc IConfigurationFileOptions.projectRelativeFilePath} */ public readonly projectRelativeFilePath : string ; private readonly _jsonPathMetadata : IJsonPathsMetadata ; private readonly _propertyInheritanceTypes : IPropertiesInheritance < TConfigurationFile > ; private __schema : JsonSchema | undefined ; private get _schema ( ) : JsonSchema { if ( ! this . __schema ) { this . __schema = JsonSchema . fromFile ( this . _schemaPath ) ; } return this . __schema ; } private readonly _configPromiseCache : Map < string , Promise < TConfigurationFile > > = new Map ( ) ; private readonly _packageJsonLookup : PackageJsonLookup = new PackageJsonLookup ( ) ; public constructor ( options ) { this . projectRelativeFilePath = options . projectRelativeFilePath ; this . _schemaPath = options . jsonSchemaPath ; this . _jsonPathMetadata = options . jsonPathMetadata || { } ; this . _propertyInheritanceTypes = options . propertyInheritance || { } ; } /**
     * Find and return a configuration file for the specified project, automatically resolving
     * `extends` properties and handling rigged configuration files. Will throw an error if a configuration
     * file cannot be found in the rig or project config folder.
     */ public async loadConfigurationFileForProjectAsync ( terminal , projectPath , rigConfig ? ) { const projectConfigurationFilePath = this . _getConfigurationFilePathForProject ( projectPath ) ; return await this . _loadConfigurationFileInnerWithCacheAsync ( terminal , projectConfigurationFilePath , new Set < string > ( ) , rigConfig ) ; } /**
     * This function is identical to  ConfigurationFile . loadConfigurationFileForProjectAsync , except
     * that it returns `undefined` instead of throwing an error if the configuration file cannot be found.
      public async tryLoadConfigurationFileForProjectAsync ( terminal , projectPath , rigConfig ? ) { try { return await this . loadConfigurationFileForProjectAsync ( terminal , projectPath , rigConfig ) ; } catch ( e ) { if ( FileSystem . isNotExistError ( e as Error ) ) { return undefined ; } throw e ; } } internal public static _formatPathForLogging : ( path : string ) => string = ( path ) => path ; /**
     * Get the path to the source file that the referenced property was originally
     * loaded from.
     */ public getObjectSourceFilePath < TObject extends object > ( obj ) { const annotation = ( obj as any ) [ CONFIGURATION_FILE_FIELD_ANNOTATION ] ; if ( annotation ) { return annotation . configurationFilePath ; } return undefined ; } /**
     * Get the value of the specified property on the specified object that was originally
     * loaded from a configuration file.
     */ public getPropertyOriginalValue < TParentProperty extends object , TValue > ( options ) { const annotation = ( options . parentObject as any ) [ CONFIGURATION_FILE_FIELD_ANNOTATION ] ; if ( annotation && annotation . originalValues . hasOwnProperty ( options . propertyName ) ) { return annotation . originalValues [ options . propertyName ] as TValue ; } else { return undefined ; } } private async _loadConfigurationFileInnerWithCacheAsync ( terminal , resolvedConfigurationFilePath , visitedConfigurationFilePaths , rigConfig ) { let cacheEntryPromise = this . _configPromiseCache . get ( resolvedConfigurationFilePath ) ; if ( ! cacheEntryPromise ) { cacheEntryPromise = this . _loadConfigurationFileInnerAsync ( terminal , resolvedConfigurationFilePath , visitedConfigurationFilePaths , rigConfig ) ; this . _configPromiseCache . set ( resolvedConfigurationFilePath , cacheEntryPromise ) ; } if ( visitedConfigurationFilePaths . has ( resolvedConfigurationFilePath ) ) { const resolvedConfigurationFilePathForLogging = ConfigurationFile . _formatPathForLogging ( resolvedConfigurationFilePath ) ; throw new Error ( 'A loop has been detected in the "extends" properties of configuration file at ' + `"${ resolvedConfigurationFilePathForLogging }".` ) ; } visitedConfigurationFilePaths . add ( resolvedConfigurationFilePath ) ; return await cacheEntryPromise ; } private async _loadConfigurationFileInnerAsync ( terminal , resolvedConfigurationFilePath , visitedConfigurationFilePaths , rigConfig ) { const resolvedConfigurationFilePathForLogging = ConfigurationFile . _formatPathForLogging ( resolvedConfigurationFilePath ) ; let fileText ; try { fileText = await FileSystem . readFileAsync ( resolvedConfigurationFilePath ) ; } catch ( e ) { if ( FileSystem . isNotExistError ( e as Error ) ) { if ( rigConfig ) { terminal . writeDebugLine ( `Config file "${ resolvedConfigurationFilePathForLogging }" does not exist. Attempting to load via rig.` ) ; const rigResult = await this . _tryLoadConfigurationFileInRigAsync ( terminal , rigConfig , visitedConfigurationFilePaths ) ; if ( rigResult ) { return rigResult ; } } else { terminal . writeDebugLine ( `Configuration file "${ resolvedConfigurationFilePathForLogging }" not found.` ) ; } ( e as Error ) . message = `File does not exist: ${ resolvedConfigurationFilePathForLogging }` ; } throw e ; } let configurationJson ; try { configurationJson = await JsonFile . parseString ( fileText ) ; } catch ( e ) { throw new Error ( `In config file "${ resolvedConfigurationFilePathForLogging }": ${ e }` ) ; } this . _schema . validateObject ( configurationJson , resolvedConfigurationFilePathForLogging ) ; this . _annotateProperties ( resolvedConfigurationFilePath , configurationJson ) ; for ( const [ jsonPath , metadata ] of Object . entries ( this . _jsonPathMetadata ) ) { JSONPath ( { path : jsonPath , json : configurationJson , callback : ( payload , payloadType , fullPayload ) => { const resolvedPath = this . _resolvePathProperty ( resolvedConfigurationFilePath , fullPayload . path , fullPayload . value , metadata ) ; ( fullPayload . parent as any ) [ fullPayload . parentProperty ] = resolvedPath ; } , otherTypeCallback : ( ) => { throw new Error ( '@other() tags are not supported' ) ; } } ) ; } let parentConfiguration = { } ; if ( configurationJson . extends ) { try { const resolvedParentConfigPath = Import . resolveModule ( { modulePath : configurationJson . extends , baseFolderPath : nodeJsPath . dirname ( resolvedConfigurationFilePath ) } ) ; parentConfiguration = await this . _loadConfigurationFileInnerWithCacheAsync ( terminal , resolvedParentConfigPath , visitedConfigurationFilePaths , undefined ) ; } catch ( e ) { if ( FileSystem . isNotExistError ( e as Error ) ) { throw new Error ( `In file "${ resolvedConfigurationFilePathForLogging }", file referenced in "extends" property ` + `("${ configurationJson . extends }") cannot be resolved.` ) ; } else { throw e ; } } } const propertyNames = new Set < string > ( [ ... Object . keys ( parentConfiguration ) , ... Object . keys ( configurationJson ) ] ) ; const resultAnnotation = { configurationFilePath : resolvedConfigurationFilePath , originalValues : { } as TConfigurationFile } ; const result = { [ CONFIGURATION_FILE_FIELD_ANNOTATION ] : resultAnnotation } as unknown as TConfigurationFile ; for ( const propertyName of propertyNames ) { if ( propertyName === '$schema' || propertyName === 'extends' ) { continue ; } const propertyValue = ( configurationJson as any ) [ propertyName ] ; const parentPropertyValue = ( parentConfiguration as any ) [ propertyName ] ; const bothAreArrays = Array . isArray ( propertyValue ) && Array . isArray ( parentPropertyValue ) ; const defaultInheritanceType = bothAreArrays ? { inheritanceType : InheritanceType . append } : { inheritanceType : InheritanceType . replace } ; const propertyInheritance = ( this . _propertyInheritanceTypes as any ) [ propertyName ] !== undefined ? ( this . _propertyInheritanceTypes as any ) [ propertyName ] : defaultInheritanceType ; let newValue ; const usePropertyValue = ( ) => { ( resultAnnotation . originalValues as any ) [ propertyName ] = this . getPropertyOriginalValue < any , any > ( { parentObject : configurationJson , propertyName : propertyName } ) ; newValue = propertyValue ; } ; const useParentPropertyValue = ( ) => { ( resultAnnotation . originalValues as any ) [ propertyName ] = this . getPropertyOriginalValue < any , any > ( { parentObject : parentConfiguration , propertyName : propertyName } ) ; newValue = parentPropertyValue ; } ; if ( propertyValue !== undefined && parentPropertyValue === undefined ) { usePropertyValue ( ) ; } else if ( parentPropertyValue !== undefined && propertyValue === undefined ) { useParentPropertyValue ( ) ; } else { switch ( propertyInheritance . inheritanceType ) { case InheritanceType . replace : { if ( propertyValue !== undefined ) { usePropertyValue ( ) ; } else { useParentPropertyValue ( ) ; } break ; } case InheritanceType . append : { if ( propertyValue !== undefined && parentPropertyValue === undefined ) { usePropertyValue ( ) ; } else if ( propertyValue === undefined && parentPropertyValue !== undefined ) { useParentPropertyValue ( ) ; } else { if ( ! Array . isArray ( propertyValue ) || ! Array . isArray ( parentPropertyValue ) ) { throw new Error ( `Issue in processing configuration file property "${ propertyName }". ` + `Property is not an array, but the inheritance type is set as "${ InheritanceType . append }"` ) ; } newValue = [ ... parentPropertyValue , ... propertyValue ] ; ( newValue as unknown as IAnnotatedField < unknown [ ] > ) [ CONFIGURATION_FILE_FIELD_ANNOTATION ] = { configurationFilePath : undefined , originalValues : { ... ( parentPropertyValue as any ) [ CONFIGURATION_FILE_FIELD_ANNOTATION ] . originalValues , ... ( propertyValue as any ) [ CONFIGURATION_FILE_FIELD_ANNOTATION ] . originalValues } } ; } break ; } case InheritanceType . custom : { const customInheritance = propertyInheritance as ICustomPropertyInheritance < unknown > ; if ( ! customInheritance . inheritanceFunction || typeof customInheritance . inheritanceFunction !== 'function' ) { throw new Error ( 'For property inheritance type "InheritanceType.custom", an inheritanceFunction must be provided.' ) ; } newValue = customInheritance . inheritanceFunction ( propertyValue , parentPropertyValue ) ; break ; } default : { throw new Error ( `Unknown inheritance type "${ propertyInheritance }"` ) ; } } } ( result as any ) [ propertyName ] = newValue ; } try { this . _schema . validateObject ( result , resolvedConfigurationFilePathForLogging ) ; } catch ( e ) { throw new Error ( `Resolved configuration object does not match schema: ${ e }` ) ; } return result ; } private async _tryLoadConfigurationFileInRigAsync ( terminal , rigConfig , visitedConfigurationFilePaths ) { if ( rigConfig . rigFound ) { const rigProfileFolder = await rigConfig . getResolvedProfileFolderAsync ( ) ; try { return await this . _loadConfigurationFileInnerWithCacheAsync ( terminal , nodeJsPath . resolve ( rigProfileFolder , this . projectRelativeFilePath ) , visitedConfigurationFilePaths , undefined ) ; } catch ( e ) { if ( ! FileSystem . isNotExistError ( e as Error ) ) { throw e ; } else { terminal . writeDebugLine ( `Configuration file "${ this . projectRelativeFilePath }" not found in rig ("${ ConfigurationFile . _formatPathForLogging ( rigProfileFolder ) }")` ) ; } } } else { terminal . writeDebugLine ( `No rig found for "${ ConfigurationFile . _formatPathForLogging ( rigConfig . projectFolderPath ) }"` ) ; } return undefined ; } private _annotateProperties < TObject > ( resolvedConfigurationFilePath , obj ) { if ( ! obj ) { return ; } if ( typeof obj === 'object' ) { this . _annotateProperty ( resolvedConfigurationFilePath , obj ) ; for ( const objValue of Object . values ( obj ) ) { this . _annotateProperties ( resolvedConfigurationFilePath , objValue ) ; } } } private _annotateProperty < TObject > ( resolvedConfigurationFilePath , obj ) { if ( ! obj ) { return ; } if ( typeof obj === 'object' ) { ( obj as unknown as IAnnotatedField < TObject > ) [ CONFIGURATION_FILE_FIELD_ANNOTATION ] = { configurationFilePath : resolvedConfigurationFilePath , originalValues : { ... obj } } ; } } private _resolvePathProperty ( configurationFilePath , propertyName , propertyValue , metadata ) { const resolutionMethod = metadata . pathResolutionMethod ; if ( resolutionMethod === undefined ) { return propertyValue ; } switch ( metadata . pathResolutionMethod ) { case PathResolutionMethod . resolvePathRelativeToConfigurationFile : { return nodeJsPath . resolve ( nodeJsPath . dirname ( configurationFilePath ) , propertyValue ) ; } case PathResolutionMethod . resolvePathRelativeToProjectRoot : { const packageRoot = this . _packageJsonLookup . tryGetPackageFolderFor ( configurationFilePath ) ; if ( ! packageRoot ) { throw new Error ( `Could not find a package root for path "${ ConfigurationFile . _formatPathForLogging ( configurationFilePath ) }"` ) ; } return nodeJsPath . resolve ( packageRoot , propertyValue ) ; } case PathResolutionMethod . NodeResolve : { return Import . resolveModule ( { modulePath : propertyValue , baseFolderPath : nodeJsPath . dirname ( configurationFilePath ) } ) ; } case PathResolutionMethod . custom : { if ( ! metadata . customResolver ) { throw new Error ( `The pathResolutionMethod was set to "${ PathResolutionMethod [ resolutionMethod ] }", but a custom ` + 'resolver was not provided.' ) ; } return metadata . customResolver ( configurationFilePath , propertyName , propertyValue ) ; } default : { throw new Error ( `Unsupported PathResolutionMethod: ${ PathResolutionMethod [ resolutionMethod ] } (${ resolutionMethod })` ) ; } } } private _getConfigurationFilePathForProject ( projectPath ) { return nodeJsPath . resolve ( projectPath , this . projectRelativeFilePath ) ; } }