diff --git a/.README/rules/normalize-see-links.md b/.README/rules/normalize-see-links.md new file mode 100644 index 000000000..9ddb2ec93 --- /dev/null +++ b/.README/rules/normalize-see-links.md @@ -0,0 +1,23 @@ +# `normalize-see-links` + +Normalizes labeled links in `@see` tags to a canonical `{@link}` form. + +## Options + +{"gitdown": "options"} + +||| +|---|---| +|Context|everywhere| +|Tags|`see`| +|Recommended|false| +|Settings|| +|Options|`canonicalForm`, `enableFixer`| + +## Failing examples + + + +## Passing examples + + diff --git a/README.md b/README.md index 01249a09a..7d2095f2f 100644 --- a/README.md +++ b/README.md @@ -491,6 +491,7 @@ non-default-recommended fixer). ||| [no-restricted-syntax](./docs/rules/no-restricted-syntax.md#readme) | Reports when certain comment structures are present. | |On in TS; Off in TS flavor|:wrench:| [no-types](./docs/rules/no-types.md#readme) | This rule reports types being used on `@param` or `@returns` (redundant with TypeScript). | |:heavy_check_mark: (Off in TS; Off in TS flavor)|| [no-undefined-types](./docs/rules/no-undefined-types.md#readme) | Besides some expected built-in types, prohibits any types not specified as globals or within `@typedef`. | +||:wrench:| [normalize-see-links](./docs/rules/normalize-see-links.md#readme) | Normalizes labeled links in `@see` tags to a canonical `{@link}` form. | ||:wrench:| [prefer-import-tag](./docs/rules/prefer-import-tag.md#readme) | Prefer `@import` tags to inline `import()` statements. | |:heavy_check_mark:|| [reject-any-type](./docs/rules/reject-any-type.md#readme) | Reports use of `any` or `*` type | |:heavy_check_mark:|| [reject-function-type](./docs/rules/reject-function-type.md#readme) | Reports use of `Function` type | diff --git a/docs/rules/normalize-see-links.md b/docs/rules/normalize-see-links.md new file mode 100644 index 000000000..0e6273344 --- /dev/null +++ b/docs/rules/normalize-see-links.md @@ -0,0 +1,420 @@ + + +# normalize-see-links + +Normalizes labeled links in `@see` tags to a canonical `{@link}` form. + + + +## Options + +A single options object has the following properties. + + + +### canonicalForm + +The canonical `{@link}` form: `"pipe"` produces `{@link url|label}`, while `"prefix"` produces `[label]{@link url}`. Defaults to `"pipe"`. + + + +### enableFixer + +Whether to enable the fixer. Defaults to `true`. + + +||| +|---|---| +|Context|everywhere| +|Tags|`see`| +|Recommended|false| +|Settings|| +|Options|`canonicalForm`, `enableFixer`| + + + +## Failing examples + +The following patterns are considered problems: + +````ts +/** + * @see [Docs](https://api.example.com/v1?ids=1|2|3) + */ +// Message: Expected @see link to use the pipe form. + +/** + * @see [See {options}](https://example.com/api) + */ +// Message: @see link cannot be safely normalized. + +/** + * @see [a}b](https://example.com) + */ +// Message: @see link cannot be safely normalized. + +/** + * @see [Foo](https://example.com/a|b) + */ +// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}] +// Message: Expected @see link to use the prefix form. + +/** + * @see [ ](https://example.com) + */ +// Message: @see link cannot be safely normalized. + +/** + * @see {@link https://example.com|A]B} + */ +// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}] +// Message: @see link cannot be safely normalized. + +/** + * @see [ ]{@link https://example.com} + */ +// Message: @see link cannot be safely normalized. + +/** + * @see [Foo](https://example.com/a}b) + */ +// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}] +// Message: Expected @see link to use the prefix form. + +/** + * @see [Foo](https://example.com/a{b) + */ +// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}] +// Message: Expected @see link to use the prefix form. + +/** + * @see [Braces](https://example.com/a{b}c) + */ +// Message: Expected @see link to use the pipe form. + +/** + * @see [Existing](https://example.com/%7C?x=1&y=2#frag) + */ +// Message: Expected @see link to use the pipe form. + +/** + * @see [Percent](https://example.com/100%) + */ +// Message: Expected @see link to use the pipe form. + +/** + * @see [A{B](https://example.com) + */ +// Message: Expected @see link to use the pipe form. + +/** + * @see [a}b](https://example.com) + */ +// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}] +// Message: Expected @see link to use the prefix form. + +/** + * @see {@link https://example.com|A|B} + */ +// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}] +// Message: Expected @see link to use the prefix form. + +/** + * @see {@link https://example.com|A`B} + */ +// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}] +// Message: Expected @see link to use the prefix form. + +/** + * @see [a\}b](https://example.com) + */ +// Message: @see link cannot be safely normalized. + +/** + * @see [See {{one} and {two}}](https://example.com) + */ +// Message: @see link cannot be safely normalized. + +/** + * @see [A}B|C](https://example.com) + */ +// Message: @see link cannot be safely normalized. + +/** + * @see [Read the guide](https://example.com/read) + */ +// Message: Expected @see link to use the pipe form. + +/** + * @see [Browse the API]{@link https://example.com/api} + */ +// Message: Expected @see link to use the pipe form. + +/** + * @see [A](https://a.com) and [B](https://b.com) + */ +// Message: Expected @see link to use the pipe form. + +/** + * @see [A](https://a.com) + * @see [B](https://b.com) + */ +// Message: Expected @see link to use the pipe form. + +/** + * @see [Unsafe](https://example.com/a|b?x=1&y=2#frag) and [Safe](https://example.com/safe) + */ +// Message: Expected @see link to use the pipe form. + +/** + * @see [JSDoc Markdown](https://example.com/jsdoc|markdown) + */ +// Settings: {"jsdoc":{"mode":"jsdoc"}} +// Message: Expected @see link to use the pipe form. + +/** + * @see [Package](@scope/pkg) + */ +// Message: @see link cannot be safely normalized. + +/** + * @see [First](https://example.com/first) + * and [Second](https://example.com/second) + */ +// Message: Expected @see link to use the pipe form. + +/** + * @see [Example](https://example.com) + */ +// Message: Expected @see link to use the pipe form. + +/** + * @see [Guide]{@link https://example.com/guide} + */ +// Message: Expected @see link to use the pipe form. + +/** + * @see [API](https://example.com/api) + */ +// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}] +// Message: Expected @see link to use the prefix form. + +/** + * @see {@link https://example.com/reference|Reference} + */ +// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}] +// Message: Expected @see link to use the prefix form. + +/** + * @see [No fix](https://example.com/no-fix) + */ +// "jsdoc/normalize-see-links": ["error"|"warn", {"enableFixer":false}] +// Message: Expected @see link to use the pipe form. + +/** + * @see [Incomplete](https://example.com/incomplete + */ +// Message: @see link cannot be safely normalized. + +/** + * @see [Incomplete]{@link} + */ +// Message: @see link cannot be safely normalized. + +/** + * @see {@link https://example.com|} + */ +// Message: @see link cannot be safely normalized. + +/** + * @see `[Code](https://example.com/code)` + */ +// Message: @see link cannot be safely normalized. + +/** + * @see [Package]{@link @scope/pkg} + */ +// Message: @see link cannot be safely normalized. + +/** + * @see {@link https://example.com Space label} + */ +// Message: @see link cannot be safely normalized. + +/** + * @see [JSDoc]{@link https://example.com/jsdoc} + */ +// Settings: {"jsdoc":{"mode":"jsdoc"}} +// Message: Expected @see link to use the pipe form. + +/** + * @see [TypeScript](https://example.com/type{script}) + */ +const value: string = 'value'; +// Message: Expected @see link to use the pipe form. + +/** + * @see [Complex](https://example.com/a_(b)) + */ +// Message: @see link cannot be safely normalized. +```` + + + + + +## Passing examples + +The following patterns are not considered problems: + +````ts +/** + * @see {@link https://api.example.com/v1?ids=1%7C2%7C3|Docs} + */ + +/** + * @see [Foo]{@link https://example.com/a%7Cb} + */ +// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}] + +/** + * @see [Foo]{@link https://example.com/a%7Db} + */ +// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}] + +/** + * @see [Foo]{@link https://example.com/a%7Bb} + */ +// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}] + +/** + * @see {@link https://example.com/a%7Bb%7Dc|Braces} + */ + +/** + * @see {@link https://example.com/%7C?x=1&y=2#frag|Existing} + */ + +/** + * @see {@link https://example.com/100%25|Percent} + */ + +/** + * @see {@link https://example.com|A{B} + */ + +/** + * @see [a}b]{@link https://example.com} + */ +// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}] + +/** + * @see {@link https://example.com|A|B} + */ + +/** + * @see [A|B]{@link https://example.com} + */ +// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}] + +/** + * @see [A`B]{@link https://example.com} + */ +// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}] + +/** + * @see {@link https://example.com/a%7Cb?x=1&y=2#frag|Unsafe} and {@link https://example.com/safe|Safe} + */ + +/** + * @see {@link https://example.com/read|Read the guide} + */ + +/** + * @see {@link https://example.com/api|Browse the API} + */ + +/** + * @see {@link https://a.com|A} and {@link https://b.com|B} + */ + +/** + * @see {@link https://a.com|A} + * @see {@link https://b.com|B} + */ + +/** + * @see {@link https://example.com/jsdoc%7Cmarkdown|JSDoc Markdown} + */ +// Settings: {"jsdoc":{"mode":"jsdoc"}} + +/** + * @see {@link https://example.com/first|First} + * and {@link https://example.com/second|Second} + */ + +/** + * @see {@link https://example.com|Example} + */ + +/** + * @see {@link https://example.com/guide|Guide} + */ + +/** + * @see [API]{@link https://example.com/api} + */ +// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}] + +/** + * @see [Reference]{@link https://example.com/reference} + */ +// "jsdoc/normalize-see-links": ["error"|"warn", {"canonicalForm":"prefix"}] + +/** + * @see {@link https://example.com/jsdoc|JSDoc} + */ +// Settings: {"jsdoc":{"mode":"jsdoc"}} + +/** + * @see {@link https://example.com/type%7Bscript%7D|TypeScript} + */ +const value: string = 'value'; + +/** + * @see https://example.com + */ + +/** + * @see MyClass#method + */ + +/** + * @see some text + */ + +/** + * @see {@link https://example.com} + */ + +/** + * @see {@link @scope/pkg|Package} + */ + +/** + * @see @scope/pkg + */ + +/** + * @returns [Example](https://example.com) + */ + +/** + * @see {@code foo|bar} + */ + +/** + * @see \[Escaped](https://example.com) + */ +```` + diff --git a/src/index-cjs.js b/src/index-cjs.js index 96b599b99..777ca8397 100644 --- a/src/index-cjs.js +++ b/src/index-cjs.js @@ -36,6 +36,7 @@ import noDefaults from './rules/noDefaults.js'; import noMissingSyntax from './rules/noMissingSyntax.js'; import noMultiAsterisks from './rules/noMultiAsterisks.js'; import noRestrictedSyntax from './rules/noRestrictedSyntax.js'; +import normalizeSeeLinks from './rules/normalizeSeeLinks.js'; import noTypes from './rules/noTypes.js'; import noUndefinedTypes from './rules/noUndefinedTypes.js'; import preferImportTag from './rules/preferImportTag.js'; @@ -128,6 +129,7 @@ index.rules = { 'no-restricted-syntax': noRestrictedSyntax, 'no-types': noTypes, 'no-undefined-types': noUndefinedTypes, + 'normalize-see-links': normalizeSeeLinks, 'prefer-import-tag': preferImportTag, 'reject-any-type': buildRejectOrPreferRuleDefinition({ description: 'Reports use of `any` or `*` type', @@ -318,6 +320,7 @@ const createRecommendedRuleset = (warnOrError, flatName) => { 'jsdoc/no-restricted-syntax': 'off', 'jsdoc/no-types': 'off', 'jsdoc/no-undefined-types': warnOrError, + 'jsdoc/normalize-see-links': 'off', 'jsdoc/prefer-import-tag': 'off', 'jsdoc/reject-any-type': warnOrError, 'jsdoc/reject-function-type': warnOrError, diff --git a/src/index.js b/src/index.js index b1b3c6636..660225736 100644 --- a/src/index.js +++ b/src/index.js @@ -42,6 +42,7 @@ import noDefaults from './rules/noDefaults.js'; import noMissingSyntax from './rules/noMissingSyntax.js'; import noMultiAsterisks from './rules/noMultiAsterisks.js'; import noRestrictedSyntax from './rules/noRestrictedSyntax.js'; +import normalizeSeeLinks from './rules/normalizeSeeLinks.js'; import noTypes from './rules/noTypes.js'; import noUndefinedTypes from './rules/noUndefinedTypes.js'; import preferImportTag from './rules/preferImportTag.js'; @@ -134,6 +135,7 @@ index.rules = { 'no-restricted-syntax': noRestrictedSyntax, 'no-types': noTypes, 'no-undefined-types': noUndefinedTypes, + 'normalize-see-links': normalizeSeeLinks, 'prefer-import-tag': preferImportTag, 'reject-any-type': buildRejectOrPreferRuleDefinition({ description: 'Reports use of `any` or `*` type', @@ -324,6 +326,7 @@ const createRecommendedRuleset = (warnOrError, flatName) => { 'jsdoc/no-restricted-syntax': 'off', 'jsdoc/no-types': 'off', 'jsdoc/no-undefined-types': warnOrError, + 'jsdoc/normalize-see-links': 'off', 'jsdoc/prefer-import-tag': 'off', 'jsdoc/reject-any-type': warnOrError, 'jsdoc/reject-function-type': warnOrError, diff --git a/src/rules.d.ts b/src/rules.d.ts index 0990466e7..1d16e4d93 100644 --- a/src/rules.d.ts +++ b/src/rules.d.ts @@ -1292,6 +1292,22 @@ export interface Rules { } ]; + /** Normalizes labeled links in `@see` tags to a canonical `{@link}` form. */ + "jsdoc/normalize-see-links": + | [] + | [ + { + /** + * The canonical `{@link}` form: `"pipe"` produces `{@link url|label}`, while `"prefix"` produces `[label]{@link url}`. Defaults to `"pipe"`. + */ + canonicalForm?: "pipe" | "prefix"; + /** + * Whether to enable the fixer. Defaults to `true`. + */ + enableFixer?: boolean; + } + ]; + /** Prefer `@import` tags to inline `import()` statements. */ "jsdoc/prefer-import-tag": | [] diff --git a/src/rules/normalizeSeeLinks.js b/src/rules/normalizeSeeLinks.js new file mode 100644 index 000000000..13b6395c2 --- /dev/null +++ b/src/rules/normalizeSeeLinks.js @@ -0,0 +1,283 @@ +import iterateJsdoc from '../iterateJsdoc.js'; + +const markdownLinkRegex = /(? { + if (!label.trim()) { + return true; + } + + if (canonicalForm === 'pipe') { + return label.includes('}'); + } + + return label.includes(']'); +}; + +/** + * @param {string} target + * @returns {string} + */ +const encodeLinkTarget = (target) => { + return encodeURI(target).replaceAll(/%25(?=[\dA-F]{2})/giv, '%'); +}; + +/** + * @param {string} description + * @param {number} index + * @returns {boolean} + */ +const isInsideMarkdownCodeSpan = (description, index) => { + for (const match of description.matchAll(markdownCodeSpanRegex)) { + const delimiterLength = match[1].length; + const contentStart = match.index + delimiterLength; + const contentEnd = match.index + match[0].length - delimiterLength; + + if (index >= contentStart && index < contentEnd) { + return true; + } + } + + return false; +}; + +/** + * @param {'pipe'|'prefix'} canonicalForm + * @param {string} label + * @param {string} target + * @returns {string} + */ +const formatLink = (canonicalForm, label, target) => { + const encodedTarget = encodeLinkTarget(target); + + return canonicalForm === 'pipe' ? + `{@link ${encodedTarget}|${label.trim()}}` : + `[${label.trim()}]{@link ${encodedTarget}}`; +}; + +/** + * @param {string} description + * @param {'pipe'|'prefix'} canonicalForm + * @returns {string} + */ +const normalizeDescription = (description, canonicalForm) => { + const markdownNormalized = description.replaceAll( + new RegExp(markdownLinkRegex, 'gv'), + (_match, label, target) => { + return formatLink(canonicalForm, label, target); + }, + ); + + if (canonicalForm === 'pipe') { + return markdownNormalized.replaceAll( + new RegExp(prefixLinkRegex, 'gv'), + (_match, label, target) => { + return formatLink(canonicalForm, label, target); + }, + ); + } + + return markdownNormalized.replaceAll( + new RegExp(pipeLinkRegex, 'gv'), + (_match, target, label) => { + return formatLink(canonicalForm, label, target); + }, + ); +}; + +export default iterateJsdoc(({ + context, + jsdoc, + utils, +}) => { + const { + canonicalForm = 'pipe', + enableFixer = true, + } = context.options[0] || {}; + + const descriptionMatcher = canonicalForm === 'pipe' ? + new RegExp(`${markdownLinkRegex.source}|${prefixLinkRegex.source}`, 'v') : + new RegExp(`${markdownLinkRegex.source}|${pipeLinkRegex.source}`, 'v'); + + /** @type {import('comment-parser').Spec[]} */ + const fixableTags = []; + + for (const tag of jsdoc.tags) { + if (tag.tag !== 'see') { + continue; + } + + const firstTokens = tag.source[0].tokens; + const tagDescription = String(utils.getTagDescription(tag)); + const rawDescription = firstTokens.name ? + firstTokens.name + firstTokens.postName + tagDescription : + tagDescription; + + let hasAmbiguousLink = false; + let hasNoncanonicalLink = false; + + for (const match of rawDescription.matchAll(markdownLinkRegex)) { + const label = match[1]; + const target = match[2]; + + if ( + isInsideMarkdownCodeSpan(rawDescription, match.index) || + scopedPackageNameRegex.test(target) || + cannotSafelyFormatLink(canonicalForm, label) + ) { + hasAmbiguousLink = true; + } else { + hasNoncanonicalLink = true; + } + } + + for (const inlineTag of tag.inlineTags) { + if (inlineTag.tag !== 'link' || !inlineTag.text) { + continue; + } + + if (inlineTag.format === 'space') { + hasAmbiguousLink = true; + continue; + } + + if (cannotSafelyFormatLink( + canonicalForm, + inlineTag.text, + )) { + hasAmbiguousLink = true; + continue; + } + + if (inlineTag.format === canonicalForm) { + continue; + } + + const { + start, + } = /** @type {typeof inlineTag & {start: number}} */ (inlineTag); + + if ( + isInsideMarkdownCodeSpan(rawDescription, start) || + scopedPackageNameRegex.test(inlineTag.namepathOrURL) + ) { + hasAmbiguousLink = true; + } else { + hasNoncanonicalLink = true; + } + } + + const unrecognizedLinkText = rawDescription + .replaceAll(new RegExp(markdownLinkRegex, 'gv'), '') + .replaceAll(new RegExp(prefixLinkRegex, 'gv'), '') + .replaceAll(new RegExp(pipeLinkRegex, 'gv'), ''); + + hasAmbiguousLink ||= markdownLinkAttemptRegex.test(unrecognizedLinkText) || + prefixLinkAttemptRegex.test(unrecognizedLinkText) || + pipeLinkAttemptRegex.test(unrecognizedLinkText); + + if (hasAmbiguousLink) { + utils.reportJSDoc('@see link cannot be safely normalized.', { + line: tag.source[0].number, + }, null); + continue; + } + + if (!hasNoncanonicalLink) { + continue; + } + + fixableTags.push(tag); + } + + for (const [ + reportIdx, + tag, + ] of fixableTags.entries()) { + utils.reportJSDoc( + `Expected @see link to use the ${canonicalForm} form.`, + { + line: tag.source[0].number, + }, + enableFixer && reportIdx === 0 ? + () => { + for (const fixableTag of fixableTags) { + const firstTokens = fixableTag.source[0].tokens; + + if (firstTokens.name) { + firstTokens.description = firstTokens.name + + firstTokens.postName + firstTokens.description; + firstTokens.name = ''; + firstTokens.postName = ''; + } + + for ( + let sourceIdx = 0; + sourceIdx < fixableTag.source.length; + sourceIdx++ + ) { + if (!descriptionMatcher.test( + fixableTag.source[sourceIdx].tokens.description, + )) { + continue; + } + + utils.setTagDescription( + fixableTag, + descriptionMatcher, + (description) => { + return normalizeDescription(description, canonicalForm); + }, + ); + } + } + } : + null, + true, + ); + } +}, { + iterateAllJsdocs: true, + meta: { + docs: { + description: 'Normalizes labeled links in `@see` tags to a canonical `{@link}` form.', + url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/normalize-see-links.md#repos-sticky-header', + }, + fixable: 'code', + schema: [ + { + additionalProperties: false, + properties: { + canonicalForm: { + description: 'The canonical `{@link}` form: `"pipe"` produces `{@link url|label}`, while `"prefix"` produces `[label]{@link url}`. Defaults to `"pipe"`.', + enum: [ + 'pipe', + 'prefix', + ], + type: 'string', + }, + enableFixer: { + description: 'Whether to enable the fixer. Defaults to `true`.', + type: 'boolean', + }, + }, + type: 'object', + }, + ], + type: 'suggestion', + }, +}); diff --git a/test/rules/assertions/normalizeSeeLinks.js b/test/rules/assertions/normalizeSeeLinks.js new file mode 100644 index 000000000..5c1c86d6a --- /dev/null +++ b/test/rules/assertions/normalizeSeeLinks.js @@ -0,0 +1,1055 @@ +import { + parser as typescriptEslintParser, +} from 'typescript-eslint'; + +export default { + invalid: [ + { + code: ` + /** + * @see [Docs](https://api.example.com/v1?ids=1|2|3) + */ + `, + errors: [ + { + line: 3, + message: 'Expected @see link to use the pipe form.', + }, + ], + output: ` + /** + * @see {@link https://api.example.com/v1?ids=1%7C2%7C3|Docs} + */ + `, + }, + // `\}` truncates pipe-label text in @es-joy/jsdoccomment. + { + code: ` + /** + * @see [See {options}](https://example.com/api) + */ + `, + errors: [ + { + line: 3, + message: '@see link cannot be safely normalized.', + }, + ], + output: null, + }, + // `\}` truncates pipe-label text in @es-joy/jsdoccomment. + { + code: ` + /** + * @see [a}b](https://example.com) + */ + `, + errors: [ + { + line: 3, + message: '@see link cannot be safely normalized.', + }, + ], + output: null, + }, + { + code: ` + /** + * @see [Foo](https://example.com/a|b) + */ + `, + errors: [ + { + line: 3, + message: 'Expected @see link to use the prefix form.', + }, + ], + options: [ + { + canonicalForm: 'prefix', + }, + ], + output: ` + /** + * @see [Foo]{@link https://example.com/a%7Cb} + */ + `, + }, + // A blank label has no intended text the fixer can preserve. + { + code: ` + /** + * @see [ ](https://example.com) + */ + `, + errors: [ + { + line: 3, + message: '@see link cannot be safely normalized.', + }, + ], + output: null, + }, + // `\]` prevents prefix-form parsing in @es-joy/jsdoccomment. + { + code: ` + /** + * @see {@link https://example.com|A]B} + */ + `, + errors: [ + { + line: 3, + message: '@see link cannot be safely normalized.', + }, + ], + options: [ + { + canonicalForm: 'prefix', + }, + ], + output: null, + }, + // A blank label has no intended text the fixer can preserve. + { + code: ` + /** + * @see [ ]{@link https://example.com} + */ + `, + errors: [ + { + line: 3, + message: '@see link cannot be safely normalized.', + }, + ], + output: null, + }, + { + code: ` + /** + * @see [Foo](https://example.com/a}b) + */ + `, + errors: [ + { + line: 3, + message: 'Expected @see link to use the prefix form.', + }, + ], + options: [ + { + canonicalForm: 'prefix', + }, + ], + output: ` + /** + * @see [Foo]{@link https://example.com/a%7Db} + */ + `, + }, + { + code: ` + /** + * @see [Foo](https://example.com/a{b) + */ + `, + errors: [ + { + line: 3, + message: 'Expected @see link to use the prefix form.', + }, + ], + options: [ + { + canonicalForm: 'prefix', + }, + ], + output: ` + /** + * @see [Foo]{@link https://example.com/a%7Bb} + */ + `, + }, + { + code: ` + /** + * @see [Braces](https://example.com/a{b}c) + */ + `, + errors: [ + { + line: 3, + message: 'Expected @see link to use the pipe form.', + }, + ], + output: ` + /** + * @see {@link https://example.com/a%7Bb%7Dc|Braces} + */ + `, + }, + { + code: ` + /** + * @see [Existing](https://example.com/%7C?x=1&y=2#frag) + */ + `, + errors: [ + { + line: 3, + message: 'Expected @see link to use the pipe form.', + }, + ], + output: ` + /** + * @see {@link https://example.com/%7C?x=1&y=2#frag|Existing} + */ + `, + }, + { + code: ` + /** + * @see [Percent](https://example.com/100%) + */ + `, + errors: [ + { + line: 3, + message: 'Expected @see link to use the pipe form.', + }, + ], + output: ` + /** + * @see {@link https://example.com/100%25|Percent} + */ + `, + }, + { + code: ` + /** + * @see [A{B](https://example.com) + */ + `, + errors: [ + { + line: 3, + message: 'Expected @see link to use the pipe form.', + }, + ], + output: ` + /** + * @see {@link https://example.com|A{B} + */ + `, + }, + { + code: ` + /** + * @see [a}b](https://example.com) + */ + `, + errors: [ + { + line: 3, + message: 'Expected @see link to use the prefix form.', + }, + ], + options: [ + { + canonicalForm: 'prefix', + }, + ], + output: ` + /** + * @see [a}b]{@link https://example.com} + */ + `, + }, + { + code: ` + /** + * @see {@link https://example.com|A|B} + */ + `, + errors: [ + { + line: 3, + message: 'Expected @see link to use the prefix form.', + }, + ], + options: [ + { + canonicalForm: 'prefix', + }, + ], + output: ` + /** + * @see [A|B]{@link https://example.com} + */ + `, + }, + { + code: ` + /** + * @see {@link https://example.com|A\`B} + */ + `, + errors: [ + { + line: 3, + message: 'Expected @see link to use the prefix form.', + }, + ], + options: [ + { + canonicalForm: 'prefix', + }, + ], + output: ` + /** + * @see [A\`B]{@link https://example.com} + */ + `, + }, + // An existing `\}` still truncates pipe-label text in the parser. + { + code: ` + /** + * @see [a\\}b](https://example.com) + */ + `, + errors: [ + { + line: 3, + message: '@see link cannot be safely normalized.', + }, + ], + output: null, + }, + // Nested and repeated braces still contain an unparseable pipe-label `}`. + { + code: ` + /** + * @see [See {{one} and {two}}](https://example.com) + */ + `, + errors: [ + { + line: 3, + message: '@see link cannot be safely normalized.', + }, + ], + output: null, + }, + // The extra pipe is safe, but the closing brace still truncates the label. + { + code: ` + /** + * @see [A}B|C](https://example.com) + */ + `, + errors: [ + { + line: 3, + message: '@see link cannot be safely normalized.', + }, + ], + output: null, + }, + { + code: ` + /** + * @see [Read the guide](https://example.com/read) + */ + `, + errors: [ + { + line: 3, + message: 'Expected @see link to use the pipe form.', + }, + ], + output: ` + /** + * @see {@link https://example.com/read|Read the guide} + */ + `, + }, + { + code: ` + /** + * @see [Browse the API]{@link https://example.com/api} + */ + `, + errors: [ + { + line: 3, + message: 'Expected @see link to use the pipe form.', + }, + ], + output: ` + /** + * @see {@link https://example.com/api|Browse the API} + */ + `, + }, + { + code: ` + /** + * @see [A](https://a.com) and [B](https://b.com) + */ + `, + errors: [ + { + line: 3, + message: 'Expected @see link to use the pipe form.', + }, + ], + output: ` + /** + * @see {@link https://a.com|A} and {@link https://b.com|B} + */ + `, + }, + { + code: ` + /** + * @see [A](https://a.com) + * @see [B](https://b.com) + */ + `, + errors: [ + { + line: 3, + message: 'Expected @see link to use the pipe form.', + }, + { + line: 4, + message: 'Expected @see link to use the pipe form.', + }, + ], + output: ` + /** + * @see {@link https://a.com|A} + * @see {@link https://b.com|B} + */ + `, + }, + { + code: ` + /** + * @see [Unsafe](https://example.com/a|b?x=1&y=2#frag) and [Safe](https://example.com/safe) + */ + `, + errors: [ + { + line: 3, + message: 'Expected @see link to use the pipe form.', + }, + ], + output: ` + /** + * @see {@link https://example.com/a%7Cb?x=1&y=2#frag|Unsafe} and {@link https://example.com/safe|Safe} + */ + `, + }, + { + code: ` + /** + * @see [JSDoc Markdown](https://example.com/jsdoc|markdown) + */ + `, + errors: [ + { + line: 3, + message: 'Expected @see link to use the pipe form.', + }, + ], + output: ` + /** + * @see {@link https://example.com/jsdoc%7Cmarkdown|JSDoc Markdown} + */ + `, + settings: { + jsdoc: { + mode: 'jsdoc', + }, + }, + }, + { + code: ` + /** + * @see [Package](@scope/pkg) + */ + `, + errors: [ + { + line: 3, + message: '@see link cannot be safely normalized.', + }, + ], + output: null, + }, + { + code: ` + /** + * @see [First](https://example.com/first) + * and [Second](https://example.com/second) + */ + `, + errors: [ + { + line: 3, + message: 'Expected @see link to use the pipe form.', + }, + ], + output: ` + /** + * @see {@link https://example.com/first|First} + * and {@link https://example.com/second|Second} + */ + `, + }, + { + code: ` + /** + * @see [Example](https://example.com) + */ + `, + errors: [ + { + line: 3, + message: 'Expected @see link to use the pipe form.', + }, + ], + output: ` + /** + * @see {@link https://example.com|Example} + */ + `, + }, + { + code: ` + /** + * @see [Guide]{@link https://example.com/guide} + */ + `, + errors: [ + { + line: 3, + message: 'Expected @see link to use the pipe form.', + }, + ], + output: ` + /** + * @see {@link https://example.com/guide|Guide} + */ + `, + }, + { + code: ` + /** + * @see [API](https://example.com/api) + */ + `, + errors: [ + { + line: 3, + message: 'Expected @see link to use the prefix form.', + }, + ], + options: [ + { + canonicalForm: 'prefix', + }, + ], + output: ` + /** + * @see [API]{@link https://example.com/api} + */ + `, + }, + { + code: ` + /** + * @see {@link https://example.com/reference|Reference} + */ + `, + errors: [ + { + line: 3, + message: 'Expected @see link to use the prefix form.', + }, + ], + options: [ + { + canonicalForm: 'prefix', + }, + ], + output: ` + /** + * @see [Reference]{@link https://example.com/reference} + */ + `, + }, + { + code: ` + /** + * @see [No fix](https://example.com/no-fix) + */ + `, + errors: [ + { + line: 3, + message: 'Expected @see link to use the pipe form.', + }, + ], + options: [ + { + enableFixer: false, + }, + ], + output: null, + }, + { + code: ` + /** + * @see [Incomplete](https://example.com/incomplete + */ + `, + errors: [ + { + line: 3, + message: '@see link cannot be safely normalized.', + }, + ], + output: null, + }, + { + code: ` + /** + * @see [Incomplete]{@link} + */ + `, + errors: [ + { + line: 3, + message: '@see link cannot be safely normalized.', + }, + ], + output: null, + }, + { + code: ` + /** + * @see {@link https://example.com|} + */ + `, + errors: [ + { + line: 3, + message: '@see link cannot be safely normalized.', + }, + ], + output: null, + }, + { + code: ` + /** + * @see \`[Code](https://example.com/code)\` + */ + `, + errors: [ + { + line: 3, + message: '@see link cannot be safely normalized.', + }, + ], + output: null, + }, + { + code: ` + /** + * @see [Package]{@link @scope/pkg} + */ + `, + errors: [ + { + line: 3, + message: '@see link cannot be safely normalized.', + }, + ], + output: null, + }, + { + code: ` + /** + * @see {@link https://example.com Space label} + */ + `, + errors: [ + { + line: 3, + message: '@see link cannot be safely normalized.', + }, + ], + output: null, + }, + { + code: ` + /** + * @see [JSDoc]{@link https://example.com/jsdoc} + */ + `, + errors: [ + { + line: 3, + message: 'Expected @see link to use the pipe form.', + }, + ], + output: ` + /** + * @see {@link https://example.com/jsdoc|JSDoc} + */ + `, + settings: { + jsdoc: { + mode: 'jsdoc', + }, + }, + }, + { + code: ` + /** + * @see [TypeScript](https://example.com/type{script}) + */ + const value: string = 'value'; + `, + errors: [ + { + line: 3, + message: 'Expected @see link to use the pipe form.', + }, + ], + languageOptions: { + parser: typescriptEslintParser, + }, + output: ` + /** + * @see {@link https://example.com/type%7Bscript%7D|TypeScript} + */ + const value: string = 'value'; + `, + }, + { + code: ` + /** + * @see [Complex](https://example.com/a_(b)) + */ + `, + errors: [ + { + line: 3, + message: '@see link cannot be safely normalized.', + }, + ], + output: null, + }, + ], + valid: [ + { + code: ` + /** + * @see {@link https://api.example.com/v1?ids=1%7C2%7C3|Docs} + */ + `, + }, + { + code: ` + /** + * @see [Foo]{@link https://example.com/a%7Cb} + */ + `, + options: [ + { + canonicalForm: 'prefix', + }, + ], + }, + { + code: ` + /** + * @see [Foo]{@link https://example.com/a%7Db} + */ + `, + options: [ + { + canonicalForm: 'prefix', + }, + ], + }, + { + code: ` + /** + * @see [Foo]{@link https://example.com/a%7Bb} + */ + `, + options: [ + { + canonicalForm: 'prefix', + }, + ], + }, + { + code: ` + /** + * @see {@link https://example.com/a%7Bb%7Dc|Braces} + */ + `, + }, + { + code: ` + /** + * @see {@link https://example.com/%7C?x=1&y=2#frag|Existing} + */ + `, + }, + { + code: ` + /** + * @see {@link https://example.com/100%25|Percent} + */ + `, + }, + { + code: ` + /** + * @see {@link https://example.com|A{B} + */ + `, + }, + { + code: ` + /** + * @see [a}b]{@link https://example.com} + */ + `, + options: [ + { + canonicalForm: 'prefix', + }, + ], + }, + { + code: ` + /** + * @see {@link https://example.com|A|B} + */ + `, + }, + { + code: ` + /** + * @see [A|B]{@link https://example.com} + */ + `, + options: [ + { + canonicalForm: 'prefix', + }, + ], + }, + { + code: ` + /** + * @see [A\`B]{@link https://example.com} + */ + `, + options: [ + { + canonicalForm: 'prefix', + }, + ], + }, + { + code: ` + /** + * @see {@link https://example.com/a%7Cb?x=1&y=2#frag|Unsafe} and {@link https://example.com/safe|Safe} + */ + `, + }, + { + code: ` + /** + * @see {@link https://example.com/read|Read the guide} + */ + `, + }, + { + code: ` + /** + * @see {@link https://example.com/api|Browse the API} + */ + `, + }, + { + code: ` + /** + * @see {@link https://a.com|A} and {@link https://b.com|B} + */ + `, + }, + { + code: ` + /** + * @see {@link https://a.com|A} + * @see {@link https://b.com|B} + */ + `, + }, + { + code: ` + /** + * @see {@link https://example.com/jsdoc%7Cmarkdown|JSDoc Markdown} + */ + `, + settings: { + jsdoc: { + mode: 'jsdoc', + }, + }, + }, + { + code: ` + /** + * @see {@link https://example.com/first|First} + * and {@link https://example.com/second|Second} + */ + `, + }, + { + code: ` + /** + * @see {@link https://example.com|Example} + */ + `, + }, + { + code: ` + /** + * @see {@link https://example.com/guide|Guide} + */ + `, + }, + { + code: ` + /** + * @see [API]{@link https://example.com/api} + */ + `, + options: [ + { + canonicalForm: 'prefix', + }, + ], + }, + { + code: ` + /** + * @see [Reference]{@link https://example.com/reference} + */ + `, + options: [ + { + canonicalForm: 'prefix', + }, + ], + }, + { + code: ` + /** + * @see {@link https://example.com/jsdoc|JSDoc} + */ + `, + settings: { + jsdoc: { + mode: 'jsdoc', + }, + }, + }, + { + code: ` + /** + * @see {@link https://example.com/type%7Bscript%7D|TypeScript} + */ + const value: string = 'value'; + `, + languageOptions: { + parser: typescriptEslintParser, + }, + }, + { + code: ` + /** + * @see https://example.com + */ + `, + }, + { + code: ` + /** + * @see MyClass#method + */ + `, + }, + { + code: ` + /** + * @see some text + */ + `, + }, + { + code: ` + /** + * @see {@link https://example.com} + */ + `, + }, + { + code: ` + /** + * @see {@link @scope/pkg|Package} + */ + `, + }, + { + code: ` + /** + * @see @scope/pkg + */ + `, + }, + { + code: ` + /** + * @returns [Example](https://example.com) + */ + `, + }, + { + code: ` + /** + * @see {@code foo|bar} + */ + `, + }, + { + code: ` + /** + * @see \\[Escaped](https://example.com) + */ + `, + }, + ], +}; diff --git a/test/rules/ruleNames.json b/test/rules/ruleNames.json index 907ad5f8d..7ff8f2ab9 100644 --- a/test/rules/ruleNames.json +++ b/test/rules/ruleNames.json @@ -30,6 +30,7 @@ "no-restricted-syntax", "no-types", "no-undefined-types", + "normalize-see-links", "prefer-import-tag", "reject-any-type", "reject-function-type",