@lewishowles/lint-config 0.7.0 → 0.8.0
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/CHANGELOG.md +14 -0
- package/README.md +22 -18
- package/comments/rules/formatting.js +5 -7
- package/comments/rules/function-documentation.js +21 -2
- package/comments/utils/documentation.js +35 -27
- package/comments/utils/jsdoc.js +347 -79
- package/comments/utils/source.js +59 -26
- package/comments/utils/wrap.js +48 -14
- package/comments.json +1 -0
- package/layers.js +60 -0
- package/package.json +6 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0: 2026-10-05
|
|
4
|
+
|
|
5
|
+
### Changes
|
|
6
|
+
|
|
7
|
+
- Breaking: `vite-plus` 1.0.0 is now required. Put the selected lint layers in a `vite.config.js` `lint` block; `vp check` and `vp lint` ignore a `.oxlintrc.json` on its own. Use `lintConfig` from `@lewishowles/lint-config/layers` to build the block, with an optional `.oxlintrc.json` for local settings. Vite+ 1.0 also adds its own `vite-plus/prefer-vite-plus-imports` rule, which may report new problems after upgrading.
|
|
8
|
+
- New `./layers` export provides `base`, `vue`, `comments`, and `lintConfig`. The Vue layer includes base, and `lintConfig` carries inherited environments and globals into the Vite+ lint block.
|
|
9
|
+
- `comments/function-documentation` adds an `ignoreInlineArrows` option, enabled by `comments.json` for test files. Arrow functions used as object property values no longer need JSDoc there; other function forms and files keep their existing requirements.
|
|
10
|
+
- `comments/formatting` changes the output of `--fix` for comments. Run `--fix` once after upgrading.
|
|
11
|
+
|
|
12
|
+
### Fixes
|
|
13
|
+
|
|
14
|
+
- `comments/formatting` keeps code spans, quotes, colons, Markdown blocks, and descriptions in mixed-tag JSDoc blocks as written. It places free-form tag text at the comment margin, removes a separator hyphen when a tag description moves onto its own line, keeps wrapped parameter, return, and throw descriptions indented, and preserves line breaks between adjacent `//` comments.
|
|
15
|
+
- The documentation rules for functions, variables, configured API calls and classes recognise comments across tool directives such as `eslint-disable-next-line`. `comments/function-documentation` accepts plain or optional JSDoc names for destructured parameters with defaults.
|
|
16
|
+
|
|
3
17
|
## 0.7.0: 2026-09-30
|
|
4
18
|
|
|
5
19
|
### Changes
|
package/README.md
CHANGED
|
@@ -62,6 +62,14 @@ Add the comments layer alongside the base or Vue layer to enforce the comment-fo
|
|
|
62
62
|
}
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
+
For functions, variables, configured API calls and classes, a tool directive comment such as `// eslint-disable-next-line` may sit between the documentation comment and the code.
|
|
66
|
+
|
|
67
|
+
For destructured parameters with defaults, document the parent and each property. Plain names such as `result.errors` and optional names such as `[result.errors]` or `[result.errors=[]]` all match `errors = []`. The parent can likewise be `result`, `[result]` or `[result={}]`. A documented default does not have to match the value in the code.
|
|
68
|
+
|
|
69
|
+
Lines below a free-form tag such as `@description` or `@note` start at the comment margin so they can be pasted into Markdown without becoming a code block. `@example` content is left as written. Wrapped `@param`, `@returns` and `@throws` descriptions keep a four-space hanging indent so each description is easy to scan beneath its tag. A description written as `name - description` loses the hyphen when it moves onto its own line.
|
|
70
|
+
|
|
71
|
+
Consecutive `//` comments keep their line breaks, and only a line past 80 columns is wrapped.
|
|
72
|
+
|
|
65
73
|
The Vue component rule reads the raw `.vue` file because Oxlint's JS Plugin API only receives the extracted script block. The comments layer loads its plugin for you, so there's no relative `jsPlugins` path to add. To pick rules yourself instead, add the plugin directly:
|
|
66
74
|
|
|
67
75
|
```json
|
|
@@ -102,36 +110,32 @@ The `comments/variable-declarations` rule accepts a `rootOnly` option to require
|
|
|
102
110
|
}
|
|
103
111
|
```
|
|
104
112
|
|
|
113
|
+
The `comments/function-documentation` rule accepts an `ignoreInlineArrows` option. The comments layer enables it for the same test files, so arrow functions used as object property values do not need JSDoc there. Function declarations, method shorthand, function expression properties, and `const` functions still need JSDoc. Other files keep the default of `false`.
|
|
114
|
+
|
|
105
115
|
The `comments/class-documentation` rule requires an immediately preceding block comment before class declarations and const-assigned class expressions. Constructors, methods, getters, and setters require full JSDoc; constructors never need an `@returns` tag, and getters and setters need one only when they return a value. Instance and static fields require an immediately preceding line comment.
|
|
106
116
|
|
|
107
117
|
### `vp check` configuration
|
|
108
118
|
|
|
109
|
-
|
|
119
|
+
On vite-plus 1.0.0, `vp check` and `vp lint` take their Oxlint settings from the `lint` block in `vite.config.js`. They ignore a `.oxlintrc.json` on its own, even though the Vite+ documentation says `vp lint` finds Oxlint config files by itself. If `vite.config.js` is missing, both commands quietly fall back to default settings. You still see plausible warnings and exit codes, but none of your rules are applied.
|
|
110
120
|
|
|
111
|
-
Each consuming repo needs a `vite.config.js` with a `lint` block
|
|
121
|
+
Each consuming repo needs a `vite.config.js` with a `lint` block. Use `lintConfig` with the layers you want. Oxlint ignores `env` and `globals` in configs listed under `extends`, so `lintConfig` copies them into the `lint` block itself, including values from the layers each one extends. Without that, `no-undef` reports Vue macros such as `defineProps` and browser globals such as `window` (see [docs/limitations.md](docs/limitations.md)). The Vue layer already includes base; add comments only if you want the comment rules. You can pass your project's `.oxlintrc.json` as the second argument to keep its local settings. `lintConfig` ignores the `extends` list in that file, so list every layer you want in the first argument.
|
|
112
122
|
|
|
113
123
|
```js
|
|
114
124
|
import { defineConfig } from "vite-plus";
|
|
115
|
-
import
|
|
116
|
-
import lintConfigComments from "@lewishowles/lint-config/comments.json" with { type: "json" };
|
|
125
|
+
import { base, comments, lintConfig } from "@lewishowles/lint-config/layers";
|
|
117
126
|
import oxlintrc from "./.oxlintrc.json" with { type: "json" };
|
|
118
127
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
ignorePatterns: oxlintrc.ignorePatterns,
|
|
123
|
-
jsPlugins: [...lintConfigBase.jsPlugins, ...lintConfigComments.jsPlugins],
|
|
124
|
-
overrides: [
|
|
125
|
-
...(lintConfigBase.overrides ?? []),
|
|
126
|
-
...(lintConfigComments.overrides ?? []),
|
|
127
|
-
...(oxlintrc.overrides ?? []),
|
|
128
|
-
],
|
|
129
|
-
rules: { ...lintConfigBase.rules, ...lintConfigComments.rules },
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
export default defineConfig({ lint });
|
|
128
|
+
export default defineConfig({
|
|
129
|
+
lint: lintConfig([base, comments], oxlintrc),
|
|
130
|
+
});
|
|
133
131
|
```
|
|
134
132
|
|
|
133
|
+
For a Vue project, use `lintConfig([vue, comments], oxlintrc)` and import `vue` instead of `base`. The local config is optional; `lintConfig([vue, comments])` also works. If you combine layers by hand, use object layers in `extends` and lift their `env` and `globals` to the top level yourself. Spreading JSON layers together replaces earlier `jsPlugins`, `overrides`, and `rules` arrays or objects.
|
|
134
|
+
|
|
135
|
+
Verify the setup with `vp lint --print-config <file>` and check that your real values, not defaults, are active. The printed `rules` leave out every rule that comes from a plugin, such as `comments/*` and `@stylistic/*`, even when those rules are running. To check a plugin layer, confirm the plugin is listed in `jsPlugins`, then lint a file that breaks one of its rules.
|
|
136
|
+
|
|
137
|
+
Vite+ also adds its own `vite-plus` lint plugin, so `vp check` can report rules that this package doesn't define. For example, `vite-plus/prefer-vite-plus-imports` reports imports from `oxlint` packages that Vite+ already provides, such as `oxlint/plugins-dev`.
|
|
138
|
+
|
|
135
139
|
## Customising
|
|
136
140
|
|
|
137
141
|
Your project's `.oxlintrc.json` can override rules, add ignore patterns, add overrides, or add plugins on top of the shared layer.
|
|
@@ -656,13 +656,10 @@ function reportLineCommentGroups(context) {
|
|
|
656
656
|
});
|
|
657
657
|
});
|
|
658
658
|
|
|
659
|
-
// The group's lines after refilling words from early-wrapped lines.
|
|
660
|
-
const refilledLines = refillCommentLines(formattedLines, maximumLineLength);
|
|
661
|
-
|
|
662
659
|
// The group's text after applying its final line formatting and
|
|
663
660
|
// placement gap.
|
|
664
661
|
const formattedText =
|
|
665
|
-
|
|
662
|
+
formattedLines
|
|
666
663
|
.map(({ prefix, text }) => (text === "" ? prefix.trimEnd() : `${prefix}${text}`))
|
|
667
664
|
.join(getNewline(context.sourceCode.text)) + (placement?.gap ?? "");
|
|
668
665
|
|
|
@@ -833,9 +830,10 @@ function reportBlockComments(context) {
|
|
|
833
830
|
}
|
|
834
831
|
|
|
835
832
|
/**
|
|
836
|
-
* The comment-formatting rule: punctuates comments as sentences
|
|
837
|
-
*
|
|
838
|
-
*
|
|
833
|
+
* The comment-formatting rule: punctuates comments as sentences and wraps any
|
|
834
|
+
* comment past 80 columns, replacing each comment in one edit. Block comments
|
|
835
|
+
* that wrap too early are refilled. Line-comment groups are reindented, but
|
|
836
|
+
* words never move between their lines.
|
|
839
837
|
*/
|
|
840
838
|
export default {
|
|
841
839
|
meta: {
|
|
@@ -53,6 +53,18 @@ export default {
|
|
|
53
53
|
meta: {
|
|
54
54
|
docs: { description: "Require JSDoc documentation for named functions and methods." },
|
|
55
55
|
type: "suggestion",
|
|
56
|
+
schema: [
|
|
57
|
+
{
|
|
58
|
+
type: "object",
|
|
59
|
+
properties: {
|
|
60
|
+
ignoreInlineArrows: {
|
|
61
|
+
type: "boolean",
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
additionalProperties: false,
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
defaultOptions: [{ ignoreInlineArrows: false }],
|
|
56
68
|
},
|
|
57
69
|
/**
|
|
58
70
|
* Create the rule's node visitors.
|
|
@@ -77,13 +89,20 @@ export default {
|
|
|
77
89
|
}
|
|
78
90
|
},
|
|
79
91
|
/**
|
|
80
|
-
* Check a first-level object method for documentation.
|
|
92
|
+
* Check a first-level object method for documentation. When the
|
|
93
|
+
* ignoreInlineArrows option is on, arrow function values are
|
|
94
|
+
* skipped, which suits small inline callbacks.
|
|
81
95
|
*
|
|
82
96
|
* @param {object} node
|
|
83
97
|
* The property node.
|
|
84
98
|
*/
|
|
85
99
|
Property(node) {
|
|
86
|
-
if (
|
|
100
|
+
if (
|
|
101
|
+
!isFirstLevelObjectProperty(node) ||
|
|
102
|
+
!isFunctionValue(node.value) ||
|
|
103
|
+
(context.options?.[0]?.ignoreInlineArrows &&
|
|
104
|
+
node.value.type === "ArrowFunctionExpression")
|
|
105
|
+
) {
|
|
87
106
|
return;
|
|
88
107
|
}
|
|
89
108
|
|
|
@@ -2,7 +2,7 @@ import { getJSDocContent, isJSDoc } from "./jsdoc.js";
|
|
|
2
2
|
import {
|
|
3
3
|
getCommentNeighbours,
|
|
4
4
|
getCommentText,
|
|
5
|
-
|
|
5
|
+
getImmediateNonDirectiveComment,
|
|
6
6
|
isLeadingComment,
|
|
7
7
|
} from "./source.js";
|
|
8
8
|
import { getPropertyName } from "./vue-macro.js";
|
|
@@ -45,8 +45,8 @@ export function isFunctionValue(node) {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
* Return the JSDoc comment
|
|
49
|
-
*
|
|
48
|
+
* Return the JSDoc comment directly above a node, when the comment qualifies as
|
|
49
|
+
* that node's documentation. Tool directive comments may sit between them.
|
|
50
50
|
*
|
|
51
51
|
* @param {object} sourceCode
|
|
52
52
|
* The Oxlint source code object.
|
|
@@ -57,26 +57,21 @@ export function isFunctionValue(node) {
|
|
|
57
57
|
* The qualifying JSDoc comment, or null when none precedes the node.
|
|
58
58
|
*/
|
|
59
59
|
function getDocumentationComment(sourceCode, node) {
|
|
60
|
-
//
|
|
61
|
-
const comment = sourceCode
|
|
62
|
-
.getAllComments()
|
|
63
|
-
.findLast((candidate) => candidate.range[1] <= node.range[0]);
|
|
60
|
+
// The closest comment above the node, looking past tool directive comments.
|
|
61
|
+
const comment = getImmediateNonDirectiveComment(sourceCode, node);
|
|
64
62
|
|
|
65
|
-
if (comment?.type !== "Block"
|
|
63
|
+
if (comment?.type !== "Block") {
|
|
66
64
|
return null;
|
|
67
65
|
}
|
|
68
66
|
|
|
69
67
|
// Checks the comments immediately around the documented node.
|
|
70
68
|
const { next, previous } = getCommentNeighbours(sourceCode, comment);
|
|
71
|
-
// Confirms there is no blank line before the documented node.
|
|
72
|
-
const gap = sourceCode.text.slice(comment.range[1], node.range[0]);
|
|
73
69
|
|
|
74
70
|
if (
|
|
75
71
|
!next ||
|
|
76
72
|
next.range[0] > node.range[0] ||
|
|
77
73
|
next.range[1] > node.range[1] ||
|
|
78
|
-
!isLeadingComment(sourceCode, comment, previous)
|
|
79
|
-
!/^\r?\n[ \t]*$/.test(gap)
|
|
74
|
+
!isLeadingComment(sourceCode, comment, previous)
|
|
80
75
|
) {
|
|
81
76
|
return null;
|
|
82
77
|
}
|
|
@@ -192,8 +187,9 @@ function getParameterPaths(sourceCode, node, rootPath = "options") {
|
|
|
192
187
|
* The JSDoc comment token.
|
|
193
188
|
*
|
|
194
189
|
* @returns {object}
|
|
195
|
-
* An object with names, the set of every documented parameter path
|
|
196
|
-
* topLevelNames, the top-level
|
|
190
|
+
* An object with names, the set of every documented parameter path with
|
|
191
|
+
* optional brackets and defaults removed, and topLevelNames, the top-level
|
|
192
|
+
* names in the order they are documented.
|
|
197
193
|
*/
|
|
198
194
|
function getDocumentedParameters(sourceCode, comment) {
|
|
199
195
|
// Splits the JSDoc block into its individual lines.
|
|
@@ -205,21 +201,16 @@ function getDocumentedParameters(sourceCode, comment) {
|
|
|
205
201
|
|
|
206
202
|
for (const line of content) {
|
|
207
203
|
// Matches an @param tag and captures its documented path.
|
|
208
|
-
const match = line.trim().match(/^@param(?:\s+\{[^}]+\})?\s+(\
|
|
204
|
+
const match = line.trim().match(/^@param(?:\s+\{[^}]+\})?\s+(\S+)/);
|
|
209
205
|
|
|
210
206
|
if (match) {
|
|
211
|
-
// The documented
|
|
212
|
-
|
|
213
|
-
const name = match[1];
|
|
207
|
+
// The documented path without optional brackets or a default value.
|
|
208
|
+
const name = getPlainParameterPath(match[1]);
|
|
214
209
|
|
|
215
210
|
names.add(name);
|
|
216
211
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const topLevelName = name.replace(/^\[|\]$/g, "").split("=")[0];
|
|
220
|
-
|
|
221
|
-
if (!topLevelName.includes(".")) {
|
|
222
|
-
topLevelNames.push(topLevelName);
|
|
212
|
+
if (!name.includes(".")) {
|
|
213
|
+
topLevelNames.push(name);
|
|
223
214
|
}
|
|
224
215
|
}
|
|
225
216
|
}
|
|
@@ -334,10 +325,12 @@ export function reportFunctionDocumentation(context, node, functionNode, options
|
|
|
334
325
|
);
|
|
335
326
|
|
|
336
327
|
for (const path of parameterPaths) {
|
|
337
|
-
// The optional
|
|
338
|
-
|
|
328
|
+
// The path without optional brackets or a default. Documented defaults
|
|
329
|
+
// are not compared with the code, so result.errors, [result.errors] and
|
|
330
|
+
// [result.errors=[]] all document the same property.
|
|
331
|
+
const plainPath = getPlainParameterPath(path);
|
|
339
332
|
|
|
340
|
-
if (!documentedParameters.has(
|
|
333
|
+
if (!documentedParameters.has(plainPath)) {
|
|
341
334
|
context.report({
|
|
342
335
|
message: `${subject} require an @param for ${path}.`,
|
|
343
336
|
node,
|
|
@@ -366,3 +359,18 @@ export function reportFunctionDocumentation(context, node, functionNode, options
|
|
|
366
359
|
});
|
|
367
360
|
}
|
|
368
361
|
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Reduce a parameter path to its plain name, so a documented path matches the
|
|
365
|
+
* required one however the documentation spells it.
|
|
366
|
+
*
|
|
367
|
+
* @param {string} path
|
|
368
|
+
* The documented or required parameter path.
|
|
369
|
+
*
|
|
370
|
+
* @returns {string}
|
|
371
|
+
* The path with brackets and any default removed, such as result.errors for
|
|
372
|
+
* [result.errors=[]].
|
|
373
|
+
*/
|
|
374
|
+
function getPlainParameterPath(path) {
|
|
375
|
+
return path.replace(/^\[|\]$/g, "").split("=")[0];
|
|
376
|
+
}
|
package/comments/utils/jsdoc.js
CHANGED
|
@@ -13,6 +13,10 @@ const tagOrder = ["param", "throws", "returns"];
|
|
|
13
13
|
const preservedSectionTags = new Set(["example"]);
|
|
14
14
|
// Fast lookup set built from tagOrder.
|
|
15
15
|
const targetTags = new Set(tagOrder);
|
|
16
|
+
// Markdown headings start with one to six hashes and a space.
|
|
17
|
+
const markdownHeadingPattern = /^#{1,6}\s/;
|
|
18
|
+
// Markdown list items start with a bullet or numbered marker and a space.
|
|
19
|
+
const markdownListItemPattern = /^(?:[-*]|\d+[.)])\s/;
|
|
16
20
|
|
|
17
21
|
/**
|
|
18
22
|
* Return whether source text is a JSDoc-style block comment.
|
|
@@ -174,19 +178,19 @@ function parseTargetTags(tagLines) {
|
|
|
174
178
|
* The aligned tag header.
|
|
175
179
|
*/
|
|
176
180
|
function formatTagHeader(entry) {
|
|
177
|
-
//
|
|
178
|
-
const typeMatch = entry
|
|
181
|
+
// The tag's type annotation, plus a name when the tag is @param.
|
|
182
|
+
const typeMatch = parseTargetTagRest(entry);
|
|
179
183
|
|
|
180
184
|
if (!typeMatch) {
|
|
181
185
|
return `@${entry.type} ${entry.rest}`.trimEnd();
|
|
182
186
|
}
|
|
183
187
|
|
|
184
188
|
// The bare {type} annotation.
|
|
185
|
-
const type = typeMatch
|
|
189
|
+
const type = typeMatch.groups.type;
|
|
186
190
|
// The parameter name, when the tag has one.
|
|
187
|
-
const name = typeMatch
|
|
191
|
+
const name = typeMatch.groups.name;
|
|
188
192
|
|
|
189
|
-
if (
|
|
193
|
+
if (name) {
|
|
190
194
|
return `@param ${type} ${name}`;
|
|
191
195
|
}
|
|
192
196
|
|
|
@@ -194,23 +198,204 @@ function formatTagHeader(entry) {
|
|
|
194
198
|
}
|
|
195
199
|
|
|
196
200
|
/**
|
|
197
|
-
* Return
|
|
201
|
+
* Return a target tag's inline description without a separator hyphen. Once the
|
|
202
|
+
* description moves onto its own line, a leading hyphen would read as a
|
|
203
|
+
* Markdown bullet.
|
|
198
204
|
*
|
|
199
205
|
* @param {object} entry
|
|
200
206
|
* The parsed tag entry.
|
|
201
207
|
*
|
|
202
208
|
* @returns {string}
|
|
203
|
-
* The inline description, when
|
|
209
|
+
* The inline description, or an empty string when there is none.
|
|
204
210
|
*/
|
|
205
211
|
function getInlineTagDescription(entry) {
|
|
206
|
-
//
|
|
207
|
-
const
|
|
212
|
+
// The parsed type and description, with a name only for parameters.
|
|
213
|
+
const tagParts = parseTargetTagRest(entry);
|
|
208
214
|
|
|
209
|
-
return
|
|
215
|
+
return (tagParts?.groups.description ?? "").replace(/^-(?:\s+|$)/, "");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Parse a target tag's type and inline description, including a parameter name
|
|
220
|
+
* only when the tag is @param.
|
|
221
|
+
*
|
|
222
|
+
* @param {object} entry
|
|
223
|
+
* The parsed tag entry.
|
|
224
|
+
*
|
|
225
|
+
* @returns {RegExpMatchArray|null}
|
|
226
|
+
* The type, name and description, or null when there is no type.
|
|
227
|
+
*/
|
|
228
|
+
function parseTargetTagRest(entry) {
|
|
229
|
+
if (entry.type === "param") {
|
|
230
|
+
return entry.rest.match(/^(?<type>\{[^}]+\})(?:\s+(?<name>\S+))?(?:\s+(?<description>.*))?$/);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return entry.rest.match(/^(?<type>\{[^}]+\})(?:\s+(?<description>.*))?$/);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Find the lines that belong to a Markdown heading, list, table or fenced code
|
|
238
|
+
* block, so formatting can leave them exactly as written.
|
|
239
|
+
*
|
|
240
|
+
* @param {string[]} lines
|
|
241
|
+
* The undecorated JSDoc lines.
|
|
242
|
+
*
|
|
243
|
+
* @returns {boolean[]}
|
|
244
|
+
* True for each line that belongs to a Markdown block, in line order.
|
|
245
|
+
*/
|
|
246
|
+
function getMarkdownStructure(lines) {
|
|
247
|
+
// Whether each line read so far belongs to a Markdown block.
|
|
248
|
+
const structure = [];
|
|
249
|
+
|
|
250
|
+
// Whether the current line is inside a fenced code block.
|
|
251
|
+
let inFence = false;
|
|
252
|
+
// The indentation of the open list item; deeper lines continue it.
|
|
253
|
+
let listIndent = null;
|
|
254
|
+
|
|
255
|
+
for (const line of lines) {
|
|
256
|
+
// The line without surrounding whitespace.
|
|
257
|
+
const trimmed = line.trim();
|
|
258
|
+
// The number of whitespace characters before the line's text.
|
|
259
|
+
const indent = line.length - line.trimStart().length;
|
|
260
|
+
|
|
261
|
+
// A list or table can start on the first line, after a blank line, or
|
|
262
|
+
// straight after another Markdown block.
|
|
263
|
+
const blockBoundary =
|
|
264
|
+
structure.length === 0 || lines[structure.length - 1].trim() === "" || structure.at(-1);
|
|
265
|
+
|
|
266
|
+
if (inFence) {
|
|
267
|
+
structure.push(true);
|
|
268
|
+
|
|
269
|
+
inFence = !trimmed.startsWith("```");
|
|
270
|
+
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (trimmed.startsWith("```")) {
|
|
275
|
+
structure.push(true);
|
|
276
|
+
|
|
277
|
+
inFence = true;
|
|
278
|
+
listIndent = null;
|
|
279
|
+
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// A bullet or a list numbered from 1 can follow prose directly. Other
|
|
284
|
+
// numbers need a block boundary, so prose such as "2) the second case"
|
|
285
|
+
// isn't read as a list.
|
|
286
|
+
if (
|
|
287
|
+
markdownListItemPattern.test(trimmed) &&
|
|
288
|
+
(blockBoundary || /^1[.)]\s/.test(trimmed) || /^[-*]\s/.test(trimmed))
|
|
289
|
+
) {
|
|
290
|
+
structure.push(true);
|
|
291
|
+
|
|
292
|
+
listIndent = indent;
|
|
293
|
+
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// An indented line continues the list item above it.
|
|
298
|
+
if (trimmed !== "" && listIndent !== null && indent > listIndent) {
|
|
299
|
+
structure.push(true);
|
|
300
|
+
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
listIndent = null;
|
|
305
|
+
|
|
306
|
+
// A heading can start anywhere, but a table must start at a boundary.
|
|
307
|
+
structure.push(
|
|
308
|
+
markdownHeadingPattern.test(trimmed) || (blockBoundary && trimmed.startsWith("|")),
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return structure;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Format the prose in a run of JSDoc lines and copy Markdown block lines
|
|
317
|
+
* through unchanged.
|
|
318
|
+
*
|
|
319
|
+
* @param {string[]} lines
|
|
320
|
+
* The undecorated JSDoc lines.
|
|
321
|
+
* @param {function} formatPlainLines
|
|
322
|
+
* Formats one run of prose lines and returns the formatted lines.
|
|
323
|
+
* @param {string[]} structureLines
|
|
324
|
+
* The lines to check for Markdown blocks, when they differ from the lines
|
|
325
|
+
* being formatted. Tag descriptions pass a blank line in place of the
|
|
326
|
+
* inline description so it is never read as Markdown.
|
|
327
|
+
*
|
|
328
|
+
* @returns {string[]}
|
|
329
|
+
* The formatted prose and unchanged Markdown lines.
|
|
330
|
+
*/
|
|
331
|
+
function splitMarkdownProse(lines, formatPlainLines, structureLines = lines) {
|
|
332
|
+
// Whether each line belongs to a Markdown block.
|
|
333
|
+
const structure = getMarkdownStructure(structureLines);
|
|
334
|
+
// The formatted lines, including unchanged Markdown blocks.
|
|
335
|
+
const result = [];
|
|
336
|
+
|
|
337
|
+
// The prose waiting to be formatted.
|
|
338
|
+
let prose = [];
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Format the waiting prose, keeping the blank line that separated it from
|
|
342
|
+
* the next Markdown block.
|
|
343
|
+
*
|
|
344
|
+
* @param {boolean} keepBlank
|
|
345
|
+
* Whether a Markdown block follows, so the blank line before it stays.
|
|
346
|
+
*/
|
|
347
|
+
function flushProse(keepBlank = false) {
|
|
348
|
+
if (prose.length > 0) {
|
|
349
|
+
// Whether the source separates the prose from the next block.
|
|
350
|
+
const endsWithBlank = prose.at(-1)?.trim() === "";
|
|
351
|
+
|
|
352
|
+
result.push(...formatPlainLines(prose));
|
|
353
|
+
|
|
354
|
+
if (keepBlank && endsWithBlank && result.at(-1) !== "") {
|
|
355
|
+
result.push("");
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
prose = [];
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
363
|
+
if (structure[index]) {
|
|
364
|
+
flushProse(true);
|
|
365
|
+
result.push(lines[index]);
|
|
366
|
+
} else {
|
|
367
|
+
prose.push(lines[index]);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
flushProse();
|
|
372
|
+
|
|
373
|
+
return result;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Wrap prose without making a new line look like a Markdown block.
|
|
378
|
+
*
|
|
379
|
+
* @param {string} text
|
|
380
|
+
* The prose to wrap.
|
|
381
|
+
* @param {number} width
|
|
382
|
+
* The available content width.
|
|
383
|
+
*
|
|
384
|
+
* @returns {string[]}
|
|
385
|
+
* The wrapped prose lines.
|
|
386
|
+
*/
|
|
387
|
+
function wrapProse(text, width) {
|
|
388
|
+
// Joins each word that looks like a Markdown marker to the word before it
|
|
389
|
+
// with a word joiner (U+2060), so wrapping never starts a line with it. The
|
|
390
|
+
// joiner turns back into a space before the lines are returned.
|
|
391
|
+
const joined = text.replace(/(\S+)\s+([-*|]|\d+[.)]|#{1,6})(?=\s)/g, "$1\u2060$2");
|
|
392
|
+
|
|
393
|
+
return wrapWords(joined, width).map((line) => line.replaceAll("\u2060", " "));
|
|
210
394
|
}
|
|
211
395
|
|
|
212
396
|
/**
|
|
213
397
|
* Format and refill JSDoc prose while preserving paragraph and list boundaries.
|
|
398
|
+
* Markdown blocks are left as written.
|
|
214
399
|
*
|
|
215
400
|
* @param {string[]} lines
|
|
216
401
|
* The prose content lines.
|
|
@@ -223,6 +408,25 @@ function getInlineTagDescription(entry) {
|
|
|
223
408
|
* The formatted prose lines.
|
|
224
409
|
*/
|
|
225
410
|
function formatUnwrappedProse(lines, addPunctuation, indentation) {
|
|
411
|
+
return splitMarkdownProse(lines, (prose) =>
|
|
412
|
+
formatPlainUnwrappedProse(prose, addPunctuation, indentation),
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Refill and format JSDoc prose that contains no Markdown blocks.
|
|
418
|
+
*
|
|
419
|
+
* @param {string[]} lines
|
|
420
|
+
* The prose lines to refill.
|
|
421
|
+
* @param {boolean} addPunctuation
|
|
422
|
+
* Whether to format each paragraph as a sentence.
|
|
423
|
+
* @param {string} indentation
|
|
424
|
+
* The indentation used by the comment.
|
|
425
|
+
*
|
|
426
|
+
* @returns {string[]}
|
|
427
|
+
* The refilled prose lines.
|
|
428
|
+
*/
|
|
429
|
+
function formatPlainUnwrappedProse(lines, addPunctuation, indentation) {
|
|
226
430
|
// The prose lines after refilling, then formatted in place below.
|
|
227
431
|
const result = refillCommentLines(
|
|
228
432
|
lines.map((line) => ({ prefix: `${indentation} * `, text: line })),
|
|
@@ -259,7 +463,8 @@ function formatUnwrappedProse(lines, addPunctuation, indentation) {
|
|
|
259
463
|
}
|
|
260
464
|
|
|
261
465
|
/**
|
|
262
|
-
* Format prose paragraphs to the block-comment width
|
|
466
|
+
* Format prose paragraphs to the block-comment width, leaving Markdown blocks
|
|
467
|
+
* as written.
|
|
263
468
|
*
|
|
264
469
|
* @param {string[]} lines
|
|
265
470
|
* The prose content lines.
|
|
@@ -272,7 +477,26 @@ function formatUnwrappedProse(lines, addPunctuation, indentation) {
|
|
|
272
477
|
* Formatted prose content lines.
|
|
273
478
|
*/
|
|
274
479
|
function formatProse(lines, width, addPunctuation) {
|
|
275
|
-
|
|
480
|
+
return splitMarkdownProse(lines, (proseLines) =>
|
|
481
|
+
formatPlainProse(proseLines, width, addPunctuation),
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Format prose paragraphs that contain no Markdown blocks.
|
|
487
|
+
*
|
|
488
|
+
* @param {string[]} lines
|
|
489
|
+
* The prose lines.
|
|
490
|
+
* @param {number} width
|
|
491
|
+
* The available content width.
|
|
492
|
+
* @param {boolean} addPunctuation
|
|
493
|
+
* Whether to format each paragraph as a sentence.
|
|
494
|
+
*
|
|
495
|
+
* @returns {string[]}
|
|
496
|
+
* The formatted prose lines.
|
|
497
|
+
*/
|
|
498
|
+
function formatPlainProse(lines, width, addPunctuation) {
|
|
499
|
+
// The formatted prose lines, built up in place.
|
|
276
500
|
const result = [];
|
|
277
501
|
|
|
278
502
|
// The prose lines collected for the paragraph in progress.
|
|
@@ -293,12 +517,15 @@ function formatProse(lines, width, addPunctuation) {
|
|
|
293
517
|
text = formatSentence(text);
|
|
294
518
|
}
|
|
295
519
|
|
|
296
|
-
result.push(...
|
|
520
|
+
result.push(...wrapProse(text, width));
|
|
297
521
|
|
|
298
522
|
paragraph = [];
|
|
299
523
|
}
|
|
300
524
|
|
|
301
|
-
for (
|
|
525
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
526
|
+
// The current undecorated JSDoc line.
|
|
527
|
+
const line = lines[index];
|
|
528
|
+
|
|
302
529
|
if (line.trim() === "") {
|
|
303
530
|
flushParagraph();
|
|
304
531
|
|
|
@@ -325,7 +552,8 @@ function formatProse(lines, width, addPunctuation) {
|
|
|
325
552
|
* @param {string[]} tagLines
|
|
326
553
|
* The undecorated tag content.
|
|
327
554
|
* @param {number} width
|
|
328
|
-
* The
|
|
555
|
+
* The width of the comment text. @param, @returns and @throws descriptions
|
|
556
|
+
* wrap four characters narrower to fit their indent.
|
|
329
557
|
* @param {boolean} addPunctuation
|
|
330
558
|
* Whether to format tag descriptions as sentences.
|
|
331
559
|
* @param {boolean} normaliseTags
|
|
@@ -364,22 +592,7 @@ function formatTags(tagLines, width, addPunctuation, normaliseTags) {
|
|
|
364
592
|
}
|
|
365
593
|
|
|
366
594
|
result.push(formatTagHeader(entry));
|
|
367
|
-
|
|
368
|
-
// The entry's inline and multi-line description text, combined.
|
|
369
|
-
const description = [getInlineTagDescription(entry), ...entry.description]
|
|
370
|
-
.filter((line) => line.trim() !== "")
|
|
371
|
-
.map((line) => line.trim());
|
|
372
|
-
|
|
373
|
-
// The description text, punctuated as a sentence when requested.
|
|
374
|
-
let descriptionText = description.join(" ");
|
|
375
|
-
|
|
376
|
-
if (addPunctuation && descriptionText !== "") {
|
|
377
|
-
descriptionText = formatSentence(descriptionText);
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
if (descriptionText !== "") {
|
|
381
|
-
result.push(...wrapWords(descriptionText, width).map((line) => ` ${line}`));
|
|
382
|
-
}
|
|
595
|
+
result.push(...formatTargetDescription(entry, width, addPunctuation));
|
|
383
596
|
|
|
384
597
|
lastType = entry.type;
|
|
385
598
|
}
|
|
@@ -393,7 +606,8 @@ function formatTags(tagLines, width, addPunctuation, normaliseTags) {
|
|
|
393
606
|
* @param {string[]} lines
|
|
394
607
|
* The undecorated tag content lines.
|
|
395
608
|
* @param {number} width
|
|
396
|
-
* The
|
|
609
|
+
* The width of the comment text. @param, @returns and @throws descriptions
|
|
610
|
+
* wrap four characters narrower to fit their indent.
|
|
397
611
|
* @param {boolean} addPunctuation
|
|
398
612
|
* Whether to format descriptions as sentences.
|
|
399
613
|
*
|
|
@@ -401,6 +615,9 @@ function formatTags(tagLines, width, addPunctuation, normaliseTags) {
|
|
|
401
615
|
* Formatted mixed tag content lines.
|
|
402
616
|
*/
|
|
403
617
|
function formatMixedTags(lines, width, addPunctuation) {
|
|
618
|
+
// Whether each line belongs to a Markdown block. Tag lines count as blank
|
|
619
|
+
// so a list ends at the next tag.
|
|
620
|
+
const structure = getMarkdownStructure(lines.map((line) => (getJSDocTagName(line) ? "" : line)));
|
|
404
621
|
// The formatted lines, built up in place.
|
|
405
622
|
const result = [];
|
|
406
623
|
|
|
@@ -409,12 +626,28 @@ function formatMixedTags(lines, width, addPunctuation) {
|
|
|
409
626
|
// Whether the current tag's content is copied through unchanged.
|
|
410
627
|
let preserveSection = false;
|
|
411
628
|
|
|
412
|
-
for (
|
|
629
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
630
|
+
// The current undecorated JSDoc line.
|
|
631
|
+
const line = lines[index];
|
|
413
632
|
// The tag name, or null when the line isn't a tag at all.
|
|
414
633
|
const tagName = getJSDocTagName(line);
|
|
415
634
|
// Matches a new @param/@throws/@returns tag line.
|
|
416
635
|
const match = line.trim().match(/^@(param|throws|returns)\b(.*)$/);
|
|
417
636
|
|
|
637
|
+
if (tagName !== null && currentEntry) {
|
|
638
|
+
// Whether the author left a blank line between this description and
|
|
639
|
+
// the next tag.
|
|
640
|
+
const hasTagSeparator = currentEntry.description.at(-1)?.trim() === "";
|
|
641
|
+
|
|
642
|
+
result.push(...formatTargetDescription(currentEntry, width, addPunctuation));
|
|
643
|
+
|
|
644
|
+
if (hasTagSeparator && result.at(-1) !== "") {
|
|
645
|
+
result.push("");
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
currentEntry = null;
|
|
649
|
+
}
|
|
650
|
+
|
|
418
651
|
if (match) {
|
|
419
652
|
currentEntry = {
|
|
420
653
|
description: [],
|
|
@@ -426,30 +659,28 @@ function formatMixedTags(lines, width, addPunctuation) {
|
|
|
426
659
|
|
|
427
660
|
result.push(formatTagHeader(currentEntry));
|
|
428
661
|
} else if (tagName !== null) {
|
|
429
|
-
currentEntry = null;
|
|
430
662
|
preserveSection = isPreservedSectionTag(line);
|
|
431
663
|
|
|
432
664
|
result.push(line.trim());
|
|
665
|
+
} else if (currentEntry) {
|
|
666
|
+
currentEntry.description.push(line);
|
|
433
667
|
} else if (preserveSection) {
|
|
434
668
|
result.push(line);
|
|
669
|
+
} else if (structure[index]) {
|
|
670
|
+
result.push(line);
|
|
435
671
|
} else if (line.trim() === "") {
|
|
436
|
-
currentEntry = null;
|
|
437
|
-
|
|
438
672
|
if (result.at(-1) !== "") {
|
|
439
673
|
result.push("");
|
|
440
674
|
}
|
|
441
675
|
} else {
|
|
442
|
-
|
|
443
|
-
let text = line.trim();
|
|
444
|
-
|
|
445
|
-
if (addPunctuation && currentEntry) {
|
|
446
|
-
text = formatSentence(text);
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
result.push(...wrapWords(text, width).map((wrappedLine) => ` ${wrappedLine}`));
|
|
676
|
+
result.push(...wrapProse(line.trim(), width));
|
|
450
677
|
}
|
|
451
678
|
}
|
|
452
679
|
|
|
680
|
+
if (currentEntry) {
|
|
681
|
+
result.push(...formatTargetDescription(currentEntry, width, addPunctuation));
|
|
682
|
+
}
|
|
683
|
+
|
|
453
684
|
while (result.at(-1) === "") {
|
|
454
685
|
result.pop();
|
|
455
686
|
}
|
|
@@ -457,13 +688,60 @@ function formatMixedTags(lines, width, addPunctuation) {
|
|
|
457
688
|
return result;
|
|
458
689
|
}
|
|
459
690
|
|
|
691
|
+
/**
|
|
692
|
+
* Format the description of a @param, @returns or @throws tag, joining the text
|
|
693
|
+
* on the tag line with the lines below it.
|
|
694
|
+
*
|
|
695
|
+
* @param {object} entry
|
|
696
|
+
* The parsed tag, with its tag-line text and the description lines
|
|
697
|
+
* collected beneath it.
|
|
698
|
+
* @param {number} width
|
|
699
|
+
* The width of the comment text. The description wraps four characters
|
|
700
|
+
* narrower to fit its indent.
|
|
701
|
+
* @param {boolean} addPunctuation
|
|
702
|
+
* Whether to format prose paragraphs as sentences.
|
|
703
|
+
*
|
|
704
|
+
* @returns {string[]}
|
|
705
|
+
* The formatted description lines beneath the tag header.
|
|
706
|
+
*/
|
|
707
|
+
function formatTargetDescription(entry, width, addPunctuation) {
|
|
708
|
+
// The description written on the tag line, without a leading hyphen.
|
|
709
|
+
const inlineDescription = getInlineTagDescription(entry);
|
|
710
|
+
// The full tag description, before wrapping.
|
|
711
|
+
const description = [inlineDescription, ...entry.description];
|
|
712
|
+
|
|
713
|
+
// Drop blank lines around the description.
|
|
714
|
+
while (description[0]?.trim() === "") {
|
|
715
|
+
description.shift();
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
while (description.at(-1)?.trim() === "") {
|
|
719
|
+
description.pop();
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Ignore the inline description when finding Markdown blocks, so a
|
|
723
|
+
// description that starts with something like "1." or "#" stays prose.
|
|
724
|
+
const structuralDescription = inlineDescription ? ["", ...description.slice(1)] : description;
|
|
725
|
+
|
|
726
|
+
return splitMarkdownProse(
|
|
727
|
+
description,
|
|
728
|
+
(prose) => {
|
|
729
|
+
return formatPlainProse(prose, Math.max(1, width - 4), addPunctuation).map((line) => {
|
|
730
|
+
return line === "" ? "" : ` ${line}`;
|
|
731
|
+
});
|
|
732
|
+
},
|
|
733
|
+
structuralDescription,
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
|
|
460
737
|
/**
|
|
461
738
|
* Format descriptions while retaining non-target tag lines.
|
|
462
739
|
*
|
|
463
740
|
* @param {string[]} lines
|
|
464
741
|
* The undecorated tag content lines.
|
|
465
742
|
* @param {number} width
|
|
466
|
-
* The
|
|
743
|
+
* The width of the comment text. @param, @returns and @throws descriptions
|
|
744
|
+
* wrap four characters narrower to fit their indent.
|
|
467
745
|
* @param {boolean} addPunctuation
|
|
468
746
|
* Whether to format descriptions as sentences.
|
|
469
747
|
*
|
|
@@ -471,11 +749,17 @@ function formatMixedTags(lines, width, addPunctuation) {
|
|
|
471
749
|
* Formatted tag content lines.
|
|
472
750
|
*/
|
|
473
751
|
function formatTagDescriptions(lines, width, addPunctuation) {
|
|
752
|
+
// Whether each line belongs to a Markdown block. Tag lines count as blank
|
|
753
|
+
// so a list ends at the next tag.
|
|
754
|
+
const structure = getMarkdownStructure(lines.map((line) => (getJSDocTagName(line) ? "" : line)));
|
|
474
755
|
// The formatted lines, built up in place.
|
|
475
756
|
const result = [];
|
|
476
757
|
|
|
477
758
|
// The description lines collected for the tag in progress.
|
|
478
759
|
let description = [];
|
|
760
|
+
// Whether the current tag is @param, @returns or @throws, whose description
|
|
761
|
+
// keeps a four-space indent.
|
|
762
|
+
let indentDescription = false;
|
|
479
763
|
// Whether the current tag's content is copied through unchanged.
|
|
480
764
|
let preserveSection = false;
|
|
481
765
|
|
|
@@ -494,12 +778,20 @@ function formatTagDescriptions(lines, width, addPunctuation) {
|
|
|
494
778
|
text = formatSentence(text);
|
|
495
779
|
}
|
|
496
780
|
|
|
497
|
-
|
|
781
|
+
// The wrapping width, four characters narrower for @param, @returns and
|
|
782
|
+
// @throws so their indent still fits.
|
|
783
|
+
const descriptionWidth = indentDescription ? Math.max(1, width - 4) : width;
|
|
784
|
+
// The indent applied to each wrapped description line.
|
|
785
|
+
const indent = indentDescription ? " " : "";
|
|
786
|
+
|
|
787
|
+
result.push(...wrapProse(text, descriptionWidth).map((line) => `${indent}${line}`));
|
|
498
788
|
|
|
499
789
|
description = [];
|
|
500
790
|
}
|
|
501
791
|
|
|
502
|
-
for (
|
|
792
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
793
|
+
// The current undecorated JSDoc line.
|
|
794
|
+
const line = lines[index];
|
|
503
795
|
// The tag name, or null when the line isn't a tag at all.
|
|
504
796
|
const tagName = getJSDocTagName(line);
|
|
505
797
|
|
|
@@ -507,9 +799,13 @@ function formatTagDescriptions(lines, width, addPunctuation) {
|
|
|
507
799
|
flushDescription();
|
|
508
800
|
result.push(line.trim());
|
|
509
801
|
|
|
802
|
+
indentDescription = isTargetTag(line);
|
|
510
803
|
preserveSection = isPreservedSectionTag(line);
|
|
511
804
|
} else if (preserveSection) {
|
|
512
805
|
result.push(line);
|
|
806
|
+
} else if (structure[index]) {
|
|
807
|
+
flushDescription();
|
|
808
|
+
result.push(line);
|
|
513
809
|
} else if (line.trim() === "") {
|
|
514
810
|
flushDescription();
|
|
515
811
|
|
|
@@ -645,15 +941,8 @@ export function formatJSDocBlockStructure(commentText, formattingOptions) {
|
|
|
645
941
|
const formattingContext = getJSDocFormattingContext(commentText, formattingOptions);
|
|
646
942
|
// The prose lines, refilled before tags are appended.
|
|
647
943
|
const prose = formatUnwrappedProse(formattingContext.proseLines, false, formattingContext.indent);
|
|
648
|
-
|
|
649
944
|
// The tag lines, without spacing or grouping normalisation.
|
|
650
|
-
const tags = formatTags(
|
|
651
|
-
formattingContext.tagLines,
|
|
652
|
-
Math.max(1, formattingContext.width - 4),
|
|
653
|
-
false,
|
|
654
|
-
false,
|
|
655
|
-
);
|
|
656
|
-
|
|
945
|
+
const tags = formatTags(formattingContext.tagLines, formattingContext.width, false, false);
|
|
657
946
|
// The formatted comment content, before tags are appended.
|
|
658
947
|
const outputLines = [...prose];
|
|
659
948
|
|
|
@@ -692,15 +981,8 @@ export function formatJSDocTagFormatting(commentText, formattingOptions) {
|
|
|
692
981
|
const formattingContext = getJSDocFormattingContext(commentText, formattingOptions);
|
|
693
982
|
// The prose, rewrapped to the comment's available width.
|
|
694
983
|
const prose = formatProse(formattingContext.proseLines, formattingContext.width, false);
|
|
695
|
-
|
|
696
984
|
// The tag lines, with spacing and grouping normalised.
|
|
697
|
-
const tags = formatTags(
|
|
698
|
-
formattingContext.tagLines,
|
|
699
|
-
Math.max(1, formattingContext.width - 4),
|
|
700
|
-
false,
|
|
701
|
-
true,
|
|
702
|
-
);
|
|
703
|
-
|
|
985
|
+
const tags = formatTags(formattingContext.tagLines, formattingContext.width, false, true);
|
|
704
986
|
// The formatted comment content, before tags are appended.
|
|
705
987
|
const outputLines = [...prose];
|
|
706
988
|
|
|
@@ -726,15 +1008,8 @@ export function formatJSDocPunctuation(commentText, formattingOptions) {
|
|
|
726
1008
|
const formattingContext = getJSDocFormattingContext(commentText, formattingOptions);
|
|
727
1009
|
// The prose, capitalised and punctuated as sentences.
|
|
728
1010
|
const prose = formatUnwrappedProse(formattingContext.proseLines, true, formattingContext.indent);
|
|
729
|
-
|
|
730
1011
|
// The tag lines, with descriptions punctuated as sentences.
|
|
731
|
-
const tags = formatTags(
|
|
732
|
-
formattingContext.tagLines,
|
|
733
|
-
Math.max(1, formattingContext.width - 4),
|
|
734
|
-
true,
|
|
735
|
-
false,
|
|
736
|
-
);
|
|
737
|
-
|
|
1012
|
+
const tags = formatTags(formattingContext.tagLines, formattingContext.width, true, false);
|
|
738
1013
|
// The formatted comment content, before tags are appended.
|
|
739
1014
|
const outputLines = [...prose];
|
|
740
1015
|
|
|
@@ -760,15 +1035,8 @@ export function formatJSDocWrapping(commentText, formattingOptions) {
|
|
|
760
1035
|
const formattingContext = getJSDocFormattingContext(commentText, formattingOptions);
|
|
761
1036
|
// The prose, rewrapped to the comment's available width.
|
|
762
1037
|
const prose = formatProse(formattingContext.proseLines, formattingContext.width, false);
|
|
763
|
-
|
|
764
1038
|
// The tag lines, without spacing or grouping normalisation.
|
|
765
|
-
const tags = formatTags(
|
|
766
|
-
formattingContext.tagLines,
|
|
767
|
-
Math.max(1, formattingContext.width - 4),
|
|
768
|
-
false,
|
|
769
|
-
false,
|
|
770
|
-
);
|
|
771
|
-
|
|
1039
|
+
const tags = formatTags(formattingContext.tagLines, formattingContext.width, false, false);
|
|
772
1040
|
// The formatted comment content, before tags are appended.
|
|
773
1041
|
const outputLines = [...prose];
|
|
774
1042
|
|
package/comments/utils/source.js
CHANGED
|
@@ -301,28 +301,68 @@ export function isLeadingComment(sourceCode, comment, previous) {
|
|
|
301
301
|
* The source node.
|
|
302
302
|
*
|
|
303
303
|
* @returns {boolean}
|
|
304
|
-
* Whether an ordinary line comment
|
|
304
|
+
* Whether an ordinary line comment sits directly above the node. Tool
|
|
305
|
+
* directive comments may sit between them.
|
|
305
306
|
*/
|
|
306
307
|
export function hasImmediateLineComment(sourceCode, node) {
|
|
307
|
-
//
|
|
308
|
-
const comment = sourceCode
|
|
309
|
-
.getAllComments()
|
|
310
|
-
.findLast((candidate) => candidate.range[1] <= node.range[0]);
|
|
308
|
+
// The closest comment above the node, looking past tool directive comments.
|
|
309
|
+
const comment = getImmediateNonDirectiveComment(sourceCode, node);
|
|
311
310
|
|
|
312
|
-
if (comment?.type !== "Line"
|
|
311
|
+
if (comment?.type !== "Line") {
|
|
313
312
|
return false;
|
|
314
313
|
}
|
|
315
314
|
|
|
316
315
|
// Checks the comments immediately around the node.
|
|
317
316
|
const { next, previous } = getCommentNeighbours(sourceCode, comment);
|
|
318
|
-
// Confirms there is no blank line before the node.
|
|
319
|
-
const gap = sourceCode.text.slice(comment.range[1], node.range[0]);
|
|
320
317
|
|
|
321
|
-
return (
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
318
|
+
return next?.range[0] === node.range[0] && isLeadingComment(sourceCode, comment, previous);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Return the closest comment above a node, skipping tool directive comments
|
|
323
|
+
* such as `// eslint-disable-next-line`. The comment, each directive and the
|
|
324
|
+
* node must each start on the line after the one before, with no blank lines.
|
|
325
|
+
*
|
|
326
|
+
* @param {object} sourceCode
|
|
327
|
+
* The Oxlint source code object.
|
|
328
|
+
* @param {object} node
|
|
329
|
+
* The source node.
|
|
330
|
+
*
|
|
331
|
+
* @returns {object|null}
|
|
332
|
+
* The comment above the node, or null when no comment sits directly above
|
|
333
|
+
* the node.
|
|
334
|
+
*/
|
|
335
|
+
export function getImmediateNonDirectiveComment(sourceCode, node) {
|
|
336
|
+
// Comments before the node, in source order.
|
|
337
|
+
const comments = sourceCode
|
|
338
|
+
.getAllComments()
|
|
339
|
+
.filter((comment) => comment.range[1] <= node.range[0]);
|
|
340
|
+
|
|
341
|
+
// The position of the closest comment that is not a tool directive.
|
|
342
|
+
const commentIndex = comments.findLastIndex((comment) => !isDirectiveComment(comment));
|
|
343
|
+
|
|
344
|
+
if (commentIndex < 0) {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// The comment that may document the node.
|
|
349
|
+
const comment = comments[commentIndex];
|
|
350
|
+
|
|
351
|
+
// Where the comment or directive checked last ends.
|
|
352
|
+
let previousEnd = comment.range[1];
|
|
353
|
+
|
|
354
|
+
for (const next of [...comments.slice(commentIndex + 1), node]) {
|
|
355
|
+
// The text between the previous item and this one.
|
|
356
|
+
const gap = sourceCode.text.slice(previousEnd, next.range[0]);
|
|
357
|
+
|
|
358
|
+
if (!/^\r?\n[ \t]*$/.test(gap)) {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
previousEnd = next.range[1];
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return comment;
|
|
326
366
|
}
|
|
327
367
|
|
|
328
368
|
/**
|
|
@@ -334,26 +374,19 @@ export function hasImmediateLineComment(sourceCode, node) {
|
|
|
334
374
|
* The source node.
|
|
335
375
|
*
|
|
336
376
|
* @returns {boolean}
|
|
337
|
-
* Whether an ordinary block comment
|
|
377
|
+
* Whether an ordinary block comment sits directly above the node. Tool
|
|
378
|
+
* directive comments may sit between them.
|
|
338
379
|
*/
|
|
339
380
|
export function hasImmediateBlockComment(sourceCode, node) {
|
|
340
|
-
//
|
|
341
|
-
const comment = sourceCode
|
|
342
|
-
.getAllComments()
|
|
343
|
-
.findLast((candidate) => candidate.range[1] <= node.range[0]);
|
|
381
|
+
// The closest comment above the node, looking past tool directive comments.
|
|
382
|
+
const comment = getImmediateNonDirectiveComment(sourceCode, node);
|
|
344
383
|
|
|
345
|
-
if (comment?.type !== "Block"
|
|
384
|
+
if (comment?.type !== "Block") {
|
|
346
385
|
return false;
|
|
347
386
|
}
|
|
348
387
|
|
|
349
388
|
// Checks the comments immediately around the node.
|
|
350
389
|
const { next, previous } = getCommentNeighbours(sourceCode, comment);
|
|
351
|
-
// Confirms there is no blank line before the node.
|
|
352
|
-
const gap = sourceCode.text.slice(comment.range[1], node.range[0]);
|
|
353
390
|
|
|
354
|
-
return (
|
|
355
|
-
next?.range[0] === node.range[0] &&
|
|
356
|
-
isLeadingComment(sourceCode, comment, previous) &&
|
|
357
|
-
/^\r?\n[ \t]*$/.test(gap)
|
|
358
|
-
);
|
|
391
|
+
return next?.range[0] === node.range[0] && isLeadingComment(sourceCode, comment, previous);
|
|
359
392
|
}
|
package/comments/utils/wrap.js
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
import { getDisplayWidth } from "./source.js";
|
|
2
2
|
|
|
3
|
+
// One word of comment text. A backtick code span counts as part of the word
|
|
4
|
+
// around it, so wrapping never splits the code inside it across lines.
|
|
5
|
+
const wordPattern = /(?:`[^`]*`|[^\s`]+|`)+/g;
|
|
6
|
+
// Text that is only an inline code span, which reads as code, not a sentence.
|
|
7
|
+
const codeSpanOnlyPattern = /^`[^`]*`$/;
|
|
8
|
+
// An inline code span anywhere in the text.
|
|
9
|
+
const codeSpanPattern = /`[^`]*`/;
|
|
10
|
+
// Text that is only a quotation, in straight or curly quotes.
|
|
11
|
+
const quoteOnlyPattern = /^(?:"[^"]*"|'[^']*'|“[^”]*”|‘[^’]*’)$/;
|
|
12
|
+
|
|
3
13
|
/**
|
|
4
14
|
* Wrap words to a maximum line width.
|
|
5
15
|
*
|
|
16
|
+
* A code span is never split: one wider than the line goes whole on its own
|
|
17
|
+
* line. Any other word wider than the line also gets its own line, and is cut
|
|
18
|
+
* at the width only when nothing comes before it on the line.
|
|
19
|
+
*
|
|
6
20
|
* @param {string} text
|
|
7
21
|
* The text to wrap.
|
|
8
22
|
* @param {number} width
|
|
@@ -12,8 +26,8 @@ import { getDisplayWidth } from "./source.js";
|
|
|
12
26
|
* Wrapped lines.
|
|
13
27
|
*/
|
|
14
28
|
export function wrapWords(text, width) {
|
|
15
|
-
// The text's
|
|
16
|
-
const words = text.trim().
|
|
29
|
+
// The text's words, with code spans kept whole.
|
|
30
|
+
const words = text.trim().match(wordPattern) ?? [];
|
|
17
31
|
// The wrapped lines, built up in place.
|
|
18
32
|
const lines = [];
|
|
19
33
|
|
|
@@ -22,6 +36,13 @@ export function wrapWords(text, width) {
|
|
|
22
36
|
|
|
23
37
|
for (const word of words) {
|
|
24
38
|
if (word.length > width && currentLine === "") {
|
|
39
|
+
// Code stays whole even when it overflows the line.
|
|
40
|
+
if (codeSpanPattern.test(word)) {
|
|
41
|
+
lines.push(word);
|
|
42
|
+
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
25
46
|
for (let index = 0; index < word.length; index += width) {
|
|
26
47
|
lines.push(word.slice(index, index + width));
|
|
27
48
|
}
|
|
@@ -74,8 +95,8 @@ export function refillCommentLines(lines, maximumLineLength) {
|
|
|
74
95
|
break;
|
|
75
96
|
}
|
|
76
97
|
|
|
77
|
-
// The words
|
|
78
|
-
const nextWords = nextLine.text.
|
|
98
|
+
// The next line's words, with code spans kept whole.
|
|
99
|
+
const nextWords = nextLine.text.match(wordPattern) ?? [];
|
|
79
100
|
|
|
80
101
|
// Whether the following line was removed after giving up all its
|
|
81
102
|
// words.
|
|
@@ -180,6 +201,9 @@ export function formatSentence(text) {
|
|
|
180
201
|
/**
|
|
181
202
|
* Capitalise the first letter of sentence text.
|
|
182
203
|
*
|
|
204
|
+
* Text that starts with anything other than a letter, such as a code span, a
|
|
205
|
+
* quote or an emoji, is returned unchanged.
|
|
206
|
+
*
|
|
183
207
|
* @param {string} text
|
|
184
208
|
* The sentence text.
|
|
185
209
|
*
|
|
@@ -194,15 +218,14 @@ export function capitaliseSentence(text) {
|
|
|
194
218
|
return text;
|
|
195
219
|
}
|
|
196
220
|
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (firstLetter < 0) {
|
|
221
|
+
// Code, quoted text and emoji keep their own casing, so only a sentence
|
|
222
|
+
// that starts with a letter is capitalised.
|
|
223
|
+
if (!/^\p{L}/u.test(trimmedText)) {
|
|
201
224
|
return text;
|
|
202
225
|
}
|
|
203
226
|
|
|
204
|
-
// The leading word,
|
|
205
|
-
const leadingWord = trimmedText.
|
|
227
|
+
// The leading word, before any space or punctuation.
|
|
228
|
+
const leadingWord = trimmedText.match(/^\p{L}[\p{L}\p{N}]*/u)?.[0] ?? "";
|
|
206
229
|
|
|
207
230
|
// A camelCase word (lowercase start, later uppercase) is a code identifier
|
|
208
231
|
// and must keep its own casing rather than sentence casing.
|
|
@@ -211,15 +234,20 @@ export function capitaliseSentence(text) {
|
|
|
211
234
|
}
|
|
212
235
|
|
|
213
236
|
// The first letter character.
|
|
214
|
-
const letter = trimmedText[
|
|
237
|
+
const letter = trimmedText[0];
|
|
215
238
|
// The text with its first letter capitalised.
|
|
216
|
-
const formattedText = `${
|
|
239
|
+
const formattedText = `${letter.toLocaleUpperCase()}${trimmedText.slice(1)}`;
|
|
217
240
|
|
|
218
241
|
return text.replace(trimmedText, formattedText);
|
|
219
242
|
}
|
|
220
243
|
|
|
221
244
|
/**
|
|
222
|
-
* Add
|
|
245
|
+
* Add a full stop to sentence text that has no closing punctuation.
|
|
246
|
+
*
|
|
247
|
+
* Text ending in a colon introduces what follows, and text that is only a code
|
|
248
|
+
* span or a quotation is not a sentence, so both are returned unchanged. A
|
|
249
|
+
* sentence that ends in a code span or quotation gets its full stop after the
|
|
250
|
+
* closing mark.
|
|
223
251
|
*
|
|
224
252
|
* @param {string} text
|
|
225
253
|
* The sentence text.
|
|
@@ -231,7 +259,13 @@ export function addTerminalPunctuation(text) {
|
|
|
231
259
|
// The sentence text, without leading or trailing whitespace.
|
|
232
260
|
const trimmedText = text.trim();
|
|
233
261
|
|
|
234
|
-
if (
|
|
262
|
+
if (
|
|
263
|
+
trimmedText === "" ||
|
|
264
|
+
trimmedText.startsWith("@") ||
|
|
265
|
+
/[.!?:]$/.test(trimmedText) ||
|
|
266
|
+
codeSpanOnlyPattern.test(trimmedText) ||
|
|
267
|
+
quoteOnlyPattern.test(trimmedText)
|
|
268
|
+
) {
|
|
235
269
|
return text;
|
|
236
270
|
}
|
|
237
271
|
|
package/comments.json
CHANGED
package/layers.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import base from "./base.json" with { type: "json" };
|
|
2
|
+
import comments from "./comments.json" with { type: "json" };
|
|
3
|
+
import vueConfig from "./vue.json" with { type: "json" };
|
|
4
|
+
|
|
5
|
+
// The Vue layer. It includes base as an object because Oxlint rejects the file
|
|
6
|
+
// path that vue.json uses to extend base.
|
|
7
|
+
export const vue = { ...vueConfig, extends: [base] };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Build a Vite+ lint block from the selected layers and local settings.
|
|
11
|
+
*
|
|
12
|
+
* @param {object[]} layers
|
|
13
|
+
* The layer objects to extend. When two layers set the same env or global,
|
|
14
|
+
* the later layer's value is used.
|
|
15
|
+
* @param {object} local
|
|
16
|
+
* Project settings that take priority over the layers. Its extends list is
|
|
17
|
+
* ignored, so every layer must be passed in layers.
|
|
18
|
+
*
|
|
19
|
+
* @returns {object}
|
|
20
|
+
* A lint block with inherited environments and globals at the top level.
|
|
21
|
+
*/
|
|
22
|
+
export function lintConfig(layers, local = {}) {
|
|
23
|
+
// The environments inherited from the selected layers.
|
|
24
|
+
const env = {};
|
|
25
|
+
// The globals inherited from the selected layers.
|
|
26
|
+
const globals = {};
|
|
27
|
+
|
|
28
|
+
for (const layer of layers) {
|
|
29
|
+
collectEnvAndGlobals(layer, env, globals);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
...local,
|
|
34
|
+
env: { ...env, ...local.env },
|
|
35
|
+
globals: { ...globals, ...local.globals },
|
|
36
|
+
extends: layers,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Copy a layer's env and globals into the collected values. The layers it
|
|
42
|
+
* extends are copied first, so the layer's own values win.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} layer
|
|
45
|
+
* The layer to read, along with every layer it extends.
|
|
46
|
+
* @param {object} env
|
|
47
|
+
* The collected environments, updated in place.
|
|
48
|
+
* @param {object} globals
|
|
49
|
+
* The collected globals, updated in place.
|
|
50
|
+
*/
|
|
51
|
+
function collectEnvAndGlobals(layer, env, globals) {
|
|
52
|
+
for (const extendedLayer of layer.extends ?? []) {
|
|
53
|
+
collectEnvAndGlobals(extendedLayer, env, globals);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
Object.assign(env, layer.env);
|
|
57
|
+
Object.assign(globals, layer.globals);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export { base, comments };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lewishowles/lint-config",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Shared oxlint configuration for Lewis Howles projects",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"config",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"comments",
|
|
25
25
|
"comments.json",
|
|
26
26
|
"imports.json",
|
|
27
|
+
"layers.js",
|
|
27
28
|
"vue.json",
|
|
28
29
|
"README.md"
|
|
29
30
|
],
|
|
@@ -36,6 +37,7 @@
|
|
|
36
37
|
"./comments.json": "./comments.json",
|
|
37
38
|
"./comments/plugin": "./comments/plugin.js",
|
|
38
39
|
"./imports.json": "./imports.json",
|
|
40
|
+
"./layers": "./layers.js",
|
|
39
41
|
"./vue.json": "./vue.json"
|
|
40
42
|
},
|
|
41
43
|
"publishConfig": {
|
|
@@ -46,15 +48,15 @@
|
|
|
46
48
|
"lint:fix": "vp check --fix",
|
|
47
49
|
"prepare": "vp config --no-agent",
|
|
48
50
|
"publint": "publint",
|
|
49
|
-
"test:unit": "node --test test/comments/*.test.js"
|
|
51
|
+
"test:unit": "node --test test/comments/*.test.js test/layers/*.test.js"
|
|
50
52
|
},
|
|
51
53
|
"devDependencies": {
|
|
52
54
|
"publint": "^0.3.22",
|
|
53
|
-
"vite-plus": "0.
|
|
55
|
+
"vite-plus": "1.0.0"
|
|
54
56
|
},
|
|
55
57
|
"peerDependencies": {
|
|
56
58
|
"@stylistic/eslint-plugin": "*",
|
|
57
|
-
"vite-plus": "0.
|
|
59
|
+
"vite-plus": "^1.0.0"
|
|
58
60
|
},
|
|
59
61
|
"engines": {
|
|
60
62
|
"node": ">=20"
|