Skip to content

Commit 9e26f64

Browse files
authored
Allow negative indexes (#2552)
* Allow negative indexes Resolves #2517
1 parent 1789d60 commit 9e26f64

File tree

6 files changed

+117
-12
lines changed

6 files changed

+117
-12
lines changed

docs/src/markdown/about/changelog.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
# Changelog
22

3-
## 10.12.1
3+
## 10.13
44

55
- **NEW**: Snippets: Allow multiple line numbers or line number blocks separated by `,`.
6+
- **NEW**: Snippets: Allow using a negative index for number start indexes and end indexes. Negative indexes are
7+
converted to positive indexes based on the number of lines in the snippet.
8+
- **FIX**: Snippets: Properly capture empty newline at end of file.
69
- **FIX**: Snippets: Fix issue where when non sections of files are included, section labels are not stripped.
710
- **FIX**: BetterEm: Fixes for complex cases.
811

pymdownx/__meta__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,5 +185,5 @@ def parse_version(ver, pre=False):
185185
return Version(major, minor, micro, release, pre, post, dev)
186186

187187

188-
__version_info__ = Version(10, 12, 1, "final")
188+
__version_info__ = Version(10, 13, 0, "final")
189189
__version__ = __version_info__._get_canonical()

pymdownx/snippets.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ class SnippetPreprocessor(Preprocessor):
7575
)
7676

7777
RE_SNIPPET_FILE = re.compile(
78-
r'(?i)(.*?)(?:((?::[0-9]*){1,2}(?:(?:,(?=[0-9:])[0-9]*)(?::[0-9]*)?)*)|(:[a-z][-_0-9a-z]*))?$'
78+
r'(?i)(.*?)(?:((?::-?[0-9]*){1,2}(?:(?:,(?=[-0-9:])-?[0-9]*)(?::-?[0-9]*)?)*)|(:[a-z][-_0-9a-z]*))?$'
7979
)
8080

8181
def __init__(self, config, md):
@@ -222,7 +222,11 @@ def download(self, url):
222222
content = response.read()
223223

224224
# Process lines
225-
return [l.decode(self.encoding) for l in content.splitlines()]
225+
last = content.endswith((b'\r', b'\n'))
226+
s_lines = [l.decode(self.encoding) for l in content.splitlines()]
227+
if last:
228+
s_lines.append('')
229+
return s_lines
226230

227231
def parse_snippets(self, lines, file_name=None, is_url=False, is_section=False):
228232
"""Parse snippets snippet."""
@@ -314,8 +318,10 @@ def parse_snippets(self, lines, file_name=None, is_url=False, is_section=False):
314318
if m.group(2):
315319
for nums in m.group(2)[1:].split(','):
316320
span = nums.split(':')
317-
start.append(max(0, int(span[0]) - 1) if span[0] else None)
318-
end.append(int(span[1]) if len(span) > 1 and span[1] else None)
321+
st = int(span[0]) if span[0] else None
322+
start.append(st if st is None or st < 0 else max(0, st - 1))
323+
en = int(span[1]) if len(span) > 1 and span[1] else None
324+
end.append(en if en is None or en >= 0 else en)
319325
elif m.group(3):
320326
section = m.group(3)[1:]
321327

@@ -338,7 +344,13 @@ def parse_snippets(self, lines, file_name=None, is_url=False, is_section=False):
338344
if not url:
339345
# Read file content
340346
with codecs.open(snippet, 'r', encoding=self.encoding) as f:
341-
s_lines = [l.rstrip('\r\n') for l in f]
347+
last = False
348+
s_lines = []
349+
for l in f:
350+
last = l.endswith(('\r', '\n'))
351+
s_lines.append(l.strip('\r\n'))
352+
if last:
353+
s_lines.append('')
342354
else:
343355
# Read URL content
344356
try:
@@ -349,10 +361,13 @@ def parse_snippets(self, lines, file_name=None, is_url=False, is_section=False):
349361
s_lines = []
350362

