Repository: facelessuser/soupsieve

Issue:
Implement 'closest' api call
Implement a `closest` api function.  it would function no different than noted [here](https://developer.mozilla.org/en-US/docs/Web/API/Element/closest).

Summary, in short, given a selector and a tag, `closest` would return the closest tag ancestor (including the tag given) that matches the selector.  See link above for examples.

This is super easy to implement.

Discussion:

Patch:
diff --git a/docs/src/markdown/about/changelog.md b/docs/src/markdown/about/changelog.md
index bfbd6a7..8ed1106 100644
--- a/docs/src/markdown/about/changelog.md
+++ b/docs/src/markdown/about/changelog.md
@@ -1,5 +1,10 @@
 # Changelog
 
+## 1.6.0
+
+- **NEW**: Add `closest` method to the API that matches closest ancestor.
+- **FIX**: Add missing `select_one` reference to module's `__all__`.
+
 ## 1.5.0
 
 - **NEW**: Add `select_one` method like Beautiful Soup has.
diff --git a/docs/src/markdown/api.md b/docs/src/markdown/api.md
index 46cea96..13624fc 100644
--- a/docs/src/markdown/api.md
+++ b/docs/src/markdown/api.md
@@ -67,6 +67,17 @@ def iselect(select, node, namespaces=None, limit=0, flags=0):
 
 `iselect` is exactly like `select` except that it returns a generator instead of a list.
 
+## `soupsieve.closest()`
+
+```py3
+def closest(select, tag, namespaces=None, flags=0):
+    """Match closest ancestor to the provided tag."""
+```
+
+`closest` returns the tag closest to the given tag that matches the given selector. The element found must be a direct ancestor of the tag or the tag itself.
+
+`closest` accepts a CSS selector string, a `Tag`/`BeautifulSoup` object, an optional [namespace](#namespaces) dictionary, and `flags`.
+
 ## `soupsieve.match()`
 
 ```py3
@@ -138,6 +149,9 @@ class SoupSieve:
     def match(self, tag):
         """Match."""
 
+    def closest(self, tag):
+        """Match closest ancestor."""
+
     def filter(self, iterable):
         """Filter."""
 
diff --git a/docs/src/markdown/index.md b/docs/src/markdown/index.md
index f669b41..a4f46ac 100644
--- a/docs/src/markdown/index.md
+++ b/docs/src/markdown/index.md
@@ -72,6 +72,20 @@ To select all tags:
 [<p class="a">Cat</p>, <p class="b">Dog</p>, <p class="c">Mouse</p>]
 ```
 
+To select closest, direct ancestor:
+
+```pycon3
+>>> import soupsieve as sv
+>>> el = sv.select_one('.c', soup)
+>>> sv.closest('div', el)
+<div>
+<!-- These are animals -->
+<p class="a">Cat</p>
+<p class="b">Dog</p>
+<p class="c">Mouse</p>
+</div>
+```
+
 To filter:
 
 ```pycon3
@@ -82,10 +96,10 @@ To filter:
 To match:
 
 ```pycon3
->>> nodes = sv.select('p:is(.a, .b, .c)', soup)
->>> sv.match(nodes[0], 'p:not(.b)')
+>>> els = sv.select('p:is(.a, .b, .c)', soup)
+>>> sv.match(els[0], 'p:not(.b)')
 True
->>> sv.match(nodes[1], 'p:not(.b)')
+>>> sv.match(els[1], 'p:not(.b)')
 False
 ```
 
diff --git a/soupsieve/__init__.py b/soupsieve/__init__.py
index b5f594d..3d3184d 100644
--- a/soupsieve/__init__.py
+++ b/soupsieve/__init__.py
@@ -34,7 +34,8 @@ from .util import DEBUG
 
 __all__ = (
     'SoupSieve', 'compile', 'purge', 'DEBUG',
-    'comments', 'icomments', 'select', 'iselect', 'match', 'filter'
+    'comments', 'icomments', 'closest', 'select', 'select_one',
+    'iselect', 'match', 'filter'
 )
 
 SoupSieve = cm.SoupSieve
@@ -64,6 +65,12 @@ def purge():
     cp._purge_cache()
 
 
+def closest(select, tag, namespaces=None, flags=0):
+    """Match closest ancestor."""
+
+    return compile(select, namespaces, flags).closest(tag)
+
+
 def match(select, tag, namespaces=None, flags=0):
     """Match node."""
 
diff --git a/soupsieve/__meta__.py b/soupsieve/__meta__.py
index 08c3852..bdc8758 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, 5, 0, "final")
+__version_info__ = Version(1, 6, 0, ".dev")
 __version__ = __version_info__._get_canonical()
diff --git a/soupsieve/css_match.py b/soupsieve/css_match.py
index 12751ba..469c406 100644
--- a/soupsieve/css_match.py
+++ b/soupsieve/css_match.py
@@ -52,6 +52,8 @@ class CSSMatch(object):
     def __init__(self, selectors, scope, namespaces, flags):
         """Initialize."""
 
+        self.assert_valid_input(scope)
+        self.tag = scope
         self.cached_meta_lang = None
         self.cached_default_forms = []
         self.cached_indeterminate_forms = []
@@ -74,6 +76,30 @@ class CSSMatch(object):
         self.html_namespace = self.is_html_ns(root)
         self.is_xml = doc._is_xml and not self.html_namespace
 
+    def assert_valid_input(self, tag):
+        """Check if valid input tag."""
+
+        # Fail on unexpected types.
+        if not util.is_tag(tag):
+            raise TypeError("Expected a BeautifulSoup 'Tag', but instead recieved type {}".format(type(tag)))
+
+    def is_html_ns(self, el):
+        """Check if in HTML namespace."""
+
+        ns = getattr(el, 'namespace') if el else None
+        return ns and ns == NS_XHTML
+
+    def is_ancestor(self, el):
+        """Check if element is an ancestor of the tag (can be the tag as well)."""
+
+        tag = self.tag
+        is_ancestor = False
+        while tag is not None and not is_ancestor:
+            if el is tag:
+                is_ancestor = True
+            tag = tag.parent
+        return is_ancestor
+
     def get_namespace(self, el):
         """Get the namespace for the element."""
 
@@ -88,6 +114,41 @@ class CSSMatch(object):
 
         return self.is_xml or self.html_namespace
 
+    def get_bidi(self, el):
+        """Get directionality from element text."""
+
+        for node in el.children:
+
+            # Analyze child text nodes
+            if util.is_tag(node):
+
+                # Avoid analyzing certain elements specified in the specification.
+                direction = DIR_MAP.get(util.lower(node.attrs.get('dir', '')), None)
+                if (
+                    util.lower(node.name) in ('bdi', 'script', 'style', 'textarea') or
+                    direction is not None
+                ):
+                    continue  # pragma: no cover
+
+                # Check directionality of this node's text
+                value = self.get_bidi(node)
+                if value is not None:
+                    return value
+
+                # Direction could not be determined
+                continue  # pragma: no cover
+
+            # Skip `doctype` comments, etc.
+            if util.is_special_string(node):
+                continue
+
+            # Analyze text nodes for directionality.
+            for c in node:
+                bidi = unicodedata.bidirectional(c)
+                if bidi in ('AL', 'R', 'L'):
+                    return ct.SEL_DIR_LTR if bidi == 'L' else ct.SEL_DIR_RTL
+        return None
+
     def get_attribute(self, el, attr, prefix):
         """Get attribute from element if it exists."""
 
@@ -662,41 +723,6 @@ class CSSMatch(object):
 
         return match
 
-    def get_bidi(self, el):
-        """Get directionality from element text."""
-
-        for node in el.children:
-
-            # Analyze child text nodes
-            if util.is_tag(node):
-
-                # Avoid analyzing certain elements specified in the specification.
-                direction = DIR_MAP.get(util.lower(node.attrs.get('dir', '')), None)
-                if (
-                    util.lower(node.name) in ('bdi', 'script', 'style', 'textarea') or
-                    direction is not None
-                ):
-                    continue  # pragma: no cover
-
-                # Check directionality of this node's text
-                value = self.get_bidi(node)
-                if value is not None:
-                    return value
-
-                # Direction could not be determined
-                continue  # pragma: no cover
-
-            # Skip `doctype` comments, etc.
-            if util.is_special_string(node):
-                continue
-
-            # Analyze text nodes for directionality.
-            for c in node:
-                bidi = unicodedata.bidirectional(c)
-                if bidi in ('AL', 'R', 'L'):
-                    return ct.SEL_DIR_LTR if bidi == 'L' else ct.SEL_DIR_RTL
-        return None
-
     def match_dir(self, el, directionality):
         """Check directionality."""
 
@@ -818,11 +844,45 @@ class CSSMatch(object):
 
         return match
 
-    def is_html_ns(self, el):
-        """Check if in HTML namespace."""
+    def select(self, limit=0):
+        """Sieve."""
 
-        ns = getattr(el, 'namespace') if el else None
-        return ns and ns == NS_XHTML
+        if limit < 1:
+            limit = None
+
+        for child in self.tag.descendants:
+            if util.is_tag(child) and self.match(child):
+                yield child
+                if limit is not None:
+                    limit -= 1
+                    if limit < 1:
+                        break
+
+    def comments(self, limit=0):
+        """Sieve."""
+
+        if limit < 1:
+            limit = None
+
+        for child in self.tag.descendants:
+            if util.is_comment(child):
+                yield child
+                if limit is not None:
+                    limit -= 1
+                    if limit < 1:
+                        break
+
+    def closest(self):
+        """Match closest ancestor."""
+
+        current = self.tag
+        closest = None
+        while closest is None and current is not None:
+            if self.match(current) and self.is_ancestor(current):
+                closest = current
+            else:
+                current = current.parent
+        return closest
 
     def match(self, el):
         """Match."""
@@ -845,45 +905,16 @@ class SoupSieve(ct.Immutable):
             flags=flags
         )
 
-    def _walk(self, tag, capture=True, comments=False):
-        """Recursively return selected tags."""
-
-        match = CSSMatch(self.selectors, tag, self.namespaces, self.flags).match
-
-        # Walk children
-        for child in tag.descendants:
-            if capture and util.is_tag(child) and match(child):
-                yield child
-            elif comments and util.is_comment(child):
-                yield child
-
-    def _sieve(self, tag, capture=True, comments=False, limit=0):
-        """Sieve."""
-
-        self._is_valid_input(tag)
-        if limit < 1:
-            limit = None
-
-        for child in self._walk(tag, capture, comments):
-            yield child
-            if limit is not None:
-                limit -= 1
-                if limit < 1:
-                    break
-
-    def _is_valid_input(self, tag):
-        """Check if valid input tag."""
-
-        # Fail on unexpected types.
-        if not util.is_tag(tag):
-            raise TypeError("Expected a BeautifulSoup 'Tag', but instead recieved type {}".format(type(tag)))
-
     def match(self, tag):
         """Match."""
 
-        self._is_valid_input(tag)
         return CSSMatch(self.selectors, tag, self.namespaces, self.flags).match(tag)
 
+    def closest(self, tag):
+        """Match closest ancestor."""
+
+        return CSSMatch(self.selectors, tag, self.namespaces, self.flags).closest()
+
     def filter(self, iterable):  # noqa A001
         """Filter."""
 
@@ -897,7 +928,7 @@ class SoupSieve(ct.Immutable):
     def icomments(self, tag, limit=0):
         """Iterate comments only."""
 
-        for comment in self._sieve(tag, capture=False, comments=True, limit=limit):
+        for comment in CSSMatch(self.selectors, tag, self.namespaces, self.flags).comments(limit):
             yield comment
 
     def select_one(self, tag):
@@ -914,7 +945,7 @@ class SoupSieve(ct.Immutable):
     def iselect(self, tag, limit=0):
         """Iterate the specified tags."""
 
-        for el in self._sieve(tag, limit=limit):
+        for el in CSSMatch(self.selectors, tag, self.namespaces, self.flags).select(limit):
             yield el
 
     def __repr__(self):  # pragma: no cover


Test patch:
diff --git a/tests/test_soupsieve.py b/tests/test_soupsieve.py
index a8d0c5c..7acb382 100644
--- a/tests/test_soupsieve.py
+++ b/tests/test_soupsieve.py
@@ -147,6 +147,31 @@ class TestSoupSieve(unittest.TestCase):
         self.assertEqual(len(nodes), 1)
         self.assertEqual(nodes[0].attrs['id'], '6')
 
+    def test_closest(self):
+        """Test closest."""
+
+        markup = """
+        <article id="article">
+          <div id="div-01">Here is div-01
+            <div id="div-02">Here is div-02
+              <div id="div-04">Here is div-04</div>
+              <div id="div-03">Here is div-03</div>
+            </div>
+            <div id="div-05">Here is div-05</div>
+          </div>
+        </article>
+        """
+
+        soup = bs4.BeautifulSoup(markup, 'html5lib')
+        el = sv.select_one('#div-03', soup)
+
+        self.assertTrue(sv.closest('#div-02', el).attrs['id'] == 'div-02')
+        self.assertTrue(sv.closest('div div', el).attrs['id'] == 'div-03')
+        self.assertTrue(sv.closest('article > div', el).attrs['id'] == 'div-01')
+        self.assertTrue(sv.closest(':not(div)', el).attrs['id'] == 'article')
+        self.assertTrue(sv.closest('div #div-05', el) is None)
+        self.assertTrue(sv.closest('a', el) is None)
+
     def test_copy_pickle(self):
         """Test copy and pickle."""