Skip to content

Commit 32e6ff7

Browse files
jdmonacoclaude
andcommitted
Add trailing whitespace removal and refactor postprocessor
- Add _strip_trailing_whitespace() to remove trailing whitespace from all lines except inside fenced code blocks - Refactor _postprocess_root() to chain helper functions - Extract _normalize_frontmatter_spacing() from existing code - Add test_spacing_features.py with tests for trailing whitespace, hard breaks, pseudo-heading spacing, and link handling - Add test_integration.py and test_plugin_interactions.py for comprehensive plugin interaction testing - Add mdformat-simple-breaks and mdformat-wikilink as test deps - Expand fixtures.md with checkbox list and link test cases Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent f02e8a7 commit 32e6ff7

8 files changed

Lines changed: 1212 additions & 20 deletions

File tree

mdformat_space_control/plugin.py

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
- EditorConfig-based indentation settings
55
- Tight list formatting with multi-paragraph awareness
66
- Frontmatter spacing normalization
7+
- Trailing whitespace removal (outside code blocks)
78
"""
89

910
import re
@@ -186,21 +187,74 @@ def _render_ordered_list(node: RenderTreeNode, context: RenderContext) -> str:
186187
}
187188

188189

189-
def _postprocess_root(text: str, node: RenderTreeNode, context: RenderContext) -> str:
190+
def _normalize_frontmatter_spacing(text: str) -> str:
190191
"""Normalize spacing after YAML frontmatter.
191192
192-
Applies two spacing rules:
193-
- Heading after frontmatter: no blank line (tight)
194-
- Other content after frontmatter: exactly one blank line
193+
Removes all blank lines between frontmatter closing delimiter and the
194+
first content block, producing tight spacing universally.
195+
196+
IMPORTANT: Only matches actual frontmatter (document starts with ---)
197+
not thematic breaks appearing mid-document.
198+
"""
199+
# Only process if document starts with frontmatter opening delimiter
200+
if not text.startswith("---\n"):
201+
return text
202+
203+
# Find the closing delimiter (second --- on its own line)
204+
# Pattern: opening --- at start, content, closing --- on its own line
205+
frontmatter_match = re.match(r"^---\n.*?\n(---\n)", text, flags=re.DOTALL)
206+
if not frontmatter_match:
207+
return text
208+
209+
# Get position after closing delimiter
210+
closing_end = frontmatter_match.end(1)
211+
before_content = text[:closing_end]
212+
after_content = text[closing_end:]
213+
214+
# Remove all blank lines after frontmatter (tight spacing for any content)
215+
after_content = re.sub(r"^\n+", "", after_content)
216+
217+
return before_content + after_content
218+
219+
220+
def _strip_trailing_whitespace(text: str) -> str:
221+
"""Strip trailing whitespace, preserving code blocks.
222+
223+
Fenced code blocks (``` or ~~~) preserve trailing whitespace
224+
since it may be semantically meaningful in code.
225+
"""
226+
lines = text.split("\n")
227+
result = []
228+
in_code_block = False
229+
230+
for line in lines:
231+
# Track fenced code block state
232+
stripped = line.lstrip()
233+
if stripped.startswith("```") or stripped.startswith("~~~"):
234+
in_code_block = not in_code_block
235+
result.append(line.rstrip()) # Strip fence line itself
236+
elif in_code_block:
237+
# Preserve trailing whitespace inside code blocks
238+
result.append(line)
239+
else:
240+
# Strip trailing whitespace everywhere else
241+
result.append(line.rstrip())
242+
243+
return "\n".join(result)
244+
245+
246+
def _postprocess_root(text: str, node: RenderTreeNode, context: RenderContext) -> str:
247+
"""Combined postprocessor for all space control features.
195248
196-
This postprocessor works with mdformat-frontmatter without conflicts
197-
since it operates on the rendered output, not the AST.
249+
Applies the following transformations in order:
250+
1. Frontmatter spacing normalization
251+
2. Trailing whitespace removal
198252
"""
199-
# Remove blank line(s) before heading: ---\n\n+# → ---\n#
200-
text = re.sub(r"^(---\n)\n+(#)", r"\1\2", text, count=1, flags=re.MULTILINE)
253+
# 1. Frontmatter spacing
254+
text = _normalize_frontmatter_spacing(text)
201255

202-
# Normalize multiple blank lines to exactly one for non-headings
203-
text = re.sub(r"^(---\n)\n{2,}(\S)", r"\1\n\2", text, count=1, flags=re.MULTILINE)
256+
# 2. Trailing whitespace removal
257+
text = _strip_trailing_whitespace(text)
204258

205259
return text
206260

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ test = [
3333
"pytest>=7.0",
3434
"pytest-cov>=4.0",
3535
"mdformat-frontmatter>=2.0.0",
36+
"mdformat-simple-breaks>=0.1.0",
37+
"mdformat-wikilink>=0.3.0",
3638
]
3739

3840
[project.urls]

tests/fixtures.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,4 +172,83 @@ mixed single and multi paragraph items
172172
Second paragraph here
173173

174174
- Simple item 2
175+
.
176+
177+
basic checkbox list
178+
.
179+
- [ ] Task 1
180+
181+
- [x] Task 2
182+
183+
- [ ] Task 3
184+
.
185+
- [ ] Task 1
186+
- [x] Task 2
187+
- [ ] Task 3
188+
.
189+
190+
nested checkbox list
191+
.
192+
- [ ] Parent task 1
193+
- [x] Completed subtask
194+
- [ ] Pending subtask
195+
- [x] Parent task 2
196+
.
197+
- [ ] Parent task 1
198+
- [x] Completed subtask
199+
- [ ] Pending subtask
200+
- [x] Parent task 2
201+
.
202+
203+
mixed checkbox and regular items
204+
.
205+
- [ ] Task item
206+
207+
- Regular item
208+
209+
- [x] Completed task
210+
.
211+
- [ ] Task item
212+
- Regular item
213+
- [x] Completed task
214+
.
215+
216+
checkbox with continuation
217+
.
218+
- [ ] Task with
219+
continuation text
220+
- [x] Another task
221+
.
222+
- [ ] Task with
223+
continuation text
224+
- [x] Another task
225+
.
226+
227+
multi-paragraph checkbox item
228+
.
229+
- [ ] Task with paragraphs
230+
231+
Second paragraph here.
232+
233+
- [x] Simple task
234+
.
235+
- [ ] Task with paragraphs
236+
237+
Second paragraph here.
238+
239+
- [x] Simple task
240+
.
241+
242+
normal link unchanged
243+
.
244+
[normal link](https://example.com)
245+
.
246+
[normal link](https://example.com)
247+
.
248+
249+
link in paragraph
250+
.
251+
Check [this link](https://example.com) for info.
252+
.
253+
Check [this link](https://example.com) for info.
175254
.

tests/test_editorconfig.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,3 +484,57 @@ def test_nested_with_multi_paragraph(self, temp_project):
484484
"""
485485
result = format_with_context(input_text, md_file)
486486
assert result == expected
487+
488+
489+
class TestEditorConfigDebug:
490+
"""Tests documenting EditorConfig resolution behavior."""
491+
492+
def test_cwd_differs_from_file_location(self, temp_project, monkeypatch):
493+
"""When CWD differs from file location, explicit file context wins."""
494+
create_editorconfig(
495+
temp_project,
496+
"""
497+
root = true
498+
499+
[*.md]
500+
indent_style = space
501+
indent_size = 4
502+
""",
503+
)
504+
505+
other_dir = temp_project / "other"
506+
other_dir.mkdir()
507+
monkeypatch.chdir(other_dir)
508+
509+
md_file = temp_project / "doc.md"
510+
result = format_with_context("- A\n - B\n", md_file)
511+
assert " - B" in result # 4-space indent
512+
513+
def test_obsidian_scenario_no_file_context(self, temp_project, monkeypatch):
514+
"""Document behavior when no file context is set (Obsidian-like)."""
515+
vault = temp_project / "vault"
516+
vault.mkdir()
517+
create_editorconfig(
518+
vault,
519+
"""
520+
root = true
521+
522+
[*.md]
523+
indent_style = space
524+
indent_size = 4
525+
""",
526+
)
527+
528+
app_dir = temp_project / "app"
529+
app_dir.mkdir()
530+
monkeypatch.chdir(app_dir)
531+
532+
fake_home = temp_project / "fake_home"
533+
fake_home.mkdir()
534+
monkeypatch.setattr(Path, "home", lambda: fake_home)
535+
536+
set_current_file(None)
537+
538+
# Without file context, vault's .editorconfig is NOT found
539+
result = mdformat.text("- A\n - B\n", extensions={"space_control"})
540+
assert " - B" in result # 2-space default

0 commit comments

Comments
 (0)