Skip to content

Commit

Permalink
Allow specifying a section in a snippet (#1825)
Browse files Browse the repository at this point in the history
* Allow specifying a section in a snippet

* Add documentation
  • Loading branch information
facelessuser committed Oct 22, 2022
1 parent 7981fda commit a5b09fc
Show file tree
Hide file tree
Showing 5 changed files with 204 additions and 7 deletions.
1 change: 1 addition & 0 deletions docs/src/markdown/about/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- **NEW**: Tabbed: Add new syntax to allow forcing a specific tab to be select by default.
- **NEW**: Snippets: Add new option to pass arbitrary HTTP headers.
- **NEW**: Snippets: Allow specifying sections in a snippet and including just the specified section.

## 9.6

Expand Down
38 changes: 38 additions & 0 deletions docs/src/markdown/extensions/snippets.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,44 @@ include.md::3
;--8<--
```

### Snippet Sections

!!! new "New 9.7"

Specifying snippet lines may not always be as the source could change moving, adding, and/or removing lines. A way
around this is to partition a snippet into named sections and then targeting a specific section to be included instead
of specific line numbers.

Snippet sections can be specified by surrounding a block of text with `--8<-- [start:name]` and `--8<-- [end:name]`.
Then a file can simply specify the snippet using the name instead of line numbers.

```
;--8<-- "include.md:name"
;--8<--
include.md:name
;--8<--
```

Unlike other snippet syntax, the section start and end syntax do not have to be on a line by themselves. This allows
you to embed them in comments depending on the file type. When a section is included, the line with the start and end
are always omitted.

If we wanted to include a function from a Python source, we could specify the snippet as follows:

```python
# --8<-- [start:func]
def my_function(var):
pass
# --8<-- [end:func]
```

And then just include it in our document:

```
;--8<-- "example.py:func"
```

### Escaping Snippets Notation

!!! new "New 9.6"
Expand Down
56 changes: 52 additions & 4 deletions pymdownx/snippets.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,16 @@ class SnippetPreprocessor(Preprocessor):
'''
)

RE_SNIPPET_FILE = re.compile(r'(.*?)(:[0-9]*)?(:[0-9]*)?$')
RE_SNIPPET_SECTION = re.compile(
r'''(?xi)
^.*?
(?P<inline_marker>-{2,}8<-{2,}[ \t]+)
\[[ \t]*(?P<type>start|end)[ \t]*:[ \t]*(?P<name>[a-z][0-9a-z]*)[ \t]*\]
.*?$
'''
)

RE_SNIPPET_FILE = re.compile(r'(?i)(.*?)(?:(:[0-9]*)?(:[0-9]*)?|(:[a-z][0-9a-z]*)?)$')

def __init__(self, config, md):
"""Initialize."""
Expand All @@ -82,6 +91,37 @@ def __init__(self, config, md):
self.tab_length = md.tab_length
super(SnippetPreprocessor, self).__init__()

def extract_section(self, section, lines):
"""Extract the specified section from the lines."""

new_lines = []
start = False
for l in lines:

# Found a snippet section marker with our specified name
m = self.RE_SNIPPET_SECTION.match(l)
if m is not None and m.group('name') == section:

# We found the start
if not start and m.group('type') == 'start':
start = True
continue

# We found the end
elif start and m.group('type') == 'end':
start = False
break

# We found an end, but no start
else:
return []

# We are currently in a section, so append the line
if start:
new_lines.append(l)

return new_lines

def get_snippet_path(self, path):
"""Get snippet path."""

Expand Down Expand Up @@ -135,7 +175,7 @@ def download(self, url):
return ['']

# Process lines
return [l.decode(self.encoding) for l in response.readlines()]
return [l.decode(self.encoding).rstrip('\r\n') for l in response.readlines()]

def parse_snippets(self, lines, file_name=None, is_url=False):
"""Parse snippets snippet."""
Expand Down Expand Up @@ -201,6 +241,7 @@ def parse_snippets(self, lines, file_name=None, is_url=False):
# Get line numbers (if specified)
end = None
start = None
section = None
m = self.RE_SNIPPET_FILE.match(path)
path = m.group(1).strip()
# Looks like we have an empty file and only lines specified
Expand All @@ -215,6 +256,9 @@ def parse_snippets(self, lines, file_name=None, is_url=False):
starting = m.group(2)
if starting and len(starting) > 1:
start = max(1, int(starting[1:]) - 1)
section_name = m.group(4)
if section_name:
section = section_name[1:]

# Ignore path links if we are in external, downloaded content
is_link = path.lower().startswith(('https://', 'http://'))
Expand All @@ -235,17 +279,21 @@ def parse_snippets(self, lines, file_name=None, is_url=False):
if not url:
# Read file content
with codecs.open(snippet, 'r', encoding=self.encoding) as f:
s_lines = [l for l in f]
s_lines = [l.rstrip('\r\n') for l in f]
if start is not None or end is not None:
s = slice(start, end)
s_lines = s_lines[s]
elif section:
s_lines = self.extract_section(section, s_lines)
else:
# Read URL content
try:
s_lines = self.download(snippet)
if start is not None or end is not None:
s = slice(start, end)
s_lines = s_lines[s]
elif section:
s_lines = self.extract_section(section, s_lines)
except SnippetMissingError:
if self.check_paths:
raise
Expand All @@ -255,7 +303,7 @@ def parse_snippets(self, lines, file_name=None, is_url=False):
new_lines.extend(
[
space + l2 for l2 in self.parse_snippets(
[l.rstrip('\r\n') for l in s_lines],
s_lines,
snippet,
is_url=url
)
Expand Down
19 changes: 19 additions & 0 deletions tests/test_extensions/_snippets/section.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/* --8<-- [start: cssSection] */
div {
color: red;
}
/* --8<-- [end: cssSection] */

<!-- --8<-- [start: htmlSection] -->
<div><p>content</p></div>
<!-- --8<-- [end: htmlSection] -->

/* --8<-- [end: cssSection2] */
/* --8<-- [start: cssSection2] */
div {
color: red;
}
/* --8<-- [end: cssSection2] */

<!-- --8<-- [start: htmlSection2] -->
<div><p>content</p></div>
97 changes: 94 additions & 3 deletions tests/test_extensions/test_snippets.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class TestSnippets(util.MdCase):
"""Test snippet cases."""

extension = [
'pymdownx.snippets',
'pymdownx.snippets', 'pymdownx.superfences'
]

extension_configs = {
Expand Down Expand Up @@ -223,6 +223,68 @@ def test_start_end_line_block(self):
True
)

def test_section_inline(self):
"""Test section partial in inline snippet."""

self.check_markdown(
R'''
```
--8<-- "section.txt:cssSection"
```
''',
'''
<div class="highlight"><pre><span></span><code>div {
color: red;
}
</code></pre></div>
''',
True
)

def test_section_block(self):
"""Test section partial in inline snippet."""

self.check_markdown(
R'''
--8<--
section.txt:htmlSection
--8<--
''',
'''
<div><p>content</p></div>
''',
True
)

def test_section_end_first(self):
"""Test section when the end is specified first."""

self.check_markdown(
R'''
--8<--
section.txt:cssSection2
--8<--
''',
'''
''',
True
)

def test_section_no_end(self):
"""Test section when the end is not specified."""

self.check_markdown(
R'''
--8<--
section.txt:htmlSection2
--8<--
''',
'''
<div><p>content</p></div>
''',
True
)


class TestSnippetsFile(util.MdCase):
"""Test snippet file case."""
Expand Down Expand Up @@ -502,7 +564,7 @@ def test_url_nested_file(self, mock_urlopen):

@patch('urllib.request.urlopen')
def test_url_lines(self, mock_urlopen):
"""Test nested file in URL."""
"""Test specifying specific lines in a URL."""

content = []
length = 0
Expand All @@ -515,7 +577,7 @@ def test_url_lines(self, mock_urlopen):
cm.status = 200
cm.code = 200
cm.readlines.return_value = content
cm.headers = {'content-length': '183'}
cm.headers = {'content-length': length}
cm.__enter__.return_value = cm
mock_urlopen.return_value = cm

Expand Down Expand Up @@ -615,6 +677,35 @@ def test_content_length_zero(self, mock_urlopen):
True
)

@patch('urllib.request.urlopen')
def test_url_sections(self, mock_urlopen):
"""Test specifying a section in a URL."""

content = []
length = 0
with open('tests/test_extensions/_snippets/section.txt', 'rb') as f:
for l in f:
length += len(l)
content.append(l)

cm = MagicMock()
cm.status = 200
cm.code = 200
cm.readlines.return_value = content
cm.headers = {'content-length': length}
cm.__enter__.return_value = cm
mock_urlopen.return_value = cm

self.check_markdown(
R'''
--8<-- "https://test.com/myfile.md:htmlSection"
''',
'''
<div><p>content</p></div>
''',
True
)


class TestURLSnippetsNoMax(util.MdCase):
"""Test snippet URL cases no max size."""
Expand Down

0 comments on commit a5b09fc

Please sign in to comment.