facelessuser: We need tests, but we also need to make sure that wide characters on narrow Python 2.7 is handled properly.
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."""
 

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.
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."""
 

pip install -e .
parse_log_pytest
requirements.txt
pytest pytest-cov coverage lxml html5lib mypy
apt-get update apt-get install -y gcc
3.9
requirements/project.txt requirements/tests.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
beautifulsoup4==4.13.3
coverage==7.8.0
exceptiongroup==1.2.2
html5lib==1.1
iniconfig==2.1.0
lxml==5.3.1
mypy==1.15.0
mypy-extensions==1.0.0
packaging==24.2
pluggy==1.5.0
pytest==8.3.5
pytest-cov==6.0.0
six==1.17.0
-e git+https://github.com/facelessuser/soupsieve.git@f335fbbf0640bacbd1e0be3b85aaf7645b3114f9#egg=soupsieve
tomli==2.2.1
typing_extensions==4.13.0
webencodings==0.5.1

name: soupsieve
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - beautifulsoup4==4.13.3
      - coverage==7.8.0
      - exceptiongroup==1.2.2
      - html5lib==1.1
      - iniconfig==2.1.0
      - lxml==5.3.1
      - mypy==1.15.0
      - mypy-extensions==1.0.0
      - packaging==24.2
      - pluggy==1.5.0
      - pytest==8.3.5
      - pytest-cov==6.0.0
      - six==1.17.0
      - tomli==2.2.1
      - typing-extensions==4.13.0
      - webencodings==0.5.1
prefix: /opt/conda/envs/soupsieve

tests/test_api.py::TestSoupSieve::test_escape_ctrl tests/test_api.py::TestSoupSieve::test_escape_hyphen tests/test_api.py::TestSoupSieve::test_escape_null tests/test_api.py::TestSoupSieve::test_escape_numbers tests/test_api.py::TestSoupSieve::test_escape_special tests/test_api.py::TestSoupSieve::test_escape_wide_unicode
tests/test_api.py::TestSoupSieve::test_cache tests/test_api.py::TestSoupSieve::test_closest_match_complex_parent tests/test_api.py::TestSoupSieve::test_closest_match_parent tests/test_api.py::TestSoupSieve::test_closest_match_self tests/test_api.py::TestSoupSieve::test_closest_must_be_parent tests/test_api.py::TestSoupSieve::test_comments tests/test_api.py::TestSoupSieve::test_compiled_comments tests/test_api.py::TestSoupSieve::test_compiled_icomments tests/test_api.py::TestSoupSieve::test_copy_pickle tests/test_api.py::TestSoupSieve::test_filter_list tests/test_api.py::TestSoupSieve::test_filter_tag tests/test_api.py::TestSoupSieve::test_icomments tests/test_api.py::TestSoupSieve::test_immutable_dict_size tests/test_api.py::TestSoupSieve::test_iselect tests/test_api.py::TestSoupSieve::test_match tests/test_api.py::TestSoupSieve::test_recompile tests/test_api.py::TestSoupSieve::test_select tests/test_api.py::TestSoupSieve::test_select_limit tests/test_api.py::TestSoupSieve::test_select_one tests/test_api.py::TestSoupSieve::test_select_one_none tests/test_api.py::TestSoupSieve::test_select_order tests/test_api.py::TestInvalid::test_immutable_dict_hashable_key tests/test_api.py::TestInvalid::test_immutable_dict_hashable_value tests/test_api.py::TestInvalid::test_immutable_dict_read_only tests/test_api.py::TestInvalid::test_immutable_object tests/test_api.py::TestInvalid::test_invalid_custom_hashable_key tests/test_api.py::TestInvalid::test_invalid_custom_hashable_value tests/test_api.py::TestInvalid::test_invalid_custom_type tests/test_api.py::TestInvalid::test_invalid_namespace_hashable_key tests/test_api.py::TestInvalid::test_invalid_namespace_hashable_value tests/test_api.py::TestInvalid::test_invalid_namespace_type tests/test_api.py::TestInvalid::test_invalid_type_input_comments tests/test_api.py::TestInvalid::test_invalid_type_input_filter tests/test_api.py::TestInvalid::test_invalid_type_input_match tests/test_api.py::TestInvalid::test_invalid_type_input_select tests/test_api.py::TestInvalidQuirks::test_immutable_dict_hashable_key tests/test_api.py::TestInvalidQuirks::test_immutable_dict_hashable_value tests/test_api.py::TestInvalidQuirks::test_immutable_dict_read_only tests/test_api.py::TestInvalidQuirks::test_immutable_object tests/test_api.py::TestInvalidQuirks::test_invalid_custom_hashable_key tests/test_api.py::TestInvalidQuirks::test_invalid_custom_hashable_value tests/test_api.py::TestInvalidQuirks::test_invalid_custom_type tests/test_api.py::TestInvalidQuirks::test_invalid_namespace_hashable_key tests/test_api.py::TestInvalidQuirks::test_invalid_namespace_hashable_value tests/test_api.py::TestInvalidQuirks::test_invalid_namespace_type tests/test_api.py::TestInvalidQuirks::test_invalid_type_input_comments tests/test_api.py::TestInvalidQuirks::test_invalid_type_input_filter tests/test_api.py::TestInvalidQuirks::test_invalid_type_input_match tests/test_api.py::TestInvalidQuirks::test_invalid_type_input_select tests/test_api.py::TestInvalidQuirks::test_quirks_warn_attribute_unquoted tests/test_api.py::TestInvalidQuirks::test_quirks_warn_relative_combinator tests/test_api.py::TestSyntaxErrorReporting::test_simple_syntax_error tests/test_api.py::TestSyntaxErrorReporting::test_syntax_error_has_text_and_position tests/test_api.py::TestSyntaxErrorReporting::test_syntax_error_on_third_line tests/test_api.py::TestSyntaxErrorReporting::test_syntax_error_with_multiple_lines
docs/src/markdown/api.md docs/src/markdown/about/changelog.md soupsieve/__init__.py soupsieve/css_parser.py docs/src/markdown/_snippets/links.txt soupsieve/util.py
docs/src/markdown/api.md docs/src/markdown/about/changelog.md soupsieve/__init__.py soupsieve/css_parser.py docs/src/markdown/_snippets/links.txt soupsieve/util.py