@siemens/eslint-plugin-defaultvalue 1.0.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.md ADDED
@@ -0,0 +1,20 @@
1
+ The MIT License
2
+
3
+ Copyright (c) Siemens 2018 - 2024
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ this software and associated documentation files (the “Software”), to deal in
7
+ the Software without restriction, including without limitation the rights to
8
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9
+ the Software, and to permit persons to whom the Software is furnished to do so,
10
+ subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # eslint-plugin-defaultvalue
2
+
3
+ An ESLint plugin to automatically enrich TSDoc comments with default values (using --fix) or check if they are all correct.
4
+ The rule is aware of `Signals` by `@angular/core` and will automatically use the actual value instead of the whole `signal` function.
5
+
6
+ ## Installation
7
+
8
+ Install `@siemens/eslint-plugin-defaultvalue` in your project.
9
+
10
+ ```bash
11
+ npm install @siemens/eslint-plugin-defaultvalue --save-dev
12
+ ```
13
+
14
+ ## Configuration
15
+
16
+ Include the ESLint plugin and rule in your relevant `eslint.config.(m)js`:
17
+
18
+ ```js
19
+ ...
20
+ import defaultvalue from '@siemens/eslint-plugin-defaultvalue';
21
+
22
+ export default [
23
+ {
24
+ ...,
25
+ plugins: {
26
+ ...,
27
+ defaultvalue
28
+ },
29
+ rules: {
30
+ ...,
31
+ 'defaultvalue/tsdoc-defaultValue-annotation': ['error']
32
+ }
33
+ }
34
+ ];
35
+ ```
36
+
37
+ ### Removing not resolved and setter @defaultValue annotations
38
+
39
+ To automatically remove not resolvable and setter @defaultValue annotations, use the following configuration:
40
+
41
+ ```js
42
+ ...,
43
+ rules: {
44
+ ...,
45
+ 'defaultvalue/tsdoc-defaultValue-annotation': ['error', 'removeAll', 1000]
46
+ }
47
+ ...
48
+ ```
package/lib/index.js ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Copyright Siemens 2024.
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+ import defaultValueRule from './rules/default-value.js';
6
+ export default {
7
+ rules: {
8
+ 'tsdoc-defaultValue-annotation': defaultValueRule
9
+ }
10
+ };
@@ -0,0 +1,67 @@
1
+ /* eslint-disable headers/header-format */
2
+ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
3
+ // See LICENSE in the project root for license information.
4
+ import * as path from 'path';
5
+ import { TSDocConfigFile } from '@microsoft/tsdoc-config';
6
+ // How often to check for modified input files. If a file's modification timestamp has changed, then we will
7
+ // evict the cache entry immediately.
8
+ const CACHE_CHECK_INTERVAL_MS = 3 * 1000;
9
+ // Evict old entries from the cache after this much time, regardless of whether the file was detected as being
10
+ // modified or not.
11
+ const CACHE_EXPIRE_MS = 20 * 1000;
12
+ // If this many objects accumulate in the cache, then it is cleared to avoid a memory leak.
13
+ const CACHE_MAX_SIZE = 100;
14
+ export class ConfigCache {
15
+ // findConfigPathForFolder() result --> loaded tsdoc.json configuration
16
+ static _cachedConfigs = new Map();
17
+ /**
18
+ * Node.js equivalent of performance.now().
19
+ */
20
+ static getTimeInMs() {
21
+ const [seconds, nanoseconds] = process.hrtime();
22
+ return seconds * 1000 + nanoseconds / 1000000;
23
+ }
24
+ static getForSourceFile(sourceFilePath) {
25
+ const sourceFileFolder = path.dirname(path.resolve(sourceFilePath));
26
+ // First, determine the file to be loaded. If not found, the configFilePath will be an empty string.
27
+ const configFilePath = TSDocConfigFile.findConfigPathForFolder(sourceFileFolder);
28
+ // If configFilePath is an empty string, then we'll use the folder of sourceFilePath as our cache key
29
+ // (instead of an empty string)
30
+ const cacheKey = configFilePath || sourceFileFolder + '/';
31
+ const nowMs = ConfigCache.getTimeInMs();
32
+ let cachedConfig = undefined;
33
+ // Do we have a cached object?
34
+ cachedConfig = ConfigCache._cachedConfigs.get(cacheKey);
35
+ if (cachedConfig) {
36
+ // Is the cached object still valid?
37
+ const loadAgeMs = nowMs - cachedConfig.loadTimeMs;
38
+ const lastCheckAgeMs = nowMs - cachedConfig.lastCheckTimeMs;
39
+ if (loadAgeMs > CACHE_EXPIRE_MS || loadAgeMs < 0) {
40
+ cachedConfig = undefined;
41
+ ConfigCache._cachedConfigs.delete(cacheKey);
42
+ }
43
+ else if (lastCheckAgeMs > CACHE_CHECK_INTERVAL_MS || lastCheckAgeMs < 0) {
44
+ cachedConfig.lastCheckTimeMs = nowMs;
45
+ if (cachedConfig.configFile.checkForModifiedFiles()) {
46
+ // Invalidate the cache because it failed to load completely
47
+ cachedConfig = undefined;
48
+ ConfigCache._cachedConfigs.delete(cacheKey);
49
+ }
50
+ }
51
+ }
52
+ // Load the object
53
+ if (!cachedConfig) {
54
+ if (ConfigCache._cachedConfigs.size > CACHE_MAX_SIZE) {
55
+ ConfigCache._cachedConfigs.clear(); // avoid a memory leak
56
+ }
57
+ const configFile = TSDocConfigFile.loadFile(configFilePath);
58
+ cachedConfig = {
59
+ configFile,
60
+ lastCheckTimeMs: nowMs,
61
+ loadTimeMs: nowMs
62
+ };
63
+ ConfigCache._cachedConfigs.set(cacheKey, cachedConfig);
64
+ }
65
+ return cachedConfig.configFile;
66
+ }
67
+ }
@@ -0,0 +1,374 @@
1
+ /* eslint-disable no-case-declarations, headers/header-format */
2
+ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
3
+ // See LICENSE in the project root for license information.
4
+ // Copy from upstream. Should be dropped when the upstream is fixed.
5
+ // See:https://github.com/microsoft/tsdoc/pull/427
6
+ import { DocNodeKind, StandardTags } from '@microsoft/tsdoc';
7
+ import { TrimSpacesTransform } from './TrimSpacesTransform.js';
8
+ var LineState;
9
+ (function (LineState) {
10
+ LineState[LineState["Closed"] = 0] = "Closed";
11
+ LineState[LineState["StartOfLine"] = 1] = "StartOfLine";
12
+ LineState[LineState["MiddleOfLine"] = 2] = "MiddleOfLine";
13
+ })(LineState || (LineState = {}));
14
+ /**
15
+ * Renders a DocNode tree as a code comment.
16
+ */
17
+ export class TSDocEmitter {
18
+ eol = '\n';
19
+ // Whether to emit the /** */ framing
20
+ _emitCommentFraming = true;
21
+ _output;
22
+ // This state machine is used by the writer functions to generate the /** */ framing around the emitted lines
23
+ _lineState = LineState.Closed;
24
+ // State for ensureLineSkipped()
25
+ _previousLineHadContent = false;
26
+ // Normally a paragraph is precede by a blank line (unless it's the first thing written).
27
+ // But sometimes we want the paragraph to be attached to the previous element, e.g. when it's part of
28
+ // an "@param" block. Setting _hangingParagraph=true accomplishes that.
29
+ _hangingParagraph = false;
30
+ renderComment(output, docComment) {
31
+ this._emitCommentFraming = true;
32
+ this.renderCompleteObject(output, docComment);
33
+ }
34
+ renderHtmlTag(output, htmlTag) {
35
+ this._emitCommentFraming = false;
36
+ this.renderCompleteObject(output, htmlTag);
37
+ }
38
+ renderDeclarationReference(output, declarationReference) {
39
+ this._emitCommentFraming = false;
40
+ this.renderCompleteObject(output, declarationReference);
41
+ }
42
+ renderCompleteObject(output, docNode) {
43
+ this._output = output;
44
+ this._lineState = LineState.Closed;
45
+ this._previousLineHadContent = false;
46
+ this._hangingParagraph = false;
47
+ this.renderNode(docNode);
48
+ this.writeEnd();
49
+ }
50
+ renderNode(docNode) {
51
+ if (docNode === undefined) {
52
+ return;
53
+ }
54
+ switch (docNode.kind) {
55
+ case DocNodeKind.Block:
56
+ const docBlock = docNode;
57
+ this.ensureLineSkipped();
58
+ this.renderNode(docBlock.blockTag);
59
+ if (docBlock.blockTag.tagNameWithUpperCase === StandardTags.returns.tagNameWithUpperCase ||
60
+ // fix in https://github.com/microsoft/tsdoc/pull/427
61
+ docBlock.blockTag.tagNameWithUpperCase ===
62
+ StandardTags.defaultValue.tagNameWithUpperCase ||
63
+ // to be fixed in a future MR once the other was merged
64
+ docBlock.blockTag.tagNameWithUpperCase === StandardTags.deprecated.tagNameWithUpperCase) {
65
+ this.writeContent(' ');
66
+ this._hangingParagraph = true;
67
+ }
68
+ this.renderNode(docBlock.content);
69
+ break;
70
+ case DocNodeKind.BlockTag:
71
+ const docBlockTag = docNode;
72
+ if (this._lineState === LineState.MiddleOfLine) {
73
+ this.writeContent(' ');
74
+ }
75
+ this.writeContent(docBlockTag.tagName);
76
+ break;
77
+ case DocNodeKind.CodeSpan:
78
+ const docCodeSpan = docNode;
79
+ this.writeContent('`');
80
+ this.writeContent(docCodeSpan.code);
81
+ this.writeContent('`');
82
+ break;
83
+ case DocNodeKind.Comment:
84
+ const docComment = docNode;
85
+ this.renderNodes([
86
+ docComment.summarySection,
87
+ docComment.remarksBlock,
88
+ docComment.privateRemarks,
89
+ docComment.deprecatedBlock,
90
+ docComment.params,
91
+ docComment.typeParams,
92
+ docComment.returnsBlock,
93
+ ...docComment.customBlocks,
94
+ ...docComment.seeBlocks,
95
+ docComment.inheritDocTag
96
+ ]);
97
+ if (docComment.modifierTagSet.nodes.length > 0) {
98
+ this.ensureLineSkipped();
99
+ this.renderNodes(docComment.modifierTagSet.nodes);
100
+ }
101
+ break;
102
+ case DocNodeKind.DeclarationReference:
103
+ const docDeclarationReference = docNode;
104
+ this.writeContent(docDeclarationReference.packageName);
105
+ this.writeContent(docDeclarationReference.importPath);
106
+ if (docDeclarationReference.packageName !== undefined ||
107
+ docDeclarationReference.importPath !== undefined) {
108
+ this.writeContent('#');
109
+ }
110
+ this.renderNodes(docDeclarationReference.memberReferences);
111
+ break;
112
+ case DocNodeKind.ErrorText:
113
+ const docErrorText = docNode;
114
+ this.writeContent(docErrorText.text);
115
+ break;
116
+ case DocNodeKind.EscapedText:
117
+ const docEscapedText = docNode;
118
+ this.writeContent(docEscapedText.encodedText);
119
+ break;
120
+ case DocNodeKind.FencedCode:
121
+ const docFencedCode = docNode;
122
+ this.ensureAtStartOfLine();
123
+ this.writeContent('```');
124
+ this.writeContent(docFencedCode.language);
125
+ this.writeNewline();
126
+ this.writeContent(docFencedCode.code);
127
+ this.writeContent('```');
128
+ this.writeNewline();
129
+ break;
130
+ case DocNodeKind.HtmlAttribute:
131
+ const docHtmlAttribute = docNode;
132
+ this.writeContent(docHtmlAttribute.name);
133
+ this.writeContent(docHtmlAttribute.spacingAfterName);
134
+ this.writeContent('=');
135
+ this.writeContent(docHtmlAttribute.spacingAfterEquals);
136
+ this.writeContent(docHtmlAttribute.value);
137
+ this.writeContent(docHtmlAttribute.spacingAfterValue);
138
+ break;
139
+ case DocNodeKind.HtmlEndTag:
140
+ const docHtmlEndTag = docNode;
141
+ this.writeContent('</');
142
+ this.writeContent(docHtmlEndTag.name);
143
+ this.writeContent('>');
144
+ break;
145
+ case DocNodeKind.HtmlStartTag:
146
+ const docHtmlStartTag = docNode;
147
+ this.writeContent('<');
148
+ this.writeContent(docHtmlStartTag.name);
149
+ this.writeContent(docHtmlStartTag.spacingAfterName);
150
+ let needsSpace = docHtmlStartTag.spacingAfterName === undefined ||
151
+ docHtmlStartTag.spacingAfterName.length === 0;
152
+ for (const attribute of docHtmlStartTag.htmlAttributes) {
153
+ if (needsSpace) {
154
+ this.writeContent(' ');
155
+ }
156
+ this.renderNode(attribute);
157
+ needsSpace =
158
+ attribute.spacingAfterValue === undefined || attribute.spacingAfterValue.length === 0;
159
+ }
160
+ this.writeContent(docHtmlStartTag.selfClosingTag ? '/>' : '>');
161
+ break;
162
+ case DocNodeKind.InheritDocTag:
163
+ const docInheritDocTag = docNode;
164
+ this.renderInlineTag(docInheritDocTag, () => {
165
+ if (docInheritDocTag.declarationReference) {
166
+ this.writeContent(' ');
167
+ this.renderNode(docInheritDocTag.declarationReference);
168
+ }
169
+ });
170
+ break;
171
+ case DocNodeKind.InlineTag:
172
+ const docInlineTag = docNode;
173
+ this.renderInlineTag(docInlineTag, () => {
174
+ if (docInlineTag.tagContent.length > 0) {
175
+ this.writeContent(' ');
176
+ this.writeContent(docInlineTag.tagContent);
177
+ }
178
+ });
179
+ break;
180
+ case DocNodeKind.LinkTag:
181
+ const docLinkTag = docNode;
182
+ this.renderInlineTag(docLinkTag, () => {
183
+ if (docLinkTag.urlDestination !== undefined || docLinkTag.codeDestination !== undefined) {
184
+ if (docLinkTag.urlDestination !== undefined) {
185
+ this.writeContent(' ');
186
+ this.writeContent(docLinkTag.urlDestination);
187
+ }
188
+ else if (docLinkTag.codeDestination !== undefined) {
189
+ this.writeContent(' ');
190
+ this.renderNode(docLinkTag.codeDestination);
191
+ }
192
+ }
193
+ if (docLinkTag.linkText !== undefined) {
194
+ this.writeContent(' ');
195
+ this.writeContent('|');
196
+ this.writeContent(' ');
197
+ this.writeContent(docLinkTag.linkText);
198
+ }
199
+ });
200
+ break;
201
+ case DocNodeKind.MemberIdentifier:
202
+ const docMemberIdentifier = docNode;
203
+ if (docMemberIdentifier.hasQuotes) {
204
+ this.writeContent('"');
205
+ this.writeContent(docMemberIdentifier.identifier); // todo: encoding
206
+ this.writeContent('"');
207
+ }
208
+ else {
209
+ this.writeContent(docMemberIdentifier.identifier);
210
+ }
211
+ break;
212
+ case DocNodeKind.MemberReference:
213
+ const docMemberReference = docNode;
214
+ if (docMemberReference.hasDot) {
215
+ this.writeContent('.');
216
+ }
217
+ if (docMemberReference.selector) {
218
+ this.writeContent('(');
219
+ }
220
+ if (docMemberReference.memberSymbol) {
221
+ this.renderNode(docMemberReference.memberSymbol);
222
+ }
223
+ else {
224
+ this.renderNode(docMemberReference.memberIdentifier);
225
+ }
226
+ if (docMemberReference.selector) {
227
+ this.writeContent(':');
228
+ this.renderNode(docMemberReference.selector);
229
+ this.writeContent(')');
230
+ }
231
+ break;
232
+ case DocNodeKind.MemberSelector:
233
+ const docMemberSelector = docNode;
234
+ this.writeContent(docMemberSelector.selector);
235
+ break;
236
+ case DocNodeKind.MemberSymbol:
237
+ const docMemberSymbol = docNode;
238
+ this.writeContent('[');
239
+ this.renderNode(docMemberSymbol.symbolReference);
240
+ this.writeContent(']');
241
+ break;
242
+ case DocNodeKind.Section:
243
+ const docSection = docNode;
244
+ this.renderNodes(docSection.nodes);
245
+ break;
246
+ case DocNodeKind.Paragraph:
247
+ // revert once upstream is fixed
248
+ const trimmedParagraph = TrimSpacesTransform.transform(docNode);
249
+ if (trimmedParagraph.nodes.length > 0) {
250
+ if (this._hangingParagraph) {
251
+ // If it's a hanging paragraph, then don't skip a line
252
+ this._hangingParagraph = false;
253
+ }
254
+ else {
255
+ this.ensureLineSkipped();
256
+ }
257
+ this.renderNodes(trimmedParagraph.nodes);
258
+ }
259
+ break;
260
+ case DocNodeKind.ParamBlock:
261
+ const docParamBlock = docNode;
262
+ this.ensureLineSkipped();
263
+ this.renderNode(docParamBlock.blockTag);
264
+ this.writeContent(' ');
265
+ this.writeContent(docParamBlock.parameterName);
266
+ this.writeContent(' - ');
267
+ this._hangingParagraph = true;
268
+ this.renderNode(docParamBlock.content);
269
+ this._hangingParagraph = false;
270
+ break;
271
+ case DocNodeKind.ParamCollection:
272
+ const docParamCollection = docNode;
273
+ this.renderNodes(docParamCollection.blocks);
274
+ break;
275
+ case DocNodeKind.PlainText:
276
+ const docPlainText = docNode;
277
+ this.writeContent(docPlainText.text);
278
+ break;
279
+ case DocNodeKind.SoftBreak:
280
+ this.writeNewline();
281
+ }
282
+ }
283
+ renderInlineTag(docInlineTagBase, writeInlineTagContent) {
284
+ this.writeContent('{');
285
+ this.writeContent(docInlineTagBase.tagName);
286
+ writeInlineTagContent();
287
+ this.writeContent('}');
288
+ }
289
+ renderNodes(docNodes) {
290
+ for (const docNode of docNodes) {
291
+ this.renderNode(docNode);
292
+ }
293
+ }
294
+ // Calls _writeNewline() only if we're not already at the start of a new line
295
+ ensureAtStartOfLine() {
296
+ if (this._lineState === LineState.MiddleOfLine) {
297
+ this.writeNewline();
298
+ }
299
+ }
300
+ // Calls _writeNewline() if needed to ensure that we have skipped at least one line
301
+ ensureLineSkipped() {
302
+ this.ensureAtStartOfLine();
303
+ if (this._previousLineHadContent) {
304
+ this.writeNewline();
305
+ }
306
+ }
307
+ // Writes literal text content. If it contains newlines, they will automatically be converted to
308
+ // _writeNewline() calls, to ensure that "*" is written at the start of each line.
309
+ writeContent(content) {
310
+ if (content === undefined || content.length === 0) {
311
+ return;
312
+ }
313
+ const splitLines = content.split(/\r?\n/g);
314
+ if (splitLines.length > 1) {
315
+ let firstLine = true;
316
+ for (const line of splitLines) {
317
+ if (firstLine) {
318
+ firstLine = false;
319
+ }
320
+ else {
321
+ this.writeNewline();
322
+ }
323
+ this.writeContent(line);
324
+ }
325
+ return;
326
+ }
327
+ if (this._lineState === LineState.Closed) {
328
+ if (this._emitCommentFraming) {
329
+ this._output.append('/**' + this.eol + ' *');
330
+ }
331
+ this._lineState = LineState.StartOfLine;
332
+ }
333
+ if (this._lineState === LineState.StartOfLine) {
334
+ if (this._emitCommentFraming) {
335
+ this._output.append(' ');
336
+ }
337
+ }
338
+ this._output.append(content);
339
+ this._lineState = LineState.MiddleOfLine;
340
+ this._previousLineHadContent = true;
341
+ }
342
+ // Starts a new line, and inserts "/**" or "*" as appropriate.
343
+ writeNewline() {
344
+ if (this._lineState === LineState.Closed) {
345
+ if (this._emitCommentFraming) {
346
+ this._output.append('/**' + this.eol + ' *');
347
+ }
348
+ this._lineState = LineState.StartOfLine;
349
+ }
350
+ this._previousLineHadContent = this._lineState === LineState.MiddleOfLine;
351
+ if (this._emitCommentFraming) {
352
+ this._output.append(this.eol + ' *');
353
+ }
354
+ else {
355
+ this._output.append(this.eol);
356
+ }
357
+ this._lineState = LineState.StartOfLine;
358
+ this._hangingParagraph = false;
359
+ }
360
+ // Closes the comment, adding the final "*/" delimiter
361
+ writeEnd() {
362
+ if (this._lineState === LineState.MiddleOfLine) {
363
+ if (this._emitCommentFraming) {
364
+ this.writeNewline();
365
+ }
366
+ }
367
+ if (this._lineState !== LineState.Closed) {
368
+ if (this._emitCommentFraming) {
369
+ this._output.append('/' + this.eol);
370
+ }
371
+ this._lineState = LineState.Closed;
372
+ }
373
+ }
374
+ }
@@ -0,0 +1,88 @@
1
+ /* eslint-disable no-case-declarations, headers/header-format */
2
+ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
3
+ // See LICENSE in the project root for license information.
4
+ // Copy from upstream. Should be dropped when the upstream is fixed.
5
+ import { DocNodeKind, DocParagraph, DocPlainText } from '@microsoft/tsdoc';
6
+ /**
7
+ * Implementation of DocNodeTransforms.trimSpacesInParagraphNodes()
8
+ */
9
+ export class TrimSpacesTransform {
10
+ static transform(docParagraph) {
11
+ const transformedNodes = [];
12
+ // Whether the next nonempty node to be added needs a space before it
13
+ let pendingSpace = false;
14
+ // The DocPlainText node that we're currently accumulating
15
+ const accumulatedTextChunks = [];
16
+ const accumulatedNodes = [];
17
+ // We always trim leading whitespace for a paragraph. This flag gets set to true
18
+ // as soon as nonempty content is encountered.
19
+ let finishedSkippingLeadingSpaces = false;
20
+ for (const node of docParagraph.nodes) {
21
+ switch (node.kind) {
22
+ case DocNodeKind.PlainText:
23
+ const docPlainText = node;
24
+ const text = docPlainText.text;
25
+ const startedWithSpace = /^\s/.test(text);
26
+ const endedWithSpace = /\s$/.test(text);
27
+ const collapsedText = text.replace(/\s+/g, ' ').trim();
28
+ if (startedWithSpace && finishedSkippingLeadingSpaces) {
29
+ pendingSpace = true;
30
+ }
31
+ if (collapsedText.length > 0) {
32
+ if (pendingSpace) {
33
+ accumulatedTextChunks.push(' ');
34
+ pendingSpace = false;
35
+ }
36
+ accumulatedTextChunks.push(collapsedText);
37
+ accumulatedNodes.push(node);
38
+ finishedSkippingLeadingSpaces = true;
39
+ }
40
+ if (endedWithSpace && finishedSkippingLeadingSpaces) {
41
+ pendingSpace = true;
42
+ }
43
+ break;
44
+ case DocNodeKind.SoftBreak:
45
+ if (finishedSkippingLeadingSpaces) {
46
+ pendingSpace = false;
47
+ }
48
+ this.finishNode(accumulatedTextChunks, transformedNodes, docParagraph, accumulatedNodes, node);
49
+ break;
50
+ default:
51
+ if (pendingSpace) {
52
+ accumulatedTextChunks.push(' ');
53
+ pendingSpace = false;
54
+ }
55
+ this.finishNode(accumulatedTextChunks, transformedNodes, docParagraph, accumulatedNodes, node);
56
+ finishedSkippingLeadingSpaces = true;
57
+ }
58
+ }
59
+ // Push the accumulated text
60
+ if (accumulatedTextChunks.length > 0) {
61
+ transformedNodes.push(new DocPlainText({
62
+ configuration: docParagraph.configuration,
63
+ text: accumulatedTextChunks.join('')
64
+ }));
65
+ accumulatedTextChunks.length = 0;
66
+ accumulatedNodes.length = 0;
67
+ }
68
+ const transformedParagraph = new DocParagraph({
69
+ configuration: docParagraph.configuration
70
+ });
71
+ transformedParagraph.appendNodes(transformedNodes);
72
+ return transformedParagraph;
73
+ }
74
+ static finishNode(accumulatedTextChunks, transformedNodes, docParagraph, accumulatedNodes, node) {
75
+ // Push the accumulated text
76
+ if (accumulatedTextChunks.length > 0) {
77
+ // TODO: We should probably track the accumulatedNodes somehow, e.g. so we can map them back to the
78
+ // original excerpts. But we need a developer scenario before we can design this API.
79
+ transformedNodes.push(new DocPlainText({
80
+ configuration: docParagraph.configuration,
81
+ text: accumulatedTextChunks.join('')
82
+ }));
83
+ accumulatedTextChunks.length = 0;
84
+ accumulatedNodes.length = 0;
85
+ }
86
+ transformedNodes.push(node);
87
+ }
88
+ }
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Copyright Siemens 2024.
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+ import { DocNodeKind, StringBuilder, TextRange, TSDocConfiguration, TSDocParser } from '@microsoft/tsdoc';
6
+ import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils';
7
+ import { ConfigCache } from './ConfigCache.js';
8
+ import { TSDocEmitter } from './TSDocEmitter.js';
9
+ const getParser = (context) => {
10
+ const tsdocConfiguration = new TSDocConfiguration();
11
+ try {
12
+ const tsdocConfigFile = ConfigCache.getForSourceFile(context.filename);
13
+ if (!tsdocConfigFile.fileNotFound) {
14
+ if (tsdocConfigFile.hasErrors) {
15
+ context.report({
16
+ loc: { line: 1, column: 1 },
17
+ messageId: 'error-loading-tsdoc-config-file',
18
+ data: {
19
+ details: tsdocConfigFile.getErrorSummary()
20
+ }
21
+ });
22
+ }
23
+ try {
24
+ tsdocConfigFile.configureParser(tsdocConfiguration);
25
+ }
26
+ catch (e) {
27
+ context.report({
28
+ loc: { line: 1, column: 1 },
29
+ messageId: 'error-applying-tsdoc-config',
30
+ data: {
31
+ details: e.message
32
+ }
33
+ });
34
+ }
35
+ }
36
+ }
37
+ catch (e) {
38
+ context.report({
39
+ loc: { line: 1, column: 1 },
40
+ messageId: 'error-loading-tsdoc-config-file',
41
+ data: {
42
+ details: `Unexpected exception: ${e.message}`
43
+ }
44
+ });
45
+ }
46
+ return new TSDocParser(tsdocConfiguration);
47
+ };
48
+ const extractComment = (node, sourceCode, parser) => {
49
+ const comment = sourceCode
50
+ .getCommentsBefore(node)
51
+ .filter(sComment => sComment.type === 'Block')
52
+ .at(-1);
53
+ if (comment?.range) {
54
+ const textRange = TextRange.fromStringRange(sourceCode.text, comment.range[0], comment.range[1]);
55
+ const parserContext = parser.parseRange(textRange);
56
+ for (const customBlock of parserContext.docComment.customBlocks) {
57
+ if (customBlock.kind === DocNodeKind.Block) {
58
+ if (customBlock.blockTag.tagName === '@defaultValue') {
59
+ return {
60
+ defaultValue: getValueText(customBlock.content).trim(),
61
+ commentNode: comment,
62
+ tsDocComment: parserContext,
63
+ defaultValueTsDocNode: customBlock
64
+ };
65
+ }
66
+ }
67
+ }
68
+ return { commentNode: comment, tsDocComment: parserContext };
69
+ }
70
+ return undefined;
71
+ };
72
+ const getValueText = (node) => {
73
+ switch (node.kind) {
74
+ case DocNodeKind.PlainText:
75
+ return node.text;
76
+ case DocNodeKind.EscapedText:
77
+ return node.decodedText;
78
+ case DocNodeKind.CodeSpan:
79
+ return `\`${node.code}\``;
80
+ case DocNodeKind.FencedCode:
81
+ return node.code;
82
+ default:
83
+ return node.getChildNodes().map(getValueText).join('');
84
+ }
85
+ };
86
+ const angularSignalNames = ['signal', 'input', 'model'];
87
+ const extractPropertyInformation = (node, sourceCode) => {
88
+ const name = node.key.type === AST_NODE_TYPES.Identifier ? node.key.name : '*';
89
+ if (node.type === AST_NODE_TYPES.MethodDefinition) {
90
+ // We cannot extract the value here.
91
+ return { propertyName: name };
92
+ }
93
+ const readonly = node.type === AST_NODE_TYPES.PropertyDefinition && node.readonly;
94
+ // Should not be documented as readonly values do not have a default value, but just a "value".
95
+ // But signals should be able to be readonly and still have their default values displayed.
96
+ if (readonly && node.value?.type !== AST_NODE_TYPES.CallExpression) {
97
+ return { propertyName: name };
98
+ }
99
+ switch (node.value?.type) {
100
+ case AST_NODE_TYPES.Literal:
101
+ return {
102
+ defaultValue: typeof node.value.value === 'string'
103
+ ? "'" + node.value.value + "'"
104
+ : '' + node.value.value,
105
+ propertyName: name
106
+ };
107
+ case AST_NODE_TYPES.CallExpression:
108
+ return {
109
+ defaultValue: extractCallExpression(node.value, sourceCode, readonly),
110
+ propertyName: name
111
+ };
112
+ case AST_NODE_TYPES.ObjectExpression:
113
+ case AST_NODE_TYPES.ArrayExpression:
114
+ case AST_NODE_TYPES.ArrowFunctionExpression:
115
+ case AST_NODE_TYPES.UnaryExpression:
116
+ case AST_NODE_TYPES.TaggedTemplateExpression:
117
+ return { defaultValue: sourceCode.getText(node.value), propertyName: name };
118
+ default:
119
+ return { propertyName: name };
120
+ }
121
+ };
122
+ const extractCallExpression = (callExpression, sourceCode, readonly) => {
123
+ switch (callExpression.callee.type) {
124
+ case AST_NODE_TYPES.Identifier:
125
+ if (angularSignalNames.includes(callExpression.callee.name)) {
126
+ const [value] = callExpression.arguments;
127
+ if (value) {
128
+ return sourceCode.getText(value);
129
+ }
130
+ else {
131
+ return undefined;
132
+ }
133
+ }
134
+ // Should not be documented.
135
+ if (readonly) {
136
+ return undefined;
137
+ }
138
+ return sourceCode.getText(callExpression);
139
+ case AST_NODE_TYPES.MemberExpression:
140
+ if (callExpression.callee.object.type === AST_NODE_TYPES.Identifier &&
141
+ angularSignalNames.includes(callExpression.callee.object.name)) {
142
+ // something like input.required().
143
+ // This will never have a defaultValue.
144
+ return undefined;
145
+ }
146
+ return sourceCode.getText(callExpression);
147
+ default:
148
+ return sourceCode.getText(callExpression);
149
+ }
150
+ };
151
+ const setIndentation = (text, column) => {
152
+ // first remove all leading whitespaces and then add the desired indentation
153
+ return text
154
+ .replace(/^\s*/gm, '')
155
+ .replace(/^/gm, new Array(column).fill(' ').join(''))
156
+ .replace(/^(\s*\*)/gm, ' $1');
157
+ };
158
+ const createDefaultValueAnnotation = (value) => {
159
+ const requiresFencing = value.match(/([{}<>`])/);
160
+ if (requiresFencing) {
161
+ return `
162
+ /**
163
+ * @defaultValue
164
+ * \`\`\`
165
+ * ${value}
166
+ * \`\`\`
167
+ */`;
168
+ }
169
+ return `/** @defaultValue ${value} */`;
170
+ };
171
+ const replaceComment = (fixer, commentNode, tsDocComment) => {
172
+ const column = commentNode.loc.start.column;
173
+ // fix tsdoc output. See: https://github.com/microsoft/tsdoc/pull/427
174
+ const stringBuilder = new StringBuilder();
175
+ new TSDocEmitter().renderComment(stringBuilder, tsDocComment.docComment);
176
+ const updatedValue = stringBuilder
177
+ .toString()
178
+ // remove trailing whitespaces
179
+ .replace(/(.*?)\s*$/gm, '$1')
180
+ .trimEnd();
181
+ const [startRange, endRange] = commentNode.range;
182
+ return fixer.replaceTextRange([startRange - column, endRange], setIndentation(updatedValue, column));
183
+ };
184
+ const createRule = ESLintUtils.RuleCreator(
185
+ // TODO: Should be URL
186
+ name => `${name}`);
187
+ export default createRule({
188
+ name: 'tsdoc-defaultValue-annotation',
189
+ defaultOptions: [],
190
+ meta: {
191
+ type: 'problem',
192
+ docs: {
193
+ description: 'enforce correct @defaultValue TSDoc annotation'
194
+ },
195
+ schema: [
196
+ {
197
+ type: 'string',
198
+ enum: ['removeAll', 'default']
199
+ }
200
+ ],
201
+ fixable: 'code',
202
+ messages: {
203
+ incorrectDefaultValueAnnotation: 'Incorrect @defaultValue TSDoc annotation: {{ message }}',
204
+ 'error-missing-default-value': 'Missing @defaultValue TSDoc annotation on {{ property }}',
205
+ 'error-wrong-default-value': 'Incorrect @defaultValue TSDoc annotation on {{ property }}. Expected: "{{ expected }}" / Actual: "{{ actual }}"',
206
+ 'error-loading-tsdoc-config-file': '{{ details }}',
207
+ 'error-applying-tsdoc-config': '{{ details }}'
208
+ }
209
+ },
210
+ create: context => {
211
+ const parser = getParser(context);
212
+ return {
213
+ 'PropertyDefinition, MethodDefinition': (node) => {
214
+ // TODO: This should be a part of the config instead
215
+ if (context.filename.endsWith('.spec.ts') || context.filename.endsWith('.harness.ts')) {
216
+ return;
217
+ }
218
+ if (node.accessibility === 'private' || node.accessibility === 'protected') {
219
+ return;
220
+ }
221
+ if (node.type !== AST_NODE_TYPES.PropertyDefinition && node.kind !== 'set') {
222
+ return undefined;
223
+ }
224
+ const parent = node.parent.parent;
225
+ if (parent.type !== AST_NODE_TYPES.ClassDeclaration) {
226
+ return undefined;
227
+ }
228
+ const comment = extractComment(node, context.sourceCode, parser);
229
+ if (comment?.tsDocComment.docComment.modifierTagSet.isInternal()) {
230
+ return;
231
+ }
232
+ const propertyInformation = extractPropertyInformation(node, context.sourceCode);
233
+ if (propertyInformation.defaultValue) {
234
+ if (!comment) {
235
+ context.report({
236
+ node,
237
+ messageId: 'error-missing-default-value',
238
+ data: { property: propertyInformation.propertyName },
239
+ fix: fixer => {
240
+ const newBlock = setIndentation(createDefaultValueAnnotation(propertyInformation.defaultValue), node.loc.start.column).replace(/^\s*/, '');
241
+ return fixer.insertTextBefore(node, `${newBlock}\n${new Array(node.loc.start.column).fill(' ').join('')}`);
242
+ }
243
+ });
244
+ }
245
+ else if (!comment.defaultValue) {
246
+ context.report({
247
+ node: comment.commentNode,
248
+ messageId: 'error-missing-default-value',
249
+ data: { property: propertyInformation.propertyName },
250
+ fix: fixer => {
251
+ const newBlock = parser.parseString(createDefaultValueAnnotation(propertyInformation.defaultValue)).docComment.customBlocks[0];
252
+ comment.tsDocComment.docComment.appendCustomBlock(newBlock);
253
+ return replaceComment(fixer, comment.commentNode, comment.tsDocComment);
254
+ }
255
+ });
256
+ }
257
+ else if (
258
+ // a format independent comparison by removing all whitespaces and just checking if the doc-comment contains the default value
259
+ !comment.defaultValue
260
+ .replaceAll(/\s/g, '')
261
+ .includes(propertyInformation.defaultValue.replaceAll(/\s/g, ''))) {
262
+ context.report({
263
+ node: comment.commentNode,
264
+ messageId: 'error-wrong-default-value',
265
+ data: {
266
+ property: propertyInformation.propertyName,
267
+ expected: propertyInformation.defaultValue,
268
+ actual: comment.defaultValue
269
+ },
270
+ fix: fixer => {
271
+ const previousDefaultValue = comment.defaultValueTsDocNode;
272
+ previousDefaultValue.content.clearNodes();
273
+ const newBlock = parser.parseString(createDefaultValueAnnotation(propertyInformation.defaultValue)).docComment.customBlocks[0];
274
+ previousDefaultValue.content.appendNodes(newBlock.content.getChildNodes());
275
+ return replaceComment(fixer, comment.commentNode, comment.tsDocComment);
276
+ }
277
+ });
278
+ }
279
+ }
280
+ }
281
+ };
282
+ }
283
+ });
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@siemens/eslint-plugin-defaultvalue",
3
+ "version": "1.0.0-next.1",
4
+ "main": "lib/index.js",
5
+ "type": "module",
6
+ "description": "Automatically enrich TSDoc comments with default values or check if they are all correct.",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+ssh://git@github.com/siemens/lint.git"
10
+ },
11
+ "author": {
12
+ "name": "Siemens",
13
+ "email": "opensource@siemens.com"
14
+ },
15
+ "homepage": "https://github.com/siemens/lint",
16
+ "bugs": "https://github.com/siemens/lint/issues",
17
+ "keywords": [
18
+ "siemens",
19
+ "lint"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "license": "MIT",
25
+ "peerDependencies": {
26
+ "eslint": "^8.0.0||^9.0.0",
27
+ "@typescript-eslint/utils": "^8.0.0"
28
+ }
29
+ }