Repository: facelessuser/soupsieve

Issue:
Add escape function to escape CSS identifiers
From the spec:

- If the character is NULL (U+0000), then the REPLACEMENT CHARACTER (U+FFFD).
- If the character is in the range [\1-\1f] (U+0001 to U+001F) or is U+007F, then the character escaped as code point.
- If the character is the first character and is in the range [0-9] (U+0030 to U+0039), then the character escaped as code point.
- If the character is the second character and is in the range [0-9] (U+0030 to U+0039) and the first character is a "-" (U+002D), then the character escaped as code point.
- If the character is the first character and is a "-" (U+002D), and there is no second character, then the escaped character.
- If the character is not handled by one of the above rules and is greater than or equal to U+0080, is "-" (U+002D) or "_" (U+005F), or is in one of the ranges [0-9] (U+0030 to U+0039), [A-Z] (U+0041 to U+005A), or \[a-z] (U+0061 to U+007A), then the character itself.
Otherwise, the escaped character.

Discussion:
facelessuser: We need tests, but we also need to make sure that wide characters on narrow Python 2.7 is handled properly.

Patch:
diff --git a/docs/src/markdown/_snippets/links.txt b/docs/src/markdown/_snippets/links.txt
index 9e2f5aa..4757541 100644
--- a/docs/src/markdown/_snippets/links.txt
+++ b/docs/src/markdown/_snippets/links.txt
@@ -1,6 +1,7 @@
 [aspell]: https://github.com/GNUAspell/aspell
 [bs4]: https://beautiful-soup-4.readthedocs.io/en/latest/#
 [contains-draft]: https://www.w3.org/TR/2001/CR-css3-selectors-20011113/#content-selectors
+[cssom]: https://drafts.csswg.org/cssom/#common-serializing-idioms
 [custom-extensions-1]: https://drafts.csswg.org/css-extensions-1/
 [html5lib]: https://github.com/html5lib/html5lib-python
 [lxml]: https://github.com/lxml/lxml
diff --git a/docs/src/markdown/about/changelog.md b/docs/src/markdown/about/changelog.md
index a095b4b..8314f45 100644
--- a/docs/src/markdown/about/changelog.md
+++ b/docs/src/markdown/about/changelog.md
@@ -2,11 +2,12 @@
 
 ## 1.9.0
 
