index.js 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. 'use strict';
  2. const atRuleParamIndex = require('../../utils/atRuleParamIndex');
  3. const declarationValueIndex = require('../../utils/declarationValueIndex');
  4. const isStandardSyntaxRule = require('../../utils/isStandardSyntaxRule');
  5. const parseSelector = require('../../utils/parseSelector');
  6. const report = require('../../utils/report');
  7. const ruleMessages = require('../../utils/ruleMessages');
  8. const validateOptions = require('../../utils/validateOptions');
  9. const valueParser = require('postcss-value-parser');
  10. const { isBoolean, assertString } = require('../../utils/validateTypes');
  11. const ruleName = 'string-quotes';
  12. const messages = ruleMessages(ruleName, {
  13. expected: (q) => `Expected ${q} quotes`,
  14. });
  15. const meta = {
  16. url: 'https://stylelint.io/user-guide/rules/list/string-quotes',
  17. };
  18. const singleQuote = `'`;
  19. const doubleQuote = `"`;
  20. /** @type {import('stylelint').Rule} */
  21. const rule = (primary, secondaryOptions, context) => {
  22. const correctQuote = primary === 'single' ? singleQuote : doubleQuote;
  23. const erroneousQuote = primary === 'single' ? doubleQuote : singleQuote;
  24. return (root, result) => {
  25. const validOptions = validateOptions(
  26. result,
  27. ruleName,
  28. {
  29. actual: primary,
  30. possible: ['single', 'double'],
  31. },
  32. {
  33. actual: secondaryOptions,
  34. possible: {
  35. avoidEscape: [isBoolean],
  36. },
  37. optional: true,
  38. },
  39. );
  40. if (!validOptions) {
  41. return;
  42. }
  43. const avoidEscape =
  44. secondaryOptions && secondaryOptions.avoidEscape !== undefined
  45. ? secondaryOptions.avoidEscape
  46. : true;
  47. root.walk((node) => {
  48. switch (node.type) {
  49. case 'atrule':
  50. checkDeclOrAtRule(node, node.params, atRuleParamIndex);
  51. break;
  52. case 'decl':
  53. checkDeclOrAtRule(node, node.value, declarationValueIndex);
  54. break;
  55. case 'rule':
  56. checkRule(node);
  57. break;
  58. }
  59. });
  60. /**
  61. * @param {import('postcss').Rule} ruleNode
  62. * @returns {void}
  63. */
  64. function checkRule(ruleNode) {
  65. if (!isStandardSyntaxRule(ruleNode)) {
  66. return;
  67. }
  68. if (!ruleNode.selector.includes('[') || !ruleNode.selector.includes('=')) {
  69. return;
  70. }
  71. /** @type {number[]} */
  72. const fixPositions = [];
  73. parseSelector(ruleNode.selector, result, ruleNode, (selectorTree) => {
  74. let selectorFixed = false;
  75. selectorTree.walkAttributes((attributeNode) => {
  76. if (!attributeNode.quoted) {
  77. return;
  78. }
  79. if (attributeNode.quoteMark === correctQuote && avoidEscape) {
  80. assertString(attributeNode.value);
  81. const needsCorrectEscape = attributeNode.value.includes(correctQuote);
  82. const needsOtherEscape = attributeNode.value.includes(erroneousQuote);
  83. if (needsOtherEscape) {
  84. return;
  85. }
  86. if (needsCorrectEscape) {
  87. if (context.fix) {
  88. selectorFixed = true;
  89. attributeNode.quoteMark = erroneousQuote;
  90. } else {
  91. report({
  92. message: messages.expected(primary === 'single' ? 'double' : primary),
  93. node: ruleNode,
  94. index: attributeNode.sourceIndex + attributeNode.offsetOf('value'),
  95. result,
  96. ruleName,
  97. });
  98. }
  99. }
  100. }
  101. if (attributeNode.quoteMark === erroneousQuote) {
  102. if (avoidEscape) {
  103. assertString(attributeNode.value);
  104. const needsCorrectEscape = attributeNode.value.includes(correctQuote);
  105. const needsOtherEscape = attributeNode.value.includes(erroneousQuote);
  106. if (needsOtherEscape) {
  107. if (context.fix) {
  108. selectorFixed = true;
  109. attributeNode.quoteMark = correctQuote;
  110. } else {
  111. report({
  112. message: messages.expected(primary),
  113. node: ruleNode,
  114. index: attributeNode.sourceIndex + attributeNode.offsetOf('value'),
  115. result,
  116. ruleName,
  117. });
  118. }
  119. return;
  120. }
  121. if (needsCorrectEscape) {
  122. return;
  123. }
  124. }
  125. if (context.fix) {
  126. selectorFixed = true;
  127. attributeNode.quoteMark = correctQuote;
  128. } else {
  129. report({
  130. message: messages.expected(primary),
  131. node: ruleNode,
  132. index: attributeNode.sourceIndex + attributeNode.offsetOf('value'),
  133. result,
  134. ruleName,
  135. });
  136. }
  137. }
  138. });
  139. if (selectorFixed) {
  140. ruleNode.selector = selectorTree.toString();
  141. }
  142. });
  143. for (const fixIndex of fixPositions) {
  144. ruleNode.selector = replaceQuote(ruleNode.selector, fixIndex, correctQuote);
  145. }
  146. }
  147. /**
  148. * @template {import('postcss').AtRule | import('postcss').Declaration} T
  149. * @param {T} node
  150. * @param {string} value
  151. * @param {(node: T) => number} getIndex
  152. * @returns {void}
  153. */
  154. function checkDeclOrAtRule(node, value, getIndex) {
  155. /** @type {number[]} */
  156. const fixPositions = [];
  157. // Get out quickly if there are no erroneous quotes
  158. if (!value.includes(erroneousQuote)) {
  159. return;
  160. }
  161. if (node.type === 'atrule' && node.name === 'charset') {
  162. // allow @charset rules to have double quotes, in spite of the configuration
  163. // TODO: @charset should always use double-quotes, see https://github.com/stylelint/stylelint/issues/2788
  164. return;
  165. }
  166. valueParser(value).walk((valueNode) => {
  167. if (valueNode.type === 'string' && valueNode.quote === erroneousQuote) {
  168. const needsEscape = valueNode.value.includes(correctQuote);
  169. if (avoidEscape && needsEscape) {
  170. // don't consider this an error
  171. return;
  172. }
  173. const openIndex = valueNode.sourceIndex;
  174. // we currently don't fix escapes
  175. if (context.fix && !needsEscape) {
  176. const closeIndex = openIndex + valueNode.value.length + erroneousQuote.length;
  177. fixPositions.push(openIndex, closeIndex);
  178. } else {
  179. report({
  180. message: messages.expected(primary),
  181. node,
  182. index: getIndex(node) + openIndex,
  183. result,
  184. ruleName,
  185. });
  186. }
  187. }
  188. });
  189. for (const fixIndex of fixPositions) {
  190. if (node.type === 'atrule') {
  191. node.params = replaceQuote(node.params, fixIndex, correctQuote);
  192. } else {
  193. node.value = replaceQuote(node.value, fixIndex, correctQuote);
  194. }
  195. }
  196. }
  197. };
  198. };
  199. /**
  200. * @param {string} string
  201. * @param {number} index
  202. * @param {string} replace
  203. * @returns {string}
  204. */
  205. function replaceQuote(string, index, replace) {
  206. return string.substring(0, index) + replace + string.substring(index + replace.length);
  207. }
  208. rule.ruleName = ruleName;
  209. rule.messages = messages;
  210. rule.meta = meta;
  211. module.exports = rule;