diff --git a/src/parser/services/service.model.ts b/src/parser/services/service.model.ts index 3b538bd..91935d7 100644 --- a/src/parser/services/service.model.ts +++ b/src/parser/services/service.model.ts @@ -1,6 +1,8 @@ import { NgParselOutput } from '../shared/model/types.model.js'; import { NgParselMethod } from '../shared/model/method.model.js'; +import { NgParselField } from '../shared/model/field.model.js'; export interface NgParselService extends NgParselOutput { + fieldsPublicExplicit: NgParselField[]; methodsPublicExplicit: NgParselMethod[]; } diff --git a/src/parser/services/service.parser.spec.ts b/src/parser/services/service.parser.spec.ts index 094151f..b90dd87 100644 --- a/src/parser/services/service.parser.spec.ts +++ b/src/parser/services/service.parser.spec.ts @@ -24,6 +24,7 @@ describe('ServiceParser', () => { type: NgParselOutputType.SERVICE, className, filePath, + fieldsPublicExplicit: [], methodsPublicExplicit: [ { name: 'foo', @@ -59,6 +60,53 @@ describe('ServiceParser', () => { type: NgParselOutputType.SERVICE, className, filePath, + fieldsPublicExplicit: [], + methodsPublicExplicit: [], + }; + + const parselOutput = parseService(ast, filePath); + expect(parselOutput).toEqual(expectedOutput); + }); + + it('should parse a Angular Service with explicit public fields', function () { + const className = 'MyTestService'; + const mockService = ` + export class ${className} { + public counter = signal(0); + public counter$ = new BehaviorSubject(0); + public increment = new Subject(); + + private notVisible = 'blub' + + foo(bar: string): string { + } + + bar(foo: string): string {} + } + `; + const ast = tsquery.ast(mockService); + const filePath = 'foo.service.ts'; + + const expectedOutput = { + type: NgParselOutputType.SERVICE, + className, + filePath, + fieldsPublicExplicit: [ + { + name: 'counter', + type: 'inferred', + value: 'signal(0)', + }, + { + name: 'counter$', + type: 'inferred', + value: 'BehaviorSubject(0)', + }, + { + name: 'increment', + type: 'Subject', + }, + ], methodsPublicExplicit: [], }; diff --git a/src/parser/services/service.parser.ts b/src/parser/services/service.parser.ts index 2f4f58a..5f9a669 100644 --- a/src/parser/services/service.parser.ts +++ b/src/parser/services/service.parser.ts @@ -5,12 +5,14 @@ import { parseClassName } from '../shared/parser/class.parser.js'; import { parseExplicitPublicMethods } from '../shared/parser/method.parser.js'; import { NgParselService } from './service.model.js'; +import { parseExplicitPublicFields } from '../shared/parser/field.parser.js'; export function parseService(ast: ts.SourceFile, filePath: string): NgParselService { return { type: NgParselOutputType.SERVICE, className: parseClassName(ast), filePath, + fieldsPublicExplicit: parseExplicitPublicFields(ast), methodsPublicExplicit: parseExplicitPublicMethods(ast), }; } diff --git a/src/parser/shared/model/field.model.ts b/src/parser/shared/model/field.model.ts new file mode 100644 index 0000000..009cea4 --- /dev/null +++ b/src/parser/shared/model/field.model.ts @@ -0,0 +1,5 @@ +export interface NgParselField { + name: string; + type: string | 'inferred'; + value?: any; +} diff --git a/src/parser/shared/parser/field.parser.spec.ts b/src/parser/shared/parser/field.parser.spec.ts new file mode 100644 index 0000000..65073ef --- /dev/null +++ b/src/parser/shared/parser/field.parser.spec.ts @@ -0,0 +1,84 @@ +import { parseExplicitPublicFields } from './field.parser.js'; +import { tsquery } from '@phenomnomnominal/tsquery'; + +describe('Field parser', () => { + it('should extract a public Signal field', () => { + const ast = tsquery.ast(` + export class MyTestClass { + public visible = signal('0'); + private notVisibleToYou = 'foo'; + } + `); + + const expectedFields = [ + { + name: 'visible', + type: 'inferred', + value: `signal('0')`, + }, + ]; + + const fieldsPublicExplicit = parseExplicitPublicFields(ast); + expect(fieldsPublicExplicit).toEqual(expectedFields); + }); + + it('should extract a public property field', () => { + const ast = tsquery.ast(` + export class MyTestClass { + public visible = 'some value'; + private notVisibleToYou = 'foo'; + } + `); + + const expectedFields = [ + { + name: 'visible', + type: 'inferred', + value: `'some value'`, + }, + ]; + + const fieldsPublicExplicit = parseExplicitPublicFields(ast); + expect(fieldsPublicExplicit).toEqual(expectedFields); + }); + + it('should extract a public Subject', () => { + const ast = tsquery.ast(` + export class MyTestClass { + public visible = new Subject('some value'); + private notVisibleToYou = 'foo'; + } + `); + + const expectedFields = [ + { + name: 'visible', + type: 'inferred', + value: `Subject('some value')`, + }, + ]; + + const fieldsPublicExplicit = parseExplicitPublicFields(ast); + expect(fieldsPublicExplicit).toEqual(expectedFields); + }); + + it('should extract a public Subject with type void', () => { + const ast = tsquery.ast(` + export class MyTestClass { + public visible = new Subject(); + private notVisibleToYou = 'foo'; + } + `); + + const expectedFields = [ + { + name: 'visible', + type: 'Subject', + value: undefined, + }, + ]; + + const fieldsPublicExplicit = parseExplicitPublicFields(ast); + expect(fieldsPublicExplicit).toEqual(expectedFields); + }); +}); diff --git a/src/parser/shared/parser/field.parser.ts b/src/parser/shared/parser/field.parser.ts new file mode 100644 index 0000000..449d0d0 --- /dev/null +++ b/src/parser/shared/parser/field.parser.ts @@ -0,0 +1,40 @@ +import * as ts from 'typescript'; +import { tsquery } from '@phenomnomnominal/tsquery'; + +import { NgParselField } from '../model/field.model.js'; + +export function parseExplicitPublicFields(ast: ts.SourceFile): NgParselField[] { + const fieldsExplicitPublic: NgParselField[] = []; + + const publicFields = tsquery(ast, 'PropertyDeclaration:has(PublicKeyword)'); + + publicFields.forEach((field) => { + const nameNode = tsquery(field.getText(), 'Identifier')[0]; + let typeNode = null; + const valueNode = + tsquery(field.getText(), 'CallExpression')[0] || + tsquery( + field.getText(), + 'NewExpression:has(NullKeyword, ObjectLiteralExpression, ArrayLiteralExpression, TrueKeyword, FalseKeyword, StringLiteral, Identifier[name=undefined], NumericLiteral, TemplateExpression, NoSubstitutionTemplateLiteral)' + )[0] || + tsquery( + field.getText(), + 'NullKeyword, ObjectLiteralExpression, ArrayLiteralExpression, TrueKeyword, FalseKeyword, StringLiteral, Identifier[name=undefined], NumericLiteral, TemplateExpression, NoSubstitutionTemplateLiteral' + )[0]; + + if (!valueNode) { + typeNode = + tsquery(field.getText(), 'TypeReference')[0] || + tsquery(field.getText(), 'NewExpression')[0] || + tsquery(field.getText(), 'CallExpression')[0]; + } + + fieldsExplicitPublic.push({ + name: nameNode!.getText(), + type: typeNode?.getText().replace('new ', '').replace('()', '') || 'inferred', + value: valueNode?.getText().replace('new ', '').replace('()', ''), + }); + }); + + return fieldsExplicitPublic; +} diff --git a/test-spa/src/app/core/counter.service.ts b/test-spa/src/app/core/counter.service.ts new file mode 100644 index 0000000..193e5de --- /dev/null +++ b/test-spa/src/app/core/counter.service.ts @@ -0,0 +1,15 @@ +import { Injectable, signal } from '@angular/core'; +import { BehaviorSubject, Subject } from 'rxjs'; + +@Injectable({ + providedIn: 'root', +}) +export class CounterService { + public counter = signal(0); + public counter$ = new BehaviorSubject(0); + public increment = new Subject(); + + public foo = true; + + private notVisible = 'blub'; +}