From cedf9d523ab100fb6312559da3defd8114cdff3d Mon Sep 17 00:00:00 2001
From: David Sveningsson <ext@sidvind.com>
Date: Sat, 14 May 2022 18:07:39 +0200
Subject: [PATCH] feat(rules): add `any` option to `attr-quotes`

fixes #152
---
 .../__snapshots__/attr-quotes.md.spec.ts.snap |   7 +-
 docs/rules/attr-quotes.md                     |   9 +-
 .../__snapshots__/attr-quotes.spec.ts.snap    | 115 +++++++++++++++--
 src/rules/attr-quotes.spec.ts                 | 102 +++++++++++++--
 src/rules/attr-quotes.ts                      | 118 ++++++++++++++----
 5 files changed, 301 insertions(+), 50 deletions(-)

diff --git a/docs/rules/__tests__/__snapshots__/attr-quotes.md.spec.ts.snap b/docs/rules/__tests__/__snapshots__/attr-quotes.md.spec.ts.snap
index 645abb826..2552df136 100644
--- a/docs/rules/__tests__/__snapshots__/attr-quotes.md.spec.ts.snap
+++ b/docs/rules/__tests__/__snapshots__/attr-quotes.md.spec.ts.snap
@@ -10,7 +10,12 @@ exports[`docs/rules/attr-quotes.md inline validation: incorrect 1`] = `
     "messages": [
       {
         "column": 4,
-        "context": undefined,
+        "context": {
+          "actual": "'",
+          "attr": "class",
+          "error": "style",
+          "expected": """,
+        },
         "line": 1,
         "message": "Attribute "class" used ' instead of expected "",
         "offset": 3,
diff --git a/docs/rules/attr-quotes.md b/docs/rules/attr-quotes.md
index 69d5df1fa..4b3b7a9ae 100644
--- a/docs/rules/attr-quotes.md
+++ b/docs/rules/attr-quotes.md
@@ -13,7 +13,7 @@ HTML allows different styles for quoting attributes:
   `<div id='foo'>`
 - Double-quote `"`:  
   `<div id="foo">`
-- Unquoted `'`:  
+- Unquoted:  
   `<div id=foo>` (with limitations on allowed content)
 
 This rule unifies which styles are allowed.
@@ -45,9 +45,10 @@ This rule takes an optional object:
 
 ### Style
 
-- `auto` requires usage of `"` unless the attribute value contains `"` (default).
-- `single` requires usage of `'` for all attributes.
-- `double` requires usage of `"` for all attributes.
+- `auto` requires usage of double quotes `"` unless the attribute value contains `"` (default).
+- `single` requires usage of single quotes `'` for all attributes.
+- `double` requires usage of double quotes `"` for all attributes.
+- `any` requires usage of either single quotes `'` or double quotes `"` for all attributes.
 
 ### Unquoted
 
