@herb-tools/linter 0.8.9 → 0.9.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/README.md +5 -5
- package/dist/{src/cli → cli}/argument-parser.js +15 -2
- package/dist/cli/argument-parser.js.map +1 -0
- package/dist/{src/cli → cli}/file-processor.js +155 -9
- package/dist/cli/file-processor.js.map +1 -0
- package/dist/cli/file-url.js +6 -0
- package/dist/cli/file-url.js.map +1 -0
- package/dist/cli/formatters/base-formatter.js.map +1 -0
- package/dist/{src/cli → cli}/formatters/detailed-formatter.js +16 -19
- package/dist/cli/formatters/detailed-formatter.js.map +1 -0
- package/dist/cli/formatters/github-actions-formatter.js.map +1 -0
- package/dist/cli/formatters/index.js.map +1 -0
- package/dist/cli/formatters/json-formatter.js.map +1 -0
- package/dist/cli/formatters/simple-formatter.js +54 -0
- package/dist/cli/formatters/simple-formatter.js.map +1 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/lint-worker.js +143 -0
- package/dist/cli/lint-worker.js.map +1 -0
- package/dist/cli/output-manager.js.map +1 -0
- package/dist/{src/cli → cli}/summary-reporter.js +13 -16
- package/dist/cli/summary-reporter.js.map +1 -0
- package/dist/{src/cli.js → cli.js} +5 -3
- package/dist/cli.js.map +1 -0
- package/dist/{src/custom-rule-loader.js → custom-rule-loader.js} +20 -4
- package/dist/custom-rule-loader.js.map +1 -0
- package/dist/herb-disable-comment-utils.js.map +1 -0
- package/dist/herb-lint.js +60648 -17513
- package/dist/herb-lint.js.map +1 -1
- package/dist/index.cjs +2621 -934
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +2554 -873
- package/dist/index.js.map +1 -1
- package/dist/lint-worker.js +71462 -0
- package/dist/lint-worker.js.map +1 -0
- package/dist/linter-ignore.js.map +1 -0
- package/dist/{src/linter.js → linter.js} +89 -74
- package/dist/linter.js.map +1 -0
- package/dist/loader.cjs +31206 -7834
- package/dist/loader.cjs.map +1 -1
- package/dist/loader.js +31168 -7802
- package/dist/loader.js.map +1 -1
- package/dist/parse-cache.js +30 -0
- package/dist/parse-cache.js.map +1 -0
- package/dist/rules/actionview-no-silent-helper.js +45 -0
- package/dist/rules/actionview-no-silent-helper.js.map +1 -0
- package/dist/{src/rules → rules}/erb-comment-syntax.js +2 -2
- package/dist/rules/erb-comment-syntax.js.map +1 -0
- package/dist/{src/rules → rules}/erb-no-case-node-children.js +2 -2
- package/dist/rules/erb-no-case-node-children.js.map +1 -0
- package/dist/rules/erb-no-conditional-html-element.js +38 -0
- package/dist/rules/erb-no-conditional-html-element.js.map +1 -0
- package/dist/rules/erb-no-conditional-open-tag.js +24 -0
- package/dist/rules/erb-no-conditional-open-tag.js.map +1 -0
- package/dist/rules/erb-no-duplicate-branch-elements.js +245 -0
- package/dist/rules/erb-no-duplicate-branch-elements.js.map +1 -0
- package/dist/{src/rules → rules}/erb-no-empty-tags.js +2 -2
- package/dist/rules/erb-no-empty-tags.js.map +1 -0
- package/dist/{src/rules → rules}/erb-no-extra-newline.js +4 -21
- package/dist/rules/erb-no-extra-newline.js.map +1 -0
- package/dist/{src/rules → rules}/erb-no-extra-whitespace-inside-tags.js +39 -13
- package/dist/rules/erb-no-extra-whitespace-inside-tags.js.map +1 -0
- package/dist/rules/erb-no-inline-case-conditions.js +40 -0
- package/dist/rules/erb-no-inline-case-conditions.js.map +1 -0
- package/dist/rules/erb-no-instance-variables-in-partials.js +67 -0
- package/dist/rules/erb-no-instance-variables-in-partials.js.map +1 -0
- package/dist/rules/erb-no-interpolated-class-names.js +47 -0
- package/dist/rules/erb-no-interpolated-class-names.js.map +1 -0
- package/dist/rules/erb-no-javascript-tag-helper.js +34 -0
- package/dist/rules/erb-no-javascript-tag-helper.js.map +1 -0
- package/dist/{src/rules → rules}/erb-no-output-control-flow.js +9 -12
- package/dist/rules/erb-no-output-control-flow.js.map +1 -0
- package/dist/rules/erb-no-output-in-attribute-name.js +30 -0
- package/dist/rules/erb-no-output-in-attribute-name.js.map +1 -0
- package/dist/rules/erb-no-output-in-attribute-position.js +30 -0
- package/dist/rules/erb-no-output-in-attribute-position.js.map +1 -0
- package/dist/rules/erb-no-raw-output-in-attribute-value.js +35 -0
- package/dist/rules/erb-no-raw-output-in-attribute-value.js.map +1 -0
- package/dist/{src/rules → rules}/erb-no-silent-tag-in-attribute-name.js +2 -2
- package/dist/rules/erb-no-silent-tag-in-attribute-name.js.map +1 -0
- package/dist/rules/erb-no-statement-in-script.js +58 -0
- package/dist/rules/erb-no-statement-in-script.js.map +1 -0
- package/dist/rules/erb-no-then-in-control-flow.js +45 -0
- package/dist/rules/erb-no-then-in-control-flow.js.map +1 -0
- package/dist/rules/erb-no-trailing-whitespace.js +138 -0
- package/dist/rules/erb-no-trailing-whitespace.js.map +1 -0
- package/dist/rules/erb-no-unsafe-js-attribute.js +36 -0
- package/dist/rules/erb-no-unsafe-js-attribute.js.map +1 -0
- package/dist/rules/erb-no-unsafe-raw.js +63 -0
- package/dist/rules/erb-no-unsafe-raw.js.map +1 -0
- package/dist/rules/erb-no-unsafe-script-interpolation.js +54 -0
- package/dist/rules/erb-no-unsafe-script-interpolation.js.map +1 -0
- package/dist/{src/rules → rules}/erb-prefer-image-tag-helper.js +5 -4
- package/dist/rules/erb-prefer-image-tag-helper.js.map +1 -0
- package/dist/{src/rules → rules}/erb-require-trailing-newline.js +2 -2
- package/dist/rules/erb-require-trailing-newline.js.map +1 -0
- package/dist/{src/rules → rules}/erb-require-whitespace-inside-tags.js +39 -15
- package/dist/rules/erb-require-whitespace-inside-tags.js.map +1 -0
- package/dist/{src/rules → rules}/erb-right-trim.js +2 -2
- package/dist/rules/erb-right-trim.js.map +1 -0
- package/dist/{src/rules → rules}/erb-strict-locals-comment-syntax.js +5 -5
- package/dist/rules/erb-strict-locals-comment-syntax.js.map +1 -0
- package/dist/{src/rules → rules}/erb-strict-locals-required.js +2 -2
- package/dist/rules/erb-strict-locals-required.js.map +1 -0
- package/dist/rules/file-utils.js.map +1 -0
- package/dist/rules/herb-disable-comment-base.js.map +1 -0
- package/dist/{src/rules → rules}/herb-disable-comment-malformed.js +2 -2
- package/dist/rules/herb-disable-comment-malformed.js.map +1 -0
- package/dist/{src/rules → rules}/herb-disable-comment-missing-rules.js +2 -2
- package/dist/rules/herb-disable-comment-missing-rules.js.map +1 -0
- package/dist/{src/rules → rules}/herb-disable-comment-no-duplicate-rules.js +2 -2
- package/dist/rules/herb-disable-comment-no-duplicate-rules.js.map +1 -0
- package/dist/{src/rules → rules}/herb-disable-comment-no-redundant-all.js +2 -2
- package/dist/rules/herb-disable-comment-no-redundant-all.js.map +1 -0
- package/dist/{src/rules → rules}/herb-disable-comment-unnecessary.js +2 -2
- package/dist/rules/herb-disable-comment-unnecessary.js.map +1 -0
- package/dist/{src/rules → rules}/herb-disable-comment-valid-rule-name.js +2 -2
- package/dist/rules/herb-disable-comment-valid-rule-name.js.map +1 -0
- package/dist/rules/html-allowed-script-type.js +57 -0
- package/dist/rules/html-allowed-script-type.js.map +1 -0
- package/dist/rules/html-anchor-require-href.js +68 -0
- package/dist/rules/html-anchor-require-href.js.map +1 -0
- package/dist/{src/rules → rules}/html-aria-attribute-must-be-valid.js +3 -3
- package/dist/rules/html-aria-attribute-must-be-valid.js.map +1 -0
- package/dist/{src/rules → rules}/html-aria-label-is-well-formatted.js +3 -3
- package/dist/rules/html-aria-label-is-well-formatted.js.map +1 -0
- package/dist/{src/rules → rules}/html-aria-level-must-be-valid.js +3 -3
- package/dist/rules/html-aria-level-must-be-valid.js.map +1 -0
- package/dist/{src/rules → rules}/html-aria-role-heading-requires-level.js +5 -4
- package/dist/rules/html-aria-role-heading-requires-level.js.map +1 -0
- package/dist/{src/rules → rules}/html-aria-role-must-be-valid.js +3 -3
- package/dist/rules/html-aria-role-must-be-valid.js.map +1 -0
- package/dist/{src/rules → rules}/html-attribute-double-quotes.js +4 -4
- package/dist/rules/html-attribute-double-quotes.js.map +1 -0
- package/dist/{src/rules → rules}/html-attribute-equals-spacing.js +2 -2
- package/dist/rules/html-attribute-equals-spacing.js.map +1 -0
- package/dist/{src/rules → rules}/html-attribute-values-require-quotes.js +2 -2
- package/dist/rules/html-attribute-values-require-quotes.js.map +1 -0
- package/dist/{src/rules → rules}/html-avoid-both-disabled-and-aria-disabled.js +9 -9
- package/dist/rules/html-avoid-both-disabled-and-aria-disabled.js.map +1 -0
- package/dist/{src/rules → rules}/html-body-only-elements.js +5 -4
- package/dist/rules/html-body-only-elements.js.map +1 -0
- package/dist/{src/rules → rules}/html-boolean-attributes-no-value.js +4 -3
- package/dist/rules/html-boolean-attributes-no-value.js.map +1 -0
- package/dist/rules/html-details-has-summary.js +52 -0
- package/dist/rules/html-details-has-summary.js.map +1 -0
- package/dist/{src/rules → rules}/html-head-only-elements.js +6 -5
- package/dist/rules/html-head-only-elements.js.map +1 -0
- package/dist/{src/rules → rules}/html-iframe-has-title.js +8 -11
- package/dist/rules/html-iframe-has-title.js.map +1 -0
- package/dist/{src/rules → rules}/html-img-require-alt.js +11 -5
- package/dist/rules/html-img-require-alt.js.map +1 -0
- package/dist/{src/rules → rules}/html-input-require-autocomplete.js +7 -10
- package/dist/rules/html-input-require-autocomplete.js.map +1 -0
- package/dist/{src/rules → rules}/html-navigation-has-label.js +6 -5
- package/dist/rules/html-navigation-has-label.js.map +1 -0
- package/dist/rules/html-no-abstract-roles.js +29 -0
- package/dist/rules/html-no-abstract-roles.js.map +1 -0
- package/dist/rules/html-no-aria-hidden-on-body.js +42 -0
- package/dist/rules/html-no-aria-hidden-on-body.js.map +1 -0
- package/dist/{src/rules → rules}/html-no-aria-hidden-on-focusable.js +6 -5
- package/dist/rules/html-no-aria-hidden-on-focusable.js.map +1 -0
- package/dist/{src/rules → rules}/html-no-block-inside-inline.js +6 -9
- package/dist/rules/html-no-block-inside-inline.js.map +1 -0
- package/dist/{src/rules → rules}/html-no-duplicate-attributes.js +4 -3
- package/dist/rules/html-no-duplicate-attributes.js.map +1 -0
- package/dist/{src/rules → rules}/html-no-duplicate-ids.js +14 -11
- package/dist/rules/html-no-duplicate-ids.js.map +1 -0
- package/dist/{src/rules → rules}/html-no-duplicate-meta-names.js +22 -20
- package/dist/rules/html-no-duplicate-meta-names.js.map +1 -0
- package/dist/{src/rules → rules}/html-no-empty-attributes.js +2 -2
- package/dist/rules/html-no-empty-attributes.js.map +1 -0
- package/dist/rules/html-no-empty-headings.js +98 -0
- package/dist/rules/html-no-empty-headings.js.map +1 -0
- package/dist/{src/rules → rules}/html-no-nested-links.js +23 -15
- package/dist/rules/html-no-nested-links.js.map +1 -0
- package/dist/{src/rules → rules}/html-no-positive-tab-index.js +3 -3
- package/dist/rules/html-no-positive-tab-index.js.map +1 -0
- package/dist/{src/rules → rules}/html-no-self-closing.js +4 -4
- package/dist/rules/html-no-self-closing.js.map +1 -0
- package/dist/{src/rules → rules}/html-no-space-in-tag.js +4 -6
- package/dist/rules/html-no-space-in-tag.js.map +1 -0
- package/dist/{src/rules → rules}/html-no-title-attribute.js +6 -5
- package/dist/rules/html-no-title-attribute.js.map +1 -0
- package/dist/{src/rules → rules}/html-no-underscores-in-attribute-names.js +2 -2
- package/dist/rules/html-no-underscores-in-attribute-names.js.map +1 -0
- package/dist/rules/html-require-closing-tags.js +29 -0
- package/dist/rules/html-require-closing-tags.js.map +1 -0
- package/dist/{src/rules → rules}/html-tag-name-lowercase.js +13 -9
- package/dist/rules/html-tag-name-lowercase.js.map +1 -0
- package/dist/{src/rules → rules}/index.js +19 -0
- package/dist/rules/index.js.map +1 -0
- package/dist/{src/rules → rules}/parser-no-errors.js +3 -3
- package/dist/rules/parser-no-errors.js.map +1 -0
- package/dist/{src/rules → rules}/rule-utils.js +141 -219
- package/dist/rules/rule-utils.js.map +1 -0
- package/dist/rules/string-utils.js.map +1 -0
- package/dist/{src/rules → rules}/svg-tag-name-capitalization.js +7 -6
- package/dist/rules/svg-tag-name-capitalization.js.map +1 -0
- package/dist/rules/turbo-permanent-require-id.js +34 -0
- package/dist/rules/turbo-permanent-require-id.js.map +1 -0
- package/dist/{src/rules.js → rules.js} +56 -10
- package/dist/rules.js.map +1 -0
- package/dist/types/cli/argument-parser.d.ts +1 -0
- package/dist/types/cli/file-processor.d.ts +13 -0
- package/dist/types/cli/file-url.d.ts +1 -0
- package/dist/types/cli/index.d.ts +1 -0
- package/dist/types/cli/lint-worker.d.ts +34 -0
- package/dist/types/custom-rule-loader.d.ts +4 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/linter.d.ts +13 -6
- package/dist/types/parse-cache.d.ts +9 -0
- package/dist/types/{src/rules/html-aria-level-must-be-valid.d.ts → rules/actionview-no-silent-helper.d.ts} +4 -3
- package/dist/types/rules/erb-comment-syntax.d.ts +1 -1
- package/dist/types/rules/erb-no-case-node-children.d.ts +1 -1
- package/dist/types/{src/rules/herb-disable-comment-malformed.d.ts → rules/erb-no-conditional-html-element.d.ts} +3 -3
- package/dist/types/{src/rules/erb-prefer-image-tag-helper.d.ts → rules/erb-no-conditional-open-tag.d.ts} +3 -3
- package/dist/types/rules/erb-no-duplicate-branch-elements.d.ts +17 -0
- package/dist/types/rules/erb-no-empty-tags.d.ts +1 -1
- package/dist/types/rules/erb-no-extra-newline.d.ts +1 -1
- package/dist/types/rules/erb-no-extra-whitespace-inside-tags.d.ts +1 -1
- package/dist/types/{src/rules/html-no-duplicate-attributes.d.ts → rules/erb-no-inline-case-conditions.d.ts} +4 -3
- package/dist/types/rules/erb-no-instance-variables-in-partials.d.ts +10 -0
- package/dist/types/{src/rules/html-no-aria-hidden-on-focusable.d.ts → rules/erb-no-interpolated-class-names.d.ts} +2 -2
- package/dist/types/{src/rules/html-aria-attribute-must-be-valid.d.ts → rules/erb-no-javascript-tag-helper.d.ts} +2 -2
- package/dist/types/rules/erb-no-output-control-flow.d.ts +1 -1
- package/dist/types/{src/rules/erb-no-silent-tag-in-attribute-name.d.ts → rules/erb-no-output-in-attribute-name.d.ts} +2 -2
- package/dist/types/{src/rules/herb-disable-comment-missing-rules.d.ts → rules/erb-no-output-in-attribute-position.d.ts} +2 -2
- package/dist/types/{src/rules/erb-no-empty-tags.d.ts → rules/erb-no-raw-output-in-attribute-value.d.ts} +2 -2
- package/dist/types/rules/erb-no-silent-tag-in-attribute-name.d.ts +1 -1
- package/dist/types/{src/rules/html-navigation-has-label.d.ts → rules/erb-no-statement-in-script.d.ts} +2 -2
- package/dist/types/rules/erb-no-then-in-control-flow.d.ts +9 -0
- package/dist/types/rules/erb-no-trailing-whitespace.d.ts +19 -0
- package/dist/types/{src/rules/html-no-positive-tab-index.d.ts → rules/erb-no-unsafe-js-attribute.d.ts} +2 -2
- package/dist/types/{src/rules/erb-no-case-node-children.d.ts → rules/erb-no-unsafe-raw.d.ts} +2 -2
- package/dist/types/rules/erb-no-unsafe-script-interpolation.d.ts +8 -0
- package/dist/types/rules/erb-prefer-image-tag-helper.d.ts +1 -1
- package/dist/types/rules/erb-require-trailing-newline.d.ts +1 -1
- package/dist/types/rules/erb-require-whitespace-inside-tags.d.ts +1 -1
- package/dist/types/rules/erb-right-trim.d.ts +1 -1
- package/dist/types/rules/erb-strict-locals-comment-syntax.d.ts +1 -1
- package/dist/types/rules/erb-strict-locals-required.d.ts +1 -1
- package/dist/types/rules/herb-disable-comment-malformed.d.ts +1 -1
- package/dist/types/rules/herb-disable-comment-missing-rules.d.ts +1 -1
- package/dist/types/rules/herb-disable-comment-no-duplicate-rules.d.ts +1 -1
- package/dist/types/rules/herb-disable-comment-no-redundant-all.d.ts +1 -1
- package/dist/types/rules/herb-disable-comment-unnecessary.d.ts +1 -1
- package/dist/types/rules/herb-disable-comment-valid-rule-name.d.ts +1 -1
- package/dist/types/{src/rules/html-anchor-require-href.d.ts → rules/html-allowed-script-type.d.ts} +2 -2
- package/dist/types/rules/html-anchor-require-href.d.ts +3 -2
- package/dist/types/rules/html-aria-attribute-must-be-valid.d.ts +1 -1
- package/dist/types/rules/html-aria-label-is-well-formatted.d.ts +1 -1
- package/dist/types/rules/html-aria-level-must-be-valid.d.ts +1 -1
- package/dist/types/rules/html-aria-role-heading-requires-level.d.ts +1 -1
- package/dist/types/rules/html-aria-role-must-be-valid.d.ts +1 -1
- package/dist/types/rules/html-attribute-double-quotes.d.ts +1 -1
- package/dist/types/rules/html-attribute-equals-spacing.d.ts +1 -1
- package/dist/types/rules/html-attribute-values-require-quotes.d.ts +1 -1
- package/dist/types/rules/html-avoid-both-disabled-and-aria-disabled.d.ts +1 -1
- package/dist/types/rules/html-body-only-elements.d.ts +1 -1
- package/dist/types/rules/html-boolean-attributes-no-value.d.ts +1 -1
- package/dist/types/{src/rules/html-no-empty-attributes.d.ts → rules/html-details-has-summary.d.ts} +4 -3
- package/dist/types/rules/html-head-only-elements.d.ts +1 -1
- package/dist/types/rules/html-iframe-has-title.d.ts +1 -1
- package/dist/types/rules/html-img-require-alt.d.ts +1 -1
- package/dist/types/rules/html-input-require-autocomplete.d.ts +1 -1
- package/dist/types/rules/html-navigation-has-label.d.ts +1 -1
- package/dist/types/{src/rules/html-no-empty-headings.d.ts → rules/html-no-abstract-roles.d.ts} +2 -2
- package/dist/types/{src/rules/erb-no-output-control-flow.d.ts → rules/html-no-aria-hidden-on-body.d.ts} +3 -3
- package/dist/types/rules/html-no-aria-hidden-on-focusable.d.ts +1 -1
- package/dist/types/rules/html-no-block-inside-inline.d.ts +1 -1
- package/dist/types/rules/html-no-duplicate-attributes.d.ts +1 -1
- package/dist/types/rules/html-no-duplicate-ids.d.ts +1 -1
- package/dist/types/rules/html-no-duplicate-meta-names.d.ts +1 -1
- package/dist/types/rules/html-no-empty-attributes.d.ts +1 -1
- package/dist/types/rules/html-no-empty-headings.d.ts +1 -1
- package/dist/types/rules/html-no-nested-links.d.ts +1 -1
- package/dist/types/rules/html-no-positive-tab-index.d.ts +1 -1
- package/dist/types/rules/html-no-self-closing.d.ts +1 -1
- package/dist/types/rules/html-no-space-in-tag.d.ts +1 -1
- package/dist/types/rules/html-no-title-attribute.d.ts +1 -1
- package/dist/types/rules/html-no-underscores-in-attribute-names.d.ts +1 -1
- package/dist/types/{src/rules/html-body-only-elements.d.ts → rules/html-require-closing-tags.d.ts} +4 -3
- package/dist/types/rules/html-tag-name-lowercase.d.ts +1 -1
- package/dist/types/rules/index.d.ts +19 -0
- package/dist/types/rules/parser-no-errors.d.ts +1 -1
- package/dist/types/rules/rule-utils.d.ts +35 -88
- package/dist/types/rules/svg-tag-name-capitalization.d.ts +1 -1
- package/dist/types/{src/rules/html-aria-role-must-be-valid.d.ts → rules/turbo-permanent-require-id.d.ts} +2 -2
- package/dist/types/types.d.ts +25 -7
- package/dist/types/urls.d.ts +1 -0
- package/dist/{src/types.js → types.js} +53 -0
- package/dist/types.js.map +1 -0
- package/dist/urls.js +5 -0
- package/dist/urls.js.map +1 -0
- package/docs/rules/README.md +23 -2
- package/docs/rules/actionview-no-silent-helper.md +57 -0
- package/docs/rules/erb-no-conditional-html-element.md +90 -0
- package/docs/rules/erb-no-conditional-open-tag.md +130 -0
- package/docs/rules/erb-no-duplicate-branch-elements.md +98 -0
- package/docs/rules/erb-no-inline-case-conditions.md +85 -0
- package/docs/rules/erb-no-instance-variables-in-partials.md +43 -0
- package/docs/rules/erb-no-interpolated-class-names.md +57 -0
- package/docs/rules/erb-no-javascript-tag-helper.md +33 -0
- package/docs/rules/erb-no-output-in-attribute-name.md +38 -0
- package/docs/rules/erb-no-output-in-attribute-position.md +60 -0
- package/docs/rules/erb-no-raw-output-in-attribute-value.md +37 -0
- package/docs/rules/erb-no-statement-in-script.md +68 -0
- package/docs/rules/erb-no-then-in-control-flow.md +86 -0
- package/docs/rules/erb-no-trailing-whitespace.md +69 -0
- package/docs/rules/erb-no-unsafe-js-attribute.md +41 -0
- package/docs/rules/erb-no-unsafe-raw.md +47 -0
- package/docs/rules/erb-no-unsafe-script-interpolation.md +73 -0
- package/docs/rules/html-allowed-script-type.md +59 -0
- package/docs/rules/html-anchor-require-href.md +19 -6
- package/docs/rules/html-details-has-summary.md +46 -0
- package/docs/rules/html-img-require-alt.md +5 -3
- package/docs/rules/html-no-abstract-roles.md +74 -0
- package/docs/rules/html-no-aria-hidden-on-body.md +44 -0
- package/docs/rules/html-require-closing-tags.md +142 -0
- package/docs/rules/parser-no-errors.md +4 -17
- package/docs/rules/turbo-permanent-require-id.md +41 -0
- package/package.json +12 -11
- package/src/cli/argument-parser.ts +20 -2
- package/src/cli/file-processor.ts +189 -10
- package/src/cli/file-url.ts +6 -0
- package/src/cli/formatters/detailed-formatter.ts +19 -21
- package/src/cli/formatters/simple-formatter.ts +23 -13
- package/src/cli/index.ts +2 -0
- package/src/cli/lint-worker.ts +208 -0
- package/src/cli/summary-reporter.ts +14 -15
- package/src/cli.ts +5 -3
- package/src/custom-rule-loader.ts +20 -5
- package/src/herb-disable-comment-utils.ts +0 -3
- package/src/index.ts +1 -0
- package/src/linter.ts +98 -79
- package/src/parse-cache.ts +39 -0
- package/src/rules/actionview-no-silent-helper.ts +58 -0
- package/src/rules/erb-comment-syntax.ts +2 -2
- package/src/rules/erb-no-case-node-children.ts +2 -2
- package/src/rules/erb-no-conditional-html-element.ts +53 -0
- package/src/rules/erb-no-conditional-open-tag.ts +37 -0
- package/src/rules/erb-no-duplicate-branch-elements.ts +320 -0
- package/src/rules/erb-no-empty-tags.ts +2 -2
- package/src/rules/erb-no-extra-newline.ts +5 -25
- package/src/rules/erb-no-extra-whitespace-inside-tags.ts +45 -15
- package/src/rules/erb-no-inline-case-conditions.ts +54 -0
- package/src/rules/erb-no-instance-variables-in-partials.ts +101 -0
- package/src/rules/erb-no-interpolated-class-names.ts +65 -0
- package/src/rules/erb-no-javascript-tag-helper.ts +47 -0
- package/src/rules/erb-no-output-control-flow.ts +10 -10
- package/src/rules/erb-no-output-in-attribute-name.ts +39 -0
- package/src/rules/erb-no-output-in-attribute-position.ts +39 -0
- package/src/rules/erb-no-raw-output-in-attribute-value.ts +47 -0
- package/src/rules/erb-no-silent-tag-in-attribute-name.ts +2 -2
- package/src/rules/erb-no-statement-in-script.ts +82 -0
- package/src/rules/erb-no-then-in-control-flow.ts +62 -0
- package/src/rules/erb-no-trailing-whitespace.ts +187 -0
- package/src/rules/erb-no-unsafe-js-attribute.ts +47 -0
- package/src/rules/erb-no-unsafe-raw.ts +83 -0
- package/src/rules/erb-no-unsafe-script-interpolation.ts +76 -0
- package/src/rules/erb-prefer-image-tag-helper.ts +5 -4
- package/src/rules/erb-require-trailing-newline.ts +2 -2
- package/src/rules/erb-require-whitespace-inside-tags.ts +42 -18
- package/src/rules/erb-right-trim.ts +2 -2
- package/src/rules/erb-strict-locals-comment-syntax.ts +5 -5
- package/src/rules/erb-strict-locals-required.ts +2 -2
- package/src/rules/herb-disable-comment-malformed.ts +2 -2
- package/src/rules/herb-disable-comment-missing-rules.ts +2 -2
- package/src/rules/herb-disable-comment-no-duplicate-rules.ts +2 -2
- package/src/rules/herb-disable-comment-no-redundant-all.ts +2 -2
- package/src/rules/herb-disable-comment-unnecessary.ts +2 -2
- package/src/rules/herb-disable-comment-valid-rule-name.ts +2 -2
- package/src/rules/html-allowed-script-type.ts +84 -0
- package/src/rules/html-anchor-require-href.ts +73 -11
- package/src/rules/html-aria-attribute-must-be-valid.ts +3 -3
- package/src/rules/html-aria-label-is-well-formatted.ts +3 -3
- package/src/rules/html-aria-level-must-be-valid.ts +3 -3
- package/src/rules/html-aria-role-heading-requires-level.ts +5 -4
- package/src/rules/html-aria-role-must-be-valid.ts +3 -3
- package/src/rules/html-attribute-double-quotes.ts +4 -4
- package/src/rules/html-attribute-equals-spacing.ts +2 -2
- package/src/rules/html-attribute-values-require-quotes.ts +2 -2
- package/src/rules/html-avoid-both-disabled-and-aria-disabled.ts +10 -11
- package/src/rules/html-body-only-elements.ts +5 -4
- package/src/rules/html-boolean-attributes-no-value.ts +4 -3
- package/src/rules/html-details-has-summary.ts +69 -0
- package/src/rules/html-head-only-elements.ts +6 -5
- package/src/rules/html-iframe-has-title.ts +8 -11
- package/src/rules/html-img-require-alt.ts +16 -5
- package/src/rules/html-input-require-autocomplete.ts +7 -10
- package/src/rules/html-navigation-has-label.ts +6 -5
- package/src/rules/html-no-abstract-roles.ts +40 -0
- package/src/rules/html-no-aria-hidden-on-body.ts +58 -0
- package/src/rules/html-no-aria-hidden-on-focusable.ts +6 -5
- package/src/rules/html-no-block-inside-inline.ts +7 -13
- package/src/rules/html-no-duplicate-attributes.ts +4 -3
- package/src/rules/html-no-duplicate-ids.ts +16 -13
- package/src/rules/html-no-duplicate-meta-names.ts +20 -19
- package/src/rules/html-no-empty-attributes.ts +2 -2
- package/src/rules/html-no-empty-headings.ts +44 -58
- package/src/rules/html-no-nested-links.ts +25 -16
- package/src/rules/html-no-positive-tab-index.ts +3 -3
- package/src/rules/html-no-self-closing.ts +5 -5
- package/src/rules/html-no-space-in-tag.ts +5 -8
- package/src/rules/html-no-title-attribute.ts +6 -5
- package/src/rules/html-no-underscores-in-attribute-names.ts +2 -2
- package/src/rules/html-require-closing-tags.ts +41 -0
- package/src/rules/html-tag-name-lowercase.ts +14 -9
- package/src/rules/index.ts +19 -0
- package/src/rules/parser-no-errors.ts +3 -3
- package/src/rules/rule-utils.ts +162 -279
- package/src/rules/svg-tag-name-capitalization.ts +10 -10
- package/src/rules/turbo-permanent-require-id.ts +49 -0
- package/src/rules.ts +60 -10
- package/src/types.ts +76 -7
- package/src/urls.ts +5 -0
- package/dist/package.json +0 -65
- package/dist/src/cli/argument-parser.js.map +0 -1
- package/dist/src/cli/file-processor.js.map +0 -1
- package/dist/src/cli/formatters/base-formatter.js.map +0 -1
- package/dist/src/cli/formatters/detailed-formatter.js.map +0 -1
- package/dist/src/cli/formatters/github-actions-formatter.js.map +0 -1
- package/dist/src/cli/formatters/index.js.map +0 -1
- package/dist/src/cli/formatters/json-formatter.js.map +0 -1
- package/dist/src/cli/formatters/simple-formatter.js +0 -44
- package/dist/src/cli/formatters/simple-formatter.js.map +0 -1
- package/dist/src/cli/index.js.map +0 -1
- package/dist/src/cli/output-manager.js.map +0 -1
- package/dist/src/cli/summary-reporter.js.map +0 -1
- package/dist/src/cli.js.map +0 -1
- package/dist/src/custom-rule-loader.js.map +0 -1
- package/dist/src/herb-disable-comment-utils.js.map +0 -1
- package/dist/src/herb-lint.js +0 -5
- package/dist/src/herb-lint.js.map +0 -1
- package/dist/src/index.js +0 -5
- package/dist/src/index.js.map +0 -1
- package/dist/src/linter-ignore.js.map +0 -1
- package/dist/src/linter.js.map +0 -1
- package/dist/src/loader.js +0 -17
- package/dist/src/loader.js.map +0 -1
- package/dist/src/rules/erb-comment-syntax.js.map +0 -1
- package/dist/src/rules/erb-no-case-node-children.js.map +0 -1
- package/dist/src/rules/erb-no-empty-tags.js.map +0 -1
- package/dist/src/rules/erb-no-extra-newline.js.map +0 -1
- package/dist/src/rules/erb-no-extra-whitespace-inside-tags.js.map +0 -1
- package/dist/src/rules/erb-no-output-control-flow.js.map +0 -1
- package/dist/src/rules/erb-no-silent-tag-in-attribute-name.js.map +0 -1
- package/dist/src/rules/erb-prefer-image-tag-helper.js.map +0 -1
- package/dist/src/rules/erb-require-trailing-newline.js.map +0 -1
- package/dist/src/rules/erb-require-whitespace-inside-tags.js.map +0 -1
- package/dist/src/rules/erb-right-trim.js.map +0 -1
- package/dist/src/rules/erb-strict-locals-comment-syntax.js.map +0 -1
- package/dist/src/rules/erb-strict-locals-required.js.map +0 -1
- package/dist/src/rules/file-utils.js.map +0 -1
- package/dist/src/rules/herb-disable-comment-base.js.map +0 -1
- package/dist/src/rules/herb-disable-comment-malformed.js.map +0 -1
- package/dist/src/rules/herb-disable-comment-missing-rules.js.map +0 -1
- package/dist/src/rules/herb-disable-comment-no-duplicate-rules.js.map +0 -1
- package/dist/src/rules/herb-disable-comment-no-redundant-all.js.map +0 -1
- package/dist/src/rules/herb-disable-comment-unnecessary.js.map +0 -1
- package/dist/src/rules/herb-disable-comment-valid-rule-name.js.map +0 -1
- package/dist/src/rules/html-anchor-require-href.js +0 -32
- package/dist/src/rules/html-anchor-require-href.js.map +0 -1
- package/dist/src/rules/html-aria-attribute-must-be-valid.js.map +0 -1
- package/dist/src/rules/html-aria-label-is-well-formatted.js.map +0 -1
- package/dist/src/rules/html-aria-level-must-be-valid.js.map +0 -1
- package/dist/src/rules/html-aria-role-heading-requires-level.js.map +0 -1
- package/dist/src/rules/html-aria-role-must-be-valid.js.map +0 -1
- package/dist/src/rules/html-attribute-double-quotes.js.map +0 -1
- package/dist/src/rules/html-attribute-equals-spacing.js.map +0 -1
- package/dist/src/rules/html-attribute-values-require-quotes.js.map +0 -1
- package/dist/src/rules/html-avoid-both-disabled-and-aria-disabled.js.map +0 -1
- package/dist/src/rules/html-body-only-elements.js.map +0 -1
- package/dist/src/rules/html-boolean-attributes-no-value.js.map +0 -1
- package/dist/src/rules/html-head-only-elements.js.map +0 -1
- package/dist/src/rules/html-iframe-has-title.js.map +0 -1
- package/dist/src/rules/html-img-require-alt.js.map +0 -1
- package/dist/src/rules/html-input-require-autocomplete.js.map +0 -1
- package/dist/src/rules/html-navigation-has-label.js.map +0 -1
- package/dist/src/rules/html-no-aria-hidden-on-focusable.js.map +0 -1
- package/dist/src/rules/html-no-block-inside-inline.js.map +0 -1
- package/dist/src/rules/html-no-duplicate-attributes.js.map +0 -1
- package/dist/src/rules/html-no-duplicate-ids.js.map +0 -1
- package/dist/src/rules/html-no-duplicate-meta-names.js.map +0 -1
- package/dist/src/rules/html-no-empty-attributes.js.map +0 -1
- package/dist/src/rules/html-no-empty-headings.js +0 -115
- package/dist/src/rules/html-no-empty-headings.js.map +0 -1
- package/dist/src/rules/html-no-nested-links.js.map +0 -1
- package/dist/src/rules/html-no-positive-tab-index.js.map +0 -1
- package/dist/src/rules/html-no-self-closing.js.map +0 -1
- package/dist/src/rules/html-no-space-in-tag.js.map +0 -1
- package/dist/src/rules/html-no-title-attribute.js.map +0 -1
- package/dist/src/rules/html-no-underscores-in-attribute-names.js.map +0 -1
- package/dist/src/rules/html-tag-name-lowercase.js.map +0 -1
- package/dist/src/rules/index.js.map +0 -1
- package/dist/src/rules/parser-no-errors.js.map +0 -1
- package/dist/src/rules/rule-utils.js.map +0 -1
- package/dist/src/rules/string-utils.js.map +0 -1
- package/dist/src/rules/svg-tag-name-capitalization.js.map +0 -1
- package/dist/src/rules.js.map +0 -1
- package/dist/src/types.js.map +0 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/dist/types/src/cli/argument-parser.d.ts +0 -25
- package/dist/types/src/cli/file-processor.d.ts +0 -43
- package/dist/types/src/cli/formatters/base-formatter.d.ts +0 -6
- package/dist/types/src/cli/formatters/detailed-formatter.d.ts +0 -13
- package/dist/types/src/cli/formatters/github-actions-formatter.d.ts +0 -17
- package/dist/types/src/cli/formatters/index.d.ts +0 -5
- package/dist/types/src/cli/formatters/json-formatter.d.ts +0 -48
- package/dist/types/src/cli/formatters/simple-formatter.d.ts +0 -8
- package/dist/types/src/cli/index.d.ts +0 -5
- package/dist/types/src/cli/output-manager.d.ts +0 -32
- package/dist/types/src/cli/summary-reporter.d.ts +0 -28
- package/dist/types/src/cli.d.ts +0 -28
- package/dist/types/src/custom-rule-loader.d.ts +0 -62
- package/dist/types/src/herb-disable-comment-utils.d.ts +0 -69
- package/dist/types/src/herb-lint.d.ts +0 -2
- package/dist/types/src/index.d.ts +0 -4
- package/dist/types/src/linter-ignore.d.ts +0 -12
- package/dist/types/src/linter.d.ts +0 -133
- package/dist/types/src/loader.d.ts +0 -20
- package/dist/types/src/rules/erb-comment-syntax.d.ts +0 -14
- package/dist/types/src/rules/erb-no-extra-newline.d.ts +0 -14
- package/dist/types/src/rules/erb-no-extra-whitespace-inside-tags.d.ts +0 -18
- package/dist/types/src/rules/erb-require-trailing-newline.d.ts +0 -9
- package/dist/types/src/rules/erb-require-whitespace-inside-tags.d.ts +0 -18
- package/dist/types/src/rules/erb-right-trim.d.ts +0 -14
- package/dist/types/src/rules/erb-strict-locals-comment-syntax.d.ts +0 -9
- package/dist/types/src/rules/erb-strict-locals-required.d.ts +0 -9
- package/dist/types/src/rules/file-utils.d.ts +0 -13
- package/dist/types/src/rules/herb-disable-comment-base.d.ts +0 -37
- package/dist/types/src/rules/herb-disable-comment-no-duplicate-rules.d.ts +0 -8
- package/dist/types/src/rules/herb-disable-comment-no-redundant-all.d.ts +0 -8
- package/dist/types/src/rules/herb-disable-comment-unnecessary.d.ts +0 -8
- package/dist/types/src/rules/herb-disable-comment-valid-rule-name.d.ts +0 -8
- package/dist/types/src/rules/html-aria-label-is-well-formatted.d.ts +0 -8
- package/dist/types/src/rules/html-aria-role-heading-requires-level.d.ts +0 -8
- package/dist/types/src/rules/html-attribute-double-quotes.d.ts +0 -15
- package/dist/types/src/rules/html-attribute-equals-spacing.d.ts +0 -14
- package/dist/types/src/rules/html-attribute-values-require-quotes.d.ts +0 -15
- package/dist/types/src/rules/html-avoid-both-disabled-and-aria-disabled.d.ts +0 -8
- package/dist/types/src/rules/html-boolean-attributes-no-value.d.ts +0 -14
- package/dist/types/src/rules/html-head-only-elements.d.ts +0 -9
- package/dist/types/src/rules/html-iframe-has-title.d.ts +0 -8
- package/dist/types/src/rules/html-img-require-alt.d.ts +0 -8
- package/dist/types/src/rules/html-input-require-autocomplete.d.ts +0 -8
- package/dist/types/src/rules/html-no-block-inside-inline.d.ts +0 -8
- package/dist/types/src/rules/html-no-duplicate-ids.d.ts +0 -8
- package/dist/types/src/rules/html-no-duplicate-meta-names.d.ts +0 -9
- package/dist/types/src/rules/html-no-nested-links.d.ts +0 -8
- package/dist/types/src/rules/html-no-self-closing.d.ts +0 -16
- package/dist/types/src/rules/html-no-space-in-tag.d.ts +0 -16
- package/dist/types/src/rules/html-no-title-attribute.d.ts +0 -8
- package/dist/types/src/rules/html-no-underscores-in-attribute-names.d.ts +0 -8
- package/dist/types/src/rules/html-tag-name-lowercase.d.ts +0 -18
- package/dist/types/src/rules/index.d.ts +0 -54
- package/dist/types/src/rules/parser-no-errors.d.ts +0 -9
- package/dist/types/src/rules/rule-utils.d.ts +0 -351
- package/dist/types/src/rules/string-utils.d.ts +0 -15
- package/dist/types/src/rules/svg-tag-name-capitalization.d.ts +0 -16
- package/dist/types/src/rules.d.ts +0 -2
- package/dist/types/src/types.d.ts +0 -190
- /package/dist/{src/cli → cli}/formatters/base-formatter.js +0 -0
- /package/dist/{src/cli → cli}/formatters/github-actions-formatter.js +0 -0
- /package/dist/{src/cli → cli}/formatters/index.js +0 -0
- /package/dist/{src/cli → cli}/formatters/json-formatter.js +0 -0
- /package/dist/{src/cli → cli}/index.js +0 -0
- /package/dist/{src/cli → cli}/output-manager.js +0 -0
- /package/dist/{src/herb-disable-comment-utils.js → herb-disable-comment-utils.js} +0 -0
- /package/dist/{src/linter-ignore.js → linter-ignore.js} +0 -0
- /package/dist/{src/rules → rules}/file-utils.js +0 -0
- /package/dist/{src/rules → rules}/herb-disable-comment-base.js +0 -0
- /package/dist/{src/rules → rules}/string-utils.js +0 -0
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { getNodesBeforePosition, getNodesAfterPosition, filterNodes, ERBContentNode, isERBOutputNode, Visitor, isToken, isParseResult, getStaticAttributeName, hasDynamicAttributeName as hasDynamicAttributeName$1, getCombinedAttributeName, hasStaticContent, getStaticContentFromNodes, Location, hasERBOutput, isEffectivelyStatic, getValidatableStaticContent, isERBNode, isWhitespaceNode, isCommentNode, isLiteralNode, isHTMLTextNode, Position, filterERBContentNodes, isNode, LiteralNode, didyoumean, filterLiteralNodes, Token, getTagName as getTagName$1, isHTMLElementNode, isHTMLOpenTagNode, HTMLCloseTagNode, WhitespaceNode, filterWhitespaceNodes, HTMLOpenTagNode, isERBCommentNode } from '@herb-tools/core';
|
|
2
1
|
import picomatch from 'picomatch';
|
|
2
|
+
import { getNodesBeforePosition, getNodesAfterPosition, filterNodes, ERBContentNode, isERBOutputNode, isERBIfNode, isERBUnlessNode, isERBElseNode, isHTMLTextNode, Visitor, isToken, isParseResult, forEachAttribute, getAttributeName, hasDynamicAttributeNameOnAttribute, getStaticAttributeValue, getAttributeValueNodes, hasERBOutput, isEffectivelyStatic, getValidatableStaticContent, getAttributeValue, getCombinedAttributeNameString, Location, Position, isERBOpenTagNode, isERBNode, isWhitespaceNode, isCommentNode, isLiteralNode, isERBCaseNode, isPureWhitespaceNode, isERBWhenNode, isHTMLElementNode, isEquivalentElement, findParentArray, removeNodeFromArray, replaceNodeWithBody, createLiteral, HTMLElementNode, PrismVisitor, splitLiteralsAtWhitespace, groupNodesByClass, filterERBContentNodes, isHTMLOpenTagNode, getTagLocalName, getAttribute, isERBCommentNode, getAttributes, findAttributeByName, isNode, LiteralNode, didyoumean, hasAttributeValue, isRubyLiteralNode, filterHTMLAttributeNodes, filterLiteralNodes, getAttributeValueQuoteType, Token, hasAttribute, isHTMLAttributeValueNode, isERBContentNode, getStaticAttributeName, isERBControlFlowNode, HTMLCloseTagNode, getTagName, createWhitespaceNode, filterWhitespaceNodes, getStaticContentFromNodes, getOpenTag, isHTMLCloseTagNode, HTMLOpenTagNode, DEFAULT_PARSER_OPTIONS } from '@herb-tools/core';
|
|
3
3
|
|
|
4
4
|
class PrintContext {
|
|
5
5
|
output = "";
|
|
@@ -141,7 +141,9 @@ class Printer extends Visitor {
|
|
|
141
141
|
return this.context.getOutput();
|
|
142
142
|
}
|
|
143
143
|
write(content) {
|
|
144
|
-
|
|
144
|
+
if (content !== null && content !== undefined) {
|
|
145
|
+
this.context.write(content);
|
|
146
|
+
}
|
|
145
147
|
}
|
|
146
148
|
}
|
|
147
149
|
|
|
@@ -201,6 +203,12 @@ class IdentityPrinter extends Printer {
|
|
|
201
203
|
this.write(node.tag_closing.value);
|
|
202
204
|
}
|
|
203
205
|
}
|
|
206
|
+
visitHTMLVirtualCloseTagNode(_node) {
|
|
207
|
+
// Virtual closing tags don't print anything (they are synthetic)
|
|
208
|
+
}
|
|
209
|
+
visitHTMLOmittedCloseTagNode(_node) {
|
|
210
|
+
// Omitted closing tags don't print anything
|
|
211
|
+
}
|
|
204
212
|
visitHTMLElementNode(node) {
|
|
205
213
|
const tagName = node.tag_name?.value;
|
|
206
214
|
if (tagName) {
|
|
@@ -219,6 +227,24 @@ class IdentityPrinter extends Printer {
|
|
|
219
227
|
this.context.exitTag();
|
|
220
228
|
}
|
|
221
229
|
}
|
|
230
|
+
visitHTMLConditionalElementNode(node) {
|
|
231
|
+
const tagName = node.tag_name?.value;
|
|
232
|
+
if (tagName) {
|
|
233
|
+
this.context.enterTag(tagName);
|
|
234
|
+
}
|
|
235
|
+
if (node.open_conditional) {
|
|
236
|
+
this.visit(node.open_conditional);
|
|
237
|
+
}
|
|
238
|
+
if (node.body) {
|
|
239
|
+
node.body.forEach(child => this.visit(child));
|
|
240
|
+
}
|
|
241
|
+
if (node.close_conditional) {
|
|
242
|
+
this.visit(node.close_conditional);
|
|
243
|
+
}
|
|
244
|
+
if (tagName) {
|
|
245
|
+
this.context.exitTag();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
222
248
|
visitHTMLAttributeNode(node) {
|
|
223
249
|
if (node.name) {
|
|
224
250
|
this.visit(node.name);
|
|
@@ -242,6 +268,12 @@ class IdentityPrinter extends Printer {
|
|
|
242
268
|
this.write(node.close_quote.value);
|
|
243
269
|
}
|
|
244
270
|
}
|
|
271
|
+
visitRubyLiteralNode(node) {
|
|
272
|
+
this.write(node.content);
|
|
273
|
+
}
|
|
274
|
+
visitRubyHTMLAttributesSplatNode(node) {
|
|
275
|
+
this.write(node.content);
|
|
276
|
+
}
|
|
245
277
|
visitHTMLCommentNode(node) {
|
|
246
278
|
if (node.comment_start) {
|
|
247
279
|
this.write(node.comment_start.value);
|
|
@@ -278,6 +310,9 @@ class IdentityPrinter extends Printer {
|
|
|
278
310
|
this.write(node.tag_closing.value);
|
|
279
311
|
}
|
|
280
312
|
}
|
|
313
|
+
visitERBOpenTagNode(node) {
|
|
314
|
+
this.printERBNode(node);
|
|
315
|
+
}
|
|
281
316
|
visitERBContentNode(node) {
|
|
282
317
|
this.printERBNode(node);
|
|
283
318
|
}
|
|
@@ -444,6 +479,259 @@ class IdentityPrinter extends Printer {
|
|
|
444
479
|
}
|
|
445
480
|
}
|
|
446
481
|
|
|
482
|
+
/**
|
|
483
|
+
* IndentPrinter - Re-indentation printer that preserves content but adjusts indentation
|
|
484
|
+
*
|
|
485
|
+
* Extends IdentityPrinter to preserve all content as-is while replacing
|
|
486
|
+
* leading whitespace on each line with the correct indentation based on
|
|
487
|
+
* the AST nesting depth.
|
|
488
|
+
*/
|
|
489
|
+
class IndentPrinter extends IdentityPrinter {
|
|
490
|
+
indentLevel = 0;
|
|
491
|
+
indentWidth;
|
|
492
|
+
pendingIndent = false;
|
|
493
|
+
constructor(indentWidth = 2) {
|
|
494
|
+
super();
|
|
495
|
+
this.indentWidth = indentWidth;
|
|
496
|
+
}
|
|
497
|
+
get indent() {
|
|
498
|
+
return " ".repeat(this.indentLevel * this.indentWidth);
|
|
499
|
+
}
|
|
500
|
+
write(content) {
|
|
501
|
+
if (this.pendingIndent && content.length > 0) {
|
|
502
|
+
this.pendingIndent = false;
|
|
503
|
+
this.context.write(this.indent + content);
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
this.context.write(content);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
visitLiteralNode(node) {
|
|
510
|
+
this.writeWithIndent(node.content);
|
|
511
|
+
}
|
|
512
|
+
visitHTMLTextNode(node) {
|
|
513
|
+
this.writeWithIndent(node.content);
|
|
514
|
+
}
|
|
515
|
+
visitHTMLElementNode(node) {
|
|
516
|
+
const tagName = node.tag_name?.value;
|
|
517
|
+
if (tagName) {
|
|
518
|
+
this.context.enterTag(tagName);
|
|
519
|
+
}
|
|
520
|
+
if (node.open_tag) {
|
|
521
|
+
this.visit(node.open_tag);
|
|
522
|
+
}
|
|
523
|
+
if (node.body) {
|
|
524
|
+
this.indentLevel++;
|
|
525
|
+
node.body.forEach(child => this.visit(child));
|
|
526
|
+
this.indentLevel--;
|
|
527
|
+
}
|
|
528
|
+
if (node.close_tag) {
|
|
529
|
+
this.visit(node.close_tag);
|
|
530
|
+
}
|
|
531
|
+
if (tagName) {
|
|
532
|
+
this.context.exitTag();
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
visitERBIfNode(node) {
|
|
536
|
+
this.printERBNode(node);
|
|
537
|
+
if (node.statements) {
|
|
538
|
+
this.indentLevel++;
|
|
539
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
540
|
+
this.indentLevel--;
|
|
541
|
+
}
|
|
542
|
+
if (node.subsequent) {
|
|
543
|
+
this.visit(node.subsequent);
|
|
544
|
+
}
|
|
545
|
+
if (node.end_node) {
|
|
546
|
+
this.visit(node.end_node);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
visitERBElseNode(node) {
|
|
550
|
+
this.printERBNode(node);
|
|
551
|
+
if (node.statements) {
|
|
552
|
+
this.indentLevel++;
|
|
553
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
554
|
+
this.indentLevel--;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
visitERBBlockNode(node) {
|
|
558
|
+
this.printERBNode(node);
|
|
559
|
+
if (node.body) {
|
|
560
|
+
this.indentLevel++;
|
|
561
|
+
node.body.forEach(child => this.visit(child));
|
|
562
|
+
this.indentLevel--;
|
|
563
|
+
}
|
|
564
|
+
if (node.end_node) {
|
|
565
|
+
this.visit(node.end_node);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
visitERBCaseNode(node) {
|
|
569
|
+
this.printERBNode(node);
|
|
570
|
+
if (node.children) {
|
|
571
|
+
this.indentLevel++;
|
|
572
|
+
node.children.forEach(child => this.visit(child));
|
|
573
|
+
this.indentLevel--;
|
|
574
|
+
}
|
|
575
|
+
if (node.conditions) {
|
|
576
|
+
this.indentLevel++;
|
|
577
|
+
node.conditions.forEach(condition => this.visit(condition));
|
|
578
|
+
this.indentLevel--;
|
|
579
|
+
}
|
|
580
|
+
if (node.else_clause) {
|
|
581
|
+
this.indentLevel++;
|
|
582
|
+
this.visit(node.else_clause);
|
|
583
|
+
this.indentLevel--;
|
|
584
|
+
}
|
|
585
|
+
if (node.end_node) {
|
|
586
|
+
this.visit(node.end_node);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
visitERBWhenNode(node) {
|
|
590
|
+
this.printERBNode(node);
|
|
591
|
+
if (node.statements) {
|
|
592
|
+
this.indentLevel++;
|
|
593
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
594
|
+
this.indentLevel--;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
visitERBWhileNode(node) {
|
|
598
|
+
this.printERBNode(node);
|
|
599
|
+
if (node.statements) {
|
|
600
|
+
this.indentLevel++;
|
|
601
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
602
|
+
this.indentLevel--;
|
|
603
|
+
}
|
|
604
|
+
if (node.end_node) {
|
|
605
|
+
this.visit(node.end_node);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
visitERBUntilNode(node) {
|
|
609
|
+
this.printERBNode(node);
|
|
610
|
+
if (node.statements) {
|
|
611
|
+
this.indentLevel++;
|
|
612
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
613
|
+
this.indentLevel--;
|
|
614
|
+
}
|
|
615
|
+
if (node.end_node) {
|
|
616
|
+
this.visit(node.end_node);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
visitERBForNode(node) {
|
|
620
|
+
this.printERBNode(node);
|
|
621
|
+
if (node.statements) {
|
|
622
|
+
this.indentLevel++;
|
|
623
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
624
|
+
this.indentLevel--;
|
|
625
|
+
}
|
|
626
|
+
if (node.end_node) {
|
|
627
|
+
this.visit(node.end_node);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
visitERBBeginNode(node) {
|
|
631
|
+
this.printERBNode(node);
|
|
632
|
+
if (node.statements) {
|
|
633
|
+
this.indentLevel++;
|
|
634
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
635
|
+
this.indentLevel--;
|
|
636
|
+
}
|
|
637
|
+
if (node.rescue_clause) {
|
|
638
|
+
this.visit(node.rescue_clause);
|
|
639
|
+
}
|
|
640
|
+
if (node.else_clause) {
|
|
641
|
+
this.visit(node.else_clause);
|
|
642
|
+
}
|
|
643
|
+
if (node.ensure_clause) {
|
|
644
|
+
this.visit(node.ensure_clause);
|
|
645
|
+
}
|
|
646
|
+
if (node.end_node) {
|
|
647
|
+
this.visit(node.end_node);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
visitERBRescueNode(node) {
|
|
651
|
+
this.printERBNode(node);
|
|
652
|
+
if (node.statements) {
|
|
653
|
+
this.indentLevel++;
|
|
654
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
655
|
+
this.indentLevel--;
|
|
656
|
+
}
|
|
657
|
+
if (node.subsequent) {
|
|
658
|
+
this.visit(node.subsequent);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
visitERBEnsureNode(node) {
|
|
662
|
+
this.printERBNode(node);
|
|
663
|
+
if (node.statements) {
|
|
664
|
+
this.indentLevel++;
|
|
665
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
666
|
+
this.indentLevel--;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
visitERBUnlessNode(node) {
|
|
670
|
+
this.printERBNode(node);
|
|
671
|
+
if (node.statements) {
|
|
672
|
+
this.indentLevel++;
|
|
673
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
674
|
+
this.indentLevel--;
|
|
675
|
+
}
|
|
676
|
+
if (node.else_clause) {
|
|
677
|
+
this.visit(node.else_clause);
|
|
678
|
+
}
|
|
679
|
+
if (node.end_node) {
|
|
680
|
+
this.visit(node.end_node);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Write content, replacing leading whitespace on each line with the current indent.
|
|
685
|
+
*
|
|
686
|
+
* Uses a pendingIndent mechanism: when content ends with a newline followed by
|
|
687
|
+
* whitespace-only, sets pendingIndent=true instead of writing the indent immediately.
|
|
688
|
+
* The indent is then applied at the correct level when the next node writes content
|
|
689
|
+
* (via the overridden write() method).
|
|
690
|
+
*/
|
|
691
|
+
writeWithIndent(content) {
|
|
692
|
+
if (!content.includes("\n")) {
|
|
693
|
+
if (this.pendingIndent) {
|
|
694
|
+
this.pendingIndent = false;
|
|
695
|
+
const trimmed = content.replace(/^[ \t]+/, "");
|
|
696
|
+
if (trimmed.length > 0) {
|
|
697
|
+
this.context.write(this.indent + trimmed);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
else {
|
|
701
|
+
this.context.write(content);
|
|
702
|
+
}
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
const lines = content.split("\n");
|
|
706
|
+
const lastIndex = lines.length - 1;
|
|
707
|
+
for (let i = 0; i < lines.length; i++) {
|
|
708
|
+
if (i > 0) {
|
|
709
|
+
this.context.write("\n");
|
|
710
|
+
}
|
|
711
|
+
const line = lines[i];
|
|
712
|
+
const trimmed = line.replace(/^[ \t]+/, "");
|
|
713
|
+
if (i === 0) {
|
|
714
|
+
if (this.pendingIndent) {
|
|
715
|
+
this.pendingIndent = false;
|
|
716
|
+
if (trimmed.length > 0) {
|
|
717
|
+
this.context.write(this.indent + trimmed);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
else {
|
|
721
|
+
this.context.write(line);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
else if (i === lastIndex && trimmed.length === 0) {
|
|
725
|
+
this.pendingIndent = true;
|
|
726
|
+
}
|
|
727
|
+
else if (trimmed.length === 0) ;
|
|
728
|
+
else {
|
|
729
|
+
this.context.write(this.indent + trimmed);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
447
735
|
const DEFAULT_ERB_TO_RUBY_STRING_OPTIONS = {
|
|
448
736
|
...DEFAULT_PRINT_OPTIONS,
|
|
449
737
|
forceQuotes: false
|
|
@@ -465,7 +753,6 @@ const DEFAULT_ERB_TO_RUBY_STRING_OPTIONS = {
|
|
|
465
753
|
* - `<% if logged_in? %>Welcome<% else %>Login<% end %>!` => `"#{logged_in? ? "Welcome" : "Login"}!"`
|
|
466
754
|
*/
|
|
467
755
|
class ERBToRubyStringPrinter extends IdentityPrinter {
|
|
468
|
-
// TODO: cleanup `.type === "AST_*" checks`
|
|
469
756
|
static print(node, options = DEFAULT_ERB_TO_RUBY_STRING_OPTIONS) {
|
|
470
757
|
const erbNodes = filterNodes([node], ERBContentNode);
|
|
471
758
|
if (erbNodes.length === 1 && isERBOutputNode(erbNodes[0]) && !options.forceQuotes) {
|
|
@@ -477,19 +764,18 @@ class ERBToRubyStringPrinter extends IdentityPrinter {
|
|
|
477
764
|
if (hasOnlyERBContent && childErbNodes.length === 1 && isERBOutputNode(childErbNodes[0]) && !options.forceQuotes) {
|
|
478
765
|
return (childErbNodes[0].content?.value || "").trim();
|
|
479
766
|
}
|
|
480
|
-
|
|
481
|
-
|
|
767
|
+
const firstChild = node.children[0];
|
|
768
|
+
if (node.children.length === 1 && isERBIfNode(firstChild) && !options.forceQuotes) {
|
|
482
769
|
const printer = new ERBToRubyStringPrinter();
|
|
483
|
-
if (printer.canConvertToTernary(
|
|
484
|
-
printer.convertToTernaryWithoutWrapper(
|
|
770
|
+
if (printer.canConvertToTernary(firstChild)) {
|
|
771
|
+
printer.convertToTernaryWithoutWrapper(firstChild);
|
|
485
772
|
return printer.context.getOutput();
|
|
486
773
|
}
|
|
487
774
|
}
|
|
488
|
-
if (node.children.length === 1 &&
|
|
489
|
-
const unlessNode = node.children[0];
|
|
775
|
+
if (node.children.length === 1 && isERBUnlessNode(firstChild) && !options.forceQuotes) {
|
|
490
776
|
const printer = new ERBToRubyStringPrinter();
|
|
491
|
-
if (printer.canConvertUnlessToTernary(
|
|
492
|
-
printer.convertUnlessToTernaryWithoutWrapper(
|
|
777
|
+
if (printer.canConvertUnlessToTernary(firstChild)) {
|
|
778
|
+
printer.convertUnlessToTernaryWithoutWrapper(firstChild);
|
|
493
779
|
return printer.context.getOutput();
|
|
494
780
|
}
|
|
495
781
|
}
|
|
@@ -529,15 +815,15 @@ class ERBToRubyStringPrinter extends IdentityPrinter {
|
|
|
529
815
|
this.visitChildNodes(node);
|
|
530
816
|
}
|
|
531
817
|
canConvertToTernary(node) {
|
|
532
|
-
if (node.subsequent && node.subsequent
|
|
818
|
+
if (node.subsequent && !isERBElseNode(node.subsequent)) {
|
|
533
819
|
return false;
|
|
534
820
|
}
|
|
535
|
-
const ifOnlyText = node.statements ? node.statements.every(
|
|
821
|
+
const ifOnlyText = node.statements ? node.statements.every(isHTMLTextNode) : true;
|
|
536
822
|
if (!ifOnlyText)
|
|
537
823
|
return false;
|
|
538
|
-
if (node.subsequent
|
|
824
|
+
if (isERBElseNode(node.subsequent)) {
|
|
539
825
|
return node.subsequent.statements
|
|
540
|
-
? node.subsequent.statements.every(
|
|
826
|
+
? node.subsequent.statements.every(isHTMLTextNode)
|
|
541
827
|
: true;
|
|
542
828
|
}
|
|
543
829
|
return true;
|
|
@@ -564,14 +850,14 @@ class ERBToRubyStringPrinter extends IdentityPrinter {
|
|
|
564
850
|
this.context.write('"');
|
|
565
851
|
this.context.write(" : ");
|
|
566
852
|
this.context.write('"');
|
|
567
|
-
if (node.subsequent && node.subsequent.
|
|
853
|
+
if (isERBElseNode(node.subsequent) && node.subsequent.statements) {
|
|
568
854
|
node.subsequent.statements.forEach(statement => this.visit(statement));
|
|
569
855
|
}
|
|
570
856
|
this.context.write('"');
|
|
571
857
|
this.context.write("}");
|
|
572
858
|
}
|
|
573
859
|
convertToTernaryWithoutWrapper(node) {
|
|
574
|
-
if (node.subsequent && node.subsequent
|
|
860
|
+
if (node.subsequent && !isERBElseNode(node.subsequent)) {
|
|
575
861
|
return false;
|
|
576
862
|
}
|
|
577
863
|
if (node.content?.value) {
|
|
@@ -594,18 +880,18 @@ class ERBToRubyStringPrinter extends IdentityPrinter {
|
|
|
594
880
|
this.context.write('"');
|
|
595
881
|
this.context.write(" : ");
|
|
596
882
|
this.context.write('"');
|
|
597
|
-
if (node.subsequent && node.subsequent.
|
|
883
|
+
if (isERBElseNode(node.subsequent) && node.subsequent.statements) {
|
|
598
884
|
node.subsequent.statements.forEach(statement => this.visit(statement));
|
|
599
885
|
}
|
|
600
886
|
this.context.write('"');
|
|
601
887
|
}
|
|
602
888
|
canConvertUnlessToTernary(node) {
|
|
603
|
-
const unlessOnlyText = node.statements ? node.statements.every(
|
|
889
|
+
const unlessOnlyText = node.statements ? node.statements.every(isHTMLTextNode) : true;
|
|
604
890
|
if (!unlessOnlyText)
|
|
605
891
|
return false;
|
|
606
|
-
if (node.else_clause
|
|
892
|
+
if (isERBElseNode(node.else_clause)) {
|
|
607
893
|
return node.else_clause.statements
|
|
608
|
-
? node.else_clause.statements.every(
|
|
894
|
+
? node.else_clause.statements.every(isHTMLTextNode)
|
|
609
895
|
: true;
|
|
610
896
|
}
|
|
611
897
|
return true;
|
|
@@ -634,7 +920,7 @@ class ERBToRubyStringPrinter extends IdentityPrinter {
|
|
|
634
920
|
this.context.write('"');
|
|
635
921
|
this.context.write(" : ");
|
|
636
922
|
this.context.write('"');
|
|
637
|
-
if (node.else_clause
|
|
923
|
+
if (isERBElseNode(node.else_clause)) {
|
|
638
924
|
node.else_clause.statements.forEach(statement => this.visit(statement));
|
|
639
925
|
}
|
|
640
926
|
this.context.write('"');
|
|
@@ -663,13 +949,16 @@ class ERBToRubyStringPrinter extends IdentityPrinter {
|
|
|
663
949
|
this.context.write('"');
|
|
664
950
|
this.context.write(" : ");
|
|
665
951
|
this.context.write('"');
|
|
666
|
-
if (node.else_clause
|
|
952
|
+
if (isERBElseNode(node.else_clause)) {
|
|
667
953
|
node.else_clause.statements.forEach(statement => this.visit(statement));
|
|
668
954
|
}
|
|
669
955
|
this.context.write('"');
|
|
670
956
|
}
|
|
671
957
|
}
|
|
672
958
|
|
|
959
|
+
const DEFAULT_LINTER_PARSER_OPTIONS = {
|
|
960
|
+
track_whitespace: true,
|
|
961
|
+
};
|
|
673
962
|
/**
|
|
674
963
|
* Default configuration for rules when defaultConfig is not specified.
|
|
675
964
|
* Custom rules can omit defaultConfig and will use these defaults.
|
|
@@ -684,26 +973,61 @@ const DEFAULT_RULE_CONFIG = {
|
|
|
684
973
|
*/
|
|
685
974
|
class ParserRule {
|
|
686
975
|
static type = "parser";
|
|
976
|
+
static ruleName;
|
|
687
977
|
/** Indicates whether this rule supports autofix. Defaults to false. */
|
|
688
978
|
static autocorrectable = false;
|
|
689
979
|
/** Indicates whether this rule supports unsafe autofix (requires --fix-unsafely). Defaults to false. */
|
|
690
980
|
static unsafeAutocorrectable = false;
|
|
981
|
+
/** Indicates whether the source should be re-indented after autofix. Defaults to false. */
|
|
982
|
+
static reindentAfterAutofix = false;
|
|
983
|
+
get ruleName() {
|
|
984
|
+
return this.constructor.ruleName;
|
|
985
|
+
}
|
|
691
986
|
get defaultConfig() {
|
|
692
987
|
return DEFAULT_RULE_CONFIG;
|
|
693
988
|
}
|
|
989
|
+
get parserOptions() {
|
|
990
|
+
return DEFAULT_LINTER_PARSER_OPTIONS;
|
|
991
|
+
}
|
|
992
|
+
createOffense(message, location, autofixContext, severity) {
|
|
993
|
+
return {
|
|
994
|
+
rule: this.ruleName,
|
|
995
|
+
code: this.ruleName,
|
|
996
|
+
source: "Herb Linter",
|
|
997
|
+
message,
|
|
998
|
+
location,
|
|
999
|
+
autofixContext,
|
|
1000
|
+
severity,
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
694
1003
|
}
|
|
695
1004
|
/**
|
|
696
1005
|
* Base class for lexer rules.
|
|
697
1006
|
*/
|
|
698
1007
|
class LexerRule {
|
|
699
1008
|
static type = "lexer";
|
|
1009
|
+
static ruleName;
|
|
700
1010
|
/** Indicates whether this rule supports autofix. Defaults to false. */
|
|
701
1011
|
static autocorrectable = false;
|
|
702
1012
|
/** Indicates whether this rule supports unsafe autofix (requires --fix-unsafely). Defaults to false. */
|
|
703
1013
|
static unsafeAutocorrectable = false;
|
|
1014
|
+
get ruleName() {
|
|
1015
|
+
return this.constructor.ruleName;
|
|
1016
|
+
}
|
|
704
1017
|
get defaultConfig() {
|
|
705
1018
|
return DEFAULT_RULE_CONFIG;
|
|
706
1019
|
}
|
|
1020
|
+
createOffense(message, location, autofixContext, severity) {
|
|
1021
|
+
return {
|
|
1022
|
+
rule: this.ruleName,
|
|
1023
|
+
code: this.ruleName,
|
|
1024
|
+
source: "Herb Linter",
|
|
1025
|
+
message,
|
|
1026
|
+
location,
|
|
1027
|
+
autofixContext,
|
|
1028
|
+
severity,
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
707
1031
|
}
|
|
708
1032
|
/**
|
|
709
1033
|
* Default context object with all keys defined but set to undefined
|
|
@@ -716,13 +1040,28 @@ const DEFAULT_LINT_CONTEXT = {
|
|
|
716
1040
|
};
|
|
717
1041
|
class SourceRule {
|
|
718
1042
|
static type = "source";
|
|
1043
|
+
static ruleName;
|
|
719
1044
|
/** Indicates whether this rule supports autofix. Defaults to false. */
|
|
720
1045
|
static autocorrectable = false;
|
|
721
1046
|
/** Indicates whether this rule supports unsafe autofix (requires --fix-unsafely). Defaults to false. */
|
|
722
1047
|
static unsafeAutocorrectable = false;
|
|
1048
|
+
get ruleName() {
|
|
1049
|
+
return this.constructor.ruleName;
|
|
1050
|
+
}
|
|
723
1051
|
get defaultConfig() {
|
|
724
1052
|
return DEFAULT_RULE_CONFIG;
|
|
725
1053
|
}
|
|
1054
|
+
createOffense(message, location, autofixContext, severity) {
|
|
1055
|
+
return {
|
|
1056
|
+
rule: this.ruleName,
|
|
1057
|
+
code: this.ruleName,
|
|
1058
|
+
source: "Herb Linter",
|
|
1059
|
+
message,
|
|
1060
|
+
location,
|
|
1061
|
+
autofixContext,
|
|
1062
|
+
severity,
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
726
1065
|
}
|
|
727
1066
|
|
|
728
1067
|
var ControlFlowType;
|
|
@@ -746,7 +1085,7 @@ class BaseRuleVisitor extends Visitor {
|
|
|
746
1085
|
* Helper method to create an unbound lint offense (without severity).
|
|
747
1086
|
* The Linter will bind severity based on the rule's config.
|
|
748
1087
|
*/
|
|
749
|
-
createOffense(message, location, autofixContext) {
|
|
1088
|
+
createOffense(message, location, autofixContext, severity) {
|
|
750
1089
|
return {
|
|
751
1090
|
rule: this.ruleName,
|
|
752
1091
|
code: this.ruleName,
|
|
@@ -754,13 +1093,14 @@ class BaseRuleVisitor extends Visitor {
|
|
|
754
1093
|
message,
|
|
755
1094
|
location,
|
|
756
1095
|
autofixContext,
|
|
1096
|
+
severity,
|
|
757
1097
|
};
|
|
758
1098
|
}
|
|
759
1099
|
/**
|
|
760
1100
|
* Helper method to add an offense to the offenses array
|
|
761
1101
|
*/
|
|
762
|
-
addOffense(message, location, autofixContext) {
|
|
763
|
-
this.offenses.push(this.createOffense(message, location, autofixContext));
|
|
1102
|
+
addOffense(message, location, autofixContext, severity) {
|
|
1103
|
+
this.offenses.push(this.createOffense(message, location, autofixContext, severity));
|
|
764
1104
|
}
|
|
765
1105
|
}
|
|
766
1106
|
/**
|
|
@@ -829,195 +1169,7 @@ class ControlFlowTrackingVisitor extends BaseRuleVisitor {
|
|
|
829
1169
|
}
|
|
830
1170
|
}
|
|
831
1171
|
/**
|
|
832
|
-
*
|
|
833
|
-
*/
|
|
834
|
-
function getAttributes(node) {
|
|
835
|
-
return node.children.filter(node => node.type === "AST_HTML_ATTRIBUTE_NODE");
|
|
836
|
-
}
|
|
837
|
-
/**
|
|
838
|
-
* Gets the tag name from an HTML tag node (lowercased)
|
|
839
|
-
*/
|
|
840
|
-
function getTagName(node) {
|
|
841
|
-
if (!node)
|
|
842
|
-
return null;
|
|
843
|
-
return node.tag_name?.value.toLowerCase() || null;
|
|
844
|
-
}
|
|
845
|
-
/**
|
|
846
|
-
* Gets the attribute name from an HTMLAttributeNode (lowercased)
|
|
847
|
-
* Returns null if the attribute name contains dynamic content (ERB)
|
|
848
|
-
*/
|
|
849
|
-
function getAttributeName(attributeNode, lowercase = true) {
|
|
850
|
-
if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
|
|
851
|
-
const nameNode = attributeNode.name;
|
|
852
|
-
const staticName = getStaticAttributeName(nameNode);
|
|
853
|
-
if (!lowercase)
|
|
854
|
-
return staticName;
|
|
855
|
-
return staticName ? staticName.toLowerCase() : null;
|
|
856
|
-
}
|
|
857
|
-
return null;
|
|
858
|
-
}
|
|
859
|
-
/**
|
|
860
|
-
* Checks if an attribute has a dynamic (ERB-containing) name
|
|
861
|
-
*/
|
|
862
|
-
function hasDynamicAttributeName(attributeNode) {
|
|
863
|
-
if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
|
|
864
|
-
const nameNode = attributeNode.name;
|
|
865
|
-
return hasDynamicAttributeName$1(nameNode);
|
|
866
|
-
}
|
|
867
|
-
return false;
|
|
868
|
-
}
|
|
869
|
-
/**
|
|
870
|
-
* Gets the combined string representation of an attribute name (for debugging)
|
|
871
|
-
* This includes both static content and ERB syntax
|
|
872
|
-
*/
|
|
873
|
-
function getCombinedAttributeNameString(attributeNode) {
|
|
874
|
-
if (attributeNode.name?.type === "AST_HTML_ATTRIBUTE_NAME_NODE") {
|
|
875
|
-
const nameNode = attributeNode.name;
|
|
876
|
-
return getCombinedAttributeName(nameNode);
|
|
877
|
-
}
|
|
878
|
-
return "";
|
|
879
|
-
}
|
|
880
|
-
/**
|
|
881
|
-
* Checks if an attribute value contains only static content (no ERB)
|
|
882
|
-
*/
|
|
883
|
-
function hasStaticAttributeValue(attributeNode) {
|
|
884
|
-
const valueNode = attributeNode.value;
|
|
885
|
-
if (!valueNode?.children)
|
|
886
|
-
return false;
|
|
887
|
-
return valueNode.children.every(child => child.type === "AST_LITERAL_NODE");
|
|
888
|
-
}
|
|
889
|
-
/**
|
|
890
|
-
* Checks if an attribute value contains dynamic content (ERB)
|
|
891
|
-
*/
|
|
892
|
-
function hasDynamicAttributeValue(attributeNode) {
|
|
893
|
-
const valueNode = attributeNode.value;
|
|
894
|
-
if (!valueNode?.children)
|
|
895
|
-
return false;
|
|
896
|
-
return valueNode.children.some(child => child.type === "AST_ERB_CONTENT_NODE");
|
|
897
|
-
}
|
|
898
|
-
/**
|
|
899
|
-
* Gets the static string value of an attribute (returns null if it contains ERB)
|
|
900
|
-
*/
|
|
901
|
-
function getStaticAttributeValue(attributeNode) {
|
|
902
|
-
if (!hasStaticAttributeValue(attributeNode))
|
|
903
|
-
return null;
|
|
904
|
-
const valueNode = attributeNode.value;
|
|
905
|
-
const result = valueNode.children
|
|
906
|
-
?.filter(child => child.type === "AST_LITERAL_NODE")
|
|
907
|
-
.map(child => child.content)
|
|
908
|
-
.join("") || "";
|
|
909
|
-
return result;
|
|
910
|
-
}
|
|
911
|
-
/**
|
|
912
|
-
* Gets the value nodes array for dynamic inspection
|
|
913
|
-
*/
|
|
914
|
-
function getAttributeValueNodes(attributeNode) {
|
|
915
|
-
const valueNode = attributeNode.value;
|
|
916
|
-
return valueNode?.children || [];
|
|
917
|
-
}
|
|
918
|
-
/**
|
|
919
|
-
* Checks if an attribute value contains any static content (for validation purposes)
|
|
920
|
-
*/
|
|
921
|
-
function hasStaticAttributeValueContent(attributeNode) {
|
|
922
|
-
const valueNodes = getAttributeValueNodes(attributeNode);
|
|
923
|
-
return hasStaticContent(valueNodes);
|
|
924
|
-
}
|
|
925
|
-
/**
|
|
926
|
-
* Gets the static content of an attribute value (all literal parts combined)
|
|
927
|
-
* Returns the concatenated literal content, or null if no literal nodes exist
|
|
928
|
-
*/
|
|
929
|
-
function getStaticAttributeValueContent(attributeNode) {
|
|
930
|
-
const valueNodes = getAttributeValueNodes(attributeNode);
|
|
931
|
-
return getStaticContentFromNodes(valueNodes);
|
|
932
|
-
}
|
|
933
|
-
/**
|
|
934
|
-
* Gets the attribute value content from an HTMLAttributeValueNode
|
|
935
|
-
*/
|
|
936
|
-
function getAttributeValue(attributeNode) {
|
|
937
|
-
const valueNode = attributeNode.value;
|
|
938
|
-
if (valueNode === null)
|
|
939
|
-
return null;
|
|
940
|
-
if (valueNode.type !== "AST_HTML_ATTRIBUTE_VALUE_NODE" || !valueNode.children?.length) {
|
|
941
|
-
return null;
|
|
942
|
-
}
|
|
943
|
-
let result = "";
|
|
944
|
-
for (const child of valueNode.children) {
|
|
945
|
-
switch (child.type) {
|
|
946
|
-
case "AST_ERB_CONTENT_NODE": {
|
|
947
|
-
const erbNode = child;
|
|
948
|
-
if (erbNode.content) {
|
|
949
|
-
result += `${erbNode.tag_opening?.value}${erbNode.content.value}${erbNode.tag_closing?.value}`;
|
|
950
|
-
}
|
|
951
|
-
break;
|
|
952
|
-
}
|
|
953
|
-
case "AST_LITERAL_NODE": {
|
|
954
|
-
result += child.content;
|
|
955
|
-
break;
|
|
956
|
-
}
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
return result;
|
|
960
|
-
}
|
|
961
|
-
/**
|
|
962
|
-
* Checks if an attribute has a value
|
|
963
|
-
*/
|
|
964
|
-
function hasAttributeValue(attributeNode) {
|
|
965
|
-
return attributeNode.value?.type === "AST_HTML_ATTRIBUTE_VALUE_NODE";
|
|
966
|
-
}
|
|
967
|
-
/**
|
|
968
|
-
* Gets the quote type used for an attribute value
|
|
969
|
-
*/
|
|
970
|
-
function getAttributeValueQuoteType(nodeOrAttribute) {
|
|
971
|
-
let valueNode;
|
|
972
|
-
if (nodeOrAttribute.type === "AST_HTML_ATTRIBUTE_NODE") {
|
|
973
|
-
const attributeNode = nodeOrAttribute;
|
|
974
|
-
if (attributeNode.value?.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
|
|
975
|
-
valueNode = attributeNode.value;
|
|
976
|
-
}
|
|
977
|
-
}
|
|
978
|
-
else if (nodeOrAttribute.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
|
|
979
|
-
valueNode = nodeOrAttribute;
|
|
980
|
-
}
|
|
981
|
-
if (valueNode) {
|
|
982
|
-
if (valueNode.quoted && valueNode.open_quote) {
|
|
983
|
-
return valueNode.open_quote.value === '"' ? "double" : "single";
|
|
984
|
-
}
|
|
985
|
-
return "none";
|
|
986
|
-
}
|
|
987
|
-
return null;
|
|
988
|
-
}
|
|
989
|
-
/**
|
|
990
|
-
* Finds an attribute by name in a list of attributes
|
|
991
|
-
*/
|
|
992
|
-
function findAttributeByName(attributes, attributeName) {
|
|
993
|
-
for (const child of attributes) {
|
|
994
|
-
if (child.type === "AST_HTML_ATTRIBUTE_NODE") {
|
|
995
|
-
const attributeNode = child;
|
|
996
|
-
const name = getAttributeName(attributeNode);
|
|
997
|
-
if (name === attributeName.toLowerCase()) {
|
|
998
|
-
return attributeNode;
|
|
999
|
-
}
|
|
1000
|
-
}
|
|
1001
|
-
}
|
|
1002
|
-
return null;
|
|
1003
|
-
}
|
|
1004
|
-
/**
|
|
1005
|
-
* Checks if a tag has a specific attribute
|
|
1006
|
-
*/
|
|
1007
|
-
function hasAttribute(node, attributeName) {
|
|
1008
|
-
if (!node)
|
|
1009
|
-
return false;
|
|
1010
|
-
return getAttribute(node, attributeName) !== null;
|
|
1011
|
-
}
|
|
1012
|
-
/**
|
|
1013
|
-
* Checks if a tag has a specific attribute
|
|
1014
|
-
*/
|
|
1015
|
-
function getAttribute(node, attributeName) {
|
|
1016
|
-
const attributes = getAttributes(node);
|
|
1017
|
-
return findAttributeByName(attributes, attributeName);
|
|
1018
|
-
}
|
|
1019
|
-
/**
|
|
1020
|
-
* Common HTML element categorization
|
|
1172
|
+
* Common HTML element categorization
|
|
1021
1173
|
*/
|
|
1022
1174
|
const HTML_INLINE_ELEMENTS = new Set([
|
|
1023
1175
|
"a", "abbr", "acronym", "b", "bdo", "big", "br", "button", "cite", "code",
|
|
@@ -1098,6 +1250,25 @@ const VALID_ARIA_ROLES = new Set([
|
|
|
1098
1250
|
"treegrid", "treeitem",
|
|
1099
1251
|
"log", "marquee"
|
|
1100
1252
|
]);
|
|
1253
|
+
/**
|
|
1254
|
+
* Abstract ARIA roles used to support the WAI-ARIA Roles Model.
|
|
1255
|
+
* Authors MUST NOT use abstract roles in content.
|
|
1256
|
+
* @see https://www.w3.org/TR/wai-aria-1.0/roles#abstract_roles
|
|
1257
|
+
*/
|
|
1258
|
+
const ABSTRACT_ARIA_ROLES = new Set([
|
|
1259
|
+
"command",
|
|
1260
|
+
"composite",
|
|
1261
|
+
"input",
|
|
1262
|
+
"landmark",
|
|
1263
|
+
"range",
|
|
1264
|
+
"roletype",
|
|
1265
|
+
"section",
|
|
1266
|
+
"sectionhead",
|
|
1267
|
+
"select",
|
|
1268
|
+
"structure",
|
|
1269
|
+
"widget",
|
|
1270
|
+
"window"
|
|
1271
|
+
]);
|
|
1101
1272
|
const ARIA_ATTRIBUTES = new Set([
|
|
1102
1273
|
'aria-activedescendant',
|
|
1103
1274
|
'aria-atomic',
|
|
@@ -1205,7 +1376,7 @@ class AttributeVisitorMixin extends BaseRuleVisitor {
|
|
|
1205
1376
|
forEachAttribute(node, (attributeNode) => {
|
|
1206
1377
|
const staticAttributeName = getAttributeName(attributeNode);
|
|
1207
1378
|
const originalAttributeName = getAttributeName(attributeNode, false) || "";
|
|
1208
|
-
const isDynamicName =
|
|
1379
|
+
const isDynamicName = hasDynamicAttributeNameOnAttribute(attributeNode);
|
|
1209
1380
|
const staticAttributeValue = getStaticAttributeValue(attributeNode);
|
|
1210
1381
|
const valueNodes = getAttributeValueNodes(attributeNode);
|
|
1211
1382
|
const hasOutputERB = hasERBOutput(valueNodes);
|
|
@@ -1267,27 +1438,6 @@ class AttributeVisitorMixin extends BaseRuleVisitor {
|
|
|
1267
1438
|
// Default implementation does nothing
|
|
1268
1439
|
}
|
|
1269
1440
|
}
|
|
1270
|
-
/**
|
|
1271
|
-
* Checks if an attribute value is quoted
|
|
1272
|
-
*/
|
|
1273
|
-
function isAttributeValueQuoted(attributeNode) {
|
|
1274
|
-
if (attributeNode.value?.type === "AST_HTML_ATTRIBUTE_VALUE_NODE") {
|
|
1275
|
-
const valueNode = attributeNode.value;
|
|
1276
|
-
return !!valueNode.quoted;
|
|
1277
|
-
}
|
|
1278
|
-
return false;
|
|
1279
|
-
}
|
|
1280
|
-
/**
|
|
1281
|
-
* Iterates over all attributes of a tag node, calling the callback for each attribute
|
|
1282
|
-
*/
|
|
1283
|
-
function forEachAttribute(node, callback) {
|
|
1284
|
-
const attributes = getAttributes(node);
|
|
1285
|
-
for (const child of attributes) {
|
|
1286
|
-
if (child.type === "AST_HTML_ATTRIBUTE_NODE") {
|
|
1287
|
-
callback(child);
|
|
1288
|
-
}
|
|
1289
|
-
}
|
|
1290
|
-
}
|
|
1291
1441
|
/**
|
|
1292
1442
|
* Base lexer visitor class that provides common functionality for lexer-based rule visitors
|
|
1293
1443
|
*/
|
|
@@ -1303,7 +1453,7 @@ class BaseLexerRuleVisitor {
|
|
|
1303
1453
|
* Helper method to create an unbound lint offense (without severity).
|
|
1304
1454
|
* The Linter will bind severity based on the rule's config.
|
|
1305
1455
|
*/
|
|
1306
|
-
createOffense(message, location, autofixContext) {
|
|
1456
|
+
createOffense(message, location, autofixContext, severity) {
|
|
1307
1457
|
return {
|
|
1308
1458
|
rule: this.ruleName,
|
|
1309
1459
|
code: this.ruleName,
|
|
@@ -1311,13 +1461,14 @@ class BaseLexerRuleVisitor {
|
|
|
1311
1461
|
message,
|
|
1312
1462
|
location,
|
|
1313
1463
|
autofixContext,
|
|
1464
|
+
severity,
|
|
1314
1465
|
};
|
|
1315
1466
|
}
|
|
1316
1467
|
/**
|
|
1317
1468
|
* Helper method to add an offense to the offenses array
|
|
1318
1469
|
*/
|
|
1319
|
-
addOffense(message, location, autofixContext) {
|
|
1320
|
-
this.offenses.push(this.createOffense(message, location, autofixContext));
|
|
1470
|
+
addOffense(message, location, autofixContext, severity) {
|
|
1471
|
+
this.offenses.push(this.createOffense(message, location, autofixContext, severity));
|
|
1321
1472
|
}
|
|
1322
1473
|
/**
|
|
1323
1474
|
* Main entry point for lexer rule visitors
|
|
@@ -1358,7 +1509,7 @@ class BaseSourceRuleVisitor {
|
|
|
1358
1509
|
* Helper method to create an unbound lint offense (without severity).
|
|
1359
1510
|
* The Linter will bind severity based on the rule's config.
|
|
1360
1511
|
*/
|
|
1361
|
-
createOffense(message, location, autofixContext) {
|
|
1512
|
+
createOffense(message, location, autofixContext, severity) {
|
|
1362
1513
|
return {
|
|
1363
1514
|
rule: this.ruleName,
|
|
1364
1515
|
code: this.ruleName,
|
|
@@ -1366,13 +1517,14 @@ class BaseSourceRuleVisitor {
|
|
|
1366
1517
|
message,
|
|
1367
1518
|
location,
|
|
1368
1519
|
autofixContext,
|
|
1520
|
+
severity,
|
|
1369
1521
|
};
|
|
1370
1522
|
}
|
|
1371
1523
|
/**
|
|
1372
1524
|
* Helper method to add an offense to the offenses array
|
|
1373
1525
|
*/
|
|
1374
|
-
addOffense(message, location, autofixContext) {
|
|
1375
|
-
this.offenses.push(this.createOffense(message, location, autofixContext));
|
|
1526
|
+
addOffense(message, location, autofixContext, severity) {
|
|
1527
|
+
this.offenses.push(this.createOffense(message, location, autofixContext, severity));
|
|
1376
1528
|
}
|
|
1377
1529
|
/**
|
|
1378
1530
|
* Main entry point for source rule visitors
|
|
@@ -1570,6 +1722,157 @@ function isHeadTag(tagName) {
|
|
|
1570
1722
|
!isHtmlOnlyTag(tag) &&
|
|
1571
1723
|
(isHeadOnlyTag(tag) || isHeadAndBodyTag(tag)));
|
|
1572
1724
|
}
|
|
1725
|
+
/**
|
|
1726
|
+
* Converts a character offset in a source string to a Position (line, column).
|
|
1727
|
+
* Lines are 1-based, columns are 0-based.
|
|
1728
|
+
*/
|
|
1729
|
+
function positionFromOffset(source, offset) {
|
|
1730
|
+
let line = 1;
|
|
1731
|
+
let column = 0;
|
|
1732
|
+
let currentOffset = 0;
|
|
1733
|
+
for (let i = 0; i < source.length && currentOffset < offset; i++) {
|
|
1734
|
+
const char = source[i];
|
|
1735
|
+
currentOffset++;
|
|
1736
|
+
if (char === "\n") {
|
|
1737
|
+
line++;
|
|
1738
|
+
column = 0;
|
|
1739
|
+
}
|
|
1740
|
+
else {
|
|
1741
|
+
column++;
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
return new Position(line, column);
|
|
1745
|
+
}
|
|
1746
|
+
/**
|
|
1747
|
+
* Creates a Location from a source string, a start offset, and a length.
|
|
1748
|
+
*/
|
|
1749
|
+
function locationFromOffset(source, startOffset, length) {
|
|
1750
|
+
const start = positionFromOffset(source, startOffset);
|
|
1751
|
+
const end = positionFromOffset(source, startOffset + length);
|
|
1752
|
+
return Location.from(start.line, start.column, end.line, end.column);
|
|
1753
|
+
}
|
|
1754
|
+
/**
|
|
1755
|
+
* Checks if a position (line, column) is within a node's location range.
|
|
1756
|
+
* @param node - The node to check
|
|
1757
|
+
* @param line - Line number (1-based)
|
|
1758
|
+
* @param column - Column number (0-based)
|
|
1759
|
+
* @returns true if the position is within the node's location
|
|
1760
|
+
*/
|
|
1761
|
+
function isPositionInNode(node, line, column) {
|
|
1762
|
+
if (!node.location)
|
|
1763
|
+
return false;
|
|
1764
|
+
const { start, end } = node.location;
|
|
1765
|
+
if (line < start.line)
|
|
1766
|
+
return false;
|
|
1767
|
+
if (line === start.line && column < start.column)
|
|
1768
|
+
return false;
|
|
1769
|
+
if (line > end.line)
|
|
1770
|
+
return false;
|
|
1771
|
+
if (line === end.line && column >= end.column)
|
|
1772
|
+
return false;
|
|
1773
|
+
return true;
|
|
1774
|
+
}
|
|
1775
|
+
/**
|
|
1776
|
+
* Finds a node in the AST that contains a specific position.
|
|
1777
|
+
* Returns the deepest (most specific) node that matches the position and optional predicate.
|
|
1778
|
+
*
|
|
1779
|
+
* @param root - The root node to search from
|
|
1780
|
+
* @param line - Line number (1-based)
|
|
1781
|
+
* @param column - Column number (0-based)
|
|
1782
|
+
* @param predicate - Optional predicate function to filter nodes
|
|
1783
|
+
* @returns The matching node or null if not found
|
|
1784
|
+
*/
|
|
1785
|
+
function findNodeAtPosition(root, line, column, predicate) {
|
|
1786
|
+
let bestMatch = null;
|
|
1787
|
+
const visited = new Set();
|
|
1788
|
+
function search(node) {
|
|
1789
|
+
if (!node || visited.has(node))
|
|
1790
|
+
return;
|
|
1791
|
+
visited.add(node);
|
|
1792
|
+
if (isPositionInNode(node, line, column)) {
|
|
1793
|
+
if (!predicate || predicate(node)) {
|
|
1794
|
+
if (!bestMatch || isMoreSpecific(node, bestMatch)) {
|
|
1795
|
+
bestMatch = node;
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
const nodeAny = node;
|
|
1800
|
+
if (typeof nodeAny.compactChildNodes === 'function') {
|
|
1801
|
+
for (const child of nodeAny.compactChildNodes()) {
|
|
1802
|
+
search(child);
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
else {
|
|
1806
|
+
if (nodeAny.children && Array.isArray(nodeAny.children)) {
|
|
1807
|
+
for (const child of nodeAny.children) {
|
|
1808
|
+
if (child)
|
|
1809
|
+
search(child);
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
if (nodeAny.body && Array.isArray(nodeAny.body)) {
|
|
1813
|
+
for (const child of nodeAny.body) {
|
|
1814
|
+
if (child)
|
|
1815
|
+
search(child);
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
function isMoreSpecific(nodeA, nodeB) {
|
|
1821
|
+
if (!nodeA.location || !nodeB.location)
|
|
1822
|
+
return false;
|
|
1823
|
+
const aStart = nodeA.location.start;
|
|
1824
|
+
const aEnd = nodeA.location.end;
|
|
1825
|
+
const bStart = nodeB.location.start;
|
|
1826
|
+
const bEnd = nodeB.location.end;
|
|
1827
|
+
const startsAtOrAfter = aStart.line > bStart.line || (aStart.line === bStart.line && aStart.column >= bStart.column);
|
|
1828
|
+
const endsAtOrBefore = aEnd.line < bEnd.line || (aEnd.line === bEnd.line && aEnd.column <= bEnd.column);
|
|
1829
|
+
return startsAtOrAfter && endsAtOrBefore;
|
|
1830
|
+
}
|
|
1831
|
+
search(root);
|
|
1832
|
+
return bestMatch;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
class ActionViewNoSilentHelperVisitor extends BaseRuleVisitor {
|
|
1836
|
+
visitHTMLElementNode(node) {
|
|
1837
|
+
this.checkActionViewHelper(node);
|
|
1838
|
+
super.visitHTMLElementNode(node);
|
|
1839
|
+
}
|
|
1840
|
+
checkActionViewHelper(node) {
|
|
1841
|
+
if (!node.element_source || node.element_source === "HTML")
|
|
1842
|
+
return;
|
|
1843
|
+
if (!isERBOpenTagNode(node.open_tag))
|
|
1844
|
+
return;
|
|
1845
|
+
if (isERBOutputNode(node.open_tag))
|
|
1846
|
+
return;
|
|
1847
|
+
const tagOpening = node.open_tag.tag_opening?.value;
|
|
1848
|
+
if (!tagOpening)
|
|
1849
|
+
return;
|
|
1850
|
+
const helperName = node.element_source.includes("#")
|
|
1851
|
+
? node.element_source.split("#").pop()
|
|
1852
|
+
: node.element_source;
|
|
1853
|
+
this.addOffense(`Avoid using \`${tagOpening} %>\` with \`${helperName}\`. Use \`<%= %>\` to ensure the helper's output is rendered.`, node.open_tag.location);
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
class ActionViewNoSilentHelperRule extends ParserRule {
|
|
1857
|
+
static ruleName = "actionview-no-silent-helper";
|
|
1858
|
+
get defaultConfig() {
|
|
1859
|
+
return {
|
|
1860
|
+
enabled: true,
|
|
1861
|
+
severity: "error"
|
|
1862
|
+
};
|
|
1863
|
+
}
|
|
1864
|
+
get parserOptions() {
|
|
1865
|
+
return {
|
|
1866
|
+
track_whitespace: true,
|
|
1867
|
+
action_view_helpers: true,
|
|
1868
|
+
};
|
|
1869
|
+
}
|
|
1870
|
+
check(result, context) {
|
|
1871
|
+
const visitor = new ActionViewNoSilentHelperVisitor(this.ruleName, context);
|
|
1872
|
+
visitor.visit(result.value);
|
|
1873
|
+
return visitor.offenses;
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1573
1876
|
|
|
1574
1877
|
class ERBCommentSyntaxVisitor extends BaseRuleVisitor {
|
|
1575
1878
|
visitERBContentNode(node) {
|
|
@@ -1587,7 +1890,7 @@ class ERBCommentSyntaxVisitor extends BaseRuleVisitor {
|
|
|
1587
1890
|
}
|
|
1588
1891
|
class ERBCommentSyntax extends ParserRule {
|
|
1589
1892
|
static autocorrectable = true;
|
|
1590
|
-
|
|
1893
|
+
static ruleName = "erb-comment-syntax";
|
|
1591
1894
|
get defaultConfig() {
|
|
1592
1895
|
return {
|
|
1593
1896
|
enabled: true,
|
|
@@ -1595,7 +1898,7 @@ class ERBCommentSyntax extends ParserRule {
|
|
|
1595
1898
|
};
|
|
1596
1899
|
}
|
|
1597
1900
|
check(result, context) {
|
|
1598
|
-
const visitor = new ERBCommentSyntaxVisitor(this.
|
|
1901
|
+
const visitor = new ERBCommentSyntaxVisitor(this.ruleName, context);
|
|
1599
1902
|
visitor.visit(result.value);
|
|
1600
1903
|
return visitor.offenses;
|
|
1601
1904
|
}
|
|
@@ -1651,7 +1954,7 @@ class ERBNoCaseNodeChildrenVisitor extends BaseRuleVisitor {
|
|
|
1651
1954
|
}
|
|
1652
1955
|
}
|
|
1653
1956
|
class ERBNoCaseNodeChildrenRule extends ParserRule {
|
|
1654
|
-
|
|
1957
|
+
static ruleName = "erb-no-case-node-children";
|
|
1655
1958
|
get defaultConfig() {
|
|
1656
1959
|
return {
|
|
1657
1960
|
enabled: true,
|
|
@@ -1659,27 +1962,132 @@ class ERBNoCaseNodeChildrenRule extends ParserRule {
|
|
|
1659
1962
|
};
|
|
1660
1963
|
}
|
|
1661
1964
|
check(result, context) {
|
|
1662
|
-
const visitor = new ERBNoCaseNodeChildrenVisitor(this.
|
|
1965
|
+
const visitor = new ERBNoCaseNodeChildrenVisitor(this.ruleName, context);
|
|
1663
1966
|
visitor.visit(result.value);
|
|
1664
1967
|
return visitor.offenses;
|
|
1665
1968
|
}
|
|
1666
1969
|
}
|
|
1667
1970
|
|
|
1668
|
-
|
|
1669
|
-
|
|
1971
|
+
function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
|
|
1972
|
+
function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
|
|
1973
|
+
function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
|
|
1974
|
+
function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return typeof key === "symbol" ? key : String(key); }
|
|
1975
|
+
function _toPrimitive(input, hint) { if (typeof input !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint); if (typeof res !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); }
|
|
1976
|
+
const dedent = createDedent({});
|
|
1977
|
+
function createDedent(options) {
|
|
1978
|
+
dedent.withOptions = newOptions => createDedent(_objectSpread(_objectSpread({}, options), newOptions));
|
|
1979
|
+
return dedent;
|
|
1980
|
+
function dedent(strings, ...values) {
|
|
1981
|
+
const raw = typeof strings === "string" ? [strings] : strings.raw;
|
|
1982
|
+
const {
|
|
1983
|
+
alignValues = false,
|
|
1984
|
+
escapeSpecialCharacters = Array.isArray(strings),
|
|
1985
|
+
trimWhitespace = true
|
|
1986
|
+
} = options;
|
|
1987
|
+
|
|
1988
|
+
// first, perform interpolation
|
|
1989
|
+
let result = "";
|
|
1990
|
+
for (let i = 0; i < raw.length; i++) {
|
|
1991
|
+
let next = raw[i];
|
|
1992
|
+
if (escapeSpecialCharacters) {
|
|
1993
|
+
// handle escaped newlines, backticks, and interpolation characters
|
|
1994
|
+
next = next.replace(/\\\n[ \t]*/g, "").replace(/\\`/g, "`").replace(/\\\$/g, "$").replace(/\\\{/g, "{");
|
|
1995
|
+
}
|
|
1996
|
+
result += next;
|
|
1997
|
+
if (i < values.length) {
|
|
1998
|
+
const value = alignValues ? alignValue(values[i], result) : values[i];
|
|
1999
|
+
|
|
2000
|
+
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
|
|
2001
|
+
result += value;
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
// now strip indentation
|
|
2006
|
+
const lines = result.split("\n");
|
|
2007
|
+
let mindent = null;
|
|
2008
|
+
for (const l of lines) {
|
|
2009
|
+
const m = l.match(/^(\s+)\S+/);
|
|
2010
|
+
if (m) {
|
|
2011
|
+
const indent = m[1].length;
|
|
2012
|
+
if (!mindent) {
|
|
2013
|
+
// this is the first indented line
|
|
2014
|
+
mindent = indent;
|
|
2015
|
+
} else {
|
|
2016
|
+
mindent = Math.min(mindent, indent);
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
if (mindent !== null) {
|
|
2021
|
+
const m = mindent; // appease TypeScript
|
|
2022
|
+
result = lines
|
|
2023
|
+
// https://github.com/typescript-eslint/typescript-eslint/issues/7140
|
|
2024
|
+
// eslint-disable-next-line @typescript-eslint/prefer-string-starts-ends-with
|
|
2025
|
+
.map(l => l[0] === " " || l[0] === "\t" ? l.slice(m) : l).join("\n");
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
// dedent eats leading and trailing whitespace too
|
|
2029
|
+
if (trimWhitespace) {
|
|
2030
|
+
result = result.trim();
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
// handle escaped newlines at the end to ensure they don't get stripped too
|
|
2034
|
+
if (escapeSpecialCharacters) {
|
|
2035
|
+
result = result.replace(/\\n/g, "\n");
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
// Workaround for Bun issue with Unicode characters
|
|
2039
|
+
// https://github.com/oven-sh/bun/issues/8745
|
|
2040
|
+
if (typeof Bun !== "undefined") {
|
|
2041
|
+
result = result.replace(
|
|
2042
|
+
// Matches e.g. \\u{1f60a} or \\u5F1F
|
|
2043
|
+
/\\u(?:\{([\da-fA-F]{1,6})\}|([\da-fA-F]{4}))/g, (_, braced, unbraced) => {
|
|
2044
|
+
var _ref;
|
|
2045
|
+
const hex = (_ref = braced !== null && braced !== void 0 ? braced : unbraced) !== null && _ref !== void 0 ? _ref : "";
|
|
2046
|
+
return String.fromCodePoint(parseInt(hex, 16));
|
|
2047
|
+
});
|
|
2048
|
+
}
|
|
2049
|
+
return result;
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
/**
|
|
2054
|
+
* Adjusts the indentation of a multi-line interpolated value to match the current line.
|
|
2055
|
+
*/
|
|
2056
|
+
function alignValue(value, precedingText) {
|
|
2057
|
+
if (typeof value !== "string" || !value.includes("\n")) {
|
|
2058
|
+
return value;
|
|
2059
|
+
}
|
|
2060
|
+
const currentLine = precedingText.slice(precedingText.lastIndexOf("\n") + 1);
|
|
2061
|
+
const indentMatch = currentLine.match(/^(\s+)/);
|
|
2062
|
+
if (indentMatch) {
|
|
2063
|
+
const indent = indentMatch[1];
|
|
2064
|
+
return value.replace(/\n/g, `\n${indent}`);
|
|
2065
|
+
}
|
|
2066
|
+
return value;
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
class ERBNoConditionalHTMLElementRuleVisitor extends BaseRuleVisitor {
|
|
2070
|
+
visitHTMLConditionalElementNode(node) {
|
|
2071
|
+
const tagName = node.tag_name?.value || "element";
|
|
2072
|
+
const condition = node.condition || "condition";
|
|
2073
|
+
const suggestion = dedent `
|
|
2074
|
+
Consider using a \`capture\` block instead:
|
|
2075
|
+
|
|
2076
|
+
<% content = capture do %>
|
|
2077
|
+
... your content here ...
|
|
2078
|
+
<% end %>
|
|
2079
|
+
|
|
2080
|
+
<%= ${condition} ? content_tag(:${tagName}, content) : content %>
|
|
2081
|
+
`;
|
|
2082
|
+
this.addOffense(dedent `
|
|
2083
|
+
Avoid opening and closing \`<${tagName}>\` tags in separate conditional blocks with the same condition. \
|
|
2084
|
+
This pattern is difficult to read and maintain. ${suggestion}
|
|
2085
|
+
`, node.location);
|
|
1670
2086
|
this.visitChildNodes(node);
|
|
1671
|
-
const { content, tag_closing } = node;
|
|
1672
|
-
if (!content)
|
|
1673
|
-
return;
|
|
1674
|
-
if (tag_closing?.value === "")
|
|
1675
|
-
return;
|
|
1676
|
-
if (content.value.trim().length > 0)
|
|
1677
|
-
return;
|
|
1678
|
-
this.addOffense("ERB tag should not be empty. Remove empty ERB tags or add content.", node.location);
|
|
1679
2087
|
}
|
|
1680
2088
|
}
|
|
1681
|
-
class
|
|
1682
|
-
|
|
2089
|
+
class ERBNoConditionalHTMLElementRule extends ParserRule {
|
|
2090
|
+
static ruleName = "erb-no-conditional-html-element";
|
|
1683
2091
|
get defaultConfig() {
|
|
1684
2092
|
return {
|
|
1685
2093
|
enabled: true,
|
|
@@ -1687,37 +2095,311 @@ class ERBNoEmptyTagsRule extends ParserRule {
|
|
|
1687
2095
|
};
|
|
1688
2096
|
}
|
|
1689
2097
|
check(result, context) {
|
|
1690
|
-
const visitor = new
|
|
2098
|
+
const visitor = new ERBNoConditionalHTMLElementRuleVisitor(this.ruleName, context);
|
|
1691
2099
|
visitor.visit(result.value);
|
|
1692
2100
|
return visitor.offenses;
|
|
1693
2101
|
}
|
|
1694
2102
|
}
|
|
1695
2103
|
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
2104
|
+
class ERBNoConditionalOpenTagRuleVisitor extends BaseRuleVisitor {
|
|
2105
|
+
visitHTMLConditionalOpenTagNode(node) {
|
|
2106
|
+
const tagName = node.tag_name?.value || "element";
|
|
2107
|
+
this.addOffense(`Avoid using ERB conditionals to split the open and closing tag of \`<${tagName}>\` element.`, node.location);
|
|
2108
|
+
this.visitChildNodes(node);
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
class ERBNoConditionalOpenTagRule extends ParserRule {
|
|
2112
|
+
static ruleName = "erb-no-conditional-open-tag";
|
|
2113
|
+
get defaultConfig() {
|
|
2114
|
+
return {
|
|
2115
|
+
enabled: true,
|
|
2116
|
+
severity: "error"
|
|
2117
|
+
};
|
|
2118
|
+
}
|
|
2119
|
+
check(result, context) {
|
|
2120
|
+
const visitor = new ERBNoConditionalOpenTagRuleVisitor(this.ruleName, context);
|
|
2121
|
+
visitor.visit(result.value);
|
|
2122
|
+
return visitor.offenses;
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
function getSignificantNodes(statements) {
|
|
2127
|
+
return statements.filter(node => !isPureWhitespaceNode(node));
|
|
2128
|
+
}
|
|
2129
|
+
function allEquivalentElements(nodes) {
|
|
2130
|
+
if (nodes.length < 2)
|
|
2131
|
+
return false;
|
|
2132
|
+
if (!nodes.every(node => isHTMLElementNode(node)))
|
|
2133
|
+
return false;
|
|
2134
|
+
const first = nodes[0];
|
|
2135
|
+
return nodes.slice(1).every(node => isEquivalentElement(first, node));
|
|
2136
|
+
}
|
|
2137
|
+
function collectBranchesFromIf(node) {
|
|
2138
|
+
const branches = [];
|
|
2139
|
+
let current = node.subsequent;
|
|
2140
|
+
branches.push(node.statements);
|
|
2141
|
+
while (current) {
|
|
2142
|
+
if (isERBElseNode(current)) {
|
|
2143
|
+
branches.push(current.statements);
|
|
2144
|
+
return branches;
|
|
2145
|
+
}
|
|
2146
|
+
if (isERBIfNode(current)) {
|
|
2147
|
+
branches.push(current.statements);
|
|
2148
|
+
current = current.subsequent;
|
|
1706
2149
|
}
|
|
1707
2150
|
else {
|
|
1708
|
-
|
|
2151
|
+
break;
|
|
1709
2152
|
}
|
|
1710
2153
|
}
|
|
1711
|
-
return
|
|
2154
|
+
return null;
|
|
1712
2155
|
}
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
2156
|
+
function collectBranchesFromUnless(node) {
|
|
2157
|
+
if (!node.else_clause)
|
|
2158
|
+
return null;
|
|
2159
|
+
return [node.statements, node.else_clause.statements];
|
|
2160
|
+
}
|
|
2161
|
+
function collectBranchesFromCase(node) {
|
|
2162
|
+
if (!node.else_clause)
|
|
2163
|
+
return null;
|
|
2164
|
+
const branches = [];
|
|
2165
|
+
for (const condition of node.conditions) {
|
|
2166
|
+
if (isERBWhenNode(condition)) {
|
|
2167
|
+
branches.push(condition.statements);
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
branches.push(node.else_clause.statements);
|
|
2171
|
+
return branches;
|
|
2172
|
+
}
|
|
2173
|
+
function collectBranches(node) {
|
|
2174
|
+
if (isERBIfNode(node))
|
|
2175
|
+
return collectBranchesFromIf(node);
|
|
2176
|
+
if (isERBUnlessNode(node))
|
|
2177
|
+
return collectBranchesFromUnless(node);
|
|
2178
|
+
if (isERBCaseNode(node))
|
|
2179
|
+
return collectBranchesFromCase(node);
|
|
2180
|
+
return null;
|
|
2181
|
+
}
|
|
2182
|
+
function findCommonPrefixCount(branches, minLength) {
|
|
2183
|
+
let count = 0;
|
|
2184
|
+
for (let index = 0; index < minLength; index++) {
|
|
2185
|
+
const nodesAtIndex = branches.map(branch => branch[index]);
|
|
2186
|
+
if (allEquivalentElements(nodesAtIndex)) {
|
|
2187
|
+
count++;
|
|
2188
|
+
}
|
|
2189
|
+
else {
|
|
2190
|
+
break;
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
return count;
|
|
2194
|
+
}
|
|
2195
|
+
function findCommonSuffixCount(branches, minLength, prefixCount) {
|
|
2196
|
+
let count = 0;
|
|
2197
|
+
for (let offset = 0; offset < minLength - prefixCount; offset++) {
|
|
2198
|
+
const nodesAtOffset = branches.map(branch => branch[branch.length - 1 - offset]);
|
|
2199
|
+
if (allEquivalentElements(nodesAtOffset)) {
|
|
2200
|
+
count++;
|
|
2201
|
+
}
|
|
2202
|
+
else {
|
|
2203
|
+
break;
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
return count;
|
|
2207
|
+
}
|
|
2208
|
+
function createWrapper(template, body) {
|
|
2209
|
+
return new HTMLElementNode({
|
|
2210
|
+
type: "AST_HTML_ELEMENT_NODE",
|
|
2211
|
+
open_tag: template.open_tag,
|
|
2212
|
+
tag_name: template.tag_name,
|
|
2213
|
+
body,
|
|
2214
|
+
close_tag: template.close_tag,
|
|
2215
|
+
is_void: template.is_void,
|
|
2216
|
+
element_source: template.element_source,
|
|
2217
|
+
location: Location.zero,
|
|
2218
|
+
errors: [],
|
|
2219
|
+
});
|
|
2220
|
+
}
|
|
2221
|
+
class ERBNoDuplicateBranchElementsVisitor extends BaseRuleVisitor {
|
|
2222
|
+
processedIfNodes = new Set();
|
|
2223
|
+
visitERBIfNode(node) {
|
|
2224
|
+
if (this.processedIfNodes.has(node)) {
|
|
2225
|
+
this.visitChildNodes(node);
|
|
1716
2226
|
return;
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
2227
|
+
}
|
|
2228
|
+
this.checkConditionalNode(node);
|
|
2229
|
+
this.visitChildNodes(node);
|
|
2230
|
+
}
|
|
2231
|
+
visitERBUnlessNode(node) {
|
|
2232
|
+
this.checkConditionalNode(node);
|
|
2233
|
+
this.visitChildNodes(node);
|
|
2234
|
+
}
|
|
2235
|
+
visitERBCaseNode(node) {
|
|
2236
|
+
this.checkConditionalNode(node);
|
|
2237
|
+
this.visitChildNodes(node);
|
|
2238
|
+
}
|
|
2239
|
+
checkConditionalNode(node) {
|
|
2240
|
+
const branches = collectBranches(node);
|
|
2241
|
+
if (!branches)
|
|
2242
|
+
return;
|
|
2243
|
+
if (isERBIfNode(node)) {
|
|
2244
|
+
this.markSubsequentIfNodesAsProcessed(node);
|
|
2245
|
+
}
|
|
2246
|
+
const state = { isFirstOffense: true };
|
|
2247
|
+
this.checkBranches(branches, node, state);
|
|
2248
|
+
}
|
|
2249
|
+
markSubsequentIfNodesAsProcessed(node) {
|
|
2250
|
+
let current = node.subsequent;
|
|
2251
|
+
while (current) {
|
|
2252
|
+
if (isERBIfNode(current)) {
|
|
2253
|
+
this.processedIfNodes.add(current);
|
|
2254
|
+
current = current.subsequent;
|
|
2255
|
+
}
|
|
2256
|
+
else {
|
|
2257
|
+
break;
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
checkBranches(branches, conditionalNode, state) {
|
|
2262
|
+
const significantBranches = branches.map(getSignificantNodes);
|
|
2263
|
+
if (significantBranches.some(branch => branch.length === 0))
|
|
2264
|
+
return;
|
|
2265
|
+
const minLength = Math.min(...significantBranches.map(branch => branch.length));
|
|
2266
|
+
const prefixCount = findCommonPrefixCount(significantBranches, minLength);
|
|
2267
|
+
const suffixCount = findCommonSuffixCount(significantBranches, minLength, prefixCount);
|
|
2268
|
+
for (let index = 0; index < prefixCount; index++) {
|
|
2269
|
+
const elements = significantBranches.map(branch => branch[index]);
|
|
2270
|
+
this.reportAndRecurse(elements, conditionalNode, state);
|
|
2271
|
+
}
|
|
2272
|
+
for (let offset = 0; offset < suffixCount; offset++) {
|
|
2273
|
+
const elements = significantBranches.map(branch => branch[branch.length - 1 - offset]);
|
|
2274
|
+
this.reportAndRecurse(elements, conditionalNode, state);
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
reportAndRecurse(elements, conditionalNode, state) {
|
|
2278
|
+
const bodies = elements.map(element => element.body);
|
|
2279
|
+
const bodiesMatch = elements.every(element => IdentityPrinter.print(element) === IdentityPrinter.print(elements[0]));
|
|
2280
|
+
for (const element of elements) {
|
|
2281
|
+
const printed = IdentityPrinter.print(element.open_tag);
|
|
2282
|
+
const autofixContext = state.isFirstOffense
|
|
2283
|
+
? { node: conditionalNode }
|
|
2284
|
+
: undefined;
|
|
2285
|
+
this.addOffense(`The \`${printed}\` element is duplicated across all branches of this conditional and can be moved outside.`, bodiesMatch ? element.location : (element?.open_tag?.location || element.location), autofixContext);
|
|
2286
|
+
state.isFirstOffense = false;
|
|
2287
|
+
}
|
|
2288
|
+
if (!bodiesMatch && bodies.every(body => body.length > 0)) {
|
|
2289
|
+
this.checkBranches(bodies, conditionalNode, state);
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
class ERBNoDuplicateBranchElementsRule extends ParserRule {
|
|
2294
|
+
static ruleName = "erb-no-duplicate-branch-elements";
|
|
2295
|
+
static autocorrectable = true;
|
|
2296
|
+
static reindentAfterAutofix = true;
|
|
2297
|
+
get defaultConfig() {
|
|
2298
|
+
return {
|
|
2299
|
+
enabled: true,
|
|
2300
|
+
severity: "warning",
|
|
2301
|
+
};
|
|
2302
|
+
}
|
|
2303
|
+
check(result, context) {
|
|
2304
|
+
const visitor = new ERBNoDuplicateBranchElementsVisitor(this.ruleName, context);
|
|
2305
|
+
visitor.visit(result.value);
|
|
2306
|
+
return visitor.offenses;
|
|
2307
|
+
}
|
|
2308
|
+
autofix(offense, result) {
|
|
2309
|
+
if (!offense.autofixContext)
|
|
2310
|
+
return null;
|
|
2311
|
+
const conditionalNode = offense.autofixContext.node;
|
|
2312
|
+
const branches = collectBranches(conditionalNode);
|
|
2313
|
+
if (!branches)
|
|
2314
|
+
return null;
|
|
2315
|
+
const significantBranches = branches.map(getSignificantNodes);
|
|
2316
|
+
if (significantBranches.some(branch => branch.length === 0))
|
|
2317
|
+
return null;
|
|
2318
|
+
const minLength = Math.min(...significantBranches.map(branch => branch.length));
|
|
2319
|
+
const prefixCount = findCommonPrefixCount(significantBranches, minLength);
|
|
2320
|
+
const suffixCount = findCommonSuffixCount(significantBranches, minLength, prefixCount);
|
|
2321
|
+
if (prefixCount === 0 && suffixCount === 0)
|
|
2322
|
+
return null;
|
|
2323
|
+
const parentInfo = findParentArray(result.value, conditionalNode);
|
|
2324
|
+
if (!parentInfo)
|
|
2325
|
+
return null;
|
|
2326
|
+
let { array: parentArray, index: conditionalIndex } = parentInfo;
|
|
2327
|
+
let hasWrapped = false;
|
|
2328
|
+
const hoistElement = (elements, position) => {
|
|
2329
|
+
const bodiesMatch = elements.every(element => IdentityPrinter.print(element) === IdentityPrinter.print(elements[0]));
|
|
2330
|
+
if (bodiesMatch) {
|
|
2331
|
+
for (let i = 0; i < branches.length; i++) {
|
|
2332
|
+
removeNodeFromArray(branches[i], elements[i]);
|
|
2333
|
+
}
|
|
2334
|
+
if (position === "before") {
|
|
2335
|
+
parentArray.splice(conditionalIndex, 0, elements[0]);
|
|
2336
|
+
conditionalIndex++;
|
|
2337
|
+
}
|
|
2338
|
+
else {
|
|
2339
|
+
parentArray.splice(conditionalIndex + 1, 0, elements[0]);
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
else {
|
|
2343
|
+
if (hasWrapped)
|
|
2344
|
+
return;
|
|
2345
|
+
for (let i = 0; i < branches.length; i++) {
|
|
2346
|
+
replaceNodeWithBody(branches[i], elements[i]);
|
|
2347
|
+
}
|
|
2348
|
+
const wrapper = createWrapper(elements[0], [createLiteral("\n"), conditionalNode, createLiteral("\n")]);
|
|
2349
|
+
parentArray[conditionalIndex] = wrapper;
|
|
2350
|
+
parentArray = wrapper.body;
|
|
2351
|
+
conditionalIndex = 1;
|
|
2352
|
+
hasWrapped = true;
|
|
2353
|
+
}
|
|
2354
|
+
};
|
|
2355
|
+
for (let index = 0; index < prefixCount; index++) {
|
|
2356
|
+
const elements = significantBranches.map(branch => branch[index]);
|
|
2357
|
+
hoistElement(elements, "before");
|
|
2358
|
+
}
|
|
2359
|
+
for (let offset = 0; offset < suffixCount; offset++) {
|
|
2360
|
+
const elements = significantBranches.map(branch => branch[branch.length - 1 - offset]);
|
|
2361
|
+
hoistElement(elements, "after");
|
|
2362
|
+
}
|
|
2363
|
+
return result;
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
class ERBNoEmptyTagsVisitor extends BaseRuleVisitor {
|
|
2368
|
+
visitERBContentNode(node) {
|
|
2369
|
+
this.visitChildNodes(node);
|
|
2370
|
+
const { content, tag_closing } = node;
|
|
2371
|
+
if (!content)
|
|
2372
|
+
return;
|
|
2373
|
+
if (tag_closing?.value === "")
|
|
2374
|
+
return;
|
|
2375
|
+
if (content.value.trim().length > 0)
|
|
2376
|
+
return;
|
|
2377
|
+
this.addOffense("ERB tag should not be empty. Remove empty ERB tags or add content.", node.location);
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
class ERBNoEmptyTagsRule extends ParserRule {
|
|
2381
|
+
static ruleName = "erb-no-empty-tags";
|
|
2382
|
+
get defaultConfig() {
|
|
2383
|
+
return {
|
|
2384
|
+
enabled: true,
|
|
2385
|
+
severity: "error"
|
|
2386
|
+
};
|
|
2387
|
+
}
|
|
2388
|
+
check(result, context) {
|
|
2389
|
+
const visitor = new ERBNoEmptyTagsVisitor(this.ruleName, context);
|
|
2390
|
+
visitor.visit(result.value);
|
|
2391
|
+
return visitor.offenses;
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
class ERBNoExtraNewLineVisitor extends BaseSourceRuleVisitor {
|
|
2396
|
+
visitSource(source) {
|
|
2397
|
+
if (source.length === 0)
|
|
2398
|
+
return;
|
|
2399
|
+
const regex = /\n{4,}/g;
|
|
2400
|
+
let match;
|
|
2401
|
+
while ((match = regex.exec(source)) !== null) {
|
|
2402
|
+
const startOffset = match.index + 3;
|
|
1721
2403
|
const endOffset = match.index + match[0].length;
|
|
1722
2404
|
const start = positionFromOffset(source, startOffset);
|
|
1723
2405
|
const end = positionFromOffset(source, endOffset);
|
|
@@ -1731,161 +2413,820 @@ class ERBNoExtraNewLineVisitor extends BaseSourceRuleVisitor {
|
|
|
1731
2413
|
}
|
|
1732
2414
|
}
|
|
1733
2415
|
}
|
|
1734
|
-
class ERBNoExtraNewLineRule extends SourceRule {
|
|
1735
|
-
static autocorrectable = true;
|
|
1736
|
-
|
|
2416
|
+
class ERBNoExtraNewLineRule extends SourceRule {
|
|
2417
|
+
static autocorrectable = true;
|
|
2418
|
+
static ruleName = "erb-no-extra-newline";
|
|
2419
|
+
get defaultConfig() {
|
|
2420
|
+
return {
|
|
2421
|
+
enabled: true,
|
|
2422
|
+
severity: "error"
|
|
2423
|
+
};
|
|
2424
|
+
}
|
|
2425
|
+
check(source, context) {
|
|
2426
|
+
const visitor = new ERBNoExtraNewLineVisitor(this.ruleName, context);
|
|
2427
|
+
visitor.visit(source);
|
|
2428
|
+
return visitor.offenses;
|
|
2429
|
+
}
|
|
2430
|
+
autofix(offense, source, _context) {
|
|
2431
|
+
if (!offense.autofixContext)
|
|
2432
|
+
return null;
|
|
2433
|
+
const { startOffset, endOffset } = offense.autofixContext;
|
|
2434
|
+
const before = source.substring(0, startOffset);
|
|
2435
|
+
const after = source.substring(endOffset);
|
|
2436
|
+
return before + after;
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
class ERBNoExtraWhitespaceInsideTagsVisitor extends BaseRuleVisitor {
|
|
2441
|
+
visitERBNode(node) {
|
|
2442
|
+
const openTag = node.tag_opening;
|
|
2443
|
+
const closeTag = node.tag_closing;
|
|
2444
|
+
const { value } = node.content ?? {};
|
|
2445
|
+
if (!openTag || !closeTag || !value)
|
|
2446
|
+
return;
|
|
2447
|
+
if (this.hasExtraLeadingWhitespace(value)) {
|
|
2448
|
+
this.reportWhitespace(node, openTag, closeTag, value, "start", 0, `Remove extra whitespace after \`${openTag.value}\`.`, "after-open");
|
|
2449
|
+
}
|
|
2450
|
+
if (openTag.value === "<%#") {
|
|
2451
|
+
const prefix = this.getCommentedTagPrefix(value);
|
|
2452
|
+
if (prefix) {
|
|
2453
|
+
const afterPrefix = value.substring(prefix.length);
|
|
2454
|
+
const tag = `<%#${prefix}`;
|
|
2455
|
+
const hasExtraWhitespace = afterPrefix.match(/^\s{2,}/) && !afterPrefix.startsWith(" \n") && !afterPrefix.startsWith("\n");
|
|
2456
|
+
if (hasExtraWhitespace) {
|
|
2457
|
+
this.reportWhitespace(node, openTag, closeTag, value, "start", prefix.length, `Remove extra whitespace after \`${tag}\`. This looks like a temporarily commented ERB tag.`, "after-comment-equals", "info");
|
|
2458
|
+
}
|
|
2459
|
+
else {
|
|
2460
|
+
this.addOffense(`\`${tag}\` looks like a temporarily commented ERB tag.`, openTag.location, { node, openTag, closeTag, content: value, fixType: "after-comment-equals", unsafe: true }, "info");
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
if (this.hasExtraTrailingWhitespace(value)) {
|
|
2465
|
+
this.reportWhitespace(node, openTag, closeTag, value, "end", 0, `Remove extra whitespace before \`${closeTag.value}\`.`, "before-close");
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
getCommentedTagPrefix(content) {
|
|
2469
|
+
if (content.startsWith("graphql"))
|
|
2470
|
+
return "graphql";
|
|
2471
|
+
if (content.startsWith("%="))
|
|
2472
|
+
return "%=";
|
|
2473
|
+
if (content.startsWith("=="))
|
|
2474
|
+
return "==";
|
|
2475
|
+
if (content.startsWith("%"))
|
|
2476
|
+
return "%";
|
|
2477
|
+
if (content.startsWith("="))
|
|
2478
|
+
return "=";
|
|
2479
|
+
if (content.startsWith("-"))
|
|
2480
|
+
return "-";
|
|
2481
|
+
return null;
|
|
2482
|
+
}
|
|
2483
|
+
hasExtraLeadingWhitespace(content) {
|
|
2484
|
+
return content.startsWith(" ") && !content.startsWith(" \n");
|
|
2485
|
+
}
|
|
2486
|
+
hasExtraTrailingWhitespace(content) {
|
|
2487
|
+
return !content.includes("\n") && /\s{2,}$/.test(content);
|
|
2488
|
+
}
|
|
2489
|
+
getWhitespaceLocation(node, content, position, offset = 0) {
|
|
2490
|
+
const contentLocation = node.content.location;
|
|
2491
|
+
if (position === "start") {
|
|
2492
|
+
const match = content.substring(offset).match(/^\s+/);
|
|
2493
|
+
const length = match ? match[0].length : 0;
|
|
2494
|
+
const startColumn = contentLocation.start.column + offset;
|
|
2495
|
+
return Location.from(contentLocation.start.line, startColumn, contentLocation.start.line, startColumn + length);
|
|
2496
|
+
}
|
|
2497
|
+
else {
|
|
2498
|
+
const match = content.match(/\s+$/);
|
|
2499
|
+
const length = match ? match[0].length : 0;
|
|
2500
|
+
return Location.from(contentLocation.end.line, contentLocation.end.column - length, contentLocation.end.line, contentLocation.end.column);
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
reportWhitespace(node, openTag, closeTag, content, position, offset, message, fixType, severity, unsafe) {
|
|
2504
|
+
const location = this.getWhitespaceLocation(node, content, position, offset);
|
|
2505
|
+
this.addOffense(message, location, {
|
|
2506
|
+
node,
|
|
2507
|
+
openTag,
|
|
2508
|
+
closeTag,
|
|
2509
|
+
content,
|
|
2510
|
+
fixType,
|
|
2511
|
+
unsafe,
|
|
2512
|
+
}, severity);
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
class ERBNoExtraWhitespaceRule extends ParserRule {
|
|
2516
|
+
static autocorrectable = true;
|
|
2517
|
+
static ruleName = "erb-no-extra-whitespace-inside-tags";
|
|
2518
|
+
get defaultConfig() {
|
|
2519
|
+
return {
|
|
2520
|
+
enabled: true,
|
|
2521
|
+
severity: "error"
|
|
2522
|
+
};
|
|
2523
|
+
}
|
|
2524
|
+
check(result, context) {
|
|
2525
|
+
const visitor = new ERBNoExtraWhitespaceInsideTagsVisitor(this.ruleName, context);
|
|
2526
|
+
visitor.visit(result.value);
|
|
2527
|
+
return visitor.offenses;
|
|
2528
|
+
}
|
|
2529
|
+
autofix(offense, result, _context) {
|
|
2530
|
+
if (!offense.autofixContext)
|
|
2531
|
+
return null;
|
|
2532
|
+
const { node, fixType } = offense.autofixContext;
|
|
2533
|
+
if (!node.content)
|
|
2534
|
+
return null;
|
|
2535
|
+
const content = node.content.value;
|
|
2536
|
+
switch (fixType) {
|
|
2537
|
+
case "before-close":
|
|
2538
|
+
node.content.value = content.replace(/\s{2,}$/, " ");
|
|
2539
|
+
break;
|
|
2540
|
+
case "after-open":
|
|
2541
|
+
node.content.value = content.replace(/^\s{2,}/, " ");
|
|
2542
|
+
break;
|
|
2543
|
+
case "after-comment-equals": {
|
|
2544
|
+
const prefix = content.startsWith("graphql") ? "graphql" : content.startsWith("%=") ? "%=" : content.startsWith("==") ? "==" : content.startsWith("%") ? "%" : content.startsWith("=") ? "=" : content.startsWith("-") ? "-" : null;
|
|
2545
|
+
if (prefix) {
|
|
2546
|
+
const afterPrefix = content.substring(prefix.length);
|
|
2547
|
+
node.content.value = prefix + " " + afterPrefix.replace(/^\s{2,}/, "");
|
|
2548
|
+
}
|
|
2549
|
+
break;
|
|
2550
|
+
}
|
|
2551
|
+
default:
|
|
2552
|
+
return null;
|
|
2553
|
+
}
|
|
2554
|
+
return result;
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
class ERBNoInlineCaseConditionsVisitor extends BaseRuleVisitor {
|
|
2559
|
+
visitERBCaseNode(node) {
|
|
2560
|
+
this.checkConditions(node, "when");
|
|
2561
|
+
this.visitChildNodes(node);
|
|
2562
|
+
}
|
|
2563
|
+
visitERBCaseMatchNode(node) {
|
|
2564
|
+
this.checkConditions(node, "in");
|
|
2565
|
+
this.visitChildNodes(node);
|
|
2566
|
+
}
|
|
2567
|
+
checkConditions(node, type) {
|
|
2568
|
+
if (!node.conditions || node.conditions.length === 0)
|
|
2569
|
+
return;
|
|
2570
|
+
for (const condition of node.conditions) {
|
|
2571
|
+
if (condition.tag_opening === null) {
|
|
2572
|
+
this.addOffense(`A \`case\` statement with \`${type}\` conditions in a single ERB tag cannot be reliably parsed, compiled, and formatted. Use separate ERB tags for \`case\` and its conditions (e.g., \`<% case x %>\` followed by \`<% ${type} y %>\`).`, node.location);
|
|
2573
|
+
break;
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
class ERBNoInlineCaseConditionsRule extends ParserRule {
|
|
2579
|
+
static ruleName = "erb-no-inline-case-conditions";
|
|
2580
|
+
get defaultConfig() {
|
|
2581
|
+
return {
|
|
2582
|
+
enabled: true,
|
|
2583
|
+
severity: "warning",
|
|
2584
|
+
};
|
|
2585
|
+
}
|
|
2586
|
+
get parserOptions() {
|
|
2587
|
+
return { strict: false };
|
|
2588
|
+
}
|
|
2589
|
+
check(result, context) {
|
|
2590
|
+
const visitor = new ERBNoInlineCaseConditionsVisitor(this.ruleName, context);
|
|
2591
|
+
visitor.visit(result.value);
|
|
2592
|
+
return visitor.offenses;
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
/**
|
|
2597
|
+
* File path and naming utilities for linter rules
|
|
2598
|
+
*/
|
|
2599
|
+
/**
|
|
2600
|
+
* Extracts the basename (filename) from a file path
|
|
2601
|
+
* Works with both forward slashes and backslashes
|
|
2602
|
+
*/
|
|
2603
|
+
function getBasename(filePath) {
|
|
2604
|
+
const lastSlash = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\"));
|
|
2605
|
+
return lastSlash === -1 ? filePath : filePath.slice(lastSlash + 1);
|
|
2606
|
+
}
|
|
2607
|
+
/**
|
|
2608
|
+
* Checks if a file is a Rails partial (filename starts with `_`)
|
|
2609
|
+
* Returns null if fileName is undefined (unknown context)
|
|
2610
|
+
*/
|
|
2611
|
+
function isPartialFile(fileName) {
|
|
2612
|
+
if (!fileName)
|
|
2613
|
+
return null;
|
|
2614
|
+
return getBasename(fileName).startsWith("_");
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
class InstanceVariableCollector extends PrismVisitor {
|
|
2618
|
+
instanceVariables = [];
|
|
2619
|
+
visitInstanceVariableReadNode(node) {
|
|
2620
|
+
this.collect(node, "read");
|
|
2621
|
+
}
|
|
2622
|
+
visitInstanceVariableWriteNode(node) {
|
|
2623
|
+
this.collect(node, "write");
|
|
2624
|
+
}
|
|
2625
|
+
visitInstanceVariableAndWriteNode(node) {
|
|
2626
|
+
this.collect(node, "write");
|
|
2627
|
+
}
|
|
2628
|
+
visitInstanceVariableOrWriteNode(node) {
|
|
2629
|
+
this.collect(node, "write");
|
|
2630
|
+
}
|
|
2631
|
+
visitInstanceVariableOperatorWriteNode(node) {
|
|
2632
|
+
this.collect(node, "write");
|
|
2633
|
+
}
|
|
2634
|
+
visitInstanceVariableTargetNode(node) {
|
|
2635
|
+
this.collect(node, "write");
|
|
2636
|
+
}
|
|
2637
|
+
collect(node, usage) {
|
|
2638
|
+
this.instanceVariables.push({
|
|
2639
|
+
name: node.name,
|
|
2640
|
+
usage,
|
|
2641
|
+
startOffset: node.location.startOffset,
|
|
2642
|
+
length: node.location.length
|
|
2643
|
+
});
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
class ERBNoInstanceVariablesInPartialsRule extends ParserRule {
|
|
2647
|
+
static ruleName = "erb-no-instance-variables-in-partials";
|
|
2648
|
+
get defaultConfig() {
|
|
2649
|
+
return {
|
|
2650
|
+
enabled: true,
|
|
2651
|
+
severity: "error",
|
|
2652
|
+
};
|
|
2653
|
+
}
|
|
2654
|
+
get parserOptions() {
|
|
2655
|
+
return {
|
|
2656
|
+
track_whitespace: true,
|
|
2657
|
+
prism_program: true,
|
|
2658
|
+
};
|
|
2659
|
+
}
|
|
2660
|
+
isEnabled(_result, context) {
|
|
2661
|
+
return isPartialFile(context?.fileName) === true;
|
|
2662
|
+
}
|
|
2663
|
+
check(result, _context) {
|
|
2664
|
+
const source = result.value.source;
|
|
2665
|
+
const prismNode = result.value.prismNode;
|
|
2666
|
+
if (!prismNode || !source)
|
|
2667
|
+
return [];
|
|
2668
|
+
const collector = new InstanceVariableCollector();
|
|
2669
|
+
collector.visit(prismNode);
|
|
2670
|
+
return collector.instanceVariables.map(ivar => {
|
|
2671
|
+
const location = locationFromOffset(source, ivar.startOffset, ivar.length);
|
|
2672
|
+
const message = ivar.usage === "read"
|
|
2673
|
+
? `Avoid using instance variables in partials. Pass \`${ivar.name}\` as a local variable instead.`
|
|
2674
|
+
: `Avoid setting instance variables in partials. Use a local variable instead of \`${ivar.name}\`.`;
|
|
2675
|
+
return this.createOffense(message, location);
|
|
2676
|
+
});
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
function groupToString(group) {
|
|
2681
|
+
return group.map(node => {
|
|
2682
|
+
if (isLiteralNode(node)) {
|
|
2683
|
+
return node.content;
|
|
2684
|
+
}
|
|
2685
|
+
return IdentityPrinter.print(node, { ignoreErrors: true });
|
|
2686
|
+
}).join("");
|
|
2687
|
+
}
|
|
2688
|
+
class ERBNoInterpolatedClassNamesVisitor extends AttributeVisitorMixin {
|
|
2689
|
+
checkStaticAttributeDynamicValue({ attributeName, valueNodes, attributeNode }) {
|
|
2690
|
+
if (attributeName !== "class")
|
|
2691
|
+
return;
|
|
2692
|
+
const splitNodes = splitLiteralsAtWhitespace(valueNodes);
|
|
2693
|
+
const groups = groupNodesByClass(splitNodes);
|
|
2694
|
+
for (const group of groups) {
|
|
2695
|
+
if (group.every(node => isPureWhitespaceNode(node)))
|
|
2696
|
+
continue;
|
|
2697
|
+
const isInterpolated = group.some(node => !isLiteralNode(node));
|
|
2698
|
+
if (!isInterpolated)
|
|
2699
|
+
continue;
|
|
2700
|
+
const hasAttachedLiteral = group.some(node => isLiteralNode(node) && node.content.trim());
|
|
2701
|
+
if (!hasAttachedLiteral)
|
|
2702
|
+
continue;
|
|
2703
|
+
const className = groupToString(group);
|
|
2704
|
+
this.addOffense(`Avoid ERB interpolation inside class names: \`${className}\`. Use standalone ERB expressions that output complete class names instead.`, attributeNode.value.location);
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2708
|
+
class ERBNoInterpolatedClassNamesRule extends ParserRule {
|
|
2709
|
+
static ruleName = "erb-no-interpolated-class-names";
|
|
2710
|
+
get defaultConfig() {
|
|
2711
|
+
return {
|
|
2712
|
+
enabled: true,
|
|
2713
|
+
severity: "warning"
|
|
2714
|
+
};
|
|
2715
|
+
}
|
|
2716
|
+
check(result, context) {
|
|
2717
|
+
const visitor = new ERBNoInterpolatedClassNamesVisitor(this.ruleName, context);
|
|
2718
|
+
visitor.visit(result.value);
|
|
2719
|
+
return visitor.offenses;
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
const JAVASCRIPT_TAG_PATTERN = /\bjavascript_tag\b/;
|
|
2724
|
+
class ERBNoJavascriptTagHelperVisitor extends BaseRuleVisitor {
|
|
2725
|
+
visitDocumentNode(node) {
|
|
2726
|
+
for (const child of node.children || []) {
|
|
2727
|
+
if (!isERBNode(child))
|
|
2728
|
+
continue;
|
|
2729
|
+
if (!isERBOutputNode(child))
|
|
2730
|
+
continue;
|
|
2731
|
+
const content = child.content?.value || "";
|
|
2732
|
+
if (JAVASCRIPT_TAG_PATTERN.test(content)) {
|
|
2733
|
+
this.addOffense("Avoid `javascript_tag`. Use inline `<script>` tags instead.", child.location);
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
super.visitDocumentNode(node);
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
class ERBNoJavascriptTagHelperRule extends ParserRule {
|
|
2740
|
+
static ruleName = "erb-no-javascript-tag-helper";
|
|
2741
|
+
get defaultConfig() {
|
|
2742
|
+
return {
|
|
2743
|
+
enabled: true,
|
|
2744
|
+
severity: "warning"
|
|
2745
|
+
};
|
|
2746
|
+
}
|
|
2747
|
+
check(result, context) {
|
|
2748
|
+
const visitor = new ERBNoJavascriptTagHelperVisitor(this.ruleName, context);
|
|
2749
|
+
visitor.visit(result.value);
|
|
2750
|
+
return visitor.offenses;
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
class ERBNoOutputControlFlowRuleVisitor extends BaseRuleVisitor {
|
|
2755
|
+
visitERBIfNode(node) {
|
|
2756
|
+
this.checkOutputControlFlow(node);
|
|
2757
|
+
this.visitChildNodes(node);
|
|
2758
|
+
}
|
|
2759
|
+
visitERBUnlessNode(node) {
|
|
2760
|
+
this.checkOutputControlFlow(node);
|
|
2761
|
+
this.visitChildNodes(node);
|
|
2762
|
+
}
|
|
2763
|
+
visitERBElseNode(node) {
|
|
2764
|
+
this.checkOutputControlFlow(node);
|
|
2765
|
+
this.visitChildNodes(node);
|
|
2766
|
+
}
|
|
2767
|
+
visitERBEndNode(node) {
|
|
2768
|
+
this.checkOutputControlFlow(node);
|
|
2769
|
+
this.visitChildNodes(node);
|
|
2770
|
+
}
|
|
2771
|
+
static CONTROL_BLOCK_NAMES = {
|
|
2772
|
+
"AST_ERB_IF_NODE": "if",
|
|
2773
|
+
"AST_ERB_ELSE_NODE": "else",
|
|
2774
|
+
"AST_ERB_END_NODE": "end",
|
|
2775
|
+
"AST_ERB_UNLESS_NODE": "unless"
|
|
2776
|
+
};
|
|
2777
|
+
checkOutputControlFlow(controlBlock) {
|
|
2778
|
+
const openTag = controlBlock.tag_opening;
|
|
2779
|
+
if (!openTag) {
|
|
2780
|
+
return;
|
|
2781
|
+
}
|
|
2782
|
+
if (openTag.value === "<%=") {
|
|
2783
|
+
const controlBlockType = ERBNoOutputControlFlowRuleVisitor.CONTROL_BLOCK_NAMES[controlBlock.type] || controlBlock.type;
|
|
2784
|
+
this.addOffense(`Control flow statements like \`${controlBlockType}\` should not be used with output tags. Use \`<% ${controlBlockType} ... %>\` instead.`, openTag.location);
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
class ERBNoOutputControlFlowRule extends ParserRule {
|
|
2789
|
+
static ruleName = "erb-no-output-control-flow";
|
|
2790
|
+
get defaultConfig() {
|
|
2791
|
+
return {
|
|
2792
|
+
enabled: true,
|
|
2793
|
+
severity: "error"
|
|
2794
|
+
};
|
|
2795
|
+
}
|
|
2796
|
+
check(result, context) {
|
|
2797
|
+
const visitor = new ERBNoOutputControlFlowRuleVisitor(this.ruleName, context);
|
|
2798
|
+
visitor.visit(result.value);
|
|
2799
|
+
return visitor.offenses;
|
|
2800
|
+
}
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
class ERBNoOutputInAttributeNameVisitor extends BaseRuleVisitor {
|
|
2804
|
+
visitHTMLAttributeNameNode(node) {
|
|
2805
|
+
for (const child of node.children) {
|
|
2806
|
+
if (!isERBNode(child))
|
|
2807
|
+
continue;
|
|
2808
|
+
if (!isERBOutputNode(child))
|
|
2809
|
+
continue;
|
|
2810
|
+
this.addOffense("Avoid ERB output in attribute names. Use static attribute names with dynamic values instead.", child.location);
|
|
2811
|
+
}
|
|
2812
|
+
super.visitHTMLAttributeNameNode(node);
|
|
2813
|
+
}
|
|
2814
|
+
}
|
|
2815
|
+
class ERBNoOutputInAttributeNameRule extends ParserRule {
|
|
2816
|
+
static ruleName = "erb-no-output-in-attribute-name";
|
|
2817
|
+
get defaultConfig() {
|
|
2818
|
+
return {
|
|
2819
|
+
enabled: true,
|
|
2820
|
+
severity: "error"
|
|
2821
|
+
};
|
|
2822
|
+
}
|
|
2823
|
+
check(result, context) {
|
|
2824
|
+
const visitor = new ERBNoOutputInAttributeNameVisitor(this.ruleName, context);
|
|
2825
|
+
visitor.visit(result.value);
|
|
2826
|
+
return visitor.offenses;
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
class ERBNoOutputInAttributePositionVisitor extends BaseRuleVisitor {
|
|
2831
|
+
visitHTMLOpenTagNode(node) {
|
|
2832
|
+
for (const child of node.children) {
|
|
2833
|
+
if (!isERBNode(child))
|
|
2834
|
+
continue;
|
|
2835
|
+
if (!isERBOutputNode(child))
|
|
2836
|
+
continue;
|
|
2837
|
+
this.addOffense("Avoid `<%= %>` in attribute position. Use `<% if ... %>` with static attributes instead.", child.location);
|
|
2838
|
+
}
|
|
2839
|
+
super.visitHTMLOpenTagNode(node);
|
|
2840
|
+
}
|
|
2841
|
+
}
|
|
2842
|
+
class ERBNoOutputInAttributePositionRule extends ParserRule {
|
|
2843
|
+
static ruleName = "erb-no-output-in-attribute-position";
|
|
2844
|
+
get defaultConfig() {
|
|
2845
|
+
return {
|
|
2846
|
+
enabled: true,
|
|
2847
|
+
severity: "error"
|
|
2848
|
+
};
|
|
2849
|
+
}
|
|
2850
|
+
check(result, context) {
|
|
2851
|
+
const visitor = new ERBNoOutputInAttributePositionVisitor(this.ruleName, context);
|
|
2852
|
+
visitor.visit(result.value);
|
|
2853
|
+
return visitor.offenses;
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
class ERBNoRawOutputInAttributeValueVisitor extends AttributeVisitorMixin {
|
|
2858
|
+
checkStaticAttributeDynamicValue({ valueNodes, attributeNode }) {
|
|
2859
|
+
this.checkValueNodes(valueNodes);
|
|
2860
|
+
}
|
|
2861
|
+
checkDynamicAttributeDynamicValue({ valueNodes }) {
|
|
2862
|
+
this.checkValueNodes(valueNodes);
|
|
2863
|
+
}
|
|
2864
|
+
checkValueNodes(nodes) {
|
|
2865
|
+
for (const node of nodes) {
|
|
2866
|
+
if (!isERBNode(node))
|
|
2867
|
+
continue;
|
|
2868
|
+
if (node.tag_opening?.value === "<%==") {
|
|
2869
|
+
this.addOffense("Avoid `<%==` in attribute values. Use `<%= %>` instead to ensure proper HTML escaping.", node.location);
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
class ERBNoRawOutputInAttributeValueRule extends ParserRule {
|
|
2875
|
+
static ruleName = "erb-no-raw-output-in-attribute-value";
|
|
2876
|
+
get defaultConfig() {
|
|
2877
|
+
return {
|
|
2878
|
+
enabled: true,
|
|
2879
|
+
severity: "error"
|
|
2880
|
+
};
|
|
2881
|
+
}
|
|
2882
|
+
check(result, context) {
|
|
2883
|
+
const visitor = new ERBNoRawOutputInAttributeValueVisitor(this.ruleName, context);
|
|
2884
|
+
visitor.visit(result.value);
|
|
2885
|
+
return visitor.offenses;
|
|
2886
|
+
}
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
class ERBNoSilentTagInAttributeNameVisitor extends BaseRuleVisitor {
|
|
2890
|
+
visitHTMLAttributeNameNode(node) {
|
|
2891
|
+
const erbNodes = filterERBContentNodes(node.children);
|
|
2892
|
+
const silentNodes = erbNodes.filter(this.isSilentERBTag);
|
|
2893
|
+
for (const node of silentNodes) {
|
|
2894
|
+
this.addOffense(`Remove silent ERB tag from HTML attribute name. Silent ERB tags (\`${node.tag_opening?.value}\`) do not output content and should not be used in attribute names.`, node.location);
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
// TODO: might be worth to extract
|
|
2898
|
+
isSilentERBTag(node) {
|
|
2899
|
+
const silentTags = ["<%", "<%-", "<%#"];
|
|
2900
|
+
return silentTags.includes(node.tag_opening?.value || "");
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
class ERBNoSilentTagInAttributeNameRule extends ParserRule {
|
|
2904
|
+
static ruleName = "erb-no-silent-tag-in-attribute-name";
|
|
2905
|
+
get defaultConfig() {
|
|
2906
|
+
return {
|
|
2907
|
+
enabled: true,
|
|
2908
|
+
severity: "error"
|
|
2909
|
+
};
|
|
2910
|
+
}
|
|
2911
|
+
check(result, context) {
|
|
2912
|
+
const visitor = new ERBNoSilentTagInAttributeNameVisitor(this.ruleName, context);
|
|
2913
|
+
visitor.visit(result.value);
|
|
2914
|
+
return visitor.offenses;
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2918
|
+
const END_PATTERN = /^\s*end\b/;
|
|
2919
|
+
class ERBNoStatementInScriptVisitor extends BaseRuleVisitor {
|
|
2920
|
+
visitHTMLElementNode(node) {
|
|
2921
|
+
if (!isHTMLOpenTagNode(node.open_tag)) {
|
|
2922
|
+
super.visitHTMLElementNode(node);
|
|
2923
|
+
return;
|
|
2924
|
+
}
|
|
2925
|
+
if (getTagLocalName(node.open_tag) === "script") {
|
|
2926
|
+
this.checkScriptElement(node);
|
|
2927
|
+
}
|
|
2928
|
+
super.visitHTMLElementNode(node);
|
|
2929
|
+
}
|
|
2930
|
+
checkScriptElement(node) {
|
|
2931
|
+
if (!isHTMLOpenTagNode(node.open_tag))
|
|
2932
|
+
return;
|
|
2933
|
+
const typeAttribute = getAttribute(node.open_tag, "type");
|
|
2934
|
+
const typeValue = typeAttribute ? getStaticAttributeValue(typeAttribute) : null;
|
|
2935
|
+
if (typeValue === "text/html") {
|
|
2936
|
+
return;
|
|
2937
|
+
}
|
|
2938
|
+
if (!node.body || node.body.length === 0) {
|
|
2939
|
+
return;
|
|
2940
|
+
}
|
|
2941
|
+
this.checkNodesForStatements(node.body);
|
|
2942
|
+
}
|
|
2943
|
+
checkNodesForStatements(nodes) {
|
|
2944
|
+
for (const child of nodes) {
|
|
2945
|
+
if (!isERBNode(child))
|
|
2946
|
+
continue;
|
|
2947
|
+
if (isERBOutputNode(child))
|
|
2948
|
+
continue;
|
|
2949
|
+
if (isERBCommentNode(child))
|
|
2950
|
+
continue;
|
|
2951
|
+
const content = child.content?.value || "";
|
|
2952
|
+
if (END_PATTERN.test(content))
|
|
2953
|
+
continue;
|
|
2954
|
+
this.addOffense("Avoid `<% %>` tags inside `<script>`. Use `<%= %>` to interpolate values into JavaScript.", child.location);
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
}
|
|
2958
|
+
class ERBNoStatementInScriptRule extends ParserRule {
|
|
2959
|
+
static ruleName = "erb-no-statement-in-script";
|
|
2960
|
+
get defaultConfig() {
|
|
2961
|
+
return {
|
|
2962
|
+
enabled: true,
|
|
2963
|
+
severity: "warning"
|
|
2964
|
+
};
|
|
2965
|
+
}
|
|
2966
|
+
check(result, context) {
|
|
2967
|
+
const visitor = new ERBNoStatementInScriptVisitor(this.ruleName, context);
|
|
2968
|
+
visitor.visit(result.value);
|
|
2969
|
+
return visitor.offenses;
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
2972
|
+
|
|
2973
|
+
class ERBNoThenInControlFlowVisitor extends BaseRuleVisitor {
|
|
2974
|
+
visitERBIfNode(node) {
|
|
2975
|
+
const content = node.content?.value?.trim() ?? "";
|
|
2976
|
+
const keyword = content.startsWith("elsif") ? "elsif" : "if";
|
|
2977
|
+
this.checkThenKeyword(keyword, node.then_keyword);
|
|
2978
|
+
this.visitChildNodes(node);
|
|
2979
|
+
}
|
|
2980
|
+
visitERBUnlessNode(node) {
|
|
2981
|
+
this.checkThenKeyword("unless", node.then_keyword);
|
|
2982
|
+
this.visitChildNodes(node);
|
|
2983
|
+
}
|
|
2984
|
+
visitERBWhenNode(node) {
|
|
2985
|
+
this.checkThenKeyword("when", node.then_keyword);
|
|
2986
|
+
this.visitChildNodes(node);
|
|
2987
|
+
}
|
|
2988
|
+
visitERBInNode(node) {
|
|
2989
|
+
this.checkThenKeyword("in", node.then_keyword);
|
|
2990
|
+
this.visitChildNodes(node);
|
|
2991
|
+
}
|
|
2992
|
+
checkThenKeyword(keyword, thenKeyword) {
|
|
2993
|
+
if (thenKeyword === null)
|
|
2994
|
+
return;
|
|
2995
|
+
this.addOffense(`Avoid using \`then\` in \`${keyword}\` expressions inside ERB templates. Use the multiline block form instead.`, thenKeyword);
|
|
2996
|
+
}
|
|
2997
|
+
}
|
|
2998
|
+
class ERBNoThenInControlFlowRule extends ParserRule {
|
|
2999
|
+
static ruleName = "erb-no-then-in-control-flow";
|
|
1737
3000
|
get defaultConfig() {
|
|
1738
3001
|
return {
|
|
1739
3002
|
enabled: true,
|
|
1740
|
-
severity: "
|
|
3003
|
+
severity: "warning",
|
|
1741
3004
|
};
|
|
1742
3005
|
}
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
visitor.visit(source);
|
|
1746
|
-
return visitor.offenses;
|
|
3006
|
+
get parserOptions() {
|
|
3007
|
+
return { strict: true };
|
|
1747
3008
|
}
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
const before = source.substring(0, startOffset);
|
|
1753
|
-
const after = source.substring(endOffset);
|
|
1754
|
-
return before + after;
|
|
3009
|
+
check(result, context) {
|
|
3010
|
+
const visitor = new ERBNoThenInControlFlowVisitor(this.ruleName, context);
|
|
3011
|
+
visitor.visit(result.value);
|
|
3012
|
+
return visitor.offenses;
|
|
1755
3013
|
}
|
|
1756
3014
|
}
|
|
1757
3015
|
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
if (
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
3016
|
+
const TRAILING_WHITESPACE = /[ \t\r\v\f\u00A0]+$/;
|
|
3017
|
+
const TRAILING_WHITESPACE_BEFORE_NEWLINE = /[ \t\r\v\f\u00A0]+(?=\n)/g;
|
|
3018
|
+
const ONLY_WHITESPACE = /^[ \t\r\v\f\u00A0]+$/;
|
|
3019
|
+
class SkipZoneCollector extends Visitor {
|
|
3020
|
+
skipZones = [];
|
|
3021
|
+
SKIP_TAGS = new Set(["pre", "textarea", "script", "style"]);
|
|
3022
|
+
visitHTMLElementNode(node) {
|
|
3023
|
+
if (isHTMLOpenTagNode(node.open_tag)) {
|
|
3024
|
+
const tagName = getTagLocalName(node.open_tag);
|
|
3025
|
+
if (tagName && this.SKIP_TAGS.has(tagName)) {
|
|
3026
|
+
this.skipZones.push({
|
|
3027
|
+
startLine: node.location.start.line,
|
|
3028
|
+
startColumn: node.location.start.column,
|
|
3029
|
+
endLine: node.location.end.line,
|
|
3030
|
+
endColumn: node.location.end.column
|
|
3031
|
+
});
|
|
3032
|
+
return;
|
|
1772
3033
|
}
|
|
1773
3034
|
}
|
|
1774
|
-
|
|
1775
|
-
this.reportWhitespace(node, openTag, closeTag, value, "end", 0, `Remove extra whitespace before \`${closeTag.value}\`.`, "before-close");
|
|
1776
|
-
}
|
|
1777
|
-
}
|
|
1778
|
-
hasExtraLeadingWhitespace(content) {
|
|
1779
|
-
return content.startsWith(" ") && !content.startsWith(" \n");
|
|
1780
|
-
}
|
|
1781
|
-
hasExtraTrailingWhitespace(content) {
|
|
1782
|
-
return !content.includes("\n") && /\s{2,}$/.test(content);
|
|
1783
|
-
}
|
|
1784
|
-
getWhitespaceLocation(node, content, position, offset = 0) {
|
|
1785
|
-
const contentLocation = node.content.location;
|
|
1786
|
-
if (position === "start") {
|
|
1787
|
-
const match = content.substring(offset).match(/^\s+/);
|
|
1788
|
-
const length = match ? match[0].length : 0;
|
|
1789
|
-
const startColumn = contentLocation.start.column + offset;
|
|
1790
|
-
return Location.from(contentLocation.start.line, startColumn, contentLocation.start.line, startColumn + length);
|
|
1791
|
-
}
|
|
1792
|
-
else {
|
|
1793
|
-
const match = content.match(/\s+$/);
|
|
1794
|
-
const length = match ? match[0].length : 0;
|
|
1795
|
-
return Location.from(contentLocation.end.line, contentLocation.end.column - length, contentLocation.end.line, contentLocation.end.column);
|
|
1796
|
-
}
|
|
3035
|
+
super.visitHTMLElementNode(node);
|
|
1797
3036
|
}
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
3037
|
+
visitERBNode(node) {
|
|
3038
|
+
if (!node.tag_opening)
|
|
3039
|
+
return;
|
|
3040
|
+
if (!node.tag_closing)
|
|
3041
|
+
return;
|
|
3042
|
+
this.skipZones.push({
|
|
3043
|
+
startLine: node.tag_opening.location.start.line,
|
|
3044
|
+
startColumn: node.tag_opening.location.start.column,
|
|
3045
|
+
endLine: node.tag_closing.location.end.line,
|
|
3046
|
+
endColumn: node.tag_closing.location.end.column
|
|
1806
3047
|
});
|
|
1807
3048
|
}
|
|
1808
3049
|
}
|
|
1809
|
-
class
|
|
3050
|
+
class ERBNoTrailingWhitespaceRule extends ParserRule {
|
|
1810
3051
|
static autocorrectable = true;
|
|
1811
|
-
|
|
3052
|
+
static ruleName = "erb-no-trailing-whitespace";
|
|
1812
3053
|
get defaultConfig() {
|
|
1813
3054
|
return {
|
|
1814
3055
|
enabled: true,
|
|
1815
|
-
severity: "error"
|
|
3056
|
+
severity: "error",
|
|
1816
3057
|
};
|
|
1817
3058
|
}
|
|
1818
|
-
check(result,
|
|
1819
|
-
const
|
|
1820
|
-
|
|
1821
|
-
|
|
3059
|
+
check(result, _context) {
|
|
3060
|
+
const offenses = [];
|
|
3061
|
+
const lines = result.source.split("\n");
|
|
3062
|
+
const candidates = this.findTrailingWhitespaceCandidates(lines);
|
|
3063
|
+
if (candidates.length === 0)
|
|
3064
|
+
return offenses;
|
|
3065
|
+
const skipZones = this.collectSkipZones(result.value);
|
|
3066
|
+
for (const candidate of candidates) {
|
|
3067
|
+
if (!this.isInSkipZone(candidate, skipZones)) {
|
|
3068
|
+
const location = Location.from(candidate.line, candidate.column, candidate.line, candidate.column + candidate.length);
|
|
3069
|
+
const node = findNodeAtPosition(result.value, candidate.line, candidate.column, (n) => isHTMLTextNode(n) || isLiteralNode(n));
|
|
3070
|
+
offenses.push({
|
|
3071
|
+
rule: this.ruleName,
|
|
3072
|
+
message: "Extra whitespace detected at end of line.",
|
|
3073
|
+
location,
|
|
3074
|
+
autofixContext: node ? { node } : undefined
|
|
3075
|
+
});
|
|
3076
|
+
}
|
|
3077
|
+
}
|
|
3078
|
+
return offenses;
|
|
3079
|
+
}
|
|
3080
|
+
findTrailingWhitespaceCandidates(lines) {
|
|
3081
|
+
const candidates = [];
|
|
3082
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3083
|
+
const line = lines[i];
|
|
3084
|
+
const match = line.match(TRAILING_WHITESPACE);
|
|
3085
|
+
if (match && match.index !== undefined) {
|
|
3086
|
+
candidates.push({
|
|
3087
|
+
line: i + 1,
|
|
3088
|
+
column: match.index,
|
|
3089
|
+
length: match[0].length
|
|
3090
|
+
});
|
|
3091
|
+
}
|
|
3092
|
+
}
|
|
3093
|
+
return candidates;
|
|
3094
|
+
}
|
|
3095
|
+
collectSkipZones(root) {
|
|
3096
|
+
const collector = new SkipZoneCollector();
|
|
3097
|
+
collector.visit(root);
|
|
3098
|
+
return collector.skipZones;
|
|
3099
|
+
}
|
|
3100
|
+
isInSkipZone(candidate, skipZones) {
|
|
3101
|
+
for (const zone of skipZones) {
|
|
3102
|
+
if (candidate.line < zone.startLine || candidate.line > zone.endLine)
|
|
3103
|
+
continue;
|
|
3104
|
+
if (candidate.line === zone.endLine && candidate.column >= zone.endColumn)
|
|
3105
|
+
continue;
|
|
3106
|
+
if (candidate.line === zone.startLine && candidate.column < zone.startColumn)
|
|
3107
|
+
continue;
|
|
3108
|
+
return true;
|
|
3109
|
+
}
|
|
3110
|
+
return false;
|
|
1822
3111
|
}
|
|
1823
3112
|
autofix(offense, result, _context) {
|
|
1824
3113
|
if (!offense.autofixContext)
|
|
1825
3114
|
return null;
|
|
1826
|
-
const { node
|
|
1827
|
-
if (
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
break;
|
|
1834
|
-
case "after-open":
|
|
1835
|
-
node.content.value = content.replace(/^\s{2,}/, " ");
|
|
1836
|
-
break;
|
|
1837
|
-
case "after-comment-equals":
|
|
1838
|
-
if (content.startsWith("=")) {
|
|
1839
|
-
const afterEquals = content.substring(1);
|
|
1840
|
-
node.content.value = "= " + afterEquals.replace(/^\s{2,}/, "");
|
|
3115
|
+
const { node } = offense.autofixContext;
|
|
3116
|
+
if (node.type === "AST_HTML_TEXT_NODE" || node.type === "AST_LITERAL_NODE") {
|
|
3117
|
+
let fixedContent = node.content.replace(TRAILING_WHITESPACE_BEFORE_NEWLINE, "");
|
|
3118
|
+
const offenseIsAtEndOfContent = this.isOffenseAtEndOfContent(offense, node);
|
|
3119
|
+
if (offenseIsAtEndOfContent) {
|
|
3120
|
+
if (this.hasTrailingWhitespaceNotIndentation(fixedContent)) {
|
|
3121
|
+
fixedContent = fixedContent.replace(TRAILING_WHITESPACE, "");
|
|
1841
3122
|
}
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
3123
|
+
if (ONLY_WHITESPACE.test(fixedContent) && node.location.start.column !== 0) {
|
|
3124
|
+
fixedContent = "";
|
|
3125
|
+
}
|
|
3126
|
+
}
|
|
3127
|
+
node.content = fixedContent;
|
|
1845
3128
|
}
|
|
1846
3129
|
return result;
|
|
1847
3130
|
}
|
|
3131
|
+
isOffenseAtEndOfContent(offense, node) {
|
|
3132
|
+
return offense.location.end.line === node.location.end.line && offense.location.end.column === node.location.end.column;
|
|
3133
|
+
}
|
|
3134
|
+
hasTrailingWhitespaceNotIndentation(content) {
|
|
3135
|
+
if (content.endsWith("\n"))
|
|
3136
|
+
return false;
|
|
3137
|
+
const endMatch = content.match(TRAILING_WHITESPACE);
|
|
3138
|
+
if (!endMatch)
|
|
3139
|
+
return false;
|
|
3140
|
+
const whitespaceStart = content.length - endMatch[0].length;
|
|
3141
|
+
if (whitespaceStart === 0)
|
|
3142
|
+
return false;
|
|
3143
|
+
const characterBefore = content[whitespaceStart - 1];
|
|
3144
|
+
if (characterBefore === "\n")
|
|
3145
|
+
return false;
|
|
3146
|
+
return true;
|
|
3147
|
+
}
|
|
1848
3148
|
}
|
|
1849
3149
|
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
3150
|
+
const JS_ATTRIBUTE_PATTERN = /^on/i;
|
|
3151
|
+
const SAFE_PATTERN$1 = /\.to_json\s*$|\bj\s*[\s(]|\bescape_javascript\s*[\s(]/;
|
|
3152
|
+
class ERBNoUnsafeJSAttributeVisitor extends AttributeVisitorMixin {
|
|
3153
|
+
checkStaticAttributeDynamicValue({ attributeName, valueNodes }) {
|
|
3154
|
+
if (!JS_ATTRIBUTE_PATTERN.test(attributeName))
|
|
3155
|
+
return;
|
|
3156
|
+
for (const node of valueNodes) {
|
|
3157
|
+
if (!isERBNode(node))
|
|
3158
|
+
continue;
|
|
3159
|
+
if (!isERBOutputNode(node))
|
|
3160
|
+
continue;
|
|
3161
|
+
const content = node.content?.value?.trim() || "";
|
|
3162
|
+
if (SAFE_PATTERN$1.test(content))
|
|
3163
|
+
continue;
|
|
3164
|
+
this.addOffense(`Unsafe ERB output in \`${attributeName}\` attribute. Use \`.to_json\`, \`j()\`, or \`escape_javascript()\` to safely encode values.`, node.location);
|
|
3165
|
+
}
|
|
1854
3166
|
}
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
3167
|
+
}
|
|
3168
|
+
class ERBNoUnsafeJSAttributeRule extends ParserRule {
|
|
3169
|
+
static ruleName = "erb-no-unsafe-js-attribute";
|
|
3170
|
+
get defaultConfig() {
|
|
3171
|
+
return {
|
|
3172
|
+
enabled: true,
|
|
3173
|
+
severity: "error"
|
|
3174
|
+
};
|
|
1858
3175
|
}
|
|
1859
|
-
|
|
1860
|
-
this.
|
|
1861
|
-
|
|
3176
|
+
check(result, context) {
|
|
3177
|
+
const visitor = new ERBNoUnsafeJSAttributeVisitor(this.ruleName, context);
|
|
3178
|
+
visitor.visit(result.value);
|
|
3179
|
+
return visitor.offenses;
|
|
1862
3180
|
}
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
3181
|
+
}
|
|
3182
|
+
|
|
3183
|
+
const RAW_PATTERN = /\braw[\s(]/;
|
|
3184
|
+
const HTML_SAFE_PATTERN = /\.html_safe\b/;
|
|
3185
|
+
const RAW_TEXT_ELEMENTS = new Set([
|
|
3186
|
+
"title",
|
|
3187
|
+
"textarea",
|
|
3188
|
+
"script",
|
|
3189
|
+
"style",
|
|
3190
|
+
"xmp",
|
|
3191
|
+
"iframe",
|
|
3192
|
+
"noembed",
|
|
3193
|
+
"noframes",
|
|
3194
|
+
"listing",
|
|
3195
|
+
"plaintext",
|
|
3196
|
+
]);
|
|
3197
|
+
class ERBNoUnsafeRawVisitor extends BaseRuleVisitor {
|
|
3198
|
+
insideRawTextElement = false;
|
|
3199
|
+
visitHTMLElementNode(node) {
|
|
3200
|
+
if (!isHTMLOpenTagNode(node.open_tag)) {
|
|
3201
|
+
super.visitHTMLElementNode(node);
|
|
3202
|
+
return;
|
|
3203
|
+
}
|
|
3204
|
+
const tagName = getTagLocalName(node.open_tag);
|
|
3205
|
+
if (tagName && RAW_TEXT_ELEMENTS.has(tagName)) {
|
|
3206
|
+
const wasInside = this.insideRawTextElement;
|
|
3207
|
+
this.insideRawTextElement = true;
|
|
3208
|
+
super.visitHTMLElementNode(node);
|
|
3209
|
+
this.insideRawTextElement = wasInside;
|
|
3210
|
+
return;
|
|
3211
|
+
}
|
|
3212
|
+
super.visitHTMLElementNode(node);
|
|
1866
3213
|
}
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
3214
|
+
visitERBContentNode(node) {
|
|
3215
|
+
if (this.insideRawTextElement)
|
|
3216
|
+
return;
|
|
3217
|
+
if (!isERBOutputNode(node))
|
|
1870
3218
|
return;
|
|
3219
|
+
const content = node.content?.value || "";
|
|
3220
|
+
if (RAW_PATTERN.test(content)) {
|
|
3221
|
+
this.addOffense("Avoid `raw()` in ERB output. It bypasses HTML escaping and can cause cross-site scripting (XSS) vulnerabilities.", node.location);
|
|
1871
3222
|
}
|
|
1872
|
-
if (
|
|
1873
|
-
|
|
1874
|
-
if (controlBlock.type === "AST_ERB_IF_NODE")
|
|
1875
|
-
controlBlockType = "if";
|
|
1876
|
-
if (controlBlock.type === "AST_ERB_ELSE_NODE")
|
|
1877
|
-
controlBlockType = "else";
|
|
1878
|
-
if (controlBlock.type === "AST_ERB_END_NODE")
|
|
1879
|
-
controlBlockType = "end";
|
|
1880
|
-
if (controlBlock.type === "AST_ERB_UNLESS_NODE")
|
|
1881
|
-
controlBlockType = "unless";
|
|
1882
|
-
this.addOffense(`Control flow statements like \`${controlBlockType}\` should not be used with output tags. Use \`<% ${controlBlockType} ... %>\` instead.`, openTag.location);
|
|
3223
|
+
if (HTML_SAFE_PATTERN.test(content)) {
|
|
3224
|
+
this.addOffense("Avoid `.html_safe` in ERB output. It bypasses HTML escaping and can cause cross-site scripting (XSS) vulnerabilities.", node.location);
|
|
1883
3225
|
}
|
|
1884
|
-
return;
|
|
1885
3226
|
}
|
|
1886
3227
|
}
|
|
1887
|
-
class
|
|
1888
|
-
|
|
3228
|
+
class ERBNoUnsafeRawRule extends ParserRule {
|
|
3229
|
+
static ruleName = "erb-no-unsafe-raw";
|
|
1889
3230
|
get defaultConfig() {
|
|
1890
3231
|
return {
|
|
1891
3232
|
enabled: true,
|
|
@@ -1893,28 +3234,50 @@ class ERBNoOutputControlFlowRule extends ParserRule {
|
|
|
1893
3234
|
};
|
|
1894
3235
|
}
|
|
1895
3236
|
check(result, context) {
|
|
1896
|
-
const visitor = new
|
|
3237
|
+
const visitor = new ERBNoUnsafeRawVisitor(this.ruleName, context);
|
|
1897
3238
|
visitor.visit(result.value);
|
|
1898
3239
|
return visitor.offenses;
|
|
1899
3240
|
}
|
|
1900
3241
|
}
|
|
1901
3242
|
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
3243
|
+
const SAFE_PATTERN = /\.to_json\b/;
|
|
3244
|
+
class ERBNoUnsafeScriptInterpolationVisitor extends BaseRuleVisitor {
|
|
3245
|
+
visitHTMLElementNode(node) {
|
|
3246
|
+
if (!isHTMLOpenTagNode(node.open_tag)) {
|
|
3247
|
+
super.visitHTMLElementNode(node);
|
|
3248
|
+
return;
|
|
3249
|
+
}
|
|
3250
|
+
if (getTagLocalName(node.open_tag) === "script") {
|
|
3251
|
+
this.checkScriptElement(node);
|
|
1908
3252
|
}
|
|
3253
|
+
super.visitHTMLElementNode(node);
|
|
1909
3254
|
}
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
3255
|
+
checkScriptElement(node) {
|
|
3256
|
+
if (!isHTMLOpenTagNode(node.open_tag))
|
|
3257
|
+
return;
|
|
3258
|
+
const typeAttribute = getAttribute(node.open_tag, "type");
|
|
3259
|
+
const typeValue = typeAttribute ? getStaticAttributeValue(typeAttribute) : null;
|
|
3260
|
+
if (typeValue === "text/html")
|
|
3261
|
+
return;
|
|
3262
|
+
if (!node.body || node.body.length === 0)
|
|
3263
|
+
return;
|
|
3264
|
+
this.checkNodesForUnsafeOutput(node.body);
|
|
3265
|
+
}
|
|
3266
|
+
checkNodesForUnsafeOutput(nodes) {
|
|
3267
|
+
for (const child of nodes) {
|
|
3268
|
+
if (!isERBNode(child))
|
|
3269
|
+
continue;
|
|
3270
|
+
if (!isERBOutputNode(child))
|
|
3271
|
+
continue;
|
|
3272
|
+
const content = child.content?.value?.trim() || "";
|
|
3273
|
+
if (SAFE_PATTERN.test(content))
|
|
3274
|
+
continue;
|
|
3275
|
+
this.addOffense("Unsafe ERB output in `<script>` tag. Use `.to_json` to safely serialize values into JavaScript.", child.location);
|
|
3276
|
+
}
|
|
1914
3277
|
}
|
|
1915
3278
|
}
|
|
1916
|
-
class
|
|
1917
|
-
|
|
3279
|
+
class ERBNoUnsafeScriptInterpolationRule extends ParserRule {
|
|
3280
|
+
static ruleName = "erb-no-unsafe-script-interpolation";
|
|
1918
3281
|
get defaultConfig() {
|
|
1919
3282
|
return {
|
|
1920
3283
|
enabled: true,
|
|
@@ -1922,7 +3285,7 @@ class ERBNoSilentTagInAttributeNameRule extends ParserRule {
|
|
|
1922
3285
|
};
|
|
1923
3286
|
}
|
|
1924
3287
|
check(result, context) {
|
|
1925
|
-
const visitor = new
|
|
3288
|
+
const visitor = new ERBNoUnsafeScriptInterpolationVisitor(this.ruleName, context);
|
|
1926
3289
|
visitor.visit(result.value);
|
|
1927
3290
|
return visitor.offenses;
|
|
1928
3291
|
}
|
|
@@ -1934,7 +3297,7 @@ class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
|
|
|
1934
3297
|
super.visitHTMLOpenTagNode(node);
|
|
1935
3298
|
}
|
|
1936
3299
|
checkImgTag(openTag) {
|
|
1937
|
-
const tagName =
|
|
3300
|
+
const tagName = getTagLocalName(openTag);
|
|
1938
3301
|
if (tagName !== "img")
|
|
1939
3302
|
return;
|
|
1940
3303
|
const attributes = getAttributes(openTag);
|
|
@@ -1995,7 +3358,7 @@ class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
|
|
|
1995
3358
|
}
|
|
1996
3359
|
}
|
|
1997
3360
|
class ERBPreferImageTagHelperRule extends ParserRule {
|
|
1998
|
-
|
|
3361
|
+
static ruleName = "erb-prefer-image-tag-helper";
|
|
1999
3362
|
get defaultConfig() {
|
|
2000
3363
|
return {
|
|
2001
3364
|
enabled: true,
|
|
@@ -2003,7 +3366,7 @@ class ERBPreferImageTagHelperRule extends ParserRule {
|
|
|
2003
3366
|
};
|
|
2004
3367
|
}
|
|
2005
3368
|
check(result, context) {
|
|
2006
|
-
const visitor = new ERBPreferImageTagHelperVisitor(this.
|
|
3369
|
+
const visitor = new ERBPreferImageTagHelperVisitor(this.ruleName, context);
|
|
2007
3370
|
visitor.visit(result.value);
|
|
2008
3371
|
return visitor.offenses;
|
|
2009
3372
|
}
|
|
@@ -2025,7 +3388,7 @@ class ERBRequireTrailingNewlineVisitor extends BaseSourceRuleVisitor {
|
|
|
2025
3388
|
}
|
|
2026
3389
|
class ERBRequireTrailingNewlineRule extends SourceRule {
|
|
2027
3390
|
static autocorrectable = true;
|
|
2028
|
-
|
|
3391
|
+
static ruleName = "erb-require-trailing-newline";
|
|
2029
3392
|
get defaultConfig() {
|
|
2030
3393
|
return {
|
|
2031
3394
|
enabled: true,
|
|
@@ -2033,7 +3396,7 @@ class ERBRequireTrailingNewlineRule extends SourceRule {
|
|
|
2033
3396
|
};
|
|
2034
3397
|
}
|
|
2035
3398
|
check(source, context) {
|
|
2036
|
-
const visitor = new ERBRequireTrailingNewlineVisitor(this.
|
|
3399
|
+
const visitor = new ERBRequireTrailingNewlineVisitor(this.ruleName, context);
|
|
2037
3400
|
visitor.visit(source);
|
|
2038
3401
|
return visitor.offenses;
|
|
2039
3402
|
}
|
|
@@ -2060,7 +3423,22 @@ class RequireWhitespaceInsideTags extends BaseRuleVisitor {
|
|
|
2060
3423
|
}
|
|
2061
3424
|
}
|
|
2062
3425
|
checkCommentTagWhitespace(node, openTag, closeTag, content) {
|
|
2063
|
-
|
|
3426
|
+
const commentedTagPrefix = this.getCommentedTagPrefix(content);
|
|
3427
|
+
if (commentedTagPrefix) {
|
|
3428
|
+
const afterPrefix = content.substring(commentedTagPrefix.length);
|
|
3429
|
+
const tag = `<%#${commentedTagPrefix}`;
|
|
3430
|
+
if (afterPrefix.length > 0 && !afterPrefix[0].match(/\s/)) {
|
|
3431
|
+
this.addOffense(`Add whitespace after \`${tag}\`. This looks like a temporarily commented ERB tag.`, openTag.location, {
|
|
3432
|
+
node,
|
|
3433
|
+
openTag,
|
|
3434
|
+
closeTag,
|
|
3435
|
+
content,
|
|
3436
|
+
fixType: "after-comment-equals",
|
|
3437
|
+
unsafe: true,
|
|
3438
|
+
}, "info");
|
|
3439
|
+
}
|
|
3440
|
+
}
|
|
3441
|
+
else if (!content.startsWith(" ") && !content.startsWith("\n")) {
|
|
2064
3442
|
this.addOffense(`Add whitespace after \`${openTag.value}\`.`, openTag.location, {
|
|
2065
3443
|
node,
|
|
2066
3444
|
openTag,
|
|
@@ -2069,15 +3447,6 @@ class RequireWhitespaceInsideTags extends BaseRuleVisitor {
|
|
|
2069
3447
|
fixType: "after-open"
|
|
2070
3448
|
});
|
|
2071
3449
|
}
|
|
2072
|
-
else if (content.startsWith("=") && content.length > 1 && !content[1].match(/\s/)) {
|
|
2073
|
-
this.addOffense(`Add whitespace after \`<%#=\`.`, openTag.location, {
|
|
2074
|
-
node,
|
|
2075
|
-
openTag,
|
|
2076
|
-
closeTag,
|
|
2077
|
-
content,
|
|
2078
|
-
fixType: "after-comment-equals"
|
|
2079
|
-
});
|
|
2080
|
-
}
|
|
2081
3450
|
if (!content.endsWith(" ") && !content.endsWith("\n")) {
|
|
2082
3451
|
this.addOffense(`Add whitespace before \`${closeTag.value}\`.`, closeTag.location, {
|
|
2083
3452
|
node,
|
|
@@ -2100,6 +3469,21 @@ class RequireWhitespaceInsideTags extends BaseRuleVisitor {
|
|
|
2100
3469
|
fixType: "after-open"
|
|
2101
3470
|
});
|
|
2102
3471
|
}
|
|
3472
|
+
getCommentedTagPrefix(content) {
|
|
3473
|
+
if (content.startsWith("graphql"))
|
|
3474
|
+
return "graphql";
|
|
3475
|
+
if (content.startsWith("%="))
|
|
3476
|
+
return "%=";
|
|
3477
|
+
if (content.startsWith("=="))
|
|
3478
|
+
return "==";
|
|
3479
|
+
if (content.startsWith("%"))
|
|
3480
|
+
return "%";
|
|
3481
|
+
if (content.startsWith("="))
|
|
3482
|
+
return "=";
|
|
3483
|
+
if (content.startsWith("-"))
|
|
3484
|
+
return "-";
|
|
3485
|
+
return null;
|
|
3486
|
+
}
|
|
2103
3487
|
checkCloseTagWhitespace(node, openTag, closeTag, content) {
|
|
2104
3488
|
if (content.endsWith(" ") || content.endsWith("\n")) {
|
|
2105
3489
|
return;
|
|
@@ -2115,7 +3499,7 @@ class RequireWhitespaceInsideTags extends BaseRuleVisitor {
|
|
|
2115
3499
|
}
|
|
2116
3500
|
class ERBRequireWhitespaceRule extends ParserRule {
|
|
2117
3501
|
static autocorrectable = true;
|
|
2118
|
-
|
|
3502
|
+
static ruleName = "erb-require-whitespace-inside-tags";
|
|
2119
3503
|
get defaultConfig() {
|
|
2120
3504
|
return {
|
|
2121
3505
|
enabled: true,
|
|
@@ -2123,7 +3507,7 @@ class ERBRequireWhitespaceRule extends ParserRule {
|
|
|
2123
3507
|
};
|
|
2124
3508
|
}
|
|
2125
3509
|
check(result, context) {
|
|
2126
|
-
const visitor = new RequireWhitespaceInsideTags(this.
|
|
3510
|
+
const visitor = new RequireWhitespaceInsideTags(this.ruleName, context);
|
|
2127
3511
|
visitor.visit(result.value);
|
|
2128
3512
|
return visitor.offenses;
|
|
2129
3513
|
}
|
|
@@ -2142,9 +3526,12 @@ class ERBRequireWhitespaceRule extends ParserRule {
|
|
|
2142
3526
|
node.content.value = " " + content;
|
|
2143
3527
|
return result;
|
|
2144
3528
|
}
|
|
2145
|
-
if (fixType === "after-comment-equals"
|
|
2146
|
-
|
|
2147
|
-
|
|
3529
|
+
if (fixType === "after-comment-equals") {
|
|
3530
|
+
const prefix = content.startsWith("graphql") ? "graphql" : content.startsWith("%=") ? "%=" : content.startsWith("==") ? "==" : content.startsWith("%") ? "%" : content.startsWith("=") ? "=" : content.startsWith("-") ? "-" : null;
|
|
3531
|
+
if (prefix) {
|
|
3532
|
+
node.content.value = prefix + " " + content.substring(prefix.length);
|
|
3533
|
+
return result;
|
|
3534
|
+
}
|
|
2148
3535
|
}
|
|
2149
3536
|
return null;
|
|
2150
3537
|
}
|
|
@@ -2162,7 +3549,7 @@ class ERBRightTrimVisitor extends BaseRuleVisitor {
|
|
|
2162
3549
|
}
|
|
2163
3550
|
class ERBRightTrimRule extends ParserRule {
|
|
2164
3551
|
static autocorrectable = true;
|
|
2165
|
-
|
|
3552
|
+
static ruleName = "erb-right-trim";
|
|
2166
3553
|
get defaultConfig() {
|
|
2167
3554
|
return {
|
|
2168
3555
|
enabled: true,
|
|
@@ -2170,7 +3557,7 @@ class ERBRightTrimRule extends ParserRule {
|
|
|
2170
3557
|
};
|
|
2171
3558
|
}
|
|
2172
3559
|
check(result, context) {
|
|
2173
|
-
const visitor = new ERBRightTrimVisitor(this.
|
|
3560
|
+
const visitor = new ERBRightTrimVisitor(this.ruleName, context);
|
|
2174
3561
|
visitor.visit(result.value);
|
|
2175
3562
|
return visitor.offenses;
|
|
2176
3563
|
}
|
|
@@ -2193,27 +3580,6 @@ class ERBRightTrimRule extends ParserRule {
|
|
|
2193
3580
|
}
|
|
2194
3581
|
}
|
|
2195
3582
|
|
|
2196
|
-
/**
|
|
2197
|
-
* File path and naming utilities for linter rules
|
|
2198
|
-
*/
|
|
2199
|
-
/**
|
|
2200
|
-
* Extracts the basename (filename) from a file path
|
|
2201
|
-
* Works with both forward slashes and backslashes
|
|
2202
|
-
*/
|
|
2203
|
-
function getBasename(filePath) {
|
|
2204
|
-
const lastSlash = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\"));
|
|
2205
|
-
return lastSlash === -1 ? filePath : filePath.slice(lastSlash + 1);
|
|
2206
|
-
}
|
|
2207
|
-
/**
|
|
2208
|
-
* Checks if a file is a Rails partial (filename starts with `_`)
|
|
2209
|
-
* Returns null if fileName is undefined (unknown context)
|
|
2210
|
-
*/
|
|
2211
|
-
function isPartialFile(fileName) {
|
|
2212
|
-
if (!fileName)
|
|
2213
|
-
return null;
|
|
2214
|
-
return getBasename(fileName).startsWith("_");
|
|
2215
|
-
}
|
|
2216
|
-
|
|
2217
3583
|
/**
|
|
2218
3584
|
* Checks if parentheses in a string are balanced
|
|
2219
3585
|
* Returns false if there are more closing parens than opening at any point
|
|
@@ -2286,7 +3652,7 @@ function splitByTopLevelComma(str) {
|
|
|
2286
3652
|
return result;
|
|
2287
3653
|
}
|
|
2288
3654
|
|
|
2289
|
-
const STRICT_LOCALS_PATTERN = /^locals:\s+\(
|
|
3655
|
+
const STRICT_LOCALS_PATTERN = /^locals:\s+\(.*\)\s*$/s;
|
|
2290
3656
|
function isValidStrictLocalsFormat(content) {
|
|
2291
3657
|
return STRICT_LOCALS_PATTERN.test(content);
|
|
2292
3658
|
}
|
|
@@ -2311,13 +3677,13 @@ function detectLocalsWithoutColon(content) {
|
|
|
2311
3677
|
return /^locals?\(/.test(content);
|
|
2312
3678
|
}
|
|
2313
3679
|
function detectSingularLocal(content) {
|
|
2314
|
-
return
|
|
3680
|
+
return content.startsWith('local:');
|
|
2315
3681
|
}
|
|
2316
3682
|
function detectMissingColonBeforeParens(content) {
|
|
2317
3683
|
return /^locals\s+\(/.test(content);
|
|
2318
3684
|
}
|
|
2319
3685
|
function detectMissingSpaceAfterColon(content) {
|
|
2320
|
-
return
|
|
3686
|
+
return content.startsWith('locals:(');
|
|
2321
3687
|
}
|
|
2322
3688
|
function detectMissingParentheses(content) {
|
|
2323
3689
|
return /^locals:\s*[^(]/.test(content);
|
|
@@ -2481,7 +3847,7 @@ class ERBStrictLocalsCommentSyntaxVisitor extends BaseRuleVisitor {
|
|
|
2481
3847
|
}
|
|
2482
3848
|
}
|
|
2483
3849
|
class ERBStrictLocalsCommentSyntaxRule extends ParserRule {
|
|
2484
|
-
|
|
3850
|
+
static ruleName = "erb-strict-locals-comment-syntax";
|
|
2485
3851
|
get defaultConfig() {
|
|
2486
3852
|
return {
|
|
2487
3853
|
enabled: true,
|
|
@@ -2489,7 +3855,7 @@ class ERBStrictLocalsCommentSyntaxRule extends ParserRule {
|
|
|
2489
3855
|
};
|
|
2490
3856
|
}
|
|
2491
3857
|
check(result, context) {
|
|
2492
|
-
const visitor = new ERBStrictLocalsCommentSyntaxVisitor(this.
|
|
3858
|
+
const visitor = new ERBStrictLocalsCommentSyntaxVisitor(this.ruleName, context);
|
|
2493
3859
|
visitor.visit(result.value);
|
|
2494
3860
|
return visitor.offenses;
|
|
2495
3861
|
}
|
|
@@ -2512,7 +3878,7 @@ class ERBStrictLocalsRequiredVisitor extends BaseSourceRuleVisitor {
|
|
|
2512
3878
|
}
|
|
2513
3879
|
class ERBStrictLocalsRequiredRule extends SourceRule {
|
|
2514
3880
|
static unsafeAutocorrectable = true;
|
|
2515
|
-
|
|
3881
|
+
static ruleName = "erb-strict-locals-required";
|
|
2516
3882
|
get defaultConfig() {
|
|
2517
3883
|
return {
|
|
2518
3884
|
enabled: false,
|
|
@@ -2520,7 +3886,7 @@ class ERBStrictLocalsRequiredRule extends SourceRule {
|
|
|
2520
3886
|
};
|
|
2521
3887
|
}
|
|
2522
3888
|
check(source, context) {
|
|
2523
|
-
const visitor = new ERBStrictLocalsRequiredVisitor(this.
|
|
3889
|
+
const visitor = new ERBStrictLocalsRequiredVisitor(this.ruleName, context);
|
|
2524
3890
|
visitor.visit(source);
|
|
2525
3891
|
return visitor.offenses;
|
|
2526
3892
|
}
|
|
@@ -2688,71 +4054,75 @@ class HerbDisableCommentParsedVisitor extends HerbDisableCommentBaseVisitor {
|
|
|
2688
4054
|
}
|
|
2689
4055
|
}
|
|
2690
4056
|
|
|
2691
|
-
class
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
4057
|
+
class HerbDisableCommentMalformedVisitor extends HerbDisableCommentBaseVisitor {
|
|
4058
|
+
checkHerbDisableComment(node, content) {
|
|
4059
|
+
const trimmed = content.trim();
|
|
4060
|
+
const looksLikeHerbDisable = trimmed.startsWith("herb:disable");
|
|
4061
|
+
if (!looksLikeHerbDisable)
|
|
4062
|
+
return;
|
|
4063
|
+
if (trimmed.length > "herb:disable".length) {
|
|
4064
|
+
const charAfterPrefix = trimmed["herb:disable".length];
|
|
4065
|
+
if (charAfterPrefix !== ' ' && charAfterPrefix !== '\t' && charAfterPrefix !== '\n') {
|
|
4066
|
+
this.addOffense("`herb:disable` comment is missing a space after `herb:disable`. Add a space before the rule names.", node.location);
|
|
4067
|
+
return;
|
|
4068
|
+
}
|
|
4069
|
+
}
|
|
4070
|
+
const afterPrefix = trimmed.substring("herb:disable".length).trim();
|
|
4071
|
+
if (afterPrefix.length === 0)
|
|
4072
|
+
return;
|
|
4073
|
+
const parsed = parseHerbDisableContent(content);
|
|
4074
|
+
if (parsed !== null)
|
|
4075
|
+
return;
|
|
4076
|
+
let message = "`herb:disable` comment is malformed.";
|
|
4077
|
+
const rulesString = afterPrefix.trim();
|
|
4078
|
+
if (rulesString.endsWith(',')) {
|
|
4079
|
+
message = "`herb:disable` comment has a trailing comma. Remove the trailing comma.";
|
|
4080
|
+
}
|
|
4081
|
+
else if (rulesString.includes(',,') || rulesString.match(/,\s*,/)) {
|
|
4082
|
+
message = "`herb:disable` comment has consecutive commas. Remove extra commas.";
|
|
4083
|
+
}
|
|
4084
|
+
else if (rulesString.startsWith(',')) {
|
|
4085
|
+
message = "`herb:disable` comment starts with a comma. Remove the leading comma.";
|
|
4086
|
+
}
|
|
4087
|
+
this.addOffense(message, node.location);
|
|
2710
4088
|
}
|
|
2711
4089
|
}
|
|
2712
|
-
class
|
|
2713
|
-
|
|
4090
|
+
class HerbDisableCommentMalformedRule extends ParserRule {
|
|
4091
|
+
static ruleName = "herb-disable-comment-malformed";
|
|
2714
4092
|
get defaultConfig() {
|
|
2715
4093
|
return {
|
|
2716
4094
|
enabled: true,
|
|
2717
|
-
severity: "
|
|
4095
|
+
severity: "error"
|
|
2718
4096
|
};
|
|
2719
4097
|
}
|
|
2720
4098
|
check(result, context) {
|
|
2721
|
-
const
|
|
2722
|
-
if (!validRuleNames)
|
|
2723
|
-
return [];
|
|
2724
|
-
if (validRuleNames.length === 0)
|
|
2725
|
-
return [];
|
|
2726
|
-
const visitor = new HerbDisableCommentValidRuleNameVisitor(this.name, validRuleNames, context);
|
|
4099
|
+
const visitor = new HerbDisableCommentMalformedVisitor(this.ruleName, context);
|
|
2727
4100
|
visitor.visit(result.value);
|
|
2728
4101
|
return visitor.offenses;
|
|
2729
4102
|
}
|
|
2730
4103
|
}
|
|
2731
4104
|
|
|
2732
|
-
class
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
if (herbDisable.ruleNames.length <= 1)
|
|
4105
|
+
class HerbDisableCommentMissingRulesVisitor extends HerbDisableCommentBaseVisitor {
|
|
4106
|
+
checkHerbDisableComment(node, content) {
|
|
4107
|
+
const herbDisable = parseHerbDisableContent(content);
|
|
4108
|
+
if (herbDisable)
|
|
2737
4109
|
return;
|
|
2738
|
-
const
|
|
2739
|
-
if (!
|
|
4110
|
+
const emptyFormat = /^\s*herb:disable\s*$/;
|
|
4111
|
+
if (!emptyFormat.test(content))
|
|
2740
4112
|
return;
|
|
2741
|
-
|
|
2742
|
-
const message = `Using \`all\` with specific rules is redundant. Use \`herb:disable all\` by itself or list only specific rules.`;
|
|
2743
|
-
this.addOffenseWithFallback(message, location, node);
|
|
4113
|
+
this.addOffense(`\`herb:disable\` comment is missing rule names. Specify \`all\` or list specific rules to disable.`, node.location);
|
|
2744
4114
|
}
|
|
2745
4115
|
}
|
|
2746
|
-
class
|
|
2747
|
-
|
|
4116
|
+
class HerbDisableCommentMissingRulesRule extends ParserRule {
|
|
4117
|
+
static ruleName = "herb-disable-comment-missing-rules";
|
|
2748
4118
|
get defaultConfig() {
|
|
2749
4119
|
return {
|
|
2750
4120
|
enabled: true,
|
|
2751
|
-
severity: "
|
|
4121
|
+
severity: "error"
|
|
2752
4122
|
};
|
|
2753
4123
|
}
|
|
2754
4124
|
check(result, context) {
|
|
2755
|
-
const visitor = new
|
|
4125
|
+
const visitor = new HerbDisableCommentMissingRulesVisitor(this.ruleName, context);
|
|
2756
4126
|
visitor.visit(result.value);
|
|
2757
4127
|
return visitor.offenses;
|
|
2758
4128
|
}
|
|
@@ -2774,7 +4144,7 @@ class HerbDisableCommentNoDuplicateRulesVisitor extends HerbDisableCommentParsed
|
|
|
2774
4144
|
}
|
|
2775
4145
|
}
|
|
2776
4146
|
class HerbDisableCommentNoDuplicateRulesRule extends ParserRule {
|
|
2777
|
-
|
|
4147
|
+
static ruleName = "herb-disable-comment-no-duplicate-rules";
|
|
2778
4148
|
get defaultConfig() {
|
|
2779
4149
|
return {
|
|
2780
4150
|
enabled: true,
|
|
@@ -2782,81 +4152,36 @@ class HerbDisableCommentNoDuplicateRulesRule extends ParserRule {
|
|
|
2782
4152
|
};
|
|
2783
4153
|
}
|
|
2784
4154
|
check(result, context) {
|
|
2785
|
-
const visitor = new HerbDisableCommentNoDuplicateRulesVisitor(this.
|
|
2786
|
-
visitor.visit(result.value);
|
|
2787
|
-
return visitor.offenses;
|
|
2788
|
-
}
|
|
2789
|
-
}
|
|
2790
|
-
|
|
2791
|
-
class HerbDisableCommentMissingRulesVisitor extends HerbDisableCommentBaseVisitor {
|
|
2792
|
-
checkHerbDisableComment(node, content) {
|
|
2793
|
-
const herbDisable = parseHerbDisableContent(content);
|
|
2794
|
-
if (herbDisable)
|
|
2795
|
-
return;
|
|
2796
|
-
const emptyFormat = /^\s*herb:disable\s*$/;
|
|
2797
|
-
if (!emptyFormat.test(content))
|
|
2798
|
-
return;
|
|
2799
|
-
this.addOffense(`\`herb:disable\` comment is missing rule names. Specify \`all\` or list specific rules to disable.`, node.location);
|
|
2800
|
-
}
|
|
2801
|
-
}
|
|
2802
|
-
class HerbDisableCommentMissingRulesRule extends ParserRule {
|
|
2803
|
-
name = "herb-disable-comment-missing-rules";
|
|
2804
|
-
get defaultConfig() {
|
|
2805
|
-
return {
|
|
2806
|
-
enabled: true,
|
|
2807
|
-
severity: "error"
|
|
2808
|
-
};
|
|
2809
|
-
}
|
|
2810
|
-
check(result, context) {
|
|
2811
|
-
const visitor = new HerbDisableCommentMissingRulesVisitor(this.name, context);
|
|
4155
|
+
const visitor = new HerbDisableCommentNoDuplicateRulesVisitor(this.ruleName, context);
|
|
2812
4156
|
visitor.visit(result.value);
|
|
2813
4157
|
return visitor.offenses;
|
|
2814
4158
|
}
|
|
2815
4159
|
}
|
|
2816
4160
|
|
|
2817
|
-
class
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
const looksLikeHerbDisable = trimmed.startsWith("herb:disable");
|
|
2821
|
-
if (!looksLikeHerbDisable)
|
|
4161
|
+
class HerbDisableCommentNoRedundantAllVisitor extends HerbDisableCommentParsedVisitor {
|
|
4162
|
+
checkParsedHerbDisable(node, _content, herbDisable) {
|
|
4163
|
+
if (!herbDisable.ruleNames.includes("all"))
|
|
2822
4164
|
return;
|
|
2823
|
-
if (
|
|
2824
|
-
const charAfterPrefix = trimmed["herb:disable".length];
|
|
2825
|
-
if (charAfterPrefix !== ' ' && charAfterPrefix !== '\t' && charAfterPrefix !== '\n') {
|
|
2826
|
-
this.addOffense("`herb:disable` comment is missing a space after `herb:disable`. Add a space before the rule names.", node.location);
|
|
2827
|
-
return;
|
|
2828
|
-
}
|
|
2829
|
-
}
|
|
2830
|
-
const afterPrefix = trimmed.substring("herb:disable".length).trim();
|
|
2831
|
-
if (afterPrefix.length === 0)
|
|
4165
|
+
if (herbDisable.ruleNames.length <= 1)
|
|
2832
4166
|
return;
|
|
2833
|
-
const
|
|
2834
|
-
if (
|
|
4167
|
+
const allDetail = herbDisable.ruleNameDetails.find(detail => detail.name === "all");
|
|
4168
|
+
if (!allDetail)
|
|
2835
4169
|
return;
|
|
2836
|
-
|
|
2837
|
-
const
|
|
2838
|
-
|
|
2839
|
-
message = "`herb:disable` comment has a trailing comma. Remove the trailing comma.";
|
|
2840
|
-
}
|
|
2841
|
-
else if (rulesString.includes(',,') || rulesString.match(/,\s*,/)) {
|
|
2842
|
-
message = "`herb:disable` comment has consecutive commas. Remove extra commas.";
|
|
2843
|
-
}
|
|
2844
|
-
else if (rulesString.startsWith(',')) {
|
|
2845
|
-
message = "`herb:disable` comment starts with a comma. Remove the leading comma.";
|
|
2846
|
-
}
|
|
2847
|
-
this.addOffense(message, node.location);
|
|
4170
|
+
const location = this.createRuleNameLocation(node, allDetail);
|
|
4171
|
+
const message = `Using \`all\` with specific rules is redundant. Use \`herb:disable all\` by itself or list only specific rules.`;
|
|
4172
|
+
this.addOffenseWithFallback(message, location, node);
|
|
2848
4173
|
}
|
|
2849
4174
|
}
|
|
2850
|
-
class
|
|
2851
|
-
|
|
4175
|
+
class HerbDisableCommentNoRedundantAllRule extends ParserRule {
|
|
4176
|
+
static ruleName = "herb-disable-comment-no-redundant-all";
|
|
2852
4177
|
get defaultConfig() {
|
|
2853
4178
|
return {
|
|
2854
4179
|
enabled: true,
|
|
2855
|
-
severity: "
|
|
4180
|
+
severity: "warning"
|
|
2856
4181
|
};
|
|
2857
4182
|
}
|
|
2858
4183
|
check(result, context) {
|
|
2859
|
-
const visitor = new
|
|
4184
|
+
const visitor = new HerbDisableCommentNoRedundantAllVisitor(this.ruleName, context);
|
|
2860
4185
|
visitor.visit(result.value);
|
|
2861
4186
|
return visitor.offenses;
|
|
2862
4187
|
}
|
|
@@ -2903,7 +4228,7 @@ class HerbDisableCommentUnnecessaryVisitor extends HerbDisableCommentParsedVisit
|
|
|
2903
4228
|
}
|
|
2904
4229
|
}
|
|
2905
4230
|
class HerbDisableCommentUnnecessaryRule extends ParserRule {
|
|
2906
|
-
|
|
4231
|
+
static ruleName = "herb-disable-comment-unnecessary";
|
|
2907
4232
|
get defaultConfig() {
|
|
2908
4233
|
return {
|
|
2909
4234
|
enabled: true,
|
|
@@ -2919,37 +4244,161 @@ class HerbDisableCommentUnnecessaryRule extends ParserRule {
|
|
|
2919
4244
|
return [];
|
|
2920
4245
|
if (!ignoredOffensesByLine)
|
|
2921
4246
|
return [];
|
|
2922
|
-
const visitor = new HerbDisableCommentUnnecessaryVisitor(this.
|
|
4247
|
+
const visitor = new HerbDisableCommentUnnecessaryVisitor(this.ruleName, ignoredOffensesByLine, validRuleNames, context);
|
|
4248
|
+
visitor.visit(result.value);
|
|
4249
|
+
return visitor.offenses;
|
|
4250
|
+
}
|
|
4251
|
+
}
|
|
4252
|
+
|
|
4253
|
+
class HerbDisableCommentValidRuleNameVisitor extends HerbDisableCommentParsedVisitor {
|
|
4254
|
+
validRuleNames = new Set();
|
|
4255
|
+
validRuleNamesList = [];
|
|
4256
|
+
constructor(ruleName, validRuleNames, context) {
|
|
4257
|
+
super(ruleName, context);
|
|
4258
|
+
this.validRuleNames = new Set([...validRuleNames, "all"]);
|
|
4259
|
+
this.validRuleNamesList = Array.from(this.validRuleNames);
|
|
4260
|
+
}
|
|
4261
|
+
checkParsedHerbDisable(node, _content, herbDisable) {
|
|
4262
|
+
herbDisable.ruleNameDetails.forEach(ruleDetail => {
|
|
4263
|
+
if (this.validRuleNames.has(ruleDetail.name))
|
|
4264
|
+
return;
|
|
4265
|
+
const suggestion = didyoumean(ruleDetail.name, this.validRuleNamesList);
|
|
4266
|
+
const message = suggestion
|
|
4267
|
+
? `Unknown rule \`${ruleDetail.name}\`. Did you mean \`${suggestion}\`?`
|
|
4268
|
+
: `Unknown rule \`${ruleDetail.name}\`.`;
|
|
4269
|
+
const location = this.createRuleNameLocation(node, ruleDetail);
|
|
4270
|
+
this.addOffenseWithFallback(message, location, node);
|
|
4271
|
+
});
|
|
4272
|
+
}
|
|
4273
|
+
}
|
|
4274
|
+
class HerbDisableCommentValidRuleNameRule extends ParserRule {
|
|
4275
|
+
static ruleName = "herb-disable-comment-valid-rule-name";
|
|
4276
|
+
get defaultConfig() {
|
|
4277
|
+
return {
|
|
4278
|
+
enabled: true,
|
|
4279
|
+
severity: "warning"
|
|
4280
|
+
};
|
|
4281
|
+
}
|
|
4282
|
+
check(result, context) {
|
|
4283
|
+
const validRuleNames = context?.validRuleNames;
|
|
4284
|
+
if (!validRuleNames)
|
|
4285
|
+
return [];
|
|
4286
|
+
if (validRuleNames.length === 0)
|
|
4287
|
+
return [];
|
|
4288
|
+
const visitor = new HerbDisableCommentValidRuleNameVisitor(this.ruleName, validRuleNames, context);
|
|
2923
4289
|
visitor.visit(result.value);
|
|
2924
4290
|
return visitor.offenses;
|
|
2925
4291
|
}
|
|
2926
4292
|
}
|
|
2927
4293
|
|
|
2928
|
-
|
|
4294
|
+
const ALLOWED_TYPES = ["text/javascript"];
|
|
4295
|
+
class AllowedScriptTypeVisitor extends BaseRuleVisitor {
|
|
2929
4296
|
visitHTMLOpenTagNode(node) {
|
|
4297
|
+
if (getTagLocalName(node) === "script") {
|
|
4298
|
+
this.visitScriptNode(node);
|
|
4299
|
+
}
|
|
4300
|
+
}
|
|
4301
|
+
visitScriptNode(node) {
|
|
4302
|
+
const typeAttribute = getAttribute(node, "type");
|
|
4303
|
+
if (!typeAttribute) {
|
|
4304
|
+
return;
|
|
4305
|
+
}
|
|
4306
|
+
if (!hasAttributeValue(typeAttribute)) {
|
|
4307
|
+
this.addOffense("Avoid using an empty `type` attribute on the `<script>` tag. Either set a valid type or remove the attribute entirely.", typeAttribute.location);
|
|
4308
|
+
return;
|
|
4309
|
+
}
|
|
4310
|
+
this.validateTypeAttribute(typeAttribute);
|
|
4311
|
+
}
|
|
4312
|
+
validateTypeAttribute(typeAttribute) {
|
|
4313
|
+
const typeValue = getStaticAttributeValue(typeAttribute);
|
|
4314
|
+
if (typeValue === null)
|
|
4315
|
+
return;
|
|
4316
|
+
if (typeValue === "") {
|
|
4317
|
+
this.addOffense("Avoid using an empty `type` attribute on the `<script>` tag. Either set a valid type or remove the attribute entirely.", typeAttribute.location);
|
|
4318
|
+
return;
|
|
4319
|
+
}
|
|
4320
|
+
if (ALLOWED_TYPES.includes(typeValue))
|
|
4321
|
+
return;
|
|
4322
|
+
this.addOffense(`Avoid using \`${typeValue}\` as the \`type\` attribute for the \`<script>\` tag. ` +
|
|
4323
|
+
`Must be one of: ${ALLOWED_TYPES.map(t => `\`${t}\``).join(", ")}` +
|
|
4324
|
+
`${" or blank" }.`, typeAttribute.location);
|
|
4325
|
+
}
|
|
4326
|
+
}
|
|
4327
|
+
class HTMLAllowedScriptTypeRule extends ParserRule {
|
|
4328
|
+
static ruleName = "html-allowed-script-type";
|
|
4329
|
+
get defaultConfig() {
|
|
4330
|
+
return {
|
|
4331
|
+
enabled: true,
|
|
4332
|
+
severity: "error"
|
|
4333
|
+
};
|
|
4334
|
+
}
|
|
4335
|
+
check(result, context) {
|
|
4336
|
+
const visitor = new AllowedScriptTypeVisitor(this.ruleName, context);
|
|
4337
|
+
visitor.visit(result.value);
|
|
4338
|
+
return visitor.offenses;
|
|
4339
|
+
}
|
|
4340
|
+
}
|
|
4341
|
+
|
|
4342
|
+
class AnchorRequireHrefVisitor extends BaseRuleVisitor {
|
|
4343
|
+
visitHTMLElementNode(node) {
|
|
2930
4344
|
this.checkATag(node);
|
|
2931
|
-
super.
|
|
4345
|
+
super.visitHTMLElementNode(node);
|
|
2932
4346
|
}
|
|
2933
4347
|
checkATag(node) {
|
|
2934
|
-
const tagName =
|
|
4348
|
+
const tagName = getTagLocalName(node);
|
|
2935
4349
|
if (tagName !== "a") {
|
|
2936
4350
|
return;
|
|
2937
4351
|
}
|
|
2938
|
-
|
|
2939
|
-
|
|
4352
|
+
const hrefAttribute = this.getHrefAttribute(node);
|
|
4353
|
+
if (!hrefAttribute) {
|
|
4354
|
+
this.addOffense("Add an `href` attribute to `<a>` to ensure it is focusable and accessible. Links should navigate somewhere. If you need a clickable element without navigation, use a `<button>` instead.", node.tag_name.location);
|
|
4355
|
+
return;
|
|
4356
|
+
}
|
|
4357
|
+
const hrefValue = getStaticAttributeValue(hrefAttribute);
|
|
4358
|
+
if (hrefValue === "#") {
|
|
4359
|
+
this.addOffense('Avoid `href="#"` on `<a>`. `href="#"` does not navigate anywhere, scrolls the page to the top, and adds `#` to the URL. If you need a clickable element without navigation, use a `<button>` instead.', hrefAttribute.location);
|
|
4360
|
+
return;
|
|
4361
|
+
}
|
|
4362
|
+
if (hrefValue !== null && hrefValue.startsWith("javascript:void")) {
|
|
4363
|
+
this.addOffense('Avoid `javascript:void(0)` in `href` on `<a>`. Links should navigate somewhere. If you need a clickable element without navigation, use a `<button>` instead.', hrefAttribute.location);
|
|
4364
|
+
return;
|
|
4365
|
+
}
|
|
4366
|
+
if (this.hasNilHrefValue(hrefAttribute)) {
|
|
4367
|
+
this.addOffense("Avoid passing `nil` as the URL for `link_to`. Links should navigate somewhere. If you need a clickable element without navigation, use a `<button>` instead.", hrefAttribute.location);
|
|
4368
|
+
}
|
|
4369
|
+
}
|
|
4370
|
+
hasNilHrefValue(hrefAttribute) {
|
|
4371
|
+
const valueNode = hrefAttribute.value;
|
|
4372
|
+
if (!valueNode)
|
|
4373
|
+
return false;
|
|
4374
|
+
return valueNode.children.some(child => isRubyLiteralNode(child) && child.content === "url_for(nil)");
|
|
4375
|
+
}
|
|
4376
|
+
getHrefAttribute(node) {
|
|
4377
|
+
const openTag = node.open_tag;
|
|
4378
|
+
if (isHTMLOpenTagNode(openTag)) {
|
|
4379
|
+
return getAttribute(openTag, "href");
|
|
4380
|
+
}
|
|
4381
|
+
if (isERBOpenTagNode(openTag)) {
|
|
4382
|
+
return findAttributeByName(filterHTMLAttributeNodes(openTag.children), "href");
|
|
2940
4383
|
}
|
|
4384
|
+
return null;
|
|
2941
4385
|
}
|
|
2942
4386
|
}
|
|
2943
4387
|
class HTMLAnchorRequireHrefRule extends ParserRule {
|
|
2944
|
-
|
|
4388
|
+
static ruleName = "html-anchor-require-href";
|
|
2945
4389
|
get defaultConfig() {
|
|
2946
4390
|
return {
|
|
2947
4391
|
enabled: true,
|
|
2948
4392
|
severity: "error"
|
|
2949
4393
|
};
|
|
2950
4394
|
}
|
|
4395
|
+
get parserOptions() {
|
|
4396
|
+
return {
|
|
4397
|
+
action_view_helpers: true,
|
|
4398
|
+
};
|
|
4399
|
+
}
|
|
2951
4400
|
check(result, context) {
|
|
2952
|
-
const visitor = new
|
|
4401
|
+
const visitor = new AnchorRequireHrefVisitor(this.ruleName, context);
|
|
2953
4402
|
visitor.visit(result.value);
|
|
2954
4403
|
return visitor.offenses;
|
|
2955
4404
|
}
|
|
@@ -2971,15 +4420,15 @@ class AriaAttributeMustBeValid extends AttributeVisitorMixin {
|
|
|
2971
4420
|
}
|
|
2972
4421
|
}
|
|
2973
4422
|
class HTMLAriaAttributeMustBeValid extends ParserRule {
|
|
2974
|
-
|
|
4423
|
+
static ruleName = "html-aria-attribute-must-be-valid";
|
|
2975
4424
|
get defaultConfig() {
|
|
2976
4425
|
return {
|
|
2977
4426
|
enabled: true,
|
|
2978
|
-
severity: "
|
|
4427
|
+
severity: "warning"
|
|
2979
4428
|
};
|
|
2980
4429
|
}
|
|
2981
4430
|
check(result, context) {
|
|
2982
|
-
const visitor = new AriaAttributeMustBeValid(this.
|
|
4431
|
+
const visitor = new AriaAttributeMustBeValid(this.ruleName, context);
|
|
2983
4432
|
visitor.visit(result.value);
|
|
2984
4433
|
return visitor.offenses;
|
|
2985
4434
|
}
|
|
@@ -3008,15 +4457,15 @@ class AriaLabelIsWellFormattedVisitor extends AttributeVisitorMixin {
|
|
|
3008
4457
|
}
|
|
3009
4458
|
}
|
|
3010
4459
|
class HTMLAriaLabelIsWellFormattedRule extends ParserRule {
|
|
3011
|
-
|
|
4460
|
+
static ruleName = "html-aria-label-is-well-formatted";
|
|
3012
4461
|
get defaultConfig() {
|
|
3013
4462
|
return {
|
|
3014
4463
|
enabled: true,
|
|
3015
|
-
severity: "
|
|
4464
|
+
severity: "warning"
|
|
3016
4465
|
};
|
|
3017
4466
|
}
|
|
3018
4467
|
check(result, context) {
|
|
3019
|
-
const visitor = new AriaLabelIsWellFormattedVisitor(this.
|
|
4468
|
+
const visitor = new AriaLabelIsWellFormattedVisitor(this.ruleName, context);
|
|
3020
4469
|
visitor.visit(result.value);
|
|
3021
4470
|
return visitor.offenses;
|
|
3022
4471
|
}
|
|
@@ -3060,15 +4509,15 @@ class HTMLAriaLevelMustBeValidVisitor extends AttributeVisitorMixin {
|
|
|
3060
4509
|
}
|
|
3061
4510
|
}
|
|
3062
4511
|
class HTMLAriaLevelMustBeValidRule extends ParserRule {
|
|
3063
|
-
|
|
4512
|
+
static ruleName = "html-aria-level-must-be-valid";
|
|
3064
4513
|
get defaultConfig() {
|
|
3065
4514
|
return {
|
|
3066
4515
|
enabled: true,
|
|
3067
|
-
severity: "
|
|
4516
|
+
severity: "warning"
|
|
3068
4517
|
};
|
|
3069
4518
|
}
|
|
3070
4519
|
check(result, context) {
|
|
3071
|
-
const visitor = new HTMLAriaLevelMustBeValidVisitor(this.
|
|
4520
|
+
const visitor = new HTMLAriaLevelMustBeValidVisitor(this.ruleName, context);
|
|
3072
4521
|
visitor.visit(result.value);
|
|
3073
4522
|
return visitor.offenses;
|
|
3074
4523
|
}
|
|
@@ -3085,15 +4534,15 @@ class AriaRoleHeadingRequiresLevel extends AttributeVisitorMixin {
|
|
|
3085
4534
|
}
|
|
3086
4535
|
}
|
|
3087
4536
|
class HTMLAriaRoleHeadingRequiresLevelRule extends ParserRule {
|
|
3088
|
-
|
|
4537
|
+
static ruleName = "html-aria-role-heading-requires-level";
|
|
3089
4538
|
get defaultConfig() {
|
|
3090
4539
|
return {
|
|
3091
4540
|
enabled: true,
|
|
3092
|
-
severity: "
|
|
4541
|
+
severity: "warning"
|
|
3093
4542
|
};
|
|
3094
4543
|
}
|
|
3095
4544
|
check(result, context) {
|
|
3096
|
-
const visitor = new AriaRoleHeadingRequiresLevel(this.
|
|
4545
|
+
const visitor = new AriaRoleHeadingRequiresLevel(this.ruleName, context);
|
|
3097
4546
|
visitor.visit(result.value);
|
|
3098
4547
|
return visitor.offenses;
|
|
3099
4548
|
}
|
|
@@ -3111,15 +4560,15 @@ class AriaRoleMustBeValid extends AttributeVisitorMixin {
|
|
|
3111
4560
|
}
|
|
3112
4561
|
}
|
|
3113
4562
|
class HTMLAriaRoleMustBeValidRule extends ParserRule {
|
|
3114
|
-
|
|
4563
|
+
static ruleName = "html-aria-role-must-be-valid";
|
|
3115
4564
|
get defaultConfig() {
|
|
3116
4565
|
return {
|
|
3117
4566
|
enabled: true,
|
|
3118
|
-
severity: "
|
|
4567
|
+
severity: "warning"
|
|
3119
4568
|
};
|
|
3120
4569
|
}
|
|
3121
4570
|
check(result, context) {
|
|
3122
|
-
const visitor = new AriaRoleMustBeValid(this.
|
|
4571
|
+
const visitor = new AriaRoleMustBeValid(this.ruleName, context);
|
|
3123
4572
|
visitor.visit(result.value);
|
|
3124
4573
|
return visitor.offenses;
|
|
3125
4574
|
}
|
|
@@ -3153,7 +4602,7 @@ class AttributeDoubleQuotesVisitor extends AttributeVisitorMixin {
|
|
|
3153
4602
|
}
|
|
3154
4603
|
class HTMLAttributeDoubleQuotesRule extends ParserRule {
|
|
3155
4604
|
static autocorrectable = true;
|
|
3156
|
-
|
|
4605
|
+
static ruleName = "html-attribute-double-quotes";
|
|
3157
4606
|
get defaultConfig() {
|
|
3158
4607
|
return {
|
|
3159
4608
|
enabled: true,
|
|
@@ -3161,7 +4610,7 @@ class HTMLAttributeDoubleQuotesRule extends ParserRule {
|
|
|
3161
4610
|
};
|
|
3162
4611
|
}
|
|
3163
4612
|
check(result, context) {
|
|
3164
|
-
const visitor = new AttributeDoubleQuotesVisitor(this.
|
|
4613
|
+
const visitor = new AttributeDoubleQuotesVisitor(this.ruleName, context);
|
|
3165
4614
|
visitor.visit(result.value);
|
|
3166
4615
|
return visitor.offenses;
|
|
3167
4616
|
}
|
|
@@ -3196,7 +4645,7 @@ class HTMLAttributeEqualsSpacingVisitor extends BaseRuleVisitor {
|
|
|
3196
4645
|
}
|
|
3197
4646
|
class HTMLAttributeEqualsSpacingRule extends ParserRule {
|
|
3198
4647
|
static autocorrectable = true;
|
|
3199
|
-
|
|
4648
|
+
static ruleName = "html-attribute-equals-spacing";
|
|
3200
4649
|
get defaultConfig() {
|
|
3201
4650
|
return {
|
|
3202
4651
|
enabled: true,
|
|
@@ -3204,7 +4653,7 @@ class HTMLAttributeEqualsSpacingRule extends ParserRule {
|
|
|
3204
4653
|
};
|
|
3205
4654
|
}
|
|
3206
4655
|
check(result, context) {
|
|
3207
|
-
const visitor = new HTMLAttributeEqualsSpacingVisitor(this.
|
|
4656
|
+
const visitor = new HTMLAttributeEqualsSpacingVisitor(this.ruleName, context);
|
|
3208
4657
|
visitor.visit(result.value);
|
|
3209
4658
|
return visitor.offenses;
|
|
3210
4659
|
}
|
|
@@ -3250,7 +4699,7 @@ class AttributeValuesRequireQuotesVisitor extends AttributeVisitorMixin {
|
|
|
3250
4699
|
}
|
|
3251
4700
|
class HTMLAttributeValuesRequireQuotesRule extends ParserRule {
|
|
3252
4701
|
static autocorrectable = true;
|
|
3253
|
-
|
|
4702
|
+
static ruleName = "html-attribute-values-require-quotes";
|
|
3254
4703
|
get defaultConfig() {
|
|
3255
4704
|
return {
|
|
3256
4705
|
enabled: true,
|
|
@@ -3258,7 +4707,7 @@ class HTMLAttributeValuesRequireQuotesRule extends ParserRule {
|
|
|
3258
4707
|
};
|
|
3259
4708
|
}
|
|
3260
4709
|
check(result, context) {
|
|
3261
|
-
const visitor = new AttributeValuesRequireQuotesVisitor(this.
|
|
4710
|
+
const visitor = new AttributeValuesRequireQuotesVisitor(this.ruleName, context);
|
|
3262
4711
|
visitor.visit(result.value);
|
|
3263
4712
|
return visitor.offenses;
|
|
3264
4713
|
}
|
|
@@ -3295,7 +4744,7 @@ class AvoidBothDisabledAndAriaDisabledVisitor extends BaseRuleVisitor {
|
|
|
3295
4744
|
super.visitHTMLOpenTagNode(node);
|
|
3296
4745
|
}
|
|
3297
4746
|
checkElement(node) {
|
|
3298
|
-
const tagName =
|
|
4747
|
+
const tagName = getTagLocalName(node);
|
|
3299
4748
|
if (!tagName || !ELEMENTS_WITH_NATIVE_DISABLED_ATTRIBUTE_SUPPORT.has(tagName)) {
|
|
3300
4749
|
return;
|
|
3301
4750
|
}
|
|
@@ -3314,24 +4763,23 @@ class AvoidBothDisabledAndAriaDisabledVisitor extends BaseRuleVisitor {
|
|
|
3314
4763
|
if (!attribute)
|
|
3315
4764
|
return false;
|
|
3316
4765
|
const valueNode = attribute.value;
|
|
3317
|
-
if (!valueNode
|
|
4766
|
+
if (!isHTMLAttributeValueNode(valueNode))
|
|
3318
4767
|
return false;
|
|
3319
|
-
|
|
3320
|
-
if (!htmlValueNode.children)
|
|
4768
|
+
if (!valueNode.children)
|
|
3321
4769
|
return false;
|
|
3322
|
-
return
|
|
4770
|
+
return valueNode.children.some(isERBContentNode);
|
|
3323
4771
|
}
|
|
3324
4772
|
}
|
|
3325
4773
|
class HTMLAvoidBothDisabledAndAriaDisabledRule extends ParserRule {
|
|
3326
|
-
|
|
4774
|
+
static ruleName = "html-avoid-both-disabled-and-aria-disabled";
|
|
3327
4775
|
get defaultConfig() {
|
|
3328
4776
|
return {
|
|
3329
4777
|
enabled: true,
|
|
3330
|
-
severity: "
|
|
4778
|
+
severity: "warning"
|
|
3331
4779
|
};
|
|
3332
4780
|
}
|
|
3333
4781
|
check(result, context) {
|
|
3334
|
-
const visitor = new AvoidBothDisabledAndAriaDisabledVisitor(this.
|
|
4782
|
+
const visitor = new AvoidBothDisabledAndAriaDisabledVisitor(this.ruleName, context);
|
|
3335
4783
|
visitor.visit(result.value);
|
|
3336
4784
|
return visitor.offenses;
|
|
3337
4785
|
}
|
|
@@ -3340,7 +4788,7 @@ class HTMLAvoidBothDisabledAndAriaDisabledRule extends ParserRule {
|
|
|
3340
4788
|
class HTMLBodyOnlyElementsVisitor extends BaseRuleVisitor {
|
|
3341
4789
|
elementStack = [];
|
|
3342
4790
|
visitHTMLElementNode(node) {
|
|
3343
|
-
const tagName =
|
|
4791
|
+
const tagName = getTagLocalName(node);
|
|
3344
4792
|
if (!tagName)
|
|
3345
4793
|
return;
|
|
3346
4794
|
this.checkBodyOnlyElement(node, tagName);
|
|
@@ -3366,7 +4814,7 @@ class HTMLBodyOnlyElementsVisitor extends BaseRuleVisitor {
|
|
|
3366
4814
|
}
|
|
3367
4815
|
class HTMLBodyOnlyElementsRule extends ParserRule {
|
|
3368
4816
|
static autocorrectable = false;
|
|
3369
|
-
|
|
4817
|
+
static ruleName = "html-body-only-elements";
|
|
3370
4818
|
get defaultConfig() {
|
|
3371
4819
|
return {
|
|
3372
4820
|
enabled: true,
|
|
@@ -3375,7 +4823,56 @@ class HTMLBodyOnlyElementsRule extends ParserRule {
|
|
|
3375
4823
|
};
|
|
3376
4824
|
}
|
|
3377
4825
|
check(result, context) {
|
|
3378
|
-
const visitor = new HTMLBodyOnlyElementsVisitor(this.
|
|
4826
|
+
const visitor = new HTMLBodyOnlyElementsVisitor(this.ruleName, context);
|
|
4827
|
+
visitor.visit(result.value);
|
|
4828
|
+
return visitor.offenses;
|
|
4829
|
+
}
|
|
4830
|
+
}
|
|
4831
|
+
|
|
4832
|
+
class DetailsHasSummaryVisitor extends BaseRuleVisitor {
|
|
4833
|
+
visitHTMLElementNode(node) {
|
|
4834
|
+
this.checkDetailsElement(node);
|
|
4835
|
+
super.visitHTMLElementNode(node);
|
|
4836
|
+
}
|
|
4837
|
+
checkDetailsElement(node) {
|
|
4838
|
+
const tagName = getTagLocalName(node);
|
|
4839
|
+
if (tagName !== "details") {
|
|
4840
|
+
return;
|
|
4841
|
+
}
|
|
4842
|
+
if (!this.hasDirectSummaryChild(node)) {
|
|
4843
|
+
this.addOffense("`<details>` element must have a direct `<summary>` child element.", node.location);
|
|
4844
|
+
}
|
|
4845
|
+
}
|
|
4846
|
+
hasDirectSummaryChild(node) {
|
|
4847
|
+
if (!node.body || node.body.length === 0) {
|
|
4848
|
+
return false;
|
|
4849
|
+
}
|
|
4850
|
+
for (const child of node.body) {
|
|
4851
|
+
if (isHTMLElementNode(child)) {
|
|
4852
|
+
const childTagName = getTagLocalName(child);
|
|
4853
|
+
if (childTagName === "summary") {
|
|
4854
|
+
return true;
|
|
4855
|
+
}
|
|
4856
|
+
}
|
|
4857
|
+
}
|
|
4858
|
+
return false;
|
|
4859
|
+
}
|
|
4860
|
+
}
|
|
4861
|
+
class HTMLDetailsHasSummaryRule extends ParserRule {
|
|
4862
|
+
static ruleName = "html-details-has-summary";
|
|
4863
|
+
get defaultConfig() {
|
|
4864
|
+
return {
|
|
4865
|
+
enabled: true,
|
|
4866
|
+
severity: "warning"
|
|
4867
|
+
};
|
|
4868
|
+
}
|
|
4869
|
+
get parserOptions() {
|
|
4870
|
+
return {
|
|
4871
|
+
action_view_helpers: true,
|
|
4872
|
+
};
|
|
4873
|
+
}
|
|
4874
|
+
check(result, context) {
|
|
4875
|
+
const visitor = new DetailsHasSummaryVisitor(this.ruleName, context);
|
|
3379
4876
|
visitor.visit(result.value);
|
|
3380
4877
|
return visitor.offenses;
|
|
3381
4878
|
}
|
|
@@ -3400,7 +4897,7 @@ class BooleanAttributesNoValueVisitor extends AttributeVisitorMixin {
|
|
|
3400
4897
|
}
|
|
3401
4898
|
class HTMLBooleanAttributesNoValueRule extends ParserRule {
|
|
3402
4899
|
static autocorrectable = true;
|
|
3403
|
-
|
|
4900
|
+
static ruleName = "html-boolean-attributes-no-value";
|
|
3404
4901
|
get defaultConfig() {
|
|
3405
4902
|
return {
|
|
3406
4903
|
enabled: true,
|
|
@@ -3408,7 +4905,7 @@ class HTMLBooleanAttributesNoValueRule extends ParserRule {
|
|
|
3408
4905
|
};
|
|
3409
4906
|
}
|
|
3410
4907
|
check(result, context) {
|
|
3411
|
-
const visitor = new BooleanAttributesNoValueVisitor(this.
|
|
4908
|
+
const visitor = new BooleanAttributesNoValueVisitor(this.ruleName, context);
|
|
3412
4909
|
visitor.visit(result.value);
|
|
3413
4910
|
return visitor.offenses;
|
|
3414
4911
|
}
|
|
@@ -3425,7 +4922,7 @@ class HTMLBooleanAttributesNoValueRule extends ParserRule {
|
|
|
3425
4922
|
class HeadOnlyElementsVisitor extends BaseRuleVisitor {
|
|
3426
4923
|
elementStack = [];
|
|
3427
4924
|
visitHTMLElementNode(node) {
|
|
3428
|
-
const tagName =
|
|
4925
|
+
const tagName = getTagLocalName(node);
|
|
3429
4926
|
if (!tagName)
|
|
3430
4927
|
return;
|
|
3431
4928
|
this.checkHeadOnlyElement(node, tagName);
|
|
@@ -3449,7 +4946,7 @@ class HeadOnlyElementsVisitor extends BaseRuleVisitor {
|
|
|
3449
4946
|
this.addOffense(`Element \`<${tagName}>\` must be placed inside the \`<head>\` tag.`, node.location);
|
|
3450
4947
|
}
|
|
3451
4948
|
hasItempropAttribute(node) {
|
|
3452
|
-
return hasAttribute(node
|
|
4949
|
+
return hasAttribute(node, "itemprop");
|
|
3453
4950
|
}
|
|
3454
4951
|
get insideHead() {
|
|
3455
4952
|
return this.elementStack.includes("head");
|
|
@@ -3463,7 +4960,7 @@ class HeadOnlyElementsVisitor extends BaseRuleVisitor {
|
|
|
3463
4960
|
}
|
|
3464
4961
|
class HTMLHeadOnlyElementsRule extends ParserRule {
|
|
3465
4962
|
static autocorrectable = false;
|
|
3466
|
-
|
|
4963
|
+
static ruleName = "html-head-only-elements";
|
|
3467
4964
|
get defaultConfig() {
|
|
3468
4965
|
return {
|
|
3469
4966
|
enabled: true,
|
|
@@ -3472,7 +4969,7 @@ class HTMLHeadOnlyElementsRule extends ParserRule {
|
|
|
3472
4969
|
};
|
|
3473
4970
|
}
|
|
3474
4971
|
check(result, context) {
|
|
3475
|
-
const visitor = new HeadOnlyElementsVisitor(this.
|
|
4972
|
+
const visitor = new HeadOnlyElementsVisitor(this.ruleName, context);
|
|
3476
4973
|
visitor.visit(result.value);
|
|
3477
4974
|
return visitor.offenses;
|
|
3478
4975
|
}
|
|
@@ -3484,16 +4981,12 @@ class IframeHasTitleVisitor extends BaseRuleVisitor {
|
|
|
3484
4981
|
super.visitHTMLOpenTagNode(node);
|
|
3485
4982
|
}
|
|
3486
4983
|
checkIframeElement(node) {
|
|
3487
|
-
const tagName =
|
|
4984
|
+
const tagName = getTagLocalName(node);
|
|
3488
4985
|
if (tagName !== "iframe") {
|
|
3489
4986
|
return;
|
|
3490
4987
|
}
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
const ariaHiddenValue = getAttributeValue(ariaHiddenAttribute);
|
|
3494
|
-
if (ariaHiddenValue === "true") {
|
|
3495
|
-
return;
|
|
3496
|
-
}
|
|
4988
|
+
if (getStaticAttributeValue(node, "aria-hidden") === "true") {
|
|
4989
|
+
return;
|
|
3497
4990
|
}
|
|
3498
4991
|
const attribute = getAttribute(node, "title");
|
|
3499
4992
|
if (!attribute) {
|
|
@@ -3507,15 +5000,15 @@ class IframeHasTitleVisitor extends BaseRuleVisitor {
|
|
|
3507
5000
|
}
|
|
3508
5001
|
}
|
|
3509
5002
|
class HTMLIframeHasTitleRule extends ParserRule {
|
|
3510
|
-
|
|
5003
|
+
static ruleName = "html-iframe-has-title";
|
|
3511
5004
|
get defaultConfig() {
|
|
3512
5005
|
return {
|
|
3513
5006
|
enabled: true,
|
|
3514
|
-
severity: "
|
|
5007
|
+
severity: "warning"
|
|
3515
5008
|
};
|
|
3516
5009
|
}
|
|
3517
5010
|
check(result, context) {
|
|
3518
|
-
const visitor = new IframeHasTitleVisitor(this.
|
|
5011
|
+
const visitor = new IframeHasTitleVisitor(this.ruleName, context);
|
|
3519
5012
|
visitor.visit(result.value);
|
|
3520
5013
|
return visitor.offenses;
|
|
3521
5014
|
}
|
|
@@ -3527,25 +5020,30 @@ class ImgRequireAltVisitor extends BaseRuleVisitor {
|
|
|
3527
5020
|
super.visitHTMLOpenTagNode(node);
|
|
3528
5021
|
}
|
|
3529
5022
|
checkImgTag(node) {
|
|
3530
|
-
const tagName =
|
|
5023
|
+
const tagName = getTagLocalName(node);
|
|
3531
5024
|
if (tagName !== "img") {
|
|
3532
5025
|
return;
|
|
3533
5026
|
}
|
|
3534
5027
|
if (!hasAttribute(node, "alt")) {
|
|
3535
5028
|
this.addOffense('Missing required `alt` attribute on `<img>` tag. Add `alt=""` for decorative images or `alt="description"` for informative images.', node.tag_name.location);
|
|
5029
|
+
return;
|
|
5030
|
+
}
|
|
5031
|
+
const altAttribute = getAttribute(node, "alt");
|
|
5032
|
+
if (altAttribute && !hasAttributeValue(altAttribute)) {
|
|
5033
|
+
this.addOffense('The `alt` attribute has no value. Add `alt=""` for decorative images or `alt="description"` for informative images.', altAttribute.location);
|
|
3536
5034
|
}
|
|
3537
5035
|
}
|
|
3538
5036
|
}
|
|
3539
5037
|
class HTMLImgRequireAltRule extends ParserRule {
|
|
3540
|
-
|
|
5038
|
+
static ruleName = "html-img-require-alt";
|
|
3541
5039
|
get defaultConfig() {
|
|
3542
5040
|
return {
|
|
3543
5041
|
enabled: true,
|
|
3544
|
-
severity: "
|
|
5042
|
+
severity: "warning"
|
|
3545
5043
|
};
|
|
3546
5044
|
}
|
|
3547
5045
|
check(result, context) {
|
|
3548
|
-
const visitor = new ImgRequireAltVisitor(this.
|
|
5046
|
+
const visitor = new ImgRequireAltVisitor(this.ruleName, context);
|
|
3549
5047
|
visitor.visit(result.value);
|
|
3550
5048
|
return visitor.offenses;
|
|
3551
5049
|
}
|
|
@@ -3574,10 +5072,7 @@ class HTMLInputRequireAutocompleteVisitor extends BaseRuleVisitor {
|
|
|
3574
5072
|
checkInputTag(node) {
|
|
3575
5073
|
if (!this.isInputTag(node) || this.hasAutocomplete(node))
|
|
3576
5074
|
return;
|
|
3577
|
-
const
|
|
3578
|
-
if (!typeAttribute)
|
|
3579
|
-
return;
|
|
3580
|
-
const typeValue = getStaticAttributeValueContent(typeAttribute);
|
|
5075
|
+
const typeValue = getStaticAttributeValue(node, "type");
|
|
3581
5076
|
if (!typeValue)
|
|
3582
5077
|
return;
|
|
3583
5078
|
if (!this.HTML_INPUT_TYPES_REQUIRING_AUTOCOMPLETE.has(typeValue))
|
|
@@ -3594,7 +5089,7 @@ class HTMLInputRequireAutocompleteVisitor extends BaseRuleVisitor {
|
|
|
3594
5089
|
return true;
|
|
3595
5090
|
}
|
|
3596
5091
|
isInputTag(node) {
|
|
3597
|
-
const tagName =
|
|
5092
|
+
const tagName = getTagLocalName(node);
|
|
3598
5093
|
if (tagName === "input") {
|
|
3599
5094
|
return true;
|
|
3600
5095
|
}
|
|
@@ -3604,15 +5099,15 @@ class HTMLInputRequireAutocompleteVisitor extends BaseRuleVisitor {
|
|
|
3604
5099
|
}
|
|
3605
5100
|
}
|
|
3606
5101
|
class HTMLInputRequireAutocompleteRule extends ParserRule {
|
|
3607
|
-
|
|
5102
|
+
static ruleName = "html-input-require-autocomplete";
|
|
3608
5103
|
get defaultConfig() {
|
|
3609
5104
|
return {
|
|
3610
5105
|
enabled: true,
|
|
3611
|
-
severity: "
|
|
5106
|
+
severity: "warning"
|
|
3612
5107
|
};
|
|
3613
5108
|
}
|
|
3614
5109
|
check(result, context) {
|
|
3615
|
-
const visitor = new HTMLInputRequireAutocompleteVisitor(this.
|
|
5110
|
+
const visitor = new HTMLInputRequireAutocompleteVisitor(this.ruleName, context);
|
|
3616
5111
|
visitor.visit(result.value);
|
|
3617
5112
|
return visitor.offenses;
|
|
3618
5113
|
}
|
|
@@ -3624,7 +5119,7 @@ class NavigationHasLabelVisitor extends BaseRuleVisitor {
|
|
|
3624
5119
|
super.visitHTMLOpenTagNode(node);
|
|
3625
5120
|
}
|
|
3626
5121
|
checkNavigationElement(node) {
|
|
3627
|
-
const tagName =
|
|
5122
|
+
const tagName = getTagLocalName(node);
|
|
3628
5123
|
const isNavElement = tagName === "nav";
|
|
3629
5124
|
const hasNavigationRole = this.hasRoleNavigation(node);
|
|
3630
5125
|
if (!isNavElement && !hasNavigationRole) {
|
|
@@ -3651,15 +5146,81 @@ class NavigationHasLabelVisitor extends BaseRuleVisitor {
|
|
|
3651
5146
|
}
|
|
3652
5147
|
}
|
|
3653
5148
|
class HTMLNavigationHasLabelRule extends ParserRule {
|
|
3654
|
-
|
|
5149
|
+
static ruleName = "html-navigation-has-label";
|
|
3655
5150
|
get defaultConfig() {
|
|
3656
5151
|
return {
|
|
3657
5152
|
enabled: false,
|
|
3658
|
-
severity: "
|
|
5153
|
+
severity: "warning"
|
|
5154
|
+
};
|
|
5155
|
+
}
|
|
5156
|
+
check(result, context) {
|
|
5157
|
+
const visitor = new NavigationHasLabelVisitor(this.ruleName, context);
|
|
5158
|
+
visitor.visit(result.value);
|
|
5159
|
+
return visitor.offenses;
|
|
5160
|
+
}
|
|
5161
|
+
}
|
|
5162
|
+
|
|
5163
|
+
class NoAbstractRolesVisitor extends AttributeVisitorMixin {
|
|
5164
|
+
checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
|
|
5165
|
+
if (attributeName !== "role")
|
|
5166
|
+
return;
|
|
5167
|
+
if (!attributeValue)
|
|
5168
|
+
return;
|
|
5169
|
+
const normalizedValue = attributeValue.toLowerCase();
|
|
5170
|
+
if (!ABSTRACT_ARIA_ROLES.has(normalizedValue))
|
|
5171
|
+
return;
|
|
5172
|
+
this.addOffense(`The \`role\` attribute must not use abstract ARIA role \`${attributeValue}\`. Abstract roles are not meant to be used directly.`, attributeNode.location);
|
|
5173
|
+
}
|
|
5174
|
+
}
|
|
5175
|
+
class HTMLNoAbstractRolesRule extends ParserRule {
|
|
5176
|
+
static ruleName = "html-no-abstract-roles";
|
|
5177
|
+
get defaultConfig() {
|
|
5178
|
+
return {
|
|
5179
|
+
enabled: true,
|
|
5180
|
+
severity: "warning"
|
|
5181
|
+
};
|
|
5182
|
+
}
|
|
5183
|
+
check(result, context) {
|
|
5184
|
+
const visitor = new NoAbstractRolesVisitor(this.ruleName, context);
|
|
5185
|
+
visitor.visit(result.value);
|
|
5186
|
+
return visitor.offenses;
|
|
5187
|
+
}
|
|
5188
|
+
}
|
|
5189
|
+
|
|
5190
|
+
class NoAriaHiddenBodyVisitor extends BaseRuleVisitor {
|
|
5191
|
+
visitHTMLOpenTagNode(node) {
|
|
5192
|
+
this.checkAriaHiddenOnBody(node);
|
|
5193
|
+
super.visitHTMLOpenTagNode(node);
|
|
5194
|
+
}
|
|
5195
|
+
checkAriaHiddenOnBody(node) {
|
|
5196
|
+
const tagName = getTagLocalName(node);
|
|
5197
|
+
if (tagName !== "body")
|
|
5198
|
+
return;
|
|
5199
|
+
if (this.hasAriaHidden(node)) {
|
|
5200
|
+
this.addOffense("The `aria-hidden` attribute should never be present on the `<body>` element, as it hides the entire document from assistive technology users.", node.tag_name.location);
|
|
5201
|
+
}
|
|
5202
|
+
}
|
|
5203
|
+
hasAriaHidden(node) {
|
|
5204
|
+
if (!hasAttribute(node, "aria-hidden"))
|
|
5205
|
+
return false;
|
|
5206
|
+
const attributes = getAttributes(node);
|
|
5207
|
+
const ariaHiddenAttr = findAttributeByName(attributes, "aria-hidden");
|
|
5208
|
+
if (!ariaHiddenAttr)
|
|
5209
|
+
return false;
|
|
5210
|
+
const value = getAttributeValue(ariaHiddenAttr);
|
|
5211
|
+
return value === null || value === "" || value === "true";
|
|
5212
|
+
}
|
|
5213
|
+
}
|
|
5214
|
+
class HTMLNoAriaHiddenOnBodyRule extends ParserRule {
|
|
5215
|
+
static ruleName = "html-no-aria-hidden-on-body";
|
|
5216
|
+
get defaultConfig() {
|
|
5217
|
+
return {
|
|
5218
|
+
enabled: true,
|
|
5219
|
+
severity: "warning"
|
|
3659
5220
|
};
|
|
3660
5221
|
}
|
|
3661
5222
|
check(result, context) {
|
|
3662
|
-
const visitor = new
|
|
5223
|
+
const visitor = new NoAriaHiddenBodyVisitor(this.ruleName, context);
|
|
3663
5224
|
visitor.visit(result.value);
|
|
3664
5225
|
return visitor.offenses;
|
|
3665
5226
|
}
|
|
@@ -3689,7 +5250,7 @@ class NoAriaHiddenOnFocusableVisitor extends BaseRuleVisitor {
|
|
|
3689
5250
|
return value === "true";
|
|
3690
5251
|
}
|
|
3691
5252
|
isFocusable(node) {
|
|
3692
|
-
const tagName =
|
|
5253
|
+
const tagName = getTagLocalName(node);
|
|
3693
5254
|
if (!tagName)
|
|
3694
5255
|
return false;
|
|
3695
5256
|
const tabIndexValue = this.getTabIndexValue(node);
|
|
@@ -3722,15 +5283,15 @@ class NoAriaHiddenOnFocusableVisitor extends BaseRuleVisitor {
|
|
|
3722
5283
|
}
|
|
3723
5284
|
}
|
|
3724
5285
|
class HTMLNoAriaHiddenOnFocusableRule extends ParserRule {
|
|
3725
|
-
|
|
5286
|
+
static ruleName = "html-no-aria-hidden-on-focusable";
|
|
3726
5287
|
get defaultConfig() {
|
|
3727
5288
|
return {
|
|
3728
5289
|
enabled: true,
|
|
3729
|
-
severity: "
|
|
5290
|
+
severity: "warning"
|
|
3730
5291
|
};
|
|
3731
5292
|
}
|
|
3732
5293
|
check(result, context) {
|
|
3733
|
-
const visitor = new NoAriaHiddenOnFocusableVisitor(this.
|
|
5294
|
+
const visitor = new NoAriaHiddenOnFocusableVisitor(this.ruleName, context);
|
|
3734
5295
|
visitor.visit(result.value);
|
|
3735
5296
|
return visitor.offenses;
|
|
3736
5297
|
}
|
|
@@ -3738,9 +5299,6 @@ class HTMLNoAriaHiddenOnFocusableRule extends ParserRule {
|
|
|
3738
5299
|
|
|
3739
5300
|
class BlockInsideInlineVisitor extends BaseRuleVisitor {
|
|
3740
5301
|
inlineStack = [];
|
|
3741
|
-
isValidHTMLOpenTag(node) {
|
|
3742
|
-
return !!(node.open_tag && node.open_tag.type === "AST_HTML_OPEN_TAG_NODE");
|
|
3743
|
-
}
|
|
3744
5302
|
getElementType(tagName) {
|
|
3745
5303
|
const isInline = isInlineElement(tagName);
|
|
3746
5304
|
const isBlock = isBlockElement(tagName);
|
|
@@ -3764,19 +5322,18 @@ class BlockInsideInlineVisitor extends BaseRuleVisitor {
|
|
|
3764
5322
|
this.inlineStack = savedStack;
|
|
3765
5323
|
}
|
|
3766
5324
|
visitHTMLElementNode(node) {
|
|
3767
|
-
if (!
|
|
5325
|
+
if (!isHTMLOpenTagNode(node.open_tag)) {
|
|
3768
5326
|
super.visitHTMLElementNode(node);
|
|
3769
5327
|
return;
|
|
3770
5328
|
}
|
|
3771
|
-
const
|
|
3772
|
-
const tagName = openTag.tag_name?.value.toLowerCase();
|
|
5329
|
+
const tagName = node.open_tag.tag_name?.value.toLowerCase();
|
|
3773
5330
|
if (!tagName) {
|
|
3774
5331
|
super.visitHTMLElementNode(node);
|
|
3775
5332
|
return;
|
|
3776
5333
|
}
|
|
3777
5334
|
const { isInline, isBlock, isUnknown } = this.getElementType(tagName);
|
|
3778
5335
|
if ((isBlock || isUnknown) && this.inlineStack.length > 0) {
|
|
3779
|
-
this.addOffenseMessage(tagName, isBlock,
|
|
5336
|
+
this.addOffenseMessage(tagName, isBlock, node.open_tag);
|
|
3780
5337
|
}
|
|
3781
5338
|
if (isInline) {
|
|
3782
5339
|
this.visitInlineElement(node, tagName);
|
|
@@ -3786,7 +5343,7 @@ class BlockInsideInlineVisitor extends BaseRuleVisitor {
|
|
|
3786
5343
|
}
|
|
3787
5344
|
}
|
|
3788
5345
|
class HTMLNoBlockInsideInlineRule extends ParserRule {
|
|
3789
|
-
|
|
5346
|
+
static ruleName = "html-no-block-inside-inline";
|
|
3790
5347
|
get defaultConfig() {
|
|
3791
5348
|
return {
|
|
3792
5349
|
enabled: false,
|
|
@@ -3794,7 +5351,7 @@ class HTMLNoBlockInsideInlineRule extends ParserRule {
|
|
|
3794
5351
|
};
|
|
3795
5352
|
}
|
|
3796
5353
|
check(result, context) {
|
|
3797
|
-
const visitor = new BlockInsideInlineVisitor(this.
|
|
5354
|
+
const visitor = new BlockInsideInlineVisitor(this.ruleName, context);
|
|
3798
5355
|
visitor.visit(result.value);
|
|
3799
5356
|
return visitor.offenses;
|
|
3800
5357
|
}
|
|
@@ -3901,7 +5458,7 @@ class NoDuplicateAttributesVisitor extends ControlFlowTrackingVisitor {
|
|
|
3901
5458
|
}
|
|
3902
5459
|
}
|
|
3903
5460
|
class HTMLNoDuplicateAttributesRule extends ParserRule {
|
|
3904
|
-
|
|
5461
|
+
static ruleName = "html-no-duplicate-attributes";
|
|
3905
5462
|
get defaultConfig() {
|
|
3906
5463
|
return {
|
|
3907
5464
|
enabled: true,
|
|
@@ -3909,7 +5466,7 @@ class HTMLNoDuplicateAttributesRule extends ParserRule {
|
|
|
3909
5466
|
};
|
|
3910
5467
|
}
|
|
3911
5468
|
check(result, context) {
|
|
3912
|
-
const visitor = new NoDuplicateAttributesVisitor(this.
|
|
5469
|
+
const visitor = new NoDuplicateAttributesVisitor(this.ruleName, context);
|
|
3913
5470
|
visitor.visit(result.value);
|
|
3914
5471
|
return visitor.offenses;
|
|
3915
5472
|
}
|
|
@@ -3977,34 +5534,35 @@ class NoDuplicateIdsVisitor extends ControlFlowTrackingVisitor {
|
|
|
3977
5534
|
}
|
|
3978
5535
|
extractIdValue(attributeNode) {
|
|
3979
5536
|
const valueNodes = attributeNode.value?.children || [];
|
|
3980
|
-
|
|
5537
|
+
const isDynamic = hasERBOutput(valueNodes);
|
|
5538
|
+
if (isDynamic && this.isInControlFlow && this.currentControlFlowType === ControlFlowType.LOOP) {
|
|
3981
5539
|
return null;
|
|
3982
5540
|
}
|
|
3983
5541
|
const identifier = isEffectivelyStatic(valueNodes) ? getValidatableStaticContent(valueNodes) : OutputPrinter.print(valueNodes);
|
|
3984
5542
|
if (!identifier)
|
|
3985
5543
|
return null;
|
|
3986
|
-
return { identifier, shouldTrackDuplicates: true };
|
|
5544
|
+
return { identifier, shouldTrackDuplicates: true, isDynamic };
|
|
3987
5545
|
}
|
|
3988
5546
|
isWhitespaceOnlyId(identifier) {
|
|
3989
5547
|
return identifier !== '' && identifier.trim() === '';
|
|
3990
5548
|
}
|
|
3991
5549
|
processIdDuplicate(idValue, attributeNode) {
|
|
3992
|
-
const { identifier, shouldTrackDuplicates } = idValue;
|
|
5550
|
+
const { identifier, shouldTrackDuplicates, isDynamic } = idValue;
|
|
3993
5551
|
if (!shouldTrackDuplicates)
|
|
3994
5552
|
return;
|
|
3995
5553
|
if (this.isInControlFlow) {
|
|
3996
|
-
this.handleControlFlowId(identifier, attributeNode);
|
|
5554
|
+
this.handleControlFlowId(identifier, attributeNode, isDynamic);
|
|
3997
5555
|
}
|
|
3998
5556
|
else {
|
|
3999
5557
|
this.handleGlobalId(identifier, attributeNode);
|
|
4000
5558
|
}
|
|
4001
5559
|
}
|
|
4002
|
-
handleControlFlowId(identifier, attributeNode) {
|
|
5560
|
+
handleControlFlowId(identifier, attributeNode, isDynamic) {
|
|
4003
5561
|
if (this.currentControlFlowType === ControlFlowType.LOOP) {
|
|
4004
5562
|
this.handleLoopId(identifier, attributeNode);
|
|
4005
5563
|
}
|
|
4006
5564
|
else {
|
|
4007
|
-
this.handleConditionalId(identifier, attributeNode);
|
|
5565
|
+
this.handleConditionalId(identifier, attributeNode, isDynamic);
|
|
4008
5566
|
}
|
|
4009
5567
|
this.currentBranchIds.add(identifier);
|
|
4010
5568
|
}
|
|
@@ -4018,16 +5576,18 @@ class NoDuplicateIdsVisitor extends ControlFlowTrackingVisitor {
|
|
|
4018
5576
|
this.addSameLoopIterationOffense(identifier, attributeNode.location);
|
|
4019
5577
|
}
|
|
4020
5578
|
}
|
|
4021
|
-
handleConditionalId(identifier, attributeNode) {
|
|
5579
|
+
handleConditionalId(identifier, attributeNode, isDynamic) {
|
|
4022
5580
|
if (this.currentBranchIds.has(identifier)) {
|
|
4023
5581
|
this.addSameBranchOffense(identifier, attributeNode.location);
|
|
4024
5582
|
return;
|
|
4025
5583
|
}
|
|
4026
|
-
if (this.documentIds.has(identifier)) {
|
|
5584
|
+
if (!isDynamic && this.documentIds.has(identifier)) {
|
|
4027
5585
|
this.addDuplicateIdOffense(identifier, attributeNode.location);
|
|
4028
5586
|
return;
|
|
4029
5587
|
}
|
|
4030
|
-
|
|
5588
|
+
if (!isDynamic) {
|
|
5589
|
+
this.controlFlowIds.add(identifier);
|
|
5590
|
+
}
|
|
4031
5591
|
}
|
|
4032
5592
|
handleGlobalId(identifier, attributeNode) {
|
|
4033
5593
|
if (this.documentIds.has(identifier)) {
|
|
@@ -4053,7 +5613,7 @@ class NoDuplicateIdsVisitor extends ControlFlowTrackingVisitor {
|
|
|
4053
5613
|
}
|
|
4054
5614
|
}
|
|
4055
5615
|
class HTMLNoDuplicateIdsRule extends ParserRule {
|
|
4056
|
-
|
|
5616
|
+
static ruleName = "html-no-duplicate-ids";
|
|
4057
5617
|
get defaultConfig() {
|
|
4058
5618
|
return {
|
|
4059
5619
|
enabled: true,
|
|
@@ -4061,7 +5621,7 @@ class HTMLNoDuplicateIdsRule extends ParserRule {
|
|
|
4061
5621
|
};
|
|
4062
5622
|
}
|
|
4063
5623
|
check(result, context) {
|
|
4064
|
-
const visitor = new NoDuplicateIdsVisitor(this.
|
|
5624
|
+
const visitor = new NoDuplicateIdsVisitor(this.ruleName, context);
|
|
4065
5625
|
visitor.visit(result.value);
|
|
4066
5626
|
return visitor.offenses;
|
|
4067
5627
|
}
|
|
@@ -4073,7 +5633,7 @@ class HTMLNoDuplicateMetaNamesVisitor extends ControlFlowTrackingVisitor {
|
|
|
4073
5633
|
currentBranchMetas = [];
|
|
4074
5634
|
controlFlowMetas = [];
|
|
4075
5635
|
visitHTMLElementNode(node) {
|
|
4076
|
-
const tagName =
|
|
5636
|
+
const tagName = getTagLocalName(node);
|
|
4077
5637
|
if (!tagName)
|
|
4078
5638
|
return;
|
|
4079
5639
|
if (tagName === "head") {
|
|
@@ -4133,21 +5693,23 @@ class HTMLNoDuplicateMetaNamesVisitor extends ControlFlowTrackingVisitor {
|
|
|
4133
5693
|
this.currentBranchMetas.push(metaTag);
|
|
4134
5694
|
}
|
|
4135
5695
|
extractAttributes(node, metaTag) {
|
|
4136
|
-
if (isHTMLElementNode(node)
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
5696
|
+
if (!isHTMLElementNode(node))
|
|
5697
|
+
return;
|
|
5698
|
+
if (!isHTMLOpenTagNode(node.open_tag))
|
|
5699
|
+
return;
|
|
5700
|
+
forEachAttribute(node.open_tag, (attributeNode) => {
|
|
5701
|
+
const name = getAttributeName(attributeNode);
|
|
5702
|
+
const value = getAttributeValue(attributeNode)?.trim();
|
|
5703
|
+
if (name === "name" && value) {
|
|
5704
|
+
metaTag.nameValue = value;
|
|
5705
|
+
}
|
|
5706
|
+
else if (name === "http-equiv" && value) {
|
|
5707
|
+
metaTag.httpEquivValue = value;
|
|
5708
|
+
}
|
|
5709
|
+
else if (name === "media" && value) {
|
|
5710
|
+
metaTag.mediaValue = value;
|
|
5711
|
+
}
|
|
5712
|
+
});
|
|
4151
5713
|
}
|
|
4152
5714
|
handleControlFlowMeta(metaTag) {
|
|
4153
5715
|
if (this.currentControlFlowType === ControlFlowType.LOOP) {
|
|
@@ -4196,7 +5758,7 @@ class HTMLNoDuplicateMetaNamesVisitor extends ControlFlowTrackingVisitor {
|
|
|
4196
5758
|
}
|
|
4197
5759
|
class HTMLNoDuplicateMetaNamesRule extends ParserRule {
|
|
4198
5760
|
static autocorrectable = false;
|
|
4199
|
-
|
|
5761
|
+
static ruleName = "html-no-duplicate-meta-names";
|
|
4200
5762
|
get defaultConfig() {
|
|
4201
5763
|
return {
|
|
4202
5764
|
enabled: true,
|
|
@@ -4204,7 +5766,7 @@ class HTMLNoDuplicateMetaNamesRule extends ParserRule {
|
|
|
4204
5766
|
};
|
|
4205
5767
|
}
|
|
4206
5768
|
check(result, context) {
|
|
4207
|
-
const visitor = new HTMLNoDuplicateMetaNamesVisitor(this.
|
|
5769
|
+
const visitor = new HTMLNoDuplicateMetaNamesVisitor(this.ruleName, context);
|
|
4208
5770
|
visitor.visit(result.value);
|
|
4209
5771
|
return visitor.offenses;
|
|
4210
5772
|
}
|
|
@@ -4296,7 +5858,7 @@ class NoEmptyAttributesVisitor extends AttributeVisitorMixin {
|
|
|
4296
5858
|
}
|
|
4297
5859
|
}
|
|
4298
5860
|
class HTMLNoEmptyAttributesRule extends ParserRule {
|
|
4299
|
-
|
|
5861
|
+
static ruleName = "html-no-empty-attributes";
|
|
4300
5862
|
get defaultConfig() {
|
|
4301
5863
|
return {
|
|
4302
5864
|
enabled: true,
|
|
@@ -4304,7 +5866,7 @@ class HTMLNoEmptyAttributesRule extends ParserRule {
|
|
|
4304
5866
|
};
|
|
4305
5867
|
}
|
|
4306
5868
|
check(result, context) {
|
|
4307
|
-
const visitor = new NoEmptyAttributesVisitor(this.
|
|
5869
|
+
const visitor = new NoEmptyAttributesVisitor(this.ruleName, context);
|
|
4308
5870
|
visitor.visit(result.value);
|
|
4309
5871
|
return visitor.offenses;
|
|
4310
5872
|
}
|
|
@@ -4312,22 +5874,18 @@ class HTMLNoEmptyAttributesRule extends ParserRule {
|
|
|
4312
5874
|
|
|
4313
5875
|
class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
|
|
4314
5876
|
visitHTMLElementNode(node) {
|
|
4315
|
-
const tagName =
|
|
5877
|
+
const tagName = getTagLocalName(node);
|
|
4316
5878
|
if (tagName === "template")
|
|
4317
5879
|
return;
|
|
4318
5880
|
this.checkHeadingElement(node);
|
|
4319
5881
|
super.visitHTMLElementNode(node);
|
|
4320
5882
|
}
|
|
4321
5883
|
checkHeadingElement(node) {
|
|
4322
|
-
|
|
4323
|
-
return;
|
|
4324
|
-
if (!isHTMLOpenTagNode(node.open_tag))
|
|
4325
|
-
return;
|
|
4326
|
-
const tagName = getTagName(node.open_tag);
|
|
5884
|
+
const tagName = getTagLocalName(node);
|
|
4327
5885
|
if (!tagName)
|
|
4328
5886
|
return;
|
|
4329
5887
|
const isStandardHeading = HEADING_TAGS.has(tagName);
|
|
4330
|
-
const isAriaHeading = this.hasHeadingRole(node
|
|
5888
|
+
const isAriaHeading = this.hasHeadingRole(node);
|
|
4331
5889
|
if (!isStandardHeading && !isAriaHeading) {
|
|
4332
5890
|
return;
|
|
4333
5891
|
}
|
|
@@ -4342,81 +5900,67 @@ class NoEmptyHeadingsVisitor extends BaseRuleVisitor {
|
|
|
4342
5900
|
if (!node.body || node.body.length === 0) {
|
|
4343
5901
|
return true;
|
|
4344
5902
|
}
|
|
4345
|
-
|
|
4346
|
-
|
|
5903
|
+
return !this.hasAccessibleContent(node.body);
|
|
5904
|
+
}
|
|
5905
|
+
hasAccessibleContent(nodes) {
|
|
5906
|
+
for (const child of nodes) {
|
|
4347
5907
|
if (isLiteralNode(child) || isHTMLTextNode(child)) {
|
|
4348
5908
|
if (child.content.trim().length > 0) {
|
|
4349
|
-
|
|
4350
|
-
break;
|
|
5909
|
+
return true;
|
|
4351
5910
|
}
|
|
4352
5911
|
}
|
|
4353
5912
|
else if (isHTMLElementNode(child)) {
|
|
4354
5913
|
if (this.isElementAccessible(child)) {
|
|
4355
|
-
|
|
4356
|
-
break;
|
|
5914
|
+
return true;
|
|
4357
5915
|
}
|
|
4358
5916
|
}
|
|
4359
|
-
else {
|
|
4360
|
-
|
|
4361
|
-
|
|
5917
|
+
else if (isERBOutputNode(child)) {
|
|
5918
|
+
return true;
|
|
5919
|
+
}
|
|
5920
|
+
else if (isERBControlFlowNode(child)) {
|
|
5921
|
+
if (this.hasAccessibleContentInControlFlow(child)) {
|
|
5922
|
+
return true;
|
|
5923
|
+
}
|
|
4362
5924
|
}
|
|
4363
5925
|
}
|
|
4364
|
-
return
|
|
5926
|
+
return false;
|
|
4365
5927
|
}
|
|
4366
|
-
|
|
4367
|
-
const
|
|
4368
|
-
|
|
4369
|
-
|
|
4370
|
-
return false;
|
|
5928
|
+
hasAccessibleContentInControlFlow(node) {
|
|
5929
|
+
const nodeWithStatements = node;
|
|
5930
|
+
if (nodeWithStatements.statements && this.hasAccessibleContent(nodeWithStatements.statements)) {
|
|
5931
|
+
return true;
|
|
4371
5932
|
}
|
|
4372
|
-
|
|
4373
|
-
|
|
5933
|
+
if (nodeWithStatements.body && this.hasAccessibleContent(nodeWithStatements.body)) {
|
|
5934
|
+
return true;
|
|
5935
|
+
}
|
|
5936
|
+
if (nodeWithStatements.subsequent) {
|
|
5937
|
+
return this.hasAccessibleContentInControlFlow(nodeWithStatements.subsequent);
|
|
5938
|
+
}
|
|
5939
|
+
return false;
|
|
5940
|
+
}
|
|
5941
|
+
hasHeadingRole(node) {
|
|
5942
|
+
return getStaticAttributeValue(node, "role") === "heading";
|
|
4374
5943
|
}
|
|
4375
5944
|
isElementAccessible(node) {
|
|
4376
|
-
if (
|
|
4377
|
-
return
|
|
4378
|
-
if (!isHTMLOpenTagNode(node.open_tag))
|
|
4379
|
-
return true;
|
|
4380
|
-
const attributes = getAttributes(node.open_tag);
|
|
4381
|
-
const ariaHiddenAttribute = findAttributeByName(attributes, "aria-hidden");
|
|
4382
|
-
if (ariaHiddenAttribute) {
|
|
4383
|
-
const ariaHiddenValue = getAttributeValue(ariaHiddenAttribute);
|
|
4384
|
-
if (ariaHiddenValue === "true") {
|
|
4385
|
-
return false;
|
|
4386
|
-
}
|
|
5945
|
+
if (getStaticAttributeValue(node, "aria-hidden") === "true") {
|
|
5946
|
+
return false;
|
|
4387
5947
|
}
|
|
4388
5948
|
if (!node.body || node.body.length === 0) {
|
|
4389
5949
|
return false;
|
|
4390
5950
|
}
|
|
4391
|
-
|
|
4392
|
-
if (isLiteralNode(child) || isHTMLTextNode(child)) {
|
|
4393
|
-
if (child.content.trim().length > 0) {
|
|
4394
|
-
return true;
|
|
4395
|
-
}
|
|
4396
|
-
}
|
|
4397
|
-
else if (isHTMLElementNode(child)) {
|
|
4398
|
-
if (this.isElementAccessible(child)) {
|
|
4399
|
-
return true;
|
|
4400
|
-
}
|
|
4401
|
-
}
|
|
4402
|
-
else {
|
|
4403
|
-
// If there's any non-literal/non-text/non-element content (like ERB), consider it accessible
|
|
4404
|
-
return true;
|
|
4405
|
-
}
|
|
4406
|
-
}
|
|
4407
|
-
return false;
|
|
5951
|
+
return this.hasAccessibleContent(node.body);
|
|
4408
5952
|
}
|
|
4409
5953
|
}
|
|
4410
5954
|
class HTMLNoEmptyHeadingsRule extends ParserRule {
|
|
4411
|
-
|
|
5955
|
+
static ruleName = "html-no-empty-headings";
|
|
4412
5956
|
get defaultConfig() {
|
|
4413
5957
|
return {
|
|
4414
5958
|
enabled: true,
|
|
4415
|
-
severity: "
|
|
5959
|
+
severity: "warning"
|
|
4416
5960
|
};
|
|
4417
5961
|
}
|
|
4418
5962
|
check(result, context) {
|
|
4419
|
-
const visitor = new NoEmptyHeadingsVisitor(this.
|
|
5963
|
+
const visitor = new NoEmptyHeadingsVisitor(this.ruleName, context);
|
|
4420
5964
|
visitor.visit(result.value);
|
|
4421
5965
|
return visitor.offenses;
|
|
4422
5966
|
}
|
|
@@ -4432,25 +5976,32 @@ class NestedLinkVisitor extends BaseRuleVisitor {
|
|
|
4432
5976
|
return false;
|
|
4433
5977
|
}
|
|
4434
5978
|
visitHTMLElementNode(node) {
|
|
4435
|
-
if (!node.open_tag
|
|
5979
|
+
if (!node.open_tag) {
|
|
4436
5980
|
super.visitHTMLElementNode(node);
|
|
4437
5981
|
return;
|
|
4438
5982
|
}
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
|
|
5983
|
+
switch (node.open_tag.type) {
|
|
5984
|
+
case "AST_HTML_OPEN_TAG_NODE": {
|
|
5985
|
+
const openTag = node.open_tag;
|
|
5986
|
+
const tagName = getTagLocalName(openTag);
|
|
5987
|
+
if (tagName !== "a") {
|
|
5988
|
+
super.visitHTMLElementNode(node);
|
|
5989
|
+
return;
|
|
5990
|
+
}
|
|
5991
|
+
this.checkNestedLink(openTag);
|
|
5992
|
+
this.linkStack.push(openTag);
|
|
5993
|
+
super.visitHTMLElementNode(node);
|
|
5994
|
+
this.linkStack.pop();
|
|
5995
|
+
break;
|
|
5996
|
+
}
|
|
5997
|
+
case "AST_HTML_CONDITIONAL_OPEN_TAG_NODE":
|
|
5998
|
+
super.visitHTMLElementNode(node);
|
|
5999
|
+
break;
|
|
4444
6000
|
}
|
|
4445
|
-
// If we're already inside a link, this is a nested link
|
|
4446
|
-
this.checkNestedLink(openTag);
|
|
4447
|
-
this.linkStack.push(openTag);
|
|
4448
|
-
super.visitHTMLElementNode(node);
|
|
4449
|
-
this.linkStack.pop();
|
|
4450
6001
|
}
|
|
4451
6002
|
// Handle self-closing <a> tags (though they're not valid HTML, they might exist)
|
|
4452
6003
|
visitHTMLOpenTagNode(node) {
|
|
4453
|
-
const tagName =
|
|
6004
|
+
const tagName = getTagLocalName(node);
|
|
4454
6005
|
if (tagName === "a" && node.is_void) {
|
|
4455
6006
|
this.checkNestedLink(node);
|
|
4456
6007
|
}
|
|
@@ -4458,7 +6009,7 @@ class NestedLinkVisitor extends BaseRuleVisitor {
|
|
|
4458
6009
|
}
|
|
4459
6010
|
}
|
|
4460
6011
|
class HTMLNoNestedLinksRule extends ParserRule {
|
|
4461
|
-
|
|
6012
|
+
static ruleName = "html-no-nested-links";
|
|
4462
6013
|
get defaultConfig() {
|
|
4463
6014
|
return {
|
|
4464
6015
|
enabled: true,
|
|
@@ -4466,7 +6017,7 @@ class HTMLNoNestedLinksRule extends ParserRule {
|
|
|
4466
6017
|
};
|
|
4467
6018
|
}
|
|
4468
6019
|
check(result, context) {
|
|
4469
|
-
const visitor = new NestedLinkVisitor(this.
|
|
6020
|
+
const visitor = new NestedLinkVisitor(this.ruleName, context);
|
|
4470
6021
|
visitor.visit(result.value);
|
|
4471
6022
|
return visitor.offenses;
|
|
4472
6023
|
}
|
|
@@ -4483,15 +6034,15 @@ class NoPositiveTabIndexVisitor extends AttributeVisitorMixin {
|
|
|
4483
6034
|
}
|
|
4484
6035
|
}
|
|
4485
6036
|
class HTMLNoPositiveTabIndexRule extends ParserRule {
|
|
4486
|
-
|
|
6037
|
+
static ruleName = "html-no-positive-tab-index";
|
|
4487
6038
|
get defaultConfig() {
|
|
4488
6039
|
return {
|
|
4489
6040
|
enabled: true,
|
|
4490
|
-
severity: "
|
|
6041
|
+
severity: "warning"
|
|
4491
6042
|
};
|
|
4492
6043
|
}
|
|
4493
6044
|
check(result, context) {
|
|
4494
|
-
const visitor = new NoPositiveTabIndexVisitor(this.
|
|
6045
|
+
const visitor = new NoPositiveTabIndexVisitor(this.ruleName, context);
|
|
4495
6046
|
visitor.visit(result.value);
|
|
4496
6047
|
return visitor.offenses;
|
|
4497
6048
|
}
|
|
@@ -4499,7 +6050,7 @@ class HTMLNoPositiveTabIndexRule extends ParserRule {
|
|
|
4499
6050
|
|
|
4500
6051
|
class NoSelfClosingVisitor extends BaseRuleVisitor {
|
|
4501
6052
|
visitHTMLElementNode(node) {
|
|
4502
|
-
if (
|
|
6053
|
+
if (getTagLocalName(node) === "svg") {
|
|
4503
6054
|
this.visit(node.open_tag);
|
|
4504
6055
|
}
|
|
4505
6056
|
else {
|
|
@@ -4508,7 +6059,7 @@ class NoSelfClosingVisitor extends BaseRuleVisitor {
|
|
|
4508
6059
|
}
|
|
4509
6060
|
visitHTMLOpenTagNode(node) {
|
|
4510
6061
|
if (node.tag_closing?.value === "/>") {
|
|
4511
|
-
const tagName = getTagName
|
|
6062
|
+
const tagName = getTagName(node);
|
|
4512
6063
|
const instead = isVoidElement(tagName) ? `<${tagName}>` : `<${tagName}></${tagName}>`;
|
|
4513
6064
|
this.addOffense(`Use \`${instead}\` instead of self-closing \`<${tagName} />\` for HTML compatibility.`, node.location, {
|
|
4514
6065
|
node,
|
|
@@ -4520,7 +6071,7 @@ class NoSelfClosingVisitor extends BaseRuleVisitor {
|
|
|
4520
6071
|
}
|
|
4521
6072
|
class HTMLNoSelfClosingRule extends ParserRule {
|
|
4522
6073
|
static autocorrectable = true;
|
|
4523
|
-
|
|
6074
|
+
static ruleName = "html-no-self-closing";
|
|
4524
6075
|
get defaultConfig() {
|
|
4525
6076
|
return {
|
|
4526
6077
|
enabled: true,
|
|
@@ -4529,7 +6080,7 @@ class HTMLNoSelfClosingRule extends ParserRule {
|
|
|
4529
6080
|
};
|
|
4530
6081
|
}
|
|
4531
6082
|
check(result, context) {
|
|
4532
|
-
const visitor = new NoSelfClosingVisitor(this.
|
|
6083
|
+
const visitor = new NoSelfClosingVisitor(this.ruleName, context);
|
|
4533
6084
|
visitor.visit(result.value);
|
|
4534
6085
|
return visitor.offenses;
|
|
4535
6086
|
}
|
|
@@ -4677,7 +6228,7 @@ class HTMLNoSpaceInTagVisitor extends BaseRuleVisitor {
|
|
|
4677
6228
|
class HTMLNoSpaceInTagRule extends ParserRule {
|
|
4678
6229
|
// TODO: enable and fix autofix
|
|
4679
6230
|
static autocorrectable = false;
|
|
4680
|
-
|
|
6231
|
+
static ruleName = "html-no-space-in-tag";
|
|
4681
6232
|
get defaultConfig() {
|
|
4682
6233
|
return {
|
|
4683
6234
|
enabled: false,
|
|
@@ -4685,7 +6236,7 @@ class HTMLNoSpaceInTagRule extends ParserRule {
|
|
|
4685
6236
|
};
|
|
4686
6237
|
}
|
|
4687
6238
|
check(result, context) {
|
|
4688
|
-
const visitor = new HTMLNoSpaceInTagVisitor(this.
|
|
6239
|
+
const visitor = new HTMLNoSpaceInTagVisitor(this.ruleName, context);
|
|
4689
6240
|
visitor.visit(result.value);
|
|
4690
6241
|
return visitor.offenses;
|
|
4691
6242
|
}
|
|
@@ -4696,9 +6247,7 @@ class HTMLNoSpaceInTagRule extends ParserRule {
|
|
|
4696
6247
|
if (!node)
|
|
4697
6248
|
return null;
|
|
4698
6249
|
if (isHTMLOpenTagNode(node)) {
|
|
4699
|
-
|
|
4700
|
-
const whitespace = new WhitespaceNode({ type: "AST_WHITESPACE_NODE", value: token, location: Location.zero, errors: [] });
|
|
4701
|
-
node.children.push(whitespace);
|
|
6250
|
+
node.children.push(createWhitespaceNode());
|
|
4702
6251
|
return result;
|
|
4703
6252
|
}
|
|
4704
6253
|
if (!isWhitespaceNode(node))
|
|
@@ -4744,7 +6293,7 @@ class NoTitleAttributeVisitor extends BaseRuleVisitor {
|
|
|
4744
6293
|
super.visitHTMLOpenTagNode(node);
|
|
4745
6294
|
}
|
|
4746
6295
|
checkTitleAttribute(node) {
|
|
4747
|
-
const tagName =
|
|
6296
|
+
const tagName = getTagLocalName(node);
|
|
4748
6297
|
if (!tagName || this.ALLOWED_ELEMENTS_WITH_TITLE.has(tagName)) {
|
|
4749
6298
|
return;
|
|
4750
6299
|
}
|
|
@@ -4754,15 +6303,15 @@ class NoTitleAttributeVisitor extends BaseRuleVisitor {
|
|
|
4754
6303
|
}
|
|
4755
6304
|
}
|
|
4756
6305
|
class HTMLNoTitleAttributeRule extends ParserRule {
|
|
4757
|
-
|
|
6306
|
+
static ruleName = "html-no-title-attribute";
|
|
4758
6307
|
get defaultConfig() {
|
|
4759
6308
|
return {
|
|
4760
6309
|
enabled: false,
|
|
4761
|
-
severity: "
|
|
6310
|
+
severity: "warning"
|
|
4762
6311
|
};
|
|
4763
6312
|
}
|
|
4764
6313
|
check(result, context) {
|
|
4765
|
-
const visitor = new NoTitleAttributeVisitor(this.
|
|
6314
|
+
const visitor = new NoTitleAttributeVisitor(this.ruleName, context);
|
|
4766
6315
|
visitor.visit(result.value);
|
|
4767
6316
|
return visitor.offenses;
|
|
4768
6317
|
}
|
|
@@ -4792,7 +6341,7 @@ class HTMLNoUnderscoresInAttributeNamesVisitor extends AttributeVisitorMixin {
|
|
|
4792
6341
|
}
|
|
4793
6342
|
}
|
|
4794
6343
|
class HTMLNoUnderscoresInAttributeNamesRule extends ParserRule {
|
|
4795
|
-
|
|
6344
|
+
static ruleName = "html-no-underscores-in-attribute-names";
|
|
4796
6345
|
get defaultConfig() {
|
|
4797
6346
|
return {
|
|
4798
6347
|
enabled: true,
|
|
@@ -4800,7 +6349,34 @@ class HTMLNoUnderscoresInAttributeNamesRule extends ParserRule {
|
|
|
4800
6349
|
};
|
|
4801
6350
|
}
|
|
4802
6351
|
check(result, context) {
|
|
4803
|
-
const visitor = new HTMLNoUnderscoresInAttributeNamesVisitor(this.
|
|
6352
|
+
const visitor = new HTMLNoUnderscoresInAttributeNamesVisitor(this.ruleName, context);
|
|
6353
|
+
visitor.visit(result.value);
|
|
6354
|
+
return visitor.offenses;
|
|
6355
|
+
}
|
|
6356
|
+
}
|
|
6357
|
+
|
|
6358
|
+
class RequireClosingTagsVisitor extends BaseRuleVisitor {
|
|
6359
|
+
visitHTMLOmittedCloseTagNode(node) {
|
|
6360
|
+
const tagName = node.tag_name?.value;
|
|
6361
|
+
if (!tagName)
|
|
6362
|
+
return;
|
|
6363
|
+
this.addOffense(`Missing explicit closing tag for \`<${tagName}>\`. Use \`</${tagName}>\` instead of relying on implicit tag closing.`, node.location);
|
|
6364
|
+
}
|
|
6365
|
+
}
|
|
6366
|
+
class HTMLRequireClosingTagsRule extends ParserRule {
|
|
6367
|
+
static autocorrectable = false;
|
|
6368
|
+
static ruleName = "html-require-closing-tags";
|
|
6369
|
+
get defaultConfig() {
|
|
6370
|
+
return {
|
|
6371
|
+
enabled: true,
|
|
6372
|
+
severity: "error",
|
|
6373
|
+
};
|
|
6374
|
+
}
|
|
6375
|
+
get parserOptions() {
|
|
6376
|
+
return { strict: false };
|
|
6377
|
+
}
|
|
6378
|
+
check(result, context) {
|
|
6379
|
+
const visitor = new RequireClosingTagsVisitor(this.ruleName, context);
|
|
4804
6380
|
visitor.visit(result.value);
|
|
4805
6381
|
return visitor.offenses;
|
|
4806
6382
|
}
|
|
@@ -4819,9 +6395,11 @@ class XMLDeclarationChecker extends BaseRuleVisitor {
|
|
|
4819
6395
|
}
|
|
4820
6396
|
class TagNameLowercaseVisitor extends BaseRuleVisitor {
|
|
4821
6397
|
visitHTMLElementNode(node) {
|
|
4822
|
-
if (
|
|
4823
|
-
this.checkTagName(node
|
|
4824
|
-
|
|
6398
|
+
if (getTagLocalName(node) === "svg") {
|
|
6399
|
+
this.checkTagName(getOpenTag(node));
|
|
6400
|
+
if (node.close_tag && isHTMLCloseTagNode(node.close_tag)) {
|
|
6401
|
+
this.checkTagName(node.close_tag);
|
|
6402
|
+
}
|
|
4825
6403
|
}
|
|
4826
6404
|
else {
|
|
4827
6405
|
super.visitHTMLElementNode(node);
|
|
@@ -4836,7 +6414,7 @@ class TagNameLowercaseVisitor extends BaseRuleVisitor {
|
|
|
4836
6414
|
checkTagName(node) {
|
|
4837
6415
|
if (!node)
|
|
4838
6416
|
return;
|
|
4839
|
-
const tagName = getTagName
|
|
6417
|
+
const tagName = getTagName(node);
|
|
4840
6418
|
if (!tagName)
|
|
4841
6419
|
return;
|
|
4842
6420
|
const lowercaseTagName = tagName.toLowerCase();
|
|
@@ -4853,7 +6431,7 @@ class TagNameLowercaseVisitor extends BaseRuleVisitor {
|
|
|
4853
6431
|
}
|
|
4854
6432
|
class HTMLTagNameLowercaseRule extends ParserRule {
|
|
4855
6433
|
static autocorrectable = true;
|
|
4856
|
-
|
|
6434
|
+
static ruleName = "html-tag-name-lowercase";
|
|
4857
6435
|
get defaultConfig() {
|
|
4858
6436
|
return {
|
|
4859
6437
|
enabled: true,
|
|
@@ -4862,12 +6440,12 @@ class HTMLTagNameLowercaseRule extends ParserRule {
|
|
|
4862
6440
|
};
|
|
4863
6441
|
}
|
|
4864
6442
|
isEnabled(result, _context) {
|
|
4865
|
-
const checker = new XMLDeclarationChecker(this.
|
|
6443
|
+
const checker = new XMLDeclarationChecker(this.ruleName);
|
|
4866
6444
|
checker.visit(result.value);
|
|
4867
6445
|
return !checker.hasXMLDeclaration;
|
|
4868
6446
|
}
|
|
4869
6447
|
check(result, context) {
|
|
4870
|
-
const visitor = new TagNameLowercaseVisitor(this.
|
|
6448
|
+
const visitor = new TagNameLowercaseVisitor(this.ruleName, context);
|
|
4871
6449
|
visitor.visit(result.value);
|
|
4872
6450
|
return visitor.offenses;
|
|
4873
6451
|
}
|
|
@@ -4889,14 +6467,39 @@ class HTMLTagNameLowercaseRule extends ParserRule {
|
|
|
4889
6467
|
closeTag.tag_name.value = correctedTagName;
|
|
4890
6468
|
break;
|
|
4891
6469
|
case "AST_HTML_CLOSE_TAG_NODE":
|
|
4892
|
-
const openTag = parentElement
|
|
4893
|
-
openTag
|
|
6470
|
+
const openTag = getOpenTag(parentElement);
|
|
6471
|
+
if (openTag?.tag_name) {
|
|
6472
|
+
openTag.tag_name.value = correctedTagName;
|
|
6473
|
+
}
|
|
4894
6474
|
break;
|
|
4895
6475
|
}
|
|
4896
6476
|
return result;
|
|
4897
6477
|
}
|
|
4898
6478
|
}
|
|
4899
6479
|
|
|
6480
|
+
class ParserNoErrorsRule extends ParserRule {
|
|
6481
|
+
static ruleName = "parser-no-errors";
|
|
6482
|
+
get defaultConfig() {
|
|
6483
|
+
return {
|
|
6484
|
+
enabled: true,
|
|
6485
|
+
severity: "error"
|
|
6486
|
+
};
|
|
6487
|
+
}
|
|
6488
|
+
check(result) {
|
|
6489
|
+
return result.recursiveErrors().map(error => this.herbErrorToLintOffense(error));
|
|
6490
|
+
}
|
|
6491
|
+
herbErrorToLintOffense(error) {
|
|
6492
|
+
return {
|
|
6493
|
+
message: `${error.message} (\`${error.type}\`)`,
|
|
6494
|
+
location: error.location,
|
|
6495
|
+
severity: error.severity,
|
|
6496
|
+
rule: this.ruleName,
|
|
6497
|
+
code: this.ruleName,
|
|
6498
|
+
source: "linter"
|
|
6499
|
+
};
|
|
6500
|
+
}
|
|
6501
|
+
}
|
|
6502
|
+
|
|
4900
6503
|
class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
|
|
4901
6504
|
insideSVG = false;
|
|
4902
6505
|
visitHTMLElementNode(node) {
|
|
@@ -4909,10 +6512,10 @@ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
|
|
|
4909
6512
|
return;
|
|
4910
6513
|
}
|
|
4911
6514
|
if (this.insideSVG) {
|
|
4912
|
-
if (node.open_tag) {
|
|
6515
|
+
if (isHTMLOpenTagNode(node.open_tag)) {
|
|
4913
6516
|
this.checkTagName(node.open_tag);
|
|
4914
6517
|
}
|
|
4915
|
-
if (node.close_tag) {
|
|
6518
|
+
if (node.close_tag && isHTMLCloseTagNode(node.close_tag)) {
|
|
4916
6519
|
this.checkTagName(node.close_tag);
|
|
4917
6520
|
}
|
|
4918
6521
|
}
|
|
@@ -4928,9 +6531,9 @@ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
|
|
|
4928
6531
|
const correctCamelCase = SVG_LOWERCASE_TO_CAMELCASE.get(lowercaseTagName);
|
|
4929
6532
|
if (correctCamelCase && tagName !== correctCamelCase) {
|
|
4930
6533
|
let type = node.type;
|
|
4931
|
-
if (node
|
|
6534
|
+
if (isHTMLOpenTagNode(node))
|
|
4932
6535
|
type = "Opening";
|
|
4933
|
-
if (node
|
|
6536
|
+
if (isHTMLCloseTagNode(node))
|
|
4934
6537
|
type = "Closing";
|
|
4935
6538
|
this.addOffense(`${type} SVG tag name \`${tagName}\` should use proper capitalization. Use \`${correctCamelCase}\` instead.`, node.tag_name.location, {
|
|
4936
6539
|
node,
|
|
@@ -4942,7 +6545,7 @@ class SVGTagNameCapitalizationVisitor extends BaseRuleVisitor {
|
|
|
4942
6545
|
}
|
|
4943
6546
|
class SVGTagNameCapitalizationRule extends ParserRule {
|
|
4944
6547
|
static autocorrectable = true;
|
|
4945
|
-
|
|
6548
|
+
static ruleName = "svg-tag-name-capitalization";
|
|
4946
6549
|
get defaultConfig() {
|
|
4947
6550
|
return {
|
|
4948
6551
|
enabled: true,
|
|
@@ -4950,7 +6553,7 @@ class SVGTagNameCapitalizationRule extends ParserRule {
|
|
|
4950
6553
|
};
|
|
4951
6554
|
}
|
|
4952
6555
|
check(result, context) {
|
|
4953
|
-
const visitor = new SVGTagNameCapitalizationVisitor(this.
|
|
6556
|
+
const visitor = new SVGTagNameCapitalizationVisitor(this.ruleName, context);
|
|
4954
6557
|
visitor.visit(result.value);
|
|
4955
6558
|
return visitor.offenses;
|
|
4956
6559
|
}
|
|
@@ -4965,49 +6568,75 @@ class SVGTagNameCapitalizationRule extends ParserRule {
|
|
|
4965
6568
|
}
|
|
4966
6569
|
}
|
|
4967
6570
|
|
|
4968
|
-
class
|
|
4969
|
-
|
|
6571
|
+
class TurboPermanentRequireIdVisitor extends BaseRuleVisitor {
|
|
6572
|
+
visitHTMLOpenTagNode(node) {
|
|
6573
|
+
this.checkTurboPermanent(node);
|
|
6574
|
+
super.visitHTMLOpenTagNode(node);
|
|
6575
|
+
}
|
|
6576
|
+
checkTurboPermanent(node) {
|
|
6577
|
+
const turboPermanentAttribute = getAttribute(node, "data-turbo-permanent");
|
|
6578
|
+
if (!turboPermanentAttribute) {
|
|
6579
|
+
return;
|
|
6580
|
+
}
|
|
6581
|
+
const idAttribute = getAttribute(node, "id");
|
|
6582
|
+
if (!idAttribute) {
|
|
6583
|
+
this.addOffense("Elements with `data-turbo-permanent` must have an `id` attribute. Without an `id`, Turbo can't track the element across page changes and the permanent behavior won't work as expected.", turboPermanentAttribute.location);
|
|
6584
|
+
}
|
|
6585
|
+
}
|
|
6586
|
+
}
|
|
6587
|
+
class TurboPermanentRequireIdRule extends ParserRule {
|
|
6588
|
+
static ruleName = "turbo-permanent-require-id";
|
|
4970
6589
|
get defaultConfig() {
|
|
4971
6590
|
return {
|
|
4972
6591
|
enabled: true,
|
|
4973
6592
|
severity: "error"
|
|
4974
6593
|
};
|
|
4975
6594
|
}
|
|
4976
|
-
check(result) {
|
|
4977
|
-
|
|
4978
|
-
|
|
4979
|
-
|
|
4980
|
-
return {
|
|
4981
|
-
message: `${error.message} (\`${error.type}\`)`,
|
|
4982
|
-
location: error.location,
|
|
4983
|
-
severity: error.severity,
|
|
4984
|
-
rule: this.name,
|
|
4985
|
-
code: this.name,
|
|
4986
|
-
source: "linter"
|
|
4987
|
-
};
|
|
6595
|
+
check(result, context) {
|
|
6596
|
+
const visitor = new TurboPermanentRequireIdVisitor(this.ruleName, context);
|
|
6597
|
+
visitor.visit(result.value);
|
|
6598
|
+
return visitor.offenses;
|
|
4988
6599
|
}
|
|
4989
6600
|
}
|
|
4990
6601
|
|
|
4991
6602
|
const rules = [
|
|
6603
|
+
ActionViewNoSilentHelperRule,
|
|
4992
6604
|
ERBCommentSyntax,
|
|
4993
6605
|
ERBNoCaseNodeChildrenRule,
|
|
6606
|
+
ERBNoConditionalHTMLElementRule,
|
|
6607
|
+
ERBNoConditionalOpenTagRule,
|
|
6608
|
+
ERBNoDuplicateBranchElementsRule,
|
|
4994
6609
|
ERBNoEmptyTagsRule,
|
|
4995
6610
|
ERBNoExtraNewLineRule,
|
|
4996
6611
|
ERBNoExtraWhitespaceRule,
|
|
6612
|
+
ERBNoInlineCaseConditionsRule,
|
|
6613
|
+
ERBNoInstanceVariablesInPartialsRule,
|
|
6614
|
+
ERBNoInterpolatedClassNamesRule,
|
|
6615
|
+
ERBNoJavascriptTagHelperRule,
|
|
4997
6616
|
ERBNoOutputControlFlowRule,
|
|
6617
|
+
ERBNoOutputInAttributeNameRule,
|
|
6618
|
+
ERBNoOutputInAttributePositionRule,
|
|
6619
|
+
ERBNoRawOutputInAttributeValueRule,
|
|
4998
6620
|
ERBNoSilentTagInAttributeNameRule,
|
|
6621
|
+
ERBNoStatementInScriptRule,
|
|
6622
|
+
ERBNoThenInControlFlowRule,
|
|
6623
|
+
ERBNoTrailingWhitespaceRule,
|
|
6624
|
+
ERBNoUnsafeJSAttributeRule,
|
|
6625
|
+
ERBNoUnsafeRawRule,
|
|
6626
|
+
ERBNoUnsafeScriptInterpolationRule,
|
|
4999
6627
|
ERBPreferImageTagHelperRule,
|
|
5000
6628
|
ERBRequireTrailingNewlineRule,
|
|
5001
6629
|
ERBRequireWhitespaceRule,
|
|
5002
6630
|
ERBRightTrimRule,
|
|
5003
6631
|
ERBStrictLocalsCommentSyntaxRule,
|
|
5004
6632
|
ERBStrictLocalsRequiredRule,
|
|
5005
|
-
HerbDisableCommentValidRuleNameRule,
|
|
5006
|
-
HerbDisableCommentNoRedundantAllRule,
|
|
5007
|
-
HerbDisableCommentNoDuplicateRulesRule,
|
|
5008
|
-
HerbDisableCommentMissingRulesRule,
|
|
5009
6633
|
HerbDisableCommentMalformedRule,
|
|
6634
|
+
HerbDisableCommentMissingRulesRule,
|
|
6635
|
+
HerbDisableCommentNoDuplicateRulesRule,
|
|
6636
|
+
HerbDisableCommentNoRedundantAllRule,
|
|
5010
6637
|
HerbDisableCommentUnnecessaryRule,
|
|
6638
|
+
HerbDisableCommentValidRuleNameRule,
|
|
6639
|
+
HTMLAllowedScriptTypeRule,
|
|
5011
6640
|
HTMLAnchorRequireHrefRule,
|
|
5012
6641
|
HTMLAriaAttributeMustBeValid,
|
|
5013
6642
|
HTMLAriaLabelIsWellFormattedRule,
|
|
@@ -5019,12 +6648,15 @@ const rules = [
|
|
|
5019
6648
|
HTMLAttributeValuesRequireQuotesRule,
|
|
5020
6649
|
HTMLAvoidBothDisabledAndAriaDisabledRule,
|
|
5021
6650
|
HTMLBodyOnlyElementsRule,
|
|
6651
|
+
HTMLDetailsHasSummaryRule,
|
|
5022
6652
|
HTMLBooleanAttributesNoValueRule,
|
|
5023
6653
|
HTMLHeadOnlyElementsRule,
|
|
5024
6654
|
HTMLIframeHasTitleRule,
|
|
5025
6655
|
HTMLImgRequireAltRule,
|
|
5026
6656
|
HTMLInputRequireAutocompleteRule,
|
|
5027
6657
|
HTMLNavigationHasLabelRule,
|
|
6658
|
+
HTMLNoAbstractRolesRule,
|
|
6659
|
+
HTMLNoAriaHiddenOnBodyRule,
|
|
5028
6660
|
HTMLNoAriaHiddenOnFocusableRule,
|
|
5029
6661
|
HTMLNoBlockInsideInlineRule,
|
|
5030
6662
|
HTMLNoDuplicateAttributesRule,
|
|
@@ -5038,9 +6670,11 @@ const rules = [
|
|
|
5038
6670
|
HTMLNoSpaceInTagRule,
|
|
5039
6671
|
HTMLNoTitleAttributeRule,
|
|
5040
6672
|
HTMLNoUnderscoresInAttributeNamesRule,
|
|
6673
|
+
HTMLRequireClosingTagsRule,
|
|
5041
6674
|
HTMLTagNameLowercaseRule,
|
|
5042
|
-
SVGTagNameCapitalizationRule,
|
|
5043
6675
|
ParserNoErrorsRule,
|
|
6676
|
+
SVGTagNameCapitalizationRule,
|
|
6677
|
+
TurboPermanentRequireIdRule,
|
|
5044
6678
|
];
|
|
5045
6679
|
|
|
5046
6680
|
const HERB_LINTER_PREFIX = "herb:linter";
|
|
@@ -5083,10 +6717,39 @@ class LinterIgnoreDetector extends Visitor {
|
|
|
5083
6717
|
}
|
|
5084
6718
|
}
|
|
5085
6719
|
|
|
6720
|
+
class ParseCache {
|
|
6721
|
+
herb;
|
|
6722
|
+
cache = new Map();
|
|
6723
|
+
constructor(herb) {
|
|
6724
|
+
this.herb = herb;
|
|
6725
|
+
}
|
|
6726
|
+
get(source, parserOptions = {}) {
|
|
6727
|
+
const effectiveOptions = this.resolveOptions(parserOptions);
|
|
6728
|
+
const key = source + JSON.stringify(effectiveOptions);
|
|
6729
|
+
let result = this.cache.get(key);
|
|
6730
|
+
if (!result) {
|
|
6731
|
+
result = this.herb.parse(source, effectiveOptions);
|
|
6732
|
+
this.cache.set(key, result);
|
|
6733
|
+
}
|
|
6734
|
+
return result;
|
|
6735
|
+
}
|
|
6736
|
+
clear() {
|
|
6737
|
+
this.cache.clear();
|
|
6738
|
+
}
|
|
6739
|
+
resolveOptions(parserOptions) {
|
|
6740
|
+
return {
|
|
6741
|
+
...DEFAULT_PARSER_OPTIONS,
|
|
6742
|
+
...DEFAULT_LINTER_PARSER_OPTIONS,
|
|
6743
|
+
...parserOptions
|
|
6744
|
+
};
|
|
6745
|
+
}
|
|
6746
|
+
}
|
|
6747
|
+
|
|
5086
6748
|
class Linter {
|
|
5087
6749
|
rules;
|
|
5088
6750
|
allAvailableRules;
|
|
5089
6751
|
herb;
|
|
6752
|
+
parseCache;
|
|
5090
6753
|
offenses;
|
|
5091
6754
|
config;
|
|
5092
6755
|
/**
|
|
@@ -5117,6 +6780,7 @@ class Linter {
|
|
|
5117
6780
|
*/
|
|
5118
6781
|
constructor(herb, rules, config, allAvailableRules) {
|
|
5119
6782
|
this.herb = herb;
|
|
6783
|
+
this.parseCache = new ParseCache(herb);
|
|
5120
6784
|
this.config = config;
|
|
5121
6785
|
this.rules = rules !== undefined ? rules : this.getDefaultRules();
|
|
5122
6786
|
this.allAvailableRules = allAvailableRules !== undefined ? allAvailableRules : this.rules;
|
|
@@ -5136,9 +6800,8 @@ class Linter {
|
|
|
5136
6800
|
static filterRulesByConfig(allRules, userRulesConfig) {
|
|
5137
6801
|
return allRules.filter(ruleClass => {
|
|
5138
6802
|
const instance = new ruleClass();
|
|
5139
|
-
const ruleName = instance.name;
|
|
5140
6803
|
const defaultEnabled = instance.defaultConfig?.enabled ?? DEFAULT_RULE_CONFIG.enabled;
|
|
5141
|
-
const userRuleConfig = userRulesConfig?.[ruleName];
|
|
6804
|
+
const userRuleConfig = userRulesConfig?.[ruleClass.ruleName];
|
|
5142
6805
|
if (userRuleConfig !== undefined) {
|
|
5143
6806
|
return userRuleConfig.enabled !== false;
|
|
5144
6807
|
}
|
|
@@ -5180,29 +6843,39 @@ class Linter {
|
|
|
5180
6843
|
getRuleCount() {
|
|
5181
6844
|
return this.rules.length;
|
|
5182
6845
|
}
|
|
6846
|
+
findRuleClass(ruleName) {
|
|
6847
|
+
return this.rules.find(ruleClass => ruleClass.ruleName === ruleName);
|
|
6848
|
+
}
|
|
5183
6849
|
/**
|
|
5184
|
-
* Type guard to check if a rule is a LexerRule
|
|
6850
|
+
* Type guard to check if a rule class is a LexerRule class
|
|
5185
6851
|
*/
|
|
5186
|
-
|
|
5187
|
-
return
|
|
6852
|
+
isLexerRuleClass(ruleClass) {
|
|
6853
|
+
return ruleClass.type === "lexer";
|
|
5188
6854
|
}
|
|
5189
6855
|
/**
|
|
5190
|
-
* Type guard to check if a rule is a SourceRule
|
|
6856
|
+
* Type guard to check if a rule class is a SourceRule class
|
|
5191
6857
|
*/
|
|
5192
|
-
|
|
5193
|
-
return
|
|
6858
|
+
isSourceRuleClass(ruleClass) {
|
|
6859
|
+
return ruleClass.type === "source";
|
|
6860
|
+
}
|
|
6861
|
+
/**
|
|
6862
|
+
* Type guard to check if a rule class is a ParserRule class
|
|
6863
|
+
*/
|
|
6864
|
+
isParserRuleClass(ruleClass) {
|
|
6865
|
+
return ruleClass.type === "parser" || ruleClass.type === undefined;
|
|
5194
6866
|
}
|
|
5195
6867
|
/**
|
|
5196
6868
|
* Execute a single rule and return its unbound offenses.
|
|
5197
6869
|
* Handles rule type checking (Lexer/Parser/Source) and isEnabled checks.
|
|
5198
6870
|
*/
|
|
5199
|
-
executeRule(rule, parseResult, lexResult, source, context) {
|
|
6871
|
+
executeRule(ruleClass, rule, parseResult, lexResult, source, context) {
|
|
6872
|
+
const ruleName = rule.ruleName;
|
|
5200
6873
|
if (this.config && context?.fileName) {
|
|
5201
|
-
if (!this.config.isRuleEnabledForPath(
|
|
6874
|
+
if (!this.config.isRuleEnabledForPath(ruleName, context.fileName)) {
|
|
5202
6875
|
return [];
|
|
5203
6876
|
}
|
|
5204
6877
|
}
|
|
5205
|
-
if (context?.fileName && !this.config?.linter?.rules?.[
|
|
6878
|
+
if (context?.fileName && !this.config?.linter?.rules?.[ruleName]?.exclude) {
|
|
5206
6879
|
const defaultExclude = rule.defaultConfig?.exclude ?? DEFAULT_RULE_CONFIG.exclude;
|
|
5207
6880
|
if (defaultExclude && defaultExclude.length > 0) {
|
|
5208
6881
|
const isExcluded = defaultExclude.some(pattern => picomatch.isMatch(context.fileName, pattern));
|
|
@@ -5213,34 +6886,37 @@ class Linter {
|
|
|
5213
6886
|
}
|
|
5214
6887
|
let isEnabled = true;
|
|
5215
6888
|
let ruleOffenses;
|
|
5216
|
-
if (this.
|
|
5217
|
-
|
|
5218
|
-
|
|
6889
|
+
if (this.isLexerRuleClass(ruleClass)) {
|
|
6890
|
+
const lexerRule = rule;
|
|
6891
|
+
if (lexerRule.isEnabled) {
|
|
6892
|
+
isEnabled = lexerRule.isEnabled(lexResult, context);
|
|
5219
6893
|
}
|
|
5220
6894
|
if (isEnabled) {
|
|
5221
|
-
ruleOffenses =
|
|
6895
|
+
ruleOffenses = lexerRule.check(lexResult, context);
|
|
5222
6896
|
}
|
|
5223
6897
|
else {
|
|
5224
6898
|
ruleOffenses = [];
|
|
5225
6899
|
}
|
|
5226
6900
|
}
|
|
5227
|
-
else if (this.
|
|
5228
|
-
|
|
5229
|
-
|
|
6901
|
+
else if (this.isSourceRuleClass(ruleClass)) {
|
|
6902
|
+
const sourceRule = rule;
|
|
6903
|
+
if (sourceRule.isEnabled) {
|
|
6904
|
+
isEnabled = sourceRule.isEnabled(source, context);
|
|
5230
6905
|
}
|
|
5231
6906
|
if (isEnabled) {
|
|
5232
|
-
ruleOffenses =
|
|
6907
|
+
ruleOffenses = sourceRule.check(source, context);
|
|
5233
6908
|
}
|
|
5234
6909
|
else {
|
|
5235
6910
|
ruleOffenses = [];
|
|
5236
6911
|
}
|
|
5237
6912
|
}
|
|
5238
6913
|
else {
|
|
5239
|
-
|
|
5240
|
-
|
|
6914
|
+
const parserRule = rule;
|
|
6915
|
+
if (parserRule.isEnabled) {
|
|
6916
|
+
isEnabled = parserRule.isEnabled(parseResult, context);
|
|
5241
6917
|
}
|
|
5242
6918
|
if (isEnabled) {
|
|
5243
|
-
ruleOffenses =
|
|
6919
|
+
ruleOffenses = parserRule.check(parseResult, context);
|
|
5244
6920
|
}
|
|
5245
6921
|
else {
|
|
5246
6922
|
ruleOffenses = [];
|
|
@@ -5292,7 +6968,7 @@ class Linter {
|
|
|
5292
6968
|
this.offenses = [];
|
|
5293
6969
|
let ignoredCount = 0;
|
|
5294
6970
|
let wouldBeIgnoredCount = 0;
|
|
5295
|
-
const parseResult = this.
|
|
6971
|
+
const parseResult = this.parseCache.get(source);
|
|
5296
6972
|
// Check for file-level ignore directive using visitor
|
|
5297
6973
|
if (hasLinterIgnoreDirective(parseResult)) {
|
|
5298
6974
|
return {
|
|
@@ -5310,20 +6986,12 @@ class Linter {
|
|
|
5310
6986
|
const ignoredOffensesByLine = new Map();
|
|
5311
6987
|
const herbDisableCache = new Map();
|
|
5312
6988
|
if (hasParserErrors) {
|
|
5313
|
-
const hasParserRule = this.
|
|
6989
|
+
const hasParserRule = this.findRuleClass("parser-no-errors");
|
|
5314
6990
|
if (hasParserRule) {
|
|
5315
6991
|
const rule = new ParserNoErrorsRule();
|
|
5316
6992
|
const offenses = rule.check(parseResult);
|
|
5317
6993
|
this.offenses.push(...offenses);
|
|
5318
6994
|
}
|
|
5319
|
-
return {
|
|
5320
|
-
offenses: this.offenses,
|
|
5321
|
-
errors: this.offenses.filter(o => o.severity === "error").length,
|
|
5322
|
-
warnings: this.offenses.filter(o => o.severity === "warning").length,
|
|
5323
|
-
info: this.offenses.filter(o => o.severity === "info").length,
|
|
5324
|
-
hints: this.offenses.filter(o => o.severity === "hint").length,
|
|
5325
|
-
ignored: 0
|
|
5326
|
-
};
|
|
5327
6995
|
}
|
|
5328
6996
|
for (let i = 0; i < sourceLines.length; i++) {
|
|
5329
6997
|
const line = sourceLines[i];
|
|
@@ -5334,30 +7002,36 @@ class Linter {
|
|
|
5334
7002
|
}
|
|
5335
7003
|
context = {
|
|
5336
7004
|
...context,
|
|
5337
|
-
validRuleNames: this.getAvailableRules().map(
|
|
7005
|
+
validRuleNames: this.getAvailableRules().map(ruleClass => ruleClass.ruleName),
|
|
5338
7006
|
ignoredOffensesByLine
|
|
5339
7007
|
};
|
|
5340
|
-
const regularRules = this.rules.filter(
|
|
5341
|
-
|
|
5342
|
-
|
|
5343
|
-
|
|
5344
|
-
|
|
5345
|
-
|
|
5346
|
-
|
|
5347
|
-
|
|
5348
|
-
|
|
7008
|
+
const regularRules = this.rules.filter(ruleClass => ruleClass.ruleName !== "herb-disable-comment-unnecessary");
|
|
7009
|
+
for (const ruleClass of regularRules) {
|
|
7010
|
+
const rule = new ruleClass();
|
|
7011
|
+
const parserOptions = this.isParserRuleClass(ruleClass) ? rule.parserOptions : {};
|
|
7012
|
+
const parseResult = this.parseCache.get(source, parserOptions);
|
|
7013
|
+
// Skip parser rules whose parse result has errors (parser-no-errors handled above)
|
|
7014
|
+
// Skip lexer/source rules when the default parse has errors
|
|
7015
|
+
if (this.isParserRuleClass(ruleClass)) {
|
|
7016
|
+
if (parseResult.recursiveErrors().length > 0)
|
|
7017
|
+
continue;
|
|
7018
|
+
}
|
|
7019
|
+
else if (hasParserErrors) {
|
|
7020
|
+
continue;
|
|
7021
|
+
}
|
|
7022
|
+
const unboundOffenses = this.executeRule(ruleClass, rule, parseResult, lexResult, source, context);
|
|
7023
|
+
const boundOffenses = this.bindSeverity(unboundOffenses, ruleClass.ruleName);
|
|
7024
|
+
const { kept, ignored, wouldBeIgnored } = this.filterOffenses(boundOffenses, ruleClass.ruleName, ignoredOffensesByLine, herbDisableCache, context?.ignoreDisableComments);
|
|
5349
7025
|
ignoredCount += ignored.length;
|
|
5350
7026
|
wouldBeIgnoredCount += wouldBeIgnored.length;
|
|
5351
7027
|
this.offenses.push(...kept);
|
|
5352
7028
|
}
|
|
5353
|
-
const unnecessaryRuleClass = this.
|
|
5354
|
-
const rule = new RuleClass();
|
|
5355
|
-
return rule.name === "herb-disable-comment-unnecessary";
|
|
5356
|
-
});
|
|
7029
|
+
const unnecessaryRuleClass = this.findRuleClass("herb-disable-comment-unnecessary");
|
|
5357
7030
|
if (unnecessaryRuleClass) {
|
|
5358
7031
|
const unnecessaryRule = new unnecessaryRuleClass();
|
|
7032
|
+
const parseResult = this.parseCache.get(source, unnecessaryRule.parserOptions);
|
|
5359
7033
|
const unboundOffenses = unnecessaryRule.check(parseResult, context);
|
|
5360
|
-
const boundOffenses = this.bindSeverity(unboundOffenses,
|
|
7034
|
+
const boundOffenses = this.bindSeverity(unboundOffenses, unnecessaryRuleClass.ruleName);
|
|
5361
7035
|
this.offenses.push(...boundOffenses);
|
|
5362
7036
|
}
|
|
5363
7037
|
const finalOffenses = this.offenses;
|
|
@@ -5390,23 +7064,20 @@ class Linter {
|
|
|
5390
7064
|
* @returns Array of offenses with severity bound
|
|
5391
7065
|
*/
|
|
5392
7066
|
bindSeverity(unboundOffenses, ruleName) {
|
|
5393
|
-
const
|
|
5394
|
-
|
|
5395
|
-
return instance.name === ruleName;
|
|
5396
|
-
});
|
|
5397
|
-
if (!RuleClass) {
|
|
7067
|
+
const ruleClass = this.findRuleClass(ruleName);
|
|
7068
|
+
if (!ruleClass) {
|
|
5398
7069
|
return unboundOffenses.map(offense => ({
|
|
5399
7070
|
...offense,
|
|
5400
7071
|
severity: "error"
|
|
5401
7072
|
}));
|
|
5402
7073
|
}
|
|
5403
|
-
const ruleInstance = new
|
|
7074
|
+
const ruleInstance = new ruleClass();
|
|
5404
7075
|
const defaultSeverity = ruleInstance.defaultConfig?.severity ?? DEFAULT_RULE_CONFIG.severity;
|
|
5405
7076
|
const userRuleConfig = this.config?.linter?.rules?.[ruleName];
|
|
5406
7077
|
const severity = userRuleConfig?.severity ?? defaultSeverity;
|
|
5407
7078
|
return unboundOffenses.map(offense => ({
|
|
5408
7079
|
...offense,
|
|
5409
|
-
severity
|
|
7080
|
+
severity: offense.severity ?? severity
|
|
5410
7081
|
}));
|
|
5411
7082
|
}
|
|
5412
7083
|
/**
|
|
@@ -5425,14 +7096,11 @@ class Linter {
|
|
|
5425
7096
|
const parserOffenses = [];
|
|
5426
7097
|
const sourceOffenses = [];
|
|
5427
7098
|
for (const offense of lintResult.offenses) {
|
|
5428
|
-
const
|
|
5429
|
-
|
|
5430
|
-
return instance.name === offense.rule;
|
|
5431
|
-
});
|
|
5432
|
-
if (!RuleClass)
|
|
7099
|
+
const ruleClass = this.findRuleClass(offense.rule);
|
|
7100
|
+
if (!ruleClass)
|
|
5433
7101
|
continue;
|
|
5434
|
-
if (
|
|
5435
|
-
else if (
|
|
7102
|
+
if (this.isLexerRuleClass(ruleClass)) ;
|
|
7103
|
+
else if (this.isSourceRuleClass(ruleClass)) {
|
|
5436
7104
|
sourceOffenses.push(offense);
|
|
5437
7105
|
}
|
|
5438
7106
|
else {
|
|
@@ -5443,15 +7111,16 @@ class Linter {
|
|
|
5443
7111
|
const fixed = [];
|
|
5444
7112
|
const unfixed = [];
|
|
5445
7113
|
if (parserOffenses.length > 0) {
|
|
5446
|
-
const parseResult = this.
|
|
7114
|
+
const parseResult = this.parseCache.get(currentSource);
|
|
7115
|
+
let needsReindent = false;
|
|
5447
7116
|
for (const offense of parserOffenses) {
|
|
5448
|
-
const
|
|
5449
|
-
if (!
|
|
7117
|
+
const ruleClass = this.findRuleClass(offense.rule);
|
|
7118
|
+
if (!ruleClass) {
|
|
5450
7119
|
unfixed.push(offense);
|
|
5451
7120
|
continue;
|
|
5452
7121
|
}
|
|
5453
|
-
const rule = new
|
|
5454
|
-
const isUnsafe =
|
|
7122
|
+
const rule = new ruleClass();
|
|
7123
|
+
const isUnsafe = ruleClass.unsafeAutocorrectable === true || offense.autofixContext?.unsafe === true;
|
|
5455
7124
|
if (!rule.autofix) {
|
|
5456
7125
|
unfixed.push(offense);
|
|
5457
7126
|
continue;
|
|
@@ -5475,14 +7144,21 @@ class Linter {
|
|
|
5475
7144
|
const fixedResult = rule.autofix(offense, parseResult, context);
|
|
5476
7145
|
if (fixedResult) {
|
|
5477
7146
|
fixed.push(offense);
|
|
7147
|
+
if (this.isParserRuleClass(ruleClass) && ruleClass.reindentAfterAutofix === true) {
|
|
7148
|
+
needsReindent = true;
|
|
7149
|
+
}
|
|
5478
7150
|
}
|
|
5479
7151
|
else {
|
|
5480
7152
|
unfixed.push(offense);
|
|
5481
7153
|
}
|
|
5482
7154
|
}
|
|
5483
7155
|
if (fixed.length > 0) {
|
|
5484
|
-
|
|
5485
|
-
|
|
7156
|
+
if (needsReindent) {
|
|
7157
|
+
currentSource = new IndentPrinter().print(parseResult.value);
|
|
7158
|
+
}
|
|
7159
|
+
else {
|
|
7160
|
+
currentSource = new IdentityPrinter().print(parseResult.value);
|
|
7161
|
+
}
|
|
5486
7162
|
}
|
|
5487
7163
|
}
|
|
5488
7164
|
if (sourceOffenses.length > 0) {
|
|
@@ -5493,13 +7169,13 @@ class Linter {
|
|
|
5493
7169
|
return b.location.start.column - a.location.start.column;
|
|
5494
7170
|
});
|
|
5495
7171
|
for (const offense of sortedSourceOffenses) {
|
|
5496
|
-
const
|
|
5497
|
-
if (!
|
|
7172
|
+
const ruleClass = this.findRuleClass(offense.rule);
|
|
7173
|
+
if (!ruleClass) {
|
|
5498
7174
|
unfixed.push(offense);
|
|
5499
7175
|
continue;
|
|
5500
7176
|
}
|
|
5501
|
-
const rule = new
|
|
5502
|
-
const isUnsafe =
|
|
7177
|
+
const rule = new ruleClass();
|
|
7178
|
+
const isUnsafe = ruleClass.unsafeAutocorrectable === true || offense.autofixContext?.unsafe === true;
|
|
5503
7179
|
if (!rule.autofix) {
|
|
5504
7180
|
unfixed.push(offense);
|
|
5505
7181
|
continue;
|
|
@@ -5526,5 +7202,10 @@ class Linter {
|
|
|
5526
7202
|
}
|
|
5527
7203
|
}
|
|
5528
7204
|
|
|
5529
|
-
|
|
7205
|
+
const DOCS_BASE_URL = "https://herb-tools.dev/linter/rules";
|
|
7206
|
+
function ruleDocumentationUrl(ruleId) {
|
|
7207
|
+
return `${DOCS_BASE_URL}/${ruleId}`;
|
|
7208
|
+
}
|
|
7209
|
+
|
|
7210
|
+
export { ABSTRACT_ARIA_ROLES, ARIA_ATTRIBUTES, AttributeVisitorMixin, BaseLexerRuleVisitor, BaseRuleVisitor, BaseSourceRuleVisitor, ControlFlowTrackingVisitor, ControlFlowType, DEFAULT_LINTER_PARSER_OPTIONS, DEFAULT_LINT_CONTEXT, DEFAULT_RULE_CONFIG, DOCUMENT_ONLY_TAG_NAMES, ERBCommentSyntax, ERBNoCaseNodeChildrenRule, ERBNoConditionalOpenTagRule, ERBNoDuplicateBranchElementsRule, ERBNoEmptyTagsRule, ERBNoExtraNewLineRule, ERBNoExtraWhitespaceRule, ERBNoInlineCaseConditionsRule, ERBNoInstanceVariablesInPartialsRule, ERBNoJavascriptTagHelperRule, ERBNoOutputControlFlowRule, ERBNoOutputInAttributeNameRule, ERBNoOutputInAttributePositionRule, ERBNoRawOutputInAttributeValueRule, ERBNoSilentTagInAttributeNameRule, ERBNoStatementInScriptRule, ERBNoThenInControlFlowRule, ERBNoTrailingWhitespaceRule, ERBNoUnsafeJSAttributeRule, ERBNoUnsafeRawRule, ERBNoUnsafeScriptInterpolationRule, ERBPreferImageTagHelperRule, ERBRequireTrailingNewlineRule, ERBRequireWhitespaceRule, ERBRightTrimRule, ERBStrictLocalsCommentSyntaxRule, ERBStrictLocalsRequiredRule, HEADING_TAGS, HEAD_AND_BODY_TAG_NAMES, HEAD_ONLY_TAG_NAMES, HTMLAllowedScriptTypeRule, HTMLAnchorRequireHrefRule, HTMLAriaLabelIsWellFormattedRule, HTMLAriaLevelMustBeValidRule, HTMLAriaRoleHeadingRequiresLevelRule, HTMLAriaRoleMustBeValidRule, HTMLAttributeDoubleQuotesRule, HTMLAttributeEqualsSpacingRule, HTMLAttributeValuesRequireQuotesRule, HTMLAvoidBothDisabledAndAriaDisabledRule, HTMLBodyOnlyElementsRule, HTMLBooleanAttributesNoValueRule, HTMLDetailsHasSummaryRule, HTMLHeadOnlyElementsRule, HTMLIframeHasTitleRule, HTMLImgRequireAltRule, HTMLInputRequireAutocompleteRule, HTMLNavigationHasLabelRule, HTMLNoAbstractRolesRule, HTMLNoAriaHiddenOnBodyRule, HTMLNoAriaHiddenOnFocusableRule, HTMLNoBlockInsideInlineRule, HTMLNoDuplicateAttributesRule, HTMLNoDuplicateIdsRule, HTMLNoDuplicateMetaNamesRule, HTMLNoEmptyAttributesRule, HTMLNoEmptyHeadingsRule, HTMLNoNestedLinksRule, HTMLNoPositiveTabIndexRule, HTMLNoSelfClosingRule, HTMLNoSpaceInTagRule, HTMLNoTitleAttributeRule, HTMLNoUnderscoresInAttributeNamesRule, HTMLRequireClosingTagsRule, HTMLTagNameLowercaseRule, HTML_BLOCK_ELEMENTS, HTML_BOOLEAN_ATTRIBUTES, HTML_INLINE_ELEMENTS, HTML_ONLY_TAG_NAMES, HTML_VOID_ELEMENTS, HerbDisableCommentBaseVisitor, HerbDisableCommentMalformedRule, HerbDisableCommentMissingRulesRule, HerbDisableCommentNoDuplicateRulesRule, HerbDisableCommentNoRedundantAllRule, HerbDisableCommentParsedVisitor, HerbDisableCommentUnnecessaryRule, HerbDisableCommentValidRuleNameRule, LexerRule, Linter, ParserRule, STRICT_LOCALS_PATTERN, SVGTagNameCapitalizationRule, SVG_CAMEL_CASE_ELEMENTS, SVG_LOWERCASE_TO_CAMELCASE, SourceRule, VALID_ARIA_ROLES, createEndOfFileLocation, findNodeAtPosition, findNodeByLocation, findParent, getBasename, hasBalancedParentheses, isBlockElement, isBodyOnlyTag, isBodyTag, isBooleanAttribute, isDocumentOnlyTag, isHeadAndBodyTag, isHeadOnlyTag, isHeadTag, isHtmlOnlyTag, isInlineElement, isPartialFile, isVoidElement, locationFromOffset, locationsEqual, positionFromOffset, ruleDocumentationUrl, rules, splitByTopLevelComma };
|
|
5530
7211
|
//# sourceMappingURL=index.js.map
|