Repository: facelessuser/soupsieve

Issue:
Use proper CSS identifier patterns for class, id, tags, etc.
Right now we allow things like `#3` which isn't really allowed in CSS.  We should use proper patterns that mimic CSS appropriately.

Discussion:

Patch:
diff --git a/docs/src/markdown/about/changelog.md b/docs/src/markdown/about/changelog.md
index 531ca31..cf1534c 100644
--- a/docs/src/markdown/about/changelog.md
+++ b/docs/src/markdown/about/changelog.md
@@ -1,5 +1,10 @@
 # Changelog
 
+## 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`.
+- **FIX**: Do not raise `NotImplementedError` for supported pseudo classes/elements with bad syntax, instead raise `SyntaxError`.
+
 ## 1.0.1
 
 - **FIX**: When giving a tag to `select`, it should only return the children of that tag, never the tag itself.
diff --git a/soupsieve/__meta__.py b/soupsieve/__meta__.py
index 4c3c35d..77a37c1 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, 0, 1, "final")
+__version_info__ = Version(1, 0, 2, "final")
 __version__ = __version_info__._get_canonical()
diff --git a/soupsieve/css_parser.py b/soupsieve/css_parser.py
index b605383..d325f2a 100644
--- a/soupsieve/css_parser.py
+++ b/soupsieve/css_parser.py
@@ -6,29 +6,58 @@ from . import css_match as cm
 from . import css_types as ct
 from collections import OrderedDict
 
+SUPPORTED_PSEUDO = {
+    ":root",
+    ":empty",
+    ":has",
+    ":is",
+    ":matches",
+    ":not",
+    ":where",
+    ":first-child",
+    ":first-of-type",
+    ":last-child",
+    ":last-of-type",
+    ":only-child",
+    ":only-of-type",
+    ":nth-child",
+    ":nth-last-child",
+    ":nth-of-type",
+    ":nth-last-of-type"
+}
 
 # Sub-patterns parts
 WS = r'[ \t\r\n\f]'
 
 CSS_ESCAPES = r'(?:\\[a-f0-9]{{1,6}}{ws}?|\\.)'.format(ws=WS)
 
+IDENTIFIER = r'''
+(?:(?!-?\d|--)(?:[^\u0000-\u002c\u002e\u002f\u003A-\u0040\u005B-\u005E\u0060\u007B-\u009f]|{esc})+)
+'''.format(esc=CSS_ESCAPES)
+
 NTH = r'(?:[-+])?(?:\d+n?|n)(?:(?<=n){ws}*(?:[-+]){ws}*(?:\d+))?'.format(ws=WS)
 
-VALUE = r'''(?P<value>"(?:\\.|[^\\"]+)*?"|'(?:\\.|[^\\']+)*?'|(?:[^'"\[\] \f\t\r\n]|{esc})+)'''.format(esc=CSS_ESCAPES)
+VALUE = r'''
+(?P<value>
+    "(?:\\.|[^\\"]+)*?"|
+    '(?:\\.|[^\\']+)*?'|
+    {ident}+
+)
+'''.format(ident=IDENTIFIER)
 
 ATTR = r'''(?:{ws}*(?P<cmp>[~^|*$]?=){ws}*{value}(?P<case>{ws}+[is])?)?{ws}*\]'''.format(ws=WS, value=VALUE)
 
 # Selector patterns
-PAT_ID = r'#(?:[-\w]|{esc})+'.format(esc=CSS_ESCAPES)
+PAT_ID = r'\#{ident}'.format(ident=IDENTIFIER)
 
-PAT_CLASS = r'\.(?:[-\w]|{esc})+'.format(esc=CSS_ESCAPES)
+PAT_CLASS = r'\.{ident}'.format(ident=IDENTIFIER)
 
-PAT_TAG = r'(?:(?:(?:[-\w]|{esc})+|\*)?\|)?(?:(?:[-\w]|{esc})+|\*)'.format(esc=CSS_ESCAPES)
+PAT_TAG = r'(?:(?:{ident}|\*)?\|)?(?:{ident}|\*)'.format(ident=IDENTIFIER)
 