diff --git a/src/rules/__snapshots__/attr-quotes.spec.ts.snap b/src/rules/__snapshots__/attr-quotes.spec.ts.snap
index a1503f3c6..de318d4e2 100644
--- a/src/rules/__snapshots__/attr-quotes.spec.ts.snap
+++ b/src/rules/__snapshots__/attr-quotes.spec.ts.snap
@@ -1,15 +1,106 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`rule attr-quotes should contain documentation (auto) 1`] = `
-{
-  "description": "Attribute values are required to be quoted with doublequotes unless the attribute value itself contains doublequotes in which case singlequotes should be used.",
-  "url": "https://html-validate.org/rules/attr-quotes.html",
-}
-`;
-
-exports[`rule attr-quotes should contain documentation (fixed) 1`] = `
-{
-  "description": "Attribute values are required to be quoted with doublequotes.",
-  "url": "https://html-validate.org/rules/attr-quotes.html",
-}
+exports[`rule attr-quotes should contain documentation style "any" and unquoted "false" 1`] = `
+"This attribute is not quoted properly.
+
+Under the current configuration attributes must be:
+
+- quoted with single quotes \`'\` or
+- quoted with double quotes \`"\`
+"
+`;
+
+exports[`rule attr-quotes should contain documentation style "any" and unquoted "true" 1`] = `
+"This attribute is not quoted properly.
+
+Under the current configuration attributes must be:
+
+- quoted with single quotes \`'\` or
+- quoted with double quotes \`"\` or
+- unquoted (if applicable)
+"
+`;
+
+exports[`rule attr-quotes should contain documentation style "auto" and unquoted "false" 1`] = `
+"This attribute is not quoted properly.
+
+Under the current configuration attributes must be:
+
+- quoted with double quotes \`"\` unless the value contains double quotes in which case single quotes \`'\` should be used instead
+"
+`;
+
+exports[`rule attr-quotes should contain documentation style "auto" and unquoted "true" 1`] = `
+"This attribute is not quoted properly.
+
+Under the current configuration attributes must be:
+
+- quoted with double quotes \`"\` unless the value contains double quotes in which case single quotes \`'\` should be used instead or
+- unquoted (if applicable)
+"
+`;
+
+exports[`rule attr-quotes should contain documentation style "double" and unquoted "false" 1`] = `
+"This attribute is not quoted properly.
+
+Under the current configuration attributes must be:
+
+- quoted with double quotes \`"\`
+"
+`;
+
+exports[`rule attr-quotes should contain documentation style "double" and unquoted "true" 1`] = `
+"This attribute is not quoted properly.
+
+Under the current configuration attributes must be:
+
+- quoted with double quotes \`"\` or
+- unquoted (if applicable)
+"
+`;
+
+exports[`rule attr-quotes should contain documentation style "single" and unquoted "false" 1`] = `
+"This attribute is not quoted properly.
+
+Under the current configuration attributes must be:
+
+- quoted with single quotes \`'\`
+"
+`;
+
+exports[`rule attr-quotes should contain documentation style "single" and unquoted "true" 1`] = `
+"This attribute is not quoted properly.
+
+Under the current configuration attributes must be:
+
+- quoted with single quotes \`'\` or
+- unquoted (if applicable)
+"
+`;
+
+exports[`rule attr-quotes should contain documentation with style context 1`] = `
+"Attribute \`foo\` must use \`"\` instead of \`'\`.
+
+Under the current configuration attributes must be:
+
+- quoted with double quotes \`"\` unless the value contains double quotes in which case single quotes \`'\` should be used instead
+"
+`;
+
+exports[`rule attr-quotes should contain documentation with unquoted context 1`] = `
+"Attribute \`foo\` must not be unquoted.
+
+Under the current configuration attributes must be:
+
+- quoted with double quotes \`"\` unless the value contains double quotes in which case single quotes \`'\` should be used instead
+"
+`;
+
+exports[`rule attr-quotes should contain documentation without context 1`] = `
+"This attribute is not quoted properly.
+
+Under the current configuration attributes must be:
+
+- quoted with double quotes \`"\` unless the value contains double quotes in which case single quotes \`'\` should be used instead
+"
 `;
diff --git a/src/rules/attr-quotes.spec.ts b/src/rules/attr-quotes.spec.ts
index b0ef993c1..19738bb1a 100644
--- a/src/rules/attr-quotes.spec.ts
+++ b/src/rules/attr-quotes.spec.ts
@@ -1,5 +1,6 @@
 import HtmlValidate from "../htmlvalidate";
 import "../jest";
+import { RuleStyleContext, RuleUnquotedContext } from "./attr-quotes";
 
 describe("rule attr-quotes", () => {
 	let htmlvalidate: HtmlValidate;
@@ -79,6 +80,32 @@ describe("rule attr-quotes", () => {
 		});
 	});
 
