flowmark 0.6.0__tar.gz → 0.6.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. {flowmark-0.6.0 → flowmark-0.6.2}/Makefile +7 -1
  2. {flowmark-0.6.0 → flowmark-0.6.2}/PKG-INFO +1 -1
  3. flowmark-0.6.2/devtools/benchmark.py +226 -0
  4. flowmark-0.6.2/docs/project/specs/active/plan-2026-01-14-list-spacing-control.md +329 -0
  5. flowmark-0.6.2/docs/project/specs/active/valid-2026-01-14-list-spacing-control.md +85 -0
  6. flowmark-0.6.2/docs/project/specs/done/plan-2026-01-14-atomic-tag-wrapping.md +424 -0
  7. flowmark-0.6.2/docs/project/specs/done/plan-2026-01-14-markdoc-tag-compatibility.md +475 -0
  8. flowmark-0.6.2/docs/project/specs/done/valid-2026-01-14-atomic-tag-wrapping.md +115 -0
  9. {flowmark-0.6.0 → flowmark-0.6.2}/src/flowmark/__init__.py +2 -2
  10. {flowmark-0.6.0 → flowmark-0.6.2}/src/flowmark/cli.py +13 -0
  11. {flowmark-0.6.0 → flowmark-0.6.2}/src/flowmark/formats/flowmark_markdown.py +66 -11
  12. flowmark-0.6.2/src/flowmark/linewrapping/atomic_patterns.py +189 -0
  13. flowmark-0.6.2/src/flowmark/linewrapping/block_heuristics.py +151 -0
  14. {flowmark-0.6.0 → flowmark-0.6.2}/src/flowmark/linewrapping/line_wrappers.py +20 -12
  15. {flowmark-0.6.0 → flowmark-0.6.2}/src/flowmark/linewrapping/markdown_filling.py +16 -5
  16. flowmark-0.6.2/src/flowmark/linewrapping/protocols.py +15 -0
  17. flowmark-0.6.2/src/flowmark/linewrapping/tag_handling.py +531 -0
  18. {flowmark-0.6.0 → flowmark-0.6.2}/src/flowmark/linewrapping/text_filling.py +4 -2
  19. flowmark-0.6.2/src/flowmark/linewrapping/text_wrapping.py +258 -0
  20. {flowmark-0.6.0 → flowmark-0.6.2}/src/flowmark/reformat_api.py +14 -3
  21. {flowmark-0.6.0 → flowmark-0.6.2}/src/flowmark/typography/smartquotes.py +86 -44
  22. {flowmark-0.6.0 → flowmark-0.6.2}/tests/test_escape_handling.py +3 -3
  23. {flowmark-0.6.0 → flowmark-0.6.2}/tests/test_filling.py +2 -1
  24. {flowmark-0.6.0 → flowmark-0.6.2}/tests/test_heading_spacing.py +2 -1
  25. flowmark-0.6.2/tests/test_list_spacing.py +410 -0
  26. flowmark-0.6.2/tests/test_tag_formatting.py +688 -0
  27. flowmark-0.6.2/tests/test_wrapping.py +1269 -0
  28. {flowmark-0.6.0 → flowmark-0.6.2}/tests/testdocs/testdoc.expected.auto.md +292 -117
  29. {flowmark-0.6.0 → flowmark-0.6.2}/tests/testdocs/testdoc.expected.cleaned.md +291 -116
  30. {flowmark-0.6.0 → flowmark-0.6.2}/tests/testdocs/testdoc.expected.plain.md +292 -118
  31. {flowmark-0.6.0 → flowmark-0.6.2}/tests/testdocs/testdoc.expected.semantic.md +291 -116
  32. {flowmark-0.6.0 → flowmark-0.6.2}/tests/testdocs/testdoc.orig.md +232 -0
  33. flowmark-0.6.0/src/flowmark/linewrapping/text_wrapping.py +0 -313
  34. flowmark-0.6.0/tests/test_list_spacing.py +0 -312
  35. flowmark-0.6.0/tests/test_wrapping.py +0 -535
  36. {flowmark-0.6.0 → flowmark-0.6.2}/.copier-answers.yml +0 -0
  37. {flowmark-0.6.0 → flowmark-0.6.2}/.cursor/rules/automatic-shortcut-triggers.mdc +0 -0
  38. {flowmark-0.6.0 → flowmark-0.6.2}/.cursor/rules/backward-compatibility-rules.mdc +0 -0
  39. {flowmark-0.6.0 → flowmark-0.6.2}/.cursor/rules/convex-rules.mdc +0 -0
  40. {flowmark-0.6.0 → flowmark-0.6.2}/.cursor/rules/flowmark.mdc +0 -0
  41. {flowmark-0.6.0 → flowmark-0.6.2}/.cursor/rules/general-coding-rules.mdc +0 -0
  42. {flowmark-0.6.0 → flowmark-0.6.2}/.cursor/rules/general-comment-rules.mdc +0 -0
  43. {flowmark-0.6.0 → flowmark-0.6.2}/.cursor/rules/general-eng-assistant-rules.mdc +0 -0
  44. {flowmark-0.6.0 → flowmark-0.6.2}/.cursor/rules/general-style-rules.mdc +0 -0
  45. {flowmark-0.6.0 → flowmark-0.6.2}/.cursor/rules/general-testing-rules.mdc +0 -0
  46. {flowmark-0.6.0 → flowmark-0.6.2}/.cursor/rules/python-rules-opinionated.mdc +0 -0
  47. {flowmark-0.6.0 → flowmark-0.6.2}/.cursor/rules/python-rules.mdc +0 -0
  48. {flowmark-0.6.0 → flowmark-0.6.2}/.cursor/rules/tool-development-rules.mdc +0 -0
  49. {flowmark-0.6.0 → flowmark-0.6.2}/.cursor/rules/typescript-cli-tool-rules.mdc +0 -0
  50. {flowmark-0.6.0 → flowmark-0.6.2}/.cursor/rules/typescript-rules.mdc +0 -0
  51. {flowmark-0.6.0 → flowmark-0.6.2}/.github/workflows/ci.yml +0 -0
  52. {flowmark-0.6.0 → flowmark-0.6.2}/.github/workflows/publish.yml +0 -0
  53. {flowmark-0.6.0 → flowmark-0.6.2}/.gitignore +0 -0
  54. {flowmark-0.6.0 → flowmark-0.6.2}/.speculate/copier-answers.yml +0 -0
  55. {flowmark-0.6.0 → flowmark-0.6.2}/.speculate/settings.yml +0 -0
  56. {flowmark-0.6.0 → flowmark-0.6.2}/.vscode/settings.json +0 -0
  57. {flowmark-0.6.0 → flowmark-0.6.2}/LICENSE +0 -0
  58. {flowmark-0.6.0 → flowmark-0.6.2}/README.md +0 -0
  59. {flowmark-0.6.0 → flowmark-0.6.2}/devtools/lint.py +0 -0
  60. {flowmark-0.6.0 → flowmark-0.6.2}/docs/development.md +0 -0
  61. {flowmark-0.6.0 → flowmark-0.6.2}/docs/development.npm.sample.md +0 -0
  62. {flowmark-0.6.0 → flowmark-0.6.2}/docs/docs-overview.md +0 -0
  63. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-guidelines/general-tdd-guidelines.md +0 -0
  64. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-guidelines/golden-testing-guidelines.md +0 -0
  65. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-guidelines/typescript-dependency-injection-guidelines.md +0 -0
  66. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-guidelines/typescript-testing-guidelines.md +0 -0
  67. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-rules/automatic-shortcut-triggers.md +0 -0
  68. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-rules/backward-compatibility-rules.md +0 -0
  69. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-rules/convex-rules.md +0 -0
  70. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-rules/general-coding-rules.md +0 -0
  71. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-rules/general-comment-rules.md +0 -0
  72. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-rules/general-eng-assistant-rules.md +0 -0
  73. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-rules/general-style-rules.md +0 -0
  74. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-rules/general-testing-rules.md +0 -0
  75. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-rules/python-rules-opinionated.md +0 -0
  76. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-rules/python-rules.md +0 -0
  77. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-rules/tool-development-rules.md +0 -0
  78. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-rules/typescript-cli-tool-rules.md +0 -0
  79. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-rules/typescript-rules.md +0 -0
  80. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-setup/shortcut:setup-beads.md +0 -0
  81. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-setup/shortcut:setup-github-cli.md +0 -0
  82. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-shortcuts/shortcut:cleanup-all.md +0 -0
  83. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-shortcuts/shortcut:cleanup-remove-trivial-tests.md +0 -0
  84. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-shortcuts/shortcut:cleanup-update-docstrings.md +0 -0
  85. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-shortcuts/shortcut:coding-spike.md +0 -0
  86. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-shortcuts/shortcut:commit-code.md +0 -0
  87. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-shortcuts/shortcut:create-or-update-pr-with-validation-plan.md +0 -0
  88. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-shortcuts/shortcut:create-or-update-validation-plan.md +0 -0
  89. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-shortcuts/shortcut:create-pr-simple.md +0 -0
  90. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-shortcuts/shortcut:implement-beads.md +0 -0
  91. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-shortcuts/shortcut:implement-spec.md +0 -0
  92. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-shortcuts/shortcut:merge-upstream.md +0 -0
  93. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-shortcuts/shortcut:new-architecture-doc.md +0 -0
  94. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-shortcuts/shortcut:new-implementation-beads-from-spec.md +0 -0
  95. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-shortcuts/shortcut:new-implementation-spec.md +0 -0
  96. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-shortcuts/shortcut:new-plan-spec.md +0 -0
  97. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-shortcuts/shortcut:new-research-brief.md +0 -0
  98. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-shortcuts/shortcut:new-validation-spec.md +0 -0
  99. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-shortcuts/shortcut:precommit-process.md +0 -0
  100. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-shortcuts/shortcut:refine-spec.md +0 -0
  101. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-shortcuts/shortcut:review-all-code-specs-docs-convex.md +0 -0
  102. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-shortcuts/shortcut:review-pr-and-fix-with-beads.md +0 -0
  103. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-shortcuts/shortcut:review-pr.md +0 -0
  104. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-shortcuts/shortcut:revise-architecture-doc.md +0 -0
  105. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-shortcuts/shortcut:update-spec.md +0 -0
  106. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/agent-shortcuts/shortcut:update-specs-status.md +0 -0
  107. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/research/archive/.empty +0 -0
  108. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/research/current/.empty +0 -0
  109. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/research/current/research-cli-golden-testing.md +0 -0
  110. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/research/current/research-code-coverage-typescript.md +0 -0
  111. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/research/current/research-convex-db-limits-best-practices.md +0 -0
  112. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/research/current/research-modern-typescript-cli-patterns.md +0 -0
  113. {flowmark-0.6.0 → flowmark-0.6.2}/docs/general/research/current/research-modern-typescript-monorepo-patterns.md +0 -0
  114. {flowmark-0.6.0 → flowmark-0.6.2}/docs/installation.md +0 -0
  115. {flowmark-0.6.0 → flowmark-0.6.2}/docs/markdoc-tag-support-plan.md +0 -0
  116. {flowmark-0.6.0 → flowmark-0.6.2}/docs/project/architecture/archive/.empty +0 -0
  117. {flowmark-0.6.0 → flowmark-0.6.2}/docs/project/architecture/current/.empty +0 -0
  118. {flowmark-0.6.0 → flowmark-0.6.2}/docs/project/architecture/template-architecture.md +0 -0
  119. {flowmark-0.6.0 → flowmark-0.6.2}/docs/project/research/archive/.empty +0 -0
  120. {flowmark-0.6.0 → flowmark-0.6.2}/docs/project/research/current/.empty +0 -0
  121. {flowmark-0.6.0 → flowmark-0.6.2}/docs/project/research/template-research-brief.md +0 -0
  122. {flowmark-0.6.0 → flowmark-0.6.2}/docs/project/specs/active/.empty +0 -0
  123. {flowmark-0.6.0 → flowmark-0.6.2}/docs/project/specs/backlog/.empty +0 -0
  124. {flowmark-0.6.0 → flowmark-0.6.2}/docs/project/specs/done/.empty +0 -0
  125. {flowmark-0.6.0 → flowmark-0.6.2}/docs/project/specs/paused/.empty +0 -0
  126. {flowmark-0.6.0 → flowmark-0.6.2}/docs/project/specs/template-bugfix.md +0 -0
  127. {flowmark-0.6.0 → flowmark-0.6.2}/docs/project/specs/template-implementation-spec.md +0 -0
  128. {flowmark-0.6.0 → flowmark-0.6.2}/docs/project/specs/template-plan-spec.md +0 -0
  129. {flowmark-0.6.0 → flowmark-0.6.2}/docs/project/specs/template-validation-spec.md +0 -0
  130. {flowmark-0.6.0 → flowmark-0.6.2}/docs/publishing.md +0 -0
  131. {flowmark-0.6.0 → flowmark-0.6.2}/pyproject.toml +0 -0
  132. {flowmark-0.6.0 → flowmark-0.6.2}/src/flowmark/formats/frontmatter.py +0 -0
  133. {flowmark-0.6.0 → flowmark-0.6.2}/src/flowmark/linewrapping/sentence_split_regex.py +0 -0
  134. {flowmark-0.6.0 → flowmark-0.6.2}/src/flowmark/py.typed +0 -0
  135. {flowmark-0.6.0 → flowmark-0.6.2}/src/flowmark/transforms/doc_cleanups.py +0 -0
  136. {flowmark-0.6.0 → flowmark-0.6.2}/src/flowmark/transforms/doc_transforms.py +0 -0
  137. {flowmark-0.6.0 → flowmark-0.6.2}/src/flowmark/typography/ellipses.py +0 -0
  138. {flowmark-0.6.0 → flowmark-0.6.2}/tests/test_alerts.py +0 -0
  139. {flowmark-0.6.0 → flowmark-0.6.2}/tests/test_cleanups.py +0 -0
  140. {flowmark-0.6.0 → flowmark-0.6.2}/tests/test_ellipses.py +0 -0
  141. {flowmark-0.6.0 → flowmark-0.6.2}/tests/test_fenced_code_blocks.py +0 -0
  142. {flowmark-0.6.0 → flowmark-0.6.2}/tests/test_frontmatter.py +0 -0
  143. {flowmark-0.6.0 → flowmark-0.6.2}/tests/test_ref_docs.py +0 -0
  144. {flowmark-0.6.0 → flowmark-0.6.2}/tests/test_sentences.py +0 -0
  145. {flowmark-0.6.0 → flowmark-0.6.2}/tests/test_smartquotes.py +0 -0
  146. {flowmark-0.6.0 → flowmark-0.6.2}/tests/test_width_options.py +0 -0
  147. {flowmark-0.6.0 → flowmark-0.6.2}/tests/testdocs/.gitignore +0 -0
  148. {flowmark-0.6.0 → flowmark-0.6.2}/uv.lock +0 -0
