Skip to content

Commit

Permalink
Allow negative indexes (#2552)
Browse files Browse the repository at this point in the history
* Allow negative indexes

Resolves #2517
  • Loading branch information
facelessuser authored Dec 22, 2024
1 parent 1789d60 commit 9e26f64
Show file tree
Hide file tree
Showing 6 changed files with 117 additions and 12 deletions.
5 changes: 4 additions & 1 deletion docs/src/markdown/about/changelog.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
2 changes: 1 addition & 1 deletion pymdownx/__meta__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
29 changes: 22 additions & 7 deletions pymdownx/snippets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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:]

Expand All @@ -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:
Expand All @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions pymdownx/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
67 changes: 64 additions & 3 deletions tests/test_extensions/test_snippets.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,9 @@ def test_inline(self):
---8<--- "b.txt"
''',
R'''
<p>Snippet
Snippet
---8&lt;--- "b.txt"</p>
<p>Snippet</p>
<p>Snippet</p>
<p>---8&lt;--- "b.txt"</p>
<ul>
<li>
<p>Testing indentation</p>
Expand Down Expand Up @@ -300,6 +300,67 @@ def test_start_multi_hanging_comma(self):
True
)

def test_negative_range(self):
"""Test negative indexing range."""

self.check_markdown(
R'''
---8<--- "lines.txt:-3:-2"
''',
'''
<p>This is the end of the file.
There is no more.</p>
''',
True
)

def test_negative_single(self):
"""Test negative indexing single line."""

self.check_markdown(
R'''
---8<--- "lines.txt:-2:-2"
''',
'''
<p>There is no more.</p>
''',
True
)

def test_mixed_negative(self):
"""Test negative indexing single line."""

self.check_markdown(
R'''
---8<--- "lines.txt:8:-2"
---8<--- "lines.txt:-3:9"
''',
'''
<p>This is the end of the file.
There is no more.</p>
<p>This is the end of the file.
There is no more.</p>
''',
True
)

def test_start_negative_multi(self):
"""Test multiple line specifiers with negative indexes."""

self.check_markdown(
R'''
---8<--- "lines.txt:1:2,-3:-2"
''',
'''
<p>This is a multi-line
snippet.
This is the end of the file.
There is no more.</p>
''',
True
)

def test_end_line_inline(self):
"""Test ending line with inline syntax."""

Expand Down
13 changes: 13 additions & 0 deletions tests/test_targeted.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,19 @@ def test_windows_relative_path(self):
self.assertEqual(is_url, False)
self.assertEqual(is_absolute, False)

def test_clamp(self):
"""Test clamp."""

self.assertEqual(util.clamp(3, None, None), 3)
self.assertEqual(util.clamp(3, 4, None), 4)
self.assertEqual(util.clamp(4, 4, None), 4)
self.assertEqual(util.clamp(4, None, 4), 4)
self.assertEqual(util.clamp(5, None, 4), 4)
self.assertEqual(util.clamp(3, 4, 6), 4)
self.assertEqual(util.clamp(7, 4, 6), 6)
self.assertEqual(util.clamp(4, 4, 6), 4)
self.assertEqual(util.clamp(6, 4, 6), 6)


def run():
"""Run pytest."""
Expand Down

0 comments on commit 9e26f64

Please sign in to comment.