invar-tools 1.10.0__py3-none-any.whl → 1.11.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.
invar/core/doc_edit.py ADDED
@@ -0,0 +1,187 @@
1
+ """
2
+ Markdown document editing functions.
3
+
4
+ DX-76 Phase A-2: Section-level document editing.
5
+ Core module - pure logic, no I/O.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Literal
11
+
12
+ from deal import pre
13
+
14
+ if TYPE_CHECKING:
15
+ from invar.core.doc_parser import Section
16
+
17
+
18
+ @pre(lambda source, section, new_content, keep_heading=True: section.line_start >= 1)
19
+ @pre(lambda source, section, new_content, keep_heading=True: section.line_end >= section.line_start)
20
+ @pre(lambda source, section, new_content, keep_heading=True: section.line_end <= len(source.split("\n")))
21
+ def replace_section(
22
+ source: str,
23
+ section: Section,
24
+ new_content: str,
25
+ keep_heading: bool = True,
26
+ ) -> str:
27
+ """Replace a section's content with new content.
28
+
29
+ Args:
30
+ source: Original document source
31
+ section: Section to replace
32
+ new_content: New content to insert
33
+ keep_heading: If True, preserve the original heading line
34
+
35
+ Returns:
36
+ Modified document source
37
+
38
+ Examples:
39
+ >>> from invar.core.doc_parser import Section
40
+ >>> source = "# Title\\n\\nOld content\\n\\n# Next"
41
+ >>> section = Section("Title", "title", 1, 1, 3, 20, "title", [])
42
+ >>> result = replace_section(source, section, "New content")
43
+ >>> "# Title" in result
44
+ True
45
+ >>> "New content" in result
46
+ True
47
+ >>> "Old content" not in result
48
+ True
49
+
50
+ >>> # Without keeping heading
51
+ >>> result2 = replace_section(source, section, "# New Title\\n\\nNew content", keep_heading=False)
52
+ >>> "# New Title" in result2
53
+ True
54
+ """
55
+ lines = source.split("\n")
56
+
57
+ # Calculate line indices (0-indexed)
58
+ if keep_heading:
59
+ # Keep the heading line, replace content after it
60
+ start_idx = section.line_start # Skip heading (0-indexed after heading)
61
+ end_idx = section.line_end # 1-indexed inclusive
62
+ else:
63
+ # Replace everything including heading
64
+ start_idx = section.line_start - 1 # 0-indexed
65
+ end_idx = section.line_end # 1-indexed inclusive
66
+
67
+ # Split new content into lines
68
+ new_lines = new_content.split("\n") if new_content else []
69
+
70
+ # Build result
71
+ result_lines = lines[:start_idx] + new_lines + lines[end_idx:]
72
+
73
+ return "\n".join(result_lines)
74
+
75
+
76
+ @pre(lambda source, anchor, content, position="after": anchor.line_start >= 1)
77
+ @pre(lambda source, anchor, content, position="after": anchor.line_end <= len(source.split("\n")))
78
+ @pre(lambda source, anchor, content, position="after": position in ("before", "after", "first_child", "last_child"))
79
+ def insert_section(
80
+ source: str,
81
+ anchor: Section,
82
+ content: str,
83
+ position: Literal["before", "after", "first_child", "last_child"] = "after",
84
+ ) -> str:
85
+ """Insert new content relative to an anchor section.
86
+
87
+ Args:
88
+ source: Original document source
89
+ anchor: Reference section for insertion
90
+ content: Content to insert (should include heading if adding a section)
91
+ position: Where to insert relative to anchor:
92
+ - "before": Before the anchor section
93
+ - "after": After the anchor section (including children)
94
+ - "first_child": As first child of anchor
95
+ - "last_child": As last child of anchor
96
+
97
+ Returns:
98
+ Modified document source
99
+
100
+ Examples:
101
+ >>> from invar.core.doc_parser import Section
102
+ >>> source = "# Title\\n\\nContent\\n\\n# Next"
103
+ >>> anchor = Section("Title", "title", 1, 1, 3, 20, "title", [])
104
+ >>> result = insert_section(source, anchor, "\\n## Subsection\\n\\nNew text", "after")
105
+ >>> "## Subsection" in result
106
+ True
107
+
108
+ >>> # Insert before
109
+ >>> result2 = insert_section(source, anchor, "# Preface\\n\\nIntro\\n", "before")
110
+ >>> result2.startswith("# Preface")
111
+ True
112
+ """
113
+ lines = source.split("\n")
114
+ content_lines = content.split("\n") if content else []
115
+
116
+ if position == "before":
117
+ # Insert before anchor's line_start
118
+ insert_idx = anchor.line_start - 1 # 0-indexed
119
+ elif position == "after":
120
+ # Insert after anchor's line_end
121
+ insert_idx = anchor.line_end # After this line (0-indexed = line_end since it's 1-indexed)
122
+ elif position == "first_child":
123
+ # Insert right after the heading line
124
+ insert_idx = anchor.line_start # Right after heading (0-indexed)
125
+ else: # last_child
126
+ # Insert before the end of the section (before next sibling or EOF)
127
+ insert_idx = anchor.line_end # At the end of section
128
+
129
+ # Build result
130
+ result_lines = lines[:insert_idx] + content_lines + lines[insert_idx:]
131
+
132
+ return "\n".join(result_lines)
133
+
134
+
135
+ @pre(lambda source, section, include_children=True: section.line_start >= 1)
136
+ @pre(lambda source, section, include_children=True: section.line_end >= section.line_start)
137
+ @pre(lambda source, section, include_children=True: section.line_end <= len(source.split("\n")))
138
+ def delete_section(source: str, section: Section, include_children: bool = True) -> str:
139
+ """Delete a section from the document.
140
+
141
+ Removes content from line_start to line_end inclusive.
142
+ When include_children=False, preserves child sections.
143
+
144
+ Args:
145
+ source: Original document source
146
+ section: Section to delete
147
+ include_children: If True, delete children too (default)
148
+
149
+ Returns:
150
+ Modified document source
151
+
152
+ Examples:
153
+ >>> from invar.core.doc_parser import Section
154
+ >>> source = "# Keep\\n\\n# Delete\\n\\nContent\\n\\n# Also Keep"
155
+ >>> section = Section("Delete", "delete", 1, 3, 5, 20, "delete", [])
156
+ >>> result = delete_section(source, section)
157
+ >>> "# Keep" in result
158
+ True
159
+ >>> "# Delete" not in result
160
+ True
161
+ >>> "# Also Keep" in result
162
+ True
163
+
164
+ >>> # Without children - preserves child sections
165
+ >>> parent = Section("Parent", "parent", 1, 1, 4, 100, "parent", [
166
+ ... Section("Child", "child", 2, 3, 4, 50, "parent/child", [])
167
+ ... ])
168
+ >>> src = "# Parent\\nIntro\\n## Child\\nBody"
169
+ >>> result = delete_section(src, parent, include_children=False)
170
+ >>> "# Parent" not in result
171
+ True
172
+ >>> "## Child" in result
173
+ True
174
+ """
175
+ lines = source.split("\n")
176
+ start_idx = section.line_start - 1
177
+
178
+ if include_children or not section.children:
179
+ end_idx = section.line_end
180
+ else:
181
+ # Stop before first child
182
+ first_child_line = section.children[0].line_start
183
+ end_idx = first_child_line - 1
184
+
185
+ result_lines = lines[:start_idx] + lines[end_idx:]
186
+
187
+ return "\n".join(result_lines)