+	describe("with any option", () => {
+		beforeAll(() => {
+			htmlvalidate = new HtmlValidate({
+				rules: { "attr-quotes": ["error", { style: "any" }] },
+			});
+		});
+
+		it("should not report when attributes use double quotes", () => {
+			expect.assertions(1);
+			const report = htmlvalidate.validateString('<div foo="bar"></div>');
+			expect(report).toBeValid();
+		});
+
+		it("should not report error when attributes use single quotes", () => {
+			expect.assertions(1);
+			const report = htmlvalidate.validateString("<div foo='bar'></div>");
+			expect(report).toBeValid();
+		});
+
+		it("should not report for boolean attribute", () => {
+			expect.assertions(1);
+			const report = htmlvalidate.validateString("<input checked>");
+			expect(report).toBeValid();
+		});
+	});
+
 	describe("with unquoted allowed", () => {
 		beforeAll(() => {
 			htmlvalidate = new HtmlValidate({
@@ -131,23 +158,74 @@ describe("rule attr-quotes", () => {
 				rules: { "attr-quotes": ["error", { style: "foobar" }] },
 			});
 		}).toThrowErrorMatchingInlineSnapshot(
-			`"Rule configuration error: /rules/attr-quotes/1/style must be equal to one of the allowed values: auto, double, single"`
+			`"Rule configuration error: /rules/attr-quotes/1/style must be equal to one of the allowed values: auto, double, single, any"`
 		);
 	});
 
-	it("should contain documentation (auto)", () => {
-		expect.assertions(1);
-		htmlvalidate = new HtmlValidate({
-			rules: { "attr-quotes": ["error", { style: "auto" }] },
+	describe("should contain documentation", () => {
+		it("url", () => {
+			expect.assertions(1);
+			htmlvalidate = new HtmlValidate({
+				rules: { "attr-quotes": "error" },
+			});
+			const docs = htmlvalidate.getRuleDocumentation("attr-quotes");
+			expect(docs?.url).toMatchInlineSnapshot(`"https://html-validate.org/rules/attr-quotes.html"`);
 		});
-		expect(htmlvalidate.getRuleDocumentation("attr-quotes")).toMatchSnapshot();
-	});
 
-	it("should contain documentation (fixed)", () => {
-		expect.assertions(1);
-		htmlvalidate = new HtmlValidate({
-			rules: { "attr-quotes": ["error", { style: "double" }] },
+		it("without context", () => {
+			expect.assertions(1);
+			htmlvalidate = new HtmlValidate({
+				rules: { "attr-quotes": "error" },
+			});
+			const docs = htmlvalidate.getRuleDocumentation("attr-quotes");
+			expect(docs?.description).toMatchSnapshot();
+		});
+
+		it("with unquoted context", () => {
+			expect.assertions(1);
+			htmlvalidate = new HtmlValidate({
+				rules: { "attr-quotes": "error" },
+			});
+			const context: RuleUnquotedContext = {
+				error: "unquoted",
+				attr: "foo",
+			};
+			const docs = htmlvalidate.getRuleDocumentation("attr-quotes", null, context);
+			expect(docs?.description).toMatchSnapshot();
+		});
+
+		it("with style context", () => {
+			expect.assertions(1);
+			htmlvalidate = new HtmlValidate({
+				rules: { "attr-quotes": "error" },
+			});
+			const context: RuleStyleContext = {
+				error: "style",
+				attr: "foo",
+				actual: "'",
+				expected: '"',
+			};
+			const docs = htmlvalidate.getRuleDocumentation("attr-quotes", null, context);
+			expect(docs?.description).toMatchSnapshot();
+		});
+
+		it.each`
+			style       | unquoted
+			${"auto"}   | ${false}
+			${"auto"}   | ${true}
+			${"any"}    | ${false}
+			${"any"}    | ${true}
+			${"single"} | ${false}
+			${"single"} | ${true}
+			${"double"} | ${false}
+			${"double"} | ${true}
+		`('style "$style" and unquoted "$unquoted"', ({ style, unquoted }) => {
+			expect.assertions(1);
+			htmlvalidate = new HtmlValidate({
+				rules: { "attr-quotes": ["error", { style, unquoted }] },
+			});
+			const docs = htmlvalidate.getRuleDocumentation("attr-quotes");
+			expect(docs?.description).toMatchSnapshot();
 		});
-		expect(htmlvalidate.getRuleDocumentation("attr-quotes")).toMatchSnapshot();
 	});
 });
diff --git a/src/rules/attr-quotes.ts b/src/rules/attr-quotes.ts
index 5aa611b79..a63224911 100644
--- a/src/rules/attr-quotes.ts
+++ b/src/rules/attr-quotes.ts
@@ -7,10 +7,25 @@ enum QuoteStyle {
 	SINGLE_QUOTE = "'",
 	DOUBLE_QUOTE = '"',
 	AUTO_QUOTE = "auto",
+	ANY_QUOTE = "any",
 }
 
+export interface RuleStyleContext {
+	error: "style";
+	attr: string;
+	expected: string;
+	actual: string;
+}
+
+export interface RuleUnquotedContext {
+	error: "unquoted";
+	attr: string;
+}
+
+type RuleContext = RuleStyleContext | RuleUnquotedContext;
+
 interface RuleOptions {
-	style: "auto" | "single" | "double";
+	style: "auto" | "single" | "double" | "any";
 	unquoted: boolean;
 }
 
@@ -19,13 +34,51 @@ const defaults: RuleOptions = {
 	unquoted: false,
 };
 
-export default class AttrQuotes extends Rule<void, RuleOptions> {
+function describeError(context?: RuleContext): string {
+	if (context) {
+		switch (context.error) {
+			case "style":
+				return `Attribute \`${context.attr}\` must use \`${context.expected}\` instead of \`${context.actual}\`.`;
+			case "unquoted":
+				return `Attribute \`${context.attr}\` must not be unquoted.`;
+		}
+	} else {
+		return "This attribute is not quoted properly.";
+	}
+}
+
+function describeStyle(style: QuoteStyle, unquoted: boolean): string {
+	const description: string[] = [];
+	switch (style) {
+		case QuoteStyle.AUTO_QUOTE:
+			description.push(
+				"- quoted with double quotes `\"` unless the value contains double quotes in which case single quotes `'` should be used instead"
+			);
+			break;
+		case QuoteStyle.ANY_QUOTE:
+			description.push("- quoted with single quotes `'`");
+			description.push('- quoted with double quotes `"`');
+			break;
+		case QuoteStyle.SINGLE_QUOTE:
+		case QuoteStyle.DOUBLE_QUOTE: {
+			const name = style === QuoteStyle.SINGLE_QUOTE ? "single" : "double";
+			description.push(`- quoted with ${name} quotes \`${style}\``);
+			break;
+		}
+	}
+	if (unquoted) {
+		description.push("- unquoted (if applicable)");
+	}
+	return `${description.join(" or\n")}\n`;
+}
+
+export default class AttrQuotes extends Rule<RuleContext, RuleOptions> {
 	private style: QuoteStyle;
 
 	public static schema(): SchemaObject {
 		return {
 			style: {
-				enum: ["auto", "double", "single"],
+				enum: ["auto", "double", "single", "any"],
 				type: "string",
 			},
 			unquoted: {
@@ -34,18 +87,20 @@ export default class AttrQuotes extends Rule<void, RuleOptions> {
 		};
 	}
 
-	public documentation(): RuleDocumentation {
-		if (this.options.style === "auto") {
-			return {
-				description: `Attribute values are required to be quoted with doublequotes unless the attribute value itself contains doublequotes in which case singlequotes should be used.`,
-				url: ruleDocumentationUrl(__filename),
-			};
-		} else {
-			return {
-				description: `Attribute values are required to be quoted with ${this.options.style}quotes.`,
-				url: ruleDocumentationUrl(__filename),
-			};
-		}
+	public documentation(context?: RuleContext): RuleDocumentation {
+		const { style } = this;
+		const { unquoted } = this.options;
+		const description = [
+			describeError(context),
+			"",
+			"Under the current configuration attributes must be:",
+			"",
+			describeStyle(style, unquoted),
+		];
+		return {
+			description: description.join("\n"),
+			url: ruleDocumentationUrl(__filename),
+		};
 	}
 
 	public constructor(options: Partial<RuleOptions>) {
@@ -62,23 +117,42 @@ export default class AttrQuotes extends Rule<void, RuleOptions> {
 
 			if (!event.quote) {
 				if (this.options.unquoted === false) {
-					this.report(event.target, `Attribute "${event.key}" using unquoted value`);
+					const message = `Attribute "${event.key}" using unquoted value`;
+					const context: RuleUnquotedContext = {
+						error: "unquoted",
+						attr: event.key,
+					};
+					this.report(event.target, message, null, context);
 				}
 				return;
 			}
 
+			/* if the style is set to any we skip the rest of the rule as the only
+			 * thing that matters is if the "unquoted" options triggers an error or
+			 * not */
+			if (this.style === QuoteStyle.ANY_QUOTE) {
+				return;
+			}
+
 			const expected = this.resolveQuotemark(event.value.toString(), this.style);
 
 			if (event.quote !== expected) {
-				this.report(
-					event.target,
-					`Attribute "${event.key}" used ${event.quote} instead of expected ${expected}`
-				);
+				const message = `Attribute "${event.key}" used ${event.quote} instead of expected ${expected}`;
+				const context: RuleStyleContext = {
+					error: "style",
+					attr: event.key,
+					actual: event.quote,
+					expected,
+				};
+				this.report(event.target, message, null, context);
 			}
 		});
 	}
 
-	private resolveQuotemark(value: string, style: QuoteStyle): QuoteMark {
+	private resolveQuotemark(
+		value: string,
+		style: Exclude<QuoteStyle, QuoteStyle.ANY_QUOTE>
+	): QuoteMark {
 		if (style === QuoteStyle.AUTO_QUOTE) {
 			return value.includes('"') ? "'" : '"';
 		} else {
@@ -95,6 +169,8 @@ function parseStyle(style: string): QuoteStyle {
 			return QuoteStyle.DOUBLE_QUOTE;
 		case "single":
 			return QuoteStyle.SINGLE_QUOTE;
+		case "any":
+			return QuoteStyle.ANY_QUOTE;
 		/* istanbul ignore next: covered by schema validation */
 		default:
 			throw new ConfigError(`Invalid style "${style}" for "attr-quotes" rule`);
-- 
GitLab