diff --git a/codeinclude/plugin.py b/codeinclude/plugin.py index b0f3931..bcdda64 100644 --- a/codeinclude/plugin.py +++ b/codeinclude/plugin.py @@ -2,6 +2,8 @@ import os import shlex import textwrap +from dataclasses import dataclass +from typing import List from mkdocs.plugins import BasePlugin from codeinclude.resolver import select @@ -22,18 +24,92 @@ $ """ -RE_SNIPPET = r"""(?x) +RE_SNIPPET = r"""(?xm) ^ (?P\s*) \[(?P[^\]]*)\]\((?P<filename>[^)]+)\) - ([\t ]+(?P<params>.*))? + ([\t\n ]+(?P<params>[\w:-]+))? (?P<ignored_trailing_space>\s*) $ """ -def get_substitute(page, title, filename, lines, block, inside_block): +class CodeIncludePlugin(BasePlugin): + def on_page_markdown(self, markdown, page, config, site_navigation=None, **kwargs): + "Provide a hook for defining functions from an external module" + + blocks = find_code_include_blocks(markdown) + substitutes = get_substitutes(blocks, page) + return substitute(markdown, substitutes) + + +@dataclass +class CodeIncludeBlock(object): + first_line_index: int + last_line_index: int + content: str + + +def find_code_include_blocks(markdown: str) -> List[CodeIncludeBlock]: + ci_blocks = list() + first = -1 + in_block = False + lines = markdown.splitlines() + for index, line in enumerate(lines): + if re.match(RE_START, lines[index]): + if in_block: + raise ValueError(f"Found two consecutive code-include starts: at lines {first} and {index}") + first = index + in_block = True + elif re.match(RE_END, lines[index]): + if not in_block: + raise ValueError(f"Found code-include end without preceding start at line {index}") + last = index + content = '\n'.join(lines[first:last + 1]) + ci_blocks.append(CodeIncludeBlock(first, last, content)) + in_block = False + return ci_blocks + + +@dataclass +class Replacement(object): + first_line_index: int + last_line_index: int + content: str + + +def get_substitutes(blocks: List[CodeIncludeBlock], page) -> List[Replacement]: + replacements = list() + for ci_block in blocks: + replacement_content = "" + for snippet_match in re.finditer(RE_SNIPPET, ci_block.content): + title = snippet_match.group("title") + filename = snippet_match.group("filename") + indent = snippet_match.group("leading_space") + raw_params = snippet_match.group("params") + + if raw_params: + params = dict(token.split(":") for token in shlex.split(raw_params)) + lines = params.get("lines", "") + block = params.get("block", "") + inside_block = params.get("inside_block", "") + else: + lines = "" + block = "" + inside_block = "" + + code_block = get_substitute( + page, title, filename, lines, block, inside_block + ) + # re-indent + code_block = re.sub("^", indent, code_block, flags=re.MULTILINE) + + replacement_content += code_block + replacements.append(Replacement(ci_block.first_line_index, ci_block.last_line_index, replacement_content)) + return replacements + +def get_substitute(page, title, filename, lines, block, inside_block): page_parent_dir = os.path.dirname(page.file.abs_src_path) import_path = os.path.join(page_parent_dir, filename) with open(import_path) as f: @@ -48,53 +124,24 @@ def get_substitute(page, title, filename, lines, block, inside_block): return '\n```java tab="' + title + '"\n' + dedented + "\n```\n\n" -class CodeIncludePlugin(BasePlugin): - def on_page_markdown(self, markdown, page, config, site_navigation=None, **kwargs): - "Provide a hook for defining functions from an external module" - - active = False - results = "" - for line in markdown.splitlines(): - boundary = False - - # detect end - if active and re.match(RE_END, line): - active = False - boundary = True - - # handle each line of a codeinclude zone - if active: - snippet_match = re.match(RE_SNIPPET, line) - if snippet_match: - title = snippet_match.group("title") - filename = snippet_match.group("filename") - indent = snippet_match.group("leading_space") - raw_params = snippet_match.group("params") - - if raw_params: - params = dict(token.split(":") for token in shlex.split(raw_params)) - lines = params.get("lines", "") - block = params.get("block", "") - inside_block = params.get("inside_block", "") - else: - lines = "" - block = "" - inside_block = "" - - code_block = get_substitute( - page, title, filename, lines, block, inside_block - ) - # re-indent - code_block = re.sub("^", indent, code_block, flags=re.MULTILINE) - results += code_block - - # detect start - if re.match(RE_START, line): - active = True - boundary = True - - # outside a codeinclude zone and ignoring the boundaries - if not active and not boundary: - results += line + "\n" - - return results +def substitute(markdown: str, substitutes: List[Replacement]) -> str: + substitutes_by_first_line = dict() + # Index substitutes by the first line + for s in substitutes: + substitutes_by_first_line[s.first_line_index] = s + + # Perform substitutions + result = "" + index = 0 + lines = markdown.splitlines() + while index < len(lines): + if index in substitutes_by_first_line.keys(): + # Replace the codeinclude fragment starting at this line + substitute = substitutes_by_first_line[index] + result += substitute.content + index = substitute.last_line_index + else: + # Keep the input line + result += lines[index] + "\n" + index += 1 + return result diff --git a/tests/codeinclude/test_plugin.py b/tests/codeinclude/test_plugin.py index 54775f4..40e4428 100644 --- a/tests/codeinclude/test_plugin.py +++ b/tests/codeinclude/test_plugin.py @@ -8,12 +8,42 @@ from codeinclude.plugin import CodeIncludePlugin -MARKDOWN_EXAMPLE = """ +MARKDOWN_EXAMPLE_NO_INCLUDES = """ +# hello world + +some text before + +""" + +MARKDOWN_EXAMPLE_NO_SELECTOR = """ +# hello world + +some text before +<!--codeinclude--> +[foo](Foo.java) +<!--/codeinclude--> +and some text after + +""" + +MARKDOWN_EXAMPLE_SELECTOR_ON_SAME_LINE = """ +# hello world + +some text before +<!--codeinclude--> +[foo](Foo.java) lines:1 +<!--/codeinclude--> +and some text after + +""" + +MARKDOWN_EXAMPLE_SELECTOR_ON_NEXT_LINE = """ # hello world some text before <!--codeinclude--> [foo](Foo.java) +lines:1 <!--/codeinclude--> and some text after @@ -39,9 +69,16 @@ class PluginTextCase(unittest.TestCase): - def test_simple_case(self): + def test_no_includes(self): + plugin = CodeIncludePlugin() + result = plugin.on_page_markdown(MARKDOWN_EXAMPLE_NO_INCLUDES, PAGE_EXAMPLE, dict()) + + self.assertEqual(MARKDOWN_EXAMPLE_NO_INCLUDES.strip(), + result.strip()) + + def test_simple_case_no_selector(self): plugin = CodeIncludePlugin() - result = plugin.on_page_markdown(MARKDOWN_EXAMPLE, PAGE_EXAMPLE, dict()) + result = plugin.on_page_markdown(MARKDOWN_EXAMPLE_NO_SELECTOR, PAGE_EXAMPLE, dict()) print(result) self.assertEqual(textwrap.dedent(""" @@ -59,6 +96,44 @@ def test_simple_case(self): """).strip(), result.strip()) + def test_simple_case_selector_on_same_line(self): + plugin = CodeIncludePlugin() + result = plugin.on_page_markdown(MARKDOWN_EXAMPLE_SELECTOR_ON_SAME_LINE, PAGE_EXAMPLE, dict()) + + print(result) + self.assertEqual(textwrap.dedent(""" + # hello world + + some text before + + ```java tab=\"foo\" + public class Foo { + + ``` + + and some text after + """).strip(), + result.strip()) + + def test_simple_case_selector_on_next_line(self): + plugin = CodeIncludePlugin() + result = plugin.on_page_markdown(MARKDOWN_EXAMPLE_SELECTOR_ON_NEXT_LINE, PAGE_EXAMPLE, dict()) + + print(result) + self.assertEqual(textwrap.dedent(""" + # hello world + + some text before + + ```java tab=\"foo\" + public class Foo { + + ``` + + and some text after + """).strip(), + result.strip()) + def test_multi_tab_case(self): plugin = CodeIncludePlugin() result = plugin.on_page_markdown(MULTI_TAB_MARKDOWN_EXAMPLE, PAGE_EXAMPLE, dict())