Repository: frostming/marko

Issue:
Parse error on mixed preceding whitespaces
Example doc:

```md
# Summary

* [主页](README.md)
* [安全技术]
    * Windows取证
\t* 常用命令
\t   * [基础网络命令](安全技术/常用命令/基础网络命令.md)
```

Comments:

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3c1a6e0..2f7da2c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -45,6 +45,7 @@ jobs:
       - name: Install dependencies
         run: |
           pdm use -f ${{ matrix.python-version }}
+          pdm config set parallel_install false
           pip install wheel
           pdm install -d -s toc -s codehilite
       - name: Run Tests
diff --git a/marko/_compat.py b/marko/_compat.py
index ba77dd7..2a12269 100644
--- a/marko/_compat.py
+++ b/marko/_compat.py
@@ -16,8 +16,17 @@ if PY2:
         s = s.encode("utf-8")
         return _quote(s, safe)
 
+    def lru_cache(maxsize=128, typed=False):
+        """Python 2.7 doesn't support LRU cache, return a fake decoractor."""
+
+        def decorator(f):
+            return f
+
+        return decorator
+
 
 else:
     string_types = str
     import html
     from urllib.parse import quote
+    from functools import lru_cache
diff --git a/marko/block.py b/marko/block.py
index a50e17c..c4f4fac 100644
--- a/marko/block.py
+++ b/marko/block.py
@@ -84,7 +84,7 @@ class Document(BlockElement):
     virtual = True
 
     def __init__(self, text):  # type: (str) -> None
-        self.link_ref_defs = {}   # type: Dict[str, Tuple[str, str]]
+        self.link_ref_defs = {}  # type: Dict[str, Tuple[str, str]]
         source = Source(text)
         inline._root_node = self  # type: ignore
         with source.under_state(self):
@@ -168,7 +168,7 @@ class CodeBlock(BlockElement):
         if isinstance(source.state, Quote):
             # requires five spaces to prefix
             prefix = source.prefix[:-1] + " {4}"
-        return cls.strip_prefix(line, prefix)   # type: ignore
+        return cls.strip_prefix(line, prefix)  # type: ignore
 
     @classmethod
     def parse(cls, source):  # type: (Source) -> str
@@ -229,9 +229,9 @@ class FencedCode(BlockElement):
         if not m:
             return None
         prefix, leading, info = m.groups()
-        if leading[0] == '`' and '`' in info:
+        if leading[0] == "`" and "`" in info:
             return None
-        lang, extra = (info.split(None, 1) + [''] * 2)[:2]
+        lang, extra = (info.split(None, 1) + [""] * 2)[:2]
         cls._parse_info = prefix, leading, lang, extra
         return m
 
@@ -281,7 +281,7 @@ class HTMLBlock(BlockElement):
     """HTML blocks, parsed as it is"""
 
     priority = 5
-    _end_cond = None    # Optional[Match]
+    _end_cond = None  # Optional[Match]
 
     def __init__(self, lines):  # type: (str) -> None
         self.children = lines
@@ -532,7 +532,15 @@ class ListItem(BlockElement):
             return False
         if not source.expect_re(cls.pattern):
             return False
-        indent, bullet, mid, tail = cls.parse_leading(source.next_line())  # type: ignore
+        next_line = source.next_line(False)
+        assert next_line is not None
+        for i in range(1, len(next_line) + 1):
+            m = re.match(source.prefix, next_line[:i].expandtabs(4))
+            if not m:
+                continue
+            next_line = next_line[:i].expandtabs(4)[m.end() :] + next_line[i:]
+            break
+        indent, bullet, mid, tail = cls.parse_leading(next_line)  # type: ignore
         parent = source.state
         assert isinstance(parent, List)
         if (
diff --git a/marko/cli.py b/marko/cli.py
index cb1d301..c4ddccb 100644
--- a/marko/cli.py
+++ b/marko/cli.py
@@ -41,15 +41,11 @@ def parse(args):
         default="marko.HTMLRenderer",
         help="Specify another renderer class",
     )
-    parser.add_argument(
-        "-o",
-        "--output",
-        help="Ouput to a file"
-    )
+    parser.add_argument("-o", "--output", help="Ouput to a file")
     parser.add_argument(
         "document",
         nargs="?",
-        help="The document to convert, will use stdin if not given."
+        help="The document to convert, will use stdin if not given.",
     )
     return parser.parse_args(args)
 
