From 41ff320d8e672832f543da07838888bf96163ba4 Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Fri, 24 Apr 2026 13:54:37 -0600 Subject: [PATCH 1/7] Copy input.md to output.md through Cells --- .mockery.yaml | 9 + Makefile | 26 ++- go.mod | 4 +- go.sum | 20 ++- internal/Cell_mock_test.go | 146 +++++++++++++++++ internal/cell.go | 114 +++++++++++++ internal/cell_test.go | 318 ++++++++++++++++++++++++++++++++++++ internal/file.go | 35 ++++ internal/file_test.go | 35 ++++ internal/testdata/input.md | 52 +++--- internal/testdata/output.md | 58 +++---- 11 files changed, 735 insertions(+), 82 deletions(-) create mode 100644 .mockery.yaml create mode 100644 internal/Cell_mock_test.go create mode 100644 internal/cell.go create mode 100644 internal/cell_test.go create mode 100644 internal/file.go create mode 100644 internal/file_test.go diff --git a/.mockery.yaml b/.mockery.yaml new file mode 100644 index 0000000..c1a8c3a --- /dev/null +++ b/.mockery.yaml @@ -0,0 +1,9 @@ +template: testify +filename: "{{.InterfaceName}}_mock_test.go" +packages: + litdoc/internal: + interfaces: + Cell: + config: + dir: ./internal/ + pkgname: internal_test \ No newline at end of file diff --git a/Makefile b/Makefile index 4828d68..966796e 100644 --- a/Makefile +++ b/Makefile @@ -1,31 +1,27 @@ .PHONY: pre-pr -pre-pr: fmt-check vet test +pre-pr: test -.PHONY: vet -vet: - @go vet ./... +.PHONY: mock +mock: + @mockery -.PHONY: fmt-check -fmt-check: - @unformatted=$$(gofmt -l .); \ - if [ -n "$$unformatted" ]; then \ - echo "unformatted files:"; \ - echo "$$unformatted"; \ - exit 1; \ - fi +.PHONY: mock-clean +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 bin/litdoc: $(GO_FILES) - @go build -o bin/litdoc . + @GOCACHE=$(GOCACHE) go build -o bin/litdoc . .PHONY: build build: bin/litdoc .PHONY: test test: build - @go test ./... --count=1 + @GOCACHE=$(GOCACHE) go test ./... --count=1 .PHONY: clean clean: - @rm -rf bin/ \ No newline at end of file + @rm -rf bin/ diff --git a/go.mod b/go.mod index 5b09597..aaa3191 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,10 @@ go 1.23 require ( github.com/rogpeppe/go-internal v1.14.1 + github.com/sergi/go-diff v1.4.0 github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 github.com/spf13/cobra v1.10.2 - github.com/stretchr/testify v1.11.1 + github.com/stretchr/testify v1.9.0 ) require ( @@ -14,6 +15,7 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/stretchr/objx v0.5.2 // indirect golang.org/x/sys v0.26.0 // indirect golang.org/x/tools v0.26.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 73935a9..911b596 100644 --- a/go.sum +++ b/go.sum @@ -1,27 +1,41 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 h1:6C8qej6f1bStuePVkLSFxoU22XBS165D3klxlzRg8F4= github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82/go.mod h1:xe4pgH49k4SsmkQq5OT8abwhWmnzkhpgnXeekbx2efw= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/Cell_mock_test.go b/internal/Cell_mock_test.go new file mode 100644 index 0000000..5903cea --- /dev/null +++ b/internal/Cell_mock_test.go @@ -0,0 +1,146 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package internal_test + +import ( + "litdoc/internal" + + mock "github.com/stretchr/testify/mock" +) + +// NewMockCell creates a new instance of MockCell. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockCell(t interface { + mock.TestingT + Cleanup(func()) +}) *MockCell { + mock := &MockCell{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockCell is an autogenerated mock type for the Cell type +type MockCell struct { + mock.Mock +} + +type MockCell_Expecter struct { + mock *mock.Mock +} + +func (_m *MockCell) EXPECT() *MockCell_Expecter { + return &MockCell_Expecter{mock: &_m.Mock} +} + +// Execute provides a mock function for the type MockCell +func (_mock *MockCell) Execute() (internal.Cell, error) { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for Execute") + } + + var r0 internal.Cell + var r1 error + if returnFunc, ok := ret.Get(0).(func() (internal.Cell, error)); ok { + return returnFunc() + } + if returnFunc, ok := ret.Get(0).(func() internal.Cell); ok { + r0 = returnFunc() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(internal.Cell) + } + } + if returnFunc, ok := ret.Get(1).(func() error); ok { + r1 = returnFunc() + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockCell_Execute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Execute' +type MockCell_Execute_Call struct { + *mock.Call +} + +// Execute is a helper method to define mock.On call +func (_e *MockCell_Expecter) Execute() *MockCell_Execute_Call { + return &MockCell_Execute_Call{Call: _e.mock.On("Execute")} +} + +func (_c *MockCell_Execute_Call) Run(run func()) *MockCell_Execute_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockCell_Execute_Call) Return(cell internal.Cell, err error) *MockCell_Execute_Call { + _c.Call.Return(cell, err) + return _c +} + +func (_c *MockCell_Execute_Call) RunAndReturn(run func() (internal.Cell, error)) *MockCell_Execute_Call { + _c.Call.Return(run) + return _c +} + +// Render provides a mock function for the type MockCell +func (_mock *MockCell) Render() (string, error) { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for Render") + } + + var r0 string + var r1 error + if returnFunc, ok := ret.Get(0).(func() (string, error)); ok { + return returnFunc() + } + if returnFunc, ok := ret.Get(0).(func() string); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func() error); ok { + r1 = returnFunc() + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockCell_Render_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Render' +type MockCell_Render_Call struct { + *mock.Call +} + +// Render is a helper method to define mock.On call +func (_e *MockCell_Expecter) Render() *MockCell_Render_Call { + return &MockCell_Render_Call{Call: _e.mock.On("Render")} +} + +func (_c *MockCell_Render_Call) Run(run func()) *MockCell_Render_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockCell_Render_Call) Return(s string, err error) *MockCell_Render_Call { + _c.Call.Return(s, err) + return _c +} + +func (_c *MockCell_Render_Call) RunAndReturn(run func() (string, error)) *MockCell_Render_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/cell.go b/internal/cell.go new file mode 100644 index 0000000..d57dcec --- /dev/null +++ b/internal/cell.go @@ -0,0 +1,114 @@ +package internal + +import ( + "bytes" + "fmt" +) + +type Cell interface { + Execute() (Cell, error) + Render() (string, error) +} + +type StaticCell struct { + content string +} + +func MakeStaticCellFromRaw(raw string) StaticCell { + return StaticCell{content: raw} +} + +func (t StaticCell) Execute() (Cell, error) { + return t, nil +} + +func (t StaticCell) Render() (string, error) { + return t.content, nil +} + +type BashCell struct { + fencedCode string + output string +} + +func MakeBashCellFromRaw(fencedCode, output string) BashCell { + return BashCell{fencedCode: fencedCode, output: output} +} + +func (c BashCell) Execute() (Cell, error) { + return c, nil +} + +func (c BashCell) Render() (string, error) { + if c.output == "" { + return c.fencedCode, nil + } + return c.fencedCode + "\n" + c.output, nil +} + +type InfoString struct { + Lang string + IsLitdoc bool +} + +func ParseInfoString(b Block) InfoString { + firstLine := b.content + if i := bytes.IndexByte(b.content, '\n'); i >= 0 { + firstLine = b.content[:i] + } + var raw []byte + switch b.kind { + case BlockKindFencedCode: + raw = bytes.TrimLeft(firstLine, "`~") + case BlockKindHTMLComment: + raw = bytes.TrimSpace(bytes.TrimPrefix(firstLine, []byte("\n"), + ), + want: internal.InfoString{Lang: "bash", IsLitdoc: true}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := internal.ParseInfoString(tt.block) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestMakeBashCellFromRaw(t *testing.T) { + // given + code := "```bash\necho hello\n```\n" + + // when + gotCell := internal.MakeBashCellFromRaw(code, "") + + // then + got, err := gotCell.Render() + require.NoError(t, err) + assert.Equal(t, code, got) +} + +func TestBashCellExecute(t *testing.T) { + // given + fencedCode := "```bash\necho hello\n```\n" + cell := internal.MakeBashCellFromRaw(fencedCode, "") + + // when + gotCell, err := cell.Execute() + + // then + require.NoError(t, err) + rendered, err := gotCell.Render() + require.NoError(t, err) + assert.Equal(t, fencedCode, rendered) +} + +func TestBashCellRender(t *testing.T) { + t.Run("without output", func(t *testing.T) { + // given + code := "```bash\necho hello\n```\n" + cell := internal.MakeBashCellFromRaw(code, "") + + // when + gotContent, err := cell.Render() + + // then + require.NoError(t, err) + assert.Equal(t, code, gotContent) + }) + + t.Run("with output", func(t *testing.T) { + // given + fencedCode := "```bash\necho hello\n```\n" + cell := internal.MakeBashCellFromRaw(fencedCode, "") + executed, err := cell.Execute() + require.NoError(t, err) + + // when + gotContent, err := executed.Render() + + // then + require.NoError(t, err) + assert.Equal(t, fencedCode, gotContent) + }) +} + +func TestExecute(t *testing.T) { + t.Run("calls Execute on each cell and returns results", func(t *testing.T) { + // given + result := NewMockCell(t) + cell := NewMockCell(t) + cell.EXPECT().Execute().Return(result, nil) + cells := []internal.Cell{cell} + + // when + gotCells, err := internal.Execute(cells) + + // then + require.NoError(t, err) + require.Len(t, gotCells, 1) + assert.Equal(t, result, gotCells[0]) + }) + + t.Run("returns error when a cell Execute fails", func(t *testing.T) { + // given + cell := NewMockCell(t) + cell.EXPECT().Execute().Return(nil, assert.AnError) + cells := []internal.Cell{cell} + + // when + _, err := internal.Execute(cells) + + // then + require.ErrorContains(t, err, "executing cell") + require.ErrorIs(t, err, assert.AnError) + }) +} + +func TestCompose(t *testing.T) { + t.Run("calls Render on each cell and returns concatenated results", func(t *testing.T) { + // given + cell1 := NewMockCell(t) + cell1.EXPECT().Render().Return("hello", nil) + cell2 := NewMockCell(t) + cell2.EXPECT().Render().Return(" world", nil) + cells := []internal.Cell{cell1, cell2} + + // when + got, err := internal.Compose(cells) + + // then + require.NoError(t, err) + assert.Equal(t, "hello world", got) + }) + + t.Run("returns error when a cell Render fails", func(t *testing.T) { + // given + cell := NewMockCell(t) + cell.EXPECT().Render().Return("", assert.AnError) + cells := []internal.Cell{cell} + + // when + _, err := internal.Compose(cells) + + // then + require.ErrorContains(t, err, "rendering cell") + require.ErrorIs(t, err, assert.AnError) + }) +} + +func TestClassify(t *testing.T) { + textBlock := func(content string) internal.Block { + return internal.MakeBlockFromRaw(internal.BlockKindText, []byte(content)) + } + bashLitdocBlock := func(content string) internal.Block { + return internal.MakeBlockFromRaw(internal.BlockKindFencedCode, []byte(content)) + } + + t.Run("single text block becomes StaticCell", func(t *testing.T) { + // given + blocks := []internal.Block{textBlock("hello")} + + // when + cells, err := internal.Classify(blocks) + + // then + require.NoError(t, err) + require.Len(t, cells, 1) + _, ok := cells[0].(internal.StaticCell) + require.True(t, ok, "expected StaticCell, got %T", cells[0]) + got, err := cells[0].Render() + require.NoError(t, err) + assert.Equal(t, "hello", got) + }) + + t.Run("litdoc block becomes BashCell", func(t *testing.T) { + // given + code := "```bash | litdoc\necho hello\n```\n" + blocks := []internal.Block{bashLitdocBlock(code)} + + // when + cells, err := internal.Classify(blocks) + + // then + require.NoError(t, err) + require.Len(t, cells, 1) + _, ok := cells[0].(internal.BashCell) + require.True(t, ok, "expected BashCell, got %T", cells[0]) + rendered, err := cells[0].Render() + require.NoError(t, err) + assert.Equal(t, code, rendered) + }) + + t.Run("litdoc block prevents merging of surrounding text blocks", func(t *testing.T) { + // given + code := "```bash | litdoc\necho hello\n```\n" + blocks := []internal.Block{ + textBlock("before"), + bashLitdocBlock(code), + textBlock("after"), + } + + // when + cells, err := internal.Classify(blocks) + + // then + require.NoError(t, err) + require.Len(t, cells, 3) + rendered0, err := cells[0].Render() + require.NoError(t, err) + assert.Equal(t, "before", rendered0) + _, ok := cells[1].(internal.BashCell) + assert.True(t, ok, "expected BashCell, got %T", cells[1]) + rendered2, err := cells[2].Render() + require.NoError(t, err) + assert.Equal(t, "after", rendered2) + }) + + t.Run("litdoc block with unsupported language returns error", func(t *testing.T) { + // given + blocks := []internal.Block{ + bashLitdocBlock("```go | litdoc\nfmt.Println()\n```\n"), + } + + // when + _, err := internal.Classify(blocks) + + // then + require.ErrorContains(t, err, "unsupported language") + }) + + t.Run("non-litdoc fenced code block becomes StaticCell", func(t *testing.T) { + // given + code := "```bash\necho hello\n```\n" + blocks := []internal.Block{ + internal.MakeBlockFromRaw(internal.BlockKindFencedCode, []byte(code)), + } + + // when + cells, err := internal.Classify(blocks) + + // then + require.NoError(t, err) + require.Len(t, cells, 1) + rendered, err := cells[0].Render() + require.NoError(t, err) + assert.Equal(t, code, rendered) + }) +} diff --git a/internal/file.go b/internal/file.go new file mode 100644 index 0000000..314001a --- /dev/null +++ b/internal/file.go @@ -0,0 +1,35 @@ +package internal + +import ( + "fmt" + "os" +) + +func ProcessFile(srcFilePath string) (string, error) { + srcContent, err := os.ReadFile(srcFilePath) + if err != nil { + return "", fmt.Errorf("reading source file: %w", err) + } + + blocks, err := MakeBlocksFromMarkdown(srcContent) + if err != nil { + return "", fmt.Errorf("parsing source file: %w", err) + } + + cells, err := Classify(blocks) + if err != nil { + return "", fmt.Errorf("classifying blocks into cells: %w", err) + } + + cells, err = Execute(cells) + if err != nil { + return "", fmt.Errorf("executing cells: %w", err) + } + + dstContent, err := Compose(cells) + if err != nil { + return "", fmt.Errorf("composing cells into content: %w", err) + } + + return dstContent, nil +} diff --git a/internal/file_test.go b/internal/file_test.go new file mode 100644 index 0000000..5518685 --- /dev/null +++ b/internal/file_test.go @@ -0,0 +1,35 @@ +package internal_test + +import ( + _ "embed" + "os" + "testing" + + "litdoc/internal" +) + +//go:embed testdata/input.md +var renderInput []byte + +//go:embed testdata/output.md +var renderOutput []byte + +func TestProcessFile(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "*.md") + if err != nil { + t.Fatal(err) + } + if _, err := f.Write(renderInput); err != nil { + t.Fatal(err) + } + f.Close() + + 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) + } +} diff --git a/internal/testdata/input.md b/internal/testdata/input.md index cb143f2..43428d8 100644 --- a/internal/testdata/input.md +++ b/internal/testdata/input.md @@ -1,43 +1,39 @@ -# Hello +# Usage examples -The following fenced code blocks should be ignored: +## Content blocks that should be copied over without execution -```bash -echo "just, bash" -``` +- Text content (and the headings above) -This next one should be executed: +- Static fenced code blocks -```bash | litdoc -echo "hello, world" -``` + ```bash + echo "static" + ``` -Another one to be ignored: +- HTML comments - + -And one more to be executed: +- Verbatim blocks - + ````md + ```bash | litdoc + echo "hello, world" + ``` + ```` -Here's a previously executed block: +## Content blocks that should be executed + +- Fenced code block ```bash | litdoc echo "hello, world" ``` - -output - - -And a verbatim block that should be ignored: +- HTML comment -````md -```bash | litdoc -echo "hello, world" -``` -```` \ No newline at end of file + diff --git a/internal/testdata/output.md b/internal/testdata/output.md index 4b7c795..43428d8 100644 --- a/internal/testdata/output.md +++ b/internal/testdata/output.md @@ -1,51 +1,39 @@ -# Hello +# Usage examples -The following fenced code blocks should be ignored: +## Content blocks that should be copied over without execution -```bash -echo "just, bash" -``` - -This next one should be executed: +- Text content (and the headings above) -```bash | litdoc -echo "hello, world" -``` +- Static fenced code blocks - -output - + ```bash + echo "static" + ``` -Another one to be ignored: +- HTML comments - + -And one more to be executed: +- Verbatim blocks - + ````md + ```bash | litdoc + echo "hello, world" + ``` + ```` - -output - +## Content blocks that should be executed -Here's a previously executed block: +- Fenced code block ```bash | litdoc echo "hello, world" ``` - -output - - -And a verbatim block that should be ignored: +- HTML comment -````md -```bash | litdoc -echo "hello, world" -``` -```` \ No newline at end of file + From f7d3ae0ed40800f0d2eefdf6caf791bc8d62bc3d Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Fri, 24 Apr 2026 14:01:22 -0600 Subject: [PATCH 2/7] Use a string builder in Compose --- internal/cell.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/cell.go b/internal/cell.go index d57dcec..9ed3230 100644 --- a/internal/cell.go +++ b/internal/cell.go @@ -3,6 +3,7 @@ package internal import ( "bytes" "fmt" + "strings" ) type Cell interface { @@ -102,13 +103,13 @@ func Execute(cells []Cell) ([]Cell, error) { } func Compose(cells []Cell) (string, error) { - var dst string + var dst strings.Builder for _, c := range cells { rendered, err := c.Render() if err != nil { return "", fmt.Errorf("rendering cell: %w", err) } - dst += rendered + dst.WriteString(rendered) } - return dst, nil + return dst.String(), nil } From 075c35334950d7123c65f3473c5dbc8b6b1a8307 Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Fri, 24 Apr 2026 14:02:52 -0600 Subject: [PATCH 3/7] Use an idiomatic loop in Classify, since we don't need the index --- internal/cell.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/cell.go b/internal/cell.go index 9ed3230..8569dcb 100644 --- a/internal/cell.go +++ b/internal/cell.go @@ -74,8 +74,7 @@ func ParseInfoString(b Block) InfoString { func Classify(blocks []Block) ([]Cell, error) { var cells []Cell - for i := 0; i < len(blocks); i++ { - b := blocks[i] + for _, b := range blocks { info := ParseInfoString(b) switch { case info.IsLitdoc && info.Lang == "bash": From ff4aaebe72bd12ae9092f9e8cf1bce25ac19279f Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Fri, 24 Apr 2026 14:06:40 -0600 Subject: [PATCH 4/7] Bring back fmt-check and vet targets --- Makefile | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 966796e..97331e6 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,22 @@ .PHONY: pre-pr -pre-pr: test +pre-pr: fmt-check vet test + +.PHONY: fmt +fmt: + @gofmt -w . + +.PHONY: fmt-check +fmt-check: + @unformatted=$$(gofmt -l .); \ + if [ -n "$$unformatted" ]; then \ + echo "unformatted files:"; \ + echo "$$unformatted"; \ + exit 1; \ + fi + +.PHONY: vet +vet: + @go vet ./... .PHONY: mock mock: From 489975234a1b6024a0637439980cda520f1927c3 Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Fri, 24 Apr 2026 14:14:59 -0600 Subject: [PATCH 5/7] Update dependencies --- go.mod | 3 +-- go.sum | 18 +++--------------- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/go.mod b/go.mod index aaa3191..f22569b 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,9 @@ go 1.23 require ( github.com/rogpeppe/go-internal v1.14.1 - github.com/sergi/go-diff v1.4.0 github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 github.com/spf13/cobra v1.10.2 - github.com/stretchr/testify v1.9.0 + github.com/stretchr/testify v1.11.1 ) require ( diff --git a/go.sum b/go.sum index 911b596..4748e68 100644 --- a/go.sum +++ b/go.sum @@ -1,41 +1,29 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= -github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 h1:6C8qej6f1bStuePVkLSFxoU22XBS165D3klxlzRg8F4= github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82/go.mod h1:xe4pgH49k4SsmkQq5OT8abwhWmnzkhpgnXeekbx2efw= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From a880f04e2dd0d41e0778e51ab914a71c45265562 Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Fri, 24 Apr 2026 15:28:52 -0600 Subject: [PATCH 6/7] Update test names --- internal/cell_test.go | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/internal/cell_test.go b/internal/cell_test.go index fc44b44..42a14c8 100644 --- a/internal/cell_test.go +++ b/internal/cell_test.go @@ -154,7 +154,7 @@ func TestBashCellRender(t *testing.T) { } func TestExecute(t *testing.T) { - t.Run("calls Execute on each cell and returns results", func(t *testing.T) { + t.Run("happy path", func(t *testing.T) { // given result := NewMockCell(t) cell := NewMockCell(t) @@ -170,7 +170,7 @@ func TestExecute(t *testing.T) { assert.Equal(t, result, gotCells[0]) }) - t.Run("returns error when a cell Execute fails", func(t *testing.T) { + t.Run("cell.Execute fails", func(t *testing.T) { // given cell := NewMockCell(t) cell.EXPECT().Execute().Return(nil, assert.AnError) @@ -186,7 +186,7 @@ func TestExecute(t *testing.T) { } func TestCompose(t *testing.T) { - t.Run("calls Render on each cell and returns concatenated results", func(t *testing.T) { + t.Run("happy path", func(t *testing.T) { // given cell1 := NewMockCell(t) cell1.EXPECT().Render().Return("hello", nil) @@ -202,7 +202,7 @@ func TestCompose(t *testing.T) { assert.Equal(t, "hello world", got) }) - t.Run("returns error when a cell Render fails", func(t *testing.T) { + t.Run("cell.Render fails", func(t *testing.T) { // given cell := NewMockCell(t) cell.EXPECT().Render().Return("", assert.AnError) @@ -242,7 +242,7 @@ func TestClassify(t *testing.T) { assert.Equal(t, "hello", got) }) - t.Run("litdoc block becomes BashCell", func(t *testing.T) { + t.Run("litdoc bash block becomes BashCell", func(t *testing.T) { // given code := "```bash | litdoc\necho hello\n```\n" blocks := []internal.Block{bashLitdocBlock(code)} @@ -260,7 +260,7 @@ func TestClassify(t *testing.T) { assert.Equal(t, code, rendered) }) - t.Run("litdoc block prevents merging of surrounding text blocks", func(t *testing.T) { + t.Run("mixed block types are each classified independently", func(t *testing.T) { // given code := "```bash | litdoc\necho hello\n```\n" blocks := []internal.Block{ @@ -285,19 +285,6 @@ func TestClassify(t *testing.T) { assert.Equal(t, "after", rendered2) }) - t.Run("litdoc block with unsupported language returns error", func(t *testing.T) { - // given - blocks := []internal.Block{ - bashLitdocBlock("```go | litdoc\nfmt.Println()\n```\n"), - } - - // when - _, err := internal.Classify(blocks) - - // then - require.ErrorContains(t, err, "unsupported language") - }) - t.Run("non-litdoc fenced code block becomes StaticCell", func(t *testing.T) { // given code := "```bash\necho hello\n```\n" @@ -315,4 +302,17 @@ func TestClassify(t *testing.T) { require.NoError(t, err) assert.Equal(t, code, rendered) }) + + t.Run("litdoc block with unsupported language", func(t *testing.T) { + // given + blocks := []internal.Block{ + bashLitdocBlock("```go | litdoc\nfmt.Println()\n```\n"), + } + + // when + _, err := internal.Classify(blocks) + + // then + require.ErrorContains(t, err, "unsupported language") + }) } From 0e1ea02b55bc9efd0d71bc2e845703fab860c995 Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Fri, 24 Apr 2026 15:34:13 -0600 Subject: [PATCH 7/7] Make sure to clean and regenerate mocks as a part of PR --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 97331e6..4710f80 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .PHONY: pre-pr -pre-pr: fmt-check vet test +pre-pr: clean mock fmt-check vet test .PHONY: fmt fmt: @@ -40,5 +40,5 @@ test: build @GOCACHE=$(GOCACHE) go test ./... --count=1 .PHONY: clean -clean: +clean: mock-clean @rm -rf bin/