testmark-go

Patch feature and Write feature.

commit 8ed56c1d8ad05109ba183d2cec2f3f733ed2dc84 · parent 6515cef47b · browse this tree
Eric Myhre, 2021-08-17 19:17:36 UTC

diff --git a/patch.go b/patch.go
new file mode 100644
index 0000000..19b3e32
--- /dev/null
+++ b/patch.go
@@ -0,0 +1,82 @@
+package testmark
+
+import (
+	"bytes"
+	"unicode"
+)
+
+func Patch(oldDoc *Document, hunks ...Hunk) (newDoc *Document) {
+	// First pool up the hunk names we've been asked to patch.
+	// We want to go over things in the order already present in the document,
+	// so the order our varargs came in is not relevant nor helpful.
+	//
+	// Also, validate them real quick.
+	// Empty names and names with whitespace are unacceptable.
+	newHunks := make(map[string]Hunk, len(hunks))
+	for _, hunk := range hunks {
+		if hunk.Name == "" || bytes.IndexFunc([]byte(hunk.Name), unicode.IsSpace) >= 0 {
+			panic("hunk name must not be empty and cannot contain whitespace")
+		}
+		newHunks[hunk.Name] = hunk
+	}
+
+	// Mutation is bad.
+	// Immediately start making a new document.
+	// Prep it with about the same amount of memory as the old one.
+	newDoc = &Document{
+		Lines:       make([][]byte, 0, len(oldDoc.Lines)),
+		DataHunks:   make([]DocHunk, 0, len(oldDoc.DataHunks)),
+		HunksByName: make(map[string]DocHunk, len(oldDoc.HunksByName)),
+	}
+
+	// Range over the document and apply patches.
+	// We'll build up a whole new document as we go (byte slices and all!).
+	var leftOff int
+	for _, hunk := range oldDoc.DataHunks {
+		// Copy any prose lines from wherever we left off, up to the start of the new hunk.
+		// And advance the marker for leftOff marker to past the end of the old hunk.
+		newDoc.Lines = append(newDoc.Lines, oldDoc.Lines[leftOff:hunk.LineStart]...)
+		leftOff = hunk.LineEnd + 1
+
+		var newBodyLines [][]byte
+		if newHunk, exists := newHunks[hunk.Name]; exists {
+			// Split our new hunk's body into lines, ready to append to the total content lines.
+			// The rest... copy it into 'hunk', actually, it's a local variable and it makes the code slightly more DRY.
+			newBodyLines = bytes.Split(newHunk.Body, sigilLineBreak)
+			hunk.BlockTag = newHunk.BlockTag
+
+			// Yeet from newHunks, as it's now handled.
+			delete(newHunks, hunk.Name)
+		} else {
+			// Just... keep the old lines, which we can sub-slice back out of the old document.
+			newBodyLines = oldDoc.Lines[hunk.LineStart+2 : hunk.LineEnd]
+		}
+
+		// Append the hunk framing, and the body lines.
+		// Watch how this changes the offsets, so we can build a new DocHunk with info that's correct.
+		// (If you're just going to serialize this, it wouldn't matter, but if you want to patch multiple times, it matters.)
+		newLineStart := len(newDoc.Lines)
+		newDoc.Lines = append(newDoc.Lines, bytes.Join([][]byte{sigilTestmark, []byte(hunk.Name)}, nil))
+		newDoc.Lines = append(newDoc.Lines, bytes.Join([][]byte{sigilCodeBlock, []byte(hunk.BlockTag)}, nil))
+		newDoc.Lines = append(newDoc.Lines, newBodyLines...)
+		newDoc.Lines = append(newDoc.Lines, sigilCodeBlock)
+		newLineEnd := len(newDoc.Lines)
+		docHunk := DocHunk{
+			LineStart: newLineStart,
+			LineEnd:   newLineEnd,
+			Hunk:      hunk.Hunk,
+		}
+		// Append the updated hunk info to newDoc.
+		newDoc.DataHunks = append(newDoc.DataHunks, docHunk)
+		newDoc.HunksByName[hunk.Name] = docHunk
+	}
+
+	// Copy any remaining trailing prose lines.
+	newDoc.Lines = append(newDoc.Lines, oldDoc.Lines[leftOff:]...)
+
+	// Now for any hunks we have left... We'll just stick them on the end, I guess.
+	// And *now* the dang order of our original args matters.  We wouldn't want this to be randomized.
+	// TODO
+
+	return
+}
diff --git a/patch_test.go b/patch_test.go
new file mode 100644
index 0000000..e3f1153
--- /dev/null
+++ b/patch_test.go
@@ -0,0 +1,19 @@
+package testmark
+
+import (
+	"path/filepath"
+	"testing"
+)
+
+func TestPatch(t *testing.T) {
+	testdata, err := filepath.Abs("testdata")
+	if err != nil {
+		panic(err)
+	}
+	doc, err := ReadFile(filepath.Join(testdata, "example.md"))
+	doc = Patch(doc,
+		Hunk{Name: "more-data", BlockTag: "text", Body: []byte("you have been...\nreplaced.\nand gotten\nrather longer.")},
+		Hunk{Name: "this-is-the-data-name", BlockTag: "", Body: []byte("this one gets shorter.")},
+	)
+	t.Logf("%s", doc.String())
+}
diff --git a/read.go b/read.go
index a489ec5..808fee1 100644
--- a/read.go
+++ b/read.go
@@ -38,7 +38,7 @@ func Parse(data []byte) (*Document, error) {
 	}
 
 	// Markdown can be effectively parsed line by line.
