oh-markdown-tool 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,34 @@
1
+ """Markdown document tool for structural editing and formatting."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .numbering import NumberingIssue, RenumberResult, SectionNumberer, ValidationResult
6
+ from .parser import MarkdownParser, ParseResult, Section
7
+ from .toc import (
8
+ TocAction,
9
+ TocManager,
10
+ TocRemoveResult,
11
+ TocUpdateResult,
12
+ TocValidationResult,
13
+ )
14
+ from .tool import MarkdownAction, MarkdownDocumentTool, MarkdownExecutor, MarkdownObservation
15
+
16
+ __all__ = [
17
+ "__version__",
18
+ "MarkdownParser",
19
+ "ParseResult",
20
+ "Section",
21
+ "SectionNumberer",
22
+ "NumberingIssue",
23
+ "RenumberResult",
24
+ "ValidationResult",
25
+ "MarkdownDocumentTool",
26
+ "MarkdownAction",
27
+ "MarkdownObservation",
28
+ "MarkdownExecutor",
29
+ "TocAction",
30
+ "TocManager",
31
+ "TocUpdateResult",
32
+ "TocRemoveResult",
33
+ "TocValidationResult",
34
+ ]
@@ -0,0 +1,158 @@
1
+ """Markdown formatting operations using mdformat and pymarkdownlnt."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import mdformat
8
+ from pymarkdown.api import PyMarkdownApi
9
+
10
+
11
+ @dataclass
12
+ class LintIssue:
13
+ """A single markdown lint issue."""
14
+
15
+ line: int
16
+ column: int
17
+ rule_id: str
18
+ rule_name: str
19
+ message: str
20
+ extra_info: str | None = None
21
+
22
+
23
+ @dataclass
24
+ class LintResult:
25
+ """Result of linting a markdown document."""
26
+
27
+ issues: list[LintIssue]
28
+
29
+ @property
30
+ def has_issues(self) -> bool:
31
+ """Return True if there are any lint issues."""
32
+ return len(self.issues) > 0
33
+
34
+
35
+ @dataclass
36
+ class FixResult:
37
+ """Result of auto-fixing markdown issues."""
38
+
39
+ was_fixed: bool
40
+ content: str
41
+ issues_remaining: list[LintIssue]
42
+
43
+ @property
44
+ def has_remaining_issues(self) -> bool:
45
+ """Return True if there are issues that couldn't be auto-fixed."""
46
+ return len(self.issues_remaining) > 0
47
+
48
+
49
+ @dataclass
50
+ class RewrapResult:
51
+ """Result of rewrapping a markdown document."""
52
+
53
+ content: str
54
+ was_modified: bool
55
+
56
+
57
+ class MarkdownFormatter:
58
+ """Formats markdown documents using mdformat and pymarkdownlnt.
59
+
60
+ Provides three operations:
61
+ - rewrap: Normalize paragraph line lengths (preserves code blocks, tables, lists)
62
+ - lint: Scan for markdown issues and report them
63
+ - fix: Auto-fix markdown issues where possible
64
+ """
65
+
66
+ def __init__(self):
67
+ """Initialize the formatter."""
68
+ self._api = PyMarkdownApi()
69
+
70
+ def rewrap(self, content: str, width: int = 80) -> RewrapResult:
71
+ """Rewrap paragraphs to specified width.
72
+
73
+ Uses mdformat to parse the markdown AST and only wrap paragraph text,
74
+ leaving structural elements (code blocks, tables, lists) intact.
75
+
76
+ Args:
77
+ content: Markdown content to rewrap.
78
+ width: Maximum line width (default 80).
79
+
80
+ Returns:
81
+ RewrapResult with the rewrapped content.
82
+ """
83
+ result = mdformat.text(content, options={"wrap": width})
84
+ return RewrapResult(
85
+ content=result,
86
+ was_modified=result != content,
87
+ )
88
+
89
+ def lint(self, content: str) -> LintResult:
90
+ """Scan content for markdown issues.
91
+
92
+ Uses pymarkdownlnt to check for common markdown issues like:
93
+ - MD009: Trailing spaces
94
+ - MD012: Multiple consecutive blank lines
95
+ - MD013: Line length (if not rewrapped first)
96
+ - And many more...
97
+
98
+ Args:
99
+ content: Markdown content to lint.
100
+
101
+ Returns:
102
+ LintResult with list of issues found.
103
+ """
104
+ if not content:
105
+ return LintResult(issues=[])
106
+
107
+ result = self._api.scan_string(content)
108
+ issues = [
109
+ LintIssue(
110
+ line=failure.line_number,
111
+ column=failure.column_number,
112
+ rule_id=failure.rule_id,
113
+ rule_name=failure.rule_name,
114
+ message=failure.rule_description,
115
+ extra_info=failure.extra_error_information or None,
116
+ )
117
+ for failure in result.scan_failures
118
+ ]
119
+ return LintResult(issues=issues)
120
+
121
+ def fix(self, content: str) -> FixResult:
122
+ """Auto-fix markdown issues where possible.
123
+
124
+ Uses pymarkdownlnt's fix mode to automatically correct issues.
125
+ Not all issues can be auto-fixed - the result includes any
126
+ remaining issues that require manual attention.
127
+
128
+ Args:
129
+ content: Markdown content to fix.
130
+
131
+ Returns:
132
+ FixResult with fixed content and any remaining issues.
133
+ """
134
+ if not content:
135
+ return FixResult(was_fixed=False, content="", issues_remaining=[])
136
+
137
+ fix_result = self._api.fix_string(content)
138
+ fixed_content = fix_result.fixed_file if fix_result.was_fixed else content
139
+
140
+ # Scan the fixed content to find remaining issues
141
+ scan_result = self._api.scan_string(fixed_content)
142
+ remaining_issues = [
143
+ LintIssue(
144
+ line=failure.line_number,
145
+ column=failure.column_number,
146
+ rule_id=failure.rule_id,
147
+ rule_name=failure.rule_name,
148
+ message=failure.rule_description,
149
+ extra_info=failure.extra_error_information or None,
150
+ )
151
+ for failure in scan_result.scan_failures
152
+ ]
153
+
154
+ return FixResult(
155
+ was_fixed=fix_result.was_fixed,
156
+ content=fixed_content,
157
+ issues_remaining=remaining_issues,
158
+ )
@@ -0,0 +1,384 @@
1
+ """Section numbering validation and management for markdown documents."""
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+
6
+ from .parser import MarkdownParser, Section
7
+
8
+
9
+ @dataclass
10
+ class NumberingIssue:
11
+ """Represents a section numbering issue."""
12
+
13
+ section_title: str
14
+ expected: str
15
+ actual: str | None
16
+ line_number: int
17
+ issue_type: str # 'missing_number', 'wrong_number', 'invalid_format'
18
+
19
+ @property
20
+ def message(self) -> str:
21
+ """Generate a descriptive message for this issue."""
22
+ if self.issue_type == "missing_number":
23
+ return f"Section '{self.section_title}' is missing a number (expected: {self.expected})"
24
+ if self.issue_type == "wrong_number":
25
+ return f"Section '{self.section_title}' has wrong number '{self.actual}' (expected: {self.expected})"
26
+ if self.issue_type == "invalid_format":
27
+ return f"Section '{self.section_title}' has invalid number format '{self.actual}'"
28
+ return f"Section '{self.section_title}' has numbering issue: {self.issue_type}"
29
+
30
+
31
+ @dataclass
32
+ class ValidationResult:
33
+ """Result of section numbering validation."""
34
+
35
+ valid: bool
36
+ issues: list[NumberingIssue]
37
+ recommendations: list[str]
38
+
39
+
40
+ @dataclass
41
+ class RenumberResult:
42
+ """Result of renumbering operation."""
43
+
44
+ content: str
45
+ sections_renumbered: int
46
+ was_modified: bool
47
+ toc_skipped: bool
48
+
49
+
50
+ class SectionNumberer:
51
+ """Handles section numbering validation, normalization, and renumbering."""
52
+
53
+ def __init__(self):
54
+ self.toc_section: Section | None = None
55
+
56
+ def validate(
57
+ self, sections: list[Section], toc_section: Section | None = None
58
+ ) -> ValidationResult:
59
+ """
60
+ Validate section numbering consistency.
61
+
62
+ Args:
63
+ sections: List of top-level sections from parser
64
+ toc_section: TOC section to skip during validation
65
+
66
+ Returns:
67
+ ValidationResult with issues and recommendations
68
+ """
69
+ self.toc_section = toc_section
70
+ issues: list[NumberingIssue] = []
71
+
72
+ # Get all sections in document order, excluding TOC
73
+ all_sections = self._get_numbered_sections(sections)
74
+
75
+ # Validate numbering sequence
76
+ expected_numbers = self._generate_expected_numbers(all_sections)
77
+
78
+ for section, expected in zip(all_sections, expected_numbers, strict=True):
79
+ if section.number != expected:
80
+ issue_type = "missing_number" if section.number is None else "wrong_number"
81
+ issues.append(
82
+ NumberingIssue(
83
+ section_title=section.title,
84
+ expected=expected,
85
+ actual=section.number,
86
+ line_number=section.start_line + 1, # Convert to 1-based
87
+ issue_type=issue_type,
88
+ )
89
+ )
90
+
91
+ # Generate recommendations
92
+ recommendations = self._generate_recommendations(issues)
93
+
94
+ return ValidationResult(
95
+ valid=len(issues) == 0, issues=issues, recommendations=recommendations
96
+ )
97
+
98
+ def normalize(self, sections: list[Section]) -> list[Section]:
99
+ """
100
+ Normalize section numbering to be sequential and properly nested.
101
+
102
+ Args:
103
+ sections: List of sections to normalize
104
+
105
+ Returns:
106
+ List of sections with corrected numbering
107
+ """
108
+ # Get all sections excluding TOC
109
+ all_sections = self._get_numbered_sections(sections)
110
+
111
+ # Generate correct numbers
112
+ expected_numbers = self._generate_expected_numbers(all_sections)
113
+
114
+ # Update section numbers
115
+ for section, expected_number in zip(all_sections, expected_numbers, strict=True):
116
+ section.number = expected_number
117
+
118
+ return sections
119
+
120
+ def renumber(
121
+ self, sections: list[Section], toc_section: Section | None = None
122
+ ) -> dict[str, object]:
123
+ """Renumber all sections sequentially, skipping TOC.
124
+
125
+ This is the low-level API that operates on Section objects.
126
+ For most use cases, prefer renumber_content() which handles
127
+ the full parse → renumber → reconstruct flow.
128
+
129
+ Args:
130
+ sections: List of sections to renumber
131
+ toc_section: TOC section to skip
132
+
133
+ Returns:
134
+ Dictionary with renumbering results
135
+ """
136
+ self.toc_section = toc_section
137
+
138
+ # Count sections before renumbering
139
+ all_sections = self._get_numbered_sections(sections)
140
+ sections_before = len([s for s in all_sections if s.number is not None])
141
+
142
+ # Normalize numbering
143
+ self.normalize(sections)
144
+
145
+ # Count sections after renumbering
146
+ sections_after = len([s for s in all_sections if s.number is not None])
147
+
148
+ return {
149
+ "result": "success",
150
+ "sections_renumbered": sections_after,
151
+ "toc_skipped": toc_section is not None,
152
+ "sections_before": sections_before,
153
+ "sections_after": sections_after,
154
+ }
155
+
156
+ def renumber_content(self, content: str) -> RenumberResult:
157
+ """Renumber all sections in a document and return the updated content.
158
+
159
+ This is the primary method for renumbering - it handles parsing,
160
+ renumbering, and reconstructing the document in one operation.
161
+
162
+ Args:
163
+ content: The markdown document content.
164
+
165
+ Returns:
166
+ RenumberResult with updated content and statistics.
167
+ """
168
+ parser = MarkdownParser()
169
+ result = parser.parse_content(content)
170
+
171
+ self.toc_section = result.toc_section
172
+ all_sections = self._get_numbered_sections(result.sections)
173
+
174
+ # Normalize numbering (updates Section objects in place)
175
+ self.normalize(result.sections)
176
+
177
+ # Reconstruct document with updated headings
178
+ updated_content = self._reconstruct_document(content, parser.get_all_sections())
179
+
180
+ return RenumberResult(
181
+ content=updated_content,
182
+ sections_renumbered=len(all_sections),
183
+ was_modified=updated_content != content,
184
+ toc_skipped=result.toc_section is not None,
185
+ )
186
+
187
+ def format_heading(self, section: Section) -> str:
188
+ """Format a section heading with proper number formatting.
189
+
190
+ Level 2 sections get a trailing period (e.g., "## 1. Title")
191
+ Level 3+ sections don't (e.g., "### 1.1 Title")
192
+
193
+ Args:
194
+ section: The section to format.
195
+
196
+ Returns:
197
+ The formatted heading line.
198
+ """
199
+ hashes = "#" * section.level
200
+ if section.number:
201
+ if section.level == 2:
202
+ return f"{hashes} {section.number}. {section.title}"
203
+ return f"{hashes} {section.number} {section.title}"
204
+ return f"{hashes} {section.title}"
205
+
206
+ def _reconstruct_document(self, content: str, sections: list[Section]) -> str:
207
+ """Reconstruct document with updated section numbering.
208
+
209
+ Args:
210
+ content: The original document content.
211
+ sections: All sections with updated numbering.
212
+
213
+ Returns:
214
+ The document with updated section numbers.
215
+ """
216
+ lines = content.splitlines()
217
+ heading_pattern = re.compile(r"^(#{1,6})\s+(.+)$")
218
+
219
+ for section in sections:
220
+ if section.start_line < len(lines):
221
+ line = lines[section.start_line]
222
+ if heading_pattern.match(line.strip()):
223
+ lines[section.start_line] = self.format_heading(section)
224
+
225
+ result = "\n".join(lines)
226
+ # Preserve trailing newline if original had one
227
+ if content.endswith("\n") and not result.endswith("\n"):
228
+ result += "\n"
229
+ return result
230
+
231
+ def _get_numbered_sections(self, sections: list[Section]) -> list[Section]:
232
+ """Get all sections that should be numbered, excluding TOC."""
233
+ numbered_sections = []
234
+
235
+ for section in sections:
236
+ # Skip TOC section
237
+ if self.toc_section and section == self.toc_section:
238
+ continue
239
+
240
+ # Add this section and all its children
241
+ numbered_sections.extend(section.get_all_sections())
242
+
243
+ # Filter out TOC if it appears in children
244
+ if self.toc_section:
245
+ numbered_sections = [s for s in numbered_sections if s != self.toc_section]
246
+
247
+ return numbered_sections
248
+
249
+ def _generate_expected_numbers(self, sections: list[Section]) -> list[str]:
250
+ """Generate expected section numbers based on hierarchy."""
251
+ if not sections:
252
+ return []
253
+
254
+ expected_numbers = []
255
+ counters: dict[int, int] = {} # level -> counter
256
+
257
+ for section in sections:
258
+ level = section.level
259
+
260
+ # Initialize counter for this level if not exists
261
+ if level not in counters:
262
+ counters[level] = 0
263
+
264
+ # Increment counter for this level
265
+ counters[level] += 1
266
+
267
+ # Reset counters for deeper levels
268
+ counters = {lvl: cnt for lvl, cnt in counters.items() if lvl <= level}
269
+
270
+ # Build number string
271
+ number_parts = []
272
+ for lvl in sorted(counters.keys()):
273
+ if lvl <= level:
274
+ number_parts.append(str(counters[lvl]))
275
+
276
+ expected_numbers.append(".".join(number_parts))
277
+
278
+ return expected_numbers
279
+
280
+ def _generate_recommendations(self, issues: list[NumberingIssue]) -> list[str]:
281
+ """Generate recommendations based on validation issues."""
282
+ recommendations = []
283
+
284
+ if not issues:
285
+ return recommendations
286
+
287
+ # Check for numbering issues
288
+ has_numbering_issues = any(
289
+ issue.issue_type in ["missing_number", "wrong_number"] for issue in issues
290
+ )
291
+
292
+ if has_numbering_issues:
293
+ recommendations.append("Run 'renumber' to fix section numbering.")
294
+
295
+ # Check for format issues
296
+ has_format_issues = any(issue.issue_type == "invalid_format" for issue in issues)
297
+
298
+ if has_format_issues:
299
+ recommendations.append("Fix section number formatting manually.")
300
+
301
+ return recommendations
302
+
303
+ def get_section_number_at_level(self, number: str, target_level: int) -> str:
304
+ """
305
+ Extract section number at a specific level.
306
+
307
+ Args:
308
+ number: Full section number (e.g., "1.2.3")
309
+ target_level: Level to extract (1-based)
310
+
311
+ Returns:
312
+ Section number at the specified level
313
+ """
314
+ if not number:
315
+ return ""
316
+
317
+ parts = number.split(".")
318
+ if target_level <= len(parts):
319
+ return ".".join(parts[:target_level])
320
+
321
+ return number
322
+
323
+ def increment_section_number(self, number: str) -> str:
324
+ """
325
+ Increment the last part of a section number.
326
+
327
+ Args:
328
+ number: Section number to increment (e.g., "1.2.3")
329
+
330
+ Returns:
331
+ Incremented section number (e.g., "1.2.4")
332
+ """
333
+ if not number:
334
+ return "1"
335
+
336
+ parts = number.split(".")
337
+ if parts:
338
+ try:
339
+ last_part = int(parts[-1])
340
+ parts[-1] = str(last_part + 1)
341
+ return ".".join(parts)
342
+ except ValueError:
343
+ # If last part is not a number, append .1
344
+ return f"{number}.1"
345
+
346
+ return "1"
347
+
348
+ def get_parent_number(self, number: str) -> str:
349
+ """
350
+ Get the parent section number.
351
+
352
+ Args:
353
+ number: Section number (e.g., "1.2.3")
354
+
355
+ Returns:
356
+ Parent section number (e.g., "1.2")
357
+ """
358
+ if not number:
359
+ return ""
360
+
361
+ parts = number.split(".")
362
+ if len(parts) > 1:
363
+ return ".".join(parts[:-1])
364
+
365
+ return ""
366
+
367
+ def is_valid_number_format(self, number: str) -> bool:
368
+ """
369
+ Check if a section number has valid format.
370
+
371
+ Args:
372
+ number: Section number to validate
373
+
374
+ Returns:
375
+ True if format is valid
376
+ """
377
+ if not number:
378
+ return False
379
+
380
+ parts = number.split(".")
381
+ if not parts:
382
+ return False
383
+
384
+ return all(part.isdigit() and int(part) > 0 for part in parts)