Repository: facelessuser/pymdown-extensions

Issue:
Allow indexing from end
### Description

In the current state, when including specific line numbers, we can target specific lines.

1. To set starting line number: `myfile:3`
    * This will include the entire file STARTING at line number 3.
2. For lines up to a specific line: `myfile::10`
    * This will include lines 1 - 10
3. For a range of lines: `myfile:4:12`
    * This will include lines 4 - 12.
4. To include a SPECIFIC line: `myfile:3:3`
    * This will only include line number 3
    
    
BUT, what if we wanted to include the last 10 lines of the file. There currently is not a way to index from the end of the file.

### Benefits

Easy feature, which adds more utility, for potentially niche use-cases.

### Solution Idea

Add the ability to do negative indexing:

Given, a file that is 100 lines long-

1. To set starting line number: `myfile:-3`
    * This will include lines 97-100
 2. For lines up to a specific line: `myfile::-51`
    * This will include lines 1 - 49
3. For a range of lines: `myfile:4:-25`
    * This will include lines 4 - 75.
4. To include a SPECIFIC line: `myfile:-0:-0`
    * This will only include the very last line.

Hints from issue comments:
@XtremeOwnageDotCom: The idea, is from a relatively new feature in c#

Related Docs:

* https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-8.0/ranges
* https://blog.ndepend.com/c-index-and-range-operators-explained/
@facelessuser: Just noting that I've seen this and will be considering it. I don't recall if there are any complications involved here, but generally, it feels like a reasonable request, assuming the logic fits well with what we are doing.@facelessuser: > ### Solution Idea
> Add the ability to do negative indexing:
> 
> Given, a file that is 100 lines long-
> 
> 1. To set starting line number: `myfile:-3`
>    
>    * This will include lines 97-100
> 2. For lines up to a specific line: `myfile::-51`
>    
>    * This will include lines 1 - 49
> 3. For a range of lines: `myfile:4:-25`
>    
>    * This will include lines 4 - 75.
> 4. To include a SPECIFIC line: `myfile:-0:-0`
>    
>    * This will only include the very last line.

Some comments on the proposal.

- Negative indexes will be converted from negative indexes to positive indexes based on the total number of lines. If you have a 100 lines, -1 is equivalent to line 100, etc.

- Negative zero has no significance. Lines increment from 1 to the end, so snippets reference lines with that assumption. When a user specifies zero in Snippets, it is currently clamped to 1 just for sanity, that will not change for a negative -0. As far as snippets is concerned, zero is zero, and you should not use zero, but if you do, it will be clamped to 1. So don't use zero.

- Snippets, previously, did not account for trailing, empty newlines in snippets. This will be rectified to help avoid confusion when viewing a file in an editor and trying to decide what the last line actually is.

Solution patch:
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."""
 


Test patch:
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'''
-            <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>
@@ -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."""
 
diff --git a/tests/test_targeted.py b/tests/test_targeted.py
index 7e3d48b25..35be55459 100644
--- a/tests/test_targeted.py
+++ b/tests/test_targeted.py
@@ -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."""