@@ -4,7 +4,7 @@
4
4
 
5
5
  .DEFAULT_GOAL := default
6
6
 
7
- .PHONY: default install lint test upgrade build clean format-docs
7
+ .PHONY: default install lint test upgrade build clean format-docs benchmark profile
8
8
 
9
9
  default: format-docs install lint test
10
10
 
@@ -34,6 +34,12 @@ clean:
34
34
  format-docs:
35
35
  uv run flowmark --auto README.md docs/*.md
36
36
 
37
+ benchmark:
38
+ uv run devtools/benchmark.py --compare 0.6.0
39
+
40
+ profile:
41
+ uv run devtools/benchmark.py --profile
42
+
37
43
  # Reset the expected reference docs to the actual ones currently produced.
38
44
  reset-ref-docs:
39
45
  cp tests/testdocs/testdoc.actual.auto.md tests/testdocs/testdoc.expected.auto.md
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flowmark
3
- Version: 0.6.0
3
+ Version: 0.6.2
4
4
  Summary: Better auto-formatting for Markdown and plaintext
5
5
  Project-URL: Repository, https://github.com/jlevy/flowmark
6
6
  Author-email: Joshua Levy <joshua@cal.berkeley.edu>
@@ -0,0 +1,226 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Benchmark script for Flowmark performance testing.
4
+
5
+ Usage:
6
+ # Benchmark current dev version
7
+ uv run devtools/benchmark.py
8
+
9
+ # Benchmark a specific released version
10
+ uv run devtools/benchmark.py --version 0.6.0
11
+
12
+ # Benchmark with profiling enabled
13
+ uv run devtools/benchmark.py --profile
14
+
15
+ # Compare current dev against a release
16
+ uv run devtools/benchmark.py --compare 0.6.0
17
+
18
+ # Custom test file and iterations
19
+ uv run devtools/benchmark.py --file tests/testdocs/testdoc.orig.md --iterations 10
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import cProfile
26
+ import io
27
+ import pstats
28
+ import statistics
29
+ import subprocess
30
+ import sys
31
+ import time
32
+ from pathlib import Path
33
+ from textwrap import dedent
34
+
35
+ DEFAULT_TEST_FILE = Path("tests/testdocs/testdoc.orig.md")
36
+ DEFAULT_ITERATIONS = 10
37
+
38
+
39
+ def benchmark_current(test_file: Path, iterations: int, semantic: bool = True) -> list[float]:
40
+ """Benchmark the current dev version by importing directly."""
41
+ sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
42
+ from flowmark import reformat_text
43
+
44
+ content = test_file.read_text()
45
+ print("Benchmarking current dev version")
46
+ print(f"File: {test_file} ({len(content)} chars, {len(content.splitlines())} lines)")
47
+ print(f"Running {iterations} iterations...\n")
48
+
49
+ times: list[float] = []
50
+ for i in range(iterations):
51
+ start = time.perf_counter()
52
+ reformat_text(content, semantic=semantic)
53
+ elapsed = time.perf_counter() - start
54
+ times.append(elapsed)
55
+ print(f" Run {i + 1}: {elapsed * 1000:.1f}ms")
56
+
57
+ return times
58
+
59
+
60
+ def benchmark_version(
61
+ version: str, test_file: Path, iterations: int, semantic: bool = True
62
+ ) -> list[float]:
63
+ """Benchmark a specific released version using uvx."""
64
+ content = test_file.read_text()
65
+ print(f"Benchmarking v{version}")
66
+ print(f"File: {test_file} ({len(content)} chars, {len(content.splitlines())} lines)")
67
+ print(f"Running {iterations} iterations...\n")
68
+
69
+ benchmark_script = dedent(f'''
70
+ import time
71
+ import statistics
72
+ from pathlib import Path
73
+ from flowmark import reformat_text
74
+
75
+ content = Path("{test_file}").read_text()
76
+ times = []
77
+ for i in range({iterations}):
78
+ start = time.perf_counter()
79
+ reformat_text(content, semantic={semantic})
80
+ elapsed = time.perf_counter() - start
81
+ times.append(elapsed)
82
+ print(f" Run {{i + 1}}: {{elapsed * 1000:.1f}}ms")
83
+
84
+ # Output times as comma-separated for parsing
85
+ print("TIMES:" + ",".join(str(t) for t in times))
86
+ ''').strip()
87
+
88
+ result = subprocess.run(
89
+ ["uvx", f"--with=flowmark=={version}", "python", "-c", benchmark_script],
90
+ capture_output=True,
91
+ text=True,
92
+ timeout=300,
93
+ )
94
+
95
+ if result.returncode != 0:
96
+ print(f"Error running v{version}: {result.stderr}", file=sys.stderr)
97
+ return []
98
+
99
+ # Print the run output (excluding the TIMES line)
100
+ for line in result.stdout.splitlines():
101
+ if not line.startswith("TIMES:"):
102
+ print(line)
103
+
104
+ # Parse times from output
105
+ for line in result.stdout.splitlines():
106
+ if line.startswith("TIMES:"):
107
+ return [float(t) for t in line[6:].split(",")]
108
+
109
+ return []
110
+
111
+
112
+ def print_stats(name: str, times: list[float]) -> None:
113
+ """Print statistics for timing results."""
114
+ if not times:
115
+ print(f"{name}: No valid results")
116
+ return
117
+ print(f"\n{name}:")
118
+ print(f" Mean: {statistics.mean(times) * 1000:.1f}ms")
119
+ print(f" Median: {statistics.median(times) * 1000:.1f}ms")
120
+ print(f" Min: {min(times) * 1000:.1f}ms")
121
+ print(f" Max: {max(times) * 1000:.1f}ms")
122
+ if len(times) > 1:
123
+ print(f" StdDev: {statistics.stdev(times) * 1000:.1f}ms")
124
+
125
+
126
+ def profile_current(test_file: Path, semantic: bool = True) -> None:
127
+ """Profile the current dev version and show top functions."""
128
+ sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
129
+ from flowmark import reformat_text
130
+
131
+ content = test_file.read_text()
132
+ print("Profiling current dev version")
133
+ print(f"File: {test_file} ({len(content)} chars, {len(content.splitlines())} lines)\n")
134
+
135
+ profiler = cProfile.Profile()
136
+ profiler.enable()
137
+ reformat_text(content, semantic=semantic)
138
+ profiler.disable()
139
+
140
+ stream = io.StringIO()
141
+ stats = pstats.Stats(profiler, stream=stream)
142
+ stats.sort_stats("cumulative")
143
+ stats.print_stats(40)
144
+ print(stream.getvalue())
145
+
146
+
147
+ def main() -> None:
148
+ parser = argparse.ArgumentParser(
149
+ description="Benchmark Flowmark performance",
150
+ formatter_class=argparse.RawDescriptionHelpFormatter,
151
+ epilog=__doc__,
152
+ )
153
+ parser.add_argument(
154
+ "--version",
155
+ "-v",
156
+ help="Benchmark a specific released version (e.g., 0.6.0)",
157
+ )
158
+ parser.add_argument(
159
+ "--compare",
160
+ "-c",
161
+ help="Compare current dev against a specific version (e.g., 0.6.0)",
162
+ )
163
+ parser.add_argument(
164
+ "--profile",
165
+ "-p",
166
+ action="store_true",
167
+ help="Run profiling on current dev version",
168
+ )
169
+ parser.add_argument(
170
+ "--file",
171
+ "-f",
172
+ type=Path,
173
+ default=DEFAULT_TEST_FILE,
174
+ help=f"Test file to use (default: {DEFAULT_TEST_FILE})",
175
+ )
176
+ parser.add_argument(
177
+ "--iterations",
178
+ "-n",
179
+ type=int,
180
+ default=DEFAULT_ITERATIONS,
181
+ help=f"Number of iterations (default: {DEFAULT_ITERATIONS})",
182
+ )
183
+ parser.add_argument(
184
+ "--plain",
185
+ action="store_true",
186
+ help="Use plain line wrapping instead of semantic",
187
+ )
188
+
189
+ args = parser.parse_args()
190
+ semantic = not args.plain
191
+
192
+ if not args.file.exists():
193
+ print(f"Error: Test file not found: {args.file}", file=sys.stderr)
194
+ sys.exit(1)
195
+
196
+ if args.profile:
197
+ profile_current(args.file, semantic=semantic)
198
+ elif args.compare:
199
+ current_times = benchmark_current(args.file, args.iterations, semantic=semantic)
200
+ print()
201
+ old_times = benchmark_version(args.compare, args.file, args.iterations, semantic=semantic)
202
+
203
+ print_stats("Current dev version", current_times)
204
+ print_stats(f"v{args.compare}", old_times)
205
+
206
+ if current_times and old_times:
207
+ current_mean = statistics.mean(current_times)
208
+ old_mean = statistics.mean(old_times)
209
+ ratio = current_mean / old_mean
210
+ diff_pct = (ratio - 1) * 100
211
+ print("\nComparison:")
212
+ if ratio > 1:
213
+ print(f" Current is {diff_pct:.1f}% slower than v{args.compare}")
214
+ else:
215
+ print(f" Current is {abs(diff_pct):.1f}% faster than v{args.compare}")
216
+ print(f" Ratio: {ratio:.2f}x")
217
+ elif args.version:
218
+ times = benchmark_version(args.version, args.file, args.iterations, semantic=semantic)
219
+ print_stats(f"v{args.version}", times)
220
+ else:
221
+ times = benchmark_current(args.file, args.iterations, semantic=semantic)
222
+ print_stats("Current dev version", times)
223
+
224
+
225
+ if __name__ == "__main__":
226
+ main()
@@ -0,0 +1,329 @@
1
+ # Plan Spec: List Spacing Control (`--list-spacing` Option)
2
+
3
+ ## Purpose
4
+
5
+ This is a technical design doc for adding a `--list-spacing` CLI option to Flowmark that
6
+ controls how tight vs loose list formatting is handled during Markdown normalization.
7
+
8
+ **Terminology Note**: CommonMark uses "tight" and "loose" as the official terms for list
9
+ spacing. A tight list has no blank lines between items; a loose list has blank lines.
10
+
11
+ ## Background
12
+
13
+ Flowmark has historically converted **all lists to loose format** (blank lines between
14
+ items). This was an intentional design decision documented in
15
+ [markdown_filling.py:51-52](src/flowmark/linewrapping/markdown_filling.py#L51-L52):
16
+
17
+ > "Also enforces that all list items have two newlines between them, so that items are
18
+ > separate paragraphs when viewed as plaintext."
19
+
20
+ **CommonMark Spec Reference:**
21
+
22
+ Per [CommonMark 0.31.2](https://spec.commonmark.org/0.31.2/#lists):
23
+
24
+ | List Type | Definition | HTML Rendering |
25
+ |-----------|------------|----------------|
26
+ | **Tight** | No blank lines between items | Content NOT wrapped in `<p>` tags |
27
+ | **Loose** | Blank lines between items OR internal blank lines | Content wrapped in `<p>` tags |
28
+
29
+ A list becomes loose if:
30
+ 1. Any blank line separates consecutive items
31
+ 2. Any item contains multiple block elements with blank lines between them
32
+
33
+ **Current Behavior Issues:**
34
+
35
+ - Cannot round-trip documents (tight lists become loose)
36
+ - Author intent for compact lists is not preserved
37
+ - HTML rendering changes semantically (`<li>text</li>` vs `<li><p>text</p></li>`)
38
+ - Files become larger due to extra newlines
39
+
40
+ **Key Finding:**
41
+
42
+ Marko (the Markdown parser) exposes a `tight` boolean attribute on parsed `List` elements,
43
+ making it feasible to detect and preserve the original list style.
44
+
45
+ ## Summary of Task
46
+
47
+ Add a `--list-spacing` CLI option with three modes:
48
+
49
+ | Mode | Behavior |
50
+ |------|----------|
51
+ | `preserve` | Keep lists tight or loose as authored (NEW DEFAULT) |
52
+ | `loose` | Convert all lists to loose format (current behavior) |
53
+ | `tight` | Convert all lists to tight format where possible |
54
+
55
+ This will be exposed as:
56
+ - CLI: `--list-spacing={preserve|tight|loose}` (default: `preserve`)
57
+ - API: `list_spacing: Literal["preserve", "tight", "loose"] = "preserve"` parameter
58
+
59
+ ## Backward Compatibility
60
+
61
+ **BACKWARD COMPATIBILITY REQUIREMENTS:**
62
+
63
+ - **Code types, methods, and function signatures**: KEEP DEPRECATED - existing callers
64
+ using default parameters should get the new `preserve` behavior, but we need to ensure
65
+ no breaking changes for anyone explicitly passing parameters
66
+
67
+ - **Library APIs**: KEEP DEPRECATED for `reformat_text()` and `fill_markdown()` - add new
68
+ `list_spacing` parameter with default that maintains current output for most use cases
69
+
70
+ - **CLI behavior**: BREAKING CHANGE (intentional) - the new default `preserve` will
71
+ produce different output for tight lists. This is the desired behavior per user request.
72
+
73
+ - **File formats**: SUPPORT BOTH - existing Markdown files should produce similar output
74
+ for already-loose lists; tight lists will now remain tight by default
75
+
76
+ **Key Compatibility Note:**
77
+
78
+ The `testdoc.expected.*.md` files will need to be regenerated since they contain tight
79
+ lists that are currently converted to loose. The new expected output will preserve tight
80
+ lists.
81
+
82
+ ## Stage 1: Planning Stage
83
+
84
+ ### Minimum Viable Feature
85
+
86
+ 1. Add `--list-spacing` CLI option with `preserve`, `loose`, and `tight` modes
87
+ 2. Default to `preserve` (breaking change from current `loose` behavior)
88
+ 3. Detect list tightness via Marko's `list.tight` attribute
89
+ 4. Thread spacing mode through to list rendering
90
+ 5. Update test expectations
91
+
92
+ ### Not In Scope
93
+
94
+ - Per-list overrides (e.g., `<!-- flowmark: loose -->` comments)
95
+ - Heuristic-based auto-detection of "should be tight" vs "should be loose"
96
+ - Different behavior for nested lists vs top-level lists
97
+
98
+ ### Acceptance Criteria
99
+
100
+ 1. `flowmark --list-spacing=preserve file.md` preserves tight lists as tight
101
+ 2. `flowmark --list-spacing=preserve file.md` preserves loose lists as loose
102
+ 3. `flowmark --list-spacing=loose file.md` converts all lists to loose (current behavior)
103
+ 4. `flowmark --list-spacing=tight file.md` converts all lists to tight where possible
104
+ 5. `flowmark file.md` uses `preserve` by default
105
+ 6. Nested lists are controlled independently (each preserves its own tightness)
106
+ 7. Lists with multi-paragraph items remain loose regardless of mode (CommonMark requirement)
107
+ 8. `make lint` and `make test` pass
108
+
109
+ ### Resolved Questions
110
+
111
+ - [x] **Q1**: Should `tight` mode force-convert loose lists, or only convert lists that
112
+ CAN be tight (no multi-paragraph items)?
113
+ **Decision**: Only convert lists that can be tight; leave multi-paragraph items loose.
114
+
115
+ - [x] **Q2**: How should nested lists behave? Same mode as parent, or independent?
116
+ **Decision**: Each list independently uses the mode—nested tight stays tight, nested
117
+ loose stays loose in `preserve` mode.
118
+
119
+ - [x] **Q3**: Should `--auto` flag continue to use `loose`, or switch to `preserve`?
120
+ **Decision**: All modes use `preserve` as the default. Explicit `--list-spacing=X`
121
+ overrides any other settings (including `--auto`).
122
+
123
+ ## Stage 2: Architecture Stage
124
+
125
+ ### Current Architecture
126
+
127
+ List rendering is handled in
128
+ [flowmark_markdown.py:226-261](src/flowmark/formats/flowmark_markdown.py#L226-L261):
129
+
130
+ ```python
131
+ def render_list(self, element: block.List) -> str:
132
+ # Currently ignores element.tight
133
+ result: list[str] = []
134
+ for i, child in enumerate(element.children):
135
+ # ... configure prefix ...
136
+ with self.container(prefix, subsequent_indent):
137
+ rendered_item = self.render(child)
138
+ result.append(rendered_item)
139
+ return "".join(result)
140
+
141
+ def render_list_item(self, element: block.ListItem) -> str:
142
+ result = ""
143
+ # We want all list items to have two newlines between them.
144
+ if self._suppress_item_break:
145
+ self._suppress_item_break = False
146
+ else:
147
+ result += self._second_prefix.strip() + "\n" # <-- Always adds blank line
148
+ result += self.render_children(element)
149
+ return result
150
+ ```
151
+
152
+ **Key Issue**: `render_list_item()` always adds a blank line between items via
153
+ `_suppress_item_break` logic, regardless of the original list tightness.
154
+
155
+ ### Proposed Changes
156
+
157
+ #### Change 1: Add `ListSpacing` Enum
158
+
159
+ **Location**: New in `flowmark_markdown.py` or separate types module
160
+
161
+ ```python
162
+ from enum import StrEnum
163
+
164
+ class ListSpacing(StrEnum):
165
+ preserve = "preserve"
166
+ loose = "loose"
167
+ tight = "tight"
168
+ ```
169
+
170
+ #### Change 2: Thread Spacing Mode Through Renderer
171
+
172
+ **Location**: `MarkdownNormalizer.__init__()` and `flowmark_markdown()`
173
+
174
+ Add `_list_spacing: ListSpacing` instance variable that controls list rendering behavior.
175
+
176
+ #### Change 3: Modify `render_list()` to Detect Tightness
177
+
178
+ **Location**: `render_list()` method
179
+
180
+ ```python
181
+ def render_list(self, element: block.List) -> str:
182
+ # Determine effective tightness based on mode
183
+ if self._list_spacing == ListSpacing.preserve:
184
+ is_tight = element.tight
185
+ elif self._list_spacing == ListSpacing.tight:
186
+ is_tight = self._can_be_tight(element) # Check for multi-para items
187
+ else: # loose
188
+ is_tight = False
189
+
190
+ # Pass is_tight to render_list_item via instance variable
191
+ old_tight = self._current_list_tight
192
+ self._current_list_tight = is_tight
193
+ # ... existing rendering logic ...
194
+ self._current_list_tight = old_tight
195
+ ```
196
+
197
+ #### Change 4: Modify `render_list_item()` to Respect Tightness
198
+
199
+ **Location**: `render_list_item()` method
200
+
201
+ ```python
202
+ def render_list_item(self, element: block.ListItem) -> str:
203
+ result = ""
204
+ # Only add blank line for loose lists
205
+ if not self._current_list_tight:
206
+ if self._suppress_item_break:
207
+ self._suppress_item_break = False
208
+ else:
209
+ result += self._second_prefix.strip() + "\n"
210
+ # ... rest of method
211
+ ```
212
+
213
+ #### Change 5: Update CLI and API
214
+
215
+ **Locations**:
216
+ - `cli.py`: Add `--list-spacing` argument
217
+ - `reformat_api.py`: Add `list_spacing` parameter to `reformat_text()`, `reformat_file()`,
218
+ `reformat_files()`
219
+ - `markdown_filling.py`: Add `list_spacing` parameter to `fill_markdown()`
220
+
221
+ ### Architecture Diagram
222
+
223
+ ```
224
+ CLI: --list-spacing=preserve
225
+ │
226
+ ▼
227
+ reformat_file()
228
+ │
229
+ ▼
230
+ fill_markdown(list_spacing="preserve")
231
+ │
232
+ ▼
233
+ flowmark_markdown(line_wrapper, list_spacing="preserve")
234
+ │
235
+ ▼
236
+ MarkdownNormalizer(line_wrapper, list_spacing="preserve")
237
+ │
238
+ ├─► render_list() ──► reads element.tight
239
+ │ │
240
+ │ ▼
241
+ │ Determines effective tightness based on mode
242
+ │ │
243
+ │ ▼
244
+ └─► render_list_item() ──► adds blank line only if not tight
245
+ ```
246
+
247
+ ## Stage 3: Implementation Phases
248
+
249
+ ### Phase 1: Core Infrastructure
250
+
251
+ - [x] Add `ListSpacing` enum
252
+ - [x] Add `list_spacing` parameter to `MarkdownNormalizer.__init__()`
253
+ - [x] Add `_current_list_tight` instance variable for threading state
254
+ - [x] Add `_can_be_tight()` helper method for `tight` mode
255
+
256
+ ### Phase 2: List Rendering Changes
257
+
258
+ - [x] Modify `render_list()` to compute effective tightness
259
+ - [x] Modify `render_list_item()` to conditionally add blank lines
260
+ - [x] Handle edge cases (nested lists, code blocks in items, etc.)
261
+
262
+ ### Phase 3: API Updates
263
+
264
+ - [x] Add `list_spacing` parameter to `fill_markdown()`
265
+ - [x] Add `list_spacing` parameter to `reformat_text()`
266
+ - [x] Add `list_spacing` parameter to `reformat_file()` and `reformat_files()`
267
+
268
+ ### Phase 4: CLI Updates
269
+
270
+ - [x] Add `--list-spacing` argument to CLI
271
+ - [x] Update `--auto` flag to use `preserve`
272
+ - [x] Update help text
273
+
274
+ ### Phase 5: Testing and Documentation
275
+
276
+ - [x] Add unit tests for `ListSpacing` modes
277
+ - [x] Update `test_list_spacing.py` with preserve/tight mode tests
278
+ - [x] Regenerate `testdoc.expected.*.md` files
279
+ - [x] Update README/docstrings
280
+
281
+ ## Stage 4: Validation Stage
282
+
283
+ ### Test Strategy
284
+
285
+ 1. **Unit Tests**: Test each spacing mode with tight and loose input lists
286
+ 2. **Edge Cases**:
287
+ - Nested lists (tight outer, loose inner and vice versa)
288
+ - Lists with code blocks (must remain loose per CommonMark)
289
+ - Lists with multiple paragraphs (must remain loose)
290
+ - Mixed content lists
291
+ 3. **Integration Tests**: Full document processing with various modes
292
+ 4. **Regression Tests**: Ensure loose mode produces identical output to current behavior
293
+
294
+ ### Test Cases
295
+
296
+ ```python
297
+ # Tight list stays tight in preserve mode
298
+ def test_tight_list_preserved():
299
+ input = "- one\n- two\n- three"
300
+ output = fill_markdown(input, list_spacing="preserve")
301
+ assert output == "- one\n- two\n- three\n"
302
+
303
+ # Tight list becomes loose in loose mode
304
+ def test_tight_list_to_loose():
305
+ input = "- one\n- two\n- three"
306
+ output = fill_markdown(input, list_spacing="loose")
307
+ assert output == "- one\n\n- two\n\n- three\n"
308
+
309
+ # Loose list becomes tight in tight mode
310
+ def test_loose_list_to_tight():
311
+ input = "- one\n\n- two\n\n- three"
312
+ output = fill_markdown(input, list_spacing="tight")
313
+ assert output == "- one\n- two\n- three\n"
314
+
315
+ # Multi-paragraph item stays loose even in tight mode
316
+ def test_multi_para_stays_loose():
317
+ input = "- para1\n\n para2\n- item2"
318
+ output = fill_markdown(input, list_spacing="tight")
319
+ # Item with multiple paragraphs forces loose
320
+ assert "\n\n" in output
321
+ ```
322
+
323
+ ### Success Criteria
324
+
325
+ - [x] All existing tests pass (with updated expectations)
326
+ - [x] New spacing mode tests pass
327
+ - [x] `make lint` passes
328
+ - [x] `make test` passes
329
+ - [x] Manual testing with real documents confirms expected behavior
@@ -0,0 +1,85 @@
1
+ # Feature Validation: List Spacing Control (`--list-spacing` Option)
2
+
3
+ ## Purpose
4
+
5
+ This is a validation spec for the `--list-spacing` CLI option that controls how tight vs
6
+ loose list formatting is handled during Markdown normalization.
7
+
8
+ **Feature Plan:** [plan-2026-01-14-list-spacing-control.md](plan-2026-01-14-list-spacing-control.md)
9
+
10
+ ## Stage 4: Validation Stage
11
+
12
+ ## Automated Validation (Testing Performed)
13
+
14
+ ### Unit Testing
15
+
16
+ The following unit tests have been added or updated in `tests/test_list_spacing.py`:
17
+
18
+ **Preserve Mode Tests (default):**
19
+ - `test_tight_list_preserved` - Tight lists stay tight
20
+ - `test_loose_list_preserved` - Loose lists stay loose
21
+ - `test_preserve_is_default` - Confirm preserve is the default
22
+ - `test_numbered_list_preserve` - Numbered lists preserve tightness
23
+
24
+ **Loose Mode Tests:**
25
+ - `test_tight_list_to_loose` - Tight lists become loose
26
+ - `test_loose_list_stays_loose` - Loose lists stay loose
27
+ - `test_numbered_list_to_loose` - Numbered lists become loose
28
+
29
+ **Tight Mode Tests:**
30
+ - `test_loose_list_to_tight` - Loose lists become tight
31
+ - `test_tight_list_stays_tight` - Tight lists stay tight
32
+ - `test_multi_para_stays_loose_in_tight_mode` - Multi-paragraph items force loose (CommonMark
33
+ requirement)
34
+
35
+ **Nested List Tests:**
36
+ - `test_nested_lists_independent_preserve` - Each nested list independently preserves
37
+ tightness
38
+ - `test_nested_lists_loose_outer_tight_inner` - Mixed outer/inner tightness preserved
39
+
40
+ **Complex Content Tests:**
41
+ - `test_list_items_with_code_blocks_preserve` - Code blocks preserve tightness
42
+ - `test_list_items_with_code_blocks_loose` - Code blocks with loose mode
43
+ - `test_list_items_with_quote_blocks` - Quote blocks preserve tightness
44
+
45
+ **Spacing Normalization Tests:**
46
+ - `test_input_spacing_normalization_loose` - Various input spacings normalize to loose
47
+ - `test_input_spacing_normalization_tight` - Various input spacings normalize to tight
48
+ - `test_complex_content_with_loose_mode` - Complex content spacing in loose mode
49
+ - `test_multi_paragraph_spacing_loose_mode` - Multi-paragraph items in loose mode
50
+
51
+ **Other Updated Tests:**
52
+ - `test_escape_handling.py` - Updated to use `list_spacing="loose"` for escape tests
53
+ - `test_filling.py` - Updated to use `list_spacing="loose"` for backward compatibility
54
+ - `test_heading_spacing.py` - Updated to use `list_spacing="loose"` where needed
55
+
56
+ ### Integration and End-to-End Testing
57
+
58
+ - All 172 tests pass (`make test`)
59
+ - Linting passes (`make lint`)
60
+ - Reference documents (`tests/testdocs/testdoc.expected.*.md`) regenerated for new default
61
+ behavior
62
+
63
+ ## Manual Testing Needed
64
+
65
+ All core functionality is covered by automated tests. Manual review is only needed for:
66
+
67
+ ### Code Review Items
68
+
69
+ 1. **Help text clarity** - Review `flowmark --help` to confirm the `--list-spacing` option
70
+ description is clear and understandable
71
+
72
+ 2. **Breaking change communication** - Confirm the breaking change (default behavior changed
73
+ from `loose` to `preserve`) is adequately documented in:
74
+ - The commit message
75
+ - The PR description
76
+ - The plan spec
77
+
78
+ ### No Additional Manual Testing Required
79
+
80
+ The following are all covered by automated tests:
81
+ - All three modes (`preserve`, `loose`, `tight`)
82
+ - Nested list handling
83
+ - Multi-paragraph items
84
+ - CLI argument parsing
85
+ - File processing (in-place and stdout)