Add experimental AstServiceParser
This commit is contained in:
		| @@ -4,6 +4,7 @@ import { ParserInterface } from '../parsers/parser.interface'; | |||||||
| import { PipeParser } from '../parsers/pipe.parser'; | import { PipeParser } from '../parsers/pipe.parser'; | ||||||
| import { DirectiveParser } from '../parsers/directive.parser'; | import { DirectiveParser } from '../parsers/directive.parser'; | ||||||
| import { ServiceParser } from '../parsers/service.parser'; | import { ServiceParser } from '../parsers/service.parser'; | ||||||
|  | import { AstServiceParser } from '../parsers/ast-service.parser'; | ||||||
| import { CompilerInterface } from '../compilers/compiler.interface'; | import { CompilerInterface } from '../compilers/compiler.interface'; | ||||||
| import { JsonCompiler } from '../compilers/json.compiler'; | import { JsonCompiler } from '../compilers/json.compiler'; | ||||||
| import { NamespacedJsonCompiler } from '../compilers/namespaced-json.compiler'; | import { NamespacedJsonCompiler } from '../compilers/namespaced-json.compiler'; | ||||||
| @@ -19,18 +20,18 @@ const options = cli.parse({ | |||||||
| 	format: ['f', 'Output format', ['json', 'namespaced-json', 'pot'], 'json'], | 	format: ['f', 'Output format', ['json', 'namespaced-json', 'pot'], 'json'], | ||||||
| 	replace: ['r', 'Replace the contents of output file if it exists (Merges by default)', 'boolean', false], | 	replace: ['r', 'Replace the contents of output file if it exists (Merges by default)', 'boolean', false], | ||||||
| 	sort: ['s', 'Sort translations in the output file in alphabetical order', 'boolean', false], | 	sort: ['s', 'Sort translations in the output file in alphabetical order', 'boolean', false], | ||||||
| 	clean: ['c', 'Remove obsolete strings when merging', 'boolean', false] | 	clean: ['c', 'Remove obsolete strings when merging', 'boolean', false], | ||||||
|  | 	experimental: ['e', 'Use experimental AST Service Parser', 'boolean', false] | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const patterns: string[] = [ | const patterns: string[] = [ | ||||||
| 	'/**/*.html', | 	'/**/*.html', | ||||||
| 	'/**/*.ts', | 	'/**/*.ts' | ||||||
| 	'/**/*.js' |  | ||||||
| ]; | ]; | ||||||
| const parsers: ParserInterface[] = [ | const parsers: ParserInterface[] = [ | ||||||
| 	new PipeParser(), | 	new PipeParser(), | ||||||
| 	new DirectiveParser(), | 	new DirectiveParser(), | ||||||
| 	new ServiceParser() | 	options.experimental ? new AstServiceParser() : new ServiceParser() | ||||||
| ]; | ]; | ||||||
|  |  | ||||||
| let compiler: CompilerInterface; | let compiler: CompilerInterface; | ||||||
|   | |||||||
							
								
								
									
										155
									
								
								src/parsers/ast-service.parser.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										155
									
								
								src/parsers/ast-service.parser.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,155 @@ | |||||||
|  | import { ParserInterface } from './parser.interface'; | ||||||
|  | import { TranslationCollection } from '../utils/translation.collection'; | ||||||
|  | import { syntaxKindToName } from '../utils/ast-utils'; | ||||||
|  |  | ||||||
|  | import * as ts from 'typescript'; | ||||||
|  |  | ||||||
|  | export class AstServiceParser implements ParserInterface { | ||||||
|  |  | ||||||
|  | 	protected _sourceFile: ts.SourceFile; | ||||||
|  |  | ||||||
|  | 	protected _instancePropertyName: any; | ||||||
|  | 	protected _serviceClassName: string = 'TranslateService'; | ||||||
|  | 	protected _serviceMethodNames: string[] = ['get', 'instant']; | ||||||
|  |  | ||||||
|  | 	public extract(contents: string, path?: string): TranslationCollection { | ||||||
|  | 		let collection: TranslationCollection = new TranslationCollection(); | ||||||
|  |  | ||||||
|  | 		this._sourceFile = this._createSourceFile(path, contents); | ||||||
|  |  | ||||||
|  | 		this._instancePropertyName = this._getInstancePropertyName(); | ||||||
|  | 		if (!this._instancePropertyName) { | ||||||
|  | 			return collection; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		const callNodes = this._findCallNodes(); | ||||||
|  | 		callNodes.forEach(callNode => { | ||||||
|  | 			const keys: string[] = this._getCallArgStrings(callNode); | ||||||
|  | 			if (keys && keys.length) { | ||||||
|  | 				collection = collection.addKeys(keys); | ||||||
|  | 			} | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		return collection; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	protected _createSourceFile(path: string, contents: string): ts.SourceFile { | ||||||
|  | 		return ts.createSourceFile(path, contents, ts.ScriptTarget.ES6, /*setParentNodes */ false); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	/** | ||||||
|  | 	 * Detect what the TranslateService instance property | ||||||
|  | 	 * is called by inspecting constructor params | ||||||
|  | 	 */ | ||||||
|  | 	protected _getInstancePropertyName(): string { | ||||||
|  | 		const constructorNode = this._findConstructorNode(); | ||||||
|  | 		const result = constructorNode.parameters.find(parameter => { | ||||||
|  | 			// Skip if visibility modifier is not present (we want it set as an instance property) | ||||||
|  | 			if (!parameter.modifiers) { | ||||||
|  | 				return false; | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			// Make sure className is of the correct type | ||||||
|  | 			const className: string = ( (parameter.type as ts.TypeReferenceNode).typeName as ts.Identifier).text; | ||||||
|  | 			if (className !== this._serviceClassName) { | ||||||
|  | 				return false; | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			return true; | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		if (result) { | ||||||
|  | 			return (result.name as ts.Identifier).text; | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	/** | ||||||
|  | 	 * Find first constructor | ||||||
|  | 	 */ | ||||||
|  | 	protected _findConstructorNode(): ts.ConstructorDeclaration { | ||||||
|  | 		const constructors = this._findNodes(this._sourceFile, ts.SyntaxKind.Constructor, true) as ts.ConstructorDeclaration[]; | ||||||
|  | 		if (constructors.length) { | ||||||
|  | 			return constructors[0]; | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	/** | ||||||
|  | 	 * Find all calls to TranslateService methods | ||||||
|  | 	 */ | ||||||
|  | 	protected _findCallNodes(node?: ts.Node): ts.CallExpression[] { | ||||||
|  | 		if (!node) { | ||||||
|  | 			node = this._sourceFile; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		let callNodes = this._findNodes(node, ts.SyntaxKind.CallExpression) as ts.CallExpression[]; | ||||||
|  | 		callNodes = callNodes | ||||||
|  | 			// Only call expressions with arguments | ||||||
|  | 			.filter(callNode => callNode.arguments.length > 0) | ||||||
|  | 			// More filters | ||||||
|  | 			.filter(callNode => { | ||||||
|  | 				const propAccess = callNode.getChildAt(0).getChildAt(0) as ts.PropertyAccessExpression; | ||||||
|  | 				if (!propAccess || propAccess.kind !== ts.SyntaxKind.PropertyAccessExpression) { | ||||||
|  | 					return false; | ||||||
|  | 				} | ||||||
|  | 				if (!propAccess.getFirstToken() || propAccess.getFirstToken().kind !== ts.SyntaxKind.ThisKeyword) { | ||||||
|  | 					return false; | ||||||
|  | 				} | ||||||
|  | 				if (propAccess.name.text !== this._instancePropertyName) { | ||||||
|  | 					return false; | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				const methodAccess = callNode.getChildAt(0) as ts.PropertyAccessExpression; | ||||||
|  | 				if (!methodAccess || methodAccess.kind !== ts.SyntaxKind.PropertyAccessExpression) { | ||||||
|  | 					return false; | ||||||
|  | 				} | ||||||
|  | 				if (!methodAccess.name || this._serviceMethodNames.indexOf(methodAccess.name.text) === -1) { | ||||||
|  | 					return false; | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				return true; | ||||||
|  | 			}); | ||||||
|  |  | ||||||
|  | 		return callNodes; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	/** | ||||||
|  | 	 * Get strings from function call's first argument | ||||||
|  | 	 */ | ||||||
|  | 	protected _getCallArgStrings(callNode: ts.CallExpression): string[] { | ||||||
|  | 		if (!callNode.arguments.length) { | ||||||
|  | 			return; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		const firstArg = callNode.arguments[0]; | ||||||
|  | 		switch (firstArg.kind) { | ||||||
|  | 			case ts.SyntaxKind.StringLiteral: | ||||||
|  | 			case ts.SyntaxKind.FirstTemplateToken: | ||||||
|  | 				return [(firstArg as ts.StringLiteral).text]; | ||||||
|  | 			case ts.SyntaxKind.ArrayLiteralExpression: | ||||||
|  | 				return (firstArg as ts.ArrayLiteralExpression).elements | ||||||
|  | 					.map((element: ts.StringLiteral) => element.text); | ||||||
|  | 			case ts.SyntaxKind.Identifier: | ||||||
|  | 				console.log('WARNING: We cannot extract variable values passed to TranslateService (yet)'); | ||||||
|  | 				break; | ||||||
|  | 			default: | ||||||
|  | 				console.log(`SKIP: Unknown argument type: '${syntaxKindToName(firstArg.kind)}'`, firstArg); | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	/** | ||||||
|  | 	 * Find all child nodes of a kind | ||||||
|  | 	 */ | ||||||
|  | 	protected _findNodes(node: ts.Node, kind: ts.SyntaxKind, onlyOne: boolean = false): ts.Node[] { | ||||||
|  | 		if (node.kind === kind && onlyOne) { | ||||||
|  | 			return [node]; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		const childrenNodes: ts.Node[] = node.getChildren(this._sourceFile); | ||||||
|  | 		const initialValue: ts.Node[] = node.kind === kind ? [node] : []; | ||||||
|  |  | ||||||
|  | 		return childrenNodes.reduce((result: ts.Node[], childNode: ts.Node) => { | ||||||
|  | 			return result.concat(this._findNodes(childNode, kind)); | ||||||
|  | 		}, initialValue); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | } | ||||||
							
								
								
									
										19
									
								
								src/utils/ast-utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/utils/ast-utils.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | |||||||
|  | import * as ts from 'typescript'; | ||||||
|  |  | ||||||
|  | export function printAllChildren(sourceFile: ts.SourceFile, node: ts.Node, depth = 0) { | ||||||
|  | 	console.log( | ||||||
|  | 		new Array(depth + 1).join('----'), | ||||||
|  | 		`[${node.kind}]`, | ||||||
|  | 		syntaxKindToName(node.kind), | ||||||
|  | 		`[pos: ${node.pos}-${node.end}]`, | ||||||
|  | 		':\t\t\t', | ||||||
|  | 		node.getFullText(sourceFile).trim() | ||||||
|  | 	); | ||||||
|  |  | ||||||
|  | 	depth++; | ||||||
|  | 	node.getChildren(sourceFile).forEach(childNode => printAllChildren(sourceFile, childNode, depth)); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function syntaxKindToName(kind: ts.SyntaxKind) { | ||||||
|  | 	return ts.SyntaxKind[kind]; | ||||||
|  | } | ||||||
							
								
								
									
										154
									
								
								tests/parsers/ast-service.parser.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										154
									
								
								tests/parsers/ast-service.parser.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,154 @@ | |||||||
|  | import { expect } from 'chai'; | ||||||
|  |  | ||||||
|  | import { AstServiceParser } from '../../src/parsers/ast-service.parser'; | ||||||
|  |  | ||||||
|  | class TestAstServiceParser extends AstServiceParser { | ||||||
|  |  | ||||||
|  | 	/*public getInstancePropertyName(): string { | ||||||
|  | 		return this._getInstancePropertyName(); | ||||||
|  | 	}*/ | ||||||
|  |  | ||||||
|  | } | ||||||
|  |  | ||||||
|  | describe('AstServiceParser', () => { | ||||||
|  |  | ||||||
|  | 	const componentFilename: string = 'test.component.ts'; | ||||||
|  |  | ||||||
|  | 	let parser: TestAstServiceParser; | ||||||
|  |  | ||||||
|  | 	beforeEach(() => { | ||||||
|  | 		parser = new TestAstServiceParser(); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	/*it('should extract variable used for TranslateService', () => { | ||||||
|  | 		const contents = ` | ||||||
|  | 			@Component({ }) | ||||||
|  | 			export class AppComponent { | ||||||
|  | 				public constructor( | ||||||
|  | 					_serviceA: ServiceA, | ||||||
|  | 					public _serviceB: ServiceB, | ||||||
|  | 					protected _translateService: TranslateService | ||||||
|  | 			) { } | ||||||
|  | 		`; | ||||||
|  | 		const name = parser.getInstancePropertyName(); | ||||||
|  | 		expect(name).to.equal('_translateService'); | ||||||
|  | 	});*/ | ||||||
|  |  | ||||||
|  | 	it('should extract strings in TranslateService\'s get() method', () => { | ||||||
|  | 		const contents = ` | ||||||
|  | 			@Component({ }) | ||||||
|  | 			export class AppComponent { | ||||||
|  | 				public constructor(protected _translateService: TranslateService) { } | ||||||
|  | 				public test() { | ||||||
|  | 					this._translateService.get('Hello World'); | ||||||
|  | 				} | ||||||
|  | 		`; | ||||||
|  | 		const keys = parser.extract(contents, componentFilename).keys(); | ||||||
|  | 		expect(keys).to.deep.equal(['Hello World']); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	it('should extract strings in TranslateService\'s instant() method', () => { | ||||||
|  | 		const contents = ` | ||||||
|  | 			@Component({ }) | ||||||
|  | 			export class AppComponent { | ||||||
|  | 				public constructor(protected _translateService: TranslateService) { } | ||||||
|  | 				public test() { | ||||||
|  | 					this._translateService.instant('Hello World'); | ||||||
|  | 				} | ||||||
|  | 		`; | ||||||
|  | 		const keys = parser.extract(contents, componentFilename).keys(); | ||||||
|  | 		expect(keys).to.deep.equal(['Hello World']); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	it('should extract array of strings in TranslateService\'s get() method', () => { | ||||||
|  | 		const contents = ` | ||||||
|  | 			@Component({ }) | ||||||
|  | 			export class AppComponent { | ||||||
|  | 				public constructor(protected _translateService: TranslateService) { } | ||||||
|  | 				public test() { | ||||||
|  | 					this._translateService.get(['Hello', 'World']); | ||||||
|  | 				} | ||||||
|  | 		`; | ||||||
|  | 		const keys = parser.extract(contents, componentFilename).keys(); | ||||||
|  | 		expect(keys).to.deep.equal(['Hello', 'World']); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	it('should extract array of strings in TranslateService\'s instant() method', () => { | ||||||
|  | 		const contents = ` | ||||||
|  | 			@Component({ }) | ||||||
|  | 			export class AppComponent { | ||||||
|  | 				public constructor(protected _translateService: TranslateService) { } | ||||||
|  | 				public test() { | ||||||
|  | 					this._translateService.instant(['Hello', 'World']); | ||||||
|  | 				} | ||||||
|  | 		`; | ||||||
|  | 		const key = parser.extract(contents, componentFilename).keys(); | ||||||
|  | 		expect(key).to.deep.equal(['Hello', 'World']); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	it('should not extract strings in get()/instant() methods of other services', () => { | ||||||
|  | 		const contents = ` | ||||||
|  | 			@Component({ }) | ||||||
|  | 			export class AppComponent { | ||||||
|  | 				public constructor( | ||||||
|  | 					protected _translateService: TranslateService, | ||||||
|  | 					protected _otherService: OtherService | ||||||
|  | 				) { } | ||||||
|  | 				public test() { | ||||||
|  | 					this._otherService.get('Hello World'); | ||||||
|  | 					this._otherService.instant('Hi there'); | ||||||
|  | 				} | ||||||
|  | 		`; | ||||||
|  | 		const keys = parser.extract(contents, componentFilename).keys(); | ||||||
|  | 		expect(keys).to.deep.equal([]); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	it('should extract strings with liberal spacing', () => { | ||||||
|  | 		const contents = ` | ||||||
|  | 			@Component({ }) | ||||||
|  | 			export class AppComponent { | ||||||
|  | 				public constructor( | ||||||
|  | 					protected _translateService: TranslateService, | ||||||
|  | 					protected _otherService: OtherService | ||||||
|  | 				) { } | ||||||
|  | 				public test() { | ||||||
|  | 					this._translateService.instant('Hello'); | ||||||
|  | 					this._translateService.get ( 'World' ); | ||||||
|  | 					this._translateService.instant ( ['How'] ); | ||||||
|  | 					this._translateService.get([ 'Are' ]); | ||||||
|  | 					this._translateService.get([ 'You' , 'Today' ]); | ||||||
|  | 				} | ||||||
|  | 		`; | ||||||
|  | 		const keys = parser.extract(contents, componentFilename).keys(); | ||||||
|  | 		expect(keys).to.deep.equal(['Hello', 'World', 'How', 'Are', 'You', 'Today']); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	it('should not extract string when not accessing property', () => { | ||||||
|  | 		const contents = ` | ||||||
|  | 			@Component({ }) | ||||||
|  | 			export class AppComponent { | ||||||
|  | 				public constructor(protected trans: TranslateService) { } | ||||||
|  | 				public test() { | ||||||
|  | 					trans.get("You are expected at {{time}}", {time: moment.format('H:mm')}).subscribe(); | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 		`; | ||||||
|  | 		const keys = parser.extract(contents, componentFilename).keys(); | ||||||
|  | 		expect(keys).to.deep.equal([]); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	it('should extract string with params on same line', () => { | ||||||
|  | 		const contents = ` | ||||||
|  | 			@Component({ }) | ||||||
|  | 			export class AppComponent { | ||||||
|  | 				public constructor(protected _translateService: TranslateService) { } | ||||||
|  | 				public test() { | ||||||
|  | 					this._translateService.get('You are expected at {{time}}', {time: moment.format('H:mm')}); | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 		`; | ||||||
|  | 		const keys = parser.extract(contents, componentFilename).keys(); | ||||||
|  | 		expect(keys).to.deep.equal(['You are expected at {{time}}']); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | }); | ||||||
| @@ -137,4 +137,19 @@ describe('ServiceParser', () => { | |||||||
| 		expect(keys).to.deep.equal([]); | 		expect(keys).to.deep.equal([]); | ||||||
| 	}); | 	}); | ||||||
|  |  | ||||||
|  | 	// FAILS (Use AstServiceParser) | ||||||
|  | 	/*it('should extract string with params on same line', () => { | ||||||
|  | 		const contents = ` | ||||||
|  | 			@Component({ }) | ||||||
|  | 			export class AppComponent { | ||||||
|  | 				public constructor(protected _translateService: TranslateService) { } | ||||||
|  | 				public test() { | ||||||
|  | 					this._translateService.get('You are expected at {{time}}', {time: moment.format('H:mm')}); | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 		`; | ||||||
|  | 		const keys = parser.extract(contents, componentFilename).keys(); | ||||||
|  | 		expect(keys).to.deep.equal(['You are expected at {{time}}']); | ||||||
|  | 	});*/ | ||||||
|  |  | ||||||
| }); | }); | ||||||
|   | |||||||
| @@ -6,7 +6,7 @@ | |||||||
| 		"indent": [true, "tabs"], | 		"indent": [true, "tabs"], | ||||||
| 		"semicolon": [true, "always", "ignore-interfaces"], | 		"semicolon": [true, "always", "ignore-interfaces"], | ||||||
| 		"quotemark": [true, "single", "avoid-escape"], | 		"quotemark": [true, "single", "avoid-escape"], | ||||||
| 		"only-arrow-functions": true, | 		"only-arrow-functions": false, | ||||||
| 		"no-duplicate-variable": true, | 		"no-duplicate-variable": true, | ||||||
| 		"member-access": true, | 		"member-access": true, | ||||||
| 		"member-ordering": [ | 		"member-ordering": [ | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user