json-source-edit 0.1.0__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jérémie Lumbroso
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,184 @@
1
+ Metadata-Version: 2.4
2
+ Name: json-source-edit
3
+ Version: 0.1.0
4
+ Summary: Surgical, byte-preserving JSON editing — change only what you intend to change.
5
+ Author: Jérémie Lumbroso
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/jlumbroso/json-source-edit
8
+ Project-URL: Repository, https://github.com/jlumbroso/json-source-edit
9
+ Project-URL: Provenance, https://github.com/jlumbroso/json-source-edit/blob/main/PROVENANCE.md
10
+ Keywords: json,json-patch,diff,text-editing,format-preserving
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
14
+ Classifier: Topic :: Text Processing :: Markup
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.8
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Requires-Python: >=3.8
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: json-source-map>=1.0.5
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest; extra == "dev"
27
+ Requires-Dist: pytest-cov; extra == "dev"
28
+ Dynamic: license-file
29
+
30
+ # json-source-edit
31
+
32
+ A Python package for **surgical, byte-preserving JSON editing.** Change
33
+ only what you intend to change — everything else in the file survives
34
+ byte-for-byte, so your diff shows your edit, not a full-file rewrite.
35
+
36
+ ## The problem
37
+
38
+ The obvious way to edit a JSON file is parse → modify → serialize:
39
+
40
+ ```python
41
+ data = json.loads(text)
42
+ data["steps"][1]["line"] = 433
43
+ text = json.dumps(data, indent=2)
44
+ ```
45
+
46
+ This is correct, and it destroys your diff. Real evidence, from the project
47
+ this package was extracted out of: three simple changes to one file — two
48
+ field updates and one deletion — were *expected* to produce a ~10-line diff.
49
+ The parse-modify-serialize round-trip produced a **500+ line diff** instead.
50
+
51
+ That's because `json.dumps` doesn't know or care what your original
52
+ formatting was — indentation, key order, quote style, all of it gets
53
+ re-decided from scratch, every time, whether or not you touched it.
54
+ Multiply that by a codebase-sized JSON file and code review becomes
55
+ impossible: every line looks changed, and the actual edit is buried.
56
+
57
+ `json-source-edit` fixes this at the source: it never re-serializes the
58
+ document. It computes exact byte positions for each change against the
59
+ *original text* and splices replacement bytes into the gaps — the same
60
+ operation a film or tape splicer performs, cut and rejoin at an exact point,
61
+ everything else on the reel undisturbed.
62
+
63
+ ## Install
64
+
65
+ Not yet on PyPI (see [Status](#status) below). Until then:
66
+
67
+ ```bash
68
+ git clone https://github.com/jlumbroso/json-source-edit
69
+ cd json-source-edit
70
+ pip install -e .
71
+ ```
72
+
73
+ ## Usage
74
+
75
+ ```python
76
+ from json_source_edit import JSONEditor
77
+
78
+ editor = JSONEditor.from_string("""{
79
+ "title": "Rust Basics",
80
+ "steps": [
81
+ {"file": "src/main.rs", "line": 10},
82
+ {"file": "src/lib.rs", "line": 20}
83
+ ]
84
+ }""")
85
+ editor.replace("/steps/0/line", 433)
86
+ editor.remove("/steps/1")
87
+ result = editor.apply(validate=True)
88
+ print(editor.preview_diff())
89
+ ```
90
+
91
+ ```diff
92
+ --- original
93
+ +++ modified
94
+ @@ -1,7 +1,6 @@
95
+ {
96
+ "title": "Rust Basics",
97
+ "steps": [
98
+ - {"file": "src/main.rs", "line": 10},
99
+ - {"file": "src/lib.rs", "line": 20}
100
+ + {"file": "src/main.rs", "line": 433}
101
+ ]
102
+ }
103
+ ```
104
+
105
+ `validate=True` catches mistakes before they reach you. It reconstructs the
106
+ expected result independently — replaying your edits against a copy of the
107
+ parsed document — and diffs that against what the surgical edit actually
108
+ produced. If they disagree, it raises `SemanticValidationError` instead of
109
+ silently returning something subtly wrong.
110
+
111
+ `apply()` defaults to `validate=False`; `save()` defaults to `validate=True`
112
+ — writing to disk is where a mistake actually costs you something, so that
113
+ path checks unless you opt out.
114
+
115
+ Three more methods help you inspect a pending batch before committing to
116
+ it: `get_value`, `get_modifications`, and `preview_diff`.
117
+
118
+ ## Scope
119
+
120
+ | JSON Patch operation | Status |
121
+ |---|---|
122
+ | `replace` | Supported — any path, any depth |
123
+ | `remove` | Supported — array elements and object properties, any depth, except the document root itself (structurally undefined: the root has no parent container to apply a comma/whitespace rule against) |
124
+ | `add`, `move`, `copy`, `test` | Not implemented — raise `NotImplementedError` |
125
+
126
+ Multiple `replace`/`remove` calls batch correctly against the same original
127
+ document — every path resolves in original-document coordinates regardless
128
+ of what else is in the batch, so edit order doesn't matter and one deletion
129
+ can't corrupt another edit's position.
130
+
131
+ ## Benchmark (v0)
132
+
133
+ Reproduce with `python benchmarks/throughput.py` — a standalone script, no
134
+ test framework required.
135
+
136
+ Methodology: replaces 5% of a flat array's elements at each size, median of
137
+ 5 runs, reporting throughput against the *original* document size (the more
138
+ relevant number for "can this handle my file" than edit count alone).
139
+
140
+ Single-machine numbers, not a controlled benchmark environment — expect
141
+ real variance run to run and machine to machine; rerun locally before
142
+ relying on any of this for capacity planning.
143
+
144
+ | Size | Elements | Document | Edits | Median time | Throughput |
145
+ |---|---|---|---|---|---|
146
+ | small | 200 | 0.01MB | 10 | 0.65ms | 15,353 edits/sec, 13.7 MB/sec |
147
+ | medium | 2,000 | 0.09MB | 100 | 6.25ms | 15,990 edits/sec, 14.9 MB/sec |
148
+ | large | 20,000 | 0.97MB | 1000 | 87.22ms | 11,465 edits/sec, 11.1 MB/sec |
149
+
150
+ (Measured on macOS/arm64, Python 3.12.4, 2026-08-01 — see the script's own
151
+ output for full per-run samples; the "large" row in particular showed
152
+ bimodal timing on this machine, a real observation, not smoothed over.)
153
+
154
+ ## Provenance
155
+
156
+ This package was designed and hardened inside
157
+ [`codetour-cli`](https://github.com/jlumbroso/codetour-cli), across four
158
+ independent review gates, before being extracted here with no known
159
+ remaining incompleteness. See [`PROVENANCE.md`](PROVENANCE.md) for the
160
+ commit-by-commit history and [`docs/adr/0001-extraction-from-codetour-cli.md`](docs/adr/0001-extraction-from-codetour-cli.md)
161
+ for the extraction decisions themselves (import strategy, the dependency
162
+ verdict, the supported-Python-versions floor).
163
+
164
+ ## Credits
165
+
166
+ The position-mapping this package edits against — turning a JSON document
167
+ into exact byte offsets for every value and key — is done by
168
+ [`json-source-map`](https://pypi.org/project/json-source-map/), a small,
169
+ dependency-free library by David Andersson. It's the one piece of this
170
+ package that isn't ours: everything in `src/json_source_edit/source_map.py`
171
+ is a thin seam around it, and everything else in this package builds on top
172
+ of what it computes. Elegant, narrowly-scoped libraries like this are what
173
+ make a package like this one feasible to write at all.
174
+
175
+ ## Status
176
+
177
+ Pre-release. 187 tests, 100% line coverage. Not yet on PyPI — CI is scaffolded
178
+ (GitHub Actions test matrix, Python 3.8–3.13) but the publish workflow is
179
+ deliberately inert pending the repo owner's two-step activation (see
180
+ `.github/workflows/publish.yml`).
181
+
182
+ ## License
183
+
184
+ MIT.
@@ -0,0 +1,155 @@
1
+ # json-source-edit
2
+
3
+ A Python package for **surgical, byte-preserving JSON editing.** Change
4
+ only what you intend to change — everything else in the file survives
5
+ byte-for-byte, so your diff shows your edit, not a full-file rewrite.
6
+
7
+ ## The problem
8
+
9
+ The obvious way to edit a JSON file is parse → modify → serialize:
10
+
11
+ ```python
12
+ data = json.loads(text)
13
+ data["steps"][1]["line"] = 433
14
+ text = json.dumps(data, indent=2)
15
+ ```
16
+
17
+ This is correct, and it destroys your diff. Real evidence, from the project
18
+ this package was extracted out of: three simple changes to one file — two
19
+ field updates and one deletion — were *expected* to produce a ~10-line diff.
20
+ The parse-modify-serialize round-trip produced a **500+ line diff** instead.
21
+
22
+ That's because `json.dumps` doesn't know or care what your original
23
+ formatting was — indentation, key order, quote style, all of it gets
24
+ re-decided from scratch, every time, whether or not you touched it.
25
+ Multiply that by a codebase-sized JSON file and code review becomes
26
+ impossible: every line looks changed, and the actual edit is buried.
27
+
28
+ `json-source-edit` fixes this at the source: it never re-serializes the
29
+ document. It computes exact byte positions for each change against the
30
+ *original text* and splices replacement bytes into the gaps — the same
31
+ operation a film or tape splicer performs, cut and rejoin at an exact point,
32
+ everything else on the reel undisturbed.
33
+
34
+ ## Install
35
+
36
+ Not yet on PyPI (see [Status](#status) below). Until then:
37
+
38
+ ```bash
39
+ git clone https://github.com/jlumbroso/json-source-edit
40
+ cd json-source-edit
41
+ pip install -e .
42
+ ```
43
+
44
+ ## Usage
45
+
46
+ ```python
47
+ from json_source_edit import JSONEditor
48
+
49
+ editor = JSONEditor.from_string("""{
50
+ "title": "Rust Basics",
51
+ "steps": [
52
+ {"file": "src/main.rs", "line": 10},
53
+ {"file": "src/lib.rs", "line": 20}
54
+ ]
55
+ }""")
56
+ editor.replace("/steps/0/line", 433)
57
+ editor.remove("/steps/1")
58
+ result = editor.apply(validate=True)
59
+ print(editor.preview_diff())
60
+ ```
61
+
62
+ ```diff
63
+ --- original
64
+ +++ modified
65
+ @@ -1,7 +1,6 @@
66
+ {
67
+ "title": "Rust Basics",
68
+ "steps": [
69
+ - {"file": "src/main.rs", "line": 10},
70
+ - {"file": "src/lib.rs", "line": 20}
71
+ + {"file": "src/main.rs", "line": 433}
72
+ ]
73
+ }
74
+ ```
75
+
76
+ `validate=True` catches mistakes before they reach you. It reconstructs the
77
+ expected result independently — replaying your edits against a copy of the
78
+ parsed document — and diffs that against what the surgical edit actually
79
+ produced. If they disagree, it raises `SemanticValidationError` instead of
80
+ silently returning something subtly wrong.
81
+
82
+ `apply()` defaults to `validate=False`; `save()` defaults to `validate=True`
83
+ — writing to disk is where a mistake actually costs you something, so that
84
+ path checks unless you opt out.
85
+
86
+ Three more methods help you inspect a pending batch before committing to
87
+ it: `get_value`, `get_modifications`, and `preview_diff`.
88
+
89
+ ## Scope
90
+
91
+ | JSON Patch operation | Status |
92
+ |---|---|
93
+ | `replace` | Supported — any path, any depth |
94
+ | `remove` | Supported — array elements and object properties, any depth, except the document root itself (structurally undefined: the root has no parent container to apply a comma/whitespace rule against) |
95
+ | `add`, `move`, `copy`, `test` | Not implemented — raise `NotImplementedError` |
96
+
97
+ Multiple `replace`/`remove` calls batch correctly against the same original
98
+ document — every path resolves in original-document coordinates regardless
99
+ of what else is in the batch, so edit order doesn't matter and one deletion
100
+ can't corrupt another edit's position.
101
+
102
+ ## Benchmark (v0)
103
+
104
+ Reproduce with `python benchmarks/throughput.py` — a standalone script, no
105
+ test framework required.
106
+
107
+ Methodology: replaces 5% of a flat array's elements at each size, median of
108
+ 5 runs, reporting throughput against the *original* document size (the more
109
+ relevant number for "can this handle my file" than edit count alone).
110
+
111
+ Single-machine numbers, not a controlled benchmark environment — expect
112
+ real variance run to run and machine to machine; rerun locally before
113
+ relying on any of this for capacity planning.
114
+
115
+ | Size | Elements | Document | Edits | Median time | Throughput |
116
+ |---|---|---|---|---|---|
117
+ | small | 200 | 0.01MB | 10 | 0.65ms | 15,353 edits/sec, 13.7 MB/sec |
118
+ | medium | 2,000 | 0.09MB | 100 | 6.25ms | 15,990 edits/sec, 14.9 MB/sec |
119
+ | large | 20,000 | 0.97MB | 1000 | 87.22ms | 11,465 edits/sec, 11.1 MB/sec |
120
+
121
+ (Measured on macOS/arm64, Python 3.12.4, 2026-08-01 — see the script's own
122
+ output for full per-run samples; the "large" row in particular showed
123
+ bimodal timing on this machine, a real observation, not smoothed over.)
124
+
125
+ ## Provenance
126
+
127
+ This package was designed and hardened inside
128
+ [`codetour-cli`](https://github.com/jlumbroso/codetour-cli), across four
129
+ independent review gates, before being extracted here with no known
130
+ remaining incompleteness. See [`PROVENANCE.md`](PROVENANCE.md) for the
131
+ commit-by-commit history and [`docs/adr/0001-extraction-from-codetour-cli.md`](docs/adr/0001-extraction-from-codetour-cli.md)
132
+ for the extraction decisions themselves (import strategy, the dependency
133
+ verdict, the supported-Python-versions floor).
134
+
135
+ ## Credits
136
+
137
+ The position-mapping this package edits against — turning a JSON document
138
+ into exact byte offsets for every value and key — is done by
139
+ [`json-source-map`](https://pypi.org/project/json-source-map/), a small,
140
+ dependency-free library by David Andersson. It's the one piece of this
141
+ package that isn't ours: everything in `src/json_source_edit/source_map.py`
142
+ is a thin seam around it, and everything else in this package builds on top
143
+ of what it computes. Elegant, narrowly-scoped libraries like this are what
144
+ make a package like this one feasible to write at all.
145
+
146
+ ## Status
147
+
148
+ Pre-release. 187 tests, 100% line coverage. Not yet on PyPI — CI is scaffolded
149
+ (GitHub Actions test matrix, Python 3.8–3.13) but the publish workflow is
150
+ deliberately inert pending the repo owner's two-step activation (see
151
+ `.github/workflows/publish.yml`).
152
+
153
+ ## License
154
+
155
+ MIT.
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "json-source-edit"
7
+ version = "0.1.0"
8
+ description = "Surgical, byte-preserving JSON editing — change only what you intend to change."
9
+ authors = [{name = "Jérémie Lumbroso"}]
10
+ readme = "README.md"
11
+ requires-python = ">=3.8"
12
+ license = {text = "MIT"}
13
+ keywords = ["json", "json-patch", "diff", "text-editing", "format-preserving"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Topic :: Software Development :: Libraries :: Python Modules",
18
+ "Topic :: Text Processing :: Markup",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.8",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ ]
26
+ dependencies = [
27
+ "json-source-map>=1.0.5",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ dev = [
32
+ "pytest",
33
+ "pytest-cov",
34
+ ]
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/jlumbroso/json-source-edit"
38
+ Repository = "https://github.com/jlumbroso/json-source-edit"
39
+ Provenance = "https://github.com/jlumbroso/json-source-edit/blob/main/PROVENANCE.md"
40
+
41
+ [tool.setuptools.packages.find]
42
+ where = ["src"]
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,16 @@
1
+ """json-source-edit: surgical, byte-preserving JSON editing.
2
+
3
+ "Only change what you intend to change, preserve everything else
4
+ byte-for-byte." See ADR-0013 in the parent codetour-cli project
5
+ (``docs/adr/0013-format-preserving-json-editing.md``) for the design.
6
+ """
7
+ from .editor import JSONEditor, Modification
8
+ from .operations import UnsupportedRemovePath
9
+ from .validator import SemanticValidationError
10
+
11
+ __all__ = [
12
+ "JSONEditor",
13
+ "Modification",
14
+ "UnsupportedRemovePath",
15
+ "SemanticValidationError",
16
+ ]
@@ -0,0 +1,119 @@
1
+ """Core edit-application algorithm.
2
+
3
+ The O(n log n) cursor traversal (ADR-0013's chosen Approach 4: sort edits by
4
+ start position, then build the result in one linear pass, alternating copied
5
+ original-text chunks with spliced-in replacement values), plus the two
6
+ batch-level passes Round 4 added for array-element deletions (§2b):
7
+ coalescing overlapping/adjacent deletion-only edits, and stripping the
8
+ trailing comma a coalesced deletion can orphan.
9
+ """
10
+ from dataclasses import dataclass
11
+ from typing import Dict, List, Optional
12
+
13
+
14
+ @dataclass
15
+ class Edit:
16
+ """A single position-based text replacement.
17
+
18
+ Positions are always in ORIGINAL-document coordinates (ADR-0013 Round 4
19
+ §1's coordinate-frame contract) — they never shift to account for other
20
+ edits in the same batch.
21
+ """
22
+
23
+ start: int
24
+ end: int
25
+ replacement: str
26
+ path: str
27
+
28
+
29
+ def coalesce_deletions(edits: List[Edit]) -> List[Edit]:
30
+ """Merge overlapping/touching deletion-only (``replacement == ""``)
31
+ edits; leave every other edit untouched.
32
+
33
+ Regression: R3-2 (ADR-0013 Round 3) — batch-deleting an array's true
34
+ last element alongside its immediate predecessor makes both claim the
35
+ same comma. Cicerone's Option (ii), affirmed in Round 4 §2b: deletions
36
+ are order-independent, so merging their ranges is semantically safe.
37
+ """
38
+ deletions = sorted((e for e in edits if e.replacement == ""), key=lambda e: e.start)
39
+ others = [e for e in edits if e.replacement != ""]
40
+ if not deletions:
41
+ return sorted(others, key=lambda e: e.start)
42
+
43
+ merged: List[Edit] = [deletions[0]]
44
+ for e in deletions[1:]:
45
+ last = merged[-1]
46
+ if e.start <= last.end:
47
+ merged[-1] = Edit(last.start, max(last.end, e.end), "", last.path)
48
+ else:
49
+ merged.append(e)
50
+ return sorted(merged + others, key=lambda e: e.start)
51
+
52
+
53
+ def strip_orphaned_trailing_commas(
54
+ text: str, edits: List[Edit], array_close_by_path: Dict[str, int]
55
+ ) -> List[Edit]:
56
+ """Splicer's addendum to §2b (ADR-0013 Round 4): coalescing alone can
57
+ still emit invalid JSON. When a (coalesced) deletion range reaches an
58
+ array's closing bracket but the array isn't being emptied entirely, the
59
+ element surviving just before the deleted range keeps a now-orphaned
60
+ trailing comma — it was never last, so it never claimed its own comma.
61
+
62
+ ``array_close_by_path`` maps each array's own JSON-pointer path (the
63
+ parent of any deleted element's path) to the position of that array's
64
+ ``]`` character itself — i.e. ``source_map[array_path].value_end - 1``,
65
+ **not** ``value_end`` directly (``value_end`` is exclusive; passing it
66
+ unadjusted silently no-ops this fix whenever the array isn't the last
67
+ thing in the document — the off-by-one Round 4 caught in itself, see
68
+ ``OPS-2.5.8`` / ``TEST-MATRIX-0013.md``).
69
+ """
70
+ fixed: List[Edit] = []
71
+ for e in edits:
72
+ if e.replacement != "":
73
+ fixed.append(e)
74
+ continue
75
+ start, end = e.start, e.end
76
+ parent = e.path.rsplit("/", 1)[0]
77
+ close_pos: Optional[int] = array_close_by_path.get(parent)
78
+ if (
79
+ close_pos is not None
80
+ and close_pos >= end
81
+ and text[end:close_pos].strip() == ""
82
+ and start > 0
83
+ and text[start - 1] == ","
84
+ ):
85
+ start -= 1
86
+ fixed.append(Edit(start, end, e.replacement, e.path))
87
+ return fixed
88
+
89
+
90
+ def apply_edits(original: str, edits: List[Edit]) -> str:
91
+ """Apply multiple edits to ``original`` in a single linear pass.
92
+
93
+ Args:
94
+ original: the original text.
95
+ edits: edits to apply (sorted internally; the input list is not
96
+ mutated).
97
+
98
+ Returns:
99
+ The modified text.
100
+
101
+ Raises:
102
+ ValueError: if any two edits overlap after sorting.
103
+ """
104
+ sorted_edits = sorted(edits, key=lambda e: e.start)
105
+
106
+ for i in range(len(sorted_edits) - 1):
107
+ if sorted_edits[i].end > sorted_edits[i + 1].start:
108
+ raise ValueError(
109
+ f"Overlapping edits: {sorted_edits[i].path} and {sorted_edits[i + 1].path}"
110
+ )
111
+
112
+ cursor = 0
113
+ result: List[str] = []
114
+ for edit in sorted_edits:
115
+ result.append(original[cursor:edit.start])
116
+ result.append(edit.replacement)
117
+ cursor = edit.end
118
+ result.append(original[cursor:])
119
+ return "".join(result)