├── images ├── angular.png ├── bindings.png ├── component.gif ├── statusbar.png ├── find-unused.png ├── code-actions.png ├── gotodefinition.gif ├── member-diagnostics.png ├── model-intellisense.png ├── find-all-references-1.png ├── find-all-references-2.png ├── find-all-references-3.png └── find-all-references-4.png ├── .gitignore ├── test ├── test_files │ ├── templates │ │ ├── testTemplate.html │ │ └── componentDefinitionProvider.html │ ├── components │ │ ├── component_noCtrlAlias.ts │ │ ├── component_ctrlAlias.ts │ │ ├── component_simple.ts │ │ ├── reexported_components.ts │ │ ├── component_importClass.ts │ │ ├── component_importLiteral.ts │ │ ├── component_importReexportedClass.ts │ │ ├── component_importReexportedLiteral.ts │ │ ├── component_comments.ts │ │ ├── test.component.js │ │ ├── component_consts.ts │ │ ├── component_inline_template.ts │ │ ├── component_literal.ts │ │ ├── component_required_template.ts │ │ ├── component_class.ts │ │ ├── component_staticFields.ts │ │ ├── exported_components.ts │ │ └── component_constructor_init.ts │ ├── directives │ │ ├── directive.function.arrow.returnExpression.ts │ │ ├── directive.function.ts │ │ ├── directive.function.arrow.ts │ │ ├── directive.function.named.ts │ │ ├── directive.register.arrowFunc.ts │ │ ├── directive.function.injectedParams.ts │ │ ├── directive.function.named.arrow.ts │ │ ├── directive.register.blockArrowFunc.ts │ │ ├── directive.register.functionExpression.ts │ │ ├── directive.default_restrict.ts │ │ ├── directive.multiple.ts │ │ ├── directive.init.class_property.ts │ │ └── directive.init.class_ctor.ts │ ├── controllers │ │ ├── controller_simple.ts │ │ ├── controller_differentName.ts │ │ ├── controller_multiple_ignored.ts │ │ ├── controller_multiple.ts │ │ ├── controller_members.ts │ │ └── controller_chained.ts │ ├── angular │ │ ├── jspm1 │ │ │ ├── bower.json │ │ │ └── package.json │ │ ├── bower │ │ │ ├── bower.json │ │ │ └── package.json │ │ ├── jspm2 │ │ │ └── package.json │ │ ├── webpack │ │ │ └── package.json │ │ ├── angular_version_too_low │ │ │ └── package.json │ │ └── angular_version_too_high │ │ │ └── package.json │ └── routes │ │ ├── route.component_template.ts │ │ ├── route.inline_template.ts │ │ ├── route.external_template.ts │ │ ├── route.required_template.ts │ │ └── route.views_template.ts ├── utils │ ├── mockedConfig.ts │ ├── component │ │ ├── componentBinding.test.ts │ │ └── component.test.ts │ ├── angular.test.ts │ ├── typescriptParser.test.ts │ ├── route.test.ts │ ├── htmlTemplateInfoCache.test.ts │ ├── helpers.ts │ ├── directive │ │ └── directive.test.ts │ └── controller │ │ └── controller.test.ts └── providers │ ├── directiveDefinitionProvider.test.ts │ ├── componentCompletionProvider.test.ts │ ├── directiveReferencesProvider.test.ts │ ├── codeActionsProvider.test.ts │ ├── memberCompletionProvider.test.ts │ └── componentDefinitionProvider.test.ts ├── src ├── main.ts ├── symbols.ts ├── commands │ ├── commands.ts │ ├── didYouMean.ts │ ├── ignoreMemberDiagnostic.ts │ ├── findUnusedDirectives.ts │ ├── findUnusedComponents.ts │ └── switchComponentParts.ts ├── utils │ ├── configurationChangeListener.ts │ ├── htmlTemplate │ │ ├── types.d.ts │ │ ├── streams │ │ │ ├── splitToLines.ts │ │ │ └── memberAccessParser.ts │ │ ├── relativePath.ts │ │ ├── htmlTags.ts │ │ └── htmlTemplateInfoResult.ts │ ├── component │ │ ├── helpers.ts │ │ ├── componentBinding.ts │ │ ├── component.ts │ │ └── componentsCache.ts │ ├── angular.ts │ ├── sourceFile.ts │ ├── directive │ │ ├── directive.ts │ │ └── directiveCache.ts │ ├── route │ │ ├── route.ts │ │ ├── routesCache.ts │ │ └── routeParser.ts │ ├── controller │ │ ├── controller.ts │ │ ├── property.ts │ │ ├── member.ts │ │ ├── method.ts │ │ └── controllerParser.ts │ ├── sourceFilesScanner.ts │ ├── configParser.ts │ ├── fileWatcher.ts │ ├── controllerHelper.ts │ ├── logging.ts │ ├── templateParser.ts │ ├── vsc.ts │ ├── memberAccessDiagnostics.ts │ └── htmlDocumentHelper.ts ├── providers │ ├── directiveDefinitionProvider.ts │ ├── directiveReferencesProvider.ts │ ├── directiveCompletionProvider.ts │ ├── componentDefinitionProvider.ts │ ├── memberCompletionProvider.ts │ ├── componentCompletionProvider.ts │ ├── referencesProvider.ts │ ├── codeActionsProvider.ts │ ├── bindingProvider.ts │ ├── memberDefinitionProvider.ts │ └── memberReferencesProvider.ts └── configurationFile.ts ├── .bithoundrc ├── .vscodeignore ├── .vscode ├── extensions.json ├── settings.json ├── tasks.json └── launch.json ├── coverconfig.json ├── CONTRIBUTING.md ├── tsconfig.json ├── .travis.yml ├── LICENSE ├── tslint.json └── package.json /images/angular.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipatalas/ngComponentUtility/HEAD/images/angular.png -------------------------------------------------------------------------------- /images/bindings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipatalas/ngComponentUtility/HEAD/images/bindings.png -------------------------------------------------------------------------------- /images/component.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipatalas/ngComponentUtility/HEAD/images/component.gif -------------------------------------------------------------------------------- /images/statusbar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipatalas/ngComponentUtility/HEAD/images/statusbar.png -------------------------------------------------------------------------------- /images/find-unused.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipatalas/ngComponentUtility/HEAD/images/find-unused.png -------------------------------------------------------------------------------- /images/code-actions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipatalas/ngComponentUtility/HEAD/images/code-actions.png -------------------------------------------------------------------------------- /images/gotodefinition.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipatalas/ngComponentUtility/HEAD/images/gotodefinition.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | server 3 | node_modules 4 | /*.vsix 5 | /.vscode/symbols.json 6 | /.vscode-test/** 7 | /coverage/** 8 | -------------------------------------------------------------------------------- /images/member-diagnostics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipatalas/ngComponentUtility/HEAD/images/member-diagnostics.png -------------------------------------------------------------------------------- /images/model-intellisense.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipatalas/ngComponentUtility/HEAD/images/model-intellisense.png -------------------------------------------------------------------------------- /images/find-all-references-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipatalas/ngComponentUtility/HEAD/images/find-all-references-1.png -------------------------------------------------------------------------------- /images/find-all-references-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipatalas/ngComponentUtility/HEAD/images/find-all-references-2.png -------------------------------------------------------------------------------- /images/find-all-references-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipatalas/ngComponentUtility/HEAD/images/find-all-references-3.png -------------------------------------------------------------------------------- /images/find-all-references-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipatalas/ngComponentUtility/HEAD/images/find-all-references-4.png -------------------------------------------------------------------------------- /test/test_files/templates/testTemplate.html: -------------------------------------------------------------------------------- 1 | vm. <- testing member completion here 2 | <- this should only suggest view model name -------------------------------------------------------------------------------- /test/test_files/templates/componentDefinitionProvider.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /test/test_files/components/component_noCtrlAlias.ts: -------------------------------------------------------------------------------- 1 | angular.module('app').component('exampleComponent', { 2 | controller: 'TestController' 3 | }); -------------------------------------------------------------------------------- /test/test_files/components/component_ctrlAlias.ts: -------------------------------------------------------------------------------- 1 | angular.module('app').component('exampleComponent', { 2 | controllerAs: 'vm', 3 | controller: 'TestController' 4 | }); -------------------------------------------------------------------------------- /test/test_files/components/component_simple.ts: -------------------------------------------------------------------------------- 1 | angular.module('app').component('exampleComponent', { 2 | bindings: { 3 | config: '<', 4 | data: '<' 5 | } 6 | }); -------------------------------------------------------------------------------- /test/test_files/components/reexported_components.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line:variable-name 2 | export { ExampleComponentLiteral, ExampleComponentClass } from './exported_components'; 3 | -------------------------------------------------------------------------------- /test/test_files/directives/directive.function.arrow.returnExpression.ts: -------------------------------------------------------------------------------- 1 | module Inside.A.Module { 2 | angular.module('app').directive('functionDirective', () => ({ 3 | restrict: 'E' 4 | })); 5 | } -------------------------------------------------------------------------------- /test/test_files/directives/directive.function.ts: -------------------------------------------------------------------------------- 1 | module Inside.A.Module { 2 | angular.module('app').directive('functionDirective', function() { 3 | return { 4 | restrict: 'E' 5 | } 6 | }); 7 | } -------------------------------------------------------------------------------- /test/test_files/controllers/controller_simple.ts: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | export class TestController { 4 | 5 | } 6 | 7 | angular.module('app').controller('TestController', TestController); -------------------------------------------------------------------------------- /test/test_files/directives/directive.function.arrow.ts: -------------------------------------------------------------------------------- 1 | module Inside.A.Module { 2 | angular.module('app').directive('functionDirective', () => { 3 | return { 4 | restrict: 'E' 5 | } 6 | }); 7 | } -------------------------------------------------------------------------------- /test/test_files/angular/jspm1/bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ts-skeleton", 3 | "version": "0.4.0", 4 | "main": "dist/ts-skeleton.min.js", 5 | "ignore": [ 6 | "tasks", 7 | "test" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /test/test_files/controllers/controller_differentName.ts: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | export class TestController { 4 | 5 | } 6 | 7 | angular.module('app').controller('differentName', TestController); -------------------------------------------------------------------------------- /test/test_files/routes/route.component_template.ts: -------------------------------------------------------------------------------- 1 | angular 2 | .module('app') 3 | .config(function ($stateProvider) { 4 | $stateProvider 5 | .state('example_route', { 6 | component: 'componentName', 7 | }); 8 | }); -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as vsc from 'vscode'; 2 | import { Extension } from './extension'; 3 | 4 | const extension = new Extension(); 5 | 6 | export async function activate(context: vsc.ExtensionContext) { 7 | extension.activate(context); 8 | } 9 | -------------------------------------------------------------------------------- /test/test_files/components/component_importClass.ts: -------------------------------------------------------------------------------- 1 | import { ExampleComponentClass } from './exported_components'; 2 | import angular from 'angular'; 3 | 4 | angular.module('app').component('exampleComponent', new ExampleComponentClass()); 5 | -------------------------------------------------------------------------------- /test/test_files/components/component_importLiteral.ts: -------------------------------------------------------------------------------- 1 | import { ExampleComponentLiteral } from "./exported_components"; 2 | import angular from 'angular'; 3 | 4 | angular.module('app').component('exampleComponent', ExampleComponentLiteral); 5 | -------------------------------------------------------------------------------- /test/test_files/routes/route.inline_template.ts: -------------------------------------------------------------------------------- 1 | angular 2 | .module('app') 3 | .config(function ($stateProvider) { 4 | $stateProvider 5 | .state('example_route', { 6 | template: 'Route inline template', 7 | }); 8 | }); -------------------------------------------------------------------------------- /test/test_files/routes/route.external_template.ts: -------------------------------------------------------------------------------- 1 | angular 2 | .module('app') 3 | .config(function ($stateProvider) { 4 | $stateProvider 5 | .state('example_route', { 6 | templateUrl: './subdir/template.html', 7 | }); 8 | }); -------------------------------------------------------------------------------- /test/test_files/components/component_importReexportedClass.ts: -------------------------------------------------------------------------------- 1 | import { ExampleComponentClass } from "./reexported_components"; 2 | import angular from 'angular'; 3 | 4 | angular.module('app').component('exampleComponent', new ExampleComponentClass()); 5 | -------------------------------------------------------------------------------- /test/test_files/components/component_importReexportedLiteral.ts: -------------------------------------------------------------------------------- 1 | import { ExampleComponentLiteral } from './reexported_components'; 2 | import angular from 'angular'; 3 | 4 | angular.module('app').component('exampleComponent', ExampleComponentLiteral); 5 | -------------------------------------------------------------------------------- /test/test_files/directives/directive.function.named.ts: -------------------------------------------------------------------------------- 1 | module Inside.A.Module { 2 | function functionDirective() { 3 | return { 4 | restrict: 'E' 5 | } 6 | } 7 | 8 | angular.module('app').directive('functionDirective', functionDirective); 9 | } -------------------------------------------------------------------------------- /test/test_files/directives/directive.register.arrowFunc.ts: -------------------------------------------------------------------------------- 1 | module Inside.A.Module { 2 | // @ts-ignore 3 | export class ClassDirective implements ng.IDirective {} 4 | 5 | angular.module('app').directive('classDirective', () => new ClassDirective()); 6 | } -------------------------------------------------------------------------------- /test/test_files/routes/route.required_template.ts: -------------------------------------------------------------------------------- 1 | angular 2 | .module('app') 3 | .config(function ($stateProvider) { 4 | $stateProvider 5 | .state('example_route', { 6 | template: require('./subdir/template.html'), 7 | }); 8 | }); -------------------------------------------------------------------------------- /.bithoundrc: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": [ 3 | "**/node_modules/**", 4 | "test/test_files/**", 5 | "test/index.ts" 6 | ], 7 | "test": [ 8 | "test/**/*.test.ts" 9 | ], 10 | "critics": { 11 | "wc": { 12 | "limit": 300 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /test/test_files/components/component_comments.ts: -------------------------------------------------------------------------------- 1 | angular.module('app').component('exampleComponent', { 2 | /* multi 3 | line 4 | comment */ 5 | 6 | // single line comment 7 | bindings: { 8 | config: '<', 9 | data: '<' 10 | } 11 | }); -------------------------------------------------------------------------------- /test/test_files/components/test.component.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | 'use strict'; 3 | 4 | angular 5 | .module('app') 6 | .component('exampleComponent', { 7 | bindings: { 8 | config: '<', 9 | data: '<' 10 | } 11 | }); 12 | })(); -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | typings/** 3 | out/test/** 4 | test/** 5 | src/** 6 | **/*.map 7 | .gitignore 8 | tsconfig.json 9 | vsc-extension-quickstart.md 10 | tslint.json 11 | *.vsix 12 | coverage/** 13 | .vscode-test/** 14 | .travis.yml 15 | .bithoundrc 16 | -------------------------------------------------------------------------------- /test/test_files/directives/directive.function.injectedParams.ts: -------------------------------------------------------------------------------- 1 | module Inside.A.Module { 2 | angular.module('app').directive('functionDirective', ['$interval', 'dateFilter', function ($interval, dateFilter) { 3 | return { 4 | restrict: 'E' 5 | } 6 | }]); 7 | } -------------------------------------------------------------------------------- /test/test_files/components/component_consts.ts: -------------------------------------------------------------------------------- 1 | const componentName = 'exampleComponent'; 2 | const componentConfiguration = { 3 | bindings: { 4 | config: '<', 5 | data: '<' 6 | } 7 | }; 8 | 9 | angular.module('app').component(componentName, componentConfiguration); -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 6 | "eg2.tslint" 7 | ] 8 | } -------------------------------------------------------------------------------- /test/test_files/controllers/controller_multiple_ignored.ts: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | export class TestController { 4 | } 5 | 6 | export class TestController2 implements ng.IComponentOptions { 7 | } 8 | 9 | angular.module('app').controller('TestController', TestController); -------------------------------------------------------------------------------- /test/test_files/directives/directive.function.named.arrow.ts: -------------------------------------------------------------------------------- 1 | module Inside.A.Module { 2 | const functionDirective = () => { 3 | return { 4 | restrict: 'E' 5 | } 6 | } 7 | 8 | angular.module('app').directive('functionDirective', functionDirective); 9 | 10 | } -------------------------------------------------------------------------------- /test/test_files/directives/directive.register.blockArrowFunc.ts: -------------------------------------------------------------------------------- 1 | module Inside.A.Module { 2 | // @ts-ignore 3 | export class ClassDirective implements ng.IDirective {} 4 | 5 | angular.module('app').directive('classDirective', () => { 6 | return new ClassDirective() 7 | }); 8 | } -------------------------------------------------------------------------------- /test/test_files/components/component_inline_template.ts: -------------------------------------------------------------------------------- 1 | 2 | function controller() {} 3 | 4 | angular.module('moduleName').component('componentName', { 5 | template: 'inlineTemplateBody', 6 | bindings: { 7 | data: '<' 8 | }, 9 | controller: controller 10 | }); -------------------------------------------------------------------------------- /test/test_files/directives/directive.register.functionExpression.ts: -------------------------------------------------------------------------------- 1 | module Inside.A.Module { 2 | // @ts-ignore 3 | export class ClassDirective implements ng.IDirective {} 4 | 5 | angular.module('app').directive('classDirective', function() { 6 | return new ClassDirective() 7 | }); 8 | } -------------------------------------------------------------------------------- /test/test_files/components/component_literal.ts: -------------------------------------------------------------------------------- 1 | 2 | let ExampleComponentLiteral: ng.IComponentOptions = { 3 | controller: 'ExampleCtrl', 4 | bindings: { 5 | exampleBinding: '<' 6 | } 7 | }; 8 | 9 | angular.module('app').component('exampleComponent', ExampleComponentLiteral); 10 | -------------------------------------------------------------------------------- /test/test_files/controllers/controller_multiple.ts: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | export class TestController { 4 | 5 | } 6 | 7 | export class TestController2 { 8 | 9 | } 10 | 11 | angular.module('app').controller('1', TestController); 12 | angular.module('app').controller('2', TestController2); -------------------------------------------------------------------------------- /coverconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "enabled": true, 3 | "relativeSourcePath": "../src", 4 | "relativeCoverageDir": "../../coverage", 5 | "ignorePatterns": [ 6 | "**/node_modules/**" 7 | ], 8 | "includePid": false, 9 | "reports": [ 10 | "json", 11 | "html", 12 | "lcov" 13 | ], 14 | "verbose": false 15 | } -------------------------------------------------------------------------------- /test/test_files/components/component_required_template.ts: -------------------------------------------------------------------------------- 1 | let template = require('./template.html'); 2 | 3 | function controller() {} 4 | 5 | angular.module('moduleName').component('componentName', { 6 | template: template, 7 | bindings: { 8 | data: '<' 9 | }, 10 | controller: controller 11 | }); -------------------------------------------------------------------------------- /test/test_files/components/component_class.ts: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | export class ExampleComponentClass implements ng.IComponentOptions { 4 | public controller = 'ExampleCtrl'; 5 | public bindings = { 6 | exampleBinding: '<' 7 | }; 8 | } 9 | 10 | angular.module('app').component('exampleComponent', new ExampleComponentClass()); 11 | -------------------------------------------------------------------------------- /test/test_files/components/component_staticFields.ts: -------------------------------------------------------------------------------- 1 | 2 | class Controller { 3 | public static componentName = 'exampleComponent'; 4 | public static componentConfiguration = { 5 | bindings: { 6 | config: '<', 7 | data: '<' 8 | } 9 | }; 10 | 11 | } 12 | 13 | angular.module('app').component(Controller.componentName, Controller.componentConfiguration); -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | - please keep your code consistent with existing codebase 2 | - make sure there are no tslint errors (`npm run linter` or install [TSLint](https://marketplace.visualstudio.com/items?itemName=eg2.tslint) extension for live fixes) 3 | - write unit tests when adding new features 4 | - provide examples for features you're introducing so that it's easier to understand the logic behind the code 5 | -------------------------------------------------------------------------------- /src/symbols.ts: -------------------------------------------------------------------------------- 1 | export const events = { 2 | componentsChanged: Symbol('componentsChanged'), 3 | htmlReferencesChanged: Symbol('htmlReferencesChanged'), 4 | routesChanged: Symbol('routesChanged'), 5 | directivesChanged: Symbol('directivesChanged'), 6 | memberFound: Symbol('memberFound'), 7 | configurationFile: { 8 | ignoredMemberDiagnosticChanged: Symbol('ignoredMemberDiagnosticChanged') 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /test/test_files/components/exported_components.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line:variable-name 2 | export let ExampleComponentLiteral: ng.IComponentOptions = { 3 | controller: 'ExampleCtrl', 4 | bindings: { 5 | exampleBinding: '<' 6 | } 7 | }; 8 | 9 | export class ExampleComponentClass implements ng.IComponentOptions { 10 | public controller = 'ExampleCtrl'; 11 | public bindings = { 12 | exampleBinding: '<' 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "lib": [ 5 | "es6", 6 | "es2015.promise", 7 | "es2017.object", 8 | "dom" 9 | ], 10 | "module": "commonjs", 11 | "moduleResolution": "node", 12 | "outDir": "out", 13 | "sourceMap": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true 16 | }, 17 | "exclude": [ 18 | "node_modules", 19 | "server", 20 | "test/test_files/**/*.ts" 21 | ] 22 | } -------------------------------------------------------------------------------- /test/test_files/angular/bower/bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-seed", 3 | "description": "A starter project for AngularJS", 4 | "version": "0.0.0", 5 | "homepage": "https://github.com/angular/angular-seed", 6 | "license": "MIT", 7 | "private": true, 8 | "dependencies": { 9 | "angular": "~1.5.0", 10 | "angular-route": "~1.5.0", 11 | "angular-loader": "~1.5.0", 12 | "angular-mocks": "~1.5.0", 13 | "html5-boilerplate": "^5.3.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: "10" 3 | 4 | os: 5 | - osx 6 | - linux 7 | 8 | env: 9 | - NODE_ENV="test" 10 | 11 | before_install: 12 | - | 13 | if [ $TRAVIS_OS_NAME == "linux" ]; then 14 | export DISPLAY=':99.0' 15 | /usr/bin/Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & 16 | fi 17 | 18 | script: 19 | - yarn 20 | - yarn vscode:prepublish 21 | - yarn test 22 | 23 | after_success: 24 | - npm run coverage 25 | 26 | cache: yarn -------------------------------------------------------------------------------- /test/test_files/routes/route.views_template.ts: -------------------------------------------------------------------------------- 1 | angular 2 | .module('app') 3 | .config(function ($stateProvider) { 4 | $stateProvider 5 | .state('example_route', { 6 | views: { 7 | 'inline-component': 'InlineComponent', 8 | 'inline-template': { 9 | template: 'inline-template' 10 | }, 11 | 'template-url': { 12 | templateUrl: './subdir/template.html', 13 | }, 14 | 'template-component': { 15 | component: 'TemplateComponent' 16 | } 17 | } 18 | }); 19 | }); -------------------------------------------------------------------------------- /test/test_files/directives/directive.default_restrict.ts: -------------------------------------------------------------------------------- 1 | module Inside.A.Module { 2 | // @ts-ignore 3 | export class ClassDirective implements ng.IDirective { 4 | link(scope: ng.IScope, element: ng.IAugmentedJQuery, attrs: ng.IAttributes) { 5 | // directive logic 6 | }; 7 | 8 | static factory() { 9 | var directive = () => { 10 | return new ClassDirective(); 11 | }; 12 | 13 | directive['$inject'] = []; 14 | return directive; 15 | } 16 | } 17 | 18 | angular.module('app').directive('classDirective', ClassDirective.factory()); 19 | } -------------------------------------------------------------------------------- /test/test_files/controllers/controller_members.ts: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | /* tslint:disable */ 4 | export class TestController { 5 | private privateField: string; 6 | public publicField: string; 7 | implicitlyPublicField: string; 8 | customType: IReturnType; 9 | 10 | testMethod(p1: string): number { 11 | return 0; 12 | } 13 | 14 | arrowFunction = (p1: string, p2: number): number => { 15 | return 1; 16 | } 17 | } 18 | 19 | angular.module('app').controller('TestController', TestController); 20 | 21 | interface IReturnType { 22 | field: string; 23 | } -------------------------------------------------------------------------------- /test/test_files/directives/directive.multiple.ts: -------------------------------------------------------------------------------- 1 | module Inside.A.Module { 2 | // @ts-ignore 3 | export class ClassDirective1 implements ng.IDirective {} 4 | export class ClassDirective2 implements ng.IDirective {} 5 | export class ClassDirective3 implements ng.IDirective {} 6 | 7 | angular.module('app').directive('classDirective1', () => new ClassDirective1()); 8 | angular.module('app').directive('classDirective2', function() { 9 | return new ClassDirective2() 10 | }); 11 | angular.module('app').directive('classDirective3', () => { 12 | return new ClassDirective3() 13 | }); 14 | } -------------------------------------------------------------------------------- /test/test_files/directives/directive.init.class_property.ts: -------------------------------------------------------------------------------- 1 | module Inside.A.Module { 2 | // @ts-ignore 3 | export class ClassDirective implements ng.IDirective { 4 | restrict = 'E'; 5 | 6 | link(scope: ng.IScope, element: ng.IAugmentedJQuery, attrs: ng.IAttributes) { 7 | // directive logic 8 | }; 9 | 10 | static factory() { 11 | var directive = () => { 12 | return new ClassDirective(); 13 | }; 14 | 15 | directive['$inject'] = []; 16 | return directive; 17 | } 18 | } 19 | 20 | angular.module('app').directive('classDirective', ClassDirective.factory()); 21 | } -------------------------------------------------------------------------------- /test/test_files/components/component_constructor_init.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import ExampleController from './exported_controller'; 3 | import angular from 'angular'; 4 | 5 | const template = require('./example-template.html'); 6 | 7 | class ExampleComponent { 8 | public controller: any; 9 | public bindings: any; 10 | public template: string; 11 | 12 | constructor() { 13 | this.controller = ExampleController; 14 | this.template = template; 15 | this.bindings = { 16 | data: '<' 17 | }; 18 | } 19 | } 20 | 21 | angular 22 | .module('app', []) 23 | .component('exampleComponent', new ExampleComponent()) 24 | -------------------------------------------------------------------------------- /src/commands/commands.ts: -------------------------------------------------------------------------------- 1 | 2 | export const Commands = { 3 | RefreshComponents: 'ngComponents.refreshAngularComponents', 4 | RefreshMemberDiagnostics: 'ngComponents.refreshMemberDiagnostics', 5 | FindUnusedComponents: 'ngComponents.findUnusedAngularComponents', 6 | FindUnusedDirectives: 'ngComponents.findUnusedDirectives', 7 | MarkAsAngularProject: 'ngComponents.markAsAngularProject', 8 | SwitchComponentParts: 'ngComponents.switchComponentParts', 9 | MemberDiagnostic: { 10 | IgnoreMember: 'ngComponents.memberDiagnostic.ignoreMemberDiagnostic', 11 | DidYouMean: 'ngComponents.memberDiagnostic.didYouMean' 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /src/commands/didYouMean.ts: -------------------------------------------------------------------------------- 1 | import * as vsc from 'vscode'; 2 | import { Commands } from './commands'; 3 | 4 | export class DidYouMeanCommand implements vsc.Disposable { 5 | private disposable: vsc.Disposable; 6 | 7 | constructor() { 8 | this.disposable = vsc.commands.registerTextEditorCommand(Commands.MemberDiagnostic.DidYouMean, this.execute); 9 | } 10 | 11 | public dispose() { 12 | this.disposable && this.disposable.dispose(); 13 | } 14 | 15 | public execute = (_textEditor: vsc.TextEditor, edit: vsc.TextEditorEdit, range: vsc.Range, replacement: string) => { 16 | edit.replace(range, replacement); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "**/node_modules": false, 5 | "out": false // set this to true to hide the "out" folder with the compiled JS files 6 | }, 7 | "search.exclude": { 8 | "out": true, // set this to false to include "out" folder in search results 9 | "**/node_modules": false 10 | }, 11 | "editor.insertSpaces": false, 12 | "editor.detectIndentation": true, 13 | "typescript.tsdk": "./node_modules/typescript/lib", 14 | "tslint.configFile": "tslint.json", 15 | "eslint.enable": false, 16 | "typescript.preferences.quoteStyle": "single" 17 | } -------------------------------------------------------------------------------- /test/test_files/directives/directive.init.class_ctor.ts: -------------------------------------------------------------------------------- 1 | module Inside.A.Module { 2 | // @ts-ignore 3 | export class ClassDirective implements ng.IDirective { 4 | restrict: string; 5 | 6 | constructor() { 7 | this.restrict = 'E'; 8 | } 9 | 10 | link(scope: ng.IScope, element: ng.IAugmentedJQuery, attrs: ng.IAttributes) { 11 | // directive logic 12 | }; 13 | 14 | static factory() { 15 | var directive = () => { 16 | return new ClassDirective(); 17 | }; 18 | 19 | directive['$inject'] = []; 20 | return directive; 21 | } 22 | } 23 | 24 | angular.module('app').directive('classDirective', ClassDirective.factory()); 25 | } -------------------------------------------------------------------------------- /test/test_files/controllers/controller_chained.ts: -------------------------------------------------------------------------------- 1 | import angular from 'angular'; 2 | 3 | // tslint:disable:max-classes-per-file 4 | // tslint:disable:indent 5 | 6 | export class TestController1 { } 7 | export class TestController2 { } 8 | export class TestController3 { } 9 | 10 | const directive = () => ({restrict: 'E'}); 11 | 12 | angular.module('app') 13 | .controller('TestController1', TestController1) 14 | .directive('directive', directive); 15 | 16 | angular.module('app') 17 | .directive('directive', directive) 18 | .controller('TestController2', TestController2); 19 | 20 | angular.module('app') 21 | .directive('directive', directive) 22 | .controller('TestController3', TestController3) 23 | .directive('directive', directive); 24 | -------------------------------------------------------------------------------- /src/utils/configurationChangeListener.ts: -------------------------------------------------------------------------------- 1 | import * as vsc from 'vscode'; 2 | 3 | export class ConfigurationChangeListener { 4 | private disposables: vsc.Disposable[] = []; 5 | 6 | constructor(private section: string) { 7 | } 8 | 9 | public registerListener = (keys: string[] | string, callback: () => void) => { 10 | if (!Array.isArray(keys)) { 11 | keys = [keys]; 12 | } 13 | 14 | const configKeys = keys; 15 | 16 | vsc.workspace.onDidChangeConfiguration((event: vsc.ConfigurationChangeEvent) => { 17 | if (configKeys.some(key => event.affectsConfiguration(`${this.section}.${key}`))) { 18 | callback(); 19 | } 20 | }, undefined, this.disposables); 21 | } 22 | 23 | public dispose = () => { 24 | this.disposables.forEach(d => d.dispose()); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/commands/ignoreMemberDiagnostic.ts: -------------------------------------------------------------------------------- 1 | import * as vsc from 'vscode'; 2 | import { Commands } from './commands'; 3 | import { RelativePath } from '../utils/htmlTemplate/relativePath'; 4 | import { ConfigurationFile } from '../configurationFile'; 5 | 6 | export class IgnoreMemberDiagnosticCommand implements vsc.Disposable { 7 | private disposable: vsc.Disposable; 8 | 9 | constructor(private configuration: ConfigurationFile) { 10 | this.disposable = vsc.commands.registerCommand(Commands.MemberDiagnostic.IgnoreMember, this.execute); 11 | } 12 | 13 | public dispose() { 14 | this.disposable && this.disposable.dispose(); 15 | } 16 | 17 | public execute = (templatePath: RelativePath, memberName: string) => { 18 | this.configuration.addIgnoredMemberDiagnostic(templatePath.relative, memberName); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/htmlTemplate/types.d.ts: -------------------------------------------------------------------------------- 1 | import { IMemberAccessEntry } from './streams/memberAccessParser'; 2 | import * as ts from "typescript"; 3 | 4 | export interface IHtmlTemplateInfoResults { 5 | htmlReferences: IHtmlReferences; 6 | directiveReferences: IHtmlReferences; 7 | templateInfo: ITemplateInfo; 8 | } 9 | 10 | export interface IHtmlReferences { 11 | [htmlName: string]: IHtmlReference[]; 12 | } 13 | 14 | export interface IHtmlReference extends ts.LineAndCharacter { 15 | relativeHtmlPath: string; 16 | } 17 | 18 | export interface ITemplateInfo { 19 | [relativeHtmlPath: string]: ITemplateInfoEntry; 20 | } 21 | 22 | export interface ITemplateInfoEntry { 23 | memberAccess: IMemberAccessEntry[]; 24 | forms: IFormInfo[]; 25 | } 26 | 27 | export interface IFormInfo extends ts.LineAndCharacter { 28 | name: string; 29 | } 30 | -------------------------------------------------------------------------------- /src/utils/htmlTemplate/streams/splitToLines.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'stream'; 2 | 3 | type VoidFunction = () => void; 4 | 5 | export class SplitToLines extends Transform { 6 | private lastLineData: string; 7 | 8 | constructor() { 9 | super({ objectMode: true }); 10 | } 11 | 12 | public _transform(chunk: Buffer, _encoding: string, done: VoidFunction) { 13 | let data = chunk.toString(); 14 | if (this.lastLineData) { 15 | data = this.lastLineData + data; 16 | } 17 | 18 | const lines = data.split(/\r\n|[\r\n]/); 19 | this.lastLineData = lines.splice(lines.length - 1, 1)[0]; 20 | 21 | lines.forEach(this.push.bind(this)); 22 | 23 | done(); 24 | } 25 | 26 | public _flush(done: VoidFunction) { 27 | if (this.lastLineData) { 28 | this.push(this.lastLineData); 29 | this.lastLineData = null; 30 | } 31 | 32 | done(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /test/test_files/angular/jspm2/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ng157es6jspm", 3 | "version": "1.0.0", 4 | "description": "Example application for setting up Angular 1.5.7 to use Componets, ES6, and jspm.", 5 | "main": "", 6 | "scripts": { 7 | "postinstall": "jspm install", 8 | "start": "lite-server" 9 | }, 10 | "author": { 11 | "name": "Karl Shifflett", 12 | "url": "http://oceanware.wordpress.com" 13 | }, 14 | "license": "MIT", 15 | "devDependencies": { 16 | "jspm": "^0.16.39", 17 | "lite-server": "^2.2.2" 18 | }, 19 | "jspm": { 20 | "directories": { 21 | "baseURL": "src" 22 | }, 23 | "dependencies": { 24 | "angular": "npm:angular@1.5.7", 25 | "ngcomponentrouter": "npm:ngcomponentrouter@^2.1.0" 26 | }, 27 | "devDependencies": { 28 | "babel": "npm:babel-core@^5.8.24", 29 | "babel-runtime": "npm:babel-runtime@^5.8.24", 30 | "core-js": "npm:core-js@^1.1.4" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/utils/component/helpers.ts: -------------------------------------------------------------------------------- 1 | import { IComponentBase, IComponentBinding } from './component'; 2 | import * as vsc from 'vscode'; 3 | import { IMember } from '../controller/member'; 4 | 5 | export type GetMembersAndBindingsFunctionType = (component: IComponentBase) => { members: IMember[], bindings: IComponentBinding[] }; 6 | 7 | export function getMembersAndBindingsFunction(getConfig: () => vsc.WorkspaceConfiguration): GetMembersAndBindingsFunctionType { 8 | return (component: IComponentBase) => { 9 | const config = getConfig(); 10 | const publicOnly = config.get('controller.publicMembersOnly'); 11 | const excludedMembers = new RegExp(config.get('controller.excludedMembers')); 12 | 13 | const members = component.controller && component.controller.getMembers(publicOnly).filter(m => !excludedMembers.test(m.name)) || []; 14 | const bindings = component.getBindings(); 15 | 16 | return { 17 | members, 18 | bindings 19 | }; 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /test/utils/mockedConfig.ts: -------------------------------------------------------------------------------- 1 | import { WorkspaceConfiguration, ConfigurationTarget } from 'vscode'; 2 | 3 | export class MockedConfig implements WorkspaceConfiguration { 4 | private _config: {[key: string]: any} = {}; 5 | readonly [key: string]: any; 6 | 7 | public setMockData(config: {[key: string]: any}) { 8 | this._config = config; 9 | } 10 | 11 | public get(section: string, defaultValue?: T): T; 12 | 13 | public get(section: any, defaultValue?: any) { 14 | return this._config[section] || defaultValue; 15 | } 16 | 17 | public has(_section: string): boolean { 18 | throw new Error('Method not implemented.'); 19 | } 20 | 21 | public inspect(_section: string): { key: string; defaultValue?: T; globalValue?: T; workspaceValue?: T; workspaceFolderValue?: T; } { 22 | throw new Error('Method not implemented.'); 23 | } 24 | 25 | public update(_section: string, _value: any, _configurationTarget?: boolean | ConfigurationTarget): Thenable { 26 | throw new Error('Method not implemented.'); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/providers/directiveDefinitionProvider.ts: -------------------------------------------------------------------------------- 1 | import * as vsc from 'vscode'; 2 | 3 | import { HtmlDocumentHelper } from '../utils/htmlDocumentHelper'; 4 | import { getLocation } from '../utils/vsc'; 5 | import { Directive } from '../utils/directive/directive'; 6 | 7 | export class DirectiveDefinitionProvider implements vsc.DefinitionProvider { 8 | private directives: Directive[]; 9 | 10 | constructor(private htmlDocumentHelper: HtmlDocumentHelper) {} 11 | 12 | public loadDirectives = (directives: Directive[]) => { 13 | this.directives = directives; 14 | } 15 | 16 | public provideDefinition(document: vsc.TextDocument, position: vsc.Position, _token: vsc.CancellationToken): vsc.Definition { 17 | const match = this.htmlDocumentHelper.parseAtPosition(document, position); 18 | 19 | if (match) { 20 | const directive = this.directives.find(c => c.htmlName === match.word); 21 | if (directive) { 22 | return getLocation({ path: directive.path, pos: directive.pos }); 23 | } 24 | } 25 | 26 | return []; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // Available variables which can be used inside of strings. 2 | // ${workspaceRoot}: the root folder of the team 3 | // ${file}: the current opened file 4 | // ${fileBasename}: the current opened file's basename 5 | // ${fileDirname}: the current opened file's dirname 6 | // ${fileExtname}: the current opened file's extension 7 | // ${cwd}: the current working directory of the spawned process 8 | 9 | // A task runner that calls a custom npm script that compiles the extension. 10 | { 11 | "version": "0.1.0", 12 | 13 | // we want to run npm 14 | "command": "npm", 15 | 16 | // the command is a shell script 17 | "isShellCommand": true, 18 | 19 | // show the output window only if unrecognized errors occur. 20 | "showOutput": "silent", 21 | 22 | // we run the custom script "compile" as defined in package.json 23 | "args": ["run", "compile", "--loglevel", "silent"], 24 | 25 | // The tsc compiler is started in watching mode 26 | "isWatching": true, 27 | 28 | // use the standard tsc in watch mode problem matcher to find compile problems in the output. 29 | "problemMatcher": "$tsc-watch" 30 | } -------------------------------------------------------------------------------- /test/utils/component/componentBinding.test.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | import { getTestSourceFile } from '../helpers'; 3 | import { TypescriptParser } from '../../../src/utils/typescriptParser'; 4 | import { ComponentBinding } from '../../../src/utils/component/componentBinding'; 5 | import should = require('should'); 6 | import _ = require('lodash'); 7 | 8 | describe('Given ComponentBinding', () => { 9 | it('when constructing fields are properly filled', () => { 10 | // arrange 11 | const contents = '({bindingName: \'<\'})'; 12 | const sourceFile = getTestSourceFile(contents); 13 | const parser = new TypescriptParser(sourceFile); 14 | 15 | const node = parser.findNode(3); 16 | const assignment = parser.closestParent(node, ts.SyntaxKind.PropertyAssignment); 17 | 18 | // act 19 | const result = new ComponentBinding(assignment, parser); 20 | 21 | // assert 22 | should(_.pick(result, 'name', 'htmlName', 'type')).be.eql({ 23 | name: 'bindingName', 24 | htmlName: 'binding-name', 25 | type: '<' 26 | }); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Ireneusz Patalas & Kamil Haładus 2 | 3 | All rights reserved. 4 | 5 | MIT License 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 12 | -------------------------------------------------------------------------------- /src/utils/angular.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as fs from 'fs'; 3 | 4 | const reVersion = /@?(~|^)?1\.[56]\.\d/; 5 | 6 | export function isValidAngularProject(projectRoot: string): boolean { 7 | const pkg = path.join(projectRoot, 'package.json'); 8 | const bower = path.join(projectRoot, 'bower.json'); 9 | 10 | if (fs.existsSync(pkg)) { 11 | if (detectPackageJson(pkg) || detectJSPM(pkg)) { 12 | return true; 13 | } 14 | } 15 | 16 | if (fs.existsSync(bower)) { 17 | return detectPackageJson(bower); 18 | } 19 | 20 | return false; 21 | } 22 | 23 | function detectPackageJson(packagePath: string) { 24 | const pkg = require(packagePath); 25 | const ver = (pkg.dependencies && pkg.dependencies['angular']); 26 | return ver && reVersion.test(ver) || false; 27 | } 28 | 29 | function detectJSPM(packagePath: string) { 30 | const pkg = require(packagePath); 31 | const ver = (pkg.jspm && pkg.jspm.dependencies && pkg.jspm.dependencies['angular']); 32 | return ver && reVersion.test(ver) || false; 33 | } 34 | 35 | // TODO: Ionic template https://github.com/jdnichollsc/Ionic-Starter-Template 36 | -------------------------------------------------------------------------------- /test/utils/angular.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | import { isValidAngularProject } from '../../src/utils/angular'; 3 | import { getTestFilePath } from './helpers'; 4 | 5 | describe('Given isAngularProject ', () => { 6 | describe('when calling', () => { 7 | it('with WebPack project then Angular should be detected', () => assertProject('webpack', true)); 8 | it('with Bower project then Angular should be detected', () => assertProject('bower', true)); 9 | it('with JSPM project then Angular should be detected', () => assertProject('jspm1', true)); 10 | it('with JSPM project #2 then Angular should be detected', () => assertProject('jspm2', true)); 11 | it('with project with out-dated Angular then Angular should NOT be detected', () => assertProject('angular_version_too_low', false)); 12 | it('with project with Angular 2 then Angular should NOT be detected', () => assertProject('angular_version_too_high', false)); 13 | }); 14 | }); 15 | 16 | function assertProject(projectName: string, expected: boolean) { 17 | const root = getTestFilePath('angular', projectName); 18 | 19 | const result = isValidAngularProject(root); 20 | 21 | assert.equal(result, expected); 22 | } 23 | -------------------------------------------------------------------------------- /src/providers/directiveReferencesProvider.ts: -------------------------------------------------------------------------------- 1 | import * as vsc from 'vscode'; 2 | import { IHtmlReferences } from '../utils/htmlTemplate/types'; 3 | import { convertHtmlReferencesToLocations } from '../utils/vsc'; 4 | import { Directive } from '../utils/directive/directive'; 5 | 6 | export class DirectiveReferencesProvider implements vsc.ReferenceProvider { 7 | private htmlReferences: IHtmlReferences; 8 | private directives: Directive[]; 9 | 10 | public load = (references: IHtmlReferences, directives: Directive[]) => { 11 | this.htmlReferences = references; 12 | this.directives = directives; 13 | } 14 | 15 | public provideReferences(document: vsc.TextDocument, position: vsc.Position, _context: vsc.ReferenceContext, _token: vsc.CancellationToken): vsc.Location[] { 16 | const wordPos = document.getWordRangeAtPosition(position); 17 | const word = document.getText(wordPos); 18 | 19 | const directive = this.directives.find(d => d.name === word || d.className === word); 20 | if (directive) { 21 | const references = this.htmlReferences[directive.htmlName]; 22 | if (references) { 23 | return convertHtmlReferencesToLocations(references); 24 | } 25 | } 26 | 27 | return []; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/utils/sourceFile.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import * as ts from 'typescript'; 4 | import { logParsingError } from './logging'; 5 | 6 | export class SourceFile { 7 | public get path(): string { 8 | return this.sourceFile.fullpath; 9 | } 10 | 11 | constructor(public sourceFile: ISourceFile) { } 12 | 13 | public static parseFromString(contents: string, filepath: string): SourceFile { 14 | const sourceFile = ts.createSourceFile(path.basename(filepath), contents, ts.ScriptTarget.ES5, true) as ISourceFile; 15 | sourceFile.fullpath = filepath; 16 | 17 | return new SourceFile(sourceFile); 18 | } 19 | 20 | public static parse(filepath: string): Promise { 21 | return new Promise((resolve, reject) => { 22 | fs.readFile(filepath, 'utf8', (err, contents) => { 23 | if (err) { 24 | return reject(err); 25 | } 26 | 27 | try { 28 | resolve(SourceFile.parseFromString(contents, filepath)); 29 | } catch (e) { 30 | logParsingError(filepath, e); 31 | resolve(null); 32 | } 33 | }); 34 | }); 35 | } 36 | } 37 | 38 | export interface ISourceFile extends ts.SourceFile { 39 | fullpath?: string; 40 | } 41 | -------------------------------------------------------------------------------- /src/utils/htmlTemplate/relativePath.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import { angularRoot } from '../vsc'; 3 | import * as vsc from 'vscode'; 4 | 5 | export class RelativePath { 6 | private readonly relativePath: string; 7 | private static readonly reSlashes = /\\/g; 8 | 9 | constructor(filepath: string, isRelative?: boolean) { 10 | if (isRelative) { 11 | this.relativePath = filepath; 12 | } else { 13 | this.relativePath = path.relative(angularRoot, filepath).replace(RelativePath.reSlashes, '/'); 14 | } 15 | } 16 | 17 | public static fromUri = (uri: vsc.Uri) => new RelativePath(uri.fsPath); 18 | public static toAbsolute = (relative: string) => new RelativePath(relative, true).absolute; 19 | 20 | public get relative() { 21 | return this.relativePath; 22 | } 23 | 24 | public get relativeLowercase() { 25 | return this.relativePath.toLowerCase(); 26 | } 27 | 28 | public get absolute() { 29 | return path.join(angularRoot, this.relativePath); 30 | } 31 | 32 | public equals = (other: RelativePath | string): boolean => { 33 | const otherPath = other instanceof RelativePath ? other : new RelativePath(other); 34 | 35 | return this.relativePath === otherPath.relativePath; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/utils/htmlTemplate/streams/memberAccessParser.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'stream'; 2 | import { events } from '../../../symbols'; 3 | 4 | export class MemberAccessParser extends Transform { 5 | private memberAccessRegex: RegExp; 6 | private lineNumber: number = 0; 7 | 8 | constructor(controllerAlias: string) { 9 | super({ objectMode: true }); 10 | 11 | this.memberAccessRegex = new RegExp(`\\b${this.escapeRegex(controllerAlias)}\\.([\\w$]+)`, 'gi'); 12 | } 13 | 14 | private escapeRegex = (s: string) => s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); 15 | 16 | public _transform(line: string, _encoding: string, done: () => void) { 17 | let m: RegExpExecArray; 18 | 19 | // tslint:disable-next-line:no-conditional-assignment 20 | while ((m = this.memberAccessRegex.exec(line)) !== null) { 21 | const eventData: IMemberAccessEntry = { 22 | line: this.lineNumber, 23 | character: m.index, 24 | memberName: m[1], 25 | expression: m[0] 26 | }; 27 | this.emit(events.memberFound, eventData); 28 | } 29 | 30 | this.lineNumber++; 31 | done(); 32 | } 33 | } 34 | 35 | export interface IMemberAccessEntry { 36 | line: number; 37 | character: number; 38 | memberName: string; 39 | expression: string; 40 | } 41 | -------------------------------------------------------------------------------- /src/utils/directive/directive.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | import { DirectiveParser } from './directiveParser'; 3 | import { SourceFile } from '../sourceFile'; 4 | import { logParsingError } from '../logging'; 5 | 6 | export const DEFAULT_RESTRICT = 'EA'; 7 | 8 | export class Directive { 9 | private _restrict: string; 10 | 11 | public name: string; 12 | public htmlName: string; 13 | public className: string; 14 | public path: string; 15 | public pos: ts.LineAndCharacter; 16 | 17 | public isElementDirective: boolean; 18 | public isAttributeDirective: boolean; 19 | 20 | public get restrict(): string { 21 | return this._restrict; 22 | } 23 | 24 | public set restrict(value: string) { 25 | this._restrict = value; 26 | this.isElementDirective = value.includes('E'); 27 | this.isAttributeDirective = value.includes('A'); 28 | } 29 | 30 | public static parse(file: SourceFile): Promise { 31 | return new Promise(async (resolve, _reject) => { 32 | try { 33 | const parser = new DirectiveParser(file); 34 | const results: Directive[] = await parser.parse(); 35 | 36 | resolve(results); 37 | } catch (e) { 38 | logParsingError(file.path, e); 39 | resolve([]); 40 | } 41 | }); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/utils/component/componentBinding.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | import { IComponentBinding } from './component'; 3 | import * as vsc from 'vscode'; 4 | import { TypescriptParser } from '../typescriptParser'; 5 | import _ = require('lodash'); 6 | 7 | export class ComponentBinding implements IComponentBinding { 8 | public name: string; 9 | public htmlName: string; 10 | public type: string; 11 | public pos: ts.LineAndCharacter; 12 | 13 | constructor(node: ts.PropertyAssignment, parser: TypescriptParser) { 14 | const { type, name } = this.parseType((node.initializer as ts.StringLiteral).text); 15 | 16 | this.name = node.name.getText(parser.sourceFile); 17 | this.type = type; 18 | this.htmlName = _.kebabCase(name || this.name); 19 | this.pos = parser.sourceFile.getLineAndCharacterOfPosition(node.initializer.pos); 20 | } 21 | 22 | private parseType = (type: string) => { 23 | const match = /^(.*?)(\w+)?$/g.exec(type); 24 | return { 25 | type: match[1], 26 | name: match[2] 27 | }; 28 | } 29 | 30 | public buildCompletionItem(): vsc.CompletionItem { 31 | const item = new vsc.CompletionItem(this.name); 32 | item.detail = `Binding: ${this.type}`; 33 | item.kind = vsc.CompletionItemKind.Reference; 34 | 35 | return item; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/utils/route/route.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | import { SourceFile } from '../sourceFile'; 3 | import { IComponentTemplate, IComponentBase } from '../component/component'; 4 | import { logParsingError } from '../logging'; 5 | import { RouteParser } from './routeParser'; 6 | import { Controller } from '../controller/controller'; 7 | import { ControllerHelper } from '../controllerHelper'; 8 | 9 | export class Route implements IComponentBase { 10 | public name: string; 11 | public pos: ts.LineAndCharacter; 12 | 13 | public views: IComponentBase[]; 14 | 15 | public path: string; 16 | public template: IComponentTemplate; 17 | public controller: Controller; 18 | public controllerAs: string; 19 | public controllerName: string; 20 | public controllerClassName: string; 21 | 22 | public getBindings = () => []; 23 | 24 | public static parse(file: SourceFile, controllers: Controller[]): Promise { 25 | return new Promise((resolve, _reject) => { 26 | try { 27 | const controllerHelper = new ControllerHelper(controllers); 28 | const parser = new RouteParser(controllerHelper, file); 29 | const results: Route[] = parser.parse(); 30 | 31 | resolve(results); 32 | } catch (e) { 33 | logParsingError(file.path, e); 34 | resolve([]); 35 | } 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/commands/findUnusedDirectives.ts: -------------------------------------------------------------------------------- 1 | import * as vsc from 'vscode'; 2 | 3 | import { Directive } from '../utils/directive/directive'; 4 | import { IHtmlReferences } from '../utils/htmlTemplate/types'; 5 | import { logError } from '../utils/logging'; 6 | 7 | export class FindUnusedDirectivesCommand { 8 | public execute = (htmlReferences: IHtmlReferences, directives: Directive[]) => { 9 | const usedDirectives = Object.keys(htmlReferences); 10 | const unusedDirectives = directives.filter(d => usedDirectives.indexOf(d.htmlName) === -1); 11 | 12 | if (unusedDirectives.length === 0) { 13 | vsc.window.showInformationMessage('All of your directives are used. Good for you :-)'); 14 | return; 15 | } 16 | const items = unusedDirectives.map(d => ({ ...d, label: d.name, description: d.htmlName })); 17 | vsc.window.showQuickPick(items, { matchOnDescription: true, placeHolder: 'Search among unused directives' }).then(directive => { 18 | if (!directive) { 19 | return; 20 | } 21 | 22 | vsc.workspace.openTextDocument(directive.path).then(doc => { 23 | vsc.window.showTextDocument(doc).then(editor => { 24 | const { line, character } = directive.pos; 25 | editor.selection = new vsc.Selection(line, character, line, character); 26 | }); 27 | }, (err) => { 28 | logError(err); 29 | }); 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/commands/findUnusedComponents.ts: -------------------------------------------------------------------------------- 1 | 2 | import { IHtmlReferences } from '../utils/htmlTemplate/types'; 3 | import { Component } from '../utils/component/component'; 4 | import * as vsc from 'vscode'; 5 | import { logError } from '../utils/logging'; 6 | 7 | export class FindUnusedComponentsCommand { 8 | public execute = (htmlReferences: IHtmlReferences, components: Component[]) => { 9 | const usedComponents = Object.keys(htmlReferences); 10 | const unusedComponents = components.filter(c => usedComponents.indexOf(c.htmlName) === -1); 11 | 12 | if (unusedComponents.length === 0) { 13 | vsc.window.showInformationMessage('All of your components are used. Good for you :-)'); 14 | return; 15 | } 16 | 17 | const items = unusedComponents.map(c => ({ ...c, label: c.name, description: c.htmlName })); 18 | 19 | vsc.window.showQuickPick(items, {matchOnDescription: true, placeHolder: 'Search among unused components'}).then(component => { 20 | if (!component) { 21 | return; 22 | } 23 | 24 | vsc.workspace.openTextDocument(component.path).then(doc => { 25 | vsc.window.showTextDocument(doc).then(editor => { 26 | const { line, character } = component.pos; 27 | editor.selection = new vsc.Selection(line, character, line, character); 28 | }); 29 | }, (err) => { 30 | logError(err); 31 | }); 32 | }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "linterOptions": { 4 | "exclude": [ 5 | "test/index.ts", 6 | "test/test_files/**/*.ts" 7 | ] 8 | }, 9 | "rules": { 10 | "no-string-literal": false, 11 | "no-unused-expression": [ 12 | true, 13 | "allow-fast-null-checks" 14 | ], 15 | "strict-type-predicates": true, 16 | "variable-name": [ 17 | true, 18 | "ban-keywords", 19 | "check-format", 20 | "allow-pascal-case", 21 | "allow-leading-underscore" 22 | ], 23 | "no-unused-variable": [ 24 | true, 25 | "check-parameters", 26 | { 27 | "ignore-pattern": "^_" 28 | } 29 | ], 30 | "indent": [ 31 | false, 32 | "tabs", 33 | 2 34 | ], 35 | "quotemark": [ 36 | true, 37 | "single", 38 | "avoid-escape" 39 | ], 40 | "max-line-length": [ 41 | true, 42 | 170 43 | ], 44 | "object-literal-sort-keys": false, 45 | "trailing-comma": [ 46 | false 47 | ], 48 | "member-ordering": [ 49 | false 50 | ], 51 | "whitespace": [ 52 | true, 53 | "check-branch", 54 | "check-decl", 55 | "check-operator", 56 | "check-module", 57 | "check-separator", 58 | "check-type" 59 | ], 60 | "arrow-parens": false, 61 | "ordered-imports": [ 62 | false 63 | ], 64 | "no-angle-bracket-type-assertion": false, 65 | "curly": [ 66 | true, 67 | "ignore-same-line" 68 | ] 69 | } 70 | } -------------------------------------------------------------------------------- /src/utils/controller/controller.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | import { SourceFile } from '../sourceFile'; 3 | import { IMember } from './member'; 4 | import { ControllerParser } from './controllerParser'; 5 | import { logParsingError } from '../logging'; 6 | 7 | export class Controller { 8 | public name: string; 9 | public className: string; 10 | public path: string; 11 | public pos: ts.LineAndCharacter; 12 | public members: IMember[] = []; 13 | 14 | public baseClassName: string; 15 | public baseClass: Controller; 16 | 17 | public getMembers = (publicOnly: boolean): IMember[] => { 18 | const allMembers = [...this.members.filter(m => !publicOnly || m.isPublic === true)]; 19 | 20 | if (this.baseClass) { 21 | allMembers.push(...this.baseClass.getMembers(publicOnly)); 22 | } 23 | 24 | return allMembers; 25 | } 26 | 27 | public isInstanceOf = (className: string): boolean => { 28 | let result = this.className === className; 29 | 30 | if (!result && this.baseClass) { 31 | result = this.baseClass.isInstanceOf(className); 32 | } 33 | 34 | return result; 35 | } 36 | 37 | public static parse(file: SourceFile): Promise { 38 | return new Promise((resolve, _reject) => { 39 | try { 40 | const parser = new ControllerParser(file); 41 | const results: Controller[] = parser.parse(); 42 | 43 | resolve(results); 44 | } catch (e) { 45 | logParsingError(file.path, e); 46 | resolve([]); 47 | } 48 | }); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/providers/directiveCompletionProvider.ts: -------------------------------------------------------------------------------- 1 | import * as vsc from 'vscode'; 2 | import { HtmlDocumentHelper } from '../utils/htmlDocumentHelper'; 3 | import { Directive } from '../utils/directive/directive'; 4 | 5 | export class DirectiveCompletionProvider implements vsc.CompletionItemProvider { 6 | private directives: Directive[]; 7 | 8 | constructor(private htmlDocumentHelper: HtmlDocumentHelper) { } 9 | 10 | public loadDirectives = (directives: Directive[]) => { 11 | this.directives = directives; 12 | } 13 | 14 | public provideCompletionItems = (document: vsc.TextDocument, position: vsc.Position, _token: vsc.CancellationToken): vsc.CompletionItem[] => { 15 | const completionInfo = this.htmlDocumentHelper.prepareElementAttributeCompletion(document, position); 16 | 17 | if (completionInfo.inClosingTag) { 18 | return []; // we don't complete anything in closing tag 19 | } 20 | 21 | if (completionInfo.tag) { 22 | return this.directives.filter(d => d.isAttributeDirective).map(this.buildCompletionItem); 23 | } 24 | 25 | if (completionInfo.hasOpeningTagBefore) { 26 | return this.directives.filter(d => d.isElementDirective).map(d => this.buildCompletionItem(d)); 27 | } 28 | 29 | return []; 30 | } 31 | 32 | private buildCompletionItem = (d: Directive) => { 33 | const item = new vsc.CompletionItem(d.htmlName, vsc.CompletionItemKind.Interface); 34 | item.insertText = d.htmlName; 35 | item.detail = 'Directive'; 36 | item.label = d.htmlName; 37 | 38 | return item; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | { 3 | "version": "0.1.0", 4 | "configurations": [ 5 | { 6 | "type": "extensionHost", 7 | "request": "launch", 8 | "name": "Launch Extension", 9 | "runtimeExecutable": "${execPath}", 10 | "args": [ 11 | "--extensionDevelopmentPath=${workspaceRoot}" 12 | ], 13 | "sourceMaps": true, 14 | "outFiles": [ 15 | "${workspaceRoot}/out/**/*.js" 16 | ], 17 | "preLaunchTask": "npm" 18 | }, 19 | { 20 | "name": "Launch Tests", 21 | "type": "extensionHost", 22 | "request": "launch", 23 | "runtimeExecutable": "${execPath}", 24 | "args": ["--extensionDevelopmentPath=${workspaceRoot}", "--extensionTestsPath=${workspaceRoot}/out/test" ], 25 | "stopOnEntry": false, 26 | "sourceMaps": true, 27 | "env": { 28 | "NODE_ENV": "test" 29 | }, 30 | "outFiles": [ 31 | "${workspaceRoot}/out/**/*.js" 32 | ], 33 | "preLaunchTask": "npm" 34 | }, 35 | { 36 | "name": "Launch Tests with degugging (no coverage)", 37 | "type": "extensionHost", 38 | "request": "launch", 39 | "runtimeExecutable": "${execPath}", 40 | "args": ["--extensionDevelopmentPath=${workspaceRoot}", "--extensionTestsPath=${workspaceRoot}/out/test" ], 41 | "stopOnEntry": false, 42 | "sourceMaps": true, 43 | "env": { 44 | "NODE_ENV": "test", 45 | "NO_COVERAGE": "true" 46 | }, 47 | "outFiles": [ 48 | "${workspaceRoot}/out/**/*.js" 49 | ], 50 | "preLaunchTask": "npm" 51 | } 52 | ] 53 | } -------------------------------------------------------------------------------- /src/utils/controller/property.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | import * as vsc from 'vscode'; 3 | import { MemberType, MemberBase } from './member'; 4 | import { IComponentBinding } from '../component/component'; 5 | import { Controller } from './controller'; 6 | 7 | export class ClassProperty extends MemberBase { 8 | public name: string; 9 | public readonly type: MemberType = MemberType.Property; 10 | 11 | private constructor(controller: Controller) { 12 | super(controller); 13 | } 14 | 15 | public static fromProperty(controller: Controller, node: ts.PropertyDeclaration | ts.GetAccessorDeclaration, sourceFile: ts.SourceFile) { 16 | const result = new ClassProperty(controller); 17 | result.fillCommonFields(node, sourceFile); 18 | 19 | return result; 20 | } 21 | 22 | public static fromConstructorParameter(controller: Controller, node: ts.ParameterDeclaration, sourceFile: ts.SourceFile) { 23 | const result = new ClassProperty(controller); 24 | result.fillCommonFields(node, sourceFile); 25 | 26 | return result; 27 | } 28 | 29 | public buildCompletionItem(bindings: IComponentBinding[]) { 30 | const item = this.createCompletionItem(); 31 | item.kind = vsc.CompletionItemKind.Field; 32 | item.documentation = 'Type: ' + this.returnType || 'any'; 33 | 34 | const binding = bindings.find(b => b.name === this.name); 35 | if (binding) { 36 | item.detail += `\r\nBinding: ${binding.type}`; 37 | item.kind = vsc.CompletionItemKind.Reference; 38 | } 39 | 40 | return item; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/utils/sourceFilesScanner.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import { SourceFile } from './sourceFile'; 3 | import * as prettyHrtime from 'pretty-hrtime'; 4 | import { log } from './logging'; 5 | import { findFiles, getConfiguration } from './vsc'; 6 | 7 | // tslint:disable:no-console 8 | export class SourceFilesScanner { 9 | public findFiles = (configKey: string, callbackFn: (src: SourceFile) => Promise, fileType: string) => { 10 | return new Promise(async (resolve, reject) => { 11 | let total = process.hrtime(); 12 | const config = getConfiguration(); 13 | const globs = config.get(configKey) as string[]; 14 | 15 | try { 16 | let globTime = process.hrtime(); 17 | const files = _.flatten(await Promise.all(globs.map(g => findFiles(g)))); 18 | globTime = process.hrtime(globTime); 19 | 20 | let parse = process.hrtime(); 21 | const sourceFiles = await Promise.all(files.map(SourceFile.parse)); 22 | parse = process.hrtime(parse); 23 | 24 | let analyze = process.hrtime(); 25 | const components = await Promise.all(sourceFiles.map(callbackFn)); 26 | const result = _.flatten(components); 27 | analyze = process.hrtime(analyze); 28 | 29 | total = process.hrtime(total); 30 | 31 | // tslint:disable-next-line:max-line-length 32 | log(`${fileType} stats [files=${files.length}, glob=${prettyHrtime(globTime)}, parse=${prettyHrtime(parse)}, analyze=${prettyHrtime(analyze)}, total=${prettyHrtime(total)}]`); 33 | 34 | resolve(result); 35 | } catch (e) { 36 | reject(e); 37 | } 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/utils/configParser.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | 3 | export class ConfigParser { 4 | private properties: {[index: string]: ts.Expression}; 5 | 6 | constructor(config: ts.ObjectLiteralExpression | ts.ClassDeclaration) { 7 | if (this.isClass(config)) { 8 | this.properties = config.members 9 | .filter(m => [ts.SyntaxKind.PropertyDeclaration, ts.SyntaxKind.Constructor].indexOf(m.kind) !== -1) 10 | .reduce((acc, member: ts.PropertyDeclaration | ts.ConstructorDeclaration) => { 11 | if (ts.isPropertyDeclaration(member)) { 12 | acc[(member.name as ts.Identifier).text] = member.initializer; 13 | } else if (ts.isConstructorDeclaration(member)) { 14 | member.body.statements 15 | .filter(m => m.kind === ts.SyntaxKind.ExpressionStatement) 16 | .forEach((m: ts.ExpressionStatement) => { 17 | if (ts.isBinaryExpression(m.expression) && ts.isPropertyAccessExpression(m.expression.left)) { 18 | acc[m.expression.left.name.getText()] = m.expression.right; 19 | } 20 | }); 21 | } 22 | return acc; 23 | }, {}); 24 | } else if (config.properties) { 25 | this.properties = config.properties 26 | .reduce((acc, member: ts.PropertyAssignment) => { 27 | acc[(member.name as ts.Identifier).text] = member.initializer; 28 | return acc; 29 | }, {}); 30 | } 31 | } 32 | 33 | public get = (name: string) => this.properties[name]; 34 | 35 | public entries = () => Object.entries(this.properties); 36 | 37 | private isClass(config: ts.ObjectLiteralExpression | ts.ClassDeclaration): config is ts.ClassDeclaration { 38 | return (config as ts.ClassDeclaration).members !== undefined; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/utils/fileWatcher.ts: -------------------------------------------------------------------------------- 1 | import * as vsc from 'vscode'; 2 | import { angularRoot } from './vsc'; 3 | import { logVerbose } from './logging'; 4 | 5 | export type CallbackFunc = (uri: vsc.Uri) => void; 6 | 7 | export class FileWatcher implements vsc.Disposable { 8 | private disposables: vsc.Disposable[] = []; 9 | 10 | private logChange = (uri: vsc.Uri) => logVerbose(`${this.type} change detected at ${uri.fsPath}`); 11 | private logAdd = (uri: vsc.Uri) => logVerbose(`New ${this.type} detected at ${uri.fsPath}`); 12 | private logDelete = (uri: vsc.Uri) => logVerbose(`${this.type} deletion detected at ${uri.fsPath}`); 13 | 14 | constructor(private type: string, globs: string[], onAdded: CallbackFunc, onChanged: CallbackFunc, onDeleted: CallbackFunc) { 15 | for (const glob of globs) { 16 | logVerbose(`Setting up ${type} watch for ${glob}`); 17 | 18 | const relativeGlob = new vsc.RelativePattern(angularRoot, glob); 19 | const watcher = vsc.workspace.createFileSystemWatcher(relativeGlob); 20 | 21 | watcher.onDidChange(this.loggedCallback(this.logChange, onChanged), undefined, this.disposables); 22 | watcher.onDidDelete(this.loggedCallback(this.logDelete, onDeleted), undefined, this.disposables); 23 | watcher.onDidCreate(this.loggedCallback(this.logAdd, onAdded), undefined, this.disposables); 24 | 25 | this.disposables.push(watcher); 26 | } 27 | } 28 | 29 | private loggedCallback = (logger: CallbackFunc, callback: CallbackFunc) => (uri: vsc.Uri) => { 30 | logger(uri); 31 | callback(uri); 32 | } 33 | 34 | public dispose() { 35 | if (this.disposables.length > 0) { 36 | this.disposables.forEach(d => d.dispose()); 37 | this.disposables = []; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/utils/controller/member.ts: -------------------------------------------------------------------------------- 1 | import * as vsc from 'vscode'; 2 | import * as ts from 'typescript'; 3 | import { IComponentBinding } from '../component/component'; 4 | import { Controller } from './controller'; 5 | 6 | export abstract class MemberBase implements IMember { 7 | public name: string; 8 | public type: MemberType; 9 | public returnType: string; 10 | public isPublic: boolean; 11 | public pos: ts.LineAndCharacter; 12 | 13 | constructor(public controller: Controller) { 14 | } 15 | 16 | protected fillCommonFields = (node: ts.PropertyDeclaration | ts.MethodDeclaration | ts.GetAccessorDeclaration | ts.ParameterDeclaration, sourceFile: ts.SourceFile) => { 17 | this.pos = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); 18 | this.name = (node.name).text; 19 | this.isPublic = node.modifiers === undefined || node.modifiers.some(modifier => modifier.kind === ts.SyntaxKind.PublicKeyword); 20 | this.setReturnType(node.type); 21 | } 22 | 23 | protected setReturnType = (type: ts.TypeNode) => { 24 | this.returnType = (type && type.getText()) || 'void'; 25 | } 26 | 27 | protected createCompletionItem = (): vsc.CompletionItem => { 28 | const item = new vsc.CompletionItem(this.name); 29 | item.detail = MemberType[this.type]; 30 | 31 | return item; 32 | } 33 | 34 | public abstract buildCompletionItem(bindings: IComponentBinding[]): vsc.CompletionItem; 35 | } 36 | 37 | export interface IMember { 38 | controller: Controller; 39 | 40 | name: string; 41 | type: MemberType; 42 | isPublic: boolean; 43 | pos: ts.LineAndCharacter; 44 | 45 | buildCompletionItem(bindings: IComponentBinding[]): vsc.CompletionItem; 46 | } 47 | 48 | export enum MemberType { 49 | Property, 50 | Method 51 | } 52 | -------------------------------------------------------------------------------- /test/test_files/angular/bower/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-seed", 3 | "private": true, 4 | "version": "0.0.0", 5 | "description": "A starter project for AngularJS", 6 | "repository": "https://github.com/angular/angular-seed", 7 | "license": "MIT", 8 | "devDependencies": { 9 | "bower": "^1.7.7", 10 | "http-server": "^0.9.0", 11 | "jasmine-core": "^2.4.1", 12 | "karma": "^0.13.22", 13 | "karma-chrome-launcher": "^0.2.3", 14 | "karma-firefox-launcher": "^0.1.7", 15 | "karma-jasmine": "^0.3.8", 16 | "karma-junit-reporter": "^0.4.1", 17 | "protractor": "^4.0.9" 18 | }, 19 | "scripts": { 20 | "postinstall": "bower install", 21 | 22 | "update-deps": "npm update", 23 | "postupdate-deps": "bower update", 24 | 25 | "prestart": "npm install", 26 | "start": "http-server -a localhost -p 8000 -c-1 ./app", 27 | 28 | "pretest": "npm install", 29 | "test": "karma start karma.conf.js", 30 | "test-single-run": "karma start karma.conf.js --single-run", 31 | 32 | "preupdate-webdriver": "npm install", 33 | "update-webdriver": "webdriver-manager update", 34 | 35 | "preprotractor": "npm run update-webdriver", 36 | "protractor": "protractor e2e-tests/protractor.conf.js", 37 | 38 | "update-index-async": "node -e \"var fs=require('fs'),indexFile='app/index-async.html',loaderFile='app/bower_components/angular-loader/angular-loader.min.js',loaderText=fs.readFileSync(loaderFile,'utf-8').split(/sourceMappingURL=angular-loader.min.js.map/).join('sourceMappingURL=bower_components/angular-loader/angular-loader.min.js.map'),indexText=fs.readFileSync(indexFile,'utf-8').split(/\\/\\/@@NG_LOADER_START@@[\\s\\S]*\\/\\/@@NG_LOADER_END@@/).join('//@@NG_LOADER_START@@\\n'+loaderText+' //@@NG_LOADER_END@@');fs.writeFileSync(indexFile,indexText);\"" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/utils/htmlTemplate/htmlTags.ts: -------------------------------------------------------------------------------- 1 | export default [ 2 | 'a', 3 | 'abbr', 4 | 'address', 5 | 'area', 6 | 'article', 7 | 'aside', 8 | 'audio', 9 | 'b', 10 | 'base', 11 | 'bdi', 12 | 'bdo', 13 | 'blockquote', 14 | 'body', 15 | 'br', 16 | 'button', 17 | 'canvas', 18 | 'caption', 19 | 'cite', 20 | 'code', 21 | 'col', 22 | 'colgroup', 23 | 'data', 24 | 'datalist', 25 | 'dd', 26 | 'del', 27 | 'details', 28 | 'dfn', 29 | 'dialog', 30 | 'div', 31 | 'dl', 32 | 'dt', 33 | 'em', 34 | 'embed', 35 | 'fieldset', 36 | 'figcaption', 37 | 'figure', 38 | 'footer', 39 | 'form', 40 | 'h1', 41 | 'h2', 42 | 'h3', 43 | 'h4', 44 | 'h5', 45 | 'h6', 46 | 'head', 47 | 'header', 48 | 'hr', 49 | 'html', 50 | 'i', 51 | 'iframe', 52 | 'img', 53 | 'input', 54 | 'ins', 55 | 'kbd', 56 | 'keygen', 57 | 'label', 58 | 'legend', 59 | 'li', 60 | 'link', 61 | 'main', 62 | 'map', 63 | 'mark', 64 | 'math', 65 | 'menu', 66 | 'menuitem', 67 | 'meta', 68 | 'meter', 69 | 'nav', 70 | 'noscript', 71 | 'object', 72 | 'ol', 73 | 'optgroup', 74 | 'option', 75 | 'output', 76 | 'p', 77 | 'param', 78 | 'picture', 79 | 'pre', 80 | 'progress', 81 | 'q', 82 | 'rb', 83 | 'rp', 84 | 'rt', 85 | 'rtc', 86 | 'ruby', 87 | 's', 88 | 'samp', 89 | 'script', 90 | 'section', 91 | 'select', 92 | 'small', 93 | 'source', 94 | 'span', 95 | 'strong', 96 | 'style', 97 | 'sub', 98 | 'summary', 99 | 'sup', 100 | 'svg', 101 | 'table', 102 | 'tbody', 103 | 'td', 104 | 'template', 105 | 'textarea', 106 | 'tfoot', 107 | 'th', 108 | 'thead', 109 | 'time', 110 | 'title', 111 | 'tr', 112 | 'track', 113 | 'u', 114 | 'ul', 115 | 'var', 116 | 'video', 117 | 'wbr' 118 | ]; 119 | -------------------------------------------------------------------------------- /src/providers/componentDefinitionProvider.ts: -------------------------------------------------------------------------------- 1 | import * as vsc from 'vscode'; 2 | 3 | import { Component } from '../utils/component/component'; 4 | import { HtmlDocumentHelper } from '../utils/htmlDocumentHelper'; 5 | import { getLocation } from '../utils/vsc'; 6 | 7 | export class ComponentDefinitionProvider implements vsc.DefinitionProvider { 8 | private components: Component[]; 9 | 10 | constructor(private htmlDocumentHelper: HtmlDocumentHelper, private getConfig: () => vsc.WorkspaceConfiguration) {} 11 | 12 | public loadComponents = (components: Component[]) => { 13 | this.components = components; 14 | } 15 | 16 | public provideDefinition(document: vsc.TextDocument, position: vsc.Position, _token: vsc.CancellationToken): vsc.Definition { 17 | const match = this.htmlDocumentHelper.parseAtPosition(document, position); 18 | 19 | if (match) { 20 | const component = this.components.find(c => c.htmlName === match.tag); 21 | if (component) { 22 | const binding = component.bindings.find(b => b.htmlName === match.word); 23 | if (binding) { 24 | return getLocation({ path: component.path, pos: binding.pos }); 25 | } 26 | 27 | if (match.word === component.htmlName) { 28 | const config = this.getConfig(); 29 | const componentParts = config.get('goToDefinition') as string[] || []; 30 | 31 | const results: vsc.Location[] = []; 32 | 33 | if (componentParts.some(p => p === 'component')) { 34 | results.push(getLocation(component)); 35 | } 36 | 37 | if (componentParts.some(p => p === 'template') && component.template) { 38 | results.push(getLocation(component.template)); 39 | } 40 | 41 | if (componentParts.some(p => p === 'controller') && component.controller) { 42 | results.push(getLocation(component.controller)); 43 | } 44 | 45 | return results; 46 | } 47 | } 48 | } 49 | 50 | return []; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/utils/controller/method.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | import * as vsc from 'vscode'; 3 | import { MemberType, MemberBase } from './member'; 4 | import { IComponentBinding } from '../component/component'; 5 | import { Controller } from './controller'; 6 | 7 | export class ClassMethod extends MemberBase { 8 | public name: string; 9 | public readonly type = MemberType.Method; 10 | public parameters: IParameter[] = []; 11 | 12 | private constructor(controller: Controller) { 13 | super(controller); 14 | } 15 | 16 | public static fromNode(controller: Controller, node: ts.PropertyDeclaration | ts.MethodDeclaration, sourceFile: ts.SourceFile) { 17 | const result = new ClassMethod(controller); 18 | result.fillCommonFields(node, sourceFile); 19 | 20 | if (isProperty(node)) { 21 | const initializer = node.initializer as ts.ArrowFunction; 22 | result.setReturnType(initializer.type); 23 | result.parameters = initializer.parameters.map(this.createParameter); 24 | } else { 25 | result.parameters = node.parameters.map(this.createParameter); 26 | } 27 | 28 | return result; 29 | } 30 | 31 | private static createParameter = (p: ts.ParameterDeclaration): IParameter => { 32 | return { 33 | name: p.name.getText(), 34 | type: p.type && p.type.getText() || 'any' 35 | }; 36 | } 37 | 38 | public buildCompletionItem(_bindings: IComponentBinding[]) { 39 | const item = this.createCompletionItem(); 40 | item.kind = vsc.CompletionItemKind.Function; 41 | item.documentation = `${this.name}(${this.parameters.map(p => p.name + ': ' + p.type).join(', ')}): ${this.returnType}`; 42 | 43 | return item; 44 | } 45 | } 46 | 47 | function isProperty(node: ts.PropertyDeclaration | ts.MethodDeclaration): node is ts.PropertyDeclaration { 48 | return (node as ts.PropertyDeclaration).initializer !== undefined; 49 | } 50 | 51 | interface IParameter { 52 | name: string; 53 | type: string; 54 | } 55 | -------------------------------------------------------------------------------- /src/utils/controllerHelper.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | import { IComponentBase } from './component/component'; 3 | import { Controller } from './controller/controller'; 4 | import { ConfigParser } from './configParser'; 5 | import { TypescriptParser } from './typescriptParser'; 6 | import _ = require('lodash'); 7 | import { ControllerParser } from './controller/controllerParser'; 8 | 9 | export class ControllerHelper { 10 | constructor(private controllers: Controller[]) { 11 | } 12 | 13 | public prepareController(component: IComponentBase, config: ConfigParser, importedFromParser?: TypescriptParser): boolean { 14 | const controllerNode = config.get('controller'); 15 | if (controllerNode) { 16 | if (ts.isStringLiteral(controllerNode)) { 17 | component.controllerName = controllerNode.text; 18 | component.controller = !_.isEmpty(this.controllers) && this.controllers.find(c => c.name === component.controllerName); 19 | } else if (ts.isIdentifier(controllerNode)) { 20 | component.controllerClassName = (controllerNode as ts.Identifier).text; 21 | 22 | const classDeclaration = importedFromParser && importedFromParser.getClassDefinition(controllerNode); 23 | if (classDeclaration) { 24 | const controllerParser = new ControllerParser(importedFromParser); 25 | const controller = controllerParser.parseControllerClass(classDeclaration); 26 | 27 | component.controller = controller; 28 | } else { 29 | component.controller = !_.isEmpty(this.controllers) && this.controllers.find(c => c.className === component.controllerClassName); 30 | } 31 | 32 | } 33 | } 34 | 35 | component.controllerAs = this.createControllerAlias(config.get('controllerAs')); 36 | 37 | return component.controller != null; 38 | } 39 | 40 | private createControllerAlias(node: ts.Expression): string { 41 | if (!node) { 42 | return '$ctrl'; 43 | } 44 | 45 | const value = node as ts.StringLiteral; 46 | return value.text; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/utils/component/component.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | import { SourceFile } from '../sourceFile'; 3 | import { Controller } from '../controller/controller'; 4 | import { ComponentParser } from './componentParser'; 5 | import { logParsingError } from '../logging'; 6 | import * as vsc from 'vscode'; 7 | import { ControllerHelper } from '../controllerHelper'; 8 | 9 | export class Component implements IComponentBase { 10 | public name: string; 11 | public htmlName: string; 12 | public bindings: IComponentBinding[] = []; 13 | public path: string; 14 | public pos: ts.LineAndCharacter; 15 | 16 | public template: IComponentTemplate; 17 | public controller: Controller; 18 | public controllerAs: string; 19 | public controllerName: string; 20 | public controllerClassName: string; 21 | 22 | public getBindings = () => this.bindings; 23 | 24 | public static parse(file: SourceFile, controllers: Controller[]): Promise { 25 | return new Promise(async (resolve, _reject) => { 26 | try { 27 | const controllerHelper = new ControllerHelper(controllers); 28 | const parser = new ComponentParser(file, controllerHelper); 29 | const results: Component[] = await parser.parse(); 30 | 31 | resolve(results); 32 | } catch (e) { 33 | logParsingError(file.path, e); 34 | resolve([]); 35 | } 36 | }); 37 | } 38 | } 39 | 40 | export interface IComponentBase { 41 | path: string; 42 | 43 | template: IComponentTemplate; 44 | 45 | controller: Controller; 46 | controllerAs: string; 47 | controllerName: string; 48 | controllerClassName: string; 49 | 50 | getBindings(): IComponentBinding[]; 51 | } 52 | 53 | export interface IComponentTemplate { 54 | path: string; 55 | pos: ts.LineAndCharacter; 56 | body?: string; // used only for inline templates 57 | } 58 | 59 | export interface IComponentBinding { 60 | name: string; 61 | htmlName: string; 62 | type: string; 63 | pos: ts.LineAndCharacter; 64 | 65 | buildCompletionItem(): vsc.CompletionItem; 66 | } 67 | -------------------------------------------------------------------------------- /src/utils/logging.ts: -------------------------------------------------------------------------------- 1 | import * as vsc from 'vscode'; 2 | import * as path from 'path'; 3 | import * as fs from 'fs'; 4 | import * as os from 'os'; 5 | import { angularRoot } from './vsc'; 6 | 7 | export function logParsingError(fullpath: string, err: Error) { 8 | const relativePath = '.' + path.sep + path.relative(angularRoot || '', fullpath); 9 | 10 | // tslint:disable-next-line:no-console 11 | console.error(`[ngComponents] There was an error analyzing ${relativePath}. 12 | Please report this as a bug and include failing file if possible (remove or change sensitive data). 13 | 14 | ${err.message} 15 | Stack trace: 16 | ${err.stack}`.trim()); 17 | } 18 | 19 | let lastError = false; 20 | 21 | export function logError(error: string | Error, prefix?: string) { 22 | if (error instanceof Error) { 23 | log(`⚠️${prefix}${error.message}\nStack trace:\n${error.stack}`, console.error); 24 | } else { 25 | log(`⚠️${error}`, console.error); 26 | } 27 | } 28 | 29 | export function logWarning(text: string) { 30 | log(`⚠️${text}`, console.warn); 31 | } 32 | 33 | export function log(text: string, logFunction?: (message?: any) => void) { 34 | const logPath = redirectToFile(); 35 | if (logPath) { 36 | fs.appendFile(logPath, text + os.EOL, err => { 37 | if (err) { 38 | if (!lastError) { 39 | // tslint:disable-next-line:no-console 40 | console.error('Error while logging to file: ' + err); 41 | lastError = true; 42 | } 43 | } else { 44 | lastError = false; 45 | } 46 | }); 47 | } else { 48 | (logFunction || console.log)(`[ngComponents] ${text}`); 49 | } 50 | } 51 | 52 | export function logVerbose(text: string) { 53 | if (isVerboseLogging()) { 54 | log(text); 55 | } 56 | } 57 | 58 | function isVerboseLogging() { 59 | const config = vsc.workspace.getConfiguration('ngComponents.logging'); 60 | return config.get('verbose', false) as boolean; 61 | } 62 | 63 | function redirectToFile() { 64 | const config = vsc.workspace.getConfiguration('ngComponents.logging'); 65 | return config.get('redirectToFile') as string; 66 | } 67 | -------------------------------------------------------------------------------- /src/commands/switchComponentParts.ts: -------------------------------------------------------------------------------- 1 | import * as vsc from 'vscode'; 2 | import { Component } from '../utils/component/component'; 3 | import { Commands } from './commands'; 4 | 5 | export class SwitchComponentPartsCommand implements vsc.Disposable { 6 | private disposable: vsc.Disposable; 7 | 8 | constructor(getComponents: () => Component[]) { 9 | this.disposable = vsc.commands.registerCommand(Commands.SwitchComponentParts, () => this.execute(getComponents())); 10 | } 11 | 12 | public execute = async (components: Component[]) => { 13 | if (!vsc.window.activeTextEditor || vsc.window.activeTextEditor.document.uri.scheme !== 'file') { 14 | return; 15 | } 16 | 17 | const uri = vsc.window.activeTextEditor.document.uri; 18 | const component = components.find(c => this.isComponentUri(c, uri) || this.isControllerUri(c, uri) || this.isTemplateUri(c, uri)); 19 | 20 | if (component) { 21 | const path = this.getNextPartPath(component, uri); 22 | if (path) { 23 | const document = await vsc.workspace.openTextDocument(path); 24 | await vsc.window.showTextDocument(document); 25 | } 26 | } 27 | } 28 | 29 | private getNextPartPath = (component: Component, uri: vsc.Uri): string => { 30 | if (this.isComponentUri(component, uri)) { 31 | return component.controller && component.controller.path || component.template && component.template.path; 32 | } 33 | 34 | if (this.isControllerUri(component, uri)) { 35 | return component.template && component.template.path || component.path; 36 | } 37 | 38 | if (this.isTemplateUri(component, uri)) { 39 | return component.path; 40 | } 41 | } 42 | 43 | private isComponentUri = (component: Component, uri: vsc.Uri): boolean => component.path === uri.fsPath; 44 | private isControllerUri = (component: Component, uri: vsc.Uri): boolean => component.controller && component.controller.path === uri.fsPath; 45 | private isTemplateUri = (component: Component, uri: vsc.Uri): boolean => component.template && component.template.path === uri.fsPath; 46 | 47 | public dispose() { 48 | this.disposable && this.disposable.dispose(); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/providers/memberCompletionProvider.ts: -------------------------------------------------------------------------------- 1 | import * as vsc from 'vscode'; 2 | import { IComponentBase } from '../utils/component/component'; 3 | import * as _ from 'lodash'; 4 | import { RelativePath } from '../utils/htmlTemplate/relativePath'; 5 | 6 | export class MemberCompletionProvider implements vsc.CompletionItemProvider { 7 | private components = new Map(); 8 | 9 | constructor(private getConfig: () => vsc.WorkspaceConfiguration) { 10 | } 11 | 12 | public loadComponents = (components: IComponentBase[]) => { 13 | this.components = new Map( 14 | components.filter(c => c.template).map(c => <[string, IComponentBase]>[new RelativePath(c.template.path).relativeLowercase, c]) 15 | ); 16 | } 17 | 18 | public provideCompletionItems = (document: vsc.TextDocument, position: vsc.Position, _token: vsc.CancellationToken): vsc.CompletionItem[] => { 19 | const relativePath = RelativePath.fromUri(document.uri); 20 | const component = this.components.get(relativePath.relativeLowercase); 21 | 22 | if (!component) { 23 | return []; 24 | } 25 | 26 | const line = document.lineAt(position.line).text; 27 | const dotIdx = line.lastIndexOf('.', position.character); 28 | const charsBetweenTheDotAndTheCursor = line.substring(dotIdx + 1, position.character); 29 | const viewModelName = line.substring(dotIdx - component.controllerAs.length, dotIdx); 30 | 31 | if (viewModelName === component.controllerAs && /^([a-z]+)?$/i.test(charsBetweenTheDotAndTheCursor)) { 32 | const config = this.getConfig(); 33 | const publicOnly = config.get('controller.publicMembersOnly'); 34 | const excludedMembers = new RegExp(config.get('controller.excludedMembers')); 35 | 36 | const members = component.controller && component.controller.getMembers(publicOnly).filter(m => !excludedMembers.test(m.name)) || []; 37 | const bindings = component.getBindings(); 38 | 39 | return _.uniqBy([ 40 | ...members.map(member => member.buildCompletionItem(bindings)), 41 | ...bindings.map(b => b.buildCompletionItem()) 42 | ], item => item.label); 43 | } 44 | 45 | return [new vsc.CompletionItem(component.controllerAs, vsc.CompletionItemKind.Field)]; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /test/test_files/angular/jspm1/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ts-skeleton", 3 | "description": "Skeleton app with typescript", 4 | "repository": { 5 | "type": "git", 6 | "url": "git@github.com:b091/ts-skeleton.git" 7 | }, 8 | "version": "0.4.0", 9 | "license": "MIT", 10 | "dependencies": {}, 11 | "devDependencies": { 12 | "browser-sync": "~2.16.0", 13 | "chai": "~3.5.0", 14 | "del": "~2.2.2", 15 | "eslint": "~3.5.0", 16 | "gulp": "~3.9.1", 17 | "gulp-directive-replace": "git+https://github.com/b091/gulp-directive-replace.git", 18 | "gulp-eslint": "~3.0.1", 19 | "gulp-ng-annotate": "~2.0.0", 20 | "gulp-protractor": "~3.0.0", 21 | "gulp-tslint": "~6.1.1", 22 | "gulp-typedoc": "~2.0.0", 23 | "jspm": "~0.16.45", 24 | "karma": "~1.3.0", 25 | "karma-chai-sinon": "~0.1.5", 26 | "karma-coverage": "~1.1.1", 27 | "karma-jspm": "~2.2.0", 28 | "karma-junit-reporter": "~1.1.0", 29 | "karma-mocha": "~1.1.1", 30 | "karma-mocha-reporter": "^2.2.0", 31 | "karma-phantomjs-launcher": "~1.0.2", 32 | "mocha": "~3.0.2", 33 | "phantomjs-prebuilt": "~2.1.12", 34 | "protractor": "~4.0.8", 35 | "run-sequence": "~1.2.2", 36 | "sinon": "~1.17.6", 37 | "sinon-chai": "~2.8.0", 38 | "tslint": "~3.15.1", 39 | "typedoc": "^0.4.5", 40 | "typescript": "^1.8.10", 41 | "typings": "~1.3.3" 42 | }, 43 | "engines": { 44 | "node": ">=5.12.0" 45 | }, 46 | "scripts": { 47 | "test": "gulp test", 48 | "postinstall": "jspm install && typings install && webdriver-manager update" 49 | }, 50 | "jspm": { 51 | "directories": { 52 | "packages": "vendor/jspm_packages" 53 | }, 54 | "configFile": "jspm.conf.js", 55 | "dependencies": { 56 | "angular": "github:angular/bower-angular@^1.5.3", 57 | "angular-toastr": "github:Foxandxss/angular-toastr@^1.7.0", 58 | "angular-ui-router": "github:angular-ui/ui-router@^0.2.18", 59 | "bootstrap": "github:twbs/bootstrap@^3.3.6" 60 | }, 61 | "devDependencies": { 62 | "angular-mocks": "github:angular/bower-angular-mocks@^1.5.3", 63 | "clean-css": "npm:clean-css@^3.4.10", 64 | "css": "github:systemjs/plugin-css@^0.1.20", 65 | "ts": "github:frankwallis/plugin-typescript@4.0.5", 66 | "typescript": "npm:typescript@1.8.9" 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /test/test_files/angular/webpack/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ng6-starter", 3 | "version": "0.0.1", 4 | "description": "Starter for Angular + ES6 + (Webpack or JSPM)", 5 | "main": "index.js", 6 | "dependencies": { 7 | "angular": "^1.5.0", 8 | "angular-ui-router": "^1.0.0-beta.1", 9 | "normalize.css": "^3.0.3" 10 | }, 11 | "devDependencies": { 12 | "angular-mocks": "^1.5.0", 13 | "babel-core": "^6.7.7", 14 | "babel-loader": "^6.2.4", 15 | "babel-plugin-transform-runtime": "^6.7.5", 16 | "babel-polyfill": "^6.7.4", 17 | "babel-preset-es2015": "^6.6.0", 18 | "babel-preset-stage-0": "^6.5.0", 19 | "babel-register": "^6.7.2", 20 | "babel-runtime": "^6.6.1", 21 | "browser-sync": "^2.11.1", 22 | "chai": "^3.4.0", 23 | "connect-history-api-fallback": "^1.1.0", 24 | "css-loader": "^0.19.0", 25 | "del": "^2.2.0", 26 | "fs-walk": "0.0.1", 27 | "gulp": "^3.9.1", 28 | "gulp-rename": "^1.2.2", 29 | "gulp-template": "^3.0.0", 30 | "gulp-util": "^3.0.7", 31 | "html-webpack-plugin": "^1.7.0", 32 | "karma": "^0.13.22", 33 | "karma-chai": "^0.1.0", 34 | "karma-chrome-launcher": "^0.2.0", 35 | "karma-mocha": "^0.2.0", 36 | "karma-mocha-reporter": "^1.0.2", 37 | "karma-sourcemap-loader": "^0.3.4", 38 | "karma-webpack": "^1.5.1", 39 | "lodash": "^4.11.1", 40 | "mocha": "^2.3.0", 41 | "ng-annotate-loader": "0.0.10", 42 | "node-libs-browser": "^0.5.0", 43 | "node-sass": "^3.13.0", 44 | "raw-loader": "^0.5.1", 45 | "run-sequence": "^1.1.0", 46 | "sass-loader": "^4.0.2", 47 | "style-loader": "^0.12.2", 48 | "supports-color": "^3.1.2", 49 | "webpack": "^1.13.3", 50 | "webpack-dev-middleware": "^1.6.1", 51 | "webpack-hot-middleware": "^2.6.0", 52 | "yargs": "^3.9.0" 53 | }, 54 | "scripts": { 55 | "build": "gulp webpack", 56 | "component": "gulp component", 57 | "serve": "gulp serve", 58 | "start": "gulp serve", 59 | "test": "karma start", 60 | "watch": "gulp serve", 61 | "webpack": "gulp webpack" 62 | }, 63 | "keywords": [ 64 | "angular", 65 | "webpack", 66 | "es6" 67 | ], 68 | "repository": { 69 | "type": "git", 70 | "url": "https://github.com/angularclass/NG6-starter.git" 71 | }, 72 | "author": "AngularClass", 73 | "license": "Apache-2.0", 74 | "babel": { 75 | "plugins": [ 76 | "transform-runtime" 77 | ], 78 | "presets": [ 79 | "es2015", 80 | "stage-0" 81 | ] 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /test/test_files/angular/angular_version_too_low/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ng6-starter", 3 | "version": "0.0.1", 4 | "description": "Starter for Angular + ES6 + (Webpack or JSPM)", 5 | "main": "index.js", 6 | "dependencies": { 7 | "angular": "1.4.9", 8 | "angular-ui-router": "^1.0.0-beta.1", 9 | "normalize.css": "^3.0.3" 10 | }, 11 | "devDependencies": { 12 | "angular-mocks": "^1.5.0", 13 | "babel-core": "^6.7.7", 14 | "babel-loader": "^6.2.4", 15 | "babel-plugin-transform-runtime": "^6.7.5", 16 | "babel-polyfill": "^6.7.4", 17 | "babel-preset-es2015": "^6.6.0", 18 | "babel-preset-stage-0": "^6.5.0", 19 | "babel-register": "^6.7.2", 20 | "babel-runtime": "^6.6.1", 21 | "browser-sync": "^2.11.1", 22 | "chai": "^3.4.0", 23 | "connect-history-api-fallback": "^1.1.0", 24 | "css-loader": "^0.19.0", 25 | "del": "^2.2.0", 26 | "fs-walk": "0.0.1", 27 | "gulp": "^3.9.1", 28 | "gulp-rename": "^1.2.2", 29 | "gulp-template": "^3.0.0", 30 | "gulp-util": "^3.0.7", 31 | "html-webpack-plugin": "^1.7.0", 32 | "karma": "^0.13.22", 33 | "karma-chai": "^0.1.0", 34 | "karma-chrome-launcher": "^0.2.0", 35 | "karma-mocha": "^0.2.0", 36 | "karma-mocha-reporter": "^1.0.2", 37 | "karma-sourcemap-loader": "^0.3.4", 38 | "karma-webpack": "^1.5.1", 39 | "lodash": "^4.11.1", 40 | "mocha": "^2.3.0", 41 | "ng-annotate-loader": "0.0.10", 42 | "node-libs-browser": "^0.5.0", 43 | "node-sass": "^3.13.0", 44 | "raw-loader": "^0.5.1", 45 | "run-sequence": "^1.1.0", 46 | "sass-loader": "^4.0.2", 47 | "style-loader": "^0.12.2", 48 | "supports-color": "^3.1.2", 49 | "webpack": "^1.13.3", 50 | "webpack-dev-middleware": "^1.6.1", 51 | "webpack-hot-middleware": "^2.6.0", 52 | "yargs": "^3.9.0" 53 | }, 54 | "scripts": { 55 | "build": "gulp webpack", 56 | "component": "gulp component", 57 | "serve": "gulp serve", 58 | "start": "gulp serve", 59 | "test": "karma start", 60 | "watch": "gulp serve", 61 | "webpack": "gulp webpack" 62 | }, 63 | "keywords": [ 64 | "angular", 65 | "webpack", 66 | "es6" 67 | ], 68 | "repository": { 69 | "type": "git", 70 | "url": "https://github.com/angularclass/NG6-starter.git" 71 | }, 72 | "author": "AngularClass", 73 | "license": "Apache-2.0", 74 | "babel": { 75 | "plugins": [ 76 | "transform-runtime" 77 | ], 78 | "presets": [ 79 | "es2015", 80 | "stage-0" 81 | ] 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/utils/htmlTemplate/htmlTemplateInfoResult.ts: -------------------------------------------------------------------------------- 1 | import _ = require('lodash'); 2 | import * as ts from 'typescript'; 3 | import * as parse5 from 'parse5'; 4 | 5 | import { IHtmlTemplateInfoResults, IHtmlReferences, ITemplateInfo } from './types'; 6 | import { IMemberAccessEntry } from './streams/memberAccessParser'; 7 | 8 | export class HtmlTemplateInfoResults implements IHtmlTemplateInfoResults { 9 | public htmlReferences: IHtmlReferences = {}; 10 | public directiveReferences: IHtmlReferences = {}; 11 | public templateInfo: ITemplateInfo = {}; 12 | 13 | public addHtmlReference = (componentHtmlName: string, relativeHtmlPath: string, location: ts.LineAndCharacter) => { 14 | this.htmlReferences[componentHtmlName] = this.htmlReferences[componentHtmlName] || []; 15 | this.htmlReferences[componentHtmlName].push({ 16 | relativeHtmlPath, 17 | ...location 18 | }); 19 | } 20 | 21 | public addDirectiveReference = (directiveHtmlName: string, relativeHtmlPath: string, location: ts.LineAndCharacter) => { 22 | this.directiveReferences[directiveHtmlName] = this.directiveReferences[directiveHtmlName] || []; 23 | this.directiveReferences[directiveHtmlName].push({ 24 | relativeHtmlPath, 25 | ...location 26 | }); 27 | } 28 | 29 | public addFormName = (relativeHtmlPath: string, formName: string, location: parse5.MarkupData.Location) => { 30 | this.initTemplateInfo(relativeHtmlPath); 31 | this.templateInfo[relativeHtmlPath].forms.push({ 32 | name: formName, 33 | line: location.line - 1, 34 | character: location.col - 1 35 | }); 36 | } 37 | 38 | public addMemberAccess = (relativeHtmlPath: string, memberAccess: IMemberAccessEntry) => { 39 | this.initTemplateInfo(relativeHtmlPath); 40 | this.templateInfo[relativeHtmlPath].memberAccess.push(memberAccess); 41 | } 42 | 43 | public deleteTemplate = (relativePath: string) => { 44 | const emptyKeys = []; 45 | 46 | _.forIn(this.htmlReferences, (value, key) => { 47 | delete value[relativePath]; 48 | 49 | if (_.isEmpty(value)) { 50 | emptyKeys.push(key); 51 | } 52 | }); 53 | 54 | delete this.templateInfo[relativePath]; 55 | 56 | emptyKeys.forEach(key => delete this.htmlReferences[key]); 57 | } 58 | 59 | private initTemplateInfo(relativeHtmlPath: string) { 60 | this.templateInfo[relativeHtmlPath] = this.templateInfo[relativeHtmlPath] || { 61 | forms: [], 62 | memberAccess: [] 63 | }; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /test/test_files/angular/angular_version_too_high/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ng6-starter", 3 | "version": "0.0.1", 4 | "description": "Starter for Angular + ES6 + (Webpack or JSPM)", 5 | "main": "index.js", 6 | "dependencies": { 7 | "angular": "^2.0.0", 8 | "angular-ui-router": "^1.0.0-beta.1", 9 | "normalize.css": "^3.0.3" 10 | }, 11 | "devDependencies": { 12 | "angular-mocks": "^1.5.0", 13 | "babel-core": "^6.7.7", 14 | "babel-loader": "^6.2.4", 15 | "babel-plugin-transform-runtime": "^6.7.5", 16 | "babel-polyfill": "^6.7.4", 17 | "babel-preset-es2015": "^6.6.0", 18 | "babel-preset-stage-0": "^6.5.0", 19 | "babel-register": "^6.7.2", 20 | "babel-runtime": "^6.6.1", 21 | "browser-sync": "^2.11.1", 22 | "chai": "^3.4.0", 23 | "connect-history-api-fallback": "^1.1.0", 24 | "css-loader": "^0.19.0", 25 | "del": "^2.2.0", 26 | "fs-walk": "0.0.1", 27 | "gulp": "^3.9.1", 28 | "gulp-rename": "^1.2.2", 29 | "gulp-template": "^3.0.0", 30 | "gulp-util": "^3.0.7", 31 | "html-webpack-plugin": "^1.7.0", 32 | "karma": "^0.13.22", 33 | "karma-chai": "^0.1.0", 34 | "karma-chrome-launcher": "^0.2.0", 35 | "karma-mocha": "^0.2.0", 36 | "karma-mocha-reporter": "^1.0.2", 37 | "karma-sourcemap-loader": "^0.3.4", 38 | "karma-webpack": "^1.5.1", 39 | "lodash": "^4.11.1", 40 | "mocha": "^2.3.0", 41 | "ng-annotate-loader": "0.0.10", 42 | "node-libs-browser": "^0.5.0", 43 | "node-sass": "^3.13.0", 44 | "raw-loader": "^0.5.1", 45 | "run-sequence": "^1.1.0", 46 | "sass-loader": "^4.0.2", 47 | "style-loader": "^0.12.2", 48 | "supports-color": "^3.1.2", 49 | "webpack": "^1.13.3", 50 | "webpack-dev-middleware": "^1.6.1", 51 | "webpack-hot-middleware": "^2.6.0", 52 | "yargs": "^3.9.0" 53 | }, 54 | "scripts": { 55 | "build": "gulp webpack", 56 | "component": "gulp component", 57 | "serve": "gulp serve", 58 | "start": "gulp serve", 59 | "test": "karma start", 60 | "watch": "gulp serve", 61 | "webpack": "gulp webpack" 62 | }, 63 | "keywords": [ 64 | "angular", 65 | "webpack", 66 | "es6" 67 | ], 68 | "repository": { 69 | "type": "git", 70 | "url": "https://github.com/angularclass/NG6-starter.git" 71 | }, 72 | "author": "AngularClass", 73 | "license": "Apache-2.0", 74 | "babel": { 75 | "plugins": [ 76 | "transform-runtime" 77 | ], 78 | "presets": [ 79 | "es2015", 80 | "stage-0" 81 | ] 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /test/utils/typescriptParser.test.ts: -------------------------------------------------------------------------------- 1 | import { getTestSourceFile } from './helpers'; 2 | import { TypescriptParser } from '../../src/utils/typescriptParser'; 3 | import should = require('should'); 4 | 5 | describe('Given TypescriptParser', () => { 6 | describe('when calling getExportedVariable()', () => { 7 | it('and variable with that name is not exported then undefined is returned', () => { 8 | const sourceFile = getTestSourceFile(`export let variable = 'value';`); 9 | const parser = new TypescriptParser(sourceFile); 10 | 11 | const result = parser.getExportedVariable('other_name'); 12 | 13 | should(result).be.undefined(); 14 | }); 15 | 16 | it('and variable is aliased as default then the variable declaration is returned', () => { 17 | const sourceFile = getTestSourceFile(`const x = 'test'; export { x as default };`); 18 | const parser = new TypescriptParser(sourceFile); 19 | 20 | const result = parser.getExportedVariable('not_found'); 21 | 22 | should(result).be.undefined(); 23 | }); 24 | 25 | ['var', 'let', 'const'].forEach(item => { 26 | it(`and '${item}' is exported then variable declaration is returned`, () => { 27 | const sourceFile = getTestSourceFile(`export ${item} variable = 'value';`); 28 | const parser = new TypescriptParser(sourceFile); 29 | 30 | const result = parser.getExportedVariable('variable'); 31 | 32 | should(result).not.be.undefined(); 33 | should(result.getText()).be.equal(`variable = 'value'`); 34 | }); 35 | 36 | it(`and '${item}' is exported via default export then variable declaration is returned`, () => { 37 | const sourceFile = getTestSourceFile(`${item} variable = 'value'; 38 | export default variable;`); 39 | const parser = new TypescriptParser(sourceFile); 40 | 41 | const result = parser.getExportedVariable('variable'); 42 | 43 | should(result).not.be.undefined(); 44 | should(result.getText()).be.equal(`variable = 'value'`); 45 | }); 46 | 47 | it(`and '${item}' is exported via default export and no matching variable exists, then default is returned`, () => { 48 | const sourceFile = getTestSourceFile(`${item} variable = 'value'; 49 | export default variable;`); 50 | const parser = new TypescriptParser(sourceFile); 51 | 52 | const result = parser.getExportedVariable('nomatch'); 53 | 54 | should(result).not.be.undefined(); 55 | should(result.getText()).be.equal(`variable = 'value'`); 56 | }); 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/providers/componentCompletionProvider.ts: -------------------------------------------------------------------------------- 1 | import * as vsc from 'vscode'; 2 | import * as _ from 'lodash'; 3 | import { Component } from '../utils/component/component'; 4 | import { HtmlDocumentHelper } from '../utils/htmlDocumentHelper'; 5 | 6 | export class ComponentCompletionProvider implements vsc.CompletionItemProvider { 7 | private components: Component[]; 8 | 9 | constructor(private htmlDocumentHelper: HtmlDocumentHelper) { } 10 | 11 | public loadComponents = (components: Component[]) => { 12 | this.components = components; 13 | } 14 | 15 | public provideCompletionItems = (document: vsc.TextDocument, position: vsc.Position, _token: vsc.CancellationToken): vsc.CompletionItem[] => { 16 | const completionInfo = this.htmlDocumentHelper.prepareElementAttributeCompletion(document, position); 17 | 18 | if (completionInfo.inClosingTag) { 19 | return []; // we don't complete anything in closing tag 20 | } 21 | 22 | if (completionInfo.tag) { 23 | const component = this.components.find(c => c.htmlName === completionInfo.tag); 24 | if (component) { 25 | return this.provideAttributeCompletions(component, completionInfo.attributes); 26 | } 27 | 28 | return []; 29 | } 30 | 31 | return this.provideTagCompletions(completionInfo.hasOpeningTagBefore, position); 32 | } 33 | 34 | private provideAttributeCompletions = (component: Component, existingAttributes: string[]): vsc.CompletionItem[] => { 35 | const attributes = _(existingAttributes); 36 | 37 | return component.bindings 38 | .filter(b => !attributes.includes(b.htmlName)) 39 | .map(b => { 40 | const item = new vsc.CompletionItem(b.htmlName, vsc.CompletionItemKind.Field); 41 | item.insertText = new vsc.SnippetString(`${b.htmlName}="$1"$0`); 42 | item.detail = 'Component binding'; 43 | item.documentation = `Binding type: ${b.type}`; 44 | item.label = ` ${b.htmlName}`; // space at the beginning so that these bindings are first on the list 45 | 46 | return item; 47 | }); 48 | } 49 | 50 | private provideTagCompletions = (hasOpeningTagBefore: boolean, position: vsc.Position): vsc.CompletionItem[] => { 51 | return this.components.map(c => { 52 | const bindings = c.bindings.map(b => `${b.htmlName}=""`).join(' '); 53 | 54 | const item = new vsc.CompletionItem(c.htmlName, vsc.CompletionItemKind.Class); 55 | item.insertText = `<${c.htmlName} ${bindings.trim()}>`; 56 | 57 | if (c.bindings.length > 0) { 58 | item.documentation = 'Component bindings:\n' 59 | + c.bindings.map(b => ` ${b.htmlName}: ${b.type}`).join('\n'); 60 | } 61 | 62 | item.additionalTextEdits = []; 63 | 64 | if (hasOpeningTagBefore) { 65 | item.additionalTextEdits.push(vsc.TextEdit.delete(new vsc.Range(position.translate(0, -1), position))); 66 | } 67 | 68 | return item; 69 | }); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/providers/referencesProvider.ts: -------------------------------------------------------------------------------- 1 | import * as vsc from 'vscode'; 2 | import { HtmlDocumentHelper } from '../utils/htmlDocumentHelper'; 3 | import { IHtmlReferences } from '../utils/htmlTemplate/types'; 4 | import { convertHtmlReferencesToLocations } from '../utils/vsc'; 5 | import { Component } from '../utils/component/component'; 6 | type DocumentHandlerDelegate = (document: vsc.TextDocument, position: vsc.Position) => vsc.Location[]; 7 | 8 | export class ReferencesProvider implements vsc.ReferenceProvider { 9 | 10 | private htmlReferences: IHtmlReferences; 11 | private components: Component[]; 12 | private documentHandlers: Map; 13 | 14 | constructor(private htmlDocumentHelper: HtmlDocumentHelper) { 15 | this.documentHandlers = new Map([ 16 | ['html', this.provideHtmlReferences], 17 | ['typescript', this.provideControllerReferences] 18 | ]); 19 | } 20 | 21 | public load = (references: IHtmlReferences, components: Component[]) => { 22 | this.htmlReferences = references; 23 | this.components = components; 24 | } 25 | 26 | // tslint:disable-next-line:member-access 27 | provideReferences(document: vsc.TextDocument, position: vsc.Position, _context: vsc.ReferenceContext, _token: vsc.CancellationToken): vsc.Location[] { 28 | const handler = this.documentHandlers.get(document.languageId); 29 | if (handler) { 30 | return handler(document, position); 31 | } 32 | 33 | return []; 34 | } 35 | 36 | private provideControllerReferences = (document: vsc.TextDocument, position: vsc.Position): vsc.Location[] => { 37 | const wordPos = document.getWordRangeAtPosition(position); 38 | const word = document.getText(wordPos); 39 | 40 | const component = this.components.find(c => (c.controller && c.controller.className === word) || c.name === word); 41 | if (component) { 42 | const references = this.htmlReferences[component.htmlName]; 43 | if (references) { 44 | return convertHtmlReferencesToLocations(references); 45 | } 46 | } 47 | 48 | return []; 49 | } 50 | 51 | private provideHtmlReferences = (document: vsc.TextDocument, position: vsc.Position): vsc.Location[] => { 52 | const bracketsBeforeCursor = this.htmlDocumentHelper.findTagBrackets(document, position, 'backward'); 53 | const bracketsAfterCursor = this.htmlDocumentHelper.findTagBrackets(document, position, 'forward'); 54 | 55 | if (this.htmlDocumentHelper.isInsideAClosedTag(bracketsBeforeCursor, bracketsAfterCursor)) { 56 | const wordPos = document.getWordRangeAtPosition(position); 57 | const componentName = document.getText(wordPos); 58 | 59 | const componentReferences = this.htmlReferences[componentName]; 60 | if (componentReferences) { 61 | return convertHtmlReferencesToLocations(componentReferences); 62 | } 63 | } 64 | 65 | return []; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /test/utils/route.test.ts: -------------------------------------------------------------------------------- 1 | import { getRouteSourceFile } from './helpers'; 2 | import { Route } from '../../src/utils/route/route'; 3 | import * as assert from 'assert'; 4 | import * as path from 'path'; 5 | import { mockRoot } from '../../src/utils/vsc'; 6 | 7 | describe('Given Route class', () => { 8 | describe('when calling parse()', () => { 9 | it('on route with views then templates are set correctly', async () => { 10 | mockRoot('testRoot'); 11 | const sourceFile = getRouteSourceFile('route.views_template.ts'); 12 | 13 | const [route] = await Route.parse(sourceFile, []); 14 | 15 | assert.equal(route.path, sourceFile.path); 16 | assert.equal((route.views[0] as Route).template.body, ''); 17 | assert.equal((route.views[1] as Route).template.body, 'inline-template'); 18 | assert.equal((route.views[2] as Route).template.path, path.normalize('testRoot/subdir/template.html')); 19 | assert.equal((route.views[3] as Route).template.body, ''); 20 | }); 21 | 22 | it('on route with inline template then template is set correctly', async () => { 23 | const sourceFile = getRouteSourceFile('route.inline_template.ts'); 24 | 25 | const [route] = await Route.parse(sourceFile, []); 26 | 27 | assert.equal(route.path, sourceFile.path); 28 | assert.equal(route.name, 'example_route'); 29 | assert.equal(route.template.body, 'Route inline template'); 30 | assert.equal(route.template.path, sourceFile.path); 31 | }); 32 | 33 | it('on route with component and no template then template is set correctly', async () => { 34 | const sourceFile = getRouteSourceFile('route.component_template.ts'); 35 | 36 | const [route] = await Route.parse(sourceFile, []); 37 | 38 | assert.equal(route.path, sourceFile.path); 39 | assert.equal(route.name, 'example_route'); 40 | assert.equal(route.template.body, ''); 41 | assert.equal(route.template.path, sourceFile.path); 42 | }); 43 | 44 | it('on route with external template then template is set correctly', async () => { 45 | mockRoot('testRoot'); 46 | const sourceFile = getRouteSourceFile('route.external_template.ts'); 47 | 48 | const [route] = await Route.parse(sourceFile, []); 49 | 50 | assert.equal(route.path, sourceFile.path); 51 | assert.equal(route.name, 'example_route'); 52 | assert.equal(route.template.path, path.normalize('testRoot/subdir/template.html')); 53 | }); 54 | 55 | it('on route with required template then template is set correctly', async () => { 56 | const sourceFile = getRouteSourceFile('route.required_template.ts'); 57 | 58 | const [route] = await Route.parse(sourceFile, []); 59 | 60 | assert.equal(route.path, sourceFile.path); 61 | assert.equal(route.name, 'example_route'); 62 | assert.equal(route.template.path, path.join(path.dirname(sourceFile.path), 'subdir/template.html')); 63 | }); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /test/utils/htmlTemplateInfoCache.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | import * as _ from 'lodash'; 3 | import proxyquire = require('proxyquire'); 4 | import { IHtmlTemplateInfoResults } from '../../src/utils/htmlTemplate/types'; 5 | import { Route } from '../../src/utils/route/route'; 6 | 7 | class MockedRelativePath { 8 | public relative: string; 9 | 10 | constructor(p: string) { 11 | this.relative = p; 12 | } 13 | } 14 | 15 | const { HtmlTemplateInfoCache } = proxyquire('../../src/utils/htmlTemplate/htmlTemplateInfoCache', { 16 | './relativePath': { RelativePath: MockedRelativePath } 17 | }); 18 | 19 | describe('Given HtmlTemplateInfoCache class', () => { 20 | describe('when calling loadInlineTemplates', () => { 21 | it('then html references should be set', async () => { 22 | // arrange 23 | const sut = new HtmlTemplateInfoCache(); 24 | 25 | const expectedResult = { 26 | 'test-component': [{ 27 | col: 12, 28 | line: 10, 29 | relativeHtmlPath: 'path' 30 | }] 31 | }; 32 | 33 | const template = { 34 | path: 'path', 35 | pos: { line: 10, character: 10 }, 36 | body: '' 37 | }; 38 | 39 | // act 40 | const result: IHtmlTemplateInfoResults = await sut.loadInlineTemplates([template]); 41 | 42 | // assert 43 | assert.deepEqual(result.htmlReferences, expectedResult); 44 | }); 45 | 46 | it('then html references should be set for routes', async () => { 47 | // arrange 48 | const sut = new HtmlTemplateInfoCache(); 49 | 50 | const expectedResult = { 51 | 'inline-component': [{ 52 | col: 27, 53 | line: 6, 54 | relativeHtmlPath: 'path' 55 | }] 56 | }; 57 | 58 | const route: Route = new Route(); 59 | 60 | route.name = 'example_route'; 61 | route.pos = { 62 | line: 4, 63 | character: 13 64 | }; 65 | route.path = 'path'; 66 | route.views = []; 67 | 68 | const inlineComponent = new Route(); 69 | inlineComponent.name = 'inline-component'; 70 | inlineComponent.pos = { 71 | line: 6, 72 | character: 24 73 | }; 74 | inlineComponent.path = 'path'; 75 | inlineComponent.template = { 76 | path: 'path', 77 | pos: { 78 | line: 6, 79 | character: 25 80 | }, 81 | body: '' 82 | }; 83 | route.views.push(inlineComponent); 84 | 85 | const routes = _.flatMap([route], (c) => { 86 | if (c.template && c.template.body) { 87 | return c.template; 88 | } 89 | 90 | if (c.views && c.views.length > 0) { 91 | return c.views.filter(v => v.template && v.template.body).map(v => v.template); 92 | } 93 | }); 94 | 95 | // act 96 | const result: IHtmlTemplateInfoResults = await sut.loadInlineTemplates(routes); 97 | 98 | // assert 99 | assert.deepEqual(result.htmlReferences, expectedResult); 100 | }); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /test/providers/directiveDefinitionProvider.test.ts: -------------------------------------------------------------------------------- 1 | import * as vsc from 'vscode'; 2 | import * as ts from 'typescript'; 3 | import should = require('should'); 4 | import { Directive } from '../../src/utils/directive/directive'; 5 | import { DirectiveDefinitionProvider } from '../../src/providers/directiveDefinitionProvider'; 6 | import { HtmlDocumentHelper } from '../../src/utils/htmlDocumentHelper'; 7 | import { createHtmlDocument } from '../utils/helpers'; 8 | 9 | const htmlDocumentHelper = new HtmlDocumentHelper(); 10 | 11 | describe('Given DirectiveDefinitionProvider', () => { 12 | describe('when calling provideDefinition()', () => { 13 | const provider = new DirectiveDefinitionProvider(htmlDocumentHelper); 14 | const cancellation = new vsc.CancellationTokenSource(); 15 | 16 | async function testProvideDefinition(directives: Directive[], contents: string) { 17 | provider.loadDirectives(directives || []); 18 | 19 | const { document, position } = await createHtmlDocument(contents); 20 | 21 | return provider.provideDefinition(document, position, cancellation.token); 22 | } 23 | 24 | it('and position is outside a closed tag then empty array is returned', async () => { 25 | // arrange, act 26 | const results = await testProvideDefinition(undefined, '^ '); 27 | 28 | // assert 29 | should(results).be.empty(); 30 | }); 31 | 32 | describe('and when position is on', () => { 33 | const directivePos = { line: 2, character: 2 }; 34 | const directives = [{ 35 | className: 'DirectiveClassName', 36 | htmlName: 'directive-name', 37 | name: 'Directive', 38 | path: 'path', 39 | pos: directivePos 40 | }, { 41 | className: 'OtherDirective', 42 | htmlName: 'other-directive', 43 | name: 'OtherDirective', 44 | path: 'wrong path', 45 | pos: { line: 1, character: 1 } 46 | }]; 47 | 48 | it(`an element directive then directive's position is returned`, async () => { 49 | // arrange, act 50 | const result = await testProvideDefinition(directives, '<^directive-name>'); 51 | 52 | // assert 53 | assertPosition(result.range.start, directivePos); 54 | }); 55 | 56 | it(`an attribute directive then directive's position is returned`, async () => { 57 | // arrange, act 58 | const result = await testProvideDefinition(directives, ''); 59 | 60 | // assert 61 | assertPosition(result.range.start, directivePos); 62 | }); 63 | 64 | it(`on non-directive attribute then empty array is returned`, async () => { 65 | // arrange, act 66 | const results = await testProvideDefinition(directives, ''); 67 | 68 | // assert 69 | should(results).be.empty(); 70 | }); 71 | }); 72 | }); 73 | }); 74 | 75 | function assertPosition(actual: vsc.Position, expected: ts.LineAndCharacter) { 76 | should(actual.line).be.equal(expected.line); 77 | should(actual.character).be.equal(expected.character); 78 | } 79 | -------------------------------------------------------------------------------- /src/utils/directive/directiveCache.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as vsc from 'vscode'; 4 | import { SourceFilesScanner } from '../sourceFilesScanner'; 5 | import { SourceFile } from '../sourceFile'; 6 | import { FileWatcher } from '../fileWatcher'; 7 | import { EventEmitter } from 'events'; 8 | import { events } from '../../symbols'; 9 | import { logError } from '../logging'; 10 | import { getConfiguration } from '../vsc'; 11 | import { RelativePath } from '../htmlTemplate/relativePath'; 12 | import { Directive } from './directive'; 13 | 14 | export class DirectiveCache extends EventEmitter implements vsc.Disposable { 15 | private scanner = new SourceFilesScanner(); 16 | private directives: Directive[] = []; 17 | private watcher: FileWatcher; 18 | 19 | private emitDirectivesChanged = () => this.emit(events.directivesChanged, this.directives); 20 | 21 | private setupWatchers = (config: vsc.WorkspaceConfiguration) => { 22 | const globs = config.get('directiveGlobs') as string[]; 23 | 24 | this.dispose(); 25 | this.watcher = new FileWatcher('Directive', globs, this.onAdded, this.onChanged, this.onDeleted); 26 | } 27 | 28 | private onAdded = async (uri: vsc.Uri) => { 29 | const src = await SourceFile.parse(uri.fsPath); 30 | const directives = await Directive.parse(src); 31 | 32 | this.directives.push(...directives); 33 | this.emitDirectivesChanged(); 34 | } 35 | 36 | private onChanged = async (uri: vsc.Uri) => { 37 | const filepath = RelativePath.fromUri(uri); 38 | 39 | const idx = this.directives.findIndex(c => filepath.equals(c.path)); 40 | if (idx === -1) { 41 | // tslint:disable-next-line:no-console 42 | console.warn('Directive does not exist, cannot update it'); 43 | return; 44 | } 45 | 46 | const src = await SourceFile.parse(filepath.absolute); 47 | const directives = await Directive.parse(src); 48 | 49 | this.deleteDirectiveFile(filepath); 50 | this.directives.push(...directives); 51 | this.emitDirectivesChanged(); 52 | } 53 | 54 | private onDeleted = (uri: vsc.Uri) => { 55 | this.deleteDirectiveFile(RelativePath.fromUri(uri)); 56 | this.emitDirectivesChanged(); 57 | } 58 | 59 | private deleteDirectiveFile = (filepath: RelativePath) => { 60 | let idx; 61 | do { 62 | idx = this.directives.findIndex(c => filepath.equals(c.path)); 63 | if (idx > -1) { 64 | this.directives.splice(idx, 1); 65 | } 66 | } while (idx > -1); 67 | } 68 | 69 | public refresh = async (): Promise => { 70 | try { 71 | const config = getConfiguration(); 72 | 73 | this.setupWatchers(config); 74 | 75 | this.directives = await this.scanner.findFiles('directiveGlobs', src => Directive.parse(src), 'Directive'); 76 | return this.directives; 77 | } catch (err) { 78 | logError(err); 79 | vsc.window.showErrorMessage('There was an error refreshing directives cache, check console for errors'); 80 | return []; 81 | } 82 | } 83 | 84 | public dispose() { 85 | if (this.watcher) { 86 | this.watcher.dispose(); 87 | } 88 | 89 | this.removeAllListeners(); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /test/utils/helpers.ts: -------------------------------------------------------------------------------- 1 | import * as _path from 'path'; 2 | import * as ts from 'typescript'; 3 | import * as fs from 'fs'; 4 | import * as vsc from 'vscode'; 5 | import { SourceFile, ISourceFile } from '../../src/utils/sourceFile'; 6 | import { IComponentBinding } from '../../src/utils/component/component'; 7 | import { CompletionItem } from 'vscode'; 8 | import { IMember } from '../../src/utils/controller/member'; 9 | 10 | const COMPONENTS_DIR = 'components'; 11 | const CONTORLLERS_DIR = 'controllers'; 12 | const ROUTES_DIR = 'routes'; 13 | const TEMPLATES_DIR = 'templates'; 14 | const DIRECTIVES_DIR = 'directives'; 15 | 16 | const TEST_FILES_ROOT = _path.join(__dirname, '../../../test/test_files'); 17 | 18 | export const getTestFilePath = (type: string, filename: string) => _path.join(TEST_FILES_ROOT, type, filename); 19 | export const getComponentsTestFilePath = (filename: string) => getTestFilePath(COMPONENTS_DIR, filename); 20 | export const getTemplatesTestFilePath = (filename: string) => getTestFilePath(TEMPLATES_DIR, filename); 21 | export const getTemplatesTestDirPath = () => _path.join(TEST_FILES_ROOT, TEMPLATES_DIR); 22 | 23 | export const getControllerSourceFile = (name: string) => getSourceFile(CONTORLLERS_DIR, name); 24 | export const getDirectiveSourceFile = (name: string) => getSourceFile(DIRECTIVES_DIR, name); 25 | export const getComponentSourceFile = (name: string) => getSourceFile(COMPONENTS_DIR, name); 26 | export const getRouteSourceFile = (name: string) => getSourceFile(ROUTES_DIR, name); 27 | 28 | function getSourceFile(type: string, name: string): SourceFile { 29 | const path = getTestFilePath(type, name); 30 | const sourceFile = ts.createSourceFile(name, fs.readFileSync(path, 'utf8'), ts.ScriptTarget.ES5, true) as ISourceFile; 31 | sourceFile.fullpath = path; 32 | 33 | return new SourceFile(sourceFile); 34 | } 35 | 36 | async function createDocument(type: string, contents: string) { 37 | const position = contents.indexOf('^'); 38 | if (position > -1) { 39 | contents = contents.replace('^', ''); 40 | } 41 | 42 | return { 43 | position: new vsc.Position(0, position), 44 | document: await vsc.workspace.openTextDocument({ 45 | content: contents, 46 | language: type 47 | }) 48 | }; 49 | } 50 | 51 | export const createHtmlDocument = async (contents: string) => createDocument('html', contents); 52 | export const createTypescriptDocument = async (contents: string) => createDocument('typescript', contents); 53 | 54 | export function getTestSourceFile(contents: string): SourceFile { 55 | const sourceFile = ts.createSourceFile('test.ts', contents, ts.ScriptTarget.ES5, true) as ISourceFile; 56 | sourceFile.fullpath = 'test.ts'; 57 | 58 | return new SourceFile(sourceFile); 59 | } 60 | 61 | export function createPropertyMember(name: string) { 62 | return { 63 | name, 64 | isPublic: true, 65 | buildCompletionItem: (_bindings) => new CompletionItem(name) 66 | }; 67 | } 68 | 69 | export function createBinding(name: string) { 70 | return { 71 | name, 72 | buildCompletionItem: () => new CompletionItem(name) 73 | }; 74 | } 75 | -------------------------------------------------------------------------------- /src/providers/codeActionsProvider.ts: -------------------------------------------------------------------------------- 1 | import * as vsc from 'vscode'; 2 | import * as path from 'path'; 3 | import _ = require('lodash'); 4 | import didYouMean = require('didyoumean2'); 5 | 6 | import { Commands } from '../commands/commands'; 7 | import { RelativePath } from '../utils/htmlTemplate/relativePath'; 8 | import { IComponentBase } from '../utils/component/component'; 9 | import { getMembersAndBindingsFunction, GetMembersAndBindingsFunctionType } from '../utils/component/helpers'; 10 | 11 | export class CodeActionProvider implements vsc.CodeActionProvider { 12 | private getMembersAndBindings: GetMembersAndBindingsFunctionType; 13 | private components = new Map(); 14 | 15 | constructor(private getConfig: () => vsc.WorkspaceConfiguration) { 16 | this.getMembersAndBindings = getMembersAndBindingsFunction(getConfig); 17 | } 18 | 19 | public loadComponents = (components: IComponentBase[]) => { 20 | this.components = new Map( 21 | components.filter(c => c.template).map(c => <[string, IComponentBase]>[new RelativePath(c.template.path).relativeLowercase, c]) 22 | ); 23 | } 24 | 25 | public provideCodeActions(document: vsc.TextDocument, range: vsc.Range, ctx: vsc.CodeActionContext, _t: vsc.CancellationToken): vsc.ProviderResult { 26 | const relativePath = RelativePath.fromUri(document.uri); 27 | const component = this.components.get(relativePath.relativeLowercase); 28 | const results: vsc.Command[] = []; 29 | 30 | const diag = ctx.diagnostics.find(d => d.range.contains(range)); 31 | 32 | if (diag) { 33 | if (component) { 34 | const { members, bindings } = this.getMembersAndBindings(component); 35 | const allMembersNames = _.union( 36 | members.map(m => m.name), 37 | bindings.map(b => b.name) 38 | ); 39 | 40 | const config = this.getConfig(); 41 | const options = this.buildDidYouMeanOptions(config); 42 | const matches: string[] = didYouMean(diag.code, allMembersNames, options); 43 | 44 | if (matches.length > 0) { 45 | const maxResults = config.get('memberDiagnostics.didYouMean.maxResults', 2); 46 | const rangeToReplace = diag.range.with(diag.range.start.translate(undefined, component.controllerAs.length + 1)); 47 | 48 | results.push(...matches.slice(0, maxResults).map(m => ({ 49 | command: Commands.MemberDiagnostic.DidYouMean, 50 | title: `Did you mean '${m}'?`, 51 | arguments: [rangeToReplace, m] 52 | }))); 53 | } 54 | } 55 | 56 | results.push({ 57 | command: Commands.MemberDiagnostic.IgnoreMember, 58 | title: `Ignore '${diag.code}' errors inside '${path.basename(document.uri.fsPath)}' (this workspace)`, 59 | arguments: [relativePath, diag.code] 60 | }); 61 | } 62 | 63 | return results; 64 | } 65 | 66 | private buildDidYouMeanOptions = (config: vsc.WorkspaceConfiguration) => { 67 | const similarityThresold = config.get('memberDiagnostics.didYouMean.similarityThreshold', 0.6); 68 | 69 | return { 70 | threshold: similarityThresold, 71 | returnType: 'all-sorted-matches' 72 | }; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/utils/route/routesCache.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as vsc from 'vscode'; 4 | import { SourceFilesScanner } from '../sourceFilesScanner'; 5 | import { Route } from './route'; 6 | import { SourceFile } from '../sourceFile'; 7 | import { FileWatcher } from '../fileWatcher'; 8 | import { EventEmitter } from 'events'; 9 | import { events } from '../../symbols'; 10 | import { Controller } from '../controller/controller'; 11 | import { logError } from '../logging'; 12 | import { getConfiguration } from '../vsc'; 13 | import { RelativePath } from '../htmlTemplate/relativePath'; 14 | 15 | export class RoutesCache extends EventEmitter implements vsc.Disposable { 16 | private controllers: Controller[]; 17 | private scanner = new SourceFilesScanner(); 18 | private routes: Route[] = []; 19 | private watcher: FileWatcher; 20 | 21 | private emitRoutesChanged = () => this.emit(events.routesChanged, this.routes); 22 | 23 | private setupWatchers = (config: vsc.WorkspaceConfiguration) => { 24 | const globs = config.get('routeGlobs') as string[]; 25 | 26 | this.dispose(); 27 | this.watcher = new FileWatcher('Route', globs, this.onAdded, this.onChanged, this.onDeleted); 28 | } 29 | 30 | private onAdded = async (uri: vsc.Uri) => { 31 | const src = await SourceFile.parse(uri.fsPath); 32 | const routes = await Route.parse(src, this.controllers); 33 | 34 | this.routes.push(...routes); 35 | this.emitRoutesChanged(); 36 | } 37 | 38 | private onChanged = async (uri: vsc.Uri) => { 39 | const filepath = RelativePath.fromUri(uri); 40 | 41 | const idx = this.routes.findIndex(c => filepath.equals(c.path)); 42 | if (idx === -1) { 43 | // tslint:disable-next-line:no-console 44 | console.warn('Component does not exist, cannot update it'); 45 | return; 46 | } 47 | 48 | const src = await SourceFile.parse(filepath.absolute); 49 | const routes = await Route.parse(src, this.controllers); 50 | 51 | this.deleteComponentFile(filepath); 52 | this.routes.push(...routes); 53 | this.emitRoutesChanged(); 54 | } 55 | 56 | private onDeleted = (uri: vsc.Uri) => { 57 | this.deleteComponentFile(RelativePath.fromUri(uri)); 58 | this.emitRoutesChanged(); 59 | } 60 | 61 | private deleteComponentFile = (filepath: RelativePath) => { 62 | let idx; 63 | do { 64 | idx = this.routes.findIndex(c => filepath.equals(c.path)); 65 | if (idx > -1) { 66 | this.routes.splice(idx, 1); 67 | } 68 | } while (idx > -1); 69 | } 70 | 71 | public refresh = async (controllers: Controller[]): Promise => { 72 | try { 73 | const config = getConfiguration(); 74 | 75 | this.setupWatchers(config); 76 | this.controllers = controllers; 77 | 78 | this.routes = await this.scanner.findFiles('routeGlobs', src => Route.parse(src, controllers), 'Route'); 79 | return this.routes; 80 | } catch (err) { 81 | logError(err); 82 | vsc.window.showErrorMessage('There was an error refreshing components cache, check console for errors'); 83 | return []; 84 | } 85 | } 86 | 87 | public dispose() { 88 | if (this.watcher) { 89 | this.watcher.dispose(); 90 | } 91 | 92 | this.removeAllListeners(); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/providers/bindingProvider.ts: -------------------------------------------------------------------------------- 1 | import * as vsc from 'vscode'; 2 | import { Component } from '../utils/component/component'; 3 | import * as _ from 'lodash'; 4 | 5 | export class BindingProvider implements vsc.CompletionItemProvider { 6 | private components: Component[]; 7 | 8 | public loadComponents = (components: Component[]) => { 9 | this.components = components; 10 | } 11 | public provideCompletionItems = (document: vsc.TextDocument, position: vsc.Position, _token: vsc.CancellationToken): vsc.CompletionItem[] => { 12 | const line = document.lineAt(position.line).text; 13 | let startIndex = line.indexOf(',') - 1; 14 | const isAllowedCharacter = /[a-z-]/i; 15 | 16 | for (let i = startIndex; i >= 0; i--) { 17 | if (!isAllowedCharacter.test(line[i])) { 18 | startIndex = i + 1; 19 | break; 20 | } 21 | startIndex = 0; 22 | } 23 | 24 | const firstCommaIndex = line.indexOf(',', startIndex); 25 | const lastCommaIndex = line.lastIndexOf(','); 26 | const componentName = line.substring(startIndex, firstCommaIndex); 27 | 28 | const component = this.components.find(x => x.htmlName === componentName); 29 | 30 | if (!component) { 31 | return []; 32 | } 33 | 34 | const existingBindings = {}; 35 | if (firstCommaIndex !== lastCommaIndex) { 36 | const existing = line.substring(firstCommaIndex + 1, lastCommaIndex).split(','); 37 | existing.forEach(b => { 38 | const split = b.split('='); 39 | let value = split[1] || ''; 40 | if (!value) { 41 | const binding = component.bindings.find(x => x.htmlName === split[0]); 42 | if (binding) { 43 | value = `${component.controllerAs}.${binding.name}`; 44 | } 45 | } else if (!value.startsWith(component.controllerAs)) { 46 | value = `${component.controllerAs}.${value}`; 47 | } 48 | 49 | existingBindings[split[0]] = value; 50 | }); 51 | } 52 | 53 | return this.provideBindingCompletions(component, existingBindings, position, startIndex); 54 | } 55 | 56 | private provideBindingCompletions = (component: Component, existingBindings: object, position: vsc.Position, startIndex: number): vsc.CompletionItem[] => { 57 | const attributes = _(Object.keys(existingBindings)); 58 | 59 | const result = component.bindings 60 | .filter(b => !attributes.includes(b.htmlName)) 61 | .map(b => { 62 | const item = new vsc.CompletionItem(b.htmlName, vsc.CompletionItemKind.Field); 63 | item.insertText = `${b.htmlName}=`; 64 | item.detail = 'Component binding'; 65 | item.documentation = `Binding type: ${b.type}`; 66 | item.label = ` ${b.htmlName}`; // space at the beginning so that these bindings are first on the list 67 | 68 | return item; 69 | }); 70 | 71 | const commandItem = new vsc.CompletionItem(' Resolve component', vsc.CompletionItemKind.Function); 72 | commandItem.insertText = `<${component.htmlName} ${attributes.map(key => key + '="' + existingBindings[key] + '"').join(' ')}>`; 73 | commandItem.additionalTextEdits = [ 74 | vsc.TextEdit.delete(new vsc.Range(position.line, startIndex, position.line, position.character)) 75 | ]; 76 | 77 | result.push(commandItem); 78 | 79 | return result; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/providers/memberDefinitionProvider.ts: -------------------------------------------------------------------------------- 1 | import * as vsc from 'vscode'; 2 | 3 | import { IComponentBase } from '../utils/component/component'; 4 | import { getLocation } from '../utils/vsc'; 5 | import { ITemplateInfo, ITemplateInfoEntry } from '../utils/htmlTemplate/types'; 6 | import { RelativePath } from '../utils/htmlTemplate/relativePath'; 7 | 8 | export class MemberDefinitionProvider implements vsc.DefinitionProvider { 9 | private templateInfo: ITemplateInfo; 10 | private components = new Map(); 11 | 12 | public loadComponents = (components: IComponentBase[], templateInfo: ITemplateInfo) => { 13 | this.templateInfo = templateInfo; 14 | this.components = new Map( 15 | components.filter(c => c.template).map(c => <[string, IComponentBase]>[new RelativePath(c.template.path).relativeLowercase, c]) 16 | ); 17 | } 18 | 19 | public provideDefinition(document: vsc.TextDocument, position: vsc.Position, _token: vsc.CancellationToken): vsc.Definition { 20 | const relativePath = RelativePath.fromUri(document.uri); 21 | const component = this.components.get(relativePath.relativeLowercase); 22 | 23 | if (!component) { 24 | return []; 25 | } 26 | 27 | const definitions: vsc.Location[] = []; 28 | 29 | const line = document.lineAt(position.line).text; 30 | const dotIdx = line.lastIndexOf('.', position.character); 31 | const viewModelName = line.substring(dotIdx - component.controllerAs.length, dotIdx); 32 | 33 | if (viewModelName === component.controllerAs) { 34 | const range = document.getWordRangeAtPosition(position); 35 | const word = document.getText(range); 36 | 37 | if (component.controller) { 38 | this.fillMemberDefinition(component, word, definitions); 39 | } 40 | 41 | if (definitions.length === 0) { 42 | this.fillBindingDefinition(component, word, definitions); 43 | } 44 | 45 | const templateForms = this.templateInfo[relativePath.relative]; 46 | if (templateForms) { 47 | this.fillFormDefinition(templateForms, viewModelName, word, definitions, relativePath); 48 | } 49 | } 50 | 51 | return definitions; 52 | } 53 | 54 | private fillFormDefinition(templateForms: ITemplateInfoEntry, viewModelName: string, word: string, definitions: vsc.Location[], relativePath: RelativePath) { 55 | const matchingForm = templateForms.forms.find(f => f.name === `${viewModelName}.${word}`); 56 | if (matchingForm) { 57 | definitions.push(getLocation({ 58 | path: relativePath.absolute, 59 | pos: matchingForm 60 | })); 61 | } 62 | } 63 | 64 | private fillMemberDefinition(component: IComponentBase, word: string, definitions: vsc.Location[]) { 65 | const member = component.controller.getMembers(false).find(m => m.name === word); 66 | if (member) { 67 | definitions.push(getLocation({ 68 | path: member.controller.path, 69 | pos: member.pos 70 | })); 71 | } 72 | } 73 | 74 | private fillBindingDefinition(component: IComponentBase, word: string, definitions: vsc.Location[]) { 75 | const binding = component.getBindings().find(b => b.name === word); 76 | if (binding) { 77 | definitions.push(getLocation({ 78 | path: component.path, 79 | pos: binding.pos 80 | })); 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /test/utils/directive/directive.test.ts: -------------------------------------------------------------------------------- 1 | import { getDirectiveSourceFile } from '../helpers'; 2 | import { Directive } from '../../../src/utils/directive/directive'; 3 | import should = require('should'); 4 | 5 | describe('Given Directive class when calling parse()', () => { 6 | it('with class based directive then the directive is properly parsed', async () => { 7 | const sourceFile = getDirectiveSourceFile('directive.init.class_property.ts'); 8 | 9 | const results = await Directive.parse(sourceFile); 10 | 11 | should(results).be.lengthOf(1); 12 | const directive = results[0]; 13 | should(directive.name).be.equal('classDirective'); 14 | should(directive.htmlName).be.equal('class-directive'); 15 | should(directive.restrict).be.equal('E'); 16 | }); 17 | 18 | it('with constructor initialized class based directive then the directive is properly parsed', async () => { 19 | const sourceFile = getDirectiveSourceFile('directive.init.class_ctor.ts'); 20 | 21 | const results = await Directive.parse(sourceFile); 22 | 23 | should(results).be.lengthOf(1); 24 | const directive = results[0]; 25 | should(directive.name).be.equal('classDirective'); 26 | should(directive.htmlName).be.equal('class-directive'); 27 | should(directive.restrict).be.equal('E'); 28 | }); 29 | 30 | it(`with directive without explicit 'restrict' then the it is set to default 'EA'`, async () => { 31 | const sourceFile = getDirectiveSourceFile('directive.default_restrict.ts'); 32 | 33 | const results = await Directive.parse(sourceFile); 34 | 35 | should(results).be.lengthOf(1); 36 | should(results[0].restrict).be.equal('EA'); 37 | }); 38 | 39 | [ 40 | 'directive.register.arrowFunc.ts', 41 | 'directive.register.blockArrowFunc.ts', 42 | 'directive.register.functionExpression.ts' 43 | ].forEach(filename => { 44 | it(`with directive initialized (${filename}) then the directive is parsed`, async () => { 45 | const sourceFile = getDirectiveSourceFile(filename); 46 | 47 | const results = await Directive.parse(sourceFile); 48 | 49 | should(results).be.lengthOf(1); 50 | should(results[0].restrict).be.equal('EA'); 51 | }); 52 | }); 53 | 54 | [ 55 | 'directive.function.ts', 56 | 'directive.function.named.ts', 57 | 'directive.function.named.arrow.ts', 58 | 'directive.function.arrow.ts', 59 | 'directive.function.arrow.returnExpression.ts', 60 | 'directive.function.injectedParams.ts' 61 | ].forEach(filename => { 62 | it(`with function based directive (${filename}) then the directive is parsed`, async () => { 63 | const sourceFile = getDirectiveSourceFile(filename); 64 | 65 | const results = await Directive.parse(sourceFile); 66 | 67 | should(results).be.lengthOf(1); 68 | should(results[0].restrict).be.equal('E'); 69 | }); 70 | }); 71 | 72 | it('with multiple directives then all directives are properly parsed', async () => { 73 | const sourceFile = getDirectiveSourceFile('directive.multiple.ts'); 74 | 75 | const results = await Directive.parse(sourceFile); 76 | 77 | should(results).be.lengthOf(3); 78 | for (const i of [1, 2, 3]) { 79 | should(results[i - 1].name).be.equal('classDirective' + i); 80 | should(results[i - 1].className).be.equal('ClassDirective' + i); 81 | } 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /src/utils/templateParser.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | import * as path from 'path'; 3 | import { kebabCase } from 'lodash'; 4 | import { TypescriptParser } from './typescriptParser'; 5 | import { IComponentTemplate } from './component/component'; 6 | import { RelativePath } from './htmlTemplate/relativePath'; 7 | import { ConfigParser } from './configParser'; 8 | 9 | export class TemplateParser { 10 | public createTemplate = (config: ConfigParser, parser: TypescriptParser) => { 11 | return this.createTemplateFromUrl(config.get('templateUrl'), parser) 12 | || this.createFromInlineTemplate(config.get('template'), parser) 13 | || this.createFromComponentDef(config.get('component'), parser); 14 | } 15 | 16 | public createFromComponentDef = (node: ts.Expression, parser: TypescriptParser): IComponentTemplate => { 17 | if (!node) { 18 | return undefined; 19 | } 20 | 21 | const literal = node as ts.LiteralExpression; 22 | const kebabName = kebabCase(literal.text); 23 | // In the future, handle bindings for usage 24 | literal.text = `<${kebabName}>`; 25 | 26 | return this.createFromInlineTemplate(literal, parser); 27 | } 28 | 29 | private createFromInlineTemplate = (node: ts.Expression, parser: TypescriptParser): IComponentTemplate => { 30 | if (!node) { 31 | return undefined; 32 | } 33 | 34 | if (node.kind === ts.SyntaxKind.StringLiteral || node.kind === ts.SyntaxKind.NoSubstitutionTemplateLiteral) { 35 | const pos = parser.sourceFile.getLineAndCharacterOfPosition(node.getStart(parser.sourceFile)); 36 | const literal = node as ts.LiteralExpression; 37 | 38 | return { path: parser.path, pos, body: literal.text } as IComponentTemplate; 39 | } else if (node.kind === ts.SyntaxKind.CallExpression) { 40 | // handle require('./template.html') 41 | const call = node as ts.CallExpression; 42 | if (call.arguments.length === 1 && call.expression.kind === ts.SyntaxKind.Identifier && call.expression.getText() === 'require') { 43 | const relativePath = (call.arguments[0] as ts.StringLiteral).text; 44 | const templatePath = path.join(path.dirname(parser.path), relativePath); 45 | 46 | return { path: templatePath, pos: { line: 0, character: 0 } } as IComponentTemplate; 47 | } 48 | } else if (node.kind === ts.SyntaxKind.Identifier) { 49 | // handle template: template 50 | const variableStatement = parser.sourceFile.statements 51 | .find(statement => statement.kind === ts.SyntaxKind.VariableStatement) as ts.VariableStatement; 52 | const declarations = variableStatement.declarationList.declarations; 53 | const templateDeclaration = declarations.find(declaration => declaration.name.getText() === node.getText()); 54 | // pass CallExpression (e.g. require('./template.html')) 55 | return this.createFromInlineTemplate(templateDeclaration.initializer, parser); 56 | } 57 | } 58 | 59 | private createTemplateFromUrl(node: ts.Expression, parser: TypescriptParser) { 60 | if (!node) { 61 | return undefined; 62 | } 63 | 64 | const relativePath = parser.getStringValueFromNode(node); 65 | if (relativePath) { 66 | const templatePath = RelativePath.toAbsolute(relativePath); 67 | 68 | return { path: templatePath, pos: { line: 0, character: 0 } } as IComponentTemplate; 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/utils/route/routeParser.ts: -------------------------------------------------------------------------------- 1 | import { TypescriptParser } from '../typescriptParser'; 2 | import { Route } from './route'; 3 | import { SourceFile } from '../sourceFile'; 4 | import * as ts from 'typescript'; 5 | import { ConfigParser } from '../configParser'; 6 | import { TemplateParser } from '../templateParser'; 7 | import { ControllerHelper } from '../controllerHelper'; 8 | import { logVerbose } from '../logging'; 9 | 10 | export class RouteParser { 11 | private tsParser: TypescriptParser; 12 | private templateParser: TemplateParser; 13 | private results: Route[] = []; 14 | 15 | constructor(private controllerHelper: ControllerHelper, file: SourceFile) { 16 | this.tsParser = new TypescriptParser(file); 17 | this.templateParser = new TemplateParser(); 18 | } 19 | 20 | public parse() { 21 | this.parseChildren(this.tsParser.sourceFile); 22 | 23 | return this.results; 24 | } 25 | 26 | private parseChildren = (node: ts.Node) => { 27 | if (node.kind === ts.SyntaxKind.CallExpression) { 28 | const call = node as ts.CallExpression; 29 | 30 | if (call.expression.kind === ts.SyntaxKind.PropertyAccessExpression 31 | && (call.expression as ts.PropertyAccessExpression).name.text === 'state' 32 | && call.arguments.length === 2) { 33 | const routeName = call.arguments[0] as ts.StringLiteral; 34 | const configObj = call.arguments[1] as ts.Expression; 35 | this.results.push(this.createRoute(routeName, configObj)); 36 | 37 | const expr = call.expression as ts.PropertyAccessExpression; 38 | if (expr.expression.kind === ts.SyntaxKind.CallExpression) { 39 | this.parseChildren(expr.expression); 40 | } 41 | } else { 42 | call.getChildren().forEach(this.parseChildren); 43 | } 44 | } else { 45 | node.getChildren().forEach(this.parseChildren); 46 | } 47 | } 48 | 49 | private createRoute = (routeName: ts.StringLiteral, configNode: ts.Expression) => { 50 | const configObj = this.tsParser.getObjectLiteralValueFromNode(configNode); 51 | const config = new ConfigParser(configObj); 52 | 53 | const route = new Route(); 54 | route.name = routeName.text; 55 | route.pos = this.tsParser.sourceFile.getLineAndCharacterOfPosition(routeName.pos); 56 | route.path = this.tsParser.path; 57 | route.views = []; 58 | 59 | route.template = this.templateParser.createTemplate(config, this.tsParser); 60 | 61 | const viewsNode = config.get('views'); 62 | 63 | if (viewsNode !== undefined) { 64 | const viewConfigNodes = new ConfigParser(this.tsParser.getObjectLiteralValueFromNode(viewsNode)).entries(); 65 | 66 | for (const viewNode of viewConfigNodes) { 67 | const viewRoute = new Route(); 68 | viewRoute.name = `${route.name}[${viewNode[0]}]`; 69 | viewRoute.pos = this.tsParser.sourceFile.getLineAndCharacterOfPosition(viewNode[1].pos); 70 | viewRoute.path = this.tsParser.path; 71 | 72 | if (ts.isStringLiteral(viewNode[1])) { 73 | viewRoute.template = this.templateParser.createFromComponentDef(viewNode[1], this.tsParser); 74 | } else if (ts.isObjectLiteralExpression(viewNode[1])) { 75 | viewRoute.template = this.templateParser.createTemplate(new ConfigParser(viewNode[1] as ts.ObjectLiteralExpression), this.tsParser); 76 | } 77 | 78 | route.views.push(viewRoute); 79 | } 80 | } 81 | 82 | if (!this.controllerHelper.prepareController(route, config)) { 83 | logVerbose(`Controller for route '${route.name}' not found (member completion and Go To Definition for this component will not work)`); 84 | } 85 | 86 | return route; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/utils/vsc.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | import * as vsc from 'vscode'; 3 | import * as path from 'path'; 4 | import * as fs from 'fs'; 5 | import { isValidAngularProject } from './angular'; 6 | import { logVerbose, log, logWarning, logError } from './logging'; 7 | import { IHtmlReference } from './htmlTemplate/types'; 8 | 9 | const workspaceRoot = vsc.workspace.workspaceFolders && vsc.workspace.workspaceFolders[0].uri.fsPath; 10 | 11 | export let angularRoot; 12 | 13 | export function mockRoot(rootPath: string) { 14 | if (process.env.NODE_ENV === 'test') { 15 | const oldRoot = angularRoot; 16 | angularRoot = rootPath; 17 | return oldRoot; 18 | } else { 19 | logError('This is only allowed in tests'); 20 | } 21 | } 22 | 23 | export function getLocation(location: { path: string, pos: ts.LineAndCharacter }) { 24 | return new vsc.Location(vsc.Uri.file(location.path), new vsc.Position(location.pos.line, location.pos.character)); 25 | } 26 | 27 | export function convertHtmlReferencesToLocations(references: IHtmlReference[]): vsc.Location[] { 28 | return references.map(ref => getLocation({ 29 | path: path.join(angularRoot, ref.relativeHtmlPath), 30 | pos: { 31 | line: ref.line, 32 | character: ref.character 33 | } 34 | })); 35 | } 36 | 37 | export function getConfiguration() { 38 | return vsc.workspace.getConfiguration('ngComponents'); 39 | } 40 | 41 | export function shouldActivateExtension() { 42 | const config = getConfiguration(); 43 | const forceEnable = config.get('forceEnable'); 44 | angularRoot = getAngularRootDirectory(); 45 | 46 | if (forceEnable) { 47 | logVerbose('forceEnable is true for this workspace, skipping auto-detection'); 48 | } else { 49 | logVerbose('Detecting Angular in workspace'); 50 | const result = 51 | angularRoot && isValidAngularProject(angularRoot) || 52 | workspaceRoot && isValidAngularProject(workspaceRoot); 53 | 54 | if (!result) { 55 | log('Angular was not detected in the project'); 56 | return false; 57 | } 58 | 59 | logVerbose('Angular detected, initializing extension.'); 60 | } 61 | 62 | return true; 63 | } 64 | 65 | export function alreadyAngularProject() { 66 | vsc.window.showInformationMessage('This project is already an AngularJS project.'); 67 | } 68 | 69 | export function notAngularProject() { 70 | const msg = 'Force enable'; 71 | vsc.window.showInformationMessage('AngularJS has not been detected in this project', msg).then(v => { 72 | if (v === msg) { 73 | markAsAngularProject(); 74 | } 75 | }); 76 | } 77 | 78 | export function markAsAngularProject() { 79 | const config = getConfiguration(); 80 | config.update('forceEnable', true, false).then(_ => { 81 | vsc.commands.executeCommand('workbench.action.reloadWindow'); 82 | }); 83 | } 84 | 85 | export function findFiles(pattern: string) { 86 | const workspacePattern = new vsc.RelativePattern(angularRoot, pattern); 87 | 88 | return vsc.workspace.findFiles(workspacePattern).then(matches => matches.map(m => m.fsPath)); 89 | } 90 | 91 | function getAngularRootDirectory() { 92 | const config = getConfiguration(); 93 | let value = config.get('angularRoot'); 94 | 95 | if (value) { 96 | value = path.join(workspaceRoot, value); 97 | if (fs.existsSync(value)) { 98 | return value; 99 | } 100 | 101 | logWarning(`${value} does not exist. Please correct angularRoot setting value`); 102 | } 103 | 104 | if (process.env.NODE_ENV === 'test') { 105 | return ''; 106 | } 107 | 108 | return workspaceRoot; 109 | } 110 | -------------------------------------------------------------------------------- /src/configurationFile.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as fs from 'fs'; 3 | import * as vsc from 'vscode'; 4 | import { logError, logWarning } from './utils/logging'; 5 | import { EventEmitter } from 'events'; 6 | import { events } from './symbols'; 7 | 8 | const CreateEmptyConfig = (): IConfiguration => ({ 9 | ignoredMemberDiagnostics: {} 10 | }); 11 | 12 | export class ConfigurationFile extends EventEmitter implements vsc.Disposable { 13 | private disposable: vsc.Disposable; 14 | private configurationPath: string; 15 | private configuration = CreateEmptyConfig(); 16 | private isSaving: boolean = false; 17 | 18 | constructor() { 19 | super(); 20 | 21 | if (!vsc.workspace.workspaceFolders) { 22 | return; 23 | } 24 | 25 | this.configurationPath = path.join(vsc.workspace.workspaceFolders[0].uri.fsPath, '.vscode/ngComponents.json'); 26 | const watcher = vsc.workspace.createFileSystemWatcher(this.configurationPath); 27 | const disposables: vsc.Disposable[] = []; 28 | 29 | watcher.onDidChange(() => this.loadAndEmit(), undefined, disposables); 30 | watcher.onDidCreate(() => this.loadAndEmit(), undefined, disposables); 31 | watcher.onDidDelete(() => this.loadAndEmit(), undefined, disposables); 32 | 33 | this.disposable = vsc.Disposable.from(...disposables, watcher); 34 | } 35 | 36 | public dispose() { 37 | this.disposable.dispose(); 38 | this.removeAllListeners(); 39 | } 40 | 41 | private loadAndEmit = async () => { 42 | if (this.isSaving) { 43 | return; 44 | } 45 | 46 | try { 47 | await this.load(); 48 | } catch (err) { 49 | logWarning('Error while opening configuration file: ' + err.message); 50 | } 51 | 52 | this.emit(events.configurationFile.ignoredMemberDiagnosticChanged); 53 | } 54 | 55 | public load = (): Promise => { 56 | return new Promise((resolve, reject) => { 57 | fs.exists(this.configurationPath, exists => { 58 | if (!exists) { 59 | this.configuration = CreateEmptyConfig(); 60 | return resolve(); 61 | } 62 | 63 | fs.readFile(this.configurationPath, { encoding: 'utf8' }, (err, data) => { 64 | if (err) { 65 | this.configuration = CreateEmptyConfig(); 66 | return reject(err); 67 | } 68 | 69 | try { 70 | this.configuration = JSON.parse(data); 71 | resolve(); 72 | } catch (ex) { 73 | this.configuration = CreateEmptyConfig(); 74 | reject(ex); 75 | } 76 | }); 77 | }); 78 | }); 79 | } 80 | 81 | private saveConfiguration = () => { 82 | const contents = JSON.stringify(this.configuration, undefined, ' '); 83 | 84 | this.isSaving = true; 85 | fs.writeFile(this.configurationPath, contents, { encoding: 'utf8' }, err => { 86 | if (err) logError(err, 'Error while writing configuration file: '); 87 | 88 | setTimeout(() => this.isSaving = false, 100); 89 | }); 90 | } 91 | 92 | public getIgnoredMemberDiagnostics = () => this.configuration.ignoredMemberDiagnostics; 93 | public addIgnoredMemberDiagnostic = (templatePath: string, memberName: string) => { 94 | const ignoredMembers = this.configuration.ignoredMemberDiagnostics; 95 | ignoredMembers[templatePath] = ignoredMembers[templatePath] || []; 96 | 97 | if (ignoredMembers[templatePath].indexOf(memberName) === -1) { 98 | ignoredMembers[templatePath].push(memberName); 99 | this.saveConfiguration(); 100 | this.emit(events.configurationFile.ignoredMemberDiagnosticChanged); 101 | } 102 | } 103 | } 104 | 105 | interface IConfiguration { 106 | ignoredMemberDiagnostics: IgnoredMemberDiagnostics; 107 | } 108 | 109 | export interface IgnoredMemberDiagnostics { 110 | [templatePath: string]: string[]; 111 | } 112 | -------------------------------------------------------------------------------- /test/providers/componentCompletionProvider.test.ts: -------------------------------------------------------------------------------- 1 | import * as vsc from 'vscode'; 2 | import should = require('should'); 3 | import { ComponentCompletionProvider } from '../../src/providers/componentCompletionProvider'; 4 | import { HtmlDocumentHelper } from '../../src/utils/htmlDocumentHelper'; 5 | import { Component } from '../../src/utils/component/component'; 6 | import { createHtmlDocument } from '../utils/helpers'; 7 | 8 | const htmlDocumentHelper = new HtmlDocumentHelper(); 9 | 10 | describe('Given ComponentCompletionProvider when calling provideCompletionItems()', () => { 11 | let provider: ComponentCompletionProvider; 12 | 13 | beforeEach(async () => { 14 | provider = new ComponentCompletionProvider(htmlDocumentHelper); 15 | }); 16 | 17 | ['', ''].forEach(contents => { 18 | it(`on a closing tag (${contents}) then empty result is returned`, async () => { 19 | const { document, position } = await createHtmlDocument(contents); 20 | 21 | const result = provider.provideCompletionItems(document, position, undefined); 22 | 23 | should(result).be.empty(); 24 | }); 25 | }); 26 | 27 | ['<^', '
^
', '
<^'].forEach(contents => { 28 | it(`on an opening tag (${contents}) then all components are returned`, async () => { 29 | const { document, position } = await createHtmlDocument(contents); 30 | provider.loadComponents([ 31 | { htmlName: 'component-one', bindings: [] }, 32 | { htmlName: 'component-two', bindings: [] } 33 | ]); 34 | 35 | const result = provider.provideCompletionItems(document, position, undefined); 36 | 37 | should(result).be.lengthOf(2); 38 | should(result.map(r => r.label)).be.eql(['component-one', 'component-two']); 39 | }); 40 | }); 41 | 42 | it(`on an opening tag then properly built CompletionItem is returned`, async () => { 43 | const { document, position } = await createHtmlDocument('<^'); 44 | provider.loadComponents([{ 45 | htmlName: 'component-one', bindings: [ 46 | { htmlName: 'one', type: '<' }, 47 | { htmlName: 'two', type: '=' }, 48 | ] 49 | }]); 50 | 51 | const [result] = provider.provideCompletionItems(document, position, undefined); 52 | 53 | should(result).be.not.undefined(); 54 | should(result.insertText).be.equal(''); 55 | should(result.label).be.equal('component-one'); 56 | should(result.kind).be.equal(vsc.CompletionItemKind.Class); 57 | should(result.documentation).containEql('Component bindings:'); 58 | should(result.documentation).containEql('one: <'); 59 | should(result.documentation).containEql('two: ='); 60 | }); 61 | 62 | it(`inside an unknown component tag then empty result is returned`, async () => { 63 | const { document, position } = await createHtmlDocument(''); 64 | provider.loadComponents([ 65 | { 66 | htmlName: 'alpha', bindings: [ 67 | { htmlName: 'binding-1' }, 68 | { htmlName: 'binding-2' }, 69 | ] 70 | } 71 | ]); 72 | 73 | const result = provider.provideCompletionItems(document, position, undefined); 74 | 75 | should(result).be.empty(); 76 | }); 77 | 78 | it(`inside a component tag then all bindings are returned`, async () => { 79 | const { document, position } = await createHtmlDocument(''); 80 | provider.loadComponents([ 81 | { 82 | htmlName: 'alpha', bindings: [ 83 | { htmlName: 'binding-1' }, 84 | { htmlName: 'binding-2' }, 85 | ] 86 | } 87 | ]); 88 | 89 | const result = provider.provideCompletionItems(document, position, undefined); 90 | 91 | should(result).be.lengthOf(2); 92 | should(result.map(r => r.label.trimLeft())).be.eql(['binding-1', 'binding-2']); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /test/providers/directiveReferencesProvider.test.ts: -------------------------------------------------------------------------------- 1 | import * as vsc from 'vscode'; 2 | import * as ts from 'typescript'; 3 | import { DirectiveReferencesProvider } from '../../src/providers/directiveReferencesProvider'; 4 | import { Directive } from '../../src/utils/directive/directive'; 5 | import { createTypescriptDocument } from '../utils/helpers'; 6 | import should = require('should'); 7 | import { IHtmlReferences } from '../../src/utils/htmlTemplate/types'; 8 | import { mockRoot } from '../../src/utils/vsc'; 9 | 10 | describe('Given DirectiveReferencesProvider', () => { 11 | describe('when calling provideReferences()', () => { 12 | const provider = new DirectiveReferencesProvider(); 13 | const cancellation = new vsc.CancellationTokenSource(); 14 | 15 | async function testProvideReferences(directives: Directive[], references: IHtmlReferences, contents: string) { 16 | provider.load(references || {}, directives || []); 17 | 18 | const { document, position } = await createTypescriptDocument(contents); 19 | 20 | return provider.provideReferences(document, position, { includeDeclaration: false }, cancellation.token); 21 | } 22 | 23 | it('and there are no directives then empty array is returned', async () => { 24 | // arrange, act 25 | const results = await testProvideReferences(null, null, '^directive'); 26 | 27 | // assert 28 | should(results).be.empty(); 29 | }); 30 | 31 | describe('with existing directives', () => { 32 | const directives = [{ 33 | className: 'DirectiveClassName', 34 | htmlName: 'directive-name', 35 | name: 'Directive', 36 | }, { 37 | className: 'OtherDirective', 38 | htmlName: 'other-directive', 39 | name: 'OtherDirective' 40 | }]; 41 | 42 | const firstLocation = { character: 1, line: 1 }; 43 | const secondLocation = { character: 2, line: 2 }; 44 | const references = { 45 | 'directive-name': [ 46 | { relativeHtmlPath: 'test.html', ...firstLocation }, 47 | { relativeHtmlPath: 'test2.html', ...secondLocation } 48 | ] 49 | }; 50 | 51 | let oldRoot: string; 52 | 53 | before(() => oldRoot = mockRoot('root')); 54 | after(() => mockRoot(oldRoot)); 55 | 56 | it('and non existing directive is searched then empty array is returned', async () => { 57 | // arrange, act 58 | const results = await testProvideReferences(directives, null, '^non-existing-directive'); 59 | 60 | // assert 61 | should(results).be.empty(); 62 | }); 63 | 64 | it('and existing directive is searched but no references are found then empty array is returned', async () => { 65 | // arrange, act 66 | const results = await testProvideReferences(directives, references, '^OtherDirective'); 67 | 68 | // assert 69 | should(results).be.empty(); 70 | }); 71 | 72 | [ 73 | { contents: '^DirectiveClassName', by: 'class name' }, 74 | { contents: '^Directive', by: 'registraiton name' } 75 | ].forEach(item => { 76 | it(`and existing directive with references is searched by ${item.by} then proper results are returned`, async () => { 77 | // arrange, act 78 | const results = await testProvideReferences(directives, references, item.contents); 79 | 80 | // assert 81 | should(results).be.lengthOf(2); 82 | assertReference(results[0], '/root/test.html', firstLocation); 83 | assertReference(results[1], '/root/test2.html', secondLocation); 84 | }); 85 | }); 86 | }); 87 | }); 88 | }); 89 | 90 | function assertReference(actual: vsc.Location, expectedPath: string, expectedPos: ts.LineAndCharacter) { 91 | should(actual.uri.path).be.equal(expectedPath); 92 | should(actual.range.start.line).be.equal(expectedPos.line); 93 | should(actual.range.start.character).be.equal(expectedPos.character); 94 | } 95 | -------------------------------------------------------------------------------- /test/providers/codeActionsProvider.test.ts: -------------------------------------------------------------------------------- 1 | import * as vsc from 'vscode'; 2 | import should = require('should'); 3 | import { CodeActionProvider } from '../../src/providers/codeActionsProvider'; 4 | import { MockedConfig } from '../utils/mockedConfig'; 5 | import { getTemplatesTestFilePath, getTemplatesTestDirPath } from '../utils/helpers'; 6 | import { mockRoot } from '../../src/utils/vsc'; 7 | import { Commands } from '../../src/commands/commands'; 8 | import { IComponentBase } from '../../src/utils/component/component'; 9 | 10 | const templatesRoot = getTemplatesTestDirPath(); 11 | const getConfig = () => config; 12 | const templatePath = getTemplatesTestFilePath('testTemplate.html'); 13 | 14 | const config = new MockedConfig(); 15 | config.setMockData({ 'controller.excludedMembers': '^excluded' }); 16 | 17 | describe('Given CodeActionsProvider when calling provideCodeActions()', () => { 18 | let document: vsc.TextDocument; 19 | let provider: CodeActionProvider; 20 | 21 | let oldRoot: string; 22 | 23 | before(() => oldRoot = mockRoot(templatesRoot)); 24 | after(() => mockRoot(oldRoot)); 25 | 26 | beforeEach(async () => { 27 | document = await vsc.workspace.openTextDocument(templatePath); 28 | provider = new CodeActionProvider(getConfig); 29 | }); 30 | 31 | it('and no diagnostic is matched then empty results are returned', () => { 32 | const context: vsc.CodeActionContext = { 33 | diagnostics: [] 34 | }; 35 | 36 | const result = provider.provideCodeActions(document, new vsc.Range(0, 0, 0, 0), context, undefined) as vsc.Command[]; 37 | 38 | should(result).be.empty(); 39 | }); 40 | 41 | it('and only diagnostic is matched then IgnoreMember command is returned', () => { 42 | const range1 = new vsc.Range(0, 0, 0, 10); 43 | const range2 = new vsc.Range(0, 10, 0, 20); 44 | 45 | const context: vsc.CodeActionContext = { 46 | diagnostics: [{ 47 | code: 'code1', 48 | range: range1 49 | }, { 50 | code: 'code2', 51 | range: range2 52 | }] 53 | }; 54 | 55 | const result = provider.provideCodeActions(document, range1, context, undefined) as vsc.Command[]; 56 | 57 | should(result).not.be.empty(); 58 | assertIgnoreMemberCommand(result[0], 'code1'); 59 | }); 60 | 61 | it('and diagnostic and DidYouMean is matched then two commands are returned', () => { 62 | // arrange 63 | const range = new vsc.Range(0, 0, 0, 10); 64 | 65 | const context: vsc.CodeActionContext = { 66 | diagnostics: [{ 67 | code: 'detials', 68 | range 69 | }] 70 | }; 71 | 72 | provider.loadComponents([{ 73 | template: { path: templatePath }, 74 | controller: { 75 | getMembers: (_publicOnly: boolean) => [{ name: 'details' }] 76 | }, 77 | controllerAs: 'vm', 78 | getBindings: () => [{name: 'binding'}] 79 | }]); 80 | 81 | // act 82 | const result = provider.provideCodeActions(document, range, context, undefined) as vsc.Command[]; 83 | 84 | // assert 85 | should(result).be.lengthOf(2); 86 | assertDidYouMeanCommand(result[0], new vsc.Range(0, 3, 0, 10), 'details'); 87 | assertIgnoreMemberCommand(result[1], 'detials'); 88 | }); 89 | }); 90 | 91 | function assertIgnoreMemberCommand(command: vsc.Command, memberName: string) { 92 | should(command.command).be.equal(Commands.MemberDiagnostic.IgnoreMember); 93 | should(command.arguments).be.lengthOf(2); 94 | should(command.arguments[1]).be.equal(memberName); 95 | } 96 | 97 | function assertDidYouMeanCommand(command: vsc.Command, rangeToReplace: vsc.Range, match: string) { 98 | should(command.command).be.equal(Commands.MemberDiagnostic.DidYouMean); 99 | should(command.arguments).be.lengthOf(2); 100 | should(rangeToReplace.isEqual(command.arguments[0])).be.true(); 101 | should(command.arguments[1]).be.equal(match); 102 | } 103 | -------------------------------------------------------------------------------- /src/providers/memberReferencesProvider.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'lodash'; 2 | import * as vsc from 'vscode'; 3 | import * as ts from 'typescript'; 4 | import * as fs from 'fs'; 5 | import { getLocation } from '../utils/vsc'; 6 | import { IComponentBase } from '../utils/component/component'; 7 | import { SourceFile } from '../utils/sourceFile'; 8 | import { TypescriptParser } from '../utils/typescriptParser'; 9 | 10 | export class MemberReferencesProvider implements vsc.ReferenceProvider { 11 | 12 | private components: IComponentBase[]; 13 | 14 | public load = (components: IComponentBase[]) => { 15 | this.components = components; 16 | } 17 | 18 | // tslint:disable-next-line:max-line-length 19 | public async provideReferences(document: vsc.TextDocument, position: vsc.Position, _context: vsc.ReferenceContext, _token: vsc.CancellationToken): Promise { 20 | const src = SourceFile.parseFromString(document.getText(), document.fileName); 21 | const index = src.sourceFile.getPositionOfLineAndCharacter(position.line, position.character); 22 | 23 | const tsParser = new TypescriptParser(src); 24 | const node = tsParser.findNode(index); 25 | 26 | if (node && ts.isIdentifier(node)) { 27 | const fieldName = this.getAccessedFieldName(node); 28 | if (fieldName) { 29 | const classDeclaration = tsParser.closestParent(node, ts.SyntaxKind.ClassDeclaration); 30 | if (classDeclaration) { 31 | const components = this.components 32 | .filter(c => c.controller && c.controller.isInstanceOf(classDeclaration.name.text)); 33 | 34 | return Promise 35 | .all(components.map(c => this.getLocations(c, fieldName))) 36 | .then(x => _.flatten(x)); 37 | } 38 | } 39 | } 40 | 41 | return Promise.resolve([]); 42 | } 43 | 44 | private getAccessedFieldName = (node: ts.Identifier): string => { 45 | if (ts.isPropertyDeclaration(node.parent) || ts.isMethodDeclaration(node.parent)) { 46 | // field/method declaration 47 | return node.text; 48 | } else if (ts.isPropertyAccessExpression(node.parent)) { 49 | // handles `this.fieldName.anotherOne` expression 50 | // works for method calls as well 51 | let exp = node.parent.expression; 52 | while (ts.isPropertyAccessExpression(exp)) { 53 | exp = exp.expression; 54 | } 55 | 56 | if (exp.kind === ts.SyntaxKind.ThisKeyword) { 57 | return node.parent.getText().substr('this.'.length); 58 | } 59 | } 60 | } 61 | 62 | private getLocations = async (component: IComponentBase, fieldName: string): Promise => { 63 | const searchString = `${component.controllerAs}.${fieldName}`; 64 | const template = component.template; 65 | 66 | return new Promise((resolve, reject) => { 67 | if (template.body) { 68 | const result = getSearchStringLocations(template.body) 69 | .map(pos => getLocation({ 70 | path: component.path, 71 | pos: { 72 | line: pos.line + template.pos.line, 73 | character: pos.character + template.pos.character 74 | } 75 | })); 76 | 77 | resolve(result); 78 | return; 79 | } 80 | 81 | fs.readFile(template.path, 'utf8', (err, contents) => { 82 | if (err) { 83 | return reject(err); 84 | } 85 | 86 | const result = getSearchStringLocations(contents) 87 | .map(pos => getLocation({ path: template.path, pos })); 88 | 89 | resolve(result); 90 | }); 91 | }); 92 | 93 | function getSearchStringLocations(contents: string) { 94 | const lines = contents.split(/\r?\n/); 95 | 96 | return lines.reduce((prev: ts.LineAndCharacter[], line, lineNr) => { 97 | let start = 0; 98 | let result; 99 | 100 | // tslint:disable-next-line:no-conditional-assignment 101 | while ((result = line.indexOf(searchString, start)) > -1) { 102 | prev.push({ line: lineNr, character: result }); 103 | start = result + 1; 104 | } 105 | 106 | return prev; 107 | }, []); 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/utils/memberAccessDiagnostics.ts: -------------------------------------------------------------------------------- 1 | import * as vsc from 'vscode'; 2 | import _ = require('lodash'); 3 | import util = require('util'); 4 | 5 | import { ITemplateInfo } from './htmlTemplate/types'; 6 | import { RelativePath } from './htmlTemplate/relativePath'; 7 | import { IComponentBase } from './component/component'; 8 | import { IMemberAccessEntry } from './htmlTemplate/streams/memberAccessParser'; 9 | import { ConfigurationFile } from '../configurationFile'; 10 | 11 | export class MemberAccessDiagnostics { 12 | constructor(private getConfig: () => vsc.WorkspaceConfiguration, private configurationFile: ConfigurationFile) { } 13 | 14 | public getDiagnostics = (components: IComponentBase[], templateInfo: ITemplateInfo): DiagnosticsByTemplate => { 15 | const config = this.getConfig(); 16 | const checkBindings = config.get('memberDiagnostics.html.checkBindings'); 17 | const checkMembers = config.get('memberDiagnostics.html.checkControllerMembers'); 18 | 19 | const componentMembers = this.getComponentMembers(components, checkBindings, checkMembers); 20 | const messageFormat = this.getMessage(checkBindings, checkMembers); 21 | 22 | const ignoredMembers = this.configurationFile.getIgnoredMemberDiagnostics(); 23 | 24 | const isIgnoredMember = 25 | (relativePath: string, m: IMemberAccessEntry) => ignoredMembers[relativePath] && ignoredMembers[relativePath].some(x => x === m.memberName); 26 | 27 | const isComponentMember = 28 | (relativePath: string, m: IMemberAccessEntry) => componentMembers[relativePath] && componentMembers[relativePath].some(x => x === m.memberName); 29 | 30 | const isFormMember = 31 | (relativePath: string, m: IMemberAccessEntry) => templateInfo[relativePath] && templateInfo[relativePath].forms.some(form => form.name === m.expression); 32 | 33 | return Object.entries(templateInfo) 34 | .reduce((allInvalid, [relativePath, template]) => { 35 | const invalidMembers = template.memberAccess.filter(m => 36 | !isIgnoredMember(relativePath, m) && 37 | !isComponentMember(relativePath, m) && 38 | !isFormMember(relativePath, m)); 39 | 40 | if (invalidMembers.length > 0) { 41 | allInvalid.push([ 42 | vsc.Uri.file(RelativePath.toAbsolute(relativePath)), 43 | invalidMembers.map(m => this.buildDiagnostic(m, messageFormat)) 44 | ]); 45 | } 46 | 47 | return allInvalid; 48 | }, []); 49 | } 50 | 51 | private getMessage(checkBindings: boolean, checkMembers: boolean): string { 52 | const parts = []; 53 | if (checkBindings) parts.push('a binding'); 54 | if (checkMembers) parts.push('a field'); 55 | 56 | return `Member '%s' does not exist as ${parts.join(' or ')} in the component`; 57 | } 58 | 59 | private buildDiagnostic = (member: IMemberAccessEntry, messageFormat: string) => { 60 | const range = new vsc.Range(member.line, member.character, member.line, member.character + member.expression.length); 61 | const message = util.format(messageFormat, member.memberName); 62 | 63 | const diagnostic = new vsc.Diagnostic(range, message, vsc.DiagnosticSeverity.Warning); 64 | diagnostic.source = 'ngComponents'; 65 | diagnostic.code = member.memberName; 66 | 67 | return diagnostic; 68 | } 69 | 70 | private getComponentMembers(components: IComponentBase[], checkBindings: boolean, checkMembers: boolean) { 71 | return components.filter(c => c.template && !c.template.body).reduce((map, component) => { 72 | const templateRelativePath = new RelativePath(component.template.path).relative; 73 | const allMembers: string[] = []; 74 | 75 | if (checkBindings) { 76 | allMembers.push(...component.getBindings().map(b => b.name)); 77 | } 78 | 79 | if (checkMembers && component.controller) { 80 | allMembers.push(...component.controller.getMembers(false).map(m => m.name)); 81 | } 82 | 83 | map[templateRelativePath] = map[templateRelativePath] || []; 84 | map[templateRelativePath].push(..._.uniq(allMembers)); 85 | return map; 86 | }, {}); 87 | } 88 | } 89 | 90 | interface IMembersByTemplate { 91 | [relativeTemplatePath: string]: string[]; 92 | } 93 | 94 | export type DiagnosticsByTemplate = Array<[vsc.Uri, vsc.Diagnostic[]]>; 95 | -------------------------------------------------------------------------------- /test/providers/memberCompletionProvider.test.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as vsc from 'vscode'; 3 | import * as should from 'should'; 4 | import sinon = require('sinon'); 5 | 6 | import { MemberCompletionProvider } from '../../src/providers/memberCompletionProvider'; 7 | import { getTemplatesTestFilePath, getTemplatesTestDirPath, createPropertyMember, createBinding } from '../utils/helpers'; 8 | import { mockRoot } from '../../src/utils/vsc'; 9 | import { IComponentBase, Component, IComponentTemplate } from '../../src/utils/component/component'; 10 | import { MockedConfig } from '../utils/mockedConfig'; 11 | import { Controller } from '../../src/utils/controller/controller'; 12 | 13 | const templatesRoot = getTemplatesTestDirPath(); 14 | const config = new MockedConfig(); 15 | const getConfig = () => config; 16 | 17 | describe('Given MemberCompletionProvider when calling provideCompletionItems()', () => { 18 | let oldRoot: string; 19 | 20 | before(() => oldRoot = mockRoot(templatesRoot)); 21 | after(() => mockRoot(oldRoot)); 22 | 23 | describe('for testTemplate.html', () => { 24 | const provider = new MemberCompletionProvider(getConfig); 25 | const templatePath = getTemplatesTestFilePath('testTemplate.html'); 26 | const cancellation = new vsc.CancellationTokenSource(); 27 | 28 | async function testProvideCompletionItems(component: IComponentBase, position: vsc.Position) { 29 | const components = component && [component] || []; 30 | provider.loadComponents(components); 31 | 32 | const textDocument = await vsc.workspace.openTextDocument(templatePath); 33 | 34 | return provider.provideCompletionItems(textDocument, position, cancellation.token); 35 | } 36 | 37 | describe('when no components are found', () => { 38 | const position = new vsc.Position(0, 0); 39 | 40 | it('(empty components) then empty list is returned', async () => { 41 | // arrange, act 42 | const results = await testProvideCompletionItems(undefined, position); 43 | 44 | // assert 45 | should(results).be.empty(); 46 | }); 47 | 48 | it('(no matching component) then empty list is returned', async () => { 49 | // arrange 50 | const component = { 51 | template: { 52 | path: path.join(templatesRoot, 'different_path.html') 53 | } 54 | }; 55 | 56 | // act 57 | const results = await testProvideCompletionItems(component, position); 58 | 59 | // assert 60 | should(results).be.empty(); 61 | }); 62 | }); 63 | 64 | describe('when component is found', () => { 65 | function setupComponent(members?: string[], bindings?: string[]) { 66 | const controller = new Controller(); 67 | controller.getMembers = () => (members && members.map(createPropertyMember)) || []; 68 | 69 | const component = new Component(); 70 | component.getBindings = () => (bindings && bindings.map(createBinding)) || []; 71 | component.controller = controller; 72 | component.controllerAs = 'vm'; 73 | component.template = { 74 | path: path.join(templatesRoot, 'testTemplate.html') 75 | }; 76 | 77 | return { component, controller }; 78 | } 79 | 80 | it('and triggering autocompletion on empty line then view model name is returned', async () => { 81 | // arrange 82 | const component = { 83 | controllerAs: 'vm', 84 | template: { 85 | path: path.join(templatesRoot, 'testTemplate.html') 86 | } 87 | }; 88 | const position = new vsc.Position(1, 0); 89 | 90 | // act 91 | const results = await testProvideCompletionItems(component, position); 92 | 93 | // assert 94 | should(results).not.be.empty(); 95 | should(results[0].label).be.equal('vm'); 96 | }); 97 | 98 | it('and triggering autocompletion for view model then both members and bindings are collected', async () => { 99 | // arrange 100 | config.setMockData({ 'controller.publicMembersOnly': true }); 101 | 102 | const { component, controller } = setupComponent(); 103 | const getMembersSpy = sinon.spy(controller, 'getMembers'); 104 | const getBindingsSpy = sinon.spy(component, 'getBindings'); 105 | const position = new vsc.Position(0, 3); 106 | 107 | // act 108 | const results = await testProvideCompletionItems(component, position); 109 | 110 | // assert 111 | should(results).be.empty(); 112 | should(getMembersSpy.callCount).be.equal(1); 113 | should(getMembersSpy.calledWith(true)).be.true(); 114 | should(getBindingsSpy.calledOnce).be.true(); 115 | }); 116 | 117 | it('and triggering autocompletion for view model then all members and bindings are returned', async () => { 118 | // arrange 119 | config.setMockData({ 'controller.excludedMembers': '^excluded' }); 120 | 121 | const members = ['member1', 'member2', 'excludedMember', 'commonName']; 122 | const bindings = ['binding1', 'binding2', 'commonName']; 123 | 124 | const { component } = setupComponent(members, bindings); 125 | const position = new vsc.Position(0, 3); 126 | 127 | // act 128 | const results = await testProvideCompletionItems(component, position); 129 | 130 | // assert 131 | should(results.map(x => x.label)).be.eql(['member1', 'member2', 'commonName', 'binding1', 'binding2']); 132 | }); 133 | }); 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /src/utils/controller/controllerParser.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | import _ = require('lodash'); 3 | import { SourceFile } from '../sourceFile'; 4 | import { Controller } from './controller'; 5 | import { ClassMethod } from './method'; 6 | import { ClassProperty } from './property'; 7 | import { isAngularModule } from '../typescriptParser'; 8 | 9 | export class ControllerParser { 10 | private results: Controller[] = []; 11 | 12 | constructor(private file: SourceFile) { 13 | } 14 | 15 | public parse = () => { 16 | this.parseChildren(this.file.sourceFile); 17 | 18 | return this.results; 19 | } 20 | 21 | private parseChildren = (node: ts.Node) => { 22 | if (node.kind === ts.SyntaxKind.FunctionDeclaration) { 23 | const functionDeclaration = node as ts.FunctionDeclaration; 24 | 25 | const controller = new Controller(); 26 | controller.path = this.file.path; 27 | controller.name = controller.className = functionDeclaration.name.text; 28 | controller.pos = this.file.sourceFile.getLineAndCharacterOfPosition(functionDeclaration.name.pos); 29 | 30 | this.results.push(controller); 31 | } else if (this.isControllerClass(node)) { 32 | const controller = this.parseControllerClass(node); 33 | 34 | this.results.push(controller); 35 | } else if (node.kind === ts.SyntaxKind.CallExpression) { 36 | const call = node as ts.CallExpression; 37 | 38 | if (isAngularModule(call.expression)) { 39 | const controllerCall = this.findControllerRegistration(call.parent); 40 | if (controllerCall) { 41 | const controllerName = controllerCall.arguments[0] as ts.StringLiteral; 42 | const controllerIdentifier = controllerCall.arguments[1] as ts.Identifier; 43 | 44 | if (controllerName.text !== controllerIdentifier.text) { 45 | const ctrl = this.results.find(c => c.className === controllerIdentifier.text); 46 | if (ctrl) { 47 | ctrl.name = controllerName.text; 48 | } 49 | } 50 | } 51 | } else { 52 | node.getChildren().forEach(this.parseChildren); 53 | } 54 | } else { 55 | node.getChildren().forEach(this.parseChildren); 56 | } 57 | } 58 | 59 | public parseControllerClass(node: ts.ClassDeclaration) { 60 | const controller = new Controller(); 61 | controller.path = this.file.path; 62 | controller.name = controller.className = node.name.text; 63 | controller.pos = this.file.sourceFile.getLineAndCharacterOfPosition(node.members.pos); 64 | controller.baseClassName = this.getBaseClassName(node); 65 | controller.members = [ 66 | ...node.members.map(m => this.createMember(controller, m)).filter(item => item), 67 | ...this.getConstructorMembers(controller, node.members) 68 | ]; 69 | 70 | return controller; 71 | } 72 | 73 | private findControllerRegistration = (node: ts.Node): ts.CallExpression => { 74 | if (node.kind === ts.SyntaxKind.PropertyAccessExpression) { 75 | const pae = node as ts.PropertyAccessExpression; 76 | if (pae.name.text === 'controller' && pae.parent && pae.parent.kind === ts.SyntaxKind.CallExpression) { 77 | const call = pae.parent as ts.CallExpression; 78 | if (call.arguments.length === 2) { 79 | return call; 80 | } 81 | } 82 | } 83 | 84 | if (node.parent) { 85 | return this.findControllerRegistration(node.parent); 86 | } 87 | } 88 | 89 | private createMember = (controller: Controller, member: ts.ClassElement) => { 90 | if (ts.isMethodDeclaration(member)) { 91 | return ClassMethod.fromNode(controller, member, this.file.sourceFile); 92 | } else if (ts.isGetAccessorDeclaration(member)) { 93 | return ClassProperty.fromProperty(controller, member, this.file.sourceFile); 94 | } else if (ts.isPropertyDeclaration(member)) { 95 | if (member.initializer && member.initializer.kind === ts.SyntaxKind.ArrowFunction) { 96 | return ClassMethod.fromNode(controller, member, this.file.sourceFile); 97 | } else { 98 | return ClassProperty.fromProperty(controller, member, this.file.sourceFile); 99 | } 100 | } 101 | } 102 | 103 | private getConstructorMembers = (controller: Controller, members: ts.NodeArray): ClassProperty[] => { 104 | const ctor = members.find((m: ts.ClassElement): m is ts.ConstructorDeclaration => m.kind === ts.SyntaxKind.Constructor); 105 | 106 | if (ctor) { 107 | return ctor.parameters.filter(p => p.modifiers).map(p => ClassProperty.fromConstructorParameter(controller, p, this.file.sourceFile)); 108 | } 109 | 110 | return []; 111 | } 112 | 113 | private getBaseClassName = (classDeclaration: ts.ClassDeclaration): string => { 114 | if (classDeclaration.heritageClauses) { 115 | const extendsClause = classDeclaration.heritageClauses.find(hc => hc.token === ts.SyntaxKind.ExtendsKeyword); 116 | 117 | if (extendsClause && extendsClause.types.length === 1) { 118 | const typeExpression = extendsClause.types[0].expression; 119 | 120 | if (ts.isPropertyAccessExpression(typeExpression)) { 121 | return typeExpression.name.text; 122 | } 123 | 124 | return typeExpression.getText(); 125 | } 126 | } 127 | } 128 | 129 | private isControllerClass(node: ts.Node): node is ts.ClassDeclaration { 130 | return ts.isClassDeclaration(node) && !this.implementsComponentOptions(node); 131 | } 132 | 133 | private implementsComponentOptions(classDeclaration: ts.ClassDeclaration) { 134 | if (!classDeclaration.heritageClauses) { 135 | return false; 136 | } 137 | 138 | const typeNames = _.flatMap(classDeclaration.heritageClauses, x => x.types.map(t => t.getText())); 139 | 140 | return typeNames.some(t => t.includes('IComponentOptions')); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/utils/htmlDocumentHelper.ts: -------------------------------------------------------------------------------- 1 | import * as vsc from 'vscode'; 2 | import * as _ from 'lodash'; 3 | 4 | const REGEX_TAG_NAME = /<\/?([a-z0-9-]+)/i; 5 | const REGEX_ATTRIBUTE_NAME = /([a-z-]+)=/gi; 6 | const REGEX_TAG = /^<[a-z-]*$/i; 7 | 8 | export interface IBracketsPosition { 9 | opening: vsc.Position; 10 | closing: vsc.Position; 11 | } 12 | 13 | export interface IElementAttributeAutoCompletion { 14 | inClosingTag?: boolean; 15 | tag?: string; 16 | attributes?: string[]; 17 | hasOpeningTagBefore?: boolean; 18 | } 19 | 20 | export class HtmlDocumentHelper { 21 | public findTagBrackets = (document: vsc.TextDocument, startFrom: vsc.Position, direction: 'backward' | 'forward'): IBracketsPosition => { 22 | let openingPosition: vsc.Position; 23 | let closingPosition: vsc.Position; 24 | let linesToSearch: number[]; 25 | let searchFunc: (searchString: string, position?: number) => number; 26 | 27 | if (direction === 'backward') { 28 | // skip cursor position when searching backwards 29 | startFrom = this.getPreviousCharacterPosition(document, startFrom); 30 | if (!startFrom) { 31 | return { 32 | opening: undefined, 33 | closing: undefined 34 | }; 35 | } 36 | linesToSearch = _.rangeRight(startFrom.line + 1); 37 | searchFunc = String.prototype.lastIndexOf; 38 | } else { 39 | linesToSearch = _.range(startFrom.line, document.lineCount); 40 | searchFunc = String.prototype.indexOf; 41 | } 42 | 43 | let startPosition = startFrom.character; 44 | 45 | while (linesToSearch.length > 0) { 46 | const line = document.lineAt(linesToSearch.shift()); 47 | 48 | const openingTag = searchFunc.apply(line.text, ['<', startPosition]); 49 | const closingTag = searchFunc.apply(line.text, ['>', startPosition]); 50 | 51 | startPosition = undefined; // should be applied only to first searched line 52 | 53 | if (!openingPosition && openingTag > -1) { 54 | openingPosition = new vsc.Position(line.lineNumber, openingTag); 55 | } 56 | 57 | if (!closingPosition && closingTag > -1) { 58 | closingPosition = new vsc.Position(line.lineNumber, closingTag); 59 | } 60 | 61 | if (openingPosition && closingPosition) { 62 | break; 63 | } 64 | } 65 | 66 | return { 67 | opening: openingPosition, 68 | closing: closingPosition 69 | }; 70 | } 71 | 72 | private getPreviousCharacterPosition = (document: vsc.TextDocument, startFrom: vsc.Position) => { 73 | if (startFrom.character === 0) { 74 | if (startFrom.line === 0) { 75 | return undefined; 76 | } 77 | return document.lineAt(startFrom.line - 1).range.end; 78 | } else { 79 | return startFrom.translate(undefined, -1); 80 | } 81 | } 82 | 83 | private parseTag = (text: string) => { 84 | let match: RegExpExecArray; 85 | match = REGEX_TAG_NAME.exec(text); 86 | const tag = match[1]; 87 | 88 | const existingAttributes: string[] = []; 89 | 90 | // tslint:disable-next-line:no-conditional-assignment 91 | while (match = REGEX_ATTRIBUTE_NAME.exec(text)) { 92 | existingAttributes.push(match[1]); 93 | } 94 | 95 | return { tag, attributes: existingAttributes }; 96 | } 97 | 98 | public isInsideAClosedTag = (beforeCursor: IBracketsPosition, afterCursor: IBracketsPosition) => { 99 | return beforeCursor.opening && (!beforeCursor.closing || beforeCursor.closing.isBefore(beforeCursor.opening)) 100 | && afterCursor.closing && (!afterCursor.opening || afterCursor.closing.isBefore(afterCursor.opening)); 101 | } 102 | 103 | public parseAtPosition = (document: vsc.TextDocument, position: vsc.Position) => { 104 | const bracketsBeforeCursor = this.findTagBrackets(document, position, 'backward'); 105 | const bracketsAfterCursor = this.findTagBrackets(document, position, 'forward'); 106 | 107 | if (this.isInsideAClosedTag(bracketsBeforeCursor, bracketsAfterCursor)) { 108 | // get everything from starting < tag till ending > 109 | const tagTextRange = new vsc.Range(bracketsBeforeCursor.opening, bracketsAfterCursor.closing); 110 | const text = document.getText(tagTextRange); 111 | 112 | const wordPos = document.getWordRangeAtPosition(position); 113 | const word = document.getText(wordPos); 114 | 115 | const { tag } = this.parseTag(text); 116 | 117 | return { word, tag }; 118 | } 119 | } 120 | 121 | public prepareElementAttributeCompletion = (document: vsc.TextDocument, position: vsc.Position): IElementAttributeAutoCompletion => { 122 | let hasOpeningTagBefore = false; 123 | const bracketsBeforeCursor = this.findTagBrackets(document, position, 'backward'); 124 | const bracketsAfterCursor = this.findTagBrackets(document, position, 'forward'); 125 | 126 | if (bracketsBeforeCursor.opening && (!bracketsBeforeCursor.closing || bracketsBeforeCursor.closing.isBefore(bracketsBeforeCursor.opening))) { 127 | // get everything from starting < tag till the cursor 128 | const openingTagTextRange = new vsc.Range(bracketsBeforeCursor.opening, position); 129 | const text = document.getText(openingTagTextRange); 130 | 131 | if (text.startsWith(' 144 | const tagTextRange = new vsc.Range(bracketsBeforeCursor.opening, bracketsAfterCursor.closing); 145 | const text = document.getText(tagTextRange); 146 | 147 | const { tag, attributes } = this.parseTag(text); 148 | 149 | return { 150 | tag, 151 | attributes, 152 | hasOpeningTagBefore 153 | }; 154 | } 155 | 156 | return { 157 | hasOpeningTagBefore, 158 | inClosingTag: false 159 | }; 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /test/providers/componentDefinitionProvider.test.ts: -------------------------------------------------------------------------------- 1 | import should = require('should'); 2 | import * as vsc from 'vscode'; 3 | import * as ts from 'typescript'; 4 | 5 | import { ComponentDefinitionProvider } from '../../src/providers/componentDefinitionProvider'; 6 | import { MockedConfig } from '../utils/mockedConfig'; 7 | import { createHtmlDocument } from '../utils/helpers'; 8 | import { HtmlDocumentHelper } from '../../src/utils/htmlDocumentHelper'; 9 | import { Component, IComponentBinding } from '../../src/utils/component/component'; 10 | 11 | const config = new MockedConfig(); 12 | const getConfig = () => config; 13 | const htmlDocumentHelper = new HtmlDocumentHelper(); 14 | 15 | describe('Given ComponentDefinitionProvider', () => { 16 | describe('when calling provideDefinition()', () => { 17 | const cancellation = new vsc.CancellationTokenSource(); 18 | const provider = new ComponentDefinitionProvider(htmlDocumentHelper, getConfig); 19 | 20 | async function testProvideDefinition(component: Component, contents: string) { 21 | const components = component && [component] || []; 22 | provider.loadComponents(components); 23 | 24 | const { document, position } = await createHtmlDocument(contents); 25 | 26 | return provider.provideDefinition(document, position, cancellation.token); 27 | } 28 | 29 | it('and position is not inside a closed tag then empty array is returned', async () => { 30 | // arrange, act 31 | const results = await testProvideDefinition(undefined, '^ '); 32 | 33 | // assert 34 | should(results).be.empty(); 35 | }); 36 | 37 | describe('and position is inside a component tag', () => { 38 | const bindingPos = { line: 1, character: 1 }; 39 | const componentsPos = { line: 2, character: 2 }; 40 | const templatePos = { line: 3, character: 3 }; 41 | const controllerPos = { line: 4, character: 4 }; 42 | 43 | const component = { 44 | path: 'component_path.ts', 45 | htmlName: 'component', 46 | pos: componentsPos, 47 | bindings: [{ 48 | htmlName: 'binding1', 49 | pos: bindingPos 50 | }], 51 | template: { 52 | path: 'template_path.ts', 53 | pos: templatePos 54 | }, 55 | controller: { 56 | path: 'controller_path.ts', 57 | pos: controllerPos 58 | } 59 | }; 60 | 61 | it('but component is not found then empty array is returned', async () => { 62 | const results = await testProvideDefinition(undefined, ''); 63 | 64 | should(results).be.empty(); 65 | }); 66 | 67 | it(`on a binding then binding's position is returned`, async () => { 68 | const results = await testProvideDefinition(component, ''); 69 | 70 | assertPosition(results.range.start, bindingPos); 71 | }); 72 | 73 | it(`on binding's value then empty result is returned`, async () => { 74 | const results = await testProvideDefinition(component, ''); 75 | 76 | should(results).be.empty(); 77 | }); 78 | 79 | describe('on the component itself', () => { 80 | it(`and different component is found then empty result is returned`, async () => { 81 | const results = await testProvideDefinition(component, ''); 82 | 83 | should(results).be.empty(); 84 | }); 85 | 86 | it(`and goToDefinition configuration is empty then empty result is returned`, async () => { 87 | const results = await testProvideDefinition(component, ''); 88 | 89 | should(results).be.empty(); 90 | }); 91 | 92 | it(`and goToDefinition configuration is set to 'component' then component's position is returned`, async () => { 93 | config.setMockData({ goToDefinition: ['component'] }); 94 | 95 | const results = await testProvideDefinition(component, ''); 96 | 97 | should(results).have.lengthOf(1); 98 | assertPosition(results[0].range.start, componentsPos); 99 | }); 100 | 101 | it(`and goToDefinition configuration is set to 'template' then template's position is returned`, async () => { 102 | config.setMockData({ goToDefinition: ['template'] }); 103 | 104 | const results = await testProvideDefinition(component, ''); 105 | 106 | should(results).have.lengthOf(1); 107 | assertPosition(results[0].range.start, templatePos); 108 | }); 109 | 110 | it(`and goToDefinition configuration is set to 'controller' then controller's position is returned`, async () => { 111 | config.setMockData({ goToDefinition: ['controller'] }); 112 | 113 | const results = await testProvideDefinition(component, ''); 114 | 115 | should(results).have.lengthOf(1); 116 | assertPosition(results[0].range.start, controllerPos); 117 | }); 118 | 119 | it(`and goToDefinition configuration is set to all parts then all positions are returned`, async () => { 120 | config.setMockData({ goToDefinition: ['component', 'template', 'controller'] }); 121 | 122 | const results = await testProvideDefinition(component, ''); 123 | 124 | should(results).have.lengthOf(3); 125 | assertPosition(results[0].range.start, componentsPos); 126 | assertPosition(results[1].range.start, templatePos); 127 | assertPosition(results[2].range.start, controllerPos); 128 | }); 129 | }); 130 | }); 131 | }); 132 | }); 133 | 134 | function assertPosition(actual: vsc.Position, expected: ts.LineAndCharacter) { 135 | should(actual.line).be.equal(expected.line); 136 | should(actual.character).be.equal(expected.character); 137 | } 138 | -------------------------------------------------------------------------------- /test/utils/component/component.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | import * as _ from 'lodash'; 3 | 4 | import { Component, IComponentBinding } from '../../../src/utils/component/component'; 5 | import { getComponentSourceFile, getComponentsTestFilePath } from '../helpers'; 6 | 7 | describe('Give Component class', () => { 8 | describe('when calling parse in AST mode', () => { 9 | const files = ['component_simple.ts', 'component_comments.ts', 'test.component.js', 'component_consts.ts', 'component_staticFields.ts']; 10 | 11 | testFiles(files); 12 | 13 | it('and controllerAs property exists then controller alias is set', async () => { 14 | const sourceFile = getComponentSourceFile('component_ctrlAlias.ts'); 15 | 16 | const component = await Component.parse(sourceFile, [{name: 'TestController'}]); 17 | 18 | assert.equal(component[0].controllerAs, 'vm'); 19 | }); 20 | 21 | it('and controllerAs property does not exist then default controller alias is set', async () => { 22 | const sourceFile = getComponentSourceFile('component_noCtrlAlias.ts'); 23 | 24 | const component = await Component.parse(sourceFile, [{name: 'TestController'}]); 25 | 26 | assert.equal(component[0].controllerAs, '$ctrl'); 27 | }); 28 | 29 | it('with component_importLiteral.ts file then a properly parsed component is returned', async () => { 30 | const sourceFile = getComponentSourceFile('component_importLiteral.ts'); 31 | 32 | const components = await Component.parse(sourceFile, []); 33 | 34 | assertComponents(components); 35 | }); 36 | 37 | it('with component_importClass.ts file then a properly parsed component is returned', async () => { 38 | const sourceFile = getComponentSourceFile('component_importClass.ts'); 39 | 40 | const components = await Component.parse(sourceFile, []); 41 | 42 | assertComponents(components); 43 | }); 44 | 45 | it('with component_class.ts file then a properly parsed component is returned', async () => { 46 | const sourceFile = getComponentSourceFile('component_class.ts'); 47 | 48 | const components = await Component.parse(sourceFile, []); 49 | 50 | assertComponents(components); 51 | }); 52 | 53 | it('with component_literal.ts file then a properly parsed component is returned', async () => { 54 | const sourceFile = getComponentSourceFile('component_literal.ts'); 55 | 56 | const components = await Component.parse(sourceFile, []); 57 | 58 | assertComponents(components); 59 | }); 60 | 61 | it('with component_importReexportedLiteral.ts file then a properly parsed component is returned', async () => { 62 | const sourceFile = getComponentSourceFile('component_importReexportedLiteral.ts'); 63 | 64 | const components = await Component.parse(sourceFile, []); 65 | 66 | assertComponents(components); 67 | }); 68 | 69 | it('with component_importReexportedClass.ts file then a properly parsed component is returned', async () => { 70 | const sourceFile = getComponentSourceFile('component_importReexportedClass.ts'); 71 | 72 | const components = await Component.parse(sourceFile, []); 73 | 74 | assertComponents(components); 75 | }); 76 | 77 | it('with component_required_template.ts file then a properly parsed component is returned', async () => { 78 | const sourceFile = getComponentSourceFile('component_required_template.ts'); 79 | 80 | const components = await Component.parse(sourceFile, []); 81 | 82 | const expectedTemplatePath = getComponentsTestFilePath('template.html'); 83 | assert.equal(components[0].template.path, expectedTemplatePath); 84 | }); 85 | 86 | it('with component_inline_template.ts file then template body is assigned to component', async () => { 87 | const sourceFile = getComponentSourceFile('component_inline_template.ts'); 88 | 89 | const components = await Component.parse(sourceFile, []); 90 | 91 | const expectedTemplateBody = 'inlineTemplateBody'; 92 | assert.equal(components[0].template.body, expectedTemplateBody); 93 | }); 94 | 95 | it('with component_constructor_init.ts file then a properly parsed component is returned', async () => { 96 | const sourceFile = getComponentSourceFile('component_constructor_init.ts'); 97 | 98 | const components = await Component.parse(sourceFile, []); 99 | 100 | const expectedTemplatePath = getComponentsTestFilePath('example-template.html'); 101 | assert.equal(components[0].template.path, expectedTemplatePath); 102 | }); 103 | }); 104 | }); 105 | 106 | function assertComponents(components: Component[], names?: string[]) { 107 | const expectedComponentsCount = (names && names.length) || 1; 108 | 109 | assert.equal(components.length, expectedComponentsCount); 110 | for (let i = 0; i < expectedComponentsCount; i++) { 111 | assert.equal(components[i].name, (names && names[i]) || 'exampleComponent'); 112 | assert.equal(components[i].bindings.length, 1); 113 | assert.equal(components[i].bindings[0].name, 'exampleBinding'); 114 | assert.equal(components[i].bindings[0].type, '<'); 115 | } 116 | } 117 | 118 | function testFiles(files: string[]) { 119 | files.forEach((file) => { 120 | it(`with '${file}' file then a properly parsed component is returned`, async () => { 121 | const sourceFile = getComponentSourceFile(file); 122 | 123 | const component = await Component.parse(sourceFile, []); 124 | 125 | assert.equal(component.length, 1); 126 | assert.equal(component[0].name, 'exampleComponent'); 127 | assert.equal(component[0].htmlName, 'example-component'); 128 | const bindings = component[0].bindings.map(b => _.pick(b, ['name', 'htmlName', 'type'])); 129 | assert.deepEqual(bindings, [ 130 | { 131 | name: 'config', 132 | htmlName: 'config', 133 | type: '<' 134 | } as IComponentBinding, 135 | { 136 | name: 'data', 137 | htmlName: 'data', 138 | type: '<' 139 | } as IComponentBinding 140 | ]); 141 | }); 142 | }); 143 | } 144 | -------------------------------------------------------------------------------- /test/utils/controller/controller.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | import * as _ from 'lodash'; 3 | 4 | import { Controller } from '../../../src/utils/controller/controller'; 5 | import { MemberBase, MemberType, IMember } from '../../../src/utils/controller/member'; 6 | import { ClassMethod } from '../../../src/utils/controller/method'; 7 | import { getControllerSourceFile } from '../helpers'; 8 | import should = require('should'); 9 | import sinon = require('sinon'); 10 | 11 | describe('Given Controller class', () => { 12 | describe('when calling parse', () => { 13 | const testCases = [{ 14 | test_file: 'controller_simple.ts', 15 | expected_results: [{ 16 | className: 'TestController', 17 | name: 'TestController' 18 | }] 19 | }, { 20 | test_file: 'controller_differentName.ts', 21 | expected_results: [{ 22 | className: 'TestController', 23 | name: 'differentName' 24 | }] 25 | }, { 26 | test_file: 'controller_multiple.ts', 27 | expected_results: [{ 28 | className: 'TestController', 29 | name: '1' 30 | }, { 31 | className: 'TestController2', 32 | name: '2' 33 | }] 34 | }, { 35 | test_file: 'controller_multiple_ignored.ts', 36 | expected_results: [{ 37 | className: 'TestController', 38 | name: 'TestController' 39 | }] 40 | }, { 41 | test_file: 'controller_chained.ts', 42 | expected_results: [1, 2, 3].map(i => ({ 43 | className: `TestController${i}`, 44 | name: `TestController${i}` 45 | })) 46 | }]; 47 | 48 | testFiles(testCases); 49 | 50 | it('with controller with members then all members are properly parsed', async () => { 51 | const sourceFile = getControllerSourceFile('controller_members.ts'); 52 | 53 | const ctrl = (await Controller.parse(sourceFile))[0]; 54 | 55 | const members = ctrl.members.map(m => m); 56 | 57 | assertField(members, 'privateField', 'string', false); 58 | assertField(members, 'publicField', 'string', true); 59 | assertField(members, 'implicitlyPublicField', 'string', true); 60 | assertField(members, 'customType', 'IReturnType', true); 61 | 62 | assertMethod(members, 'testMethod', 'number', true, [{ name: 'p1', type: 'string' }]); 63 | assertMethod(members, 'arrowFunction', 'number', true, [{ name: 'p1', type: 'string' }, { name: 'p2', type: 'number' }]); 64 | }); 65 | }); 66 | 67 | describe('when calling isInstanceOf()', () => { 68 | it('and controller is direct instance of given class then true is returned', () => { 69 | const controller = new Controller(); 70 | controller.className = 'TestClass'; 71 | 72 | const result = controller.isInstanceOf('TestClass'); 73 | 74 | should(result).be.true(); 75 | }); 76 | 77 | it('and controller is not instance of given class then false is returned', () => { 78 | const controller = new Controller(); 79 | controller.className = 'TestClass'; 80 | 81 | const result = controller.isInstanceOf('OtherTestClass'); 82 | 83 | should(result).be.false(); 84 | }); 85 | 86 | it('and controller base class is instance of given class then isInstanceOf base class is called', () => { 87 | // arrange 88 | const TESTED_CLASS = 'OtherTestClass'; 89 | const baseClass = new Controller(); 90 | baseClass.isInstanceOf = () => true; 91 | const spy = sinon.spy(baseClass, 'isInstanceOf'); 92 | 93 | const controller = new Controller(); 94 | controller.className = 'TestClass'; 95 | controller.baseClass = baseClass; 96 | 97 | // act 98 | const result = controller.isInstanceOf(TESTED_CLASS); 99 | 100 | // assert 101 | should(result).be.equal(true); 102 | should(spy.calledOnce).be.true(); 103 | }); 104 | }); 105 | 106 | describe('when calling getMembers()', () => { 107 | let controller: Controller; 108 | 109 | beforeEach(() => { 110 | const baseController = new Controller(); 111 | baseController.members = [{ 112 | isPublic: true 113 | }, { 114 | isPublic: false 115 | }]; 116 | 117 | controller = new Controller(); 118 | controller.members = [{ 119 | isPublic: true 120 | }, { 121 | isPublic: false 122 | }]; 123 | 124 | controller.baseClass = baseController; 125 | }); 126 | 127 | it('with `true` then only public members are returned', () => { 128 | const result = controller.getMembers(true); 129 | 130 | should(result).have.lengthOf(2); 131 | }); 132 | 133 | it('with `false` then all members are returned', () => { 134 | const result = controller.getMembers(false); 135 | 136 | should(result).have.lengthOf(4); 137 | }); 138 | }); 139 | }); 140 | 141 | const assertField = (members: MemberBase[], name: string, returnType: string, isPublic: boolean) => { 142 | assertMember(members, name, MemberType.Property, returnType, isPublic); 143 | }; 144 | 145 | const assertMethod = (members: MemberBase[], name: string, returnType: string, isPublic: boolean, params?: Array<{ name: string, type: string }>) => { 146 | const member = assertMember(members, name, MemberType.Method, returnType, isPublic); 147 | 148 | if (params) { 149 | assert.deepEqual(member.parameters, params); 150 | } 151 | }; 152 | 153 | function assertMember(members: MemberBase[], name: string, type: MemberType, returnType: string, isPublic: boolean) { 154 | const member = members.find(m => m.name === name); 155 | assert.notEqual(member, undefined, `Cannot find member '${name}'`); 156 | 157 | assert.equal(member.type, type, `type for field '${name}' does not match`); 158 | assert.equal(member.returnType, returnType, `returnType for field '${name}' does not match`); 159 | assert.equal(member.isPublic, isPublic, `isPublic for field '${name}' does not match`); 160 | 161 | return member; 162 | } 163 | 164 | function testFiles(cases: Array<{ test_file: string, expected_results: Array<{ className: string, name: string }> }>) { 165 | cases.forEach(test => { 166 | it(`with '${test.test_file}' file then a properly parsed controller is returned`, async () => { 167 | const sourceFile = getControllerSourceFile(test.test_file); 168 | 169 | const controllers = await Controller.parse(sourceFile); 170 | 171 | assert.equal(controllers.length, test.expected_results.length); 172 | 173 | const results = controllers.map(b => _.pick(b, ['className', 'name'])); 174 | 175 | assert.deepEqual(results, test.expected_results); 176 | }); 177 | }); 178 | } 179 | -------------------------------------------------------------------------------- /src/utils/component/componentsCache.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as _ from 'lodash'; 4 | import * as vsc from 'vscode'; 5 | import { Component } from './component'; 6 | import { Controller } from '../controller/controller'; 7 | import { SourceFile } from '../sourceFile'; 8 | import { SourceFilesScanner } from '../sourceFilesScanner'; 9 | import { FileWatcher } from '../fileWatcher'; 10 | import { EventEmitter } from 'events'; 11 | import { events } from '../../symbols'; 12 | import { logError } from '../logging'; 13 | import { getConfiguration } from '../vsc'; 14 | import { RelativePath } from '../htmlTemplate/relativePath'; 15 | 16 | export class ComponentsCache extends EventEmitter implements vsc.Disposable { 17 | private scanner = new SourceFilesScanner(); 18 | private components: Component[] = []; 19 | private controllers: Controller[] = []; 20 | private componentWatcher: FileWatcher; 21 | private controllerWatcher: FileWatcher; 22 | 23 | private emitComponentsChanged = () => this.emit(events.componentsChanged, this.components); 24 | 25 | private setupWatchers = (config: vsc.WorkspaceConfiguration) => { 26 | const componentGlobs = config.get('componentGlobs') as string[]; 27 | const controllerGlobs = config.get('controllerGlobs') as string[]; 28 | 29 | this.dispose(); 30 | 31 | this.componentWatcher = new FileWatcher('Component', componentGlobs, this.onComponentAdded, this.onComponentChanged, this.onComponentDeleted); 32 | this.controllerWatcher = new FileWatcher('Controller', controllerGlobs, this.onControllerAdded, this.onControllerChanged, this.onControllerDeleted); 33 | } 34 | 35 | private onControllerAdded = async (uri: vsc.Uri) => { 36 | const src = await SourceFile.parse(uri.fsPath); 37 | const controllers = await Controller.parse(src); 38 | 39 | this.controllers.push.apply(this.controllers, controllers); 40 | this.reassignControllers(controllers); 41 | this.assignControllersBaseClasses(); 42 | this.emitComponentsChanged(); 43 | } 44 | 45 | private onControllerDeleted = (uri: vsc.Uri) => { 46 | const filepath = RelativePath.fromUri(uri); 47 | const controllersInFile = this.controllers.filter(c => filepath.equals(c.path)); 48 | 49 | this.components 50 | .filter(c => controllersInFile.some(ctrl => ctrl === c.controller)) 51 | .forEach(c => c.controller = null); 52 | 53 | this.deleteFile(this.controllers, filepath); 54 | this.assignControllersBaseClasses(); 55 | this.emitComponentsChanged(); 56 | } 57 | 58 | private onControllerChanged = async (uri: vsc.Uri) => { 59 | const filepath = RelativePath.fromUri(uri); 60 | 61 | const idx = this.controllers.findIndex(c => filepath.equals(c.path)); 62 | if (idx === -1) { 63 | // tslint:disable-next-line:no-console 64 | console.warn('Controller does not exist, cannot update it'); 65 | return; 66 | } 67 | 68 | const src = await SourceFile.parse(filepath.absolute); 69 | const controllers = await Controller.parse(src); 70 | 71 | this.deleteFile(this.controllers, filepath); 72 | this.controllers.push.apply(this.controllers, controllers); 73 | 74 | this.reassignControllers(controllers); 75 | this.assignControllersBaseClasses(); 76 | this.emitComponentsChanged(); 77 | } 78 | 79 | private reassignControllers = (changedControllers: Controller[]) => { 80 | // check if there are any components already using these new controllers and assign them if so 81 | const ctrlNames = _.keyBy(changedControllers, c => c.name); 82 | const ctrlClassNames = _.keyBy(changedControllers, c => c.className); 83 | 84 | this.components 85 | .filter(c => c.controllerName && ctrlNames[c.controllerName] != null) 86 | .forEach(c => c.controller = ctrlNames[c.controllerName]); 87 | 88 | this.components 89 | .filter(c => c.controllerClassName && ctrlClassNames[c.controllerClassName] != null) 90 | .forEach(c => c.controller = ctrlClassNames[c.controllerClassName]); 91 | } 92 | 93 | private assignControllersBaseClasses = () => { 94 | const controllersByClassName = _.keyBy(this.controllers, c => c.className); 95 | 96 | this.controllers.filter(c => c.baseClassName).forEach(c => c.baseClass = controllersByClassName[c.baseClassName]); 97 | } 98 | 99 | private onComponentAdded = async (uri: vsc.Uri) => { 100 | const src = await SourceFile.parse(uri.fsPath); 101 | const components = await Component.parse(src, this.controllers); 102 | 103 | this.components.push.apply(this.components, components); 104 | this.emitComponentsChanged(); 105 | } 106 | 107 | private onComponentChanged = async (uri: vsc.Uri) => { 108 | const filepath = RelativePath.fromUri(uri); 109 | 110 | const idx = this.components.findIndex(c => filepath.equals(c.path)); 111 | if (idx === -1) { 112 | // tslint:disable-next-line:no-console 113 | console.warn('Component does not exist, cannot update it'); 114 | return; 115 | } 116 | 117 | const src = await SourceFile.parse(filepath.absolute); 118 | const components = await Component.parse(src, this.controllers); 119 | 120 | this.deleteFile(this.components, filepath); 121 | this.components.push.apply(this.components, components); 122 | this.emitComponentsChanged(); 123 | } 124 | 125 | private onComponentDeleted = (uri: vsc.Uri) => { 126 | this.deleteFile(this.components, RelativePath.fromUri(uri)); 127 | this.emitComponentsChanged(); 128 | } 129 | 130 | private deleteFile = (collection: Array<{ path: string }>, filepath: RelativePath) => { 131 | let idx; 132 | do { 133 | idx = collection.findIndex(c => filepath.equals(c.path)); 134 | if (idx > -1) { 135 | collection.splice(idx, 1); 136 | } 137 | } while (idx > -1); 138 | } 139 | 140 | public refresh = async (): Promise => { 141 | const config = getConfiguration(); 142 | 143 | this.setupWatchers(config); 144 | this.controllers = await this.scanner.findFiles('controllerGlobs', Controller.parse, 'Controller'); 145 | this.assignControllersBaseClasses(); 146 | 147 | const parseComponent = (src: SourceFile) => Component.parse(src, this.controllers); 148 | 149 | return this.scanner.findFiles('componentGlobs', parseComponent, 'Component').then((result: Component[]) => { 150 | this.components = result; 151 | 152 | return { 153 | components: this.components, 154 | controllers: this.controllers 155 | }; 156 | }).catch((err) => { 157 | logError(err); 158 | vsc.window.showErrorMessage('There was an error refreshing components cache, check console for errors'); 159 | return { 160 | components: null, 161 | controllers: null 162 | }; 163 | }); 164 | } 165 | 166 | public dispose() { 167 | if (this.componentWatcher) { 168 | this.componentWatcher.dispose(); 169 | } 170 | 171 | if (this.controllerWatcher) { 172 | this.controllerWatcher.dispose(); 173 | } 174 | 175 | this.removeAllListeners(); 176 | } 177 | } 178 | 179 | export interface IComponentInfoResult { 180 | components: Component[]; 181 | controllers: Controller[]; 182 | } 183 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vscode-angular-components-intellisense", 3 | "displayName": "ng1.5 components utility", 4 | "description": "Angular 1.5 components utility", 5 | "author": "Ireneusz Patalas", 6 | "license": "MIT", 7 | "version": "1.0.0", 8 | "publisher": "ipatalas", 9 | "contributors": [ 10 | "Ireneusz Patalas", 11 | "Kamil Haładus" 12 | ], 13 | "engines": { 14 | "vscode": "^1.18.0" 15 | }, 16 | "categories": [ 17 | "Programming Languages", 18 | "Other" 19 | ], 20 | "keywords": [ 21 | "angular", 22 | "AngularJS" 23 | ], 24 | "icon": "images/angular.png", 25 | "galleryBanner": { 26 | "color": "#0273D4", 27 | "theme": "dark" 28 | }, 29 | "activationEvents": [ 30 | "*" 31 | ], 32 | "repository": { 33 | "type": "git", 34 | "url": "https://github.com/ipatalas/ngComponentUtility" 35 | }, 36 | "main": "./out/src/main", 37 | "contributes": { 38 | "commands": [ 39 | { 40 | "command": "ngComponents.refreshAngularComponents", 41 | "title": "Refresh Components Cache", 42 | "category": "ngComponents" 43 | }, 44 | { 45 | "command": "ngComponents.refreshMemberDiagnostics", 46 | "title": "Refresh Member Diagnostics", 47 | "category": "ngComponents" 48 | }, 49 | { 50 | "command": "ngComponents.findUnusedAngularComponents", 51 | "title": "Find unused Angular components", 52 | "category": "ngComponents" 53 | }, 54 | { 55 | "command": "ngComponents.findUnusedDirectives", 56 | "title": "Find unused Angular directives", 57 | "category": "ngComponents" 58 | }, 59 | { 60 | "command": "ngComponents.markAsAngularProject", 61 | "title": "Force enable ngComponents utility on this workspace", 62 | "category": "ngComponents" 63 | }, 64 | { 65 | "command": "ngComponents.switchComponentParts", 66 | "title": "Switch between component/controller/template", 67 | "category": "ngComponents" 68 | } 69 | ], 70 | "keybindings": [ 71 | { 72 | "command": "ngComponents.switchComponentParts", 73 | "key": "Alt+o" 74 | } 75 | ], 76 | "configuration": { 77 | "title": "ng1.5 components utility", 78 | "type": "object", 79 | "properties": { 80 | "ngComponents.componentGlobs": { 81 | "type": [ 82 | "array", 83 | "string" 84 | ], 85 | "default": [ 86 | "**/*Component.ts" 87 | ], 88 | "description": "glob string used to search for files with components" 89 | }, 90 | "ngComponents.controllerGlobs": { 91 | "type": [ 92 | "array", 93 | "string" 94 | ], 95 | "default": [ 96 | "**/*Controller.ts" 97 | ], 98 | "description": "glob string used to search for files with controllers" 99 | }, 100 | "ngComponents.htmlGlobs": { 101 | "type": [ 102 | "array", 103 | "string" 104 | ], 105 | "default": [ 106 | "**/*.html" 107 | ], 108 | "description": "glob string used to search for HTML files for references of the components" 109 | }, 110 | "ngComponents.routeGlobs": { 111 | "type": [ 112 | "array", 113 | "string" 114 | ], 115 | "default": [ 116 | "**/*route.ts" 117 | ], 118 | "description": "glob string used to search for angular-ui-router files for references of the components" 119 | }, 120 | "ngComponents.directiveGlobs": { 121 | "type": [ 122 | "array", 123 | "string" 124 | ], 125 | "default": [ 126 | "**/*.directive.ts" 127 | ], 128 | "description": "glob string used to search for directive files" 129 | }, 130 | "ngComponents.goToDefinition": { 131 | "type": [ 132 | "array", 133 | "string" 134 | ], 135 | "default": [ 136 | "template", 137 | "controller" 138 | ], 139 | "uniqueItems": true, 140 | "description": "Specify which parts of the component should the Go To Definition action show" 141 | }, 142 | "ngComponents.controller.publicMembersOnly": { 143 | "type": "boolean", 144 | "default": true, 145 | "description": "Set to false to show all members in auto complete" 146 | }, 147 | "ngComponents.controller.excludedMembers": { 148 | "type": "string", 149 | "default": "^\\$", 150 | "description": "Regular expression which can exclude additional members (defaults to exclude Angular lifecycle hooks)" 151 | }, 152 | "ngComponents.logging.verbose": { 153 | "type": "boolean", 154 | "default": false, 155 | "description": "Set to true to enable verbose logging in developers console" 156 | }, 157 | "ngComponents.logging.redirectToFile": { 158 | "type": "string", 159 | "default": null, 160 | "description": "Set path to which you want to redirect logging to" 161 | }, 162 | "ngComponents.forceEnable": { 163 | "type": "boolean", 164 | "default": false, 165 | "description": "Use this setting to force enable the extension if AngularJS was not detected automatically" 166 | }, 167 | "ngComponents.angularRoot": { 168 | "type": "string", 169 | "default": null, 170 | "description": "Custom Angular root folder relative to workspace root (defaults to workspace root)" 171 | }, 172 | "ngComponents.memberDiagnostics.enabled": { 173 | "type": "boolean", 174 | "default": false, 175 | "description": "Enables validation of all components' HTML templates in regards to fields that are used there (experimental)" 176 | }, 177 | "ngComponents.memberDiagnostics.html.checkBindings": { 178 | "type": "boolean", 179 | "default": true, 180 | "description": "When disabled use of component's binding in the template when it's not defined in the controller will issue a warning" 181 | }, 182 | "ngComponents.memberDiagnostics.html.checkControllerMembers": { 183 | "type": "boolean", 184 | "default": true, 185 | "description": "When disabled use of component's controller member in the template will issue a warning" 186 | }, 187 | "ngComponents.memberDiagnostics.didYouMean.similarityThreshold": { 188 | "type": "number", 189 | "default": 0.6, 190 | "description": "Similarity thresold for Did You Mean suggestions" 191 | }, 192 | "ngComponents.memberDiagnostics.didYouMean.maxResults": { 193 | "type": "number", 194 | "default": 2, 195 | "description": "Determines how many suggestions to show if there are more available" 196 | } 197 | } 198 | } 199 | }, 200 | "scripts": { 201 | "vscode:prepublish": "tsc -p ./", 202 | "compile": "tsc -watch -p ./", 203 | "postinstall": "node ./node_modules/vscode/bin/install", 204 | "test": "cross-env NODE_ENV=test node ./node_modules/vscode/bin/test", 205 | "linter": "tslint -p .", 206 | "coverage": "node node_modules/codacy-coverage/bin/codacy-coverage.js < ./coverage/lcov.info" 207 | }, 208 | "devDependencies": { 209 | "@types/angular": "^1.6.55", 210 | "@types/glob": "^5.0.35", 211 | "@types/lodash": "^4.14.136", 212 | "@types/mocha": "^5.2.0", 213 | "@types/node": "^8.10.12", 214 | "@types/proxyquire": "^1.3.28", 215 | "@types/sinon": "^4.3.1", 216 | "codacy-coverage": "^3.4.0", 217 | "cross-env": "^5.2.0", 218 | "decache": "^4.5.1", 219 | "glob": "^7.1.4", 220 | "istanbul": "^0.4.5", 221 | "mocha": "^5.0.0", 222 | "proxyquire": "^2.1.1", 223 | "remap-istanbul": "^0.13.0", 224 | "should": "^13.2.3", 225 | "sinon": "^5.0.3", 226 | "tslint": "^5.18.0", 227 | "vscode": "^1.1.36" 228 | }, 229 | "dependencies": { 230 | "didyoumean2": "^1.3.0", 231 | "lodash": "^4.17.15", 232 | "parse5": "^3.0.2", 233 | "pretty-hrtime": "^1.0.3", 234 | "typescript": "^3.5.3" 235 | } 236 | } 237 | --------------------------------------------------------------------------------