Repository: facelessuser/soupsieve

Issue:
html.parser and :root issue
So `html.parser` doesn't care if your HTML is sound or not. When using the `html.parser` in BeautifulSoup, you can give it HTML code, and it will gladly parse it as a document that has multiple root nodes.  So when we use `:root`, each root node is counted as `:root`. This is probably not proper.

Most parsers will never allow a multi-root document to exist. Both `lxml` and `html5lib` go out of their ways to ensure this doesn't happen, so this is very specific to `html.parser`. So here's the question, do we ensure that root is always the `html` element for HTML parsers? Or do we still just look for a root, even if it isn't `html`, and just verify that it is the only one? I assume, if for whatever reason, we deem their is no proper root, we should probably just never allow `:root` to match.

Discussion:
facelessuser: Additionally a bug with our check for special strings was also fixed. Doctypes were accidentally omitted from our special string check.

Patch:
diff --git a/docs/src/markdown/about/changelog.md b/docs/src/markdown/about/changelog.md
index e224461..f16ded2 100644
--- a/docs/src/markdown/about/changelog.md
+++ b/docs/src/markdown/about/changelog.md
@@ -1,8 +1,10 @@
 # Changelog
 
-<!-- ## Next
+## 1.9.2
 
-- **FIX**: Shortcut last descendant calculation if possible for performance. -->
+- **FIX**: Shortcut last descendant calculation if possible for performance.
+- **FIX**: Fix issue where `Doctype` strings can be mistaken for a normal text node.
+- **FIX**: A top level tag is not a `:root` tag if it has sibling text nodes or tag nodes. This is an issue that mostly manifests when using the `html.parser` as it will allow multiple root nodes.
 
 ## 1.9.1
 
diff --git a/soupsieve/css_match.py b/soupsieve/css_match.py
index 648bb76..799cd81 100644
--- a/soupsieve/css_match.py
+++ b/soupsieve/css_match.py
@@ -113,11 +113,11 @@ class Document(object):
         return isinstance(obj, bs4.Declaration)
 
     @staticmethod
-    def is_cdata(obj):  # pragma: no cover
+    def is_cdata(obj):
         """Is CDATA."""
 
         import bs4
-        return isinstance(obj, bs4.Declaration)
+        return isinstance(obj, bs4.CData)
 
     @staticmethod
     def is_processing_instruction(obj):  # pragma: no cover
@@ -138,7 +138,7 @@ class Document(object):
         """Is special string."""
 
         import bs4
-        return isinstance(obj, (bs4.Comment, bs4.Declaration, bs4.CData, bs4.ProcessingInstruction))
+        return isinstance(obj, (bs4.Comment, bs4.Declaration, bs4.CData, bs4.ProcessingInstruction, bs4.Doctype))
 
     @classmethod
     def is_content_string(cls, obj):
@@ -254,20 +254,20 @@ class Document(object):
         return el.prefix
 
     @classmethod
-    def get_next_tag(cls, el):
+    def get_next(cls, el, tags=True):
         """Get next sibling tag."""
 
         sibling = el.next_sibling
-        while not cls.is_tag(sibling) and sibling is not None:
+        while tags and not cls.is_tag(sibling) and sibling is not None:
             sibling = sibling.next_sibling
         return sibling
 
     @classmethod
-    def get_previous_tag(cls, el):
+    def get_previous(cls, el, tags=True):
         """Get previous sibling tag."""
 
         sibling = el.previous_sibling
-        while not cls.is_tag(sibling) and sibling is not None:
+        while tags and not cls.is_tag(sibling) and sibling is not None:
             sibling = sibling.previous_sibling
         return sibling
 
@@ -663,12 +663,12 @@ class CSSMatch(Document, object):
             if parent:
                 found = self.match_selectors(parent, relation)
         elif relation[0].rel_type == REL_SIBLING:
-            sibling = self.get_previous_tag(el)
+            sibling = self.get_previous(el)
             while not found and sibling:
                 found = self.match_selectors(sibling, relation)
-                sibling = self.get_previous_tag(sibling)
+                sibling = self.get_previous(sibling)
         elif relation[0].rel_type == REL_CLOSE_SIBLING:
-            sibling = self.get_previous_tag(el)
+            sibling = self.get_previous(el)
             if sibling and self.is_tag(sibling):
                 found = self.match_selectors(sibling, relation)
         return found