-	doc.OriginalLines = bytes.Split(data, sigilLineBreak)
+	doc.Lines = bytes.Split(data, sigilLineBreak)
 
 	// The first layer of our parse is "is this in a code block".
 	// Code blocks are the only feature of markdown that meaningfully changes what mode you're in at the start of a line.
@@ -48,8 +48,8 @@ func Parse(data []byte) (*Document, error) {
 	var inCodeBlock bool
 	var expectCodeBlock bool
 	var codeBlockOffset int
-	hunkInProgress := DocHunk{Line: -1}
-	for i, line := range doc.OriginalLines {
+	hunkInProgress := DocHunk{LineStart: -1}
+	for i, line := range doc.Lines {
 		// Check for transition in or out of codeblock.
 		if bytes.HasPrefix(line, sigilCodeBlock) {
 			switch inCodeBlock {
@@ -60,12 +60,12 @@ func Parse(data []byte) (*Document, error) {
 				}
 				expectCodeBlock = false
 			case true: // ending a block
-				if hunkInProgress.Line > -1 {
-					hunkInProgress.EndLine = i
+				if hunkInProgress.LineStart > -1 {
+					hunkInProgress.LineEnd = i
 					hunkInProgress.Body = doc.Original[codeBlockOffset:offset]
 					doc.DataHunks = append(doc.DataHunks, hunkInProgress)
 					doc.HunksByName[hunkInProgress.Name] = hunkInProgress
-					hunkInProgress = DocHunk{Line: -1}
+					hunkInProgress = DocHunk{LineStart: -1}
 				}
 			}
 			inCodeBlock = !inCodeBlock
@@ -93,10 +93,10 @@ func Parse(data []byte) (*Document, error) {
 				// ... Log a complaint?  Empty block names are very silly.
 			}
 			if already, exists := doc.HunksByName[name]; exists {
-				return &doc, fmt.Errorf("repeated testmark hunk name %q, first seen on line %d, and again on line %d", name, already.Line+1, i+1)
+				return &doc, fmt.Errorf("repeated testmark hunk name %q, first seen on line %d, and again on line %d", name, already.LineStart+1, i+1)
 			}
 			expectCodeBlock = true
-			hunkInProgress.Line = i
+			hunkInProgress.LineStart = i
 			hunkInProgress.Name = name
 		}
 		// Any other text?  It's prose.  No action.
diff --git a/read_test.go b/read_test.go
index 47e9138..d06481f 100644
--- a/read_test.go
+++ b/read_test.go
@@ -13,6 +13,6 @@ func TestRead(t *testing.T) {
 	}
 	doc, err := ReadFile(filepath.Join(testdata, "example.md"))
 	for _, hunk := range doc.DataHunks {
-		fmt.Printf("hunk %q is on lines %d:%d, has body %q\n", hunk.Name, hunk.Line+1, hunk.EndLine+1, string(hunk.Body))
+		fmt.Printf("hunk %q is on lines %d:%d, has body %q\n", hunk.Name, hunk.LineStart+1, hunk.LineEnd+1, string(hunk.Body))
 	}
 }
diff --git a/testdata/example.md b/testdata/example.md
index f2742b0..b81d5b3 100644
--- a/testdata/example.md
+++ b/testdata/example.md
@@ -71,3 +71,5 @@ you can't easily describe data that doesn't have a trailing linebreak.
 ```
 A markdown codeblock always has a trailing linebreak before its close indicator, you see.
 ```
+
+That's a problem in many formats though, frankly.
diff --git a/testmark.go b/testmark.go
index 11f9d43..d6ad9eb 100644
--- a/testmark.go
+++ b/testmark.go
@@ -4,14 +4,16 @@ type Document struct {
 	// The whole thing, complete, but split into lines.
 	// We always save this, because if we are going to write this document back out,
 	// it's going to be by patching this.  (We don't try to understand, much less normalize, a full markdown AST!)
+	// May be nil if this document is the result of Patch operations rather than Parse.
 	Original []byte
 
-	// Original, sliced into lines.  Shares backing memory with Original.
+	// The document, sliced into lines.  Shares backing memory with Original, if Original is non-nil.
 	// Useful because we made it during parse anyway, and it can save us a lot of work during edits.
-	OriginalLines [][]byte
+	Lines [][]byte
 
 	// Each data hunk.
 	// Contains just offset information, and the parsed name header.
+	// Is in order of hunk appearance.
 	DataHunks []DocHunk
 
 	// Like it says on the tin.
@@ -24,10 +26,10 @@ type DocHunk struct {
 	// The code block indicator is necessarily is the following line,
 	// and the code block body one line after that.
 	// N.B. zero-indexed.  You probably want to +1 before printing to a human.
-	Line int
+	LineStart int
 
 	// Index into Document.OriginalLines that contains the closing code block indicator.
-	EndLine int
+	LineEnd int
 
 	Hunk
 }
diff --git a/write.go b/write.go
new file mode 100644
index 0000000..0a6856e
--- /dev/null
+++ b/write.go
@@ -0,0 +1,29 @@
+package testmark
+
+import (
+	"io"
+	"strings"
+)
+
+func (d Document) String() string {
+	var sb strings.Builder
+	Write(&d, &sb)
+	return sb.String()
+}
+
+func Write(doc *Document, wr io.Writer) (int, error) {
+	n := 0
+	for _, line := range doc.Lines {
+		if n2, err := wr.Write(line); err != nil {
+			return n + n2, err
+		} else {
+			n += n2
+		}
+		if n2, err := wr.Write(sigilLineBreak); err != nil {
+			return n + n2, err
+		} else {
+			n += n2
+		}
+	}
+	return n, nil
+}