Repository: vuejs/eslint-plugin-vue
Pull request #1831 (2022-03-27 04:57:16)

Fix 1786
Adds a new rule match-component-import-name as proposed in #1786 


Patch:
diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml
new file mode 100644
index 00000000..15c7d419
--- /dev/null
+++ b/.github/workflows/CI.yml
@@ -0,0 +1,42 @@
+name: CI
+on:
+  push:
+    branches: [master]
+  pull_request:
+    branches: [master]
+
+jobs:
+  lint:
+    name: Lint
+    runs-on: ubuntu-latest
+    steps:
+    - name: Checkout
+      uses: actions/checkout@v3
+    - name: Install Node.js
+      uses: actions/setup-node@v3
+      with:
+        node-version: 16
+    - name: Install Packages
+      run: npm install --legacy-peer-deps
+    - name: Lint
+      run: npm run lint
+
+  test:
+    name: Test
+    strategy:
+      matrix:
+        node: [16, 17]
+        os: [ubuntu-latest]
+
+    runs-on: ${{ matrix.os }}
+    steps:
+    - name: Checkout
+      uses: actions/checkout@v3
+    - name: Install Node.js v${{ matrix.node }}
+      uses: actions/setup-node@v3
+      with:
+        node-version: ${{ matrix.node }}
+    - name: Install Packages
+      run: npm install --legacy-peer-deps
+    - name: Test
+      run: npm test
diff --git a/docs/rules/README.md b/docs/rules/README.md
index b4ebfefd..3d2d113d 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -317,6 +317,7 @@ For example:
 | [vue/html-comment-content-spacing](./html-comment-content-spacing.md) | enforce unified spacing in HTML comments | :wrench: |
 | [vue/html-comment-indent](./html-comment-indent.md) | enforce consistent indentation in HTML comments | :wrench: |
 | [vue/match-component-file-name](./match-component-file-name.md) | require component name property to match its file name |  |
+| [vue/match-component-import-name](./match-component-import-name.md) | require the registered component name to match the imported component name |  |
 | [vue/new-line-between-multi-line-property](./new-line-between-multi-line-property.md) | enforce new lines between multi-line properties in Vue components | :wrench: |
 | [vue/next-tick-style](./next-tick-style.md) | enforce Promise or callback style in `nextTick` | :wrench: |
 | [vue/no-bare-strings-in-template](./no-bare-strings-in-template.md) | disallow the use of bare strings in `<template>` |  |
diff --git a/docs/rules/component-tags-order.md b/docs/rules/component-tags-order.md
index 9b053ab2..4fc8d245 100644
--- a/docs/rules/component-tags-order.md
+++ b/docs/rules/component-tags-order.md
@@ -14,7 +14,7 @@ since: v6.1.0
 
 ## :book: Rule Details
 
-This rule warns about the order of the `<script>`, `<template>` & `<style>` tags.
+This rule warns about the order of the top-level tags, such as `<script>`, `<template>` & `<style>`.
 
 ## :wrench: Options
 
