From 9e26f64ee58d072745af553f5376bfa3bfa53e15 Mon Sep 17 00:00:00 2001 From: Isaac Muse Date: Sun, 22 Dec 2024 15:55:12 -0700 Subject: [PATCH] Allow negative indexes (#2552) * Allow negative indexes Resolves #2517 --- docs/src/markdown/about/changelog.md | 5 +- pymdownx/__meta__.py | 2 +- pymdownx/snippets.py | 29 ++++++++--- pymdownx/util.py | 13 +++++ tests/test_extensions/test_snippets.py | 67 ++++++++++++++++++++++++-- tests/test_targeted.py | 13 +++++ 6 files changed, 117 insertions(+), 12 deletions(-) diff --git a/docs/src/markdown/about/changelog.md b/docs/src/markdown/about/changelog.md index fc01d7630..561c042fa 100644 --- a/docs/src/markdown/about/changelog.md +++ b/docs/src/markdown/about/changelog.md @@ -1,8 +1,11 @@ # Changelog -## 10.12.1 +## 10.13 - **NEW**: Snippets: Allow multiple line numbers or line number blocks separated by `,`. +- **NEW**: Snippets: Allow using a negative index for number start indexes and end indexes. Negative indexes are + converted to positive indexes based on the number of lines in the snippet. +- **FIX**: Snippets: Properly capture empty newline at end of file. - **FIX**: Snippets: Fix issue where when non sections of files are included, section labels are not stripped. - **FIX**: BetterEm: Fixes for complex cases. diff --git a/pymdownx/__meta__.py b/pymdownx/__meta__.py index 0e9a48447..11a97bcbd 100644 --- a/pymdownx/__meta__.py +++ b/pymdownx/__meta__.py @@ -185,5 +185,5 @@ def parse_version(ver, pre=False): return Version(major, minor, micro, release, pre, post, dev) -__version_info__ = Version(10, 12, 1, "final") +__version_info__ = Version(10, 13, 0, "final") __version__ = __version_info__._get_canonical() diff --git a/pymdownx/snippets.py b/pymdownx/snippets.py index 0530e02b4..6cb175858 100644 --- a/pymdownx/snippets.py +++ b/pymdownx/snippets.py @@ -75,7 +75,7 @@ class SnippetPreprocessor(Preprocessor): ) RE_SNIPPET_FILE = re.compile( - r'(?i)(.*?)(?:((?::[0-9]*){1,2}(?:(?:,(?=[0-9:])[0-9]*)(?::[0-9]*)?)*)|(:[a-z][-_0-9a-z]*))?$' + r'(?i)(.*?)(?:((?::-?[0-9]*){1,2}(?:(?:,(?=[-0-9:])-?[0-9]*)(?::-?[0-9]*)?)*)|(:[a-z][-_0-9a-z]*))?$' ) def __init__(self, config, md): @@ -222,7 +222,11 @@ def download(self, url): content = response.read() # Process lines - return [l.decode(self.encoding) for l in content.splitlines()] + last = content.endswith((b'\r', b'\n')) + s_lines = [l.decode(self.encoding) for l in content.splitlines()] + if last: + s_lines.append('') + return s_lines def parse_snippets(self, lines, file_name=None, is_url=False, is_section=False): """Parse snippets snippet.""" @@ -314,8 +318,10 @@ def parse_snippets(self, lines, file_name=None, is_url=False, is_section=False): if m.group(2): for nums in m.group(2)[1:].split(','): span = nums.split(':') - start.append(max(0, int(span[0]) - 1) if span[0] else None) - end.append(int(span[1]) if len(span) > 1 and span[1] else None) + st = int(span[0]) if span[0] else None + start.append(st if st is None or st < 0 else max(0, st - 1)) + en = int(span[1]) if len(span) > 1 and span[1] else None + end.append(en if en is None or en >= 0 else en) elif m.group(3): section = m.group(3)[1:] @@ -338,7 +344,13 @@ def parse_snippets(self, lines, file_name=None, is_url=False, is_section=False): if not url: # Read file content with codecs.open(snippet, 'r', encoding=self.encoding) as f: - s_lines = [l.rstrip('\r\n') for l in f] + last = False + s_lines = [] + for l in f: + last = l.endswith(('\r', '\n')) + s_lines.append(l.strip('\r\n')) + if last: + s_lines.append('') else: # Read URL content try: @@ -349,10 +361,13 @@ def parse_snippets(self, lines, file_name=None, is_url=False, is_section=False): s_lines = [] if s_lines: + total = len(s_lines) if start and end: final_lines = [] - for entry in zip(start, end): - final_lines.extend(s_lines[slice(entry[0], entry[1], None)]) + for sel in zip(start, end): + s_start = util.clamp(total + sel[0], 0, total) if sel[0] and sel[0] < 0 else sel[0] + s_end = util.clamp(total + 1 + sel[1], 0, total) if sel[1] and sel[1] < 0 else sel[1] + final_lines.extend(s_lines[slice(s_start, s_end, None)]) s_lines = self.dedent(final_lines) if self.dedent_subsections else final_lines elif section: s_lines = self.extract_section(section, s_lines) diff --git a/pymdownx/util.py b/pymdownx/util.py index 187630988..bdfac3d73 100644 --- a/pymdownx/util.py +++ b/pymdownx/util.py @@ -32,6 +32,19 @@ PY39 = (3, 9) <= sys.version_info +def clamp(value, mn, mx): + """Clamp the value to the given minimum and maximum.""" + + if mn is not None and mx is not None: + return max(min(value, mx), mn) + elif mn is not None: + return max(value, mn) + elif mx is not None: + return min(value, mx) + else: + return value + + def is_win(): # pragma: no cover """Is Windows.""" diff --git a/tests/test_extensions/test_snippets.py b/tests/test_extensions/test_snippets.py index 065d4369b..02128d8b5 100644 --- a/tests/test_extensions/test_snippets.py +++ b/tests/test_extensions/test_snippets.py @@ -108,9 +108,9 @@ def test_inline(self): ---8<--- "b.txt" ''', R''' -

Snippet - Snippet - ---8<--- "b.txt"

+

Snippet

+

Snippet

+

---8<--- "b.txt"