Repository: facelessuser/soupsieve

Issue:
User definable custom selectors
The idea of allowing user definable custom selectors is a cool idea that is currently in draft: https://drafts.csswg.org/css-extensions/#typedef-custom-selector.

The idea is essentially to allow create an custom pseudo-class as an alias for a more complex expression.

```css
@custom-selector :--heading h1, h2, h3, h4, h5, h6;

:--heading { /* styles for all headings */ }
:--heading + p { /* more styles */ }
```

There seems to be some open issues for a lot more complexity than what is shown here, but I think it may be reasonable to to allow stuff like this, and then a user could construct aliases for whatever they would like. That way we don't ever actually need to support such things directly ourselves.



Discussion:
facelessuser: I *think* I'm generally happy with the custom selector API right now.  I do need to decide if I'm going to call it "aliases" or "custom_selectors" though...
facelessuser: Decided to rename `aliases` parameter to `custom`.  Also renamed `Aliases` class to `Custom`.
facelessuser: I think this is ready for whenever we want to pull the trigger.

Patch:
diff --git a/docs/src/dictionary/en-custom.txt b/docs/src/dictionary/en-custom.txt
index 2c7f19f..b739fd3 100644
--- a/docs/src/dictionary/en-custom.txt
+++ b/docs/src/dictionary/en-custom.txt
@@ -18,6 +18,7 @@ Precompile
 PyPI
 PySpelling
 SVG
+TODO
 Tox
 URL's
 Unescape
@@ -31,6 +32,7 @@ builtin
 combinator
 combinators
 deprecations
+deregister
 directionality
 formatter
 hashable
diff --git a/docs/src/markdown/_snippets/links.txt b/docs/src/markdown/_snippets/links.txt
index 30d5347..cfb3004 100644
--- a/docs/src/markdown/_snippets/links.txt
+++ b/docs/src/markdown/_snippets/links.txt
@@ -1,5 +1,6 @@
 [aspell]: https://github.com/GNUAspell/aspell
 [bs4]: https://beautiful-soup-4.readthedocs.io/en/latest/#
+[custom-extensions-1]: https://drafts.csswg.org/css-extensions-1/
 [html5lib]: https://github.com/html5lib/html5lib-python
 [lxml]: https://github.com/lxml/lxml
 [registry]: https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry
diff --git a/docs/src/markdown/about/changelog.md b/docs/src/markdown/about/changelog.md
index 46675f7..bf628ca 100644
--- a/docs/src/markdown/about/changelog.md
+++ b/docs/src/markdown/about/changelog.md
@@ -1,9 +1,14 @@
 # Changelog
 
+## 1.8.0
+
+- **NEW**: Add custom selector support. (#92)
+- **FIX**: Small tweak to CSS identifier pattern to ensure it matches the CSS specification exactly.
+
 ## 1.7.3
 
 - **FIX**: Fix regression with tag names in regards to case sensitivity, and ensure there are tests to prevent breakage
-  in the future.
+in the future.
 - **FIX**: XHTML should always be case sensitive like XML.
 
 ## 1.7.2
@@ -11,9 +16,9 @@
 - **FIX**: Fix HTML detection `type` selector.
 - **FIX**: Fixes for `:enabled` and `:disabled`.
 - **FIX**: Provide a way for Beautiful Soup to parse selectors in a quirks mode to mimic some of the quirks of the old
-  select method prior to Soup Sieve, but with warnings. This is to help old scripts to not break during the transitional
-  period with newest Beautiful Soup. In the future, these quirks will raise an exception as Soup Sieve requires
-  selectors to follow the CSS specification.
+select method prior to Soup Sieve, but with warnings. This is to help old scripts to not break during the transitional
+period with newest Beautiful Soup. In the future, these quirks will raise an exception as Soup Sieve requires
+selectors to follow the CSS specification.
 
 ## 1.7.1
 
@@ -26,7 +31,7 @@
 - **NEW**: Add support for `:defined` selector. (#76)
 - **FIX**: Fix pickling issue when compiled selector contains a `NullSelector` object. (#70)
 - **FIX**: Better exception messages in the CSS selector parser and fix a position reporting issue that can occur in
-  some exceptions. (#72, #73)
+some exceptions. (#72, #73)
 - **FIX**: Don't compare prefixes when evaluating attribute namespaces, compare the actual namespace. (#75)
 - **FIX**: Split whitespace attribute lists by all whitespace characters, not just space.
 - **FIX**: `:nth-*` patterns were converting numbers to base 16 when they should have been converting to base 10.
@@ -68,20 +73,20 @@
 
 - **NEW**: Add support for `:scope`.
 - **NEW**: `:user-invalid`, `:playing`, `:paused`, and `:local-link` will not cause a failure, but all will match
-  nothing as their use cases are not possible in an environment outside a web browser.
+nothing as their use cases are not possible in an environment outside a web browser.
 - **FIX**: Fix `[attr~=value]` handling of whitespace. According to the spec, if the value contains whitespace, or is an
-  empty string, it should not match anything.
+empty string, it should not match anything.
 - **FIX**: Precompile internal patterns for pseudo-classes to prevent having to parse them again.
 
 ## 1.2.1
 
 - **FIX**: More descriptive exceptions. Exceptions will also now mention position in the pattern that is problematic.
 - **FIX**: `filter` ignores `NavigableString` objects in normal iterables and `Tag` iterables. Basically, it filters all
-  Beautiful Soup document parts regardless of iterable type where as it used to only filter out a `NavigableString` in a
-  `Tag` object. This is viewed as fixing an inconsistency.
+Beautiful Soup document parts regardless of iterable type where as it used to only filter out a `NavigableString` in a
+`Tag` object. This is viewed as fixing an inconsistency.
 - **FIX**: `DEBUG` flag has been added to help with debugging CSS selector parsing. This is mainly for development.
 - **FIX**: If forced to search for language in `meta` tag, and no language is found, cache that there is no language in
-  the `meta` tag to prevent searching again during the current select.
+the `meta` tag to prevent searching again during the current select.
 - **FIX**: If a non `BeautifulSoup`/`Tag` object is given to the API to compare against, raise a `TypeError`.
 
 ## 1.2.0
@@ -93,26 +98,26 @@
 
 - **NEW**: Adds support for `[attr!=value]` which is equivalent to `:not([attr=value])`.
 - **NEW**: Add support for `:active`, `:focus`, `:hover`, `:visited`, `:target`, `:focus-within`, `:focus-visible`,
-  `:target-within`, `:current()`/`:current`, `:past`, and `:future`, but they will never match as these states don't
-  exist
+`:target-within`, `:current()`/`:current`, `:past`, and `:future`, but they will never match as these states don't
+exist
 in the Soup Sieve environment.
 - **NEW**: Add support for `:checked`, `:enabled`, `:disabled`, `:required`, `:optional`, `:default`, and
-  `:placeholder-shown` which will only match in HTML documents as these concepts are not defined in XML.
+`:placeholder-shown` which will only match in HTML documents as these concepts are not defined in XML.
 - **NEW**: Add support for `:link` and `:any-link`, both of which will target all `<a>`, `<area>`, and `<link>` elements
-    with an `href` attribute as all links will be treated as unvisited in Soup Sieve.
+with an `href` attribute as all links will be treated as unvisited in Soup Sieve.
 - **NEW**: Add support for `:lang()` (CSS4) which works in XML and HTML.
 - **NEW**: Users must install Beautiful Soup themselves. This requirement is removed in the hopes that Beautiful Soup
-  may use this in the future.
+may use this in the future.
 - **FIX**: Attributes in the form `prefix:attr` can be matched with the form `[prefix\:attr]` without specifying a
-  namespaces if desired.
+namespaces if desired.
 - **FIX**: Fix exception when `[type]` is used (with no value).
 
 ## 1.0.2
 
 - **FIX**: Use proper CSS identifier patterns for tag names, classes, ids, etc. Things like `#3` or `#-3` should not
-  match and should require `#\33` or `#-\33`.
+match and should require `#\33` or `#-\33`.
 - **FIX**: Do not raise `NotImplementedError` for supported pseudo classes/elements with bad syntax, instead raise
-  `SyntaxError`.
+`SyntaxError`.
 
 ## 1.0.1
 
@@ -128,7 +133,7 @@ in the Soup Sieve environment.
 - **NEW**: Drop document flags. Document type can be detected from the Beautiful Soup object directly.
 - **FIX**: CSS selectors should be evaluated with CSS whitespace rules.
 - **FIX**: Processing instructions, CDATA, and declarations should all be ignored in `:contains` and child
-  considerations for `:empty`.
+considerations for `:empty`.
 - **FIX**: In Beautiful Soup, the document itself is the first tag. Do not match the "document" tag by returning false
   for any tag that doesn't have a parent.
 
diff --git a/docs/src/markdown/about/development.md b/docs/src/markdown/about/development.md
index af3a676..d6f969f 100644
--- a/docs/src/markdown/about/development.md
+++ b/docs/src/markdown/about/development.md
@@ -243,10 +243,10 @@ Attribute       | Description
 `lang`          | Contains a tuple of [`SelectorLang`](#selectorlang) objects.
 `flags`         | Selector flags that used to signal a type of selector is present.
 
-### `NullSelector`
+### `SelectorNull`
 
 ```py3
-class NullSelector:
+class SelectorNull:
     """Null Selector."""
 
     def __init__(self):
diff --git a/docs/src/markdown/api.md b/docs/src/markdown/api.md
index ae2f807..09fd224 100644
--- a/docs/src/markdown/api.md
+++ b/docs/src/markdown/api.md
@@ -44,7 +44,7 @@ but the parameter is provided for potential future use.
 ## `soupsieve.select_one()`
 
 ```py3
-def select(select, tag, namespaces=None, flags=0):
+def select(select, tag, namespaces=None, flags=0, **kwargs):
     """Select the specified tags."""
 ```
 
@@ -63,7 +63,7 @@ dictionary, and `flags`.
 ## `soupsieve.select()`
 
 ```py3
-def select(select, tag, namespaces=None, limit=0, flags=0):
+def select(select, tag, namespaces=None, limit=0, flags=0, **kwargs):
     """Select the specified tags."""
 ```
 
@@ -82,7 +82,7 @@ a `limit`, and `flags`.
 ## `soupsieve.iselect()`
 
 ```py3
-def iselect(select, node, namespaces=None, limit=0, flags=0):
+def iselect(select, node, namespaces=None, limit=0, flags=0, **kwargs):
     """Select the specified tags."""
 ```
 
@@ -91,7 +91,7 @@ def iselect(select, node, namespaces=None, limit=0, flags=0):
 ## `soupsieve.closest()`
 
 ```py3
-def closest(select, tag, namespaces=None, flags=0):
+def closest(select, tag, namespaces=None, flags=0, **kwargs):
     """Match closest ancestor to the provided tag."""
 ```
 
@@ -104,7 +104,7 @@ dictionary, and `flags`.
 ## `soupsieve.match()`
 
 ```py3
-def match(select, tag, namespaces=None, flags=0):
+def match(select, tag, namespaces=None, flags=0, **kwargs):
     """Match node."""
 ```
 
@@ -124,7 +124,7 @@ False
 ## `soupsieve.filter()`
 
 ```py3
-def filter(select, nodes, namespaces=None, flags=0):
+def filter(select, nodes, namespaces=None, flags=0, **kwargs):
     """Filter list of nodes."""
 ```
 
@@ -142,7 +142,7 @@ and flags.
 ## `soupsieve.comments()`
 
 ```
-def comments(tag, limit=0, flags=0):
+def comments(tag, limit=0, flags=0, **kwargs):
     """Get comments only."""
 ```
 
@@ -154,7 +154,7 @@ from the given tag down through all of its children.  You can limit how many com
 ## `soupsieve.icomments()`
 
 ```
-def icomments(node, limit=0, flags=0):
+def icomments(node, limit=0, flags=0, **kwargs):
     """Get comments only."""
 ```
 
@@ -163,7 +163,7 @@ def icomments(node, limit=0, flags=0):
 ## `soupsieve.compile()`
 
 ```py3
-def compile(pattern, namespaces=None, flags=0):
+def compile(pattern, namespaces=None, flags=0, **kwargs):
     """Compile CSS pattern."""
 ```
 
@@ -205,6 +205,67 @@ Soup Sieve caches compiled patterns for performance. If for whatever reason, you
 `purge`.
 
 
+## Custom Selectors
+
+The custom selector feature is loosely inspired by the `css-extensions` [proposal][custom-extensions-1]. In its current
+form, Soup Sieve allows assigning a complex selector to a custom pseudo-class name. The pseudo-class name must start
+with `:--` to avoid conflicts with any future pseudo-classes.
+
+To create custom selectors, you must first instantiate a `Custom` class. Afterwards, custom selectors are added by
+calling the `append` method. Though not usually needed, an already added custom selector can be removed with the
+`remove` method.
+
+In the following example, we will define our own custom selector called `#!css :--header` that will be an alias for
+`#!css h1, h2, h3, h4, h5, h6`.
+
+```py3
+import soupsieve as sv
+import bs4
+
+markup = """
+<html>
+<body>
+<h1 id="1">Header 1</h1>
+<h2 id="2">Header 2</h2>
+<p id="3"></p>
+<p id="4"><span>child</span></p>
+</body>
+</html
+"""
+
+custom = sv.Custom()
+custom.append(':--header', 'h1, h2, h3, h4, h5, h6')
+
+soup = bs4.BeautifulSoup(markup, 'lxml')
+print(sv.select(':--header', soup, custom=custom))
+```
+
+The above code, when run, should yield the following output:
+
+```
+[<h1 id="1">Header 1</h1>, <h2 id="2">Header 2</h2>]
+```
+
+When adding custom selectors, order is important. If a custom selector needs to rely on a previous custom selector, the
+selector that is a dependency must be added first.
+
+Assuming the same markup as in the first example, we will now create a custom selector that should find any element that
+has child elements, we will call the selector `:--parent`. Then we will create another selector called
+`:--parent-paragraph` that will use the `:--parent` selector to find `#!html <p>` elements that are also parents:
+
+```py3
+custom = sv.Custom()
+custom.append(":--parent", ":has(> *|*)")
+custom.append(":--parent-paragraph", "p:--parent")
+print(sv.select(':--parent-paragraph', soup, custom=custom))
+```
+
+The above code will yield the only paragraph that is a parent:
+
+```
+[<p id="4"><span>child</span></p>]
+```
+
 ## Namespaces
 
 Many of Soup Sieve's selector functions take an optional namespace dictionary. Namespaces, just like CSS, must be
diff --git a/soupsieve/__init__.py b/soupsieve/__init__.py
index 12a64ec..1c383d7 100644
--- a/soupsieve/__init__.py
+++ b/soupsieve/__init__.py
@@ -30,33 +30,43 @@ from .__meta__ import __version__, __version_info__  # noqa: F401
 from . import css_parser as cp
 from . import css_match as cm
 from . import css_types as ct
+from . import util
 from .util import DEBUG, _QUIRKS, SelectorSyntaxError  # noqa: F401
 
 __all__ = (
-    'SoupSieve', 'compile', 'purge', 'DEBUG', "_QUIRKS"
+    'DEBUG', "_QUIRKS", 'SoupSieve', 'compile', 'purge',
     'comments', 'icomments', 'closest', 'select', 'select_one',
-    'iselect', 'match', 'filter', 'SelectorSyntaxError'
+    'iselect', 'match', 'filter', 'SelectorSyntaxError', 'Custom'
 )
 
 SoupSieve = cm.SoupSieve
 
 
-def compile(pattern, namespaces=None, flags=0):  # noqa: A001
+def compile(pattern, namespaces=None, flags=0, **kwargs):  # noqa: A001
     """Compile CSS pattern."""
 
-    if namespaces is None:
+    ns_none = namespaces is None
+    if ns_none:
         namespaces = ct.Namespaces()
     if not isinstance(namespaces, ct.Namespaces):
-        namespaces = ct.Namespaces(**(namespaces))
+        namespaces = ct.Namespaces(**namespaces)
+
+    custom = kwargs.get('custom')
+    if custom is not None:
+        if not isinstance(custom, Custom):
+            raise TypeError("Custom selectors must be of type 'Custom'")
+        custom = cp._create_custom_selectors(custom, flags)
 
     if isinstance(pattern, SoupSieve):
-        if flags != pattern.flags:
-            raise ValueError("Cannot change flags of a pattern")
-        elif namespaces != pattern.namespaces:
-            raise ValueError("Cannot change namespaces of a pattern")
+        if flags:
+            raise ValueError("Cannot process 'flags' argument on a compiled selector list")
+        elif not ns_none:
+            raise ValueError("Cannot process 'namespaces' argument on a compiled selector list")
+        elif custom is not None:
+            raise ValueError("Cannot process 'custom' argument on a compiled selector list")
         return pattern
 
-    return cp._cached_css_compile(pattern, namespaces, flags)
+    return cp._cached_css_compile(pattern, namespaces, custom, flags)
 
 
 def purge():
@@ -65,51 +75,84 @@ def purge():
     cp._purge_cache()
 
 
-def closest(select, tag, namespaces=None, flags=0):
+def closest(select, tag, namespaces=None, flags=0, **kwargs):
     """Match closest ancestor."""
 
-    return compile(select, namespaces, flags).closest(tag)
+    return compile(select, namespaces, flags, **kwargs).closest(tag)
 
 
-def match(select, tag, namespaces=None, flags=0):
+def match(select, tag, namespaces=None, flags=0, **kwargs):
     """Match node."""
 
-    return compile(select, namespaces, flags).match(tag)
+    return compile(select, namespaces, flags, **kwargs).match(tag)
 
 
-def filter(select, iterable, namespaces=None, flags=0):  # noqa: A001
+def filter(select, iterable, namespaces=None, flags=0, **kwargs):  # noqa: A001
     """Filter list of nodes."""
 
-    return compile(select, namespaces, flags).filter(iterable)
+    return compile(select, namespaces, flags, **kwargs).filter(iterable)
 
 
-def comments(tag, limit=0, flags=0):
+def comments(tag, limit=0, flags=0, **kwargs):
     """Get comments only."""
 
     return list(icomments(tag, limit, flags))
 
 
-def icomments(tag, limit=0, flags=0):
+def icomments(tag, limit=0, flags=0, **kwargs):
     """Iterate comments only."""
 
     for comment in cm.CommentsMatch(tag).get_comments(limit):
         yield comment
 
 
-def select_one(select, tag, namespaces=None, flags=0):
+def select_one(select, tag, namespaces=None, flags=0, **kwargs):
     """Select a single tag."""
 
-    return compile(select, namespaces, flags).select_one(tag)
+    return compile(select, namespaces, flags, **kwargs).select_one(tag)
 
 
-def select(select, tag, namespaces=None, limit=0, flags=0):
+def select(select, tag, namespaces=None, limit=0, flags=0, **kwargs):
     """Select the specified tags."""
 
-    return compile(select, namespaces, flags).select(tag, limit)
+    return compile(select, namespaces, flags, **kwargs).select(tag, limit)
 
 
-def iselect(select, tag, namespaces=None, limit=0, flags=0):
+def iselect(select, tag, namespaces=None, limit=0, flags=0, **kwargs):
     """Iterate the specified tags."""
 
-    for el in compile(select, namespaces, flags).iselect(tag, limit):
+    for el in compile(select, namespaces, flags, **kwargs).iselect(tag, limit):
         yield el
+
+
+class Custom(object):
+    """
+    Custom Selectors.
+
+    This object allows us to control order, and possibly extend the complexity of aliases in the future.
+
+    TODO: If desired, we could allow for function custom selectors `:--custom(arg1, arg2)`,
+          or even custom logic, though none of these possibilities have been fully evaluated
+          for practicality or if they can even be implemented.
+    """
+
+    def __init__(self):
+        """Initialize."""
+
+        self._custom = util.odict()
+
+    def append(self, alias, selector):
+        """Append a new custom selector."""
+
+        name = util.lower(alias)
+
+        if not cp._valid_custom_name(name):
+            raise SelectorSyntaxError("The name '{}' is not a valid custom pseudo-class name".format(name))
+        if name in self._custom:
+            raise KeyError("The custom selector '{}' has already been registered".format(name))
+        self._custom[name] = selector
+
+    def remove(self, alias):
+        """Remove a custom selector by name."""
+
+        del self._custom[alias]
diff --git a/soupsieve/__meta__.py b/soupsieve/__meta__.py
index 6b340d9..7d7b84d 100644
--- a/soupsieve/__meta__.py
+++ b/soupsieve/__meta__.py
@@ -186,5 +186,5 @@ def parse_version(ver, pre=False):
     return Version(major, minor, micro, release, pre, post, dev)
 
 
-__version_info__ = Version(1, 7, 3, "final")
+__version_info__ = Version(1, 8, 0, ".dev")
 __version__ = __version_info__._get_canonical()
diff --git a/soupsieve/css_match.py b/soupsieve/css_match.py
index eb0cd23..12b3212 100644
--- a/soupsieve/css_match.py
+++ b/soupsieve/css_match.py
@@ -1128,7 +1128,7 @@ class CSSMatch(Document, object):
             for selector in selectors:
                 match = is_not
                 # We have a un-matchable situation (like `:focus` as you can focus an element in this environment)
-                if isinstance(selector, ct.NullSelector):
+                if isinstance(selector, ct.SelectorNull):
                     continue
                 # Verify tag matches
                 if not self.match_tag(el, selector.tag):
diff --git a/soupsieve/css_parser.py b/soupsieve/css_parser.py
index e253d66..3308a0d 100644
--- a/soupsieve/css_parser.py
+++ b/soupsieve/css_parser.py
@@ -93,7 +93,15 @@ WSC = r'(?:{ws}|{comments})'.format(ws=WS, comments=COMMENTS)
 # CSS escapes
 CSS_ESCAPES = r'(?:\\[a-f0-9]{{1,6}}{ws}?|\\[^\r\n\f])'.format(ws=WS)
 # CSS Identifier
-IDENTIFIER = r'(?:(?!-?[0-9]|--)(?:[^\x00-\x2c\x2e\x2f\x3A-\x40\x5B-\x5E\x60\x7B-\x9f]|{esc})+)'.format(esc=CSS_ESCAPES)
+IDENTIFIER = r'''
+(?:-?(?:[^\x00-\x2f\x30-\x40\x5B-\x5E\x60\x7B-\x9f]|{esc})+
+(?:[^\x00-\x2c\x2e\x2f\x3A-\x40\x5B-\x5E\x60\x7B-\x9f]|{esc})*)
+'''.format(esc=CSS_ESCAPES)
+# CSS Identifier expanded to allow for custom start: `--`
+EXPANDED_IDENTIFIER = r'''
+(?:(?:-?(?:[^\x00-\x2f\x30-\x40\x5B-\x5E\x60\x7B-\x9f]|{esc})+|--)
+(?:[^\x00-\x2c\x2e\x2f\x3A-\x40\x5B-\x5E\x60\x7B-\x9f]|{esc})*)
+'''.format(esc=CSS_ESCAPES)
 # `nth` content
 NTH = r'(?:[-+])?(?:[0-9]+n?|n)(?:(?<=n){ws}*(?:[-+]){ws}*(?:[0-9]+))?'.format(ws=WSC)
 # Value: quoted string or identifier
@@ -123,6 +131,8 @@ PAT_QUIRKS_ATTR = r'''
 '''.format(ws=WSC, ident=IDENTIFIER, attr=QUIRKS_ATTR)
 # Pseudo class (`:pseudo-class`, `:pseudo-class(`)
 PAT_PSEUDO_CLASS = r'(?P<name>:{ident})(?P<open>\({ws}*)?'.format(ws=WSC, ident=IDENTIFIER)
+# Custom pseudo class (`:--custom-pseudo`)
+PAT_PSEUDO_CLASS_CUSTOM = r'(?P<name>:(?=--){ident})'.format(ident=EXPANDED_IDENTIFIER)
 # Closing pseudo group (`)`)
 PAT_PSEUDO_CLOSE = r'{ws}*\)'.format(ws=WSC)
 # Pseudo element (`::pseudo-element`)
@@ -162,6 +172,7 @@ RE_LANG = re.compile(r'(?:(?P<value>{value})|(?P<split>{ws}*,{ws}*))'.format(ws=
 RE_WS = re.compile(WS)
 RE_WS_BEGIN = re.compile('^{}*'.format(WSC))
 RE_WS_END = re.compile('{}*$'.format(WSC))
+RE_CUSTOM = re.compile(r'^{}$'.format(PAT_PSEUDO_CLASS_CUSTOM), re.X)
 
 # Constants
 # List split token
@@ -185,12 +196,12 @@ _MAXCACHE = 500
 
 
 @util.lru_cache(maxsize=_MAXCACHE)
-def _cached_css_compile(pattern, namespaces, flags):
+def _cached_css_compile(pattern, namespaces, custom, flags):
     """Cached CSS compile."""
 
     return cm.SoupSieve(
         pattern,
-        CSSParser(pattern, flags).process_selectors(),
+        CSSParser(pattern, custom=custom, flags=flags).process_selectors(),
         namespaces,
         flags
     )
@@ -202,6 +213,26 @@ def _purge_cache():
     _cached_css_compile.cache_clear()
 
 
+def _valid_custom_name(name):
+    """Check if custom name is valid."""
+
+    return RE_CUSTOM.match(name) is not None
+
+
+def _create_custom_selectors(custom, flags=0):
+    """Create a dictionary of compiled custom selectors from a dictionary of selector strings."""
+
+    custom_selectors = {}
+    for key, selector in custom._custom.items():
+
+        name = util.lower(key)
+        custom_selectors[name] = CSSParser(
+            selector, custom=custom_selectors, flags=flags
+        ).process_selectors(flags=FLG_PSEUDO)
+
+    return ct.CustomSelectors(**custom_selectors)
+
+
 def css_unescape(string):
     """Unescape CSS value."""
 
@@ -275,7 +306,7 @@ class _Selector(object):
         """Freeze self."""
 
         if self.no_match:
-            return ct.NullSelector()
+            return ct.SelectorNull()
         else:
             return ct.Selector(
                 self.tag,
@@ -316,6 +347,7 @@ class CSSParser(object):
             ("pseudo_nth_type", SelectorPattern(PAT_PSEUDO_NTH_TYPE)),
             ("pseudo_lang", SelectorPattern(PAT_PSEUDO_LANG)),
             ("pseudo_dir", SelectorPattern(PAT_PSEUDO_DIR)),
+            ("pseudo_class_custom", SelectorPattern(PAT_PSEUDO_CLASS_CUSTOM)),
             ("pseudo_class", SelectorPattern(PAT_PSEUDO_CLASS)),
             ("pseudo_element", SelectorPattern(PAT_PSEUDO_ELEMENT)),
             ("at_rule", SelectorPattern(PAT_AT_RULE)),
@@ -328,13 +360,14 @@ class CSSParser(object):
         ]
     )
 
-    def __init__(self, selector, flags=0):
+    def __init__(self, selector, custom=None, flags=0):
         """Initialize."""
 
         self.pattern = selector
         self.flags = flags
         self.debug = self.flags & util.DEBUG
         self.quirks = self.flags & util._QUIRKS
+        self.custom = {} if custom is None else custom
 
     def parse_attribute_selector(self, sel, m, has_selector, quirks):
         """Create attribute selector from the returned regex match."""
@@ -425,6 +458,21 @@ class CSSParser(object):
         has_selector = True
         return has_selector
 
+    def parse_pseudo_class_custom(self, sel, m, has_selector):
+        """Parse custom pseudo class alias."""
+
+        pseudo = util.lower(m.group('name'))
+        if pseudo not in self.custom:
+            raise SelectorSyntaxError(
+                "Undefined custom selector '{}' found at postion {}".format(pseudo, m.end(0)),
+                self.pattern,
+                m.end(0)
+            )
+        selector = self.custom.get(pseudo)
+        sel.selectors.append(selector)
+        has_selector = True
+        return has_selector
+
     def parse_pseudo_class(self, sel, m, has_selector, iselector, is_html):
         """Parse pseudo class."""
 
@@ -770,6 +818,8 @@ class CSSParser(object):
                 # Handle parts
                 if key == "at_rule":
                     raise NotImplementedError("At-rules found at position {}".format(m.start(0)))
+                elif key == 'pseudo_class_custom':
+                    has_selector = self.parse_pseudo_class_custom(sel, m, has_selector)
                 elif key == 'pseudo_class':
                     has_selector, is_html = self.parse_pseudo_class(sel, m, has_selector, iselector, is_html)
                 elif key == 'pseudo_element':
diff --git a/soupsieve/css_types.py b/soupsieve/css_types.py
index 457bea5..0fd1579 100644
--- a/soupsieve/css_types.py
+++ b/soupsieve/css_types.py
@@ -4,13 +4,14 @@ from . import util
 
 __all__ = (
     'Selector',
-    'NullSelector',
+    'SelectorNull',
     'SelectorTag',
     'SelectorAttribute',
     'SelectorNth',
     'SelectorLang',
     'SelectorList',
-    'Namespaces'
+    'Namespaces',
+    'CustomSelectors'
 )
 
 
@@ -146,6 +147,22 @@ class Namespaces(ImmutableDict):
         super(Namespaces, self).__init__(*args, **kwargs)
 
 
+class CustomSelectors(ImmutableDict):
+    """Custom selectors."""
+
+    def __init__(self, *args, **kwargs):
+        """Initialize."""
+
+        arg = args[0] if args else kwargs
+        is_dict = isinstance(arg, dict)
+        if is_dict and not all([isinstance(k, util.string) and isinstance(v, SelectorList) for k, v in arg.items()]):
+            raise TypeError('CustomSelectors keys and values must be Unicode strings and SelectorLists respectively')
+        elif not is_dict and not all([isinstance(k, util.string) and isinstance(v, SelectorList) for k, v in arg]):
+            raise TypeError('CustomSelectors keys and values must be Unicode strings and SelectorLists respectively')
+
+        super(CustomSelectors, self).__init__(*args, **kwargs)
+
+
 class Selector(Immutable):
     """Selector."""
 
@@ -175,13 +192,13 @@ class Selector(Immutable):
         )
 
 
-class NullSelector(Immutable):
+class SelectorNull(Immutable):
     """Null Selector."""
 
     def __init__(self):
         """Initialize."""
 
-        super(NullSelector, self).__init__()
+        super(SelectorNull, self).__init__()
 
 
 class SelectorTag(Immutable):
@@ -301,7 +318,7 @@ def pickle_register(obj):
 
 
 pickle_register(Selector)
-pickle_register(NullSelector)
+pickle_register(SelectorNull)
 pickle_register(SelectorTag)
 pickle_register(SelectorAttribute)
 pickle_register(SelectorNth)
diff --git a/soupsieve/util.py b/soupsieve/util.py
index 44ec8c0..a56c695 100644
--- a/soupsieve/util.py
+++ b/soupsieve/util.py
@@ -9,25 +9,35 @@ MODULE = os.path.dirname(__file__)
 
 PY3 = sys.version_info >= (3, 0)
 PY35 = sys.version_info >= (3, 5)
+PY37 = sys.version_info >= (3, 7)
 
 if PY3:
     from functools import lru_cache  # noqa F401
     import copyreg  # noqa F401
     from collections.abc import Hashable, Mapping  # noqa F401
 
-    ustr = str  # noqa
-    bstr = bytes  # noqa
-    unichar = chr  # noqa
-    string = str  # noqa
+    if PY37:
+        odict = dict
+    else:
+        from collections import OrderedDict
+        odict = OrderedDict
+
+    ustr = str
+    bstr = bytes
+    unichar = chr
+    string = str
+    odict = OrderedDict if not PY37 else dict
 else:
     from backports.functools_lru_cache import lru_cache  # noqa F401
     import copy_reg as copyreg  # noqa F401
     from collections import Hashable, Mapping  # noqa F401
+    from collections import OrderedDict
 
-    ustr = unicode  # noqa
-    bstr = str  # noqa
-    unichar = unichr  # noqa
-    string = basestring  # noqa
+    ustr = unicode  # noqa: F821
+    bstr = str
+    unichar = unichr  # noqa: F821
+    string = basestring  # noqa: F821
+    odict = OrderedDict
 
 _QUIRKS = 0x20000
 DEBUG = 0x10000


Test patch:
diff --git a/tests/test_api.py b/tests/test_api.py
index 7593d28..6496b51 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -501,6 +501,16 @@ class TestSoupSieve(util.TestCase):
         sv.purge()
         self.assertEqual(sv.cp._cached_css_compile.cache_info().currsize, 0)
 
+    def test_custom_interface(self):
+        """Test Custom interface."""
+
+        custom = sv.Custom()
+        self.assertTrue(len(custom._custom) == 0)
+        custom.append(':--header', 'h1, h2, h3, h4, h5, h6')
+        self.assertTrue(len(custom._custom) == 1)
+        custom.remove(':--header')
+        self.assertTrue(len(custom._custom) == 0)
+
     def test_recompile(self):
         """If you feed through the same object, it should pass through unless you change parameters."""
 
@@ -514,13 +524,11 @@ class TestSoupSieve(util.TestCase):
         with pytest.raises(ValueError):
             sv.compile(p1, namespaces={"": ""})
 
-    def test_immutable_object(self):
-        """Test immutable object."""
-
-        obj = sv.ct.Immutable()
+        custom_selectors = sv.Custom()
+        custom_selectors.append(":--header", 'h1, h2, h3, h4, h5, h6')
 
-        with self.assertRaises(AttributeError):
-            obj.member = 3
+        with pytest.raises(ValueError):
+            sv.compile(p1, custom=custom_selectors)
 
     def test_immutable_dict_size(self):
         """Test immutable dictionary."""
@@ -528,6 +536,31 @@ class TestSoupSieve(util.TestCase):
         idict = sv.ct.ImmutableDict({'a': 'b', 'c': 'd'})
         self.assertEqual(2, len(idict))
 
+
+class TestInvalid(util.TestCase):
+    """Test invalid."""
+
+    def test_bad_custom_value(self):
+        """Test bad custom selector value."""
+
+        with self.assertRaises(TypeError):
+            sv.compile('div', custom={':--header', 'h1, h2, h3, h4, h5, h6'})
+
+    def test_custom_bad_remove(self):
+        """Test invalid custom selector remove."""
+
+        custom = sv.Custom()
+        with self.assertRaises(KeyError):
+            custom.remove(':--header')
+
+    def test_immutable_object(self):
+        """Test immutable object."""
+
+        obj = sv.ct.Immutable()
+
+        with self.assertRaises(AttributeError):
+            obj.member = 3
+
     def test_immutable_dict_read_only(self):
         """Test immutable dictionary is read only."""
 
@@ -535,10 +568,6 @@ class TestSoupSieve(util.TestCase):
         with self.assertRaises(TypeError):
             idict['a'] = 'f'
 
-
-class TestInvalid(util.TestCase):
-    """Test invalid."""
-
     def test_immutable_dict_hashable_value(self):
         """Test immutable dictionary has a hashable value."""
 
@@ -569,6 +598,24 @@ class TestInvalid(util.TestCase):
         with self.assertRaises(TypeError):
             sv.ct.Namespaces({{}: 'string'})
 
+    def test_invalid_custom_type(self):
+        """Test invalid custom selector type."""
+
+        with self.assertRaises(TypeError):
+            sv.ct.CustomSelectors(((3, 3),))
+
+    def test_invalid_custom_hashable_value(self):
+        """Test custom selector has hashable value."""
+
+        with self.assertRaises(TypeError):
+            sv.ct.CustomSelectors({'a': {}})
+
+    def test_invalid_custom_hashable_key(self):
+        """Test custom selector key is hashable."""
+
+        with self.assertRaises(TypeError):
+            sv.ct.CustomSelectors({{}: 'string'})
+
     def test_invalid_type_input_match(self):
         """Test bad input into the match API."""
 
diff --git a/tests/test_extra/test_custom.py b/tests/test_extra/test_custom.py
new file mode 100644
index 0000000..3b697ce
--- /dev/null
+++ b/tests/test_extra/test_custom.py
@@ -0,0 +1,106 @@
+"""Test custom selector aliases."""
+from __future__ import unicode_literals
+from .. import util
+import soupsieve as sv
+
+
+class TestCustomSelectors(util.TestCase):
+    """Test custom selector aliases."""
+
+    MARKUP = """
+    <body>
+    <h1 id="1">Header 1</h1>
+    <h2 id="2">Header 2</h2>
+    <p id="3"></p>
+    <p id="4"><span>child</span></p>
+    </body>
+    """
+
+    def test_custom_selectors(self):
+        """Test custom selectors."""
+
+        custom_selectors = sv.Custom()
+        custom_selectors.append(":--headers", "h1, h2, h3, h4, h5, h6")
+        custom_selectors.append(":--parent", ":has(> *|*)")
+
+        self.assert_selector(
+            self.MARKUP,
+            ':--headers',
+            ['1', '2'],
+            custom=custom_selectors,
+            flags=util.HTML
+        )
+
+        self.assert_selector(
+            self.MARKUP,
+            ':--headers:nth-child(2)',
+            ['2'],
+            custom=custom_selectors,
+            flags=util.HTML
+        )
+
+        self.assert_selector(
+            self.MARKUP,
+            'p:--parent',
+            ['4'],
+            custom=custom_selectors,
+            flags=util.HTML
+        )
+
+    def test_custom_dependency(self):
+        """Test custom selector dependency on other custom selectors."""
+
+        custom_selectors = sv.Custom()
+        custom_selectors.append(":--parent", ":has(> *|*)")
+        custom_selectors.append(":--parent-paragraph", "p:--parent")
+
+        self.assert_selector(
+            self.MARKUP,
+            ':--parent-paragraph',
+            ['4'],
+            custom=custom_selectors,
+            flags=util.HTML
+        )
+
+    def test_bad_custom(self):
+        """Test that a bad custom raises a syntax error."""
+
+        custom_selectors = sv.Custom()
+        custom_selectors.append(":--parent", ":has(> *|*)")
+        custom_selectors.append(":--parent-paragraph", "p:--parent")
+
+        self.assert_raises(':--wrong', SyntaxError, custom=custom_selectors)
+
+    def test_bad_custom_syntax(self):
+        """Test that a custom selector with bad syntax in its name fails."""
+
+        custom_selectors = sv.Custom()
+        with self.assertRaises(SyntaxError):
+            custom_selectors.append(":--parent.", ":has(> *|*)")
+
+    def test_pseudo_class_collision(self):
+        """Test that a custom selector cannot match an already existing pseudo-class name."""
+
+        custom_selectors = sv.Custom()
+
+        with self.assertRaises(SyntaxError):
+            custom_selectors.append(":hover", ":has(> *|*)")
+
+    def test_custom_collision(self):
+        """Test that a custom selector cannot match an already existing custom name."""
+
+        custom_selectors = sv.Custom()
+        custom_selectors.append(":--parent", ":has(> *|*)")
+
+        with self.assertRaises(KeyError):
+            custom_selectors.append(":--PARENT", "p:--parent")
+
+
+class TestCustomSelectorsQuirks(TestCustomSelectors):
+    """Test custom selectors with quirks."""
+
+    def setUp(self):
+        """Setup."""
+
+        self.purge()
+        self.quirks = True
diff --git a/tests/util.py b/tests/util.py
index af6840a..bd0b103 100644
--- a/tests/util.py
+++ b/tests/util.py
@@ -14,13 +14,13 @@ except ImportError:
     HTML5LIB_PRESENT = False
 
 try:
-    from bs4.builder import (  # noqa: F401
-        LXMLTreeBuilderForXML, LXMLTreeBuilder)
+    from bs4.builder import LXMLTreeBuilderForXML, LXMLTreeBuilder  # noqa: F401
     LXML_PRESENT = True
 except ImportError:
     LXML_PRESENT = False
 
 PY3 = sys.version_info >= (3, 0)
+PY37 = sys.version_info >= (3, 7)
 
 HTML5 = 0x1
 HTML = 0x2
@@ -36,7 +36,7 @@ def skip_quirks(func):
     def skip_if(self, *args, **kwargs):
         """Skip conditional wrapper."""
         if self.quirks is True:
-            return
+            raise pytest.skip('not applicable to quirks mode')
         else:
             return func(self, *args, **kwargs)
     return skip_if
@@ -48,7 +48,7 @@ def skip_py3(func):
     def skip_if(self, *args, **kwargs):
         """Skip conditional wrapper."""
         if PY3:
-            return
+            raise pytest.skip('not Python 3+')
         else:
             return func(self, *args, **kwargs)
     return skip_if
@@ -84,14 +84,14 @@ class TestCase(unittest.TestCase):
 
         sv.purge()
 
-    def compile_pattern(self, selectors, namespaces=None, flags=0):
+    def compile_pattern(self, selectors, namespaces=None, custom=None, flags=0):
         """Compile pattern."""
 
         print('PATTERN: ', selectors)
         flags |= sv.DEBUG
         if self.quirks:
             flags |= sv._QUIRKS
-        return sv.compile(selectors, namespaces=namespaces, flags=flags)
+        return sv.compile(selectors, namespaces=namespaces, custom=custom, flags=flags)
 
     def soup(self, markup, parser):
         """Get soup."""
@@ -116,20 +116,20 @@ class TestCase(unittest.TestCase):
 
         return parsers
 
-    def assert_raises(self, pattern, exception, namespace=None):
+    def assert_raises(self, pattern, exception, namespace=None, custom=None):
         """Assert raises."""
 
         print('----Running Assert Test----')
         with self.assertRaises(exception):
-            self.compile_pattern(pattern)
+            self.compile_pattern(pattern, namespaces=namespace, custom=custom)
 
-    def assert_selector(self, markup, selectors, expected_ids, namespaces={}, flags=0):
+    def assert_selector(self, markup, selectors, expected_ids, namespaces={}, custom=None, flags=0):
         """Assert selector."""
 
         parsers = self.get_parsers(flags)
 
         print('----Running Selector Test----')
-        selector = self.compile_pattern(selectors, namespaces)
+        selector = self.compile_pattern(selectors, namespaces, custom)
 
         for parser in available_parsers(*parsers):
             soup = self.soup(markup, parser)
@@ -147,10 +147,13 @@ def available_parsers(*parsers):
 
     If there are none, report the test as skipped to pytest.
     """
+
     ran_test = False
     for parser in parsers:
-        if (parser in ('xml', 'lxml') and not LXML_PRESENT) or (
-                parser == 'html5lib' and not HTML5LIB_PRESENT):
+        if (
+            (parser in ('xml', 'lxml') and not LXML_PRESENT) or
+            (parser == 'html5lib' and not HTML5LIB_PRESENT)
+        ):
             print('SKIPPED {}, not installed'.format(parser))
         else:
             ran_test = True
@@ -161,11 +164,13 @@ def available_parsers(*parsers):
 
 def requires_lxml(test):
     """Decorator that marks a test as requiring LXML."""
+
     return pytest.mark.skipif(
         not LXML_PRESENT, reason='test requires lxml')(test)
 
 
 def requires_html5lib(test):
     """Decorator that marks a test as requiring html5lib."""
+
     return pytest.mark.skipif(
         not HTML5LIB_PRESENT, reason='test requires html5lib')(test)