diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9aa71ba..c0a524b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,15 @@ jobs: name: litdoc authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' + - uses: actions/cache@v4 + with: + path: .go-cache + key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go-build- + + - run: nix develop --ignore-environment --command make vendor + - run: nix develop --ignore-environment --command make fmt-check - run: nix develop --ignore-environment --command make vet - run: nix develop --ignore-environment --command make test \ No newline at end of file diff --git a/.gitignore b/.gitignore index 36fd9d0..2e14748 100644 --- a/.gitignore +++ b/.gitignore @@ -18,8 +18,8 @@ coverage.* *.coverprofile profile.cov -# Dependency directories (remove the comment below to include it) -# vendor/ +# Dependency directories +vendor/ # Go workspace file go.work @@ -28,6 +28,9 @@ go.work.sum # env file .env +# Go build cache +.go-cache/ + # Editor/IDE .idea/ # .vscode/ diff --git a/Makefile b/Makefile index 4710f80..ecbc794 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,15 @@ +GO ?= go + .PHONY: pre-pr pre-pr: clean mock fmt-check vet test .PHONY: fmt fmt: - @gofmt -w . + @gofmt -w $(GO_FILES) .PHONY: fmt-check fmt-check: - @unformatted=$$(gofmt -l .); \ + @unformatted=$$(gofmt -l $(GO_FILES)); \ if [ -n "$$unformatted" ]; then \ echo "unformatted files:"; \ echo "$$unformatted"; \ @@ -16,7 +18,7 @@ fmt-check: .PHONY: vet vet: - @go vet ./... + @$(GO) vet ./... .PHONY: mock mock: @@ -27,17 +29,24 @@ mock-clean: @find . \( -name '*_mock_test.go' -o -name '*_mock.go' \) -not -path './vendor/*' -delete GO_FILES := $(shell find . -name '*.go' -not -path './vendor/*') -GOCACHE ?= /tmp/litdoc-go-build +GOCACHE ?= $(CURDIR)/.go-cache + +vendor: go.mod go.sum + @$(GO) mod vendor -bin/litdoc: $(GO_FILES) - @GOCACHE=$(GOCACHE) go build -o bin/litdoc . +bin/litdoc: vendor $(GO_FILES) + @GOCACHE=$(GOCACHE) $(GO) build -mod=vendor -o bin/litdoc . .PHONY: build build: bin/litdoc .PHONY: test -test: build - @GOCACHE=$(GOCACHE) go test ./... --count=1 +test: vendor + @GOCACHE=$(GOCACHE) $(GO) test -mod=vendor ./... --count=1 + +.PHONY: vendor-clean +vendor-clean: + @rm -rf vendor/ .PHONY: clean clean: mock-clean diff --git a/flake.nix b/flake.nix index ee76d83..ca92971 100644 --- a/flake.nix +++ b/flake.nix @@ -14,12 +14,13 @@ devShells.default = pkgs.mkShell { packages = with pkgs; [ go + go-mockery cacert ]; shellHook = '' export HOME=$(mktemp -d) export GOPATH=$HOME/go - export GOCACHE=$HOME/.cache/go-build + export GOCACHE=$PWD/.go-cache export SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt export NIX_SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt ''; diff --git a/internal/block.go b/internal/block.go index 8d64c5d..d6ef63f 100644 --- a/internal/block.go +++ b/internal/block.go @@ -112,6 +112,11 @@ func (c *blockCollector) collectBlockQuote( indent []byte, stripPrefix []byte, ) { + c.appendTextGap(node.StartByte(), stripPrefix, indent) + if c.pos < node.StartByte() { + c.pos = node.StartByte() + } + childIndent := concatenate(indent, []byte("> ")) childStripPrefix := concatenate(stripPrefix, []byte("> ")) for i := 0; i < int(node.ChildCount()); i++ { @@ -143,7 +148,11 @@ func (c *blockCollector) collectLeaf(node *sitter.Node, indent, stripPrefix []by end := node.EndByte() blockIndent, blockStripPrefix := blockPrefixes(node, c.content, indent, stripPrefix) - c.appendTextGap(start, stripPrefix, indent) + gapStripPrefix := stripPrefix + if isOutputMarkerRaw(c.content[start:end]) { + gapStripPrefix = blockStripPrefix + } + c.appendTextGap(start, gapStripPrefix, blockIndent) raw := stripIndent(c.content[start:end], blockStripPrefix) kind := blockKind(node, c.content) @@ -171,6 +180,9 @@ func (c *blockCollector) appendTextGap(end uint32, stripPrefix, indent []byte) { return } gap := stripIndent(c.content[c.pos:end], stripPrefix) + if len(bytes.TrimSpace(gap)) == 0 { + indent = []byte(renderIndent(string(indent))) + } c.appendBlock(BlockKindText, gap, indent) } @@ -242,6 +254,14 @@ func blockPrefixes( if len(linePrefix) > 0 && !bytes.Equal(linePrefix, indent) { blockIndent = linePrefix blockStripPrefix = linePrefix + if normalizedIndent, ok := normalizeHTMLCommentPrefix( + content[start:end], + linePrefix, + indent, + ); ok { + blockIndent = normalizedIndent + return blockIndent, blockStripPrefix + } if isSpaceIndent(linePrefix) { rawLeading := leadingLineSpaces(content[start:end]) if len(rawLeading) > 0 { @@ -266,6 +286,30 @@ func blockPrefixes( return blockIndent, blockStripPrefix } +// normalizeHTMLCommentPrefix handles a tree-sitter quirk: output markers +// inside list items are reported with a space-only linePrefix (the list +// continuation indent) rather than the rendered block indent. Detect this case +// and return the rendered indent so the marker is classified correctly. +func normalizeHTMLCommentPrefix(raw, linePrefix, containerIndent []byte) ([]byte, bool) { + renderedIndent := []byte(renderIndent(string(containerIndent))) + if !bytes.HasPrefix(linePrefix, renderedIndent) { + return nil, false + } + if len(bytes.Trim(linePrefix[len(renderedIndent):], " ")) > 0 { + return nil, false + } + if !isOutputMarkerRaw(raw) { + return nil, false + } + return renderedIndent, true +} + +func isOutputMarkerRaw(raw []byte) bool { + raw = bytes.TrimLeft(raw, " ") + return bytes.HasPrefix(raw, []byte(OutputBeginMarker)) || + bytes.HasPrefix(raw, []byte(OutputEndMarker)) +} + func isClosedFencedCodeBlock(content []byte) bool { openLineEnd := bytes.IndexByte(content, '\n') if openLineEnd < 0 { @@ -474,7 +518,9 @@ func splitInlineHTMLComments(blocks []Block) []Block { } pos := 0 for _, loc := range locs { - if loc[0] > pos { + wholeBlock := isWholeTextBlock(content, loc) + outputMarker := isOutputMarkerContent(content[loc[0]:loc[1]]) + if loc[0] > pos && !isWholeBlockPrefix(content[pos:loc[0]], wholeBlock, outputMarker) { result = append( result, MakeBlockFromRaw( @@ -485,15 +531,18 @@ func splitInlineHTMLComments(blocks []Block) []Block { ), ) } - wholeBlock := isWholeTextBlock(content, loc) commentContinuation := b.continuation if !wholeBlock { commentContinuation = b.continuation || loc[0] > 0 } + commentIndent := b.indent + if wholeBlock && outputMarker && b.indent == "" { + commentIndent = content[pos:loc[0]] + } result = append(result, MakeBlockFromRaw( BlockKindHTMLComment, content[loc[0]:loc[1]], - b.indent, + commentIndent, commentContinuation, )) pos = loc[1] @@ -508,6 +557,15 @@ func splitInlineHTMLComments(blocks []Block) []Block { return result } +func isWholeBlockPrefix(content string, wholeBlock, outputMarker bool) bool { + return wholeBlock && outputMarker && strings.TrimSpace(content) == "" +} + +func isOutputMarkerContent(content string) bool { + return strings.HasPrefix(content, OutputBeginMarker) || + strings.HasPrefix(content, OutputEndMarker) +} + func isWholeTextBlock(content string, loc []int) bool { return len(strings.TrimSpace(content[:loc[0]])) == 0 && len(strings.TrimSpace(content[loc[1]:])) == 0 diff --git a/internal/block_test.go b/internal/block_test.go index 20cf9a8..274188d 100644 --- a/internal/block_test.go +++ b/internal/block_test.go @@ -417,6 +417,15 @@ func TestMakeBlocksFromMarkdown(t *testing.T) { cmnt("", "", true), }, }, + { + name: "HTMLComment/top-level/leading-spaces", + input: " \n", + want: []internal.Block{ + text("", " ", false), + cmnt("", "", false), + text("", "\n", true), + }, + }, { name: "HTMLComment/top-level/block", input: joinLines( @@ -524,6 +533,20 @@ func TestMakeBlocksFromMarkdown(t *testing.T) { text("- ", "text", false), }, }, + { + name: "HTMLComment/list/dash/blank-before-indented-block", + input: joinLines( + "- item", + "", + " ", + ) + "\n", + want: []internal.Block{ + text("- ", "item\n", false), + text(" ", "\n", false), + cmnt(" ", "", false), + text(" ", "\n", true), + }, + }, { name: "HTMLComment/list/dash/nested/block", input: joinLines( diff --git a/internal/cell.go b/internal/cell.go index 00da053..5d618a4 100644 --- a/internal/cell.go +++ b/internal/cell.go @@ -28,22 +28,28 @@ func (t StaticCell) Render() (string, error) { type BashCell struct { fencedCode string - output string + indent string + output Output } -func MakeBashCellFromRaw(fencedCode, output string) BashCell { - return BashCell{fencedCode: fencedCode, output: output} +func MakeBashCellFromRaw(fencedCode string, output Output) BashCell { + return BashCell{ + fencedCode: fencedCode, + indent: leadingIndent(fencedCode), + output: output, + } } func (c BashCell) Execute() (Cell, error) { - return c, nil + return BashCell{ + fencedCode: c.fencedCode, + indent: c.indent, + output: MakeOutput("output"), + }, nil } func (c BashCell) Render() (string, error) { - if c.output == "" { - return c.fencedCode, nil - } - return c.fencedCode + "\n" + c.output, nil + return c.fencedCode + c.output.Render(renderIndent(c.indent)), nil } type InfoString struct { @@ -71,17 +77,27 @@ func ParseInfoString(b Block) InfoString { return InfoString{Lang: lang, Litdoc: litdoc} } -// Classify todo: review this function func Classify(blocks []Block) ([]Cell, error) { var cells []Cell - for _, b := range blocks { + i := 0 + for i < len(blocks) { + b := blocks[i] switch b.kind { case BlockKindFencedCode, BlockKindHTMLComment: info := ParseInfoString(b) switch { case info.Litdoc && info.Lang == "bash": - cell := MakeBashCellFromRaw(renderStaticBlock(b), "") - cells = append(cells, cell) + output, consumed, err := OutputFromBlocks(b, blocks[i+1:]) + if err != nil { + return nil, fmt.Errorf("parsing output: %w", err) + } + cells = append(cells, BashCell{ + fencedCode: renderStaticBlock(b), + indent: b.indent, + output: output, + }) + i += 1 + consumed + continue case info.Litdoc: return nil, fmt.Errorf("unsupported language: %q", info.Lang) default: @@ -90,6 +106,7 @@ func Classify(blocks []Block) ([]Cell, error) { default: cells = append(cells, MakeStaticCellFromRaw(renderStaticBlock(b))) } + i++ } return cells, nil } @@ -102,19 +119,22 @@ func renderStaticBlock(b Block) string { lines := strings.Split(b.content, "\n") var rendered strings.Builder renderedIndent := renderIndent(b.indent) + blankLineIndent := blankBlockQuoteLinePrefix(b.indent) for i, line := range lines { if i == len(lines)-1 && len(line) == 0 { break } if i > 0 { rendered.WriteByte('\n') - if len(line) > 0 { - rendered.WriteString(renderedIndent) - } - } else { - if len(line) > 0 && !b.continuation { - rendered.WriteString(b.indent) - } + } + if len(line) == 0 { + rendered.WriteString(blankLineIndent) + continue + } + if i > 0 { + rendered.WriteString(renderedIndent) + } else if !b.continuation { + rendered.WriteString(b.indent) } rendered.WriteString(line) } @@ -124,6 +144,13 @@ func renderStaticBlock(b Block) string { return rendered.String() } +func blankBlockQuoteLinePrefix(indent string) string { + if idx := strings.LastIndex(indent, ">"); idx >= 0 { + return indent[:idx+1] + } + return "" +} + func renderIndent(indent string) string { if idx := strings.LastIndex(indent, "> "); idx >= 0 { prefixLen := idx + len("> ") @@ -132,6 +159,18 @@ func renderIndent(indent string) string { return strings.Repeat(" ", len(indent)) } +func leadingIndent(s string) string { + line := s + if i := strings.IndexByte(s, '\n'); i >= 0 { + line = s[:i] + } + i := 0 + for i < len(line) && (line[i] == ' ' || line[i] == '\t') { + i++ + } + return line[:i] +} + func Execute(cells []Cell) ([]Cell, error) { var executedCells []Cell for _, c := range cells { diff --git a/internal/cell_test.go b/internal/cell_test.go index 6f56870..06fd766 100644 --- a/internal/cell_test.go +++ b/internal/cell_test.go @@ -192,7 +192,7 @@ func TestBashCell(t *testing.T) { "```", "", ) - cell := internal.MakeBashCellFromRaw(code, "") + cell := internal.MakeBashCellFromRaw(code, internal.Output{}) // when gotContent, err := cell.Render() @@ -208,8 +208,9 @@ func TestBashCell(t *testing.T) { "```bash", "echo hello", "```", + "", ) - output := "hello" + output := internal.MakeOutput("hello") cell := internal.MakeBashCellFromRaw(fencedCode, output) // when @@ -217,10 +218,10 @@ func TestBashCell(t *testing.T) { // then require.NoError(t, err) - assert.Equal(t, fencedCode+"\n"+output, gotContent) + assert.Equal(t, fencedCode+output.Render(""), gotContent) }) - t.Run("executes to itself", func(t *testing.T) { + t.Run("execute produces stub output", func(t *testing.T) { // given fencedCode := joinLines( "```bash", @@ -228,7 +229,7 @@ func TestBashCell(t *testing.T) { "```", "", ) - cell := internal.MakeBashCellFromRaw(fencedCode, "") + cell := internal.MakeBashCellFromRaw(fencedCode, internal.Output{}) // when gotCell, err := cell.Execute() @@ -237,7 +238,7 @@ func TestBashCell(t *testing.T) { require.NoError(t, err) rendered, err := gotCell.Render() require.NoError(t, err) - assert.Equal(t, fencedCode, rendered) + assert.Equal(t, fencedCode+internal.MakeOutput("output").Render(""), rendered) }) } @@ -446,6 +447,34 @@ func TestClassify(t *testing.T) { }, }, }, + { + name: "FencedCode/litdoc/bash/blockquote-with-output", + blocks: []internal.Block{ + code("> ", joinLines( + "```bash | litdoc", + "echo hello", + "```", + "", + ), false), + text("> ", "\n", false), + cmnt("> ", internal.OutputBeginMarker, false), + text("> ", "hello\n", false), + cmnt("> ", internal.OutputEndMarker, false), + }, + want: []wantCell{ + { + kind: "BashCell", rendered: joinLines( + "> ```bash | litdoc", + "> echo hello", + "> ```", + ">", + "> "+internal.OutputBeginMarker, + "> hello", + "> "+internal.OutputEndMarker+"\n", + ), + }, + }, + }, { name: "FencedCode/litdoc/unsupported-language", blocks: []internal.Block{ diff --git a/internal/file_test.go b/internal/file_test.go index 5518685..8e63423 100644 --- a/internal/file_test.go +++ b/internal/file_test.go @@ -6,6 +6,9 @@ import ( "testing" "litdoc/internal" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) //go:embed testdata/input.md @@ -15,21 +18,53 @@ var renderInput []byte var renderOutput []byte func TestProcessFile(t *testing.T) { - f, err := os.CreateTemp(t.TempDir(), "*.md") - if err != nil { - t.Fatal(err) + tests := []struct { + name string + input []byte + want []byte + }{ + {"input to output", renderInput, renderOutput}, + {"output to output", renderOutput, renderOutput}, } - if _, err := f.Write(renderInput); err != nil { - t.Fatal(err) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // given + f, err := os.CreateTemp(t.TempDir(), "*.md") + require.NoError(t, err) + _, err = f.Write(tt.input) + require.NoError(t, err) + err = f.Close() + require.NoError(t, err) + + // when + got, err := internal.ProcessFile(f.Name()) + + // then + require.NoError(t, err) + assert.Equal(t, string(tt.want), got) + }) } - f.Close() +} + +func TestProcessFileNestedListIndent(t *testing.T) { + // given + input := "- Level 1\n\n - Level 2\n\n ```bash | litdoc\n echo hello\n ```\n" + f, err := os.CreateTemp(t.TempDir(), "*.md") + require.NoError(t, err) + _, err = f.Write([]byte(input)) + require.NoError(t, err) + err = f.Close() + require.NoError(t, err) + // when got, err := internal.ProcessFile(f.Name()) - if err != nil { - t.Fatalf("ProcessFile: %v", err) - } - if got != string(renderOutput) { - t.Errorf("output mismatch\ngot:\n%s\nwant:\n%s", got, renderOutput) - } + // then + require.NoError(t, err) + want := "- Level 1\n\n - Level 2\n\n ```bash | litdoc\n echo hello\n ```\n" + + "\n " + internal.OutputBeginMarker + "\n" + + " output\n" + + " " + internal.OutputEndMarker + "\n" + assert.Equal(t, want, got) } diff --git a/internal/output.go b/internal/output.go new file mode 100644 index 0000000..3323f6d --- /dev/null +++ b/internal/output.go @@ -0,0 +1,237 @@ +package internal + +import ( + "fmt" + "strings" +) + +const ( + OutputBeginMarker = "" + OutputEndMarker = "" +) + +type Output struct { + content string +} + +func MakeOutput(content string) Output { + return Output{content: content} +} + +func (o Output) Render(linePrefix string) string { + if o.content == "" { + return "" + } + + var buf strings.Builder + buf.WriteString(blankBlockQuoteLinePrefix(linePrefix)) + buf.WriteByte('\n') + buf.WriteString(linePrefix + OutputBeginMarker + "\n") + for _, line := range strings.Split(strings.TrimSuffix(o.content, "\n"), "\n") { + buf.WriteString(linePrefix + line + "\n") + } + buf.WriteString(linePrefix + OutputEndMarker + "\n") + + return buf.String() +} + +func isOutputBegin(b Block) bool { + return b.kind == BlockKindHTMLComment && + strings.HasPrefix(b.content, OutputBeginMarker) +} + +func isOutputEnd(b Block) bool { + return b.kind == BlockKindHTMLComment && + strings.HasPrefix(b.content, OutputEndMarker) +} + +func OutputFromBlocks(litdoc Block, blocks []Block) (Output, int, error) { + // Output belongs to the preceding litdoc block, so its lines must use the + // indentation that rendering that block would use for following content. + outputIndent := renderIndent(litdoc.indent) + i := 0 + + var err error + i, err = skipWhitespaceLines(blocks, i, litdoc.indent, outputIndent) + if err != nil { + return Output{}, 0, err + } + + beginLinePrefix := false + if isWhitespaceBeforeOutputBegin(blocks, i) { + if err := validateInlineMarkerPrefix(blocks[i], outputIndent, "begin"); err != nil { + return Output{}, 0, err + } + beginLinePrefix = true + i++ + } + + if i >= len(blocks) || !isOutputBegin(blocks[i]) { + return Output{}, 0, nil + } + if err := validateOutputMarkerIndent(blocks[i], outputIndent, beginLinePrefix, "begin"); err != nil { + return Output{}, 0, err + } + i++ + // Inline HTML comments can leave a whitespace-only continuation block for + // the rest of the marker line. It is parser residue, not output content. + i = skipOutputMarkerLineRemainder(blocks, i) + + var buf strings.Builder + for i < len(blocks) { + // A space before an inline-split end marker is emitted as a separate + // text block, not as marker indentation. Validate and consume it before + // matching END. + endLinePrefix := false + if isWhitespaceBeforeOutputEnd(blocks, i) { + if err := validateInlineMarkerPrefix(blocks[i], outputIndent, "end"); err != nil { + return Output{}, 0, err + } + endLinePrefix = true + i++ + } + if isOutputEnd(blocks[i]) { + if err := validateOutputMarkerIndent(blocks[i], outputIndent, endLinePrefix, "end"); err != nil { + return Output{}, 0, err + } + i++ + // Consume the newline or trailing whitespace after an inline end + // marker so callers resume at the next meaningful block. + i = skipOutputMarkerLineRemainder(blocks, i) + return MakeOutput(buf.String()), i, nil + } + content, err := outputContent(blocks[i], outputIndent) + if err != nil { + return Output{}, 0, err + } + buf.WriteString(content) + i++ + } + + return Output{}, 0, fmt.Errorf("unclosed output block: missing %q", OutputEndMarker) +} + +func isWhitespaceText(b Block) bool { + return b.kind == BlockKindText && strings.TrimSpace(b.content) == "" +} + +// skipWhitespaceLines skips blank-line text blocks between a litdoc cell and +// its output block. Only blocks that contain a newline are skipped here; +// single-line whitespace-only blocks are inline marker prefixes emitted by +// the HTML comment splitter and are handled separately. +func skipWhitespaceLines(blocks []Block, i int, litdocIndent, outputIndent string) (int, error) { + for i < len(blocks) && + isWhitespaceText(blocks[i]) && + strings.Contains(blocks[i].content, "\n") { + if err := validateOutputBlankLineIndent(blocks[i], litdocIndent, outputIndent); err != nil { + return 0, err + } + i++ + } + return i, nil +} + +func validateOutputBlankLineIndent(b Block, litdocIndent, outputIndent string) error { + if litdocIndent == outputIndent || b.indent != litdocIndent { + return nil + } + return fmt.Errorf( + "output blank line indentation: got %q for content %q, want %q", + b.indent, + b.content, + outputIndent, + ) +} + +func isWhitespaceBeforeOutputBegin(blocks []Block, i int) bool { + return i+1 < len(blocks) && + blocks[i].kind == BlockKindText && + !strings.Contains(blocks[i].content, "\n") && + strings.TrimSpace(blocks[i].content) == "" && + isOutputBegin(blocks[i+1]) +} + +func validateInlineMarkerPrefix(b Block, indent, marker string) error { + if b.content == indent { + return nil + } + return fmt.Errorf( + "output %s marker indentation: got %q for content %q, want %q", + marker, + b.indent, + b.content, + indent, + ) +} + +func validateOutputMarkerIndent(b Block, indent string, inlinePrefix bool, marker string) error { + want := indent + if inlinePrefix { + want = "" + } + if b.indent == want { + return nil + } + return fmt.Errorf( + "output %s marker indentation: got %q for content %q, want %q", + marker, + b.indent, + b.content, + indent, + ) +} + +func outputContent(b Block, indent string) (string, error) { + if b.indent == indent { + return b.content, nil + } + if b.indent == "" && isSpaceIndent([]byte(indent)) { + if content, ok := stripLinePrefix(b.content, indent); ok { + return content, nil + } + } + return "", fmt.Errorf( + "output content indentation: got %q for content %q, want %q", + b.indent, + b.content, + indent, + ) +} + +func stripLinePrefix(content, prefix string) (string, bool) { + if prefix == "" { + return content, true + } + + var buf strings.Builder + for len(content) > 0 { + line := content + if i := strings.IndexByte(content, '\n'); i >= 0 { + line = content[:i+1] + } + if !strings.HasPrefix(line, prefix) { + return "", false + } + buf.WriteString(strings.TrimPrefix(line, prefix)) + content = content[len(line):] + } + return buf.String(), true +} + +func skipOutputMarkerLineRemainder(blocks []Block, i int) int { + if i < len(blocks) && + blocks[i].kind == BlockKindText && + blocks[i].continuation && + strings.TrimSpace(blocks[i].content) == "" { + return i + 1 + } + return i +} + +func isWhitespaceBeforeOutputEnd(blocks []Block, i int) bool { + return i+1 < len(blocks) && + blocks[i].kind == BlockKindText && + !strings.Contains(blocks[i].content, "\n") && + strings.TrimSpace(blocks[i].content) == "" && + isOutputEnd(blocks[i+1]) +} diff --git a/internal/output_test.go b/internal/output_test.go new file mode 100644 index 0000000..d93e2f7 --- /dev/null +++ b/internal/output_test.go @@ -0,0 +1,370 @@ +package internal_test + +import ( + "testing" + + "litdoc/internal" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMakeOutput(t *testing.T) { + // given + content := "hello" + + // when + got := internal.MakeOutput(content) + + // then + assert.Contains(t, got.Render(""), content) +} + +func TestOutput_RenderWithIndent(t *testing.T) { + // given + content := "hello" + indent := " " + + // when + got := internal.MakeOutput(content) + + // then + assert.Contains(t, got.Render(indent), indent+content) +} + +func TestOutput_Render(t *testing.T) { + tests := []struct { + name string + content string + indent string + want string + }{ + { + name: "empty", + content: "", + indent: "", + want: "", + }, + { + name: "wrap content in markers", + content: "hello\n", + indent: "", + want: joinLines( + "", + internal.OutputBeginMarker, + "hello", + internal.OutputEndMarker+"\n", + ), + }, + { + name: "ensure content rendered with trailing newline", + content: "hello", + indent: "", + want: joinLines( + "", + internal.OutputBeginMarker, + "hello", + internal.OutputEndMarker+"\n", + ), + }, + { + name: "multiline content", + content: "hello\nworld", + indent: "", + want: joinLines( + "", + internal.OutputBeginMarker, + "hello", + "world", + internal.OutputEndMarker+"\n", + ), + }, + { + name: "indent content", + content: "hello\n", + indent: " ", + want: joinLines( + "", + " "+internal.OutputBeginMarker, + " hello", + " "+internal.OutputEndMarker+"\n", + ), + }, + { + name: "blockquote content", + content: "hello\n", + indent: "> ", + want: joinLines( + ">", + "> "+internal.OutputBeginMarker, + "> hello", + "> "+internal.OutputEndMarker+"\n", + ), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // given + output := internal.MakeOutput(tt.content) + + // when + got := output.Render(tt.indent) + + // then + assert.Equal(t, tt.want, got) + }) + } +} + +func TestOutputFromBlocks(t *testing.T) { + wantOutput := func(indent, content string) string { + return internal.MakeOutput(content).Render(indent) + } + + t.Run("output block is scanned in", func(t *testing.T) { + // given + litdoc := code("", "```bash | litdoc\n```\n", false) + blocks := []internal.Block{ + cmnt("", internal.OutputBeginMarker, false), + text("", "hello\n", false), + cmnt("", internal.OutputEndMarker, false), + } + + // when + output, consumed, err := internal.OutputFromBlocks(litdoc, blocks) + + // then + require.NoError(t, err) + assert.Equal(t, wantOutput("", "hello"), output.Render("")) + assert.Equal(t, 3, consumed) + }) + + t.Run("leading whitespace blocks are skipped", func(t *testing.T) { + // given + litdoc := code("", "```bash | litdoc\n```\n", false) + blocks := []internal.Block{ + text("", "\n", false), + cmnt("", internal.OutputBeginMarker, false), + text("", "hello\n", false), + cmnt("", internal.OutputEndMarker, false), + } + + // when + output, consumed, err := internal.OutputFromBlocks(litdoc, blocks) + + // then + require.NoError(t, err) + assert.Equal(t, wantOutput("", "hello"), output.Render("")) + assert.Equal(t, 4, consumed) + }) + + t.Run("indented output block is scanned in", func(t *testing.T) { + // given + litdoc := code(" ", "```bash | litdoc\n```\n", false) + blocks := []internal.Block{ + cmnt(" ", internal.OutputBeginMarker, false), + text(" ", "hello\nworld\n", false), + cmnt(" ", internal.OutputEndMarker, false), + } + + // when + output, consumed, err := internal.OutputFromBlocks(litdoc, blocks) + + // then + require.NoError(t, err) + assert.Equal(t, wantOutput(" ", "hello\nworld"), output.Render(" ")) + assert.Equal(t, 3, consumed) + }) + + t.Run("inline marker line remainders are consumed", func(t *testing.T) { + // given + litdoc := code(" ", "```bash | litdoc\n```\n", false) + blocks := []internal.Block{ + text("", "\n", false), + text("", " ", false), + cmnt("", internal.OutputBeginMarker, false), + text("", "\n", true), + text("", " output\n", false), + text("", " ", false), + cmnt("", internal.OutputEndMarker, false), + text("", "\n", true), + text("", "\n", false), + } + + // when + output, consumed, err := internal.OutputFromBlocks(litdoc, blocks) + + // then + require.NoError(t, err) + assert.Equal(t, wantOutput(" ", "output"), output.Render(" ")) + assert.Equal(t, 8, consumed) + }) + + t.Run("parser-space-prefixed output is normalized", func(t *testing.T) { + // given + litdoc := code(" ", "```bash | litdoc\n```\n", false) + blocks := []internal.Block{ + text("", "\n", false), + text("", " ", false), + cmnt("", internal.OutputBeginMarker, false), + text("", "\n", true), + text("", " hello\n world\n", false), + text("", " ", false), + cmnt("", internal.OutputEndMarker, false), + text("", "\n", true), + } + + // when + output, consumed, err := internal.OutputFromBlocks(litdoc, blocks) + + // then + require.NoError(t, err) + assert.Equal(t, wantOutput(" ", "hello\nworld"), output.Render(" ")) + assert.Equal(t, 8, consumed) + }) + + t.Run("list item output uses rendered indentation", func(t *testing.T) { + // given + litdoc := code("- ", "```bash | litdoc\n```\n", false) + blocks := []internal.Block{ + text(" ", "\n", false), + cmnt(" ", internal.OutputBeginMarker, false), + text(" ", "hello\n", false), + cmnt(" ", internal.OutputEndMarker, false), + } + + // when + output, consumed, err := internal.OutputFromBlocks(litdoc, blocks) + + // then + require.NoError(t, err) + assert.Equal(t, wantOutput(" ", "hello"), output.Render(" ")) + assert.Equal(t, 4, consumed) + }) + + t.Run("list item output blank line must use rendered indentation", func(t *testing.T) { + // given + litdoc := code("- ", "```bash | litdoc\n```\n", false) + blocks := []internal.Block{ + text("- ", "\n", false), + cmnt(" ", internal.OutputBeginMarker, false), + text(" ", "hello\n", false), + cmnt(" ", internal.OutputEndMarker, false), + } + + // when + _, _, err := internal.OutputFromBlocks(litdoc, blocks) + + // then + require.ErrorContains(t, err, "output blank line indentation") + }) + + t.Run("indented output content must match marker indent", func(t *testing.T) { + // given + litdoc := code(" ", "```bash | litdoc\n```\n", false) + blocks := []internal.Block{ + cmnt(" ", internal.OutputBeginMarker, false), + text(" ", "hello\n", false), + cmnt(" ", internal.OutputEndMarker, false), + } + + // when + _, _, err := internal.OutputFromBlocks(litdoc, blocks) + + // then + require.ErrorContains(t, err, "output content indentation") + }) + + t.Run("indented output end marker must match begin marker indent", func(t *testing.T) { + // given + litdoc := code(" ", "```bash | litdoc\n```\n", false) + blocks := []internal.Block{ + cmnt(" ", internal.OutputBeginMarker, false), + text(" ", "hello\n", false), + cmnt(" ", internal.OutputEndMarker, false), + } + + // when + _, _, err := internal.OutputFromBlocks(litdoc, blocks) + + // then + require.ErrorContains(t, err, "output end marker indentation") + }) + + t.Run("output begin marker must match litdoc indent", func(t *testing.T) { + // given + litdoc := code(" ", "```bash | litdoc\n```\n", false) + blocks := []internal.Block{ + cmnt("", internal.OutputBeginMarker, false), + text(" ", "hello\n", false), + cmnt(" ", internal.OutputEndMarker, false), + } + + // when + _, _, err := internal.OutputFromBlocks(litdoc, blocks) + + // then + require.ErrorContains(t, err, "output begin marker indentation") + }) + + t.Run("list item output marker must use rendered indentation", func(t *testing.T) { + // given + litdoc := code("- ", "```bash | litdoc\n```\n", false) + blocks := []internal.Block{ + cmnt("- ", internal.OutputBeginMarker, false), + text(" ", "hello\n", false), + cmnt(" ", internal.OutputEndMarker, false), + } + + // when + _, _, err := internal.OutputFromBlocks(litdoc, blocks) + + // then + require.ErrorContains(t, err, "output begin marker indentation") + }) + + t.Run("no output block returns zero value and zero consumed", func(t *testing.T) { + // given + litdoc := code("", "```bash | litdoc\n```\n", false) + blocks := []internal.Block{ + text("", "some text\n", false), + } + + // when + output, consumed, err := internal.OutputFromBlocks(litdoc, blocks) + + // then + require.NoError(t, err) + assert.Equal(t, "", output.Render("")) + assert.Equal(t, 0, consumed) + }) + + t.Run("empty blocks returns zero value and zero consumed", func(t *testing.T) { + // given + litdoc := code("", "```bash | litdoc\n```\n", false) + + // when + output, consumed, err := internal.OutputFromBlocks(litdoc, nil) + + // then + require.NoError(t, err) + assert.Equal(t, "", output.Render("")) + assert.Equal(t, 0, consumed) + }) + + t.Run("opening marker without closing marker returns error", func(t *testing.T) { + // given + litdoc := code("", "```bash | litdoc\n```\n", false) + blocks := []internal.Block{ + cmnt("", internal.OutputBeginMarker, false), + text("", "hello\n", false), + } + + // when + _, _, err := internal.OutputFromBlocks(litdoc, blocks) + + // then + require.ErrorContains(t, err, "unclosed output block") + }) +} diff --git a/internal/testdata/input.md b/internal/testdata/input.md index 43428d8..2fb75d9 100644 --- a/internal/testdata/input.md +++ b/internal/testdata/input.md @@ -37,3 +37,169 @@ echo "hello, world" + +- Fenced code block with previously generated output + +```bash | litdoc +echo "hello, world" +``` + + +output + + +- Indented code block + + ```bash | litdoc + echo "hello, world" + ``` + +- Indented code block with previously generated output + + ```bash | litdoc + echo "hello, world" + ``` + + + output + + +> Block quoted fenced code block with previously generated output +> +> ```bash | litdoc +> echo "hello, world" +> ``` +> +> +> output +> + +### Fenced code block indentation cases + +Fenced code block indented one space: + + ```bash | litdoc + echo "hello, world" + ``` + +Fenced code block indented three spaces: + + ```bash | litdoc + echo "hello, world" + ``` + +> Block quoted fenced code block +> +> ```bash | litdoc +> echo "hello, world" +> ``` + +> Nested block quoted fenced code block +> +> > ```bash | litdoc +> > echo "hello, world" +> > ``` + +Fenced code block in an unordered list: + +- ```bash | litdoc + echo "hello, world" + ``` + +Fenced code block in a nested unordered list: + + - ```bash | litdoc + echo "hello, world" + ``` + +Fenced code block in a plus list with a tilde fence: + ++ ~~~bash | litdoc + echo "hello, world" + ~~~ + +Fenced code block in an ordered list: + +2. ```bash | litdoc + echo "hello, world" + ``` + +Fenced code block in a nested ordered list: + + 1. ```bash | litdoc + echo "hello, world" + ``` + +Fenced code block in a list blockquote: + + > ```bash | litdoc + > echo "hello, world" + > ``` + +> Fenced code block in a blockquote nested list: +> +> - ```bash | litdoc +> echo "hello, world" +> ``` + +> Fenced code block in a blockquote ordered list: +> +> 1. ```bash | litdoc +> echo "hello, world" +> ``` + +### HTML comment indentation cases + +> Block quoted HTML comment +> +> + +> Nested block quoted HTML comment +> +> > + +HTML comment in an unordered list: + +- + +HTML comment in a nested unordered list: + + - + +HTML comment in a star list: + +* + +HTML comment in an ordered list: + +2. + +> HTML comment in a blockquote list: +> +> - + +> HTML comment in a nested blockquote list: +> +> > - + +HTML comment in an ordered-list blockquote: + + > diff --git a/internal/testdata/output.md b/internal/testdata/output.md index 43428d8..5e08ea5 100644 --- a/internal/testdata/output.md +++ b/internal/testdata/output.md @@ -32,8 +32,270 @@ echo "hello, world" ``` + +output + + - HTML comment + + +output + + +- Fenced code block with previously generated output + +```bash | litdoc +echo "hello, world" +``` + + +output + + +- Indented code block + + ```bash | litdoc + echo "hello, world" + ``` + + + output + + +- Indented code block with previously generated output + + ```bash | litdoc + echo "hello, world" + ``` + + + output + + +> Block quoted fenced code block with previously generated output +> +> ```bash | litdoc +> echo "hello, world" +> ``` +> +> +> output +> + +### Fenced code block indentation cases + +Fenced code block indented one space: + + ```bash | litdoc + echo "hello, world" + ``` + + + output + + +Fenced code block indented three spaces: + + ```bash | litdoc + echo "hello, world" + ``` + + + output + + +> Block quoted fenced code block +> +> ```bash | litdoc +> echo "hello, world" +> ``` +> +> +> output +> + +> Nested block quoted fenced code block +> +> > ```bash | litdoc +> > echo "hello, world" +> > ``` +> > +> > +> > output +> > + +Fenced code block in an unordered list: + +- ```bash | litdoc + echo "hello, world" + ``` + + + output + + +Fenced code block in a nested unordered list: + + - ```bash | litdoc + echo "hello, world" + ``` + + + output + + +Fenced code block in a plus list with a tilde fence: + ++ ~~~bash | litdoc + echo "hello, world" + ~~~ + + + output + + +Fenced code block in an ordered list: + +2. ```bash | litdoc + echo "hello, world" + ``` + + + output + + +Fenced code block in a nested ordered list: + + 1. ```bash | litdoc + echo "hello, world" + ``` + + + output + + +Fenced code block in a list blockquote: + + > ```bash | litdoc + > echo "hello, world" + > ``` + > + > + > output + > + +> Fenced code block in a blockquote nested list: +> +> - ```bash | litdoc +> echo "hello, world" +> ``` +> +> +> output +> + +> Fenced code block in a blockquote ordered list: +> +> 1. ```bash | litdoc +> echo "hello, world" +> ``` +> +> +> output +> + +### HTML comment indentation cases + +> Block quoted HTML comment +> +> +> +> +> output +> + +> Nested block quoted HTML comment +> +> > +> > +> > +> > output +> > + +HTML comment in an unordered list: + +- + + + output + + +HTML comment in a nested unordered list: + + - + + + output + + +HTML comment in a star list: + +* + + + output + + +HTML comment in an ordered list: + +2. + + + output + + +> HTML comment in a blockquote list: +> +> - +> +> +> output +> + +> HTML comment in a nested blockquote list: +> +> > - +> > +> > +> > output +> > + +HTML comment in an ordered-list blockquote: + + > + > + > + > output + >