From 5bc3cc5312a18c59d5f91476394e6ebb6db47aee Mon Sep 17 00:00:00 2001 From: Lars Munkholm Date: Wed, 18 Sep 2019 14:02:51 +0200 Subject: [PATCH] Added rule for dollarVariableFirstInBlock --- README.md | 1 + .../dollar-variable-first-in-block/README.md | 191 ++++++ .../__tests__/index.js | 602 ++++++++++++++++++ .../dollar-variable-first-in-block/index.js | 113 ++++ src/rules/index.js | 2 + 5 files changed, 909 insertions(+) create mode 100644 src/rules/dollar-variable-first-in-block/README.md create mode 100644 src/rules/dollar-variable-first-in-block/__tests__/index.js create mode 100644 src/rules/dollar-variable-first-in-block/index.js diff --git a/README.md b/README.md index 168a1bae..5f0ea0ed 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ Please also see the [example configs](./docs/examples/) for special cases. - [`dollar-variable-colon-space-before`](./src/rules/dollar-variable-colon-space-before/README.md): Require a single space or disallow whitespace before the colon in `$`-variable declarations (Autofixable). - [`dollar-variable-default`](./src/rules/dollar-variable-default/README.md): Require `!default` flag for `$`-variable declarations. - [`dollar-variable-empty-line-before`](./src/rules/dollar-variable-empty-line-before/README.md): Require a single empty line or disallow empty lines before `$`-variable declarations (Autofixable). +- [`dollar-variable-first-in-block`](./src/rules/dollar-variable-first-in-block/README.md): Require for variables to be put first in a block (a rule or in root). - [`dollar-variable-no-missing-interpolation`](./src/rules/dollar-variable-no-missing-interpolation/README.md): Disallow Sass variables that are used without interpolation with CSS features that use custom identifiers. - [`dollar-variable-pattern`](./src/rules/dollar-variable-pattern/README.md): Specify a pattern for Sass-like variables. diff --git a/src/rules/dollar-variable-first-in-block/README.md b/src/rules/dollar-variable-first-in-block/README.md new file mode 100644 index 00000000..89dfec2d --- /dev/null +++ b/src/rules/dollar-variable-first-in-block/README.md @@ -0,0 +1,191 @@ +# dollar-variable-first-in-block + +Require `$`-variable declarations to be placed first in a block (root or a rule). + +## Options + +### `true` + +The following patterns are considered violations: + +```scss +@import '1.css'; +$var: 200px; +``` + +```scss +a { + width: 100px; + $var: 1; +} +``` + +The following patterns are *not* considered warnings: + +```scss +$var: 100px; +@import '1.css'; +``` + +```scss +a { + $var: 1; + color: red; +} +``` + +## Optional secondary options + +### `ignore: ["comments", "imports"]` + +### `"comments"` + +The following patterns are *not* considered violations: + +```scss +// Comment +$var: 1; +``` + +```scss +a { + // Comment + $var: 1; + color: red; +} +``` + +### `"imports"` + +The following patterns are *not* considered violations: + +```scss +@import '1.css'; +$var: 1; +``` + +### `except: ["root", "at-rule", "function", "mixin", "if-else", "loops"]` + +### `"root"` + +The following patterns are *not* considered warnings: + +```scss +// Imports +@import '1.css'; + +// Variables +$var: 1; +``` + +```scss +/* Imports */ +@import '1.css'; +// Variables +$var1: 1; +$var2: 1; + +a { + width: 100px; +} +``` + +### `"at-rule"` + +The following patterns are *not* considered warnings: + +```scss +@at-root .class { + width: 100px; + $var: 1; +} +``` + +### `"function"` + +The following patterns are *not* considered warnings: + +```scss +@function function-name($numbers1, $numbers2) { + $var1: 1; + + @each $number in $numbers1 { + $var1: $var1 + $number; + } + + $var: 2; + + @each $number in $numbers2 { + $var2: $var2 + $number; + } + + @return $var1 + $var2; +} +``` + +### `"mixin"` + +The following patterns are *not* considered warnings: + +```scss +@mixin mixin-name { + width: 100px; + $var: 1000px; + height: $var1; +} +``` + +### `"if-else"` + +The following patterns are *not* considered warnings: + +```scss +@if $direction == up { + width: 100px; + $var: 1000px; +} +``` + +```scss +@if $direction == up { + width: 100px; +} @else { + height: 100px; + $var: 1000px; +} +``` + +```scss +@if $direction == up { + width: 100px; + $var1: 1000px; +} @else { + height: 100px; + $var2: 1000px; +} +``` + +### `"loops"` + +The following patterns are *not* considered warnings: + +```scss +@each $size in $sizes { + width: 100px; + $var: 1000px; +} +``` + +```scss +@for $i from 1 through 3 { + width: 100px; + $var: 1000px; +} +``` + +```scss +@while $value > $base { + width: 100px; + $var: 1000px; +} +``` diff --git a/src/rules/dollar-variable-first-in-block/__tests__/index.js b/src/rules/dollar-variable-first-in-block/__tests__/index.js new file mode 100644 index 00000000..b30b9b6d --- /dev/null +++ b/src/rules/dollar-variable-first-in-block/__tests__/index.js @@ -0,0 +1,602 @@ +import rule, { ruleName, messages } from ".."; + +// always +// -------------------------------------------------------------------------- + +testRule(rule, { + ruleName, + config: [true], + syntax: "scss", + + accept: [ + { + code: `a { + $var1: 100px; + }`, + description: "$var first inside a rule." + }, + { + code: `a { + $var1: 100px; + width: 100px; + height: 100px; + }`, + description: "$var first inside a rule followed by properties." + }, + { + code: `a { + $var1: 100px; + $var1: 100px; + }`, + description: "Two $var-s first inside a rule." + }, + { + code: `a { + $var1: 100px; + + $var1: 100px; + }`, + description: "Two $var-s first inside a rule, empty line between them." + }, + { + code: `a { + + $var1: 100px; + $var1: 100px; + }`, + description: "Two $var-s first inside a rule, empty line before them." + }, + { + code: "$var1: 100px;", + description: "$var in root." + }, + { + code: ` + $var1: 100px; + @import '1.css'; + `, + description: "$var first in root." + }, + { + code: `@media (min-width: 100px) { + $var: 1000px; + }`, + description: "$var first inside a media query." + }, + { + code: `@function function-name($base) { + $var: 1000px; + }`, + description: "$var first inside a `@function`." + }, + { + code: `@mixin mixin-name { + $var: 1000px; + }`, + description: "$var first inside a `@mixin`." + }, + { + code: `@at-root .class { + $var: 1000px; + }`, + description: "$var first inside an `@at-root`." + }, + { + code: `@if $direction == up { + $var: 1000px; + }`, + description: "$var first inside an `@if`." + }, + { + code: `@if $direction == up { + width: 100px; + } @else { + $var: 1000px; + }`, + description: "$var first inside an `@else`." + }, + { + code: `@each $size in $sizes { + $var: 1000px; + }`, + description: "$var first inside an `@each` rule." + }, + { + code: `@for $i from 1 through 3 { + $var: 1000px; + }`, + description: "$var first inside a `@for` rule." + }, + { + code: `@while $value > $base { + $var: 1000px; + }`, + description: "$var first inside a `@while` rule." + } + ], + + reject: [ + { + code: `a { + width: 100px; + $var1: 100px; + }`, + description: "$var inside a rule, not first.", + message: messages.expected, + line: 3 + }, + { + code: ` + a { + b { } + + $var1: 100px; + } + `, + description: "$var following nested selector.", + message: messages.expected, + line: 5 + }, + { + code: ` + a { + // Comment + $var1: 100px; + } + `, + description: "$var following a comment.", + message: messages.expected, + line: 4 + }, + { + code: `@media (min-width: 100px) { + width: 100px; + $var: 1000px; + }`, + description: "$var not first inside a media query.", + message: messages.expected, + line: 3 + }, + { + code: `@function function-name($base) { + width: 100px; + $var: 1000px; + }`, + description: "$var not first inside a `@function`.", + message: messages.expected, + line: 3 + }, + { + code: `@mixin mixin-name { + width: 100px; + $var: 1000px; + }`, + description: "$var not first inside a `@mixin`.", + message: messages.expected, + line: 3 + }, + { + code: `@at-root .class { + width: 100px; + $var: 1000px; + }`, + description: "$var not first inside an `@at-root`.", + message: messages.expected, + line: 3 + }, + { + code: `@if $direction == up { + width: 100px; + $var: 1000px; + }`, + description: "$var not first inside an `@if`.", + message: messages.expected, + line: 3 + }, + { + code: `@if $direction == up { + width: 100px; + } @else { + height: 100px; + $var: 1000px; + }`, + description: "$var not first inside an `@else`.", + message: messages.expected, + line: 5 + }, + { + code: `@each $size in $sizes { + width: 100px; + $var: 1000px; + }`, + description: "$var not first inside an `@each` rule.", + message: messages.expected, + line: 3 + }, + { + code: `@for $i from 1 through 3 { + width: 100px; + $var: 1000px; + }`, + description: "$var not first inside a `@for` rule.", + message: messages.expected, + line: 3 + }, + { + code: `@while $value > $base { + width: 100px; + $var: 1000px; + }`, + description: "$var not first inside a `@while` rule.", + message: messages.expected, + line: 3 + } + ] +}); + +testRule(rule, { + ruleName, + config: [true, { except: ["root"] }], + syntax: "scss", + + accept: [ + { + code: ` + a { } + $var1: 100px; + `, + description: "$var in root, preceded by selector." + }, + { + code: ` + a { } + $var1: 100px; + b { } + `, + description: "$var in root, preceded and followed by selectors." + } + ] +}); + +testRule(rule, { + ruleName, + config: [true, { except: ["at-rule"] }], + syntax: "scss", + + accept: [ + { + code: `@at-root .class { + width: 100px; + $var: 1000px; + }`, + description: "$var in at-rule, preceded by selector." + } + ] +}); + +testRule(rule, { + ruleName, + config: [true, { except: ["function"] }], + syntax: "scss", + + accept: [ + { + code: `@function function-name($numbers1, $numbers2) { + $var1: 1; + + @each $number in $numbers1 { + $var1: $var1 + $number; + } + + $var: 2; + + @each $number in $numbers2 { + $var2: $var2 + $number; + } + + @return $var1 + $var2; + }`, + description: "$var in function, preceded by selector." + } + ] +}); + +testRule(rule, { + ruleName, + config: [true, { except: ["mixin"] }], + syntax: "scss", + + accept: [ + { + code: `@mixin mixin-name { + width: 100px; + $var: 1000px; + height: $var1; + }`, + description: "$var in mixin, preceded by selector." + } + ] +}); + +testRule(rule, { + ruleName, + config: [true, { except: ["if-else"] }], + syntax: "scss", + + accept: [ + { + code: `@if $direction == up { + width: 100px; + $var: 1000px; + }`, + description: "$var in @if, preceded by selector." + }, + { + code: `@if $direction == up { + width: 100px; + } @else { + height: 100px; + $var: 1000px; + }`, + description: "$var in @else, preceded by selector." + }, + { + code: `@if $direction == up { + width: 100px; + $var1: 1000px; + } @else { + height: 100px; + $var2: 1000px; + }`, + description: "$var in @if and @else, both preceded by selector." + } + ] +}); + +testRule(rule, { + ruleName, + config: [true, { except: ["loops"] }], + syntax: "scss", + + accept: [ + { + code: `@each $size in $sizes { + width: 100px; + $var: 1000px; + }`, + description: "$var not first inside an `@each` rule." + }, + { + code: `@for $i from 1 through 3 { + width: 100px; + $var: 1000px; + }`, + description: "$var not first inside a `@for` rule." + }, + { + code: `@while $value > $base { + width: 100px; + $var: 1000px; + }`, + description: "$var not first inside a `@while` rule." + } + ] +}); + +testRule(rule, { + ruleName, + config: [true, { ignore: ["imports"] }], + syntax: "scss", + + accept: [ + { + code: ` + @import '1.css'; + $var1: 100px; + + a { } + `, + description: "$var in root, preceded by import, followed by selector." + }, + { + code: ` + @import '1.css'; + $var1: 100px; + `, + description: "$var in root, preceded by import." + } + ], + + reject: [ + { + code: `a { + width: 100px; + @import url("path/_file.css"); + $var1: 100px; + }`, + description: "$var inside a rule, preceded by an import and not first.", + message: messages.expected, + line: 4 + }, + { + code: ` + a { + b { } + + @import url("path/_file.css"); + $var1: 100px; + } + `, + description: "$var following import and nested selector.", + message: messages.expected, + line: 6 + } + ] +}); + +testRule(rule, { + ruleName, + config: [true, { ignore: ["comments"] }], + syntax: "scss", + + accept: [ + { + code: `a { + // Comment + $var2: 100px; + }`, + description: "$var inside a rule following a //-comment." + }, + { + code: `a { + $var1: 100px; + // Comment + $var2: 100px; + }`, + description: "Two $var-s inside a rule, //-comment between them." + }, + { + code: `a { + $var1: 100px; + // Comment + // Comment + // Comment + $var2: 100px; + }`, + description: + "Two $var-s inside a rule, multiple //-comments between them." + }, + { + code: `a { + $var1: 100px; + + /* Comment */ + $var2: 100px; + }`, + description: + "Two $var-s inside a rule, CSS-comment and space between them." + }, + { + code: `a { + $var1: 100px; + + /* Comment + Comment + Comment */ + + $var2: 100px; + }`, + description: + "Two $var-s inside a rule, empty lines and multi-line CSS-comment between them." + } + ], + + reject: [ + { + code: `a { + width: 100px; + // Comment + $var1: 100px; + }`, + description: "$var inside a rule, preceded by //-comment and not first.", + message: messages.expected, + line: 4 + }, + { + code: ` + a { + b { } + + /* Comment */ + $var1: 100px; + } + `, + description: "$var following comment and nested selector.", + message: messages.expected, + line: 6 + }, + { + code: ` + a { + $var1: 100px; + + b { } + + // Comment + $var2: 100px; + } + `, + description: + "$var preceded by comment, a nested selector and another $var.", + message: messages.expected, + line: 8 + } + ] +}); + +testRule(rule, { + ruleName, + config: [true, { ignore: ["comments", "imports"], except: ["root"] }], + syntax: "scss", + + accept: [ + { + code: `a { + @import url("path/_file.css"); + // Comment + $var2: 100px; + }`, + description: "$var inside a rule following a //-comment and an import." + }, + { + code: ` + @import url("path/_file.css"); + // Comment + $var2: 100px; + `, + description: "$var in root following a //-comment and an import." + }, + { + code: `a { + $var1: 100px; + // Comment + @import url("path/_file.css"); + /* Comment */ + $var2: 100px; + }`, + description: + "Two $var-s inside a rule, multiple comments and an import between them." + }, + { + code: `a { + @import url("path/_file1.css"); + @import url("path/_file2.css"); + $var1: 100px; + }`, + description: "$var preceded by two imports inside a rule." + }, + { + code: ` + @import url("path/_file1.css"); + @import url("path/_file2.css"); + $var1: 100px; + `, + description: "$var preceded by two imports in root." + } + ], + + reject: [ + { + code: `a { + width: 100px; + @import url("path/_file1.css"); + // Comment + $var1: 100px; + }`, + description: + "$var inside a rule, preceded by //-comment, an import and not first.", + message: messages.expected, + line: 5 + } + ] +}); diff --git a/src/rules/dollar-variable-first-in-block/index.js b/src/rules/dollar-variable-first-in-block/index.js new file mode 100644 index 00000000..d0fedf8c --- /dev/null +++ b/src/rules/dollar-variable-first-in-block/index.js @@ -0,0 +1,113 @@ +import { + namespace, + optionsHaveException, + optionsHaveIgnored +} from "../../utils"; +import { utils } from "stylelint"; + +export const ruleName = namespace("dollar-variable-first-in-block"); + +export const messages = utils.ruleMessages(ruleName, { + expected: "Expected $-variable to be first in block" +}); + +export default function(primary, options) { + return (root, result) => { + const validOptions = utils.validateOptions( + result, + ruleName, + { + actual: primary + }, + { + actual: options, + possible: { + ignore: ["comments", "imports"], + except: ["root", "at-rule", "function", "mixin", "if-else", "loops"] + }, + optional: true + } + ); + + if (!validOptions) { + return; + } + + const isDollarVar = node => node.prop && node.prop[0] === "$"; + + root.walkDecls(decl => { + // Ignore declarations that aren't variables. + // ------------------------------------------ + if (!isDollarVar(decl)) { + return; + } + + // If selected, ignore declarations in root. + // ----------------------------------------- + if (optionsHaveException(options, "root") && decl.parent === root) { + return; + } + + // If selected, ignore declarations in different types of at-rules. + // ---------------------------------------------------------------- + if (decl.parent.type === "atrule") { + if ( + optionsHaveException(options, "at-rule") || + (optionsHaveException(options, "function") && + decl.parent.name === "function") || + (optionsHaveException(options, "mixin") && + decl.parent.name === "mixin") || + (optionsHaveException(options, "if-else") && + (decl.parent.name === "if" || decl.parent.name === "else")) || + (optionsHaveException(options, "loops") && + (decl.parent.name === "each" || + decl.parent.name === "for" || + decl.parent.name === "while")) + ) { + return; + } + } + + const previous = decl.prev(); + + // If first or preceded by another variable. + // ----------------------------------------- + if (!previous || isDollarVar(previous)) { + return; + } + + // Check if preceded only by allowed types. + // ---------------------------------------- + let precededOnlyByAllowed = true; + const allowComments = optionsHaveIgnored(options, "comments"); + const allowImports = optionsHaveIgnored(options, "imports"); + + for (const sibling of decl.parent.nodes) { + if (sibling === decl) { + break; + } else if ( + !isDollarVar(sibling) && + !( + (allowComments && sibling.type === "comment") || + (allowImports && + sibling.type === "atrule" && + sibling.name === "import") + ) + ) { + precededOnlyByAllowed = false; + } + } + + if (precededOnlyByAllowed) { + return; + } + + utils.report({ + message: messages.expected, + node: decl, + result, + ruleName + }); + }); + }; +} diff --git a/src/rules/index.js b/src/rules/index.js index a24dc219..f3d828f7 100644 --- a/src/rules/index.js +++ b/src/rules/index.js @@ -29,6 +29,7 @@ import dollarVariableColonSpaceAfter from "./dollar-variable-colon-space-after"; import dollarVariableColonSpaceBefore from "./dollar-variable-colon-space-before"; import dollarVariableDefault from "./dollar-variable-default"; import dollarVariableEmptyLineBefore from "./dollar-variable-empty-line-before"; +import dollarVariableFirstInBlock from "./dollar-variable-first-in-block"; import dollarVariableNoMissingInterpolation from "./dollar-variable-no-missing-interpolation"; import dollarVariablePattern from "./dollar-variable-pattern"; import doubleSlashCommentEmptyLineBefore from "./double-slash-comment-empty-line-before"; @@ -82,6 +83,7 @@ export default { "dollar-variable-colon-space-before": dollarVariableColonSpaceBefore, "dollar-variable-default": dollarVariableDefault, "dollar-variable-empty-line-before": dollarVariableEmptyLineBefore, + "dollar-variable-first-in-block": dollarVariableFirstInBlock, "dollar-variable-no-missing-interpolation": dollarVariableNoMissingInterpolation, "dollar-variable-pattern": dollarVariablePattern, "double-slash-comment-empty-line-before": doubleSlashCommentEmptyLineBefore,