-- **NEW**: Allow `:contans()` to accept a list of text to search for.
-- **FIX**: Don't install test files when installing the `soupsieve` package.
+- **NEW**: Allow `:contans()` to accept a list of text to search for. (#115)
+- **NEW**: Add new `escape` function for escaping CSS identifiers. (#125)
+- **FIX**: Don't install test files when installing the `soupsieve` package. (#111)
 - **FIX**: Improve efficiency of `:contains()` comparison.
 - **FIX**: Null characters should translate to the Unicode REPLACEMENT CHARACTER (`U+FFFD`) according to the
-specification. This applies to CSS escaped NULL characters as well.
+specification. This applies to CSS escaped NULL characters as well. (#124)
 - **FIX**: Escaped EOF should translate to `U+FFFD` outside of CSS strings. In a string, they should just be ignored,
 but as there is no case where we could resolve such a string and still have a valid selector, string handling remains
 the same. (#128)
diff --git a/docs/src/markdown/api.md b/docs/src/markdown/api.md
index f40aabb..83e3bbf 100644
--- a/docs/src/markdown/api.md
+++ b/docs/src/markdown/api.md
@@ -141,7 +141,7 @@ and flags.
 
 ## `soupsieve.comments()`
 
-```
+```py3
 def comments(tag, limit=0, flags=0, **kwargs):
     """Get comments only."""
 ```
@@ -153,13 +153,35 @@ from the given tag down through all of its children.  You can limit how many com
 
 ## `soupsieve.icomments()`
 
-```
+```py3
 def icomments(node, limit=0, flags=0, **kwargs):
     """Get comments only."""
 ```
 
 `icomments` is exactly like `comments` except that it returns a generator instead of a list.
 
+## `soupsieve.escape()`
+
+```py3
+def escape(ident):
+    """Escape CSS identifier."""
+```
+
+`escape` is used to escape CSS identifiers. It follows the [CSS specification][cssom] and escapes any character that
+would normally cause an identifier to be invalid.
+
+```pycon3
+'\\.foo\\#bar'
+>>> sv.escape("()[]{}")
+'\\(\\)\\[\\]\\{\\}'
+>>> sv.escape('--a')
+'--a'
+>>> sv.escape('0')
+'\\30 '
+>>> sv.escape('\0')
+'�'
+```
+
 ## `soupsieve.compile()`
 
 ```py3
diff --git a/soupsieve/__init__.py b/soupsieve/__init__.py
index d75c4ef..16c501b 100644
--- a/soupsieve/__init__.py
+++ b/soupsieve/__init__.py
@@ -117,3 +117,9 @@ def iselect(select, tag, namespaces=None, limit=0, flags=0, **kwargs):
 
     for el in compile(select, namespaces, flags, **kwargs).iselect(tag, limit):
         yield el
+
+
+def escape(ident):
+    """Escape identifier."""
+
+    return cp.escape(ident)
diff --git a/soupsieve/css_parser.py b/soupsieve/css_parser.py
index 88b3db4..66481c7 100644
--- a/soupsieve/css_parser.py
+++ b/soupsieve/css_parser.py
@@ -261,6 +261,34 @@ def css_unescape(content, string=False):
     return (RE_CSS_ESC if not string else RE_CSS_STR_ESC).sub(replace, content)
 
 
+def escape(ident):
+    """Escape identifier."""
+
+    string = []
+    length = len(ident)
+    start_dash = length > 0 and ident[0] == '-'
+    if length == 1 and start_dash:
+        # Need to escape identifier that is a single `-` with no other characters
+        string.append('\\{}'.format(ident))
+    else:
+        for index, c in enumerate(ident):
+            codepoint = util.uord(c)
+            if codepoint == 0x00:
+                string.append('\ufffd')
+            elif (0x01 <= codepoint <= 0x1F) or codepoint == 0x7F:
+                string.append('\\{:x} '.format(codepoint))
+            elif (index == 0 or (start_dash and index == 1)) and (0x30 <= codepoint <= 0x39):
+                string.append('\\{:x} '.format(codepoint))
+            elif (
+                codepoint in (0x2D, 0x5F) or codepoint >= 0x80 or (0x30 <= codepoint <= 0x39) or
+                (0x30 <= codepoint <= 0x39) or (0x41 <= codepoint <= 0x5A) or (0x61 <= codepoint <= 0x7A)
+            ):
+                string.append(c)
+            else:
+                string.append('\\{}'.format(c))
+    return ''.join(string)
+
+
 class SelectorPattern(object):
     """Selector pattern."""
 
diff --git a/soupsieve/util.py b/soupsieve/util.py
index 18bc192..976611c 100644
--- a/soupsieve/util.py
+++ b/soupsieve/util.py
@@ -71,6 +71,18 @@ def uchr(i):
         return struct.pack('i', i).decode('utf-32')
 
 
+def uord(c):
+    """Get Unicode ordinal."""
+
+    if len(c) == 2:  # pragma: no cover
+        high, low = [ord(p) for p in c]
+        ordinal = (high - 0xD800) * 0x400 + low - 0xDC00 + 0x10000
+    else:
+        ordinal = ord(c)
+
+    return ordinal
+
+
 class SelectorSyntaxError(SyntaxError):
     """Syntax error in a CSS selector."""
 


Test patch:
diff --git a/tests/test_api.py b/tests/test_api.py
index c389870..baf2d83 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -450,6 +450,39 @@ class TestSoupSieve(util.TestCase):
         self.assertTrue(sv.closest('div #div-05', el) is None)
         self.assertTrue(sv.closest('a', el) is None)
 
+    def test_escape_hyphen(self):
+        """Test escape hyphen cases."""
+
+        self.assertEqual(r'\-', sv.escape('-'))
+        self.assertEqual(r'--', sv.escape('--'))
+
+    def test_escape_numbers(self):
+        """Test escape hyphen cases."""
+
+        self.assertEqual(r'\33 ', sv.escape('3'))
+        self.assertEqual(r'-\33 ', sv.escape('-3'))
+        self.assertEqual(r'--3', sv.escape('--3'))
+
+    def test_escape_null(self):
+        """Test escape null character."""
+
+        self.assertEqual('\ufffdtest', sv.escape('\x00test'))
+
+    def test_escape_ctrl(self):
+        """Test escape control character."""
+
+        self.assertEqual(r'\1 test', sv.escape('\x01test'))
+
+    def test_escape_special(self):
+        """Test escape special character."""
+
+        self.assertEqual(r'\{\}\[\]\ \(\)', sv.escape('{}[] ()'))
+
+    def test_escape_wide_unicode(self):
+        """Test handling of wide Unicode."""
+
+        self.assertEqual('Emoji\\ \U0001F60D', sv.escape('Emoji \U0001F60D'))
+
     def test_copy_pickle(self):
         """Test copy and pickle."""