@@ -26,7 +26,7 @@ This rule warns about the order of the `<script>`, `<template>` & `<style>` tags
 }
 ```
 
-- `order` (`(string|string[])[]`) ... The order of top-level element names. default `[ [ "script", "template" ], "style" ]`.
+- `order` (`(string|string[])[]`) ... The order of top-level element names. default `[ [ "script", "template" ], "style" ]`. May also be CSS selectors, such as `script[setup]` and `i18n:not([locale=en])`.
 
 ### `{ "order": [ [ "script", "template" ], "style" ] }` (default)
 
@@ -113,6 +113,78 @@ This rule warns about the order of the `<script>`, `<template>` & `<style>` tags
 
 </eslint-code-block>
 
+### `{ 'order': ['template', 'script:not([setup])', 'script[setup]'] }`
+
+<eslint-code-block fix :rules="{'vue/component-tags-order': ['error', { 'order': ['template', 'script:not([setup])', 'script[setup]'] }]}">
+
+```vue
+<!-- ✓ GOOD -->
+<template>...</template>
+<script>/* ... */</script>
+<script setup>/* ... */</script>
+```
+
+</eslint-code-block>
+
+<eslint-code-block fix :rules="{'vue/component-tags-order': ['error', { 'order': ['template', 'script:not([setup])', 'script[setup]'] }]}">
+
+```vue
+<!-- ✗ BAD -->
+<template>...</template>
+<script setup>/* ... */</script>
+<script>/* ... */</script>
+```
+
+</eslint-code-block>
+
+### `{ 'order': ['template', 'style:not([scoped])', 'style[scoped]'] }`
+
+<eslint-code-block fix :rules="{'vue/component-tags-order': ['error', { 'order': ['template', 'style:not([scoped])', 'style[scoped]'] }]}">
+
+```vue
+<!-- ✓ GOOD -->
+<template>...</template>
+<style>/* ... */</style>
+<style scoped>/* ... */</style>
+```
+
+</eslint-code-block>
+
+<eslint-code-block fix :rules="{'vue/component-tags-order': ['error', { 'order': ['template', 'style:not([scoped])', 'style[scoped]'] }]}">
+
+```vue
+<!-- ✗ BAD -->
+<template>...</template>
+<style scoped>/* ... */</style>
+<style>/* ... */</style>
+```
+
+</eslint-code-block>
+
+### `{ 'order': ['template', 'i18n:not([locale=en])', 'i18n[locale=en]'] }`
+
+<eslint-code-block fix :rules="{'vue/component-tags-order': ['error', { 'order': ['template', 'i18n:not([locale=en])', 'i18n[locale=en]'] }]}">
+
+```vue
+<!-- ✓ GOOD -->
+<template>...</template>
+<i18n locale="ja">/* ... */</i18n>
+<i18n locale="en">/* ... */</i18n>
+```
+
+</eslint-code-block>
+
+<eslint-code-block fix :rules="{'vue/component-tags-order': ['error', { 'order': ['template', 'i18n:not([locale=en])', 'i18n[locale=en]'] }]}">
+
+```vue
+<!-- ✗ BAD -->
+<template>...</template>
+<i18n locale="en">/* ... */</i18n>
+<i18n locale="ja">/* ... */</i18n>
+```
+
+</eslint-code-block>
+
 ## :books: Further Reading
 
 - [Style guide - Single-file component top-level element order](https://vuejs.org/style-guide/rules-recommended.html#single-file-component-top-level-element-order)
diff --git a/docs/rules/match-component-import-name.md b/docs/rules/match-component-import-name.md
new file mode 100644
index 00000000..083f6b32
--- /dev/null
+++ b/docs/rules/match-component-import-name.md
@@ -0,0 +1,45 @@
+---
+pageClass: rule-details
+sidebarDepth: 0
+title: vue/match-component-import-name
+description: require the registered component name to match the imported component name
+---
+# vue/match-component-import-name
+
+> require the registered component name to match the imported component name
+
+- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
+
+## :book: Rule Details
+
+By default, this rule will validate that the imported name matches the name of the components object property identifer. Note that "matches" means that the imported name matches either the PascalCase or kebab-case version of the components object property identifer. If you would like to enforce that it must match only one of PascalCase or kebab-case, use this rule in conjunction with the rule [vue/component-definition-name-casing](./component-definition-name-casing.md).
+
+<eslint-code-block :rules="{'vue/match-component-file-name': ['error']}">
+
+```vue
+<script>
+export default {
+  components: {
+    /* ✓ GOOD */
+    AppButton,
+    AppButton: AppButton,
+    'app-button': AppButton,
+
+    /* ✗ BAD */
+    SomeOtherName: AppButton,
+    'some-other-name': AppButton
+  }
+}
+</script>
+```
+
+</eslint-code-block>
+
+## :couple: Related Rules
+
+- [vue/component-definition-name-casing](./component-definition-name-casing.md)
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/match-component-import-name.js)
+- [Test source](https://github.com/vuejs/eslint-plugin-vue/blob/master/tests/lib/rules/match-component-import-name.js)
diff --git a/docs/rules/no-restricted-html-elements.md b/docs/rules/no-restricted-html-elements.md
index 780708d6..15a62447 100644
--- a/docs/rules/no-restricted-html-elements.md
+++ b/docs/rules/no-restricted-html-elements.md
@@ -3,13 +3,12 @@ pageClass: rule-details
 sidebarDepth: 0
 title: vue/no-restricted-html-elements
 description: disallow specific HTML elements
+since: v8.6.0
 ---
 # vue/no-restricted-html-elements
 
 > disallow specific HTML elements
 
-- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
-
 ## :book: Rule Details
 
 This rule allows you to specify HTML elements that you don't want to use in your application.
@@ -90,6 +89,10 @@ The following properties can be specified for the object.
 
 </eslint-code-block>
 
+## :rocket: Version
+
+This rule was introduced in eslint-plugin-vue v8.6.0
+
 ## :mag: Implementation
 
 - [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/no-restricted-html-elements.js)
diff --git a/docs/rules/prefer-prop-type-boolean-first.md b/docs/rules/prefer-prop-type-boolean-first.md
index 3bf2edb4..1b652faf 100644
--- a/docs/rules/prefer-prop-type-boolean-first.md
+++ b/docs/rules/prefer-prop-type-boolean-first.md
@@ -3,12 +3,12 @@ pageClass: rule-details
 sidebarDepth: 0
 title: vue/prefer-prop-type-boolean-first
 description: enforce `Boolean` comes first in component prop types
+since: v8.6.0
 ---
 # vue/prefer-prop-type-boolean-first
 
 > enforce `Boolean` comes first in component prop types
 
-- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
 - :bulb: Some problems reported by this rule are manually fixable by editor [suggestions](https://eslint.org/docs/developer-guide/working-with-rules#providing-suggestions).
 
 ## :book: Rule Details
@@ -54,6 +54,10 @@ export default {
 
 - [vue/prefer-true-attribute-shorthand](./prefer-true-attribute-shorthand.md)
 
+## :rocket: Version
+
+This rule was introduced in eslint-plugin-vue v8.6.0
+
 ## :mag: Implementation
 
 - [Rule source](https://github.com/vuejs/eslint-plugin-vue/blob/master/lib/rules/prefer-prop-type-boolean-first.js)
diff --git a/lib/index.js b/lib/index.js
index 22fe9a18..d412a6bf 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -47,6 +47,7 @@ module.exports = {
     'key-spacing': require('./rules/key-spacing'),
     'keyword-spacing': require('./rules/keyword-spacing'),
     'match-component-file-name': require('./rules/match-component-file-name'),
+    'match-component-import-name': require('./rules/match-component-import-name'),
     'max-attributes-per-line': require('./rules/max-attributes-per-line'),
     'max-len': require('./rules/max-len'),
     'multi-word-component-names': require('./rules/multi-word-component-names'),
diff --git a/lib/rules/attribute-hyphenation.js b/lib/rules/attribute-hyphenation.js
index ec5a3a9a..f9126a54 100644
--- a/lib/rules/attribute-hyphenation.js
+++ b/lib/rules/attribute-hyphenation.js
@@ -106,7 +106,11 @@ module.exports = {
 
     return utils.defineTemplateBodyVisitor(context, {
       VAttribute(node) {
-        if (!utils.isCustomComponent(node.parent.parent)) return
+        if (
+          !utils.isCustomComponent(node.parent.parent) &&
+          node.parent.parent.name !== 'slot'
+        )
+          return
 
         const name = !node.directive
           ? node.key.rawName
diff --git a/lib/rules/component-tags-order.js b/lib/rules/component-tags-order.js
index afb624f9..dfd8482b 100644
--- a/lib/rules/component-tags-order.js
+++ b/lib/rules/component-tags-order.js
@@ -9,6 +9,15 @@
 // ------------------------------------------------------------------------------
 
 const utils = require('../utils')
+const { parseSelector } = require('../utils/selector')
+
+/**
+ * @typedef {import('../utils/selector').VElementSelector} VElementSelector
+ */
+
+// ------------------------------------------------------------------------------
+// Helpers
+// ------------------------------------------------------------------------------
 
 const DEFAULT_ORDER = Object.freeze([['script', 'template'], 'style'])
 
@@ -46,7 +55,7 @@ module.exports = {
     ],
     messages: {
       unexpected:
-        'The <{{name}}> should be above the <{{firstUnorderedName}}> on line {{line}}.'
+        "'<{{elementName}}{{elementAttributes}}>' should be above '<{{firstUnorderedName}}{{firstUnorderedAttributes}}>' on line {{line}}."
     }
   },
   /**
@@ -54,27 +63,60 @@ module.exports = {
    * @returns {RuleListener} AST event handlers.
    */
   create(context) {
-    /** @type {Map<string, number>} */
-    const orderMap = new Map()
+    /**
+     * @typedef {object} OrderElement
+     * @property {string} selectorText
+     * @property {VElementSelector} selector
+     * @property {number} index
+     */
+    /** @type {OrderElement[]} */
+    const orders = []
     /** @type {(string|string[])[]} */
     const orderOptions =
       (context.options[0] && context.options[0].order) || DEFAULT_ORDER
-    orderOptions.forEach((nameOrNames, index) => {
-      if (Array.isArray(nameOrNames)) {
-        for (const name of nameOrNames) {
-          orderMap.set(name, index)
+    orderOptions.forEach((selectorOrSelectors, index) => {
+      if (Array.isArray(selectorOrSelectors)) {
+        for (const selector of selectorOrSelectors) {
+          orders.push({
+            selectorText: selector,
+            selector: parseSelector(selector, context),
+            index
+          })
         }
       } else {
-        orderMap.set(nameOrNames, index)
+        orders.push({
+          selectorText: selectorOrSelectors,
+          selector: parseSelector(selectorOrSelectors, context),
+          index
+        })
       }
     })
 
     /**
-     * @param {string} name
+     * @param {VElement} element
+     * @return {string}
+     */
+    function getAttributeString(element) {
+      return element.startTag.attributes
+        .map((attribute) => {
+          if (attribute.value && attribute.value.type !== 'VLiteral') {
+            return ''
+          }
+
+          return `${attribute.key.name}${
+            attribute.value && attribute.value.value
+              ? `=${attribute.value.value}`
+              : ''
+          }`
+        })
+        .join(' ')
+    }
+
+    /**
+     * @param {VElement} element
      */
-    function getOrderPosition(name) {
-      const num = orderMap.get(name)
-      return num == null ? -1 : num
+    function getOrderElement(element) {
+      return orders.find((o) => o.selector.test(element))
     }
     const documentFragment =
       context.parserServices.getDocumentFragment &&
@@ -93,32 +135,42 @@ module.exports = {
           return
         }
         const elements = getTopLevelHTMLElements()
+
+        const elementWithOrders = elements.flatMap((element) => {
+          const order = getOrderElement(element)
+          return order ? [{ order, element }] : []
+        })
         const sourceCode = context.getSourceCode()
-        elements.forEach((element, index) => {
-          const expectedIndex = getOrderPosition(element.name)
-          if (expectedIndex < 0) {
-            return
-          }
-          const firstUnordered = elements
+        elementWithOrders.forEach(({ order: expected, element }, index) => {
+          const firstUnordered = elementWithOrders
             .slice(0, index)
-            .filter((e) => expectedIndex < getOrderPosition(e.name))
-            .sort(
-              (e1, e2) => getOrderPosition(e1.name) - getOrderPosition(e2.name)
-            )[0]
+            .filter(({ order }) => expected.index < order.index)
+            .sort((e1, e2) => e1.order.index - e2.order.index)[0]
           if (firstUnordered) {
+            const firstUnorderedAttributes = getAttributeString(
+              firstUnordered.element
+            )
+            const elementAttributes = getAttributeString(element)
+
             context.report({
               node: element,
               loc: element.loc,
               messageId: 'unexpected',
               data: {
-                name: element.name,
-                firstUnorderedName: firstUnordered.name,
-                line: firstUnordered.loc.start.line
+                elementName: element.name,
+                elementAttributes: elementAttributes
+                  ? ` ${elementAttributes}`
+                  : '',
+                firstUnorderedName: firstUnordered.element.name,
+                firstUnorderedAttributes: firstUnorderedAttributes
+                  ? ` ${firstUnorderedAttributes}`
+                  : '',
+                line: firstUnordered.element.loc.start.line
               },
               *fix(fixer) {
                 // insert element before firstUnordered
                 const fixedElements = elements.flatMap((it) => {
-                  if (it === firstUnordered) {
+                  if (it === firstUnordered.element) {
                     return [element, it]
                   } else if (it === element) {
                     return []
diff --git a/lib/rules/match-component-import-name.js b/lib/rules/match-component-import-name.js
new file mode 100644
index 00000000..5babc8dc
--- /dev/null
+++ b/lib/rules/match-component-import-name.js
@@ -0,0 +1,79 @@
+/**
+ * @author Doug Wade <douglas.b.wade@gmail.com>
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const utils = require('../utils')
+const casing = require('../utils/casing')
+
+module.exports = {
+  meta: {
+    type: 'problem',
+    schema: [],
+    docs: {
+      description:
+        'require the registered component name to match the imported component name',
+      categories: undefined,
+      url: 'https://eslint.vuejs.org/rules/match-component-import-name.html'
+    },
+    fixable: null,
+    messages: {
+      unexpected:
+        'Component alias {{importedName}} should be one of: {{expectedName}}.'
+    }
+  },
+  /**
+   * @param {RuleContext} context
+   * @returns {RuleListener}
+   */
+  create(context) {
+    /**
+     * @param {Identifier} identifier
+     * @return {Array<String>}
+     */
+    function getExpectedNames(identifier) {
+      return [
+        casing.pascalCase(identifier.name),
+        casing.kebabCase(identifier.name)
+      ]
+    }
+
+    return utils.executeOnVueComponent(context, (obj) => {
+      const components = utils.findProperty(obj, 'components')
+      if (
+        !components ||
+        !components.value ||
+        components.value.type !== 'ObjectExpression'
+      ) {
+        return
+      }
+
+      components.value.properties.forEach(
+        /** @param {Property | SpreadElement} property */
+        (property) => {
+          if (
+            property.type === 'SpreadElement' ||
+            property.value.type !== 'Identifier' ||
+            property.computed === true
+          ) {
+            return
+          }
+
+          const importedName = utils.getStaticPropertyName(property) || ''
+          const expectedNames = getExpectedNames(property.value)
+          if (!expectedNames.includes(importedName)) {
+            context.report({
+              node: property,
+              messageId: 'unexpected',
+              data: {
+                importedName,
+                expectedName: expectedNames.join(', ')
+              }
+            })
+          }
+        }
+      )
+    })
+  }
+}
diff --git a/lib/rules/no-undef-properties.js b/lib/rules/no-undef-properties.js
index 4246f771..c1213aa9 100644
--- a/lib/rules/no-undef-properties.js
+++ b/lib/rules/no-undef-properties.js
@@ -268,7 +268,6 @@ module.exports = {
 
     const scriptVisitor = utils.compositingVisitors(
       {
-        /** @param {Program} node */
         Program() {
           if (!utils.isScriptSetup(context)) {
             return
diff --git a/lib/rules/no-unregistered-components.js b/lib/rules/no-unregistered-components.js
index 4ba755dd..b4651c38 100644
--- a/lib/rules/no-unregistered-components.js
+++ b/lib/rules/no-unregistered-components.js
@@ -124,7 +124,6 @@ module.exports = {
           if (utils.isHtmlWellKnownElementName(value)) return
           usedComponentNodes.push({ node, name: value })
         },
-        /** @param {VElement} node */
         "VElement[name='template'][parent.type='VDocumentFragment']:exit"() {
           // All registered components, transformed to kebab-case
           const registeredComponentNames = registeredComponents.map(
diff --git a/lib/utils/selector.js b/lib/utils/selector.js
new file mode 100644
index 00000000..6c90e9ca
--- /dev/null
+++ b/lib/utils/selector.js
@@ -0,0 +1,605 @@
+'use strict'
+
+const parser = require('postcss-selector-parser')
+const { default: nthCheck } = require('nth-check')
+const { getAttribute, isVElement } = require('.')
+
+/**
+ * @typedef {object} VElementSelector
+ * @property {(element: VElement)=>boolean} test
+ */
+
+module.exports = {
+  parseSelector
+}
+
+/**
+ * Parses CSS selectors and returns an object with a function that tests VElement.
+ * @param {string} selector CSS selector
+ * @param {RuleContext} context - The rule context.
+ * @returns {VElementSelector}
+ */
+function parseSelector(selector, context) {
+  let astSelector
+  try {
+    astSelector = parser().astSync(selector)
+  } catch (e) {
+    context.report({
+      loc: { line: 0, column: 0 },
+      message: `Cannot parse selector: ${selector}.`
+    })
+    return {
+      test: () => false
+    }
+  }
+
+  try {
+    const test = selectorsToVElementMatcher(astSelector.nodes)
+
+    return {
+      test(element) {
+        return test(element, null)
+      }
+    }
+  } catch (e) {
+    if (e instanceof SelectorError) {
+      context.report({
+        loc: { line: 0, column: 0 },
+        message: e.message
+      })
+      return {
+        test: () => false
+      }
+    }
+    throw e
+  }
+}
+
+class SelectorError extends Error {}
+
+/**
+ * @typedef {(element: VElement, subject: VElement | null )=>boolean} VElementMatcher
+ * @typedef {Exclude<parser.Selector['nodes'][number], {type:'comment'|'root'}>} ChildNode
+ */
+
+/**
+ * Convert nodes to VElementMatcher
+ * @param {parser.Selector[]} selectorNodes
+ * @returns {VElementMatcher}
+ */
+function selectorsToVElementMatcher(selectorNodes) {
+  const selectors = selectorNodes.map((n) =>
+    selectorToVElementMatcher(cleanSelectorChildren(n))
+  )
+  return (element, subject) => selectors.some((sel) => sel(element, subject))
+}
+
+/**
+ * Clean and get the selector child nodes.
+ * @param {parser.Selector} selector
+ * @returns {ChildNode[]}
+ */
+function cleanSelectorChildren(selector) {
+  /** @type {ChildNode[]} */
+  const nodes = []
+  /** @type {ChildNode|null} */
+  let last = null
+  for (const node of selector.nodes) {
+    if (node.type === 'root') {
+      throw new SelectorError('Unexpected state type=root')
+    }
+    if (node.type === 'comment') {
+      continue
+    }
+    if (
+      (last == null || last.type === 'combinator') &&
+      isDescendantCombinator(node)
+    ) {
+      // Ignore descendant combinator
+      continue
+    }
+    if (isDescendantCombinator(last) && node.type === 'combinator') {
+      // Replace combinator
+      nodes.pop()
+    }
+    nodes.push(node)
+    last = node
+  }
+  if (isDescendantCombinator(last)) {
+    nodes.pop()
+  }
+  return nodes
+
+  /**
+   * @param {parser.Node|null} node
+   * @returns {node is parser.Combinator}
+   */
+  function isDescendantCombinator(node) {
+    return Boolean(node && node.type === 'combinator' && !node.value.trim())
+  }
+}
+/**
+ * Convert Selector child nodes to VElementMatcher
+ * @param {ChildNode[]} selectorChildren
+ * @returns {VElementMatcher}
+ */
+function selectorToVElementMatcher(selectorChildren) {
+  const nodes = [...selectorChildren]
+  let node = nodes.shift()
+  /**
+   * @type {VElementMatcher | null}
+   */
+  let result = null
+  while (node) {
+    if (node.type === 'combinator') {
+      const combinator = node.value
+      node = nodes.shift()
+      if (!node) {
+        throw new SelectorError(`Expected selector after '${combinator}'.`)
+      }
+      if (node.type === 'combinator') {
+        throw new SelectorError(`Unexpected combinator '${node.value}'.`)
+      }
+      const right = nodeToVElementMatcher(node)
+      result = combination(
+        result ||
+          // for :has()
+          ((element, subject) => element === subject),
+        combinator,
+        right
+      )
+    } else {
+      const sel = nodeToVElementMatcher(node)
+      result = result ? compound(result, sel) : sel
+    }
+    node = nodes.shift()
+  }
+  if (!result) {
+    throw new SelectorError(`Unexpected empty selector.`)
+  }
+  return result
+}
+
+/**
+ * @param {VElementMatcher} left
+ * @param {string} combinator
+ * @param {VElementMatcher} right
+ * @returns {VElementMatcher}
+ */
+function combination(left, combinator, right) {
+  switch (combinator.trim()) {
+    case '':
+      // descendant
+      return (element, subject) => {
+        if (right(element, null)) {
+          let parent = element.parent
+          while (parent.type === 'VElement') {
+            if (left(parent, subject)) {
+              return true
+            }
+            parent = parent.parent
+          }
+        }
+        return false
+      }
+    case '>':
+      // child
+      return (element, subject) => {
+        if (right(element, null)) {
+          const parent = element.parent
+          if (parent.type === 'VElement') {
+            return left(parent, subject)
+          }
+        }
+        return false
+      }
+    case '+':
+      // adjacent
+      return (element, subject) => {
+        if (right(element, null)) {
+          const before = getBeforeElement(element)
+          if (before) {
+            return left(before, subject)
+          }
+        }
+        return false
+      }
+    case '~':
+      // sibling
+      return (element, subject) => {
+        if (right(element, null)) {
+          for (const before of getBeforeElements(element)) {
+            if (left(before, subject)) {
+              return true
+            }
+          }
+        }
+        return false
+      }
+    default:
+      throw new SelectorError(`Unknown combinator: ${combinator}.`)
+  }
+}
+
+/**
+ * Convert node to VElementMatcher
+ * @param {Exclude<parser.Node, {type:'combinator'|'comment'|'root'|'selector'}>} selector
+ * @returns {VElementMatcher}
+ */
+function nodeToVElementMatcher(selector) {
+  switch (selector.type) {
+    case 'attribute':
+      return attributeNodeToVElementMatcher(selector)
+    case 'class':
+      return classNameNodeToVElementMatcher(selector)
+    case 'id':
+      return identifierNodeToVElementMatcher(selector)
+    case 'tag':
+      return tagNodeToVElementMatcher(selector)
+    case 'universal':
+      return universalNodeToVElementMatcher(selector)
+    case 'pseudo':
+      return pseudoNodeToVElementMatcher(selector)
+    case 'nesting':
+      throw new SelectorError('Unsupported nesting selector.')
+    case 'string':
+      throw new SelectorError(`Unknown selector: ${selector.value}.`)
+    default:
+      throw new SelectorError(
+        `Unknown selector: ${/** @type {any}*/ (selector).value}.`
+      )
+  }
+}
+
+/**
+ * Convert Attribute node to VElementMatcher
+ * @param {parser.Attribute} selector
+ * @returns {VElementMatcher}
+ */
+function attributeNodeToVElementMatcher(selector) {
+  const key = selector.attribute
+  if (!selector.operator) {
+    return (element) => getAttributeValue(element, key) != null
+  }
+  const value = selector.value || ''
+
+  switch (selector.operator) {
+    case '=':
+      return buildVElementMatcher(value, (attr, val) => attr === val)
+    case '~=':
+      // words
+      return buildVElementMatcher(value, (attr, val) =>
+        attr.split(/\s+/gu).includes(val)
+      )
+    case '|=':
+      // immediately followed by hyphen
+      return buildVElementMatcher(
+        value,
+        (attr, val) => attr === val || attr.startsWith(`${val}-`)
+      )
+    case '^=':
+      // prefixed
+      return buildVElementMatcher(value, (attr, val) => attr.startsWith(val))
+    case '$=':
+      // suffixed
+      return buildVElementMatcher(value, (attr, val) => attr.endsWith(val))
+    case '*=':
+      // contains
+      return buildVElementMatcher(value, (attr, val) => attr.includes(val))
+    default:
+      throw new SelectorError(`Unsupported operator: ${selector.operator}.`)
+  }
+
+  /**
+   * @param {string} selectorValue
+   * @param {(attrValue:string, selectorValue: string)=>boolean} test
+   * @returns {VElementMatcher}
+   */
+  function buildVElementMatcher(selectorValue, test) {
+    const val = selector.insensitive
+      ? selectorValue.toLowerCase()
+      : selectorValue
+    return (element) => {
+      const attrValue = getAttributeValue(element, key)
+      if (attrValue == null) {
+        return false
+      }
+      return test(
+        selector.insensitive ? attrValue.toLowerCase() : attrValue,
+        val
+      )
+    }
+  }
+}
+
+/**
+ * Convert ClassName node to VElementMatcher
+ * @param {parser.ClassName} selector
+ * @returns {VElementMatcher}
+ */
+function classNameNodeToVElementMatcher(selector) {
+  const className = selector.value
+  return (element) => {
+    const attrValue = getAttributeValue(element, 'class')
+    if (attrValue == null) {
+      return false
+    }
+    return attrValue.split(/\s+/gu).includes(className)
+  }
+}
+
+/**
+ * Convert Identifier node to VElementMatcher
+ * @param {parser.Identifier} selector
+ * @returns {VElementMatcher}
+ */
+function identifierNodeToVElementMatcher(selector) {
+  const id = selector.value
+  return (element) => {
+    const attrValue = getAttributeValue(element, 'id')
+    if (attrValue == null) {
+      return false
+    }
+    return attrValue === id
+  }
+}
+
+/**
+ * Convert Tag node to VElementMatcher
+ * @param {parser.Tag} selector
+ * @returns {VElementMatcher}
+ */
+function tagNodeToVElementMatcher(selector) {
+  const name = selector.value
+  return (element) => element.rawName === name
+}
+
+/**
+ * Convert Universal node to VElementMatcher
+ * @param {parser.Universal} _selector
+ * @returns {VElementMatcher}
+ */
+function universalNodeToVElementMatcher(_selector) {
+  return () => true
+}
+/**
+ * Convert Pseudo node to VElementMatcher
+ * @param {parser.Pseudo} selector
+ * @returns {VElementMatcher}
+ */
+function pseudoNodeToVElementMatcher(selector) {
+  const pseudo = selector.value
+  switch (pseudo) {
+    case ':not': {
+      // https://developer.mozilla.org/en-US/docs/Web/CSS/:not
+      const selectors = selectorsToVElementMatcher(selector.nodes)
+      return (element, subject) => {
+        return !selectors(element, subject)
+      }
+    }
+    case ':is':
+    case ':where':
+      // https://developer.mozilla.org/en-US/docs/Web/CSS/:is
+      // https://developer.mozilla.org/en-US/docs/Web/CSS/:where
+      return selectorsToVElementMatcher(selector.nodes)
+    case ':has':
+      // https://developer.mozilla.org/en-US/docs/Web/CSS/:has
+      return pseudoHasSelectorsToVElementMatcher(selector.nodes)
+    case ':empty':
+      // https://developer.mozilla.org/en-US/docs/Web/CSS/:empty
+      return (element) =>
+        element.children.every(
+          (child) => child.type === 'VText' && !child.value.trim()
+        )
+    case ':nth-child': {
+      // https://developer.mozilla.org/en-US/docs/Web/CSS/:nth-child
+      const nth = parseNth(selector)
+      return buildPseudoNthVElementMatcher(nth)
+    }
+    case ':nth-last-child': {
+      // https://developer.mozilla.org/en-US/docs/Web/CSS/:nth-last-child
+      const nth = parseNth(selector)
+      return buildPseudoNthVElementMatcher((index, length) =>
+        nth(length - index - 1)
+      )
+    }
+    case ':first-child':
+      // https://developer.mozilla.org/en-US/docs/Web/CSS/:first-child
+      return buildPseudoNthVElementMatcher((index) => index === 0)
+    case ':last-child':
+      // https://developer.mozilla.org/en-US/docs/Web/CSS/:last-child
+      return buildPseudoNthVElementMatcher(
+        (index, length) => index === length - 1
+      )
+    case ':only-child':
+      // https://developer.mozilla.org/en-US/docs/Web/CSS/:only-child
+      return buildPseudoNthVElementMatcher(
+        (index, length) => index === 0 && length === 1
+      )
+    case ':nth-of-type': {
+      // https://developer.mozilla.org/en-US/docs/Web/CSS/:nth-of-type
+      const nth = parseNth(selector)
+      return buildPseudoNthOfTypeVElementMatcher(nth)
+    }
+    case ':nth-last-of-type': {
+      // https://developer.mozilla.org/en-US/docs/Web/CSS/:nth-last-of-type
+      const nth = parseNth(selector)
+      return buildPseudoNthOfTypeVElementMatcher((index, length) =>
+        nth(length - index - 1)
+      )
+    }
+    case ':first-of-type':
+      // https://developer.mozilla.org/en-US/docs/Web/CSS/:first-of-type
+      return buildPseudoNthOfTypeVElementMatcher((index) => index === 0)
+    case ':last-of-type':
+      // https://developer.mozilla.org/en-US/docs/Web/CSS/:last-of-type
+      return buildPseudoNthOfTypeVElementMatcher(
+        (index, length) => index === length - 1
+      )
+    case ':only-of-type':
+      // https://developer.mozilla.org/en-US/docs/Web/CSS/:only-of-type
+      return buildPseudoNthOfTypeVElementMatcher(
+        (index, length) => index === 0 && length === 1
+      )
+    default:
+      throw new SelectorError(`Unsupported pseudo selector: ${pseudo}.`)
+  }
+}
+
+/**
+ * Convert :has() selector nodes to VElementMatcher
+ * @param {parser.Selector[]} selectorNodes
+ * @returns {VElementMatcher}
+ */
+function pseudoHasSelectorsToVElementMatcher(selectorNodes) {
+  const selectors = selectorNodes.map((n) =>
+    pseudoHasSelectorToVElementMatcher(n)
+  )
+  return (element, subject) => selectors.some((sel) => sel(element, subject))
+}
+/**
+ * Convert :has() selector node to VElementMatcher
+ * @param {parser.Selector} selector
+ * @returns {VElementMatcher}
+ */
+function pseudoHasSelectorToVElementMatcher(selector) {
+  const nodes = cleanSelectorChildren(selector)
+  const selectors = selectorToVElementMatcher(nodes)
+  const firstNode = nodes[0]
+  if (
+    firstNode.type === 'combinator' &&
+    (firstNode.value === '+' || firstNode.value === '~')
+  ) {
+    // adjacent or sibling
+    return buildVElementMatcher(selectors, (element) =>
+      getAfterElements(element)
+    )
+  }
+  // descendant or child
+  return buildVElementMatcher(selectors, (element) =>
+    element.children.filter(isVElement)
+  )
+
+  /**
+   * @param {VElementMatcher} selectors
+   * @param {(element: VElement) => VElement[]} getStartElements
+   * @returns {VElementMatcher}
+   */
+  function buildVElementMatcher(selectors, getStartElements) {
+    return (element) => {
+      const elements = [...getStartElements(element)]
+      /** @type {VElement|undefined} */
+      let curr
+      while ((curr = elements.shift())) {
+        const el = curr
+        if (selectors(el, element)) {
+          return true
+        }
+        elements.push(...el.children.filter(isVElement))
+      }
+      return false
+    }
+  }
+}
+
+/**
+ * Parse <nth>
+ * @param {parser.Pseudo} pseudoNode
+ * @returns {(index: number)=>boolean}
+ */
+function parseNth(pseudoNode) {
+  const argumentsText = pseudoNode
+    .toString()
+    .slice(pseudoNode.value.length)
+    .toLowerCase()
+  const openParenIndex = argumentsText.indexOf('(')
+  const closeParenIndex = argumentsText.lastIndexOf(')')
+  if (openParenIndex < 0 || closeParenIndex < 0) {
+    throw new SelectorError(
+      `Cannot parse An+B micro syntax (:nth-xxx() argument): ${argumentsText}.`
+    )
+  }
+
+  const argument = argumentsText
+    .slice(openParenIndex + 1, closeParenIndex)
+    .trim()
+  try {
+    return nthCheck(argument)
+  } catch (e) {
+    throw new SelectorError(
+      `Cannot parse An+B micro syntax (:nth-xxx() argument): '${argument}'.`
+    )
+  }
+}
+
+/**
+ * Build VElementMatcher for :nth-xxx()
+ * @param {(index: number, length: number)=>boolean} testIndex
+ * @returns {VElementMatcher}
+ */
+function buildPseudoNthVElementMatcher(testIndex) {
+  return (element) => {
+    const elements = element.parent.children.filter(isVElement)
+    return testIndex(elements.indexOf(element), elements.length)
+  }
+}
+
+/**
+ * Build VElementMatcher for :nth-xxx-of-type()
+ * @param {(index: number, length: number)=>boolean} testIndex
+ * @returns {VElementMatcher}
+ */
+function buildPseudoNthOfTypeVElementMatcher(testIndex) {
+  return (element) => {
+    const elements = element.parent.children
+      .filter(isVElement)
+      .filter((e) => e.rawName === element.rawName)
+    return testIndex(elements.indexOf(element), elements.length)
+  }
+}
+
+/**
+ * @param {VElement} element
+ */
+function getBeforeElement(element) {
+  return getBeforeElements(element).pop() || null
+}
+/**
+ * @param {VElement} element
+ */
+function getBeforeElements(element) {
+  const parent = element.parent
+  const index = parent.children.indexOf(element)
+  return parent.children.slice(0, index).filter(isVElement)
+}
+
+/**
+ * @param {VElement} element
+ */
+function getAfterElements(element) {
+  const parent = element.parent
+  const index = parent.children.indexOf(element)
+  return parent.children.slice(index + 1).filter(isVElement)
+}
+
+/**
+ * @param {VElementMatcher} a
+ * @param {VElementMatcher} b
+ * @returns {VElementMatcher}
+ */
+function compound(a, b) {
+  return (element, subject) => a(element, subject) && b(element, subject)
+}
+
+/**
+ * Get attribute value from given element.
+ * @param {VElement} element The element node.
+ * @param {string} attribute The attribute name.
+ */
+function getAttributeValue(element, attribute) {
+  const attr = getAttribute(element, attribute)
+  if (attr) {
+    return (attr.value && attr.value.value) || ''
+  }
+  return null
+}
diff --git a/package.json b/package.json
index 4b843c06..9ddbb72a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "eslint-plugin-vue",
-  "version": "8.5.0",
+  "version": "8.6.0",
   "description": "Official ESLint plugin for Vue.js",
   "main": "lib/index.js",
   "scripts": {
@@ -56,6 +56,8 @@
   "dependencies": {
     "eslint-utils": "^3.0.0",
     "natural-compare": "^1.4.0",
+    "nth-check": "^2.0.1",
+    "postcss-selector-parser": "^6.0.9",
     "semver": "^7.3.5",
     "vue-eslint-parser": "^8.0.1"
   },


Test patch:
diff --git a/tests/fixtures/utils/selector/attr-contains/result.json b/tests/fixtures/utils/selector/attr-contains/result.json
new file mode 100644
index 00000000..a2627437
--- /dev/null
+++ b/tests/fixtures/utils/selector/attr-contains/result.json
@@ -0,0 +1,9 @@
+{
+    "selector": "a[href*=\"example\"]",
+    "matches": [
+        "<a href=\"http://example.com\">",
+        "<a href=\"http://example.org\">",
+        "<a href=\"https://example.org\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/attr-contains/source.vue b/tests/fixtures/utils/selector/attr-contains/source.vue
new file mode 100644
index 00000000..6c9a89dd
--- /dev/null
+++ b/tests/fixtures/utils/selector/attr-contains/source.vue
@@ -0,0 +1,10 @@
+<!-- a[href*="example"] -->
+<template>
+  <ul>
+    <li><a href="#internal">Internal link</a></li>
+    <li><a href="http://example.com">Example link</a></li>
+    <li><a href="#InSensitive">Insensitive internal link</a></li>
+    <li><a href="http://example.org">Example org link</a></li>
+    <li><a href="https://example.org">Example https org link</a></li>
+  </ul>
+</template>
diff --git a/tests/fixtures/utils/selector/attr-empty-value/result.json b/tests/fixtures/utils/selector/attr-empty-value/result.json
new file mode 100644
index 00000000..1f304292
--- /dev/null
+++ b/tests/fixtures/utils/selector/attr-empty-value/result.json
@@ -0,0 +1,8 @@
+{
+    "selector": "[foo=]",
+    "matches": [
+        "<p foo>",
+        "<div foo=\"\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/attr-empty-value/source.vue b/tests/fixtures/utils/selector/attr-empty-value/source.vue
new file mode 100644
index 00000000..4a73c151
--- /dev/null
+++ b/tests/fixtures/utils/selector/attr-empty-value/source.vue
@@ -0,0 +1,11 @@
+<!-- [foo=] -->
+<template>
+  <div foo="foo">
+    <div foo=bar></div>
+    <div foo="foo bar"></div>
+    <p foo></p>
+    <input bar="foo">
+    <input foo="foo">
+    <div foo=""></div>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/attr-eq/result.json b/tests/fixtures/utils/selector/attr-eq/result.json
new file mode 100644
index 00000000..89694b04
--- /dev/null
+++ b/tests/fixtures/utils/selector/attr-eq/result.json
@@ -0,0 +1,8 @@
+{
+    "selector": "[foo=foo]",
+    "matches": [
+        "<div foo=\"foo\">",
+        "<input foo=\"foo\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/attr-eq/source.vue b/tests/fixtures/utils/selector/attr-eq/source.vue
new file mode 100644
index 00000000..fdfe34e2
--- /dev/null
+++ b/tests/fixtures/utils/selector/attr-eq/source.vue
@@ -0,0 +1,11 @@
+<!-- [foo=foo] -->
+<template>
+  <div foo="foo">
+    <div foo=bar></div>
+    <div foo="foo bar"></div>
+    <p foo></p>
+    <input bar="foo">
+    <input foo="foo">
+    <div foo=""></div>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/attr-has/result.json b/tests/fixtures/utils/selector/attr-has/result.json
new file mode 100644
index 00000000..6af6140b
--- /dev/null
+++ b/tests/fixtures/utils/selector/attr-has/result.json
@@ -0,0 +1,8 @@
+{
+    "selector": "[foo]",
+    "matches": [
+        "<div foo=\"foo\">",
+        "<p foo>"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/attr-has/source.vue b/tests/fixtures/utils/selector/attr-has/source.vue
new file mode 100644
index 00000000..aaf7b1dd
--- /dev/null
+++ b/tests/fixtures/utils/selector/attr-has/source.vue
@@ -0,0 +1,8 @@
+<!-- [foo] -->
+<template>
+  <div foo="foo">
+    <div bar></div>
+    <p foo></p>
+    <input bar="foo">
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/attr-insensitive/result.json b/tests/fixtures/utils/selector/attr-insensitive/result.json
new file mode 100644
index 00000000..5d630529
--- /dev/null
+++ b/tests/fixtures/utils/selector/attr-insensitive/result.json
@@ -0,0 +1,7 @@
+{
+    "selector": "a[href*=\"insensitive\" i]",
+    "matches": [
+        "<a href=\"#InSensitive\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/attr-insensitive/source.vue b/tests/fixtures/utils/selector/attr-insensitive/source.vue
new file mode 100644
index 00000000..129fcab0
--- /dev/null
+++ b/tests/fixtures/utils/selector/attr-insensitive/source.vue
@@ -0,0 +1,10 @@
+<!-- a[href*="insensitive" i] -->
+<template>
+  <ul>
+    <li><a href="#internal">Internal link</a></li>
+    <li><a href="http://example.com">Example link</a></li>
+    <li><a href="#InSensitive">Insensitive internal link</a></li>
+    <li><a href="http://example.org">Example org link</a></li>
+    <li><a href="https://example.org">Example https org link</a></li>
+  </ul>
+</template>
diff --git a/tests/fixtures/utils/selector/attr-lang/result.json b/tests/fixtures/utils/selector/attr-lang/result.json
new file mode 100644
index 00000000..f8c9d266
--- /dev/null
+++ b/tests/fixtures/utils/selector/attr-lang/result.json
@@ -0,0 +1,9 @@
+{
+    "selector": "div[lang|=\"zh\"]",
+    "matches": [
+        "<div lang=\"zh\">",
+        "<div lang=\"zh-CN\">",
+        "<div lang=\"zh-TW\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/attr-lang/source.vue b/tests/fixtures/utils/selector/attr-lang/source.vue
new file mode 100644
index 00000000..fa88b875
--- /dev/null
+++ b/tests/fixtures/utils/selector/attr-lang/source.vue
@@ -0,0 +1,9 @@
+<!-- div[lang|="zh"] -->
+<template>
+  <div lang="en-us en-gb en-au en-nz">Hello World!</div>
+  <div lang="pt">Olá Mundo!</div>
+  <div lang="zh">世界您好！</div>
+  <div lang="zh-CN">世界您好！</div>
+  <div lang="zh-TW">世界您好！</div>
+  <div data-lang="zh-TW">世界您好！</div>
+</template>
diff --git a/tests/fixtures/utils/selector/attr-not-insensitive/result.json b/tests/fixtures/utils/selector/attr-not-insensitive/result.json
new file mode 100644
index 00000000..41f6b6dd
--- /dev/null
+++ b/tests/fixtures/utils/selector/attr-not-insensitive/result.json
@@ -0,0 +1,5 @@
+{
+    "selector": "a[href*=\"insensitive\"]",
+    "matches": [],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/attr-not-insensitive/source.vue b/tests/fixtures/utils/selector/attr-not-insensitive/source.vue
new file mode 100644
index 00000000..2fb3e903
--- /dev/null
+++ b/tests/fixtures/utils/selector/attr-not-insensitive/source.vue
@@ -0,0 +1,11 @@
+<!-- a[href*="insensitive"] -->
+<template>
+  <!-- Test for not insensitive -->
+  <ul>
+    <li><a href="#internal">Internal link</a></li>
+    <li><a href="http://example.com">Example link</a></li>
+    <li><a href="#InSensitive">Insensitive internal link</a></li>
+    <li><a href="http://example.org">Example org link</a></li>
+    <li><a href="https://example.org">Example https org link</a></li>
+  </ul>
+</template>
diff --git a/tests/fixtures/utils/selector/attr-prefixed/result.json b/tests/fixtures/utils/selector/attr-prefixed/result.json
new file mode 100644
index 00000000..9f11bd4e
--- /dev/null
+++ b/tests/fixtures/utils/selector/attr-prefixed/result.json
@@ -0,0 +1,8 @@
+{
+    "selector": "a[href^=\"#\"]",
+    "matches": [
+        "<a href=\"#internal\">",
+        "<a href=\"#InSensitive\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/attr-prefixed/source.vue b/tests/fixtures/utils/selector/attr-prefixed/source.vue
new file mode 100644
index 00000000..2c1dc2eb
--- /dev/null
+++ b/tests/fixtures/utils/selector/attr-prefixed/source.vue
@@ -0,0 +1,10 @@
+<!-- a[href^="#"] -->
+<template>
+  <ul>
+    <li><a href="#internal">Internal link</a></li>
+    <li><a href="http://example.com">Example link</a></li>
+    <li><a href="#InSensitive">Insensitive internal link</a></li>
+    <li><a href="http://example.org">Example org link</a></li>
+    <li><a href="https://example.org">Example https org link</a></li>
+  </ul>
+</template>
diff --git a/tests/fixtures/utils/selector/attr-suffixed/result.json b/tests/fixtures/utils/selector/attr-suffixed/result.json
new file mode 100644
index 00000000..d1a9e622
--- /dev/null
+++ b/tests/fixtures/utils/selector/attr-suffixed/result.json
@@ -0,0 +1,8 @@
+{
+    "selector": "a[href$=\".org\"]",
+    "matches": [
+        "<a href=\"http://example.org\">",
+        "<a href=\"https://example.org\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/attr-suffixed/source.vue b/tests/fixtures/utils/selector/attr-suffixed/source.vue
new file mode 100644
index 00000000..7b27ad2e
--- /dev/null
+++ b/tests/fixtures/utils/selector/attr-suffixed/source.vue
@@ -0,0 +1,10 @@
+<!-- a[href$=".org"] -->
+<template>
+  <ul>
+    <li><a href="#internal">Internal link</a></li>
+    <li><a href="http://example.com">Example link</a></li>
+    <li><a href="#InSensitive">Insensitive internal link</a></li>
+    <li><a href="http://example.org">Example org link</a></li>
+    <li><a href="https://example.org">Example https org link</a></li>
+  </ul>
+</template>
diff --git a/tests/fixtures/utils/selector/attr-words/result.json b/tests/fixtures/utils/selector/attr-words/result.json
new file mode 100644
index 00000000..3fdea22c
--- /dev/null
+++ b/tests/fixtures/utils/selector/attr-words/result.json
@@ -0,0 +1,8 @@
+{
+    "selector": "div[lang~=\"en-us\"]",
+    "matches": [
+        "<div lang=\"en-us en-gb en-au en-nz\">",
+        "<div lang=\"en-us\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/attr-words/source.vue b/tests/fixtures/utils/selector/attr-words/source.vue
new file mode 100644
index 00000000..c9558519
--- /dev/null
+++ b/tests/fixtures/utils/selector/attr-words/source.vue
@@ -0,0 +1,11 @@
+<!-- div[lang~="en-us"] -->
+<template>
+  <div lang="en-us en-gb en-au en-nz">Hello World!</div>
+  <div lang="en-us">Hello World!</div>
+  <div lang="en-us-unknown">Unknown</div>
+  <div lang="pt">Olá Mundo!</div>
+  <div lang="zh">世界您好！</div>
+  <div lang="zh-CN">世界您好！</div>
+  <div lang="zh-TW">世界您好！</div>
+  <div data-lang="zh-TW">世界您好！</div>
+</template>
diff --git a/tests/fixtures/utils/selector/class/result.json b/tests/fixtures/utils/selector/class/result.json
new file mode 100644
index 00000000..6fd93bcd
--- /dev/null
+++ b/tests/fixtures/utils/selector/class/result.json
@@ -0,0 +1,8 @@
+{
+    "selector": ".foo",
+    "matches": [
+        "<div class=\"foo\">",
+        "<input class=\"foo bar\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/class/source.vue b/tests/fixtures/utils/selector/class/source.vue
new file mode 100644
index 00000000..b9616a44
--- /dev/null
+++ b/tests/fixtures/utils/selector/class/source.vue
@@ -0,0 +1,8 @@
+<!-- .foo -->
+<template>
+  <div class="foo">
+    <div class="f"></div>
+    <p class="bar"></p>
+    <input class="foo bar">
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/combinator-adjacent/result.json b/tests/fixtures/utils/selector/combinator-adjacent/result.json
new file mode 100644
index 00000000..ef2e8413
--- /dev/null
+++ b/tests/fixtures/utils/selector/combinator-adjacent/result.json
@@ -0,0 +1,8 @@
+{
+    "selector": ".foo+.bar",
+    "matches": [
+        "<div class=\"bar 3\">",
+        "<div class=\"bar 4\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/combinator-adjacent/source.vue b/tests/fixtures/utils/selector/combinator-adjacent/source.vue
new file mode 100644
index 00000000..489c0d7e
--- /dev/null
+++ b/tests/fixtures/utils/selector/combinator-adjacent/source.vue
@@ -0,0 +1,14 @@
+<!-- .foo+.bar -->
+<template>
+  <div class="bar">
+    <div class="foo">
+      <div class="foo 2">
+        <div class="bar 2"></div>
+      </div>
+      <div class="bar 3"></div>
+    </div>
+    <div class="foo 3"></div>
+    <div class="bar 4"></div>
+    <div class="bar 5"></div>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/combinator-child01/result.json b/tests/fixtures/utils/selector/combinator-child01/result.json
new file mode 100644
index 00000000..841f6965
--- /dev/null
+++ b/tests/fixtures/utils/selector/combinator-child01/result.json
@@ -0,0 +1,9 @@
+{
+    "selector": ".foo > .bar",
+    "matches": [
+        "<div class=\"bar 2\">",
+        "<div class=\"bar 3\">",
+        "<div class=\"bar\" exact>"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/combinator-child01/source.vue b/tests/fixtures/utils/selector/combinator-child01/source.vue
new file mode 100644
index 00000000..5eaa8df6
--- /dev/null
+++ b/tests/fixtures/utils/selector/combinator-child01/source.vue
@@ -0,0 +1,14 @@
+<!-- .foo > .bar -->
+<template>
+  <div class="bar">
+    <div class="foo">
+      <div class="foo 2">
+        <div class="bar 2"></div>
+      </div>
+      <div class="bar 3">
+        <div class="bar 4"></div>
+      </div>
+      <div class="bar" exact></div>
+    </div>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/combinator-child02/result.json b/tests/fixtures/utils/selector/combinator-child02/result.json
new file mode 100644
index 00000000..ca0084bb
--- /dev/null
+++ b/tests/fixtures/utils/selector/combinator-child02/result.json
@@ -0,0 +1,10 @@
+{
+    "selector": ".a > .b > .c",
+    "matches": [
+        "<div class=\"c a-a-b-c\">",
+        "<div class=\"c a-b-c\">",
+        "<div class=\"c b-a-b-c\">",
+        "<div class=\"c c-a-b-c\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/combinator-child02/source.vue b/tests/fixtures/utils/selector/combinator-child02/source.vue
new file mode 100644
index 00000000..cf2ede6e
--- /dev/null
+++ b/tests/fixtures/utils/selector/combinator-child02/source.vue
@@ -0,0 +1,162 @@
+<!-- .a > .b > .c -->
+<template>
+  <div class="a">
+    <div class="a a-a">
+      <div class="a a-a-a">
+        <div class="a a-a-a-a"></div>
+        <div class="b a-a-a-b"></div>
+        <div class="c a-a-a-c"></div>
+      </div>
+      <div class="b a-a-b">
+        <div class="a a-a-b-a"></div>
+        <div class="b a-a-b-b"></div>
+        <div class="c a-a-b-c"></div>
+      </div>
+      <div class="c a-a-c">
+        <div class="a a-a-c-a"></div>
+        <div class="b a-a-c-b"></div>
+        <div class="c a-a-c-c"></div>
+      </div>
+    </div>
+    <div class="b a-b">
+      <div class="a a-b-a">
+        <div class="a a-b-a-a"></div>
+        <div class="b a-b-a-b"></div>
+        <div class="c a-b-a-c"></div>
+      </div>
+      <div class="b a-b-b">
+        <div class="a a-b-b-a"></div>
+        <div class="b a-b-b-b"></div>
+        <div class="c a-b-b-c"></div>
+      </div>
+      <div class="c a-b-c">
+        <div class="a a-b-c-a"></div>
+        <div class="b a-b-c-b"></div>
+        <div class="c a-b-c-c"></div>
+      </div>
+    </div>
+    <div class="c a-c">
+      <div class="a a-c-a">
+        <div class="a a-c-a-a"></div>
+        <div class="b a-c-a-b"></div>
+        <div class="c a-c-a-c"></div>
+      </div>
+      <div class="b a-c-b">
+        <div class="a a-c-b-a"></div>
+        <div class="b a-c-b-b"></div>
+        <div class="c a-c-b-c"></div>
+      </div>
+      <div class="c a-c-c">
+        <div class="a a-c-c-a"></div>
+        <div class="b a-c-c-b"></div>
+        <div class="c a-c-c-c"></div>
+      </div>
+    </div>
+  </div>
+  <div class="b">
+    <div class="a b-a">
+      <div class="a b-a-a">
+        <div class="a b-a-a-a"></div>
+        <div class="b b-a-a-b"></div>
+        <div class="c b-a-a-c"></div>
+      </div>
+      <div class="b b-a-b">
+        <div class="a b-a-b-a"></div>
+        <div class="b b-a-b-b"></div>
+        <div class="c b-a-b-c"></div>
+      </div>
+      <div class="c b-a-c">
+        <div class="a b-a-c-a"></div>
+        <div class="b b-a-c-b"></div>
+        <div class="c b-a-c-c"></div>
+      </div>
+    </div>
+    <div class="b b-b">
+      <div class="a b-b-a">
+        <div class="a b-b-a-a"></div>
+        <div class="b b-b-a-b"></div>
+        <div class="c b-b-a-c"></div>
+      </div>
+      <div class="b b-b-b">
+        <div class="a b-b-b-a"></div>
+        <div class="b b-b-b-b"></div>
+        <div class="c b-b-b-c"></div>
+      </div>
+      <div class="c b-b-c">
+        <div class="a b-b-c-a"></div>
+        <div class="b b-b-c-b"></div>
+        <div class="c b-b-c-c"></div>
+      </div>
+    </div>
+    <div class="c b-c">
+      <div class="a b-c-a">
+        <div class="a b-c-a-a"></div>
+        <div class="b b-c-a-b"></div>
+        <div class="c b-c-a-c"></div>
+      </div>
+      <div class="b b-c-b">
+        <div class="a b-c-b-a"></div>
+        <div class="b b-c-b-b"></div>
+        <div class="c b-c-b-c"></div>
+      </div>
+      <div class="c b-c-c">
+        <div class="a b-c-c-a"></div>
+        <div class="b b-c-c-b"></div>
+        <div class="c b-c-c-c"></div>
+      </div>
+    </div>
+  </div>
+  <div class="c">
+    <div class="a c-a">
+      <div class="a c-a-a">
+        <div class="a c-a-a-a"></div>
+        <div class="b c-a-a-b"></div>
+        <div class="c c-a-a-c"></div>
+      </div>
+      <div class="b c-a-b">
+        <div class="a c-a-b-a"></div>
+        <div class="b c-a-b-b"></div>
+        <div class="c c-a-b-c"></div>
+      </div>
+      <div class="c c-a-c">
+        <div class="a c-a-c-a"></div>
+        <div class="b c-a-c-b"></div>
+        <div class="c c-a-c-c"></div>
+      </div>
+    </div>
+    <div class="b c-b">
+      <div class="a c-b-a">
+        <div class="a c-b-a-a"></div>
+        <div class="b c-b-a-b"></div>
+        <div class="c c-b-a-c"></div>
+      </div>
+      <div class="b c-b-b">
+        <div class="a c-b-b-a"></div>
+        <div class="b c-b-b-b"></div>
+        <div class="c c-b-b-c"></div>
+      </div>
+      <div class="c c-b-c">
+        <div class="a c-b-c-a"></div>
+        <div class="b c-b-c-b"></div>
+        <div class="c c-b-c-c"></div>
+      </div>
+    </div>
+    <div class="c c-c">
+      <div class="a c-c-a">
+        <div class="a c-c-a-a"></div>
+        <div class="b c-c-a-b"></div>
+        <div class="c c-c-a-c"></div>
+      </div>
+      <div class="b c-c-b">
+        <div class="a c-c-b-a"></div>
+        <div class="b c-c-b-b"></div>
+        <div class="c c-c-b-c"></div>
+      </div>
+      <div class="c c-c-c">
+        <div class="a c-c-c-a"></div>
+        <div class="b c-c-c-b"></div>
+        <div class="c c-c-c-c"></div>
+      </div>
+    </div>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/combinator-descendant01/result.json b/tests/fixtures/utils/selector/combinator-descendant01/result.json
new file mode 100644
index 00000000..fb3463b3
--- /dev/null
+++ b/tests/fixtures/utils/selector/combinator-descendant01/result.json
@@ -0,0 +1,10 @@
+{
+    "selector": ".foo .bar",
+    "matches": [
+        "<div class=\"bar 2\">",
+        "<div class=\"bar 3\">",
+        "<div class=\"bar 4\">",
+        "<div class=\"bar\" exact>"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/combinator-descendant01/source.vue b/tests/fixtures/utils/selector/combinator-descendant01/source.vue
new file mode 100644
index 00000000..b122dae6
--- /dev/null
+++ b/tests/fixtures/utils/selector/combinator-descendant01/source.vue
@@ -0,0 +1,14 @@
+<!-- .foo .bar -->
+<template>
+  <div class="bar">
+    <div class="foo">
+      <div class="foo 2">
+        <div class="bar 2"></div>
+      </div>
+      <div class="bar 3">
+        <div class="bar 4"></div>
+      </div>
+      <div class="bar" exact></div>
+    </div>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/combinator-descendant02/result.json b/tests/fixtures/utils/selector/combinator-descendant02/result.json
new file mode 100644
index 00000000..3d308db8
--- /dev/null
+++ b/tests/fixtures/utils/selector/combinator-descendant02/result.json
@@ -0,0 +1,14 @@
+{
+    "selector": ".a .b .c",
+    "matches": [
+        "<div class=\"c a-a-b-c\">",
+        "<div class=\"c a-b-a-c\">",
+        "<div class=\"c a-b-b-c\">",
+        "<div class=\"c a-b-c\">",
+        "<div class=\"c a-b-c-c\">",
+        "<div class=\"c a-c-b-c\">",
+        "<div class=\"c b-a-b-c\">",
+        "<div class=\"c c-a-b-c\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/combinator-descendant02/source.vue b/tests/fixtures/utils/selector/combinator-descendant02/source.vue
new file mode 100644
index 00000000..d89b8622
--- /dev/null
+++ b/tests/fixtures/utils/selector/combinator-descendant02/source.vue
@@ -0,0 +1,162 @@
+<!-- .a .b .c -->
+<template>
+  <div class="a">
+    <div class="a a-a">
+      <div class="a a-a-a">
+        <div class="a a-a-a-a"></div>
+        <div class="b a-a-a-b"></div>
+        <div class="c a-a-a-c"></div>
+      </div>
+      <div class="b a-a-b">
+        <div class="a a-a-b-a"></div>
+        <div class="b a-a-b-b"></div>
+        <div class="c a-a-b-c"></div>
+      </div>
+      <div class="c a-a-c">
+        <div class="a a-a-c-a"></div>
+        <div class="b a-a-c-b"></div>
+        <div class="c a-a-c-c"></div>
+      </div>
+    </div>
+    <div class="b a-b">
+      <div class="a a-b-a">
+        <div class="a a-b-a-a"></div>
+        <div class="b a-b-a-b"></div>
+        <div class="c a-b-a-c"></div>
+      </div>
+      <div class="b a-b-b">
+        <div class="a a-b-b-a"></div>
+        <div class="b a-b-b-b"></div>
+        <div class="c a-b-b-c"></div>
+      </div>
+      <div class="c a-b-c">
+        <div class="a a-b-c-a"></div>
+        <div class="b a-b-c-b"></div>
+        <div class="c a-b-c-c"></div>
+      </div>
+    </div>
+    <div class="c a-c">
+      <div class="a a-c-a">
+        <div class="a a-c-a-a"></div>
+        <div class="b a-c-a-b"></div>
+        <div class="c a-c-a-c"></div>
+      </div>
+      <div class="b a-c-b">
+        <div class="a a-c-b-a"></div>
+        <div class="b a-c-b-b"></div>
+        <div class="c a-c-b-c"></div>
+      </div>
+      <div class="c a-c-c">
+        <div class="a a-c-c-a"></div>
+        <div class="b a-c-c-b"></div>
+        <div class="c a-c-c-c"></div>
+      </div>
+    </div>
+  </div>
+  <div class="b">
+    <div class="a b-a">
+      <div class="a b-a-a">
+        <div class="a b-a-a-a"></div>
+        <div class="b b-a-a-b"></div>
+        <div class="c b-a-a-c"></div>
+      </div>
+      <div class="b b-a-b">
+        <div class="a b-a-b-a"></div>
+        <div class="b b-a-b-b"></div>
+        <div class="c b-a-b-c"></div>
+      </div>
+      <div class="c b-a-c">
+        <div class="a b-a-c-a"></div>
+        <div class="b b-a-c-b"></div>
+        <div class="c b-a-c-c"></div>
+      </div>
+    </div>
+    <div class="b b-b">
+      <div class="a b-b-a">
+        <div class="a b-b-a-a"></div>
+        <div class="b b-b-a-b"></div>
+        <div class="c b-b-a-c"></div>
+      </div>
+      <div class="b b-b-b">
+        <div class="a b-b-b-a"></div>
+        <div class="b b-b-b-b"></div>
+        <div class="c b-b-b-c"></div>
+      </div>
+      <div class="c b-b-c">
+        <div class="a b-b-c-a"></div>
+        <div class="b b-b-c-b"></div>
+        <div class="c b-b-c-c"></div>
+      </div>
+    </div>
+    <div class="c b-c">
+      <div class="a b-c-a">
+        <div class="a b-c-a-a"></div>
+        <div class="b b-c-a-b"></div>
+        <div class="c b-c-a-c"></div>
+      </div>
+      <div class="b b-c-b">
+        <div class="a b-c-b-a"></div>
+        <div class="b b-c-b-b"></div>
+        <div class="c b-c-b-c"></div>
+      </div>
+      <div class="c b-c-c">
+        <div class="a b-c-c-a"></div>
+        <div class="b b-c-c-b"></div>
+        <div class="c b-c-c-c"></div>
+      </div>
+    </div>
+  </div>
+  <div class="c">
+    <div class="a c-a">
+      <div class="a c-a-a">
+        <div class="a c-a-a-a"></div>
+        <div class="b c-a-a-b"></div>
+        <div class="c c-a-a-c"></div>
+      </div>
+      <div class="b c-a-b">
+        <div class="a c-a-b-a"></div>
+        <div class="b c-a-b-b"></div>
+        <div class="c c-a-b-c"></div>
+      </div>
+      <div class="c c-a-c">
+        <div class="a c-a-c-a"></div>
+        <div class="b c-a-c-b"></div>
+        <div class="c c-a-c-c"></div>
+      </div>
+    </div>
+    <div class="b c-b">
+      <div class="a c-b-a">
+        <div class="a c-b-a-a"></div>
+        <div class="b c-b-a-b"></div>
+        <div class="c c-b-a-c"></div>
+      </div>
+      <div class="b c-b-b">
+        <div class="a c-b-b-a"></div>
+        <div class="b c-b-b-b"></div>
+        <div class="c c-b-b-c"></div>
+      </div>
+      <div class="c c-b-c">
+        <div class="a c-b-c-a"></div>
+        <div class="b c-b-c-b"></div>
+        <div class="c c-b-c-c"></div>
+      </div>
+    </div>
+    <div class="c c-c">
+      <div class="a c-c-a">
+        <div class="a c-c-a-a"></div>
+        <div class="b c-c-a-b"></div>
+        <div class="c c-c-a-c"></div>
+      </div>
+      <div class="b c-c-b">
+        <div class="a c-c-b-a"></div>
+        <div class="b c-c-b-b"></div>
+        <div class="c c-c-b-c"></div>
+      </div>
+      <div class="c c-c-c">
+        <div class="a c-c-c-a"></div>
+        <div class="b c-c-c-b"></div>
+        <div class="c c-c-c-c"></div>
+      </div>
+    </div>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/combinator-sibling/result.json b/tests/fixtures/utils/selector/combinator-sibling/result.json
new file mode 100644
index 00000000..efc9bed4
--- /dev/null
+++ b/tests/fixtures/utils/selector/combinator-sibling/result.json
@@ -0,0 +1,9 @@
+{
+    "selector": ".foo~.bar",
+    "matches": [
+        "<div class=\"bar 3\">",
+        "<div class=\"bar 4\">",
+        "<div class=\"bar 5\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/combinator-sibling/source.vue b/tests/fixtures/utils/selector/combinator-sibling/source.vue
new file mode 100644
index 00000000..381e6750
--- /dev/null
+++ b/tests/fixtures/utils/selector/combinator-sibling/source.vue
@@ -0,0 +1,14 @@
+<!-- .foo~.bar -->
+<template>
+  <div class="bar">
+    <div class="foo">
+      <div class="foo 2">
+        <div class="bar 2"></div>
+      </div>
+      <div class="bar 3"></div>
+    </div>
+    <div class="foo 3"></div>
+    <div class="bar 4"></div>
+    <div class="bar 5"></div>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/comment01/result.json b/tests/fixtures/utils/selector/comment01/result.json
new file mode 100644
index 00000000..7592577f
--- /dev/null
+++ b/tests/fixtures/utils/selector/comment01/result.json
@@ -0,0 +1,7 @@
+{
+    "selector": ".foo /* comment */ :is(.bar /* comment */)",
+    "matches": [
+        "<div class=\"bar\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/comment01/source.vue b/tests/fixtures/utils/selector/comment01/source.vue
new file mode 100644
index 00000000..d388a1b9
--- /dev/null
+++ b/tests/fixtures/utils/selector/comment01/source.vue
@@ -0,0 +1,7 @@
+<!-- .foo /* comment */ :is(.bar /* comment */) -->
+<template>
+  <div class="foo">
+    <div class="bar">
+    </div>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/comment02/result.json b/tests/fixtures/utils/selector/comment02/result.json
new file mode 100644
index 00000000..3d70892a
--- /dev/null
+++ b/tests/fixtures/utils/selector/comment02/result.json
@@ -0,0 +1,7 @@
+{
+    "selector": ".foo /* comment */ > /* comment */ /* comment */ .bar /* comment */",
+    "matches": [
+        "<div class=\"bar b\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/comment02/source.vue b/tests/fixtures/utils/selector/comment02/source.vue
new file mode 100644
index 00000000..dc71c6ba
--- /dev/null
+++ b/tests/fixtures/utils/selector/comment02/source.vue
@@ -0,0 +1,13 @@
+<!-- .foo /* comment */ > /* comment */ /* comment */ .bar /* comment */ -->
+<template>
+  <div class="foo a">
+    <div class="bar b">
+    </div>
+  </div>
+  <div class="foo c">
+    <div>
+      <div class="bar d">
+      </div>
+    </div>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/comment03/result.json b/tests/fixtures/utils/selector/comment03/result.json
new file mode 100644
index 00000000..1358c04e
--- /dev/null
+++ b/tests/fixtures/utils/selector/comment03/result.json
@@ -0,0 +1,7 @@
+{
+    "selector": ".foo /* comment */ /* comment */ > /* comment */ .bar /* comment */",
+    "matches": [
+        "<div class=\"bar b\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/comment03/source.vue b/tests/fixtures/utils/selector/comment03/source.vue
new file mode 100644
index 00000000..a8381a88
--- /dev/null
+++ b/tests/fixtures/utils/selector/comment03/source.vue
@@ -0,0 +1,13 @@
+<!-- .foo /* comment */ /* comment */ > /* comment */ .bar /* comment */ -->
+<template>
+  <div class="foo a">
+    <div class="bar b">
+    </div>
+  </div>
+  <div class="foo c">
+    <div>
+      <div class="bar d">
+      </div>
+    </div>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/id/result.json b/tests/fixtures/utils/selector/id/result.json
new file mode 100644
index 00000000..616a429c
--- /dev/null
+++ b/tests/fixtures/utils/selector/id/result.json
@@ -0,0 +1,7 @@
+{
+    "selector": "#foo",
+    "matches": [
+        "<div id=\"foo\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/id/source.vue b/tests/fixtures/utils/selector/id/source.vue
new file mode 100644
index 00000000..88bdd7cb
--- /dev/null
+++ b/tests/fixtures/utils/selector/id/source.vue
@@ -0,0 +1,8 @@
+<!-- #foo -->
+<template>
+  <div id="1">
+    <div id="foo"></div>
+    <p id="3"></p>
+    <input id="4">
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/invalid-attr-op/result.json b/tests/fixtures/utils/selector/invalid-attr-op/result.json
new file mode 100644
index 00000000..edc492df
--- /dev/null
+++ b/tests/fixtures/utils/selector/invalid-attr-op/result.json
@@ -0,0 +1,7 @@
+{
+    "selector": "a[href==\"example\"]",
+    "matches": [],
+    "errors": [
+        "Unsupported operator: ==."
+    ]
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/invalid-attr-op/source.vue b/tests/fixtures/utils/selector/invalid-attr-op/source.vue
new file mode 100644
index 00000000..dc39fc6b
--- /dev/null
+++ b/tests/fixtures/utils/selector/invalid-attr-op/source.vue
@@ -0,0 +1,4 @@
+<!-- a[href=="example"] -->
+<template>
+  <a href="example"></a>
+</template>
diff --git a/tests/fixtures/utils/selector/invalid-combinators/result.json b/tests/fixtures/utils/selector/invalid-combinators/result.json
new file mode 100644
index 00000000..7f1e135e
--- /dev/null
+++ b/tests/fixtures/utils/selector/invalid-combinators/result.json
@@ -0,0 +1,7 @@
+{
+    "selector": ".foo > > .bar",
+    "matches": [],
+    "errors": [
+        "Unexpected combinator '>'."
+    ]
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/invalid-combinators/source.vue b/tests/fixtures/utils/selector/invalid-combinators/source.vue
new file mode 100644
index 00000000..2c768628
--- /dev/null
+++ b/tests/fixtures/utils/selector/invalid-combinators/source.vue
@@ -0,0 +1,6 @@
+<!-- .foo > > .bar -->
+<template>
+  <div class="foo">
+    <div class="bar"></div>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/invalid-empty/result.json b/tests/fixtures/utils/selector/invalid-empty/result.json
new file mode 100644
index 00000000..0352970a
--- /dev/null
+++ b/tests/fixtures/utils/selector/invalid-empty/result.json
@@ -0,0 +1,7 @@
+{
+    "selector": ".foo /* comment */ :is(.bar, /* comment */)",
+    "matches": [],
+    "errors": [
+        "Unexpected empty selector."
+    ]
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/invalid-empty/source.vue b/tests/fixtures/utils/selector/invalid-empty/source.vue
new file mode 100644
index 00000000..c6444bda
--- /dev/null
+++ b/tests/fixtures/utils/selector/invalid-empty/source.vue
@@ -0,0 +1,6 @@
+<!-- .foo /* comment */ :is(.bar, /* comment */) -->
+<template>
+  <div class="foo">
+    <div class="bar"></div>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/invalid-last-combinator/result.json b/tests/fixtures/utils/selector/invalid-last-combinator/result.json
new file mode 100644
index 00000000..25d9ded5
--- /dev/null
+++ b/tests/fixtures/utils/selector/invalid-last-combinator/result.json
@@ -0,0 +1,7 @@
+{
+    "selector": ".foo >",
+    "matches": [],
+    "errors": [
+        "Expected selector after '>'."
+    ]
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/invalid-last-combinator/source.vue b/tests/fixtures/utils/selector/invalid-last-combinator/source.vue
new file mode 100644
index 00000000..193c8fd1
--- /dev/null
+++ b/tests/fixtures/utils/selector/invalid-last-combinator/source.vue
@@ -0,0 +1,6 @@
+<!-- .foo > -->
+<template>
+  <div class="foo">
+    <div class="bar"></div>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/invalid-nesting/result.json b/tests/fixtures/utils/selector/invalid-nesting/result.json
new file mode 100644
index 00000000..3e8d09db
--- /dev/null
+++ b/tests/fixtures/utils/selector/invalid-nesting/result.json
@@ -0,0 +1,7 @@
+{
+    "selector": "& .foo > .bar",
+    "matches": [],
+    "errors": [
+        "Unsupported nesting selector."
+    ]
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/invalid-nesting/source.vue b/tests/fixtures/utils/selector/invalid-nesting/source.vue
new file mode 100644
index 00000000..69338be4
--- /dev/null
+++ b/tests/fixtures/utils/selector/invalid-nesting/source.vue
@@ -0,0 +1,6 @@
+<!-- & .foo > .bar -->
+<template>
+  <div class="foo">
+    <div class="bar"></div>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/invalid-nth/result.json b/tests/fixtures/utils/selector/invalid-nth/result.json
new file mode 100644
index 00000000..248758f1
--- /dev/null
+++ b/tests/fixtures/utils/selector/invalid-nth/result.json
@@ -0,0 +1,7 @@
+{
+    "selector": ":nth-child()",
+    "matches": [],
+    "errors": [
+        "Cannot parse An+B micro syntax (:nth-xxx() argument): ''."
+    ]
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/invalid-nth/source.vue b/tests/fixtures/utils/selector/invalid-nth/source.vue
new file mode 100644
index 00000000..4e2e356c
--- /dev/null
+++ b/tests/fixtures/utils/selector/invalid-nth/source.vue
@@ -0,0 +1,4 @@
+<!-- :nth-child() -->
+<template>
+  <a href="example"></a>
+</template>
diff --git a/tests/fixtures/utils/selector/invalid-selector/result.json b/tests/fixtures/utils/selector/invalid-selector/result.json
new file mode 100644
index 00000000..b8b057b0
--- /dev/null
+++ b/tests/fixtures/utils/selector/invalid-selector/result.json
@@ -0,0 +1,7 @@
+{
+    "selector": ":not(",
+    "matches": [],
+    "errors": [
+        "Cannot parse selector: :not(."
+    ]
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/invalid-selector/source.vue b/tests/fixtures/utils/selector/invalid-selector/source.vue
new file mode 100644
index 00000000..cc6ae2d4
--- /dev/null
+++ b/tests/fixtures/utils/selector/invalid-selector/source.vue
@@ -0,0 +1,4 @@
+<!-- :not( -->
+<template>
+  <a href="example"></a>
+</template>
diff --git a/tests/fixtures/utils/selector/invalid-string/result.json b/tests/fixtures/utils/selector/invalid-string/result.json
new file mode 100644
index 00000000..542d12c1
--- /dev/null
+++ b/tests/fixtures/utils/selector/invalid-string/result.json
@@ -0,0 +1,7 @@
+{
+    "selector": ".foo > \".bar\"",
+    "matches": [],
+    "errors": [
+        "Unknown selector: \".bar\"."
+    ]
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/invalid-string/source.vue b/tests/fixtures/utils/selector/invalid-string/source.vue
new file mode 100644
index 00000000..3a51d640
--- /dev/null
+++ b/tests/fixtures/utils/selector/invalid-string/source.vue
@@ -0,0 +1,6 @@
+<!-- .foo > ".bar" -->
+<template>
+  <div class="foo">
+    <div class="bar"></div>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/invalid-unknown-combinator/result.json b/tests/fixtures/utils/selector/invalid-unknown-combinator/result.json
new file mode 100644
index 00000000..486eae4f
--- /dev/null
+++ b/tests/fixtures/utils/selector/invalid-unknown-combinator/result.json
@@ -0,0 +1,7 @@
+{
+    "selector": ".foo /unknown/ .bar",
+    "matches": [],
+    "errors": [
+        "Unknown combinator: /unknown/."
+    ]
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/invalid-unknown-combinator/source.vue b/tests/fixtures/utils/selector/invalid-unknown-combinator/source.vue
new file mode 100644
index 00000000..08d148f7
--- /dev/null
+++ b/tests/fixtures/utils/selector/invalid-unknown-combinator/source.vue
@@ -0,0 +1,6 @@
+<!-- .foo /unknown/ .bar -->
+<template>
+  <div class="foo">
+    <div class="bar"></div>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/invalid-unknown-pseudo/result.json b/tests/fixtures/utils/selector/invalid-unknown-pseudo/result.json
new file mode 100644
index 00000000..6171ea02
--- /dev/null
+++ b/tests/fixtures/utils/selector/invalid-unknown-pseudo/result.json
@@ -0,0 +1,7 @@
+{
+    "selector": ".foo :unknown(.bar)",
+    "matches": [],
+    "errors": [
+        "Unsupported pseudo selector: :unknown."
+    ]
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/invalid-unknown-pseudo/source.vue b/tests/fixtures/utils/selector/invalid-unknown-pseudo/source.vue
new file mode 100644
index 00000000..5d1449ab
--- /dev/null
+++ b/tests/fixtures/utils/selector/invalid-unknown-pseudo/source.vue
@@ -0,0 +1,6 @@
+<!-- .foo :unknown(.bar) -->
+<template>
+  <div class="foo">
+    <div class="bar"></div>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/pseudo-empty/result.json b/tests/fixtures/utils/selector/pseudo-empty/result.json
new file mode 100644
index 00000000..feefab5e
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-empty/result.json
@@ -0,0 +1,8 @@
+{
+    "selector": ".box:empty",
+    "matches": [
+        "<div class=\"box a\">",
+        "<div class=\"box c\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/pseudo-empty/source.vue b/tests/fixtures/utils/selector/pseudo-empty/source.vue
new file mode 100644
index 00000000..9a47c2a5
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-empty/source.vue
@@ -0,0 +1,11 @@
+<!-- .box:empty -->
+<template>
+  <div class="box a"></div>
+  <div class="box b">Content</div>
+  <div class="box c">
+    <!-- comment. -->
+  </div>
+  <div class="box d">
+    <p></p>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/pseudo-first-child01/result.json b/tests/fixtures/utils/selector/pseudo-first-child01/result.json
new file mode 100644
index 00000000..df66f755
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-first-child01/result.json
@@ -0,0 +1,7 @@
+{
+    "selector": "p:first-child",
+    "matches": [
+        "<p class=\"b\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/pseudo-first-child01/source.vue b/tests/fixtures/utils/selector/pseudo-first-child01/source.vue
new file mode 100644
index 00000000..52ab2eb9
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-first-child01/source.vue
@@ -0,0 +1,14 @@
+<!-- p:first-child -->
+<template>
+  <div>
+    <div class="a">
+      <p class="b">This text is selected!</p>
+      <p class="c">This text isn't selected.</p>
+    </div>
+
+    <div class="d">
+      <h2 class="e">This text isn't selected: it's not a `p`.</h2>
+      <p class="f">This text isn't selected.</p>
+    </div>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/pseudo-first-child02/result.json b/tests/fixtures/utils/selector/pseudo-first-child02/result.json
new file mode 100644
index 00000000..e991fe99
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-first-child02/result.json
@@ -0,0 +1,8 @@
+{
+    "selector": "ul li:first-child",
+    "matches": [
+        "<li class=\"b\">",
+        "<li class=\"f\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/pseudo-first-child02/source.vue b/tests/fixtures/utils/selector/pseudo-first-child02/source.vue
new file mode 100644
index 00000000..90c77ad9
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-first-child02/source.vue
@@ -0,0 +1,14 @@
+<!-- ul li:first-child -->
+<template>
+  <ul class="a">
+    <li class="b">Item 1</li>
+    <li class="c">Item 2</li>
+    <li class="d">Item 3
+      <ul class="e">
+        <li class="f">Item 3.1</li>
+        <li class="g">Item 3.2</li>
+        <li class="h">Item 3.3</li>
+      </ul>
+    </li>
+  </ul>
+</template>
diff --git a/tests/fixtures/utils/selector/pseudo-first-of-type01/result.json b/tests/fixtures/utils/selector/pseudo-first-of-type01/result.json
new file mode 100644
index 00000000..408762a9
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-first-of-type01/result.json
@@ -0,0 +1,7 @@
+{
+    "selector": "p:first-of-type",
+    "matches": [
+        "<p class=\"b\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/pseudo-first-of-type01/source.vue b/tests/fixtures/utils/selector/pseudo-first-of-type01/source.vue
new file mode 100644
index 00000000..c9a012a1
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-first-of-type01/source.vue
@@ -0,0 +1,8 @@
+<!-- p:first-of-type -->
+<template>
+  <div>
+    <h2 class="a">Heading</h2>
+    <p class="b">Paragraph 1</p>
+    <p class="c">Paragraph 2</p>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/pseudo-first-of-type02/result.json b/tests/fixtures/utils/selector/pseudo-first-of-type02/result.json
new file mode 100644
index 00000000..aabc2d16
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-first-of-type02/result.json
@@ -0,0 +1,8 @@
+{
+    "selector": ".root > :first-of-type",
+    "matches": [
+        "<h2 class=\"a\">",
+        "<p class=\"b\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/pseudo-first-of-type02/source.vue b/tests/fixtures/utils/selector/pseudo-first-of-type02/source.vue
new file mode 100644
index 00000000..515de7e9
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-first-of-type02/source.vue
@@ -0,0 +1,8 @@
+<!-- .root > :first-of-type -->
+<template>
+  <div class="root">
+    <h2 class="a">Heading</h2>
+    <p class="b">Paragraph 1</p>
+    <p class="c">Paragraph 2</p>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/pseudo-has-complex01/result.json b/tests/fixtures/utils/selector/pseudo-has-complex01/result.json
new file mode 100644
index 00000000..21d9decf
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-has-complex01/result.json
@@ -0,0 +1,8 @@
+{
+    "selector": "div:has(>.foo > .foo)",
+    "matches": [
+        "<div class=\"foo\" id=\"root-1\">",
+        "<div class=\"foo nest-1\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/pseudo-has-complex01/source.vue b/tests/fixtures/utils/selector/pseudo-has-complex01/source.vue
new file mode 100644
index 00000000..abc928b8
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-has-complex01/source.vue
@@ -0,0 +1,31 @@
+<!-- div:has(>.foo > .foo) -->
+<template>
+  <div class="foo" id="root-1">
+    <div class="foo nest-1">
+      <div class="foo nest-2">
+        <div class="foo nest-3">
+        </div>
+      </div>
+    </div>
+  </div>
+  <div class="foo" id="root-2">
+    <div class="bar nest-1">
+      <div class="foo nest-2">
+        <div class="bar nest-3">
+        </div>
+      </div>
+    </div>
+  </div>
+  <div class="foo" id="root-3">
+    <div class="foo nest-1">
+      <div class="bar nest-2">
+        <div class="foo nest-3">
+        </div>
+      </div>
+    </div>
+  </div>
+  <div class="foo" id="root-4">
+    <div class="foo nest-1">
+    </div>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/pseudo-has-complex02/result.json b/tests/fixtures/utils/selector/pseudo-has-complex02/result.json
new file mode 100644
index 00000000..b8c779ec
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-has-complex02/result.json
@@ -0,0 +1,9 @@
+{
+    "selector": "div:has(>.foo .foo)",
+    "matches": [
+        "<div class=\"foo\" id=\"root-1\">",
+        "<div class=\"foo nest-1\">",
+        "<div class=\"foo\" id=\"root-3\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/pseudo-has-complex02/source.vue b/tests/fixtures/utils/selector/pseudo-has-complex02/source.vue
new file mode 100644
index 00000000..f0e1b8da
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-has-complex02/source.vue
@@ -0,0 +1,31 @@
+<!-- div:has(>.foo .foo) -->
+<template>
+  <div class="foo" id="root-1">
+    <div class="foo nest-1">
+      <div class="foo nest-2">
+        <div class="foo nest-3">
+        </div>
+      </div>
+    </div>
+  </div>
+  <div class="foo" id="root-2">
+    <div class="bar nest-1">
+      <div class="foo nest-2">
+        <div class="bar nest-3">
+        </div>
+      </div>
+    </div>
+  </div>
+  <div class="foo" id="root-3">
+    <div class="foo nest-1">
+      <div class="bar nest-2">
+        <div class="foo nest-3">
+        </div>
+      </div>
+    </div>
+  </div>
+  <div class="foo" id="root-4">
+    <div class="foo nest-1">
+    </div>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/pseudo-has01/result.json b/tests/fixtures/utils/selector/pseudo-has01/result.json
new file mode 100644
index 00000000..af3f7bc3
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-has01/result.json
@@ -0,0 +1,9 @@
+{
+    "selector": "div:has(.foo)",
+    "matches": [
+        "<div class=\"foo\" id=\"root-1\">",
+        "<div class=\"foo\" id=\"root-2\">",
+        "<div class=\"foo\" id=\"root-3\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/pseudo-has01/source.vue b/tests/fixtures/utils/selector/pseudo-has01/source.vue
new file mode 100644
index 00000000..11defe7d
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-has01/source.vue
@@ -0,0 +1,25 @@
+<!-- div:has(.foo) -->
+<template>
+  <div class="foo" id="root-1">
+    <p class="foo">
+      <span class="foo">
+      </span>
+    </p>
+  </div>
+  <div class="foo" id="root-2">
+    <p class="bar">
+      <span class="foo">
+      </span>
+    </p>
+  </div>
+  <div class="foo" id="root-3">
+    <p class="foo">
+      <span class="bar">
+      </span>
+    </p>
+  </div>
+  <div class="foo" id="root-4">
+    <p class="bar">
+    </p>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/pseudo-has02/result.json b/tests/fixtures/utils/selector/pseudo-has02/result.json
new file mode 100644
index 00000000..41326746
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-has02/result.json
@@ -0,0 +1,8 @@
+{
+    "selector": "div:has(>.foo)",
+    "matches": [
+        "<div class=\"foo\" id=\"root-1\">",
+        "<div class=\"foo\" id=\"root-3\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/pseudo-has02/source.vue b/tests/fixtures/utils/selector/pseudo-has02/source.vue
new file mode 100644
index 00000000..7fcde7d5
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-has02/source.vue
@@ -0,0 +1,25 @@
+<!-- div:has(>.foo) -->
+<template>
+  <div class="foo" id="root-1">
+    <p class="foo">
+      <span class="foo">
+      </span>
+    </p>
+  </div>
+  <div class="foo" id="root-2">
+    <p class="bar">
+      <span class="foo">
+      </span>
+    </p>
+  </div>
+  <div class="foo" id="root-3">
+    <p class="foo">
+      <span class="bar">
+      </span>
+    </p>
+  </div>
+  <div class="foo" id="root-4">
+    <p class="bar">
+    </p>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/pseudo-has03/result.json b/tests/fixtures/utils/selector/pseudo-has03/result.json
new file mode 100644
index 00000000..d818eb80
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-has03/result.json
@@ -0,0 +1,8 @@
+{
+    "selector": "div:has(+.foo)",
+    "matches": [
+        "<div class=\"bar\" id=\"root-2\">",
+        "<div class=\"foo\" id=\"root-3\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/pseudo-has03/source.vue b/tests/fixtures/utils/selector/pseudo-has03/source.vue
new file mode 100644
index 00000000..2157d535
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-has03/source.vue
@@ -0,0 +1,13 @@
+<!-- div:has(+.foo)  -->
+<template>
+  <div class="foo" id="root-1">
+  </div>
+  <div class="bar" id="root-2">
+  </div>
+  <div class="foo" id="root-3">
+  </div>
+  <div class="foo" id="root-4">
+  </div>
+  <div class="bar" id="root-5">
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/pseudo-has04/result.json b/tests/fixtures/utils/selector/pseudo-has04/result.json
new file mode 100644
index 00000000..ca52f286
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-has04/result.json
@@ -0,0 +1,9 @@
+{
+    "selector": "div:has(~.foo)",
+    "matches": [
+        "<div class=\"foo\" id=\"root-1\">",
+        "<div class=\"bar\" id=\"root-2\">",
+        "<div class=\"foo\" id=\"root-3\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/pseudo-has04/source.vue b/tests/fixtures/utils/selector/pseudo-has04/source.vue
new file mode 100644
index 00000000..9eeebaa2
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-has04/source.vue
@@ -0,0 +1,13 @@
+<!-- div:has(~.foo)  -->
+<template>
+  <div class="foo" id="root-1">
+  </div>
+  <div class="bar" id="root-2">
+  </div>
+  <div class="foo" id="root-3">
+  </div>
+  <div class="foo" id="root-4">
+  </div>
+  <div class="bar" id="root-5">
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/pseudo-is/result.json b/tests/fixtures/utils/selector/pseudo-is/result.json
new file mode 100644
index 00000000..a0893ac9
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-is/result.json
@@ -0,0 +1,12 @@
+{
+    "selector": ":where(header, main, footer) p",
+    "matches": [
+        "<p class=\"b\">",
+        "<p class=\"f\">",
+        "<p class=\"g\">",
+        "<p class=\"i\">",
+        "<p class=\"j\">",
+        "<p class=\"l\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/pseudo-is/source.vue b/tests/fixtures/utils/selector/pseudo-is/source.vue
new file mode 100644
index 00000000..944038e6
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-is/source.vue
@@ -0,0 +1,17 @@
+<!-- :where(header, main, footer) p -->
+<template>
+  <header class="a">
+    <p class="b">This is my header paragraph</p>
+  </header>
+
+  <main class="c">
+    <ul class="d">
+      <li class="e"><p class="f">This is my first</p><p class="g">list item</p></li>
+      <li class="h"><p class="i">This is my second</p><p class="j">list item</p></li>
+    </ul>
+  </main>
+
+  <footer class="k">
+    <p class="l">This is my footer paragraph</p>
+  </footer>
+</template>
diff --git a/tests/fixtures/utils/selector/pseudo-last-child/result.json b/tests/fixtures/utils/selector/pseudo-last-child/result.json
new file mode 100644
index 00000000..39202780
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-last-child/result.json
@@ -0,0 +1,8 @@
+{
+    "selector": "ul li:last-child",
+    "matches": [
+        "<li class=\"d\">",
+        "<li class=\"h\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/pseudo-last-child/source.vue b/tests/fixtures/utils/selector/pseudo-last-child/source.vue
new file mode 100644
index 00000000..04110e17
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-last-child/source.vue
@@ -0,0 +1,14 @@
+<!-- ul li:last-child -->
+<template>
+  <ul class="a">
+    <li class="b">Item 1</li>
+    <li class="c">Item 2</li>
+    <li class="d">Item 3
+      <ul class="e">
+        <li class="f">Item 3.1</li>
+        <li class="g">Item 3.2</li>
+        <li class="h">Item 3.3</li>
+      </ul>
+    </li>
+  </ul>
+</template>
diff --git a/tests/fixtures/utils/selector/pseudo-last-of-type01/result.json b/tests/fixtures/utils/selector/pseudo-last-of-type01/result.json
new file mode 100644
index 00000000..e566e599
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-last-of-type01/result.json
@@ -0,0 +1,7 @@
+{
+    "selector": "p:last-of-type",
+    "matches": [
+        "<p class=\"c\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/pseudo-last-of-type01/source.vue b/tests/fixtures/utils/selector/pseudo-last-of-type01/source.vue
new file mode 100644
index 00000000..eab09787
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-last-of-type01/source.vue
@@ -0,0 +1,8 @@
+<!-- p:last-of-type -->
+<template>
+  <div>
+    <h2 class="a">Heading</h2>
+    <p class="b">Paragraph 1</p>
+    <p class="c">Paragraph 2</p>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/pseudo-last-of-type02/result.json b/tests/fixtures/utils/selector/pseudo-last-of-type02/result.json
new file mode 100644
index 00000000..b23c5b68
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-last-of-type02/result.json
@@ -0,0 +1,8 @@
+{
+    "selector": ".root > :last-of-type",
+    "matches": [
+        "<h2 class=\"a\">",
+        "<p class=\"c\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/pseudo-last-of-type02/source.vue b/tests/fixtures/utils/selector/pseudo-last-of-type02/source.vue
new file mode 100644
index 00000000..06e2e0f5
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-last-of-type02/source.vue
@@ -0,0 +1,8 @@
+<!-- .root > :last-of-type -->
+<template>
+  <div class="root">
+    <h2 class="a">Heading</h2>
+    <p class="b">Paragraph 1</p>
+    <p class="c">Paragraph 2</p>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/pseudo-not/result.json b/tests/fixtures/utils/selector/pseudo-not/result.json
new file mode 100644
index 00000000..42e685b4
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-not/result.json
@@ -0,0 +1,9 @@
+{
+    "selector": ":not(.foo)",
+    "matches": [
+        "<template>",
+        "<div class=\"f\">",
+        "<p class=\"bar\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/pseudo-not/source.vue b/tests/fixtures/utils/selector/pseudo-not/source.vue
new file mode 100644
index 00000000..3c282fda
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-not/source.vue
@@ -0,0 +1,8 @@
+<!-- :not(.foo) -->
+<template>
+  <div class="foo">
+    <div class="f"></div>
+    <p class="bar"></p>
+    <input class="foo bar">
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/pseudo-nth-child01/result.json b/tests/fixtures/utils/selector/pseudo-nth-child01/result.json
new file mode 100644
index 00000000..be67bd4a
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-nth-child01/result.json
@@ -0,0 +1,11 @@
+{
+    "selector": "span:nth-child(2n+1)",
+    "matches": [
+        "<span class=\"x1\">",
+        "<span class=\"x3\">",
+        "<span class=\"x5\">",
+        "<span class=\"x7\">",
+        "<span class=\"x9\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/pseudo-nth-child01/source.vue b/tests/fixtures/utils/selector/pseudo-nth-child01/source.vue
new file mode 100644
index 00000000..18ed75b7
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-nth-child01/source.vue
@@ -0,0 +1,14 @@
+<!-- span:nth-child(2n+1) -->
+<template>
+  <div>
+    <span class="x1"></span>
+    <span class="x2"></span>
+    <span class="x3"></span>
+    <span class="x4"></span>
+    <span class="x5"></span>
+    <span class="x6"></span>
+    <span class="x7"></span>
+    <span class="x8"></span>
+    <span class="x9"></span>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/pseudo-nth-child02/result.json b/tests/fixtures/utils/selector/pseudo-nth-child02/result.json
new file mode 100644
index 00000000..8bddde0f
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-nth-child02/result.json
@@ -0,0 +1,8 @@
+{
+    "selector": ".foo:nth-child(odd)",
+    "matches": [
+        "<div class=\"foo a\">",
+        "<div class=\"foo c\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/pseudo-nth-child02/source.vue b/tests/fixtures/utils/selector/pseudo-nth-child02/source.vue
new file mode 100644
index 00000000..9533e330
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-nth-child02/source.vue
@@ -0,0 +1,14 @@
+<!-- .foo:nth-child(odd) -->
+<template>
+  <div>
+    <div class="foo a"></div>
+    <div class="bar a"></div>
+    <div class="baz a"></div>
+    <div class="foo b"></div>
+    <div class="bar b"></div>
+    <div class="baz b"></div>
+    <div class="foo c"></div>
+    <div class="bar c"></div>
+    <div class="baz c"></div>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/pseudo-nth-child03/result.json b/tests/fixtures/utils/selector/pseudo-nth-child03/result.json
new file mode 100644
index 00000000..d17057d8
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-nth-child03/result.json
@@ -0,0 +1,7 @@
+{
+    "selector": ".foo:nth-child(even)",
+    "matches": [
+        "<div class=\"foo b\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/pseudo-nth-child03/source.vue b/tests/fixtures/utils/selector/pseudo-nth-child03/source.vue
new file mode 100644
index 00000000..a1814b8d
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-nth-child03/source.vue
@@ -0,0 +1,14 @@
+<!-- .foo:nth-child(even) -->
+<template>
+  <div>
+    <div class="foo a"></div>
+    <div class="bar a"></div>
+    <div class="baz a"></div>
+    <div class="foo b"></div>
+    <div class="bar b"></div>
+    <div class="baz b"></div>
+    <div class="foo c"></div>
+    <div class="bar c"></div>
+    <div class="baz c"></div>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/pseudo-nth-last-child01/result.json b/tests/fixtures/utils/selector/pseudo-nth-last-child01/result.json
new file mode 100644
index 00000000..fecc3d71
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-nth-last-child01/result.json
@@ -0,0 +1,9 @@
+{
+    "selector": "tr:nth-last-child(-n+3)",
+    "matches": [
+        "<tr class=\"tr3\">",
+        "<tr class=\"tr4\">",
+        "<tr class=\"tr5\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/pseudo-nth-last-child01/source.vue b/tests/fixtures/utils/selector/pseudo-nth-last-child01/source.vue
new file mode 100644
index 00000000..d812f4cd
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-nth-last-child01/source.vue
@@ -0,0 +1,21 @@
+<!-- tr:nth-last-child(-n+3) -->
+<template><table>
+  <tbody>
+    <tr class="tr1">
+      <td>First line</td>
+    </tr>
+    <tr class="tr2">
+      <td>Second line</td>
+    </tr>
+    <tr class="tr3">
+      <td>Third line</td>
+    </tr>
+    <tr class="tr4">
+      <td>Fourth line</td>
+    </tr>
+    <tr class="tr5">
+      <td>Fifth line</td>
+    </tr>
+  </tbody>
+</table>
+</template>
diff --git a/tests/fixtures/utils/selector/pseudo-nth-last-child02/result.json b/tests/fixtures/utils/selector/pseudo-nth-last-child02/result.json
new file mode 100644
index 00000000..056a0cef
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-nth-last-child02/result.json
@@ -0,0 +1,10 @@
+{
+    "selector": "tr:nth-last-child(n+2)",
+    "matches": [
+        "<tr class=\"tr1\">",
+        "<tr class=\"tr2\">",
+        "<tr class=\"tr3\">",
+        "<tr class=\"tr4\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/pseudo-nth-last-child02/source.vue b/tests/fixtures/utils/selector/pseudo-nth-last-child02/source.vue
new file mode 100644
index 00000000..d1dd955a
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-nth-last-child02/source.vue
@@ -0,0 +1,21 @@
+<!-- tr:nth-last-child(n+2) -->
+<template><table>
+  <tbody>
+    <tr class="tr1">
+      <td>First line</td>
+    </tr>
+    <tr class="tr2">
+      <td>Second line</td>
+    </tr>
+    <tr class="tr3">
+      <td>Third line</td>
+    </tr>
+    <tr class="tr4">
+      <td>Fourth line</td>
+    </tr>
+    <tr class="tr5">
+      <td>Fifth line</td>
+    </tr>
+  </tbody>
+</table>
+</template>
diff --git a/tests/fixtures/utils/selector/pseudo-nth-last-child03/result.json b/tests/fixtures/utils/selector/pseudo-nth-last-child03/result.json
new file mode 100644
index 00000000..2689bca7
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-nth-last-child03/result.json
@@ -0,0 +1,7 @@
+{
+    "selector": "tr:nth-last-child(2)",
+    "matches": [
+        "<tr class=\"tr4\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/pseudo-nth-last-child03/source.vue b/tests/fixtures/utils/selector/pseudo-nth-last-child03/source.vue
new file mode 100644
index 00000000..2ed0c08e
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-nth-last-child03/source.vue
@@ -0,0 +1,21 @@
+<!-- tr:nth-last-child(2) -->
+<template><table>
+  <tbody>
+    <tr class="tr1">
+      <td>First line</td>
+    </tr>
+    <tr class="tr2">
+      <td>Second line</td>
+    </tr>
+    <tr class="tr3">
+      <td>Third line</td>
+    </tr>
+    <tr class="tr4">
+      <td>Fourth line</td>
+    </tr>
+    <tr class="tr5">
+      <td>Fifth line</td>
+    </tr>
+  </tbody>
+</table>
+</template>
diff --git a/tests/fixtures/utils/selector/pseudo-nth-last-of-type01/result.json b/tests/fixtures/utils/selector/pseudo-nth-last-of-type01/result.json
new file mode 100644
index 00000000..5551832f
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-nth-last-of-type01/result.json
@@ -0,0 +1,7 @@
+{
+    "selector": "span:nth-last-of-type(2)",
+    "matches": [
+        "<span class=\"e\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/pseudo-nth-last-of-type01/source.vue b/tests/fixtures/utils/selector/pseudo-nth-last-of-type01/source.vue
new file mode 100644
index 00000000..f70494be
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-nth-last-of-type01/source.vue
@@ -0,0 +1,11 @@
+<!-- span:nth-last-of-type(2) -->
+<template>
+  <div class="a">
+    <span class="b">This is a span.</span>
+    <span class="c">This is another span.</span>
+    <em class="d">This is emphasized.</em>
+    <span class="e">Wow, this span gets limed!!!</span>
+    <del class="f">This is struck through.</del>
+    <span class="g">Here is one last span.</span>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/pseudo-nth-last-of-type02/result.json b/tests/fixtures/utils/selector/pseudo-nth-last-of-type02/result.json
new file mode 100644
index 00000000..9562919e
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-nth-last-of-type02/result.json
@@ -0,0 +1,7 @@
+{
+    "selector": ":nth-last-of-type(2)",
+    "matches": [
+        "<span class=\"e\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/pseudo-nth-last-of-type02/source.vue b/tests/fixtures/utils/selector/pseudo-nth-last-of-type02/source.vue
new file mode 100644
index 00000000..7f97ff7d
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-nth-last-of-type02/source.vue
@@ -0,0 +1,11 @@
+<!-- :nth-last-of-type(2) -->
+<template>
+  <div class="a">
+    <span class="b">This is a span.</span>
+    <span class="c">This is another span.</span>
+    <em class="d">This is emphasized.</em>
+    <span class="e">Wow, this span gets limed!!!</span>
+    <del class="f">This is struck through.</del>
+    <span class="g">Here is one last span.</span>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/pseudo-nth-of-type01/result.json b/tests/fixtures/utils/selector/pseudo-nth-of-type01/result.json
new file mode 100644
index 00000000..95f6d860
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-nth-of-type01/result.json
@@ -0,0 +1,8 @@
+{
+    "selector": "p:nth-of-type(2n+1)",
+    "matches": [
+        "<p class=\"c\">",
+        "<p class=\"fancy f\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/pseudo-nth-of-type01/source.vue b/tests/fixtures/utils/selector/pseudo-nth-of-type01/source.vue
new file mode 100644
index 00000000..292700ec
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-nth-of-type01/source.vue
@@ -0,0 +1,11 @@
+<!-- p:nth-of-type(2n+1) -->
+<template>
+  <div class="a">
+    <div class="b">This element isn't counted.</div>
+    <p class="c">1st paragraph.</p>
+    <p class="fancy d">2nd paragraph.</p>
+    <div class="e">This element isn't counted.</div>
+    <p class="fancy f">3rd paragraph.</p>
+    <p class="g">4th paragraph.</p>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/pseudo-nth-of-type02/result.json b/tests/fixtures/utils/selector/pseudo-nth-of-type02/result.json
new file mode 100644
index 00000000..3a601673
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-nth-of-type02/result.json
@@ -0,0 +1,8 @@
+{
+    "selector": "p:nth-of-type(2n)",
+    "matches": [
+        "<p class=\"fancy d\">",
+        "<p class=\"g\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/pseudo-nth-of-type02/source.vue b/tests/fixtures/utils/selector/pseudo-nth-of-type02/source.vue
new file mode 100644
index 00000000..27a68245
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-nth-of-type02/source.vue
@@ -0,0 +1,11 @@
+<!-- p:nth-of-type(2n) -->
+<template>
+  <div class="a">
+    <div class="b">This element isn't counted.</div>
+    <p class="c">1st paragraph.</p>
+    <p class="fancy d">2nd paragraph.</p>
+    <div class="e">This element isn't counted.</div>
+    <p class="fancy f">3rd paragraph.</p>
+    <p class="g">4th paragraph.</p>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/pseudo-nth-of-type03/result.json b/tests/fixtures/utils/selector/pseudo-nth-of-type03/result.json
new file mode 100644
index 00000000..3ff49b5a
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-nth-of-type03/result.json
@@ -0,0 +1,7 @@
+{
+    "selector": "p.fancy:nth-of-type(2n+1)",
+    "matches": [
+        "<p class=\"fancy f\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/pseudo-nth-of-type03/source.vue b/tests/fixtures/utils/selector/pseudo-nth-of-type03/source.vue
new file mode 100644
index 00000000..d2c88382
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-nth-of-type03/source.vue
@@ -0,0 +1,11 @@
+<!-- p.fancy:nth-of-type(2n+1) -->
+<template>
+  <div class="a">
+    <div class="b">This element isn't counted.</div>
+    <p class="c">1st paragraph.</p>
+    <p class="fancy d">2nd paragraph.</p>
+    <div class="e">This element isn't counted.</div>
+    <p class="fancy f">3rd paragraph.</p>
+    <p class="g">4th paragraph.</p>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/pseudo-nth-of-type04/result.json b/tests/fixtures/utils/selector/pseudo-nth-of-type04/result.json
new file mode 100644
index 00000000..8570c4cc
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-nth-of-type04/result.json
@@ -0,0 +1,9 @@
+{
+    "selector": ":nth-of-type(2n)",
+    "matches": [
+        "<p class=\"fancy d\">",
+        "<div class=\"e\">",
+        "<p class=\"g\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/pseudo-nth-of-type04/source.vue b/tests/fixtures/utils/selector/pseudo-nth-of-type04/source.vue
new file mode 100644
index 00000000..e8837158
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-nth-of-type04/source.vue
@@ -0,0 +1,11 @@
+<!-- :nth-of-type(2n) -->
+<template>
+  <div class="a">
+    <div class="b">This element isn't counted.</div>
+    <p class="c">1st paragraph.</p>
+    <p class="fancy d">2nd paragraph.</p>
+    <div class="e">This element isn't counted.</div>
+    <p class="fancy f">3rd paragraph.</p>
+    <p class="g">4th paragraph.</p>
+  </div>
+</template>
diff --git a/tests/fixtures/utils/selector/pseudo-only-child/result.json b/tests/fixtures/utils/selector/pseudo-only-child/result.json
new file mode 100644
index 00000000..b70fc9b1
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-only-child/result.json
@@ -0,0 +1,7 @@
+{
+    "selector": "li:only-child",
+    "matches": [
+        "<li class=\"d\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/pseudo-only-child/source.vue b/tests/fixtures/utils/selector/pseudo-only-child/source.vue
new file mode 100644
index 00000000..ccf73b80
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-only-child/source.vue
@@ -0,0 +1,17 @@
+<!-- li:only-child -->
+<template>
+  <ol class="a">
+    <li class="b">First
+      <ul class="c">
+        <li class="d">This list has just one element.</li>
+      </ul>
+    </li>
+    <li class="e">Second
+      <ul class="f">
+        <li class="g">This list has three elements.</li>
+        <li class="h">This list has three elements.</li>
+        <li class="i">This list has three elements.</li>
+      </ul>
+    </li>
+  </ol>
+</template>
diff --git a/tests/fixtures/utils/selector/pseudo-only-of-type/result.json b/tests/fixtures/utils/selector/pseudo-only-of-type/result.json
new file mode 100644
index 00000000..330fcd4b
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-only-of-type/result.json
@@ -0,0 +1,8 @@
+{
+    "selector": "main :only-of-type",
+    "matches": [
+        "<p class=\"b\">",
+        "<i class=\"e\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/pseudo-only-of-type/source.vue b/tests/fixtures/utils/selector/pseudo-only-of-type/source.vue
new file mode 100644
index 00000000..6a356941
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-only-of-type/source.vue
@@ -0,0 +1,13 @@
+<!-- main :only-of-type -->
+<template>
+  <main class="root">
+    <div class="a">I am `div` #1.</div>
+    <p class="b">I am the only `p` among my siblings.</p>
+    <div class="c">I am `div` #2.</div>
+    <div class="d">I am `div` #3.
+      <i class="e">I am the only `i` child.</i>
+      <em class="f">I am `em` #1.</em>
+      <em class="g">I am `em` #2.</em>
+    </div>
+  </main>
+</template>
diff --git a/tests/fixtures/utils/selector/pseudo-where/result.json b/tests/fixtures/utils/selector/pseudo-where/result.json
new file mode 100644
index 00000000..f7a3daad
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-where/result.json
@@ -0,0 +1,12 @@
+{
+    "selector": ":is(header, main, footer) p",
+    "matches": [
+        "<p class=\"b\">",
+        "<p class=\"f\">",
+        "<p class=\"g\">",
+        "<p class=\"i\">",
+        "<p class=\"j\">",
+        "<p class=\"l\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/pseudo-where/source.vue b/tests/fixtures/utils/selector/pseudo-where/source.vue
new file mode 100644
index 00000000..f2147e21
--- /dev/null
+++ b/tests/fixtures/utils/selector/pseudo-where/source.vue
@@ -0,0 +1,17 @@
+<!-- :is(header, main, footer) p -->
+<template>
+  <header class="a">
+    <p class="b">This is my header paragraph</p>
+  </header>
+
+  <main class="c">
+    <ul class="d">
+      <li class="e"><p class="f">This is my first</p><p class="g">list item</p></li>
+      <li class="h"><p class="i">This is my second</p><p class="j">list item</p></li>
+    </ul>
+  </main>
+
+  <footer class="k">
+    <p class="l">This is my footer paragraph</p>
+  </footer>
+</template>
diff --git a/tests/fixtures/utils/selector/tag01/result.json b/tests/fixtures/utils/selector/tag01/result.json
new file mode 100644
index 00000000..a92bea02
--- /dev/null
+++ b/tests/fixtures/utils/selector/tag01/result.json
@@ -0,0 +1,9 @@
+{
+    "selector": "div",
+    "matches": [
+        "<div id=\"1\">",
+        "<div id=\"2\">",
+        "<div class=\"block\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/tag01/source.vue b/tests/fixtures/utils/selector/tag01/source.vue
new file mode 100644
index 00000000..695f8978
--- /dev/null
+++ b/tests/fixtures/utils/selector/tag01/source.vue
@@ -0,0 +1,9 @@
+<!-- div -->
+<template>
+  <div id="1">
+    <div id="2"></div>
+    <p id="3"></p>
+    <input id="4">
+  </div>
+</template>
+<div class="block"></div>
diff --git a/tests/fixtures/utils/selector/tag02/result.json b/tests/fixtures/utils/selector/tag02/result.json
new file mode 100644
index 00000000..be68df38
--- /dev/null
+++ b/tests/fixtures/utils/selector/tag02/result.json
@@ -0,0 +1,7 @@
+{
+    "selector": "p",
+    "matches": [
+        "<p id=\"3\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/tag02/source.vue b/tests/fixtures/utils/selector/tag02/source.vue
new file mode 100644
index 00000000..43985e61
--- /dev/null
+++ b/tests/fixtures/utils/selector/tag02/source.vue
@@ -0,0 +1,9 @@
+<!-- p -->
+<template>
+  <div id="1">
+    <div id="2"></div>
+    <p id="3"></p>
+    <input id="4">
+  </div>
+</template>
+<div class="block"></div>
diff --git a/tests/fixtures/utils/selector/universal/result.json b/tests/fixtures/utils/selector/universal/result.json
new file mode 100644
index 00000000..d679697d
--- /dev/null
+++ b/tests/fixtures/utils/selector/universal/result.json
@@ -0,0 +1,12 @@
+{
+    "selector": "*",
+    "matches": [
+        "<template>",
+        "<div id=\"1\">",
+        "<div id=\"foo\">",
+        "<p id=\"3\">",
+        "<input id=\"4\">",
+        "<div class=\"block\">"
+    ],
+    "errors": []
+}
\ No newline at end of file
diff --git a/tests/fixtures/utils/selector/universal/source.vue b/tests/fixtures/utils/selector/universal/source.vue
new file mode 100644
index 00000000..edc4361d
--- /dev/null
+++ b/tests/fixtures/utils/selector/universal/source.vue
@@ -0,0 +1,9 @@
+<!-- * -->
+<template>
+  <div id="1">
+    <div id="foo"></div>
+    <p id="3"></p>
+    <input id="4">
+  </div>
+</template>
+<div class="block"></div>
diff --git a/tests/lib/rules/attribute-hyphenation.js b/tests/lib/rules/attribute-hyphenation.js
index 7e7f2ea9..26f9531c 100644
--- a/tests/lib/rules/attribute-hyphenation.js
+++ b/tests/lib/rules/attribute-hyphenation.js
@@ -56,6 +56,16 @@ ruleTester.run('attribute-hyphenation', rule, {
       filename: 'test.vue',
       code: '<template><my-component :[foo-bar]></my-component></template>',
       options: ['never']
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div><slot my-prop></slot></div></template>',
+      options: ['always']
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div><slot myProp></slot></div></template>',
+      options: ['never']
     }
   ],
 
@@ -252,6 +262,32 @@ ruleTester.run('attribute-hyphenation', rule, {
           line: 3
         }
       ]
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div><slot my-prop="foo"></slot></div></template>',
+      output: '<template><div><slot myProp="foo"></slot></div></template>',
+      options: ['never'],
+      errors: [
+        {
+          message: "Attribute 'my-prop' can't be hyphenated.",
+          type: 'VIdentifier',
+          line: 1
+        }
+      ]
+    },
+    {
+      filename: 'test.vue',
+      code: '<template><div><slot MyProp="Bar"></slot></div></template>',
+      output: '<template><div><slot my-prop="Bar"></slot></div></template>',
+      options: ['always'],
+      errors: [
+        {
+          message: "Attribute 'MyProp' must be hyphenated.",
+          type: 'VIdentifier',
+          line: 1
+        }
+      ]
     }
   ]
 })
diff --git a/tests/lib/rules/component-tags-order.js b/tests/lib/rules/component-tags-order.js
index 1992d6fd..94ba7a1b 100644
--- a/tests/lib/rules/component-tags-order.js
+++ b/tests/lib/rules/component-tags-order.js
@@ -50,6 +50,8 @@ tester.run('component-tags-order', rule, {
     '<template></template><script></script><docs></docs><style></style>',
     '<script></script><template></template>',
     '<template></template><script></script>',
+    '<script setup></script><script></script>',
+    '<docs></docs><template></template><script></script><script setup></script><style></style>',
     `
       <template>
       </template>