diff --git a/marko/helpers.py b/marko/helpers.py
index 9e6654d..c733baf 100644
--- a/marko/helpers.py
+++ b/marko/helpers.py
@@ -6,7 +6,7 @@ from contextlib import contextmanager
 from importlib import import_module
 import warnings
 
-from ._compat import string_types
+from ._compat import string_types, lru_cache
 
 
 def camel_to_snake_case(name):  # type: (str) -> str
@@ -102,6 +102,7 @@ class Source(object):
         return regexp.match(self._buffer, pos)
 
     @staticmethod
+    @lru_cache()
     def match_prefix(prefix, line):  # type: (str, str) -> int
         """Check if the line starts with given prefix and
         return the position of the end of prefix.


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
index ecdd6c2..1b0364f 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -7,10 +7,8 @@ from tests.normalize import normalize_html
 
 TEST_ROOT = os.path.dirname(__file__)
 EXAMPLE_PATTERN = re.compile(
-    r'^`{32} example\n([\s\S]*?)'
-    r'^\.\n([\s\S]*?)'
-    r'^`{32}$|^#{1,6} *(.*)$',
-    flags=re.M
+    r"^`{32} example\n([\s\S]*?)" r"^\.\n([\s\S]*?)" r"^`{32}$|^#{1,6} *(.*)$",
+    flags=re.M,
 )
 
 
@@ -22,31 +20,30 @@ def parse_examples(text):
     for md, html, title in data:
         if title:
             count = 0
-            section = title.lower().replace(' ', '_')
+            section = title.lower().replace(" ", "_")
 
         if md and html:
             count += 1
-            name = '%s_%03d' % (section, count)
-            md = md.replace(u'→', '\t')
-            html = html.replace(u'→', '\t')
+            name = "%s_%03d" % (section, count)
+            md = md.replace(u"→", "\t")
+            html = html.replace(u"→", "\t")
             yield name, md, html
 
 
 class SpecTestSuite:
-
     @classmethod
     def load_spec(cls, spec_name):
         def attach_case(n, md, html):
             def method(self):
                 self.assert_case(md, html)
 
-            name = 'test_{}'.format(n)
+            name = "test_{}".format(n)
             method.__name__ = name
-            method.__doc__ = 'Run spec {} - {}'.format(spec_name, n)
+            method.__doc__ = "Run spec {} - {}".format(spec_name, n)
             setattr(cls, name, method)
 
-        spec_file = os.path.join(TEST_ROOT, 'spec/{}.txt'.format(spec_name))
-        with codecs.open(spec_file, encoding='utf-8') as f:
+        spec_file = os.path.join(TEST_ROOT, "spec/{}.txt".format(spec_name))
+        with codecs.open(spec_file, encoding="utf-8") as f:
             for name, md, html in parse_examples(f.read()):
                 if not cls.ignore_case(name):
                     attach_case(name, md, html)
@@ -58,3 +55,9 @@ class SpecTestSuite:
     def assert_case(self, text, html):
         result = self.markdown(text)
         assert normalize_html(result) == normalize_html(html)
+
+    # Extra cases that are not included
+    def test_mixed_tab_space_in_list_item(self):
+        text = "* foo\n\t* foo.bar"
+        html = "<ul><li>foo<ul><li>foo.bar</li></ul></li></ul>"
+        self.assert_case(text, html)
diff --git a/tests/test_spec.py b/tests/test_spec.py
index 8d90625..cb3df7d 100644
--- a/tests/test_spec.py
+++ b/tests/test_spec.py
@@ -4,23 +4,17 @@ from marko.ext.gfm import gfm
 
 
 class TestCommonMark(SpecTestSuite):
-
     @classmethod
     def setup_class(cls):
         cls.markdown = Markdown()
 
 
-TestCommonMark.load_spec('commonmark')
+TestCommonMark.load_spec("commonmark")
 
-GFM_IGNORE = [
-    'autolinks_015',
-    'autolinks_018',
-    'autolinks_019'
-]
+GFM_IGNORE = ["autolinks_015", "autolinks_018", "autolinks_019"]
 
 
 class TestGFM(SpecTestSuite):
-
     @classmethod
     def setup_class(cls):
         cls.markdown = gfm
@@ -30,4 +24,4 @@ class TestGFM(SpecTestSuite):
         return n in GFM_IGNORE
 
 
-TestGFM.load_spec('gfm')
+TestGFM.load_spec("gfm")