@@ -693,12 +693,12 @@ class CSSMatch(Document, object):
         elif relation[0].rel_type == REL_HAS_CLOSE_PARENT:
             found = self.match_future_child(el, relation)
         elif relation[0].rel_type == REL_HAS_SIBLING:
-            sibling = self.get_next_tag(el)
+            sibling = self.get_next(el)
             while not found and sibling:
                 found = self.match_selectors(sibling, relation)
-                sibling = self.get_next_tag(sibling)
+                sibling = self.get_next(sibling)
         elif relation[0].rel_type == REL_HAS_CLOSE_SIBLING:
-            sibling = self.get_next_tag(el)
+            sibling = self.get_next(el)
             if sibling and self.is_tag(sibling):
                 found = self.match_selectors(sibling, relation)
         return found
@@ -739,7 +739,28 @@ class CSSMatch(Document, object):
     def match_root(self, el):
         """Match element as root."""
 
-        return self.is_root(el)
+        is_root = self.is_root(el)
+        if is_root:
+            sibling = self.get_previous(el, tags=False)
+            while is_root and sibling is not None:
+                if (
+                    self.is_tag(sibling) or (self.is_content_string(sibling) and sibling.strip()) or
+                    self.is_cdata(sibling)
+                ):
+                    is_root = False
+                else:
+                    sibling = self.get_previous(sibling, tags=False)
+        if is_root:
+            sibling = self.get_next(el, tags=False)
+            while is_root and sibling is not None:
+                if (
+                    self.is_tag(sibling) or (self.is_content_string(sibling) and sibling.strip()) or
+                    self.is_cdata(sibling)
+                ):
+                    is_root = False
+                else:
+                    sibling = self.get_next(sibling, tags=False)
+        return is_root
 
     def match_scope(self, el):
         """Match element as scope."""


Test patch:
diff --git a/tests/test_level3/test_root.py b/tests/test_level3/test_root.py
index df587a4..87583de 100644
--- a/tests/test_level3/test_root.py
+++ b/tests/test_level3/test_root.py
@@ -118,6 +118,82 @@ class TestRoot(util.TestCase):
             ids.append(el['id'])
         self.assertEqual(sorted(ids), sorted(['div2']))
 
+    def test_no_root_double_tag(self):
+        """Test when there is no root due to double root tags."""
+
+        markup = """
+        <div id="1"></div>
+        <div id="2"></div>
+        """
+
+        soup = self.soup(markup, 'html.parser')
+        self.assertEqual(soup.select(':root'), [])
+
+    def test_no_root_text(self):
+        """Test when there is no root due to HTML text."""
+
+        markup = """
+        text
+        <div id="1"></div>
+        """
+
+        soup = self.soup(markup, 'html.parser')
+        self.assertEqual(soup.select(':root'), [])
+
+    def test_no_root_cdata(self):
+        """Test when there is no root due to CDATA and tag."""
+
+        markup = """
+        <![CDATA[test]]>
+        <div id="1"></div>
+        """
+
+        soup = self.soup(markup, 'html.parser')
+        self.assertEqual(soup.select(':root'), [])
+
+    def test_root_whitespace(self):
+        """Test when there is root and white space."""
+
+        markup = """
+
+        <div id="1"></div>
+        """
+
+        ids = []
+        soup = self.soup(markup, 'html.parser')
+        for el in soup.select(':root'):
+            ids.append(el['id'])
+        self.assertEqual(sorted(ids), sorted(['1']))
+
+    def test_root_preprocess(self):
+        """Test when there is root and pre-processing statement."""
+
+        markup = """
+        <?php ?>
+        <div id="1"></div>
+        """
+
+        ids = []
+        soup = self.soup(markup, 'html.parser')
+        for el in soup.select(':root'):
+            ids.append(el['id'])
+        self.assertEqual(sorted(ids), sorted(['1']))
+
+    def test_root_doctype(self):
+        """Test when there is root and doc type."""
+
+        markup = """
+        <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
+        "http://www.w3.org/TR/html4/strict.dtd">
+        <div id="1"></div>
+        """
+
+        ids = []
+        soup = self.soup(markup, 'html.parser')
+        for el in soup.select(':root'):
+            ids.append(el['id'])
+        self.assertEqual(sorted(ids), sorted(['1']))
+
 
 class TestRootQuirks(TestRoot):
     """Test root selectors with quirks."""