@@ -102,11 +104,95 @@ tester.run('component-tags-order', rule, {
       output: null,
       options: [{ order: ['docs', 'script', 'template', 'style'] }]
     },
+    {
+      code: '<script setup></script><script></script><template></template><style></style>',
+      output: null,
+      options: [
+        { order: ['script[setup]', 'script:not([setup])', 'template', 'style'] }
+      ]
+    },
+    {
+      code: '<template></template><script setup></script><script></script><style></style>',
+      output: null,
+      options: [
+        {
+          order: [['script[setup]', 'script:not([setup])', 'template'], 'style']
+        }
+      ]
+    },
+    {
+      code: '<script></script><script setup></script><template></template><style></style>',
+      output: null,
+      options: [{ order: ['script', 'template', 'style'] }]
+    },
+    {
+      code: '<template></template><script></script><script setup></script><style></style>',
+      output: null,
+      options: [{ order: [['script', 'template'], 'style'] }]
+    },
+    {
+      code: '<script></script><script setup></script><template></template><style></style>',
+      output: null,
+      options: [
+        { order: ['script:not([setup])', 'script[setup]', 'template', 'style'] }
+      ]
+    },
+    {
+      code: '<template></template><script></script><script setup></script><style></style>',
+      output: null,
+      options: [
+        {
+          order: [['script:not([setup])', 'script[setup]', 'template'], 'style']
+        }
+      ]
+    },
+    {
+      code: '<template></template><script></script><script setup></script><style scoped></style><style></style><i18n locale="ja"></i18n><i18n locale="en"></i18n>',
+      output: null,
+      options: [
+        {
+          order: [
+            ['script:not([setup])', 'script[setup]', 'template'],
+            'style[scoped]',
+            'style:not([scoped])',
+            'i18n:not([locale=en])',
+            'i18n:not([locale=ja])'
+          ]
+        }
+      ]
+    },
+    {
+      code: '<template></template><script></script><script setup></script><style scoped></style><style></style><i18n locale="en"></i18n><i18n locale="ja"></i18n>',
+      output: null,
+      options: [
+        {
+          order: [
+            'template',
+            'script:not([setup])',
+            'script[setup]',
+            'style[scoped]',
+            'style:not([scoped])',
+            'i18n[locale=en]',
+            'i18n[locale=ja]'
+          ]
+        }
+      ]
+    },
     {
       code: '<template></template><docs></docs><script></script><style></style>',
       output: null,
       options: [{ order: [['docs', 'script', 'template'], 'style'] }]
     },
+    {
+      code: '<i18n locale="en"></i18n><i18n locale="ja"></i18n>',
+      output: null,
+      options: [{ order: ['i18n[locale=en]', 'i18n[locale=ja]'] }]
+    },
+    {
+      code: '<style></style><style scoped></style>',
+      output: null,
+      options: [{ order: ['style:not([scoped])', 'style[scoped]'] }]
+    },
 
     `<script></script><style></style>`,
 
@@ -119,12 +205,12 @@ tester.run('component-tags-order', rule, {
       code: '<style></style><template></template><script></script>',
       errors: [
         {
-          message: 'The <template> should be above the <style> on line 1.',
+          message: "'<template>' should be above '<style>' on line 1.",
           line: 1,
           column: 16
         },
         {
-          message: 'The <script> should be above the <style> on line 1.',
+          message: "'<script>' should be above '<style>' on line 1.",
           line: 1,
           column: 37
         }
@@ -136,7 +222,7 @@ tester.run('component-tags-order', rule, {
       options: [{ order: ['script', 'template', 'style'] }],
       errors: [
         {
-          message: 'The <script> should be above the <template> on line 1.',
+          message: "'<script>' should be above '<template>' on line 1.",
           line: 1,
           column: 22
         }
@@ -152,7 +238,7 @@ tester.run('component-tags-order', rule, {
         <script></script>`,
       errors: [
         {
-          message: 'The <script> should be above the <style> on line 4.',
+          message: "'<script>' should be above '<style>' on line 4.",
           line: 6
         }
       ],
@@ -173,7 +259,7 @@ tester.run('component-tags-order', rule, {
       options: [{ order: ['script', 'template', 'style'] }],
       errors: [
         {
-          message: 'The <script> should be above the <template> on line 2.',
+          message: "'<script>' should be above '<template>' on line 2.",
           line: 3
         }
       ],
@@ -193,7 +279,7 @@ tester.run('component-tags-order', rule, {
       options: [{ order: ['template', 'script', 'style'] }],
       errors: [
         {
-          message: 'The <template> should be above the <script> on line 2.',
+          message: "'<template>' should be above '<script>' on line 2.",
           line: 3
         }
       ],
@@ -214,7 +300,7 @@ tester.run('component-tags-order', rule, {
       options: [{ order: ['docs', 'template', 'script', 'style'] }],
       errors: [
         {
-          message: 'The <docs> should be above the <template> on line 2.',
+          message: "'<docs>' should be above '<template>' on line 2.",
           line: 3
         }
       ],
@@ -236,7 +322,7 @@ tester.run('component-tags-order', rule, {
       options: [{ order: ['script', 'template', 'style'] }],
       errors: [
         {
-          message: 'The <script> should be above the <template> on line 2.',
+          message: "'<script>' should be above '<template>' on line 2.",
           line: 4
         }
       ],
@@ -259,7 +345,7 @@ tester.run('component-tags-order', rule, {
       options: [{ order: ['script', 'template', 'style'] }],
       errors: [
         {
-          message: 'The <script> should be above the <template> on line 2.',
+          message: "'<script>' should be above '<template>' on line 2.",
           line: 5
         }
       ],
@@ -280,7 +366,7 @@ tester.run('component-tags-order', rule, {
       options: [{ order: ['template', 'script'] }],
       errors: [
         {
-          message: 'The <template> should be above the <script> on line 2.',
+          message: "'<template>' should be above '<script>' on line 2.",
           line: 3
         }
       ],
@@ -295,11 +381,11 @@ tester.run('component-tags-order', rule, {
       `,
       errors: [
         {
-          message: 'The <template> should be above the <style> on line 2.',
+          message: "'<template>' should be above '<style>' on line 2.",
           line: 3
         },
         {
-          message: 'The <script> should be above the <style> on line 2.',
+          message: "'<script>' should be above '<style>' on line 2.",
           line: 4
         }
       ],
@@ -319,11 +405,11 @@ tester.run('component-tags-order', rule, {
       `,
       errors: [
         {
-          message: 'The <template> should be above the <style> on line 2.',
+          message: "'<template>' should be above '<style>' on line 2.",
           line: 4
         },
         {
-          message: 'The <script> should be above the <style> on line 2.',
+          message: "'<script>' should be above '<style>' on line 2.",
           line: 5
         }
       ],
@@ -343,11 +429,56 @@ tester.run('component-tags-order', rule, {
       `,
       errors: [
         {
-          message: 'The <script> should be above the <style> on line 2.',
+          message: "'<script>' should be above '<style>' on line 2.",
           line: 3
         }
       ],
       output: '\n        <script></script>\n        <style></style>\n      '
+    },
+    {
+      code: '<i18n locale="ja"></i18n><i18n locale="en"></i18n>',
+      output: '<i18n locale="en"></i18n><i18n locale="ja"></i18n>',
+      options: [{ order: ['i18n[locale=en]', 'i18n[locale=ja]'] }],
+      errors: [
+        {
+          message:
+            "'<i18n locale=en>' should be above '<i18n locale=ja>' on line 1.",
+          line: 1
+        }
+      ]
+    },
+    {
+      code: '<style scoped></style><style></style>',
+      output: '<style></style><style scoped></style>',
+      options: [{ order: ['style:not([scoped])', 'style[scoped]'] }],
+      errors: [
+        {
+          message: "'<style>' should be above '<style scoped>' on line 1.",
+          line: 1
+        }
+      ]
+    },
+    {
+      code: '<style></style><style scoped></style>',
+      output: '<style scoped></style><style></style>',
+      options: [{ order: ['style[scoped]', 'style:not([scoped])'] }],
+      errors: [
+        {
+          message: "'<style scoped>' should be above '<style>' on line 1.",
+          line: 1
+        }
+      ]
+    },
+    {
+      code: '<style></style><script></script>',
+      output: '<script></script><style></style>',
+      options: [{ order: ['script:not([scoped])', 'style:not([scoped])'] }],
+      errors: [
+        {
+          message: "'<script>' should be above '<style>' on line 1.",
+          line: 1
+        }
+      ]
     }
   ]
 })
diff --git a/tests/lib/rules/match-component-import-name.js b/tests/lib/rules/match-component-import-name.js
new file mode 100644
index 00000000..a05bfec1
--- /dev/null
+++ b/tests/lib/rules/match-component-import-name.js
@@ -0,0 +1,73 @@
+/**
+ * @author Doug Wade
+ * See LICENSE file in root directory for full license.
+ */
+'use strict'
+
+const RuleTester = require('eslint').RuleTester
+const rule = require('../../../lib/rules/match-component-import-name')
+
+const tester = new RuleTester({
+  parser: require.resolve('vue-eslint-parser'),
+  parserOptions: {
+    ecmaVersion: 2020,
+    sourceType: 'module'
+  }
+})
+
+tester.run('match-component-import-name', rule, {
+  valid: [
+    {
+      filename: 'test.vue',
+      code: `
+      <script> export default { components: { ValidImport } } </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script> export default { components: { ValidImport: ValidImport } } </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script> export default { components: { 'valid-import': ValidImport } } </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script> export default { components: { ValidImport, ...SpreadImport } } </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script> export default { components: { 'valid-import': ValidImport, [computedImport]: ComputedImport } } </script>
+      `
+    },
+    {
+      filename: 'test.vue',
+      code: `
+      <script> export default { components: { ValidImport, [differentComputedImport]: ComputedImport } } </script>
+      `
+    }
+  ],
+  invalid: [
+    {
+      filename: 'test.vue',
+      code: `
+      <script> export default { components: { InvalidExport: SomeRandomName } } </script>
+      `,
+      errors: [
+        {
+          message:
+            'Component alias InvalidExport should be one of: SomeRandomName, some-random-name.',
+          line: 2,
+          column: 47
+        }
+      ]
+    }
+  ]
+})
diff --git a/tests/lib/utils/selector.js b/tests/lib/utils/selector.js
new file mode 100644
index 00000000..6677a9fa
--- /dev/null
+++ b/tests/lib/utils/selector.js
@@ -0,0 +1,88 @@
+'use strict'
+
+const fs = require('fs')
+const path = require('path')
+const assert = require('assert')
+
+const Linter = require('eslint').Linter
+
+const selector = require('../../../lib/utils/selector')
+const utils = require('../../../lib/utils')
+
+const FIXTURE_ROOT = path.resolve(__dirname, '../../fixtures/utils/selector')
+
+/**
+ * Load test patterns from fixtures.
+ *
+ * @returns {object} The loaded patterns.
+ */
+function loadPatterns() {
+  return fs.readdirSync(FIXTURE_ROOT).map((name) => {
+    const code0 = fs.readFileSync(
+      path.join(FIXTURE_ROOT, name, 'source.vue'),
+      'utf8'
+    )
+    const code = code0.replace(/^<!--(.+?)-->/, `<!--${name}-->`)
+    const inputSelector = /^<!--(.+?)-->/.exec(code0)[1].trim()
+    return { code, name, inputSelector }
+  })
+}
+
+function extractElements(code, inputSelector) {
+  const linter = new Linter()
+  const matches = []
+
+  linter.defineRule('vue/selector-test', (context) => {
+    const parsed = selector.parseSelector(inputSelector, context)
+    return utils.defineDocumentVisitor(context, {
+      VElement(node) {
+        if (parsed.test(node)) {
+          matches.push(
+            context.getSourceCode().text.slice(...node.startTag.range)
+          )
+        }
+      }
+    })
+  })
+  linter.defineParser('vue-eslint-parser', require('vue-eslint-parser'))
+  const messages = linter.verify(
+    code,
+    {
+      parser: 'vue-eslint-parser',
+      parserOptions: { ecmaVersion: 2018 },
+      rules: { 'vue/selector-test': 'error' }
+    },
+    undefined,
+    true
+  )
+
+  return {
+    selector: inputSelector,
+    matches,
+    errors: messages.map((message) => message.message)
+  }
+}
+
+describe('parseSelector()', () => {
+  for (const { name, code, inputSelector } of loadPatterns()) {
+    describe(`'test/fixtures/utils/selector/${name}/source.vue'`, () => {
+      it('should to parse the selector to match the valid elements.', () => {
+        const elements = extractElements(code, inputSelector)
+        const actual = JSON.stringify(elements, null, 4)
+
+        // update fixture
+        // fs.writeFileSync(
+        //   path.join(FIXTURE_ROOT, name, 'result.json'),
+        //   actual,
+        //   'utf8'
+        // )
+
+        const expected = fs.readFileSync(
+          path.join(FIXTURE_ROOT, name, 'result.json'),
+          'utf8'
+        )
+        assert.strictEqual(actual, expected)
+      })
+    })
+  }
+})