351363
if s_lines:
364+
total = len(s_lines)
352365
if start and end:
353366
final_lines = []
354-
for entry in zip(start, end):
355-
final_lines.extend(s_lines[slice(entry[0], entry[1], None)])
367+
for sel in zip(start, end):
368+
s_start = util.clamp(total + sel[0], 0, total) if sel[0] and sel[0] < 0 else sel[0]
369+
s_end = util.clamp(total + 1 + sel[1], 0, total) if sel[1] and sel[1] < 0 else sel[1]
370+
final_lines.extend(s_lines[slice(s_start, s_end, None)])
356371
s_lines = self.dedent(final_lines) if self.dedent_subsections else final_lines
357372
elif section:
358373
s_lines = self.extract_section(section, s_lines)

pymdownx/util.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,19 @@
3232
PY39 = (3, 9) <= sys.version_info
3333

3434

35+
def clamp(value, mn, mx):
36+
"""Clamp the value to the given minimum and maximum."""
37+
38+
if mn is not None and mx is not None:
39+
return max(min(value, mx), mn)
40+
elif mn is not None:
41+
return max(value, mn)
42+
elif mx is not None:
43+
return min(value, mx)
44+
else:
45+
return value
46+
47+
3548
def is_win(): # pragma: no cover
3649
"""Is Windows."""
3750

tests/test_extensions/test_snippets.py

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,9 @@ def test_inline(self):
108108
---8<--- "b.txt"
109109
''',
110110
R'''
111-
<p>Snippet
112-
Snippet
113-
---8&lt;--- "b.txt"</p>
111+
<p>Snippet</p>
112+
<p>Snippet</p>
113+
<p>---8&lt;--- "b.txt"</p>
114114
<ul>
115115
<li>
116116
<p>Testing indentation</p>
@@ -300,6 +300,67 @@ def test_start_multi_hanging_comma(self):
300300
True
301301
)
302302

303+
def test_negative_range(self):
304+
"""Test negative indexing range."""
305+
306+
self.check_markdown(
307+
R'''
308+
---8<--- "lines.txt:-3:-2"
309+
''',
310+
'''
311+
<p>This is the end of the file.
312+
There is no more.</p>
313+
''',
314+
True
315+
)
316+
317+
def test_negative_single(self):
318+
"""Test negative indexing single line."""
319+
320+
self.check_markdown(
321+
R'''
322+
---8<--- "lines.txt:-2:-2"
323+
''',
324+
'''
325+
<p>There is no more.</p>
326+
''',
327+
True
328+
)
329+
330+
def test_mixed_negative(self):
331+
"""Test negative indexing single line."""
332+
333+
self.check_markdown(
334+
R'''
335+
---8<--- "lines.txt:8:-2"
336+
337+
---8<--- "lines.txt:-3:9"
338+
''',
339+
'''
340+
<p>This is the end of the file.
341+
There is no more.</p>
342+
<p>This is the end of the file.
343+
There is no more.</p>
344+
''',
345+
True
346+
)
347+
348+
def test_start_negative_multi(self):
349+
"""Test multiple line specifiers with negative indexes."""
350+
351+
self.check_markdown(
352+
R'''
353+
---8<--- "lines.txt:1:2,-3:-2"
354+
''',
355+
'''
356+
<p>This is a multi-line
357+
snippet.
358+
This is the end of the file.
359+
There is no more.</p>
360+
''',
361+
True
362+
)
363+
303364
def test_end_line_inline(self):
304365
"""Test ending line with inline syntax."""
305366

tests/test_targeted.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,19 @@ def test_windows_relative_path(self):
138138
self.assertEqual(is_url, False)
139139
self.assertEqual(is_absolute, False)
140140

141+
def test_clamp(self):
142+
"""Test clamp."""
143+
144+
self.assertEqual(util.clamp(3, None, None), 3)
145+
self.assertEqual(util.clamp(3, 4, None), 4)
146+
self.assertEqual(util.clamp(4, 4, None), 4)
147+
self.assertEqual(util.clamp(4, None, 4), 4)
148+
self.assertEqual(util.clamp(5, None, 4), 4)
149+
self.assertEqual(util.clamp(3, 4, 6), 4)
150+
self.assertEqual(util.clamp(7, 4, 6), 6)
151+
self.assertEqual(util.clamp(4, 4, 6), 4)
152+
self.assertEqual(util.clamp(6, 4, 6), 6)
153+
141154

142155
def run():
143156
"""Run pytest."""

0 commit comments

Comments
 (0)