Skip to content

Commit bde0e95

Browse files
committed
feat: 🎸 parse JSDocs
1 parent b7b6892 commit bde0e95

File tree

3 files changed

+163
-4
lines changed

3 files changed

+163
-4
lines changed

src/parser/shared/model/decorator.model.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export interface NgParselFieldDecorator {
66
initializer?: string;
77
initialValue?: string | null;
88
field: string;
9+
jsDoc: string | undefined;
910
}
1011

1112
export interface NgParselDecoratorProperties {

src/parser/shared/parser/field-decorator.parser.spec.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,49 @@ describe('Field Decorator', function () {
3636
});
3737
});
3838

39+
it('should parse JSDoc comments for input and output decorators', () => {
40+
const ast = tsquery.ast(`
41+
export class MyTestClass {
42+
/**
43+
* This is a JSDoc comment for myInput
44+
* @description Input field for the component
45+
*/
46+
@Input() myInput: string;
47+
48+
/**
49+
* This is a JSDoc comment for myOutput
50+
* @description Output event for the component
51+
*/
52+
@Output() myOutput = new EventEmitter();
53+
}
54+
`);
55+
56+
const expectedInputs = [
57+
{
58+
decorator: '@Input()',
59+
name: 'myInput',
60+
type: 'string',
61+
field: '@Input() myInput: string',
62+
jsDoc: 'This is a JSDoc comment for myInput\n@description Input field for the component',
63+
},
64+
];
65+
66+
const expectedOutputs = [
67+
{
68+
decorator: '@Output()',
69+
name: 'myOutput',
70+
initializer: 'new EventEmitter()',
71+
field: '@Output() myOutput = new EventEmitter()',
72+
jsDoc: 'This is a JSDoc comment for myOutput\n@description Output event for the component',
73+
},
74+
];
75+
76+
expect(parseInputsAndOutputs(ast)).toEqual({
77+
inputs: expectedInputs,
78+
outputs: expectedOutputs,
79+
});
80+
});
81+
3982
it('should parse input setters', () => {
4083
const ast = tsquery.ast(`
4184
export class MyTestClass {
@@ -84,6 +127,35 @@ describe('Field Decorator', function () {
84127
});
85128
});
86129

130+
it('should parse JSDoc comments for signal inputs', () => {
131+
const ast = tsquery.ast(`
132+
export class MyTestClass {
133+
/**
134+
* This is a JSDoc comment for a signal input
135+
* @description Signal input field for the component
136+
*/
137+
test = input<string>("default value");
138+
}
139+
`);
140+
141+
const expectedInputs = [
142+
{
143+
decorator: 'input',
144+
name: 'test',
145+
initialValue: '"default value"',
146+
type: 'string',
147+
required: false,
148+
field: 'test = input<string>("default value");',
149+
jsDoc:
150+
'/**\n * This is a JSDoc comment for a signal input\n * @description Signal input field for the component\n */',
151+
},
152+
];
153+
expect(parseInputsAndOutputs(ast)).toEqual({
154+
inputs: expectedInputs,
155+
outputs: [],
156+
});
157+
});
158+
87159
it('should parse a signal input with a union type', () => {
88160
const ast = tsquery.ast(`
89161
export class MyTestClass {
@@ -730,6 +802,33 @@ describe('Field Decorator', function () {
730802
});
731803
});
732804

805+
it('should parse JSDoc comments for outputs', () => {
806+
const ast = tsquery.ast(`
807+
export class MyTestClass {
808+
/**
809+
* This is a JSDoc comment for an output
810+
* @description Output event for the component
811+
*/
812+
counterChange = output<number>();
813+
}
814+
`);
815+
816+
const expectedOutputs = [
817+
{
818+
decorator: 'output',
819+
name: 'counterChange',
820+
type: 'number',
821+
field: 'counterChange = output<number>();',
822+
jsDoc:
823+
'/**\n * This is a JSDoc comment for an output\n * @description Output event for the component\n */',
824+
},
825+
];
826+
expect(parseInputsAndOutputs(ast)).toEqual({
827+
inputs: [],
828+
outputs: expectedOutputs,
829+
});
830+
});
831+
733832
it('should parse the new output outputFromObservable API', () => {
734833
const ast = tsquery.ast(`
735834
export class MyTestClass {

src/parser/shared/parser/field-decorator.parser.ts

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,24 @@ import { tsquery } from '@phenomnomnominal/tsquery';
33

44
import { NgParselFieldDecorator } from '../model/decorator.model.js';
55

6+
function extractJSDocComment(node: ts.Node | undefined): string | undefined {
7+
if (!node) return undefined;
8+
9+
const jsDocs = (node as any).jsDoc as ts.JSDoc[] | undefined;
10+
if (!jsDocs || jsDocs.length === 0) return undefined;
11+
12+
const jsDoc = jsDocs[0];
13+
let result = (jsDoc?.comment as string) ?? '';
14+
15+
if (jsDoc?.tags) {
16+
for (const tag of jsDoc.tags) {
17+
result += `\n@${tag.tagName.getText()} ${tag.comment ?? ''}`;
18+
}
19+
}
20+
21+
return result.trim();
22+
}
23+
624
export function parseInputsAndOutputs(ast: ts.SourceFile): {
725
inputs: NgParselFieldDecorator[];
826
outputs: NgParselFieldDecorator[];
@@ -30,11 +48,14 @@ function parseDecoratedSetters(ast: ts.SourceFile): NgParselFieldDecorator[] {
3048
const name = (decoratedSetters[i] as any)?.name?.getText();
3149
const type = (decoratedSetters[i] as any)?.parameters[0]?.type?.getText();
3250

51+
const jsDoc = extractJSDocComment(decoratedSetters[i]);
52+
3353
inputSetters.push({
3454
decorator: '@Input()',
3555
name,
3656
type,
3757
field: decoratedSetters[i]?.getText() || '',
58+
jsDoc,
3859
});
3960
}
4061

@@ -46,10 +67,10 @@ function parseDecoratedPropertyDeclarations(ast: ts.SourceFile): {
4667
outputs: NgParselFieldDecorator[];
4768
} {
4869
/*
49-
This is afaik the only way to get the Decorator name
50-
- getDecorators() returns nothing
51-
- canHaveDecorators() returns false
52-
*/
70+
This is afaik the only way to get the Decorator name
71+
- getDecorators() returns nothing
72+
- canHaveDecorators() returns false
73+
*/
5374
const decoratorPropertyDecorator = [...tsquery(ast, 'PropertyDeclaration:has(Decorator) > Decorator')];
5475
const decoratorPropertyDeclaration = [...tsquery(ast, 'PropertyDeclaration:has(Decorator)')];
5576

@@ -65,12 +86,15 @@ function parseDecoratedPropertyDeclarations(ast: ts.SourceFile): {
6586
const initializer = (decoratorPropertyDeclaration[i] as any)?.initializer?.getText();
6687
const field = `${decorator} ${name}${type ? ': ' + type : ' = ' + initializer}`;
6788

89+
const jsDoc = extractJSDocComment(decoratorPropertyDeclaration[i]!);
90+
6891
const componentDecorator = {
6992
decorator: decorator as string,
7093
name,
7194
type,
7295
initializer,
7396
field,
97+
jsDoc,
7498
};
7599

76100
if (decorator?.startsWith('@Inp')) {
@@ -129,13 +153,16 @@ function parseSignalInputsAndModels(ast: ts.SourceFile): NgParselFieldDecorator[
129153
),
130154
][0]?.getText() || 'inferred';
131155

156+
const jsDoc = extractJSDocComment(input);
157+
132158
if (required) {
133159
signalInputs.push({
134160
decorator,
135161
required,
136162
name: alias || name,
137163
type,
138164
field,
165+
jsDoc,
139166
});
140167
} else {
141168
signalInputs.push({
@@ -145,6 +172,7 @@ function parseSignalInputsAndModels(ast: ts.SourceFile): NgParselFieldDecorator[
145172
name: alias || name,
146173
type,
147174
field,
175+
jsDoc,
148176
});
149177
}
150178
});
@@ -153,6 +181,29 @@ function parseSignalInputsAndModels(ast: ts.SourceFile): NgParselFieldDecorator[
153181
}
154182

155183
function parseNewOutputAPI(ast: ts.SourceFile): NgParselFieldDecorator[] {
184+
// First, try to find JSDoc comments directly in the source file
185+
const fullText = ast.getFullText();
186+
const jsDocRegex = /\/\*\*([\s\S]*?)\*\//g;
187+
const jsDocMatches: { [key: string]: string } = {};
188+
189+
let match;
190+
while ((match = jsDocRegex.exec(fullText)) !== null) {
191+
// Find the next non-whitespace character after the JSDoc comment
192+
let nextNonWhitespace = match.index + match[0].length;
193+
while (nextNonWhitespace < fullText.length && /\s/.test(fullText[nextNonWhitespace]!)) {
194+
nextNonWhitespace++;
195+
}
196+
197+
// Extract the field name if it's followed by an output declaration
198+
if (nextNonWhitespace < fullText.length) {
199+
const remainingText = fullText.substring(nextNonWhitespace);
200+
const outputMatch = /(\w+)\s*=\s*output/.exec(remainingText);
201+
if (outputMatch) {
202+
jsDocMatches[outputMatch[1]!] = match[0];
203+
}
204+
}
205+
}
206+
156207
const outputNodes = [...tsquery(ast, 'PropertyDeclaration:has(CallExpression:has([name="output"]))')];
157208
const outPutnodesFromObservable = [
158209
...tsquery(ast, 'PropertyDeclaration:has(CallExpression:has([name="outputFromObservable"]))'),
@@ -167,18 +218,26 @@ function parseNewOutputAPI(ast: ts.SourceFile): NgParselFieldDecorator[] {
167218
const name = [...tsquery(field, 'BinaryExpression > Identifier')][0]?.getText() || '';
168219
const type = [...tsquery(field, 'CallExpression > *:last-child')][0]?.getText() || '';
169220

221+
// Try to get JSDoc from our map first, then fall back to extractJSDocComment
222+
let jsDoc = jsDocMatches[name];
223+
if (!jsDoc) {
224+
jsDoc = extractJSDocComment(node);
225+
}
226+
170227
if (isObservableOutput) {
171228
outputs.push({
172229
decorator: 'outputFromObservable',
173230
name,
174231
field,
232+
jsDoc,
175233
});
176234
} else {
177235
outputs.push({
178236
decorator: 'output',
179237
name,
180238
type,
181239
field,
240+
jsDoc,
182241
});
183242
}
184243
});

0 commit comments

Comments
 (0)