-PAT_ATTR = r'''(?x)
-\[{ws}*(?P<ns_attr>(?:(?:(?:[-\w]|{esc})+|\*)?\|)?(?:[-\w]|{esc})+)
+PAT_ATTR = r'''
+\[{ws}*(?P<ns_attr>(?:(?:{ident}|\*)?\|)?{ident})
 {attr}
-'''.format(ws=WS, esc=CSS_ESCAPES, attr=ATTR)
+'''.format(ws=WS, ident=IDENTIFIER, attr=ATTR)
 
 PAT_PSEUDO_OPEN = r':(?:has|is|matches|not|where)\('
 
@@ -36,22 +65,22 @@ PAT_PSEUDO_CLOSE = r'{ws}*\)'.format(ws=WS)
 
 PAT_PSEUDO = r':(?:empty|root|(?:first|last|only)-(?:child|of-type))\b'
 
-PAT_PSEUDO_NTH_CHILD = r'''(?x)
+PAT_PSEUDO_NTH_CHILD = r'''
 (?P<pseudo_nth_child>:nth-(?:last-)?child
 \({ws}*(?P<nth_child>{nth}|even|odd){ws}*(?:\)|(?<={ws})of{ws}+))
 '''.format(ws=WS, nth=NTH)
 
-PAT_PSEUDO_NTH_TYPE = r'''(?x)
+PAT_PSEUDO_NTH_TYPE = r'''
 (?P<pseudo_nth_type>:nth-(?:last-)?of-type
 \({ws}*(?P<nth_type>{nth}|even|odd){ws}*\))
 '''.format(ws=WS, nth=NTH)
 
 PAT_SPLIT = r'{ws}*?(?P<relation>[,+>~]|{ws}(?![,+>~])){ws}*'.format(ws=WS)
 
-PAT_INVALID_PSEUDO = r':[a-z0-9-]+'
-
 # Extra selector patterns
-PAT_CONTAINS = r':contains\({ws}*{value}{ws}*\)'.format(ws=WS, value=VALUE)
+PAT_PSEUDO_CONTAINS = r':contains\({ws}*{value}{ws}*\)'.format(ws=WS, value=VALUE)
+
+PAT_PSEUDO_INVALID = r':{ident}'.format(ident=IDENTIFIER)
 
 # CSS escape pattern
 RE_CSS_ESC = re.compile(r'(?:(\\[a-f0-9]{{1,6}}{ws}?)|(\\.))'.format(ws=WS), re.I)
@@ -100,7 +129,7 @@ class SelectorPattern:
     def __init__(self, pattern):
         """Initialize."""
 
-        self.pattern = re.compile(pattern, re.I)
+        self.pattern = re.compile(pattern, re.I | re.X)
 
     def enabled(self, flags):
         """Enabled."""
@@ -179,16 +208,16 @@ class CSSParser:
     css_tokens = OrderedDict(
         [
             ("pseudo_open", SelectorPattern(PAT_PSEUDO_OPEN)),
+            ("pseudo_close", SelectorPattern(PAT_PSEUDO_CLOSE)),
             ("pseudo", SelectorPattern(PAT_PSEUDO)),
-            ("contains", SelectorPattern(PAT_CONTAINS)),
+            ("pseudo_contains", SelectorPattern(PAT_PSEUDO_CONTAINS)),
             ("pseudo_nth_child", SelectorPattern(PAT_PSEUDO_NTH_CHILD)),
             ("pseudo_nth_type", SelectorPattern(PAT_PSEUDO_NTH_TYPE)),
-            ("pseudo_invalid", SelectorPattern(PAT_INVALID_PSEUDO)),
+            ("pseudo_invalid", SelectorPattern(PAT_PSEUDO_INVALID)),
             ("id", SelectorPattern(PAT_ID)),
             ("class", SelectorPattern(PAT_CLASS)),
             ("tag", SelectorPattern(PAT_TAG)),
             ("attribute", SelectorPattern(PAT_ATTR)),
-            ("pseudo_close", SelectorPattern(PAT_PSEUDO_CLOSE)),
             ("combine", SelectorPattern(PAT_SPLIT))
         ]
     )
@@ -467,10 +496,15 @@ class CSSParser:
 
                 # Handle parts
                 if key == 'pseudo_invalid':
-                    raise NotImplementedError("'{}' pseudo class is not implemented".format(m.group(0)))
+                    if util.lower(m.group(0)) in SUPPORTED_PSEUDO:
+                        raise SyntaxError("Invalid syntax for pseudo class '{}'".format(key))
+                    else:
+                        raise NotImplementedError(
+                            "'{}' pseudo class/element is not implemented at this time".format(key)
+                        )
                 elif key == 'pseudo':
                     has_selector = self.parse_pseudo(sel, m, has_selector)
-                elif key == 'contains':
+                elif key == 'pseudo_contains':
                     has_selector = self.parse_contains(sel, m, has_selector)
                 elif key in ('pseudo_nth_type', 'pseudo_nth_child'):
                     has_selector = self.parse_pseudo_nth(sel, m, has_selector, iselector)


Test patch:
diff --git a/tests/test_level1.py b/tests/test_level1.py
index 7eb24dd..af8e923 100644
--- a/tests/test_level1.py
+++ b/tests/test_level1.py
@@ -82,14 +82,14 @@ class TestLevel1(util.TestCase):
 
         self.assert_selector(
             markup,
-            "#1",
+            "#\\31",
             ["1"],
             flags=util.HTML5
         )
 
         self.assert_selector(
             markup,
-            "a#2",
+            "a#\\32",
             ["2"],
             flags=util.HTML5
         )
diff --git a/tests/test_level2.py b/tests/test_level2.py
index eedd928..3d615b4 100644
--- a/tests/test_level2.py
+++ b/tests/test_level2.py
@@ -95,7 +95,7 @@ class TestLevel2(util.TestCase):
         # Complex
         self.assert_selector(
             markup,
-            "span#4 + span#5",
+            "span#\\34 + span#\\35",
             ["5"],
             flags=util.HTML5
         )
@@ -193,7 +193,7 @@ class TestLevel2(util.TestCase):
         # No quotes
         self.assert_selector(
             markup,
-            '[id=5]',
+            '[id=\\35]',
             ["5"],
             flags=util.HTML5
         )
diff --git a/tests/test_level4.py b/tests/test_level4.py
index acc43d6..0c2a92f 100644
--- a/tests/test_level4.py
+++ b/tests/test_level4.py
@@ -162,14 +162,14 @@ class TestLevel4(util.TestCase):
 
         self.assert_selector(
             markup,
-            ":is(span, a:matches(#2))",
+            ":is(span, a:matches(#\\32))",
             ["1", "2"],
             flags=util.HTML5
         )
 
         self.assert_selector(
             markup,
-            ":where(span, a:matches(#2))",
+            ":where(span, a:matches(#\\32))",
             ["1", "2"],
             flags=util.HTML5
         )
@@ -196,7 +196,7 @@ class TestLevel4(util.TestCase):
         # So this will match
         self.assert_selector(
             markup,
-            ":is(a):is(#2)",
+            ":is(a):is(#\\32)",
             ['2'],
             flags=util.HTML5
         )
@@ -219,7 +219,7 @@ class TestLevel4(util.TestCase):
 
         self.assert_selector(
             markup,
-            'div :not(p, :not([id=5]))',
+            'div :not(p, :not([id=\\35]))',
             ['5'],
             flags=util.HTML5
         )
diff --git a/tests/test_soupsieve.py b/tests/test_soupsieve.py
index 26d9a3b..6d43290 100644
--- a/tests/test_soupsieve.py
+++ b/tests/test_soupsieve.py
@@ -101,8 +101,8 @@ class TestSoupSieve(unittest.TestCase):
 
         soup = bs4.BeautifulSoup(markup, 'html5lib')
         nodes = sv.select('span[id]', soup)
-        self.assertTrue(sv.match('span#5', nodes[0]))
-        self.assertFalse(sv.match('span#5', nodes[1]))
+        self.assertTrue(sv.match('span#\\35', nodes[0]))
+        self.assertFalse(sv.match('span#\\35', nodes[1]))
 
     def test_filter(self):
         """Test filter."""
@@ -125,11 +125,11 @@ class TestSoupSieve(unittest.TestCase):
         """
 
         soup = bs4.BeautifulSoup(markup, 'html5lib')
-        nodes = sv.filter('pre#6', soup.html.body)
+        nodes = sv.filter('pre#\\36', soup.html.body)
         self.assertEqual(len(nodes), 1)
         self.assertEqual(nodes[0].attrs['id'], '6')
 
-        nodes = sv.filter('pre#6', [el for el in soup.html.body.children if isinstance(el, bs4.Tag)])
+        nodes = sv.filter('pre#\\36', [el for el in soup.html.body.children if isinstance(el, bs4.Tag)])
         self.assertEqual(len(nodes), 1)
         self.assertEqual(nodes[0].attrs['id'], '6')
 
@@ -167,7 +167,7 @@ class TestSoupSieve(unittest.TestCase):
         sv.purge()
         self.assertEqual(sv.cp._cached_css_compile.cache_info().currsize, 0)
         for x in range(1000):
-            value = '[value={}]'.format(str(random.randint(1, 10000)))
+            value = '[value="{}"]'.format(str(random.randint(1, 10000)))
             p = sv.compile(value)
             self.assertTrue(p.pattern == value)
             self.assertTrue(sv.cp._cached_css_compile.cache_info().currsize > 0)
@@ -370,6 +370,9 @@ class TestInvalid(unittest.TestCase):
         with self.assertRaises(NotImplementedError):
             sv.compile(':before')
 
+        with self.assertRaises(SyntaxError):
+            sv.compile(':nth-child(a)')
+
     def test_invalid_pseudo_close(self):
         """Test invalid pseudo close."""