From 08fd1d63eeff500fb03747582d0bd2456e79dacc Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 14 Jan 2026 08:59:04 -0800 Subject: [PATCH 1/3] Support indented code blocks (fixes #11) Code blocks inside list items or other indented contexts now work correctly. The indentation is stripped from code before execution and re-added to output. --- markdown_code_runner.py | 58 +++++++++++++++++++++---------- tests/test_main_app.py | 76 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 18 deletions(-) diff --git a/markdown_code_runner.py b/markdown_code_runner.py index cac33da..4fcbaa5 100644 --- a/markdown_code_runner.py +++ b/markdown_code_runner.py @@ -235,6 +235,7 @@ class ProcessingState: new_lines: list[str] = field(default_factory=list) backtick_options: dict[str, Any] = field(default_factory=dict) backtick_standardize: bool = True + indent: str = "" # Indentation prefix of current code block def process_line(self, line: str, *, verbose: bool = False) -> None: """Process a line of the Markdown file.""" @@ -264,20 +265,23 @@ def _process_start_markers( verbose: bool = False, # noqa: FBT001, FBT002, ARG002 ) -> str | None: for marker_name in MARKERS: - if marker_name.endswith(":start") and is_marker(line, marker_name): - # reset output in case previous output wasn't displayed - self.output = None - self.backtick_options = _extract_backtick_options(line) - self.section, _ = marker_name.rsplit(":", 1) # type: ignore[assignment] - - # Standardize backticks if needed - if ( - marker_name == "code:backticks:start" - and self.backtick_standardize - and "markdown-code-runner" in line - ): - return re.sub(r"\smarkdown-code-runner.*", "", line) - return line + if marker_name.endswith(":start"): + match = is_marker(line, marker_name) + if match: + # reset output in case previous output wasn't displayed + self.output = None + self.backtick_options = _extract_backtick_options(line) + self.section, _ = marker_name.rsplit(":", 1) # type: ignore[assignment] + self.indent = match.group("spaces") + + # Standardize backticks if needed + if ( + marker_name == "code:backticks:start" + and self.backtick_standardize + and "markdown-code-runner" in line + ): + return re.sub(r"\smarkdown-code-runner.*", "", line) + return line return None def _process_output_start(self, line: str) -> None: @@ -287,9 +291,16 @@ def _process_output_start(self, line: str) -> None: self.output, list, ), f"Output must be a list, not {type(self.output)}, line: {line}" - # Trim trailing whitespace from output lines - trimmed_output = [line.rstrip() for line in self.output] - self.new_lines.extend([line, MARKERS["warning"], *trimmed_output]) + # Extract indent from OUTPUT:START line + output_indent = line[: len(line) - len(line.lstrip())] + + def _add_indent(s: str) -> str: + stripped = s.rstrip() + return output_indent + stripped if stripped else "" + + trimmed_output = [_add_indent(ol) for ol in self.output] + indented_warning = output_indent + MARKERS["warning"] + self.new_lines.extend([line, indented_warning, *trimmed_output]) else: self.original_output.append(line) @@ -301,6 +312,12 @@ def _process_output_end(self) -> None: self.original_output = [] self.output = None # Reset output after processing end of the output section + def _strip_indent(self, line: str) -> str: + """Strip the code block's indentation prefix from a line.""" + if self.indent and line.startswith(self.indent): + return line[len(self.indent) :] + return line + def _process_code( self, line: str, @@ -322,8 +339,13 @@ def _process_code( self.section = "normal" self.code = [] self.backtick_options = {} + self.indent = "" else: - self.code.append(remove_md_comment(line) if remove_comment else line) + # remove_md_comment already strips whitespace; for backticks, strip indent + code_line = ( + remove_md_comment(line) if remove_comment else self._strip_indent(line) + ) + self.code.append(code_line) def _process_comment_code(self, line: str, *, verbose: bool) -> None: _, language = self.section.rsplit(":", 1) diff --git a/tests/test_main_app.py b/tests/test_main_app.py index 2aebdf2..81dd9d3 100644 --- a/tests/test_main_app.py +++ b/tests/test_main_app.py @@ -881,3 +881,79 @@ def test_trailing_whitespace_trimming() -> None: assert output_section_hidden[3] == "Final line" assert output_section_hidden[4] == "" # Line with only spaces becomes empty assert output_section_hidden[5] == "" # Trailing empty line preserved + + +def test_indented_code_blocks() -> None: + """Test that indented code blocks (e.g., in list items) work correctly.""" + # Test case 1: Indented backtick code block (4 spaces, like in a list) + input_lines = [ + "1. List item:", + "", + " ```python markdown-code-runner", + " print('hello')", + " ```", + " ", + " old output", + " ", + ] + expected_output = [ + "1. List item:", + "", + " ```python markdown-code-runner", + " print('hello')", + " ```", + " ", + " " + MARKERS["warning"], + " hello", + "", + " ", + ] + assert_process(input_lines, expected_output, backtick_standardize=False) + + # Test case 2: Indented code with internal indentation (Python function) + input_lines = [ + "1. Example:", + "", + " ```python markdown-code-runner", + " def foo():", + " return 42", + " print(foo())", + " ```", + " ", + " ", + ] + expected_output = [ + "1. Example:", + "", + " ```python markdown-code-runner", + " def foo():", + " return 42", + " print(foo())", + " ```", + " ", + " " + MARKERS["warning"], + " 42", + "", + " ", + ] + assert_process(input_lines, expected_output, backtick_standardize=False) + + # Test case 3: Indented bash code block + input_lines = [ + " ```bash markdown-code-runner", + ' echo "indented bash"', + " ```", + " ", + " ", + ] + expected_output = [ + " ```bash markdown-code-runner", + ' echo "indented bash"', + " ```", + " ", + " " + MARKERS["warning"], + " indented bash", + "", + " ", + ] + assert_process(input_lines, expected_output, backtick_standardize=False) From 926d7c7eedc0cc78a5873b991304780ec3e1add5 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 14 Jan 2026 09:12:00 -0800 Subject: [PATCH 2/3] Simplify: extract _get_indent method, inline list comprehension --- markdown_code_runner.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/markdown_code_runner.py b/markdown_code_runner.py index 4fcbaa5..e2ff861 100644 --- a/markdown_code_runner.py +++ b/markdown_code_runner.py @@ -284,6 +284,11 @@ def _process_start_markers( return line return None + @staticmethod + def _get_indent(line: str) -> str: + """Extract leading whitespace from a line.""" + return line[: len(line) - len(line.lstrip())] + def _process_output_start(self, line: str) -> None: self.section = "output" if not self.skip_code_block: @@ -291,16 +296,11 @@ def _process_output_start(self, line: str) -> None: self.output, list, ), f"Output must be a list, not {type(self.output)}, line: {line}" - # Extract indent from OUTPUT:START line - output_indent = line[: len(line) - len(line.lstrip())] - - def _add_indent(s: str) -> str: - stripped = s.rstrip() - return output_indent + stripped if stripped else "" - - trimmed_output = [_add_indent(ol) for ol in self.output] - indented_warning = output_indent + MARKERS["warning"] - self.new_lines.extend([line, indented_warning, *trimmed_output]) + indent = self._get_indent(line) + trimmed_output = [ + indent + ol.rstrip() if ol.strip() else "" for ol in self.output + ] + self.new_lines.extend([line, indent + MARKERS["warning"], *trimmed_output]) else: self.original_output.append(line) From c4ea73282ebccd81e0b1b5dfd95483fa748df84e Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Wed, 14 Jan 2026 09:16:10 -0800 Subject: [PATCH 3/3] Reuse _get_indent in _process_start_markers --- markdown_code_runner.py | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/markdown_code_runner.py b/markdown_code_runner.py index e2ff861..ebcb51e 100644 --- a/markdown_code_runner.py +++ b/markdown_code_runner.py @@ -265,23 +265,21 @@ def _process_start_markers( verbose: bool = False, # noqa: FBT001, FBT002, ARG002 ) -> str | None: for marker_name in MARKERS: - if marker_name.endswith(":start"): - match = is_marker(line, marker_name) - if match: - # reset output in case previous output wasn't displayed - self.output = None - self.backtick_options = _extract_backtick_options(line) - self.section, _ = marker_name.rsplit(":", 1) # type: ignore[assignment] - self.indent = match.group("spaces") - - # Standardize backticks if needed - if ( - marker_name == "code:backticks:start" - and self.backtick_standardize - and "markdown-code-runner" in line - ): - return re.sub(r"\smarkdown-code-runner.*", "", line) - return line + if marker_name.endswith(":start") and is_marker(line, marker_name): + # reset output in case previous output wasn't displayed + self.output = None + self.backtick_options = _extract_backtick_options(line) + self.section, _ = marker_name.rsplit(":", 1) # type: ignore[assignment] + self.indent = self._get_indent(line) + + # Standardize backticks if needed + if ( + marker_name == "code:backticks:start" + and self.backtick_standardize + and "markdown-code-runner" in line + ): + return re.sub(r"\smarkdown-code-runner.*", "", line) + return line return None @staticmethod