Skip to content

feat(reference): add edit, to replace, insert or delete a block - #70

Merged
CameronBrooks11 merged 6 commits into
mainfrom
feat/edit-api
Oct 27, 2026
Merged

CameronBrooks11 merged 6 commits into
mainfrom
feat/edit-api

Conversation

@CameronBrooks11

Copy link
Copy Markdown
Member

This PR adds blockspec.edit, so a program can change one block of a document without rewriting its lines itself (closes #69). The design is in docs/editing-api.md.

  • replace_block(text, node, source, /), insert_block(text, source, /, *, before=None, after=None) and delete_block(text, node, /). Each is a pure function from text to text. With neither before= nor after=, an insert goes after the last block.
  • Naming a block. A block is named by its node path [i], the path parse reports. A caller holding a name resolves it with parse(text).resolve(name)["node"].
  • Lists, block quotes and tables. A path into a container is refused as nested-node. A whole list, quote or table can be replaced, inserted or deleted as one block, but no item or cell can. Writing inside a container means writing its prefixes, and §11.7's write-back shows how many of those lines draft 0 cannot write.
  • Read-back. The input and the result are both read with the reader, so §9 refusals come through with their own codes. The API's own codes are bad-position, no-such-block, nested-node, not-one-block, changes-another-block and rebinds-name, raised as EditRefused, a Refusal.
  • Unchanged text. A line the operation is not about keeps its bytes and its line ending. <doc>.notes/ records are never touched.

Cases. The cases are in tests/edit-cases/, not conformance/cases/, because §2 leaves editor behaviour unspecified. An author who worked from the design and SPEC alone, without the reference implementation, wrote 113 cases: 18 replace, 29 insert, 20 delete and 46 refusal, each refusal with a control. All passed on the first run with no case changed. docs/editing-api.md was clarified after the cases were written. tests/test_edit.py adds 9 tests, including an insert into an empty file, which ends its line with LF. just check exits 0, and just test passes 305.

Two checks no case reaches. I mutated each refusal and checked that the file's hash differed every time:

  • Killed: bad-position, no-such-block, nested-node, both not-one-block checks, rebinds-name, and changes-another-block with its count and frontmatter checks removed together.
  • Survived: two backstops inside changes-another-block. One is the frontmatter clause alone, the other the per-block comparison. The block-count check catches every case that reaches them, and I found no input that reaches either one alone. I kept them because they guard against an edit silently changing another block.

Inserting inside a region. A block inserted between a region's markers joins that region, which is what the markers mean. rebinds-name refuses an edit that would make a name bind to something else. For example, inserting a block directly after a one-line marker would move the marker's name onto the new block.

Signed-off-by: Cameron Brooks 95648474+CameronBrooks11@users.noreply.github.com

docs/editing-api.md sets out replace_block, insert_block and delete_block: a block named by its node path, where new and removed lines go, and the refusals, including a read-back of the result. tests/edit-cases/README.md gives the format of the cases that pin it, and both line-ending rules exempt that tree.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
113 cases for replace, insert and delete and for every refusal, each refusal with an accepting control, written from docs/editing-api.md by an author who saw no implementation. The README logs what the author was given.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
reference/blockspec/edit.py implements docs/editing-api.md and passes tests/edit-cases/ with no case changed. Each function reads its result back and refuses with a reason code when the new lines are not one block, another block reads differently, or a name would bind elsewhere. No function touches a <doc>.notes/ record. The README lists the reference's API.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
Deleting a heading between a list and an indented fence keeps the block
count a delete expects, while the list item takes in the fence line; only
the per-block comparison refuses it. With that comparison disabled, this
case is the one test that fails.

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
Deleting the last block can leave a line ending in a bare CR directly
above a kept blank line ending in LF. No bytes hold that pair as two
lines, so the blank line is lost; no block changes. The design now says
so, and a test pins delete_block("A.\r\rB.\n\n", [1]) == "A.\r\n".

Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
Signed-off-by: Cameron Brooks <95648474+CameronBrooks11@users.noreply.github.com>
@CameronBrooks11
CameronBrooks11 merged commit 3e97c4c into main Oct 27, 2026
6 checks passed
@CameronBrooks11
CameronBrooks11 deleted the feat/edit-api branch October 27, 2026 02:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

An editing API: replace, insert and delete a block

1 participant