import * as fs from 'fs' ; import * as path from 'path' ; import * as ts from 'typescript' ; import * as tss from 'typescript/lib/tsserverlibrary' ; import { SchematicContext , Tree , FileVisitor } from '@angular-devkit/schematics' ; import { WorkspaceSchema } from '@schematics/angular/utility/workspace-models' ; import { ClassChanges , BindingChanges , SelectorChange , SelectorChanges , ThemePropertyChanges , ThemeVariableChange , ImportsChanges , MemberChanges , ThemePropertyChange } from './schema' ; import { getLanguageService , getRenamePositions , getIdentifierPositions , replaceMatch , createProjectService , isMemberIgniteUI , NG_LANG_SERVICE_PACKAGE_NAME , NG_CORE_PACKAGE_NAME , findMatches } from './tsUtils' ; import { getProjectPaths , getWorkspace , getProjects , escapeRegExp , getPackageManager , canResolvePackage , tryInstallPackage , tryUninstallPackage , getPackageVersion } from './util' ; import { ServerHost } from './ServerHost' ; const TSCONFIG_PATH = 'tsconfig.json' ; export enum InputPropertyType { EVAL = 'eval' , STRING = 'string' } declare type TransformFunction = ( args : BoundPropertyObject ) => void ; export interface BoundPropertyObject { value : string ; bindingType : InputPropertyType ; } export class UpdateChanges { protected tsconfigPath = TSCONFIG_PATH ; protected _projectService : tss . server . ProjectService ; public get projectService ( ) : tss . server . ProjectService { if ( ! this . _projectService ) { this . _projectService = createProjectService ( this . serverHost ) ; const wsProject = this . workspace . projects [ this . workspace . defaultProject ] || this . workspace . projects [ 0 ] ; const mainRelPath = wsProject . architect ?. build ?. options [ 'main' ] ? path . join ( wsProject . root , wsProject . architect ?. build ?. options [ 'main' ] ) : `src/main.ts` ; this . patchTsConfig ( ) ; const mainAbsPath = path . resolve ( this . _projectService . currentDirectory , mainRelPath ) ; const scriptInfo = this . _projectService . getOrCreateScriptInfoForNormalizedPath ( tss . server . toNormalizedPath ( mainAbsPath ) , false ) ; this . _projectService . openClientFile ( scriptInfo . fileName ) ; const project = this . _projectService . findProject ( scriptInfo . containingProjects [ 0 ] . projectName ) ; project . getLanguageService ( ) . getSemanticDiagnostics ( mainAbsPath ) ; } return this . _projectService ; } protected serverHost : ServerHost ; protected workspace : WorkspaceSchema ; protected sourcePaths : string [ ] ; protected classChanges : ClassChanges ; protected outputChanges : BindingChanges ; protected inputChanges : BindingChanges ; protected selectorChanges : SelectorChanges ; protected themePropsChanges : ThemePropertyChanges ; protected importsChanges : ImportsChanges ; protected membersChanges : MemberChanges ; protected conditionFunctions : Map < string , ( ... args ) => any > = new Map < string , ( ... args ) => any > ( ) ; protected valueTransforms : Map < string , TransformFunction > = new Map < string , TransformFunction > ( ) ; private _templateFiles : string [ ] = [ ] ; private _initialTsConfig = '' ; public get templateFiles ( ) : string [ ] { if ( ! this . _templateFiles . length ) { this . sourceDirsVisitor ( ( fulPath , entry ) => { if ( fulPath . endsWith ( 'component.html' ) ) { this . _templateFiles . push ( entry . path ) ; } } ) ; } return this . _templateFiles ; } private _tsFiles : string [ ] = [ ] ; public get tsFiles ( ) : string [ ] { if ( ! this . _tsFiles . length ) { this . sourceDirsVisitor ( ( fulPath , entry ) => { if ( fulPath . endsWith ( '.ts' ) ) { this . _tsFiles . push ( entry . path ) ; } } ) ; } return this . _tsFiles ; } private _sassFiles : string [ ] = [ ] ; /** Sass (both .scss and .sass) files in the project being updated. */ public get sassFiles ( ) : string [ ] { if ( ! this . _sassFiles . length ) { const sourceDirs = getProjects ( this . workspace ) . map ( x => x . sourceRoot ) . filter ( x => x ) ; this . sourceDirsVisitor ( ( fulPath , entry ) => { if ( fulPath . endsWith ( '.scss' ) || fulPath . endsWith ( '.sass' ) ) { this . _sassFiles . push ( entry . path ) ; } } , sourceDirs ) ; } return this . _sassFiles ; } private _service : ts . LanguageService ; public get service ( ) : ts . LanguageService { if ( ! this . _service ) { this . _service = getLanguageService ( this . tsFiles , this . host ) ; } return this . _service ; } private _packageManager : 'npm' | 'yarn' ; private get packageManager ( ) : 'npm' | 'yarn' { if ( ! this . _packageManager ) { this . _packageManager = getPackageManager ( this . host ) ; } return this . _packageManager ; } param rootPath constructor ( private rootPath , private host , private context ? ) { this . workspace = getWorkspace ( host ) ; this . sourcePaths = getProjectPaths ( this . workspace ) ; this . selectorChanges = this . loadConfig ( 'selectors.json' ) ; this . classChanges = this . loadConfig ( 'classes.json' ) ; this . outputChanges = this . loadConfig ( 'outputs.json' ) ; this . inputChanges = this . loadConfig ( 'inputs.json' ) ; this . themePropsChanges = this . loadConfig ( 'theme-props.json' ) ; this . importsChanges = this . loadConfig ( 'imports.json' ) ; this . membersChanges = this . loadConfig ( 'members.json' ) ; this . serverHost = new ServerHost ( this . host ) ; } /** Apply configured changes to the Host Tree */ public applyChanges ( ) { const shouldInstallPkg = this . membersChanges && this . membersChanges . changes . length && ! canResolvePackage ( NG_LANG_SERVICE_PACKAGE_NAME ) ; if ( shouldInstallPkg ) { this . context . logger . info ( `Installing temporary migration dependencies via ${ this . packageManager }.` ) ; let targetVersion = getPackageVersion ( NG_CORE_PACKAGE_NAME ) || 'latest' ; if ( targetVersion . startsWith ( '11' ) ) { targetVersion = '11.0.0' ; } tryInstallPackage ( this . context , this . packageManager , `${ NG_LANG_SERVICE_PACKAGE_NAME }@${ targetVersion }` ) ; } this . updateTemplateFiles ( ) ; this . updateTsFiles ( ) ; this . updateMembers ( ) ; /** Sass files */ if ( this . themePropsChanges && this . themePropsChanges . changes . length ) { for ( const entryPath of this . sassFiles ) { this . updateThemeProps ( entryPath ) ; this . updateSassVariables ( entryPath ) ; } } if ( shouldInstallPkg ) { this . context . logger . info ( `Cleaning up temporary migration dependencies.` ) ; tryUninstallPackage ( this . context , this . packageManager , NG_LANG_SERVICE_PACKAGE_NAME ) ; } if ( this . _initialTsConfig !== '' ) { this . host . overwrite ( this . tsconfigPath , this . _initialTsConfig ) ; } } /** Add condition function. */ public addCondition ( conditionName , callback ) { this . conditionFunctions . set ( conditionName , callback ) ; } public addValueTransform ( functionName , callback ) { this . valueTransforms . set ( functionName , callback ) ; } /** Path must be absolute. If calling externally, use this.getAbsolutePath */ protected getDefaultLanguageService ( entryPath ) { const project = this . getDefaultProjectForFile ( entryPath ) ; return project . getLanguageService ( ) ; } protected updateSelectors ( entryPath ) { let fileContent = this . host . read ( entryPath ) . toString ( ) ; let overwrite = false ; for ( const change of this . selectorChanges . changes ) { let searchPttrn = change . type === 'component' ? '<' : '' ; searchPttrn += change . selector ; if ( fileContent . indexOf ( searchPttrn ) !== - 1 ) { fileContent = this . applySelectorChange ( fileContent , change ) ; overwrite = true ; } } if ( overwrite ) { this . host . overwrite ( entryPath , fileContent ) ; } } protected applySelectorChange ( fileContent , change ) { let regSource ; let replace ; switch ( change . type ) { case 'component' : if ( change . remove ) { regSource = String . raw `\<${ change . selector }[\s\S]*?\<\/${ change . selector }\>` ; replace = '' ; } else { regSource = String . raw `\<(\/?)${ change . selector }(?=[\s\>])` ; replace = `<$1${ change . replaceWith }` ; } break ; case 'directive' : if ( change . remove ) { regSource = String . raw `\s*?\[?${ change . selector }\]?(=(["']).*?${ '\\2' }(?=\s|\>))?` ; replace = '' ; } else { regSource = change . selector ; replace = change . replaceWith ; } break ; default : break ; } fileContent = fileContent . replace ( new RegExp ( regSource , 'g' ) , replace ) ; return fileContent ; } protected updateClasses ( entryPath ) { let fileContent = this . host . read ( entryPath ) . toString ( ) ; const alreadyReplaced = new Set < string > ( ) ; for ( const change of this . classChanges . changes ) { if ( fileContent . indexOf ( change . name ) !== - 1 ) { const positions = getRenamePositions ( entryPath , change . name , this . service ) ; for ( let i = positions . length ; i -- ; ) { const pos = positions [ i ] ; if ( i === 0 && alreadyReplaced . has ( change . replaceWith ) ) { const trailingCommaWhiteSpace = new RegExp ( /,([\s]*)(?=(\s}))/ ) ; let afterReplace = fileContent . slice ( pos . end ) ; const beforeReplace = fileContent . slice ( 0 , pos . start ) ; const leadingComma = afterReplace [ 0 ] === ',' ? 1 : 0 ; afterReplace = ! leadingComma ? afterReplace : fileContent . slice ( pos . end + leadingComma ) ; const doubleSpaceReplace = beforeReplace [ beforeReplace . length - 1 ] . match ( /\s/ ) !== null && afterReplace [ 0 ] . match ( /\s/ ) !== null ? 1 : 0 ; fileContent = ( fileContent . slice ( 0 , pos . start - doubleSpaceReplace ) + '' + afterReplace ) . replace ( trailingCommaWhiteSpace , '' ) ; } else { fileContent = fileContent . slice ( 0 , pos . start ) + change . replaceWith + fileContent . slice ( pos . end ) ; } } if ( positions . length ) { alreadyReplaced . add ( change . replaceWith ) ; this . host . overwrite ( entryPath , fileContent ) ; } } } } protected updateBindings ( entryPath , bindChanges , type = BindingType . Output ) { let fileContent = this . host . read ( entryPath ) . toString ( ) ; let overwrite = false ; for ( const change of bindChanges . changes ) { if ( fileContent . indexOf ( change . owner . selector ) === - 1 || fileContent . indexOf ( change . name ) === - 1 ) { continue ; } let base ; let replace ; let searchPattern ; if ( type === BindingType . Output ) { base = String . raw `\(${ change . name }\)=(["'])(.*?)\1` ; replace = `(${ change . replaceWith })=$1$2$1` ; } else { base = String . raw `(\s\[?)${ change . name }(\s*\]?=)(["'])(.*?)\3` ; replace = String . raw `$1${ change . replaceWith }$2$3$4$3` ; } let reg = new RegExp ( base , 'g' ) ; if ( change . remove || change . moveBetweenElementTags ) { reg = new RegExp ( String . raw `\s*${ base }(?=\s|\>)` , 'g' ) ; replace = '' ; } switch ( change . owner . type ) { case 'component' : searchPattern = String . raw `\<${ change . owner . selector }[^\>]*\>` ; break ; case 'directive' : searchPattern = String . raw `\<[^\>]*[\s\[]${ change . owner . selector }[^\>]*\>` ; break ; } const matches = fileContent . match ( new RegExp ( searchPattern , 'g' ) ) ; if ( ! matches ) { continue ; } for ( const match of matches ) { let replaceStatement = replace ; if ( ! this . areConditionsFulfilled ( match , change . conditions , entryPath ) ) { continue ; } if ( change . moveBetweenElementTags ) { const moveMatch = match . match ( reg ) ; fileContent = this . copyPropertyValueBetweenElementTags ( fileContent , match , moveMatch ) ; } if ( change . valueTransform ) { const regExpMatch = match . match ( new RegExp ( base ) ) ; const bindingType = regExpMatch && regExpMatch [ 1 ] . endsWith ( '[' ) ? InputPropertyType . EVAL : InputPropertyType . STRING ; if ( regExpMatch ) { const value = regExpMatch [ 4 ] ; const transform = this . valueTransforms . get ( change . valueTransform ) ; const args = { value , bindingType } ; transform ( args ) ; if ( args . bindingType !== bindingType ) { replaceStatement = args . bindingType === InputPropertyType . EVAL ? replaceStatement . replace ( `$1` , `$1[` ) . replace ( `$2` , `]$2` ) : replaceStatement . replace ( `$1` , regExpMatch [ 1 ] . replace ( '[' , '' ) ) . replace ( '$2' , regExpMatch [ 2 ] . replace ( ']' , '' ) ) ; } replaceStatement = replaceStatement . replace ( '$4' , args . value ) ; } } fileContent = fileContent . replace ( match , match . replace ( reg , replaceStatement ) ) ; } overwrite = true ; } if ( overwrite ) { this . host . overwrite ( entryPath , fileContent ) ; } } protected updateThemeProps ( entryPath ) { let fileContent = this . host . read ( entryPath ) . toString ( ) ; let overwrite = false ; for ( const change of this . themePropsChanges . changes ) { const _change = change as ThemePropertyChange ; if ( fileContent . indexOf ( _change . owner ) !== - 1 ) { /** owner-func:( * ); */ const searchPattern = String . raw `${ _change . owner }\([\s\S]+?\);` ; const matches = fileContent . match ( new RegExp ( searchPattern , 'g' ) ) ; if ( ! matches ) { continue ; } for ( const match of matches ) { if ( match . indexOf ( _change . name ) !== - 1 ) { const name = _change . name . replace ( '$' , '\\$' ) ; const replaceWith = _change . replaceWith ?. replace ( '$' , '\\$' ) ; const reg = new RegExp ( String . raw `^\s*${ name }:` ) ; const existing = new RegExp ( String . raw `${ replaceWith }:` ) ; const opening = `${ _change . owner }(` ; const closing = /\s*\);$/ . exec ( match ) . pop ( ) ; const body = match . substr ( opening . length , match . length - opening . length - closing . length ) ; let params = this . splitFunctionProps ( body ) ; params = params . reduce ( ( arr , param ) => { if ( reg . test ( param ) ) { const duplicate = ! ! replaceWith && arr . some ( p => existing . test ( p ) ) ; if ( ! _change . remove && ! duplicate ) { arr . push ( param . replace ( _change . name , _change . replaceWith ) ) ; } } else { arr . push ( param ) ; } return arr ; } , [ ] ) ; fileContent = fileContent . replace ( match , opening + params . join ( ',' ) + closing ) ; overwrite = true ; } } } } if ( overwrite ) { this . host . overwrite ( entryPath , fileContent ) ; } } protected isNamedArgument ( fileContent , i , occurrences , change ) { const openingBrackets = [ ] ; const closingBrackets = [ ] ; if ( fileContent [ ( occurrences [ i ] + change . name . length ) ] !== ':' || ( fileContent [ ( occurrences [ i ] + change . name . length ) ] === ' ' && fileContent [ ( occurrences [ i ] + change . name . length ) + 1 ] === ':' ) ) { return false ; } for ( let j = occurrences [ i ] ; j >= 0 ; j -- ) { if ( fileContent [ j ] === ')' ) { closingBrackets . push ( fileContent [ j ] ) ; } else if ( fileContent [ j ] === '(' ) { openingBrackets . push ( fileContent [ j ] ) ; } } return openingBrackets . length !== closingBrackets . length ; } protected updateSassVariables ( entryPath ) { let fileContent = this . host . read ( entryPath ) . toString ( ) ; let overwrite = false ; const allowedStartCharacters = new RegExp ( '(\:|\,)\s?' , 'g' ) ; const allowedEndCharacters = new RegExp ( '[;),: \r\n]' , 'g' ) ; for ( const change of this . themePropsChanges . changes ) { if ( ! ( 'owner' in change ) ) { const occurrences = findMatches ( fileContent , change . name ) ; for ( let i = occurrences . length - 1 ; i >= 0 ; i -- ) { const allowedStartEnd = fileContent [ occurrences [ i ] - 1 ] . match ( allowedStartCharacters ) || fileContent [ ( occurrences [ i ] + change . name . length ) ] . match ( allowedEndCharacters ) ; if ( allowedStartEnd && ! this . isNamedArgument ( fileContent , i , occurrences , change as ThemeVariableChange ) ) { fileContent = replaceMatch ( fileContent , change . name , change . replaceWith , occurrences [ i ] ) ; overwrite = true ; } } } } if ( overwrite ) { this . host . overwrite ( entryPath , fileContent ) ; } } protected updateImports ( entryPath ) { let fileContent = this . host . read ( entryPath ) . toString ( ) ; let overwrite = false ; for ( const change of this . importsChanges . changes ) { if ( fileContent . indexOf ( change . name ) === - 1 ) { continue ; } const replace = escapeRegExp ( change . replaceWith ) ; const base = escapeRegExp ( change . name ) ; const reg = new RegExp ( base , 'g' ) ; fileContent = fileContent . replace ( reg , replace ) ; overwrite = true ; } if ( overwrite ) { this . host . overwrite ( entryPath , fileContent ) ; } } protected updateClassMembers ( entryPath , memberChanges ) { let content = this . host . read ( entryPath ) . toString ( ) ; const absPath = tss . server . toNormalizedPath ( path . join ( process . cwd ( ) , entryPath ) ) ; const changes = new Set < { change ; position ; } > ( ) ; let langServ ; for ( const change of memberChanges . changes ) { if ( ! content . includes ( change . member ) ) { continue ; } langServ = langServ || this . getDefaultLanguageService ( absPath ) ; let matches ; if ( entryPath . endsWith ( '.ts' ) ) { const source = langServ . getProgram ( ) . getSourceFile ( absPath ) ; matches = getIdentifierPositions ( source , change . member ) . map ( x => x . start ) ; } else { matches = findMatches ( content , `.${ change . member }` ) . map ( pos => pos + 1 ) ; } for ( const matchPosition of matches ) { if ( isMemberIgniteUI ( change , langServ , absPath , matchPosition ) ) { changes . add ( { change , position : matchPosition } ) ; } } } const changesArr = Array . from ( changes ) . sort ( ( c , c1 ) => c . position - c1 . position ) . reverse ( ) ; for ( const fileChange of changesArr ) { content = replaceMatch ( content , fileChange . change . member , fileChange . change . replaceWith , fileChange . position ) ; } if ( changes . size ) { this . host . overwrite ( entryPath , content ) ; } } private patchTsConfig ( ) { this . ensureTsConfigPath ( ) ; if ( this . serverHost . fileExists ( this . tsconfigPath ) ) { let originalContent = '' ; try { originalContent = this . serverHost . readFile ( this . tsconfigPath ) ; } catch { this . context ?. logger . warn ( `Could not read ${ this . tsconfigPath }. Some Angular Ivy features might be unavailable during migrations.` ) ; return ; } let content ; const result = ts . parseConfigFileTextToJson ( this . tsconfigPath , originalContent ) ; if ( ! result . error ) { content = result . config ; } else { this . context ?. logger . warn ( `Could not parse ${ this . tsconfigPath }. Angular Ivy language service might be unavailable during migrations.` ) ; this . context ?. logger . warn ( `Error:\n${ result . error }` ) ; return ; } if ( ! content . angularCompilerOptions ) { content . angularCompilerOptions = { } ; } if ( ! content . angularCompilerOptions . strictTemplates ) { this . context ?. logger . info ( `Adding 'angularCompilerOptions.strictTemplates' to ${ this . tsconfigPath } for migration run.` ) ; content . angularCompilerOptions . strictTemplates = true ; this . host . overwrite ( this . tsconfigPath , JSON . stringify ( content ) ) ; this . _initialTsConfig = originalContent ; } } } ; private ensureTsConfigPath ( ) { if ( this . host . exists ( this . tsconfigPath ) ) { return ; } const wsProject = this . workspace . projects [ this . workspace . defaultProject ] || this . workspace . projects [ 0 ] ; const projectConfig = wsProject . architect ?. build ?. options [ 'tsConfig' ] ; if ( ! projectConfig || ! this . host . exists ( projectConfig ) ) { return ; } if ( path . posix . basename ( projectConfig ) === TSCONFIG_PATH ) { this . tsconfigPath = projectConfig ; return ; } const result = ts . parseConfigFileTextToJson ( projectConfig , this . serverHost . readFile ( projectConfig ) ) ; if ( ! result . error && result . config . extends ) { this . tsconfigPath = path . posix . join ( path . posix . dirname ( projectConfig ) , result . config . extends ) ; } } ; private loadConfig ( configJson ) { const filePath = path . join ( this . rootPath , 'changes' , configJson ) ; if ( fs . existsSync ( filePath ) ) { return JSON . parse ( fs . readFileSync ( filePath , 'utf-8' ) ) ; } } private areConditionsFulfilled ( match , conditions , entryPath ) { if ( conditions ) { for ( const condition of conditions ) { if ( this . conditionFunctions && this . conditionFunctions . has ( condition ) ) { const callback = this . conditionFunctions . get ( condition ) ; if ( callback && ! callback ( match , entryPath ) ) { return false ; } } } } return true ; } private copyPropertyValueBetweenElementTags ( fileContent , ownerMatch , propertyMatchArray ) { if ( ownerMatch && propertyMatchArray && propertyMatchArray . length > 0 ) { const propMatch = propertyMatchArray [ 0 ] . trim ( ) ; const propValueMatch = propMatch . match ( new RegExp ( `=(["'])(.+?)${ '\\1' }` ) ) ; if ( propValueMatch && propValueMatch . length > 0 ) { const propValue = propValueMatch [ propValueMatch . length - 1 ] ; if ( propMatch . startsWith ( '[' ) ) { return fileContent . replace ( ownerMatch , ownerMatch + `{{${ propValue }}}` ) ; } else { return fileContent . replace ( ownerMatch , ownerMatch + propValue ) ; } } } return fileContent ; } private sourceDirsVisitor ( visitor , dirs = this . sourcePaths ) { for ( const sourcePath of dirs ) { const srcDir = this . host . getDir ( sourcePath ) ; srcDir . visit ( visitor ) ; } } /**
     * Safe split by `','`, considering possible inner function calls. E.g.:
     * ```
     * prop: inner-func(),
     * prop2: inner2(inner-param: 3, inner-param: inner-func(..))
     * ```
     */ private splitFunctionProps ( body ) { const parts = [ ] ; let lastIndex = 0 ; let level = 0 ; for ( let i = 0 ; i < body . length ; i ++ ) { const char = body [ i ] ; switch ( char ) { case '(' : level ++ ; break ; case ')' : level -- ; break ; case ',' : if ( ! level ) { parts . push ( body . substring ( lastIndex , i ) ) ; lastIndex = i + 1 ; } break ; default : break ; } } parts . push ( body . substring ( lastIndex ) ) ; return parts ; } private updateTemplateFiles ( ) { if ( this . selectorChanges && this . selectorChanges . changes . length ) { for ( const entryPath of this . templateFiles ) { this . updateSelectors ( entryPath ) ; } } if ( this . outputChanges && this . outputChanges . changes . length ) { for ( const entryPath of this . templateFiles ) { this . updateBindings ( entryPath , this . outputChanges ) ; } } if ( this . inputChanges && this . inputChanges . changes . length ) { for ( const entryPath of this . templateFiles ) { this . updateBindings ( entryPath , this . inputChanges , BindingType . Input ) ; } } } private updateTsFiles ( ) { if ( this . classChanges && this . classChanges . changes . length ) { for ( const entryPath of this . tsFiles ) { this . updateClasses ( entryPath ) ; } } if ( this . importsChanges && this . importsChanges . changes . length ) { for ( const entryPath of this . tsFiles ) { this . updateImports ( entryPath ) ; } } } private updateMembers ( ) { if ( this . membersChanges && this . membersChanges . changes . length ) { const dirs = [ ... this . templateFiles , ... this . tsFiles ] ; for ( const entryPath of dirs ) { this . updateClassMembers ( entryPath , this . membersChanges ) ; } } } private getDefaultProjectForFile ( entryPath ) { const scriptInfo = this . projectService . getOrCreateScriptInfoForNormalizedPath ( tss . server . asNormalizedPath ( entryPath ) , false ) ; this . projectService . openClientFile ( scriptInfo . fileName ) ; const project = this . projectService . findProject ( scriptInfo . containingProjects [ 0 ] . projectName ) ; project . addMissingFileRoot ( scriptInfo . fileName ) ; return project ; } } export enum BindingType { Output , Input }