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..4710f80 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,9 @@ .PHONY: pre-pr -pre-pr: fmt-check vet test +pre-pr: clean mock fmt-check vet test -.PHONY: vet -vet: - @go vet ./... +.PHONY: fmt +fmt: + @gofmt -w . .PHONY: fmt-check fmt-check: @@ -14,18 +14,31 @@ fmt-check: exit 1; \ fi +.PHONY: vet +vet: + @go vet ./... + +.PHONY: mock +mock: + @mockery + +.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 +clean: mock-clean + @rm -rf bin/ diff --git a/go.mod b/go.mod index 5b09597..f22569b 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,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..4748e68 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ 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.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.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= 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..8569dcb --- /dev/null +++ b/internal/cell.go @@ -0,0 +1,114 @@ +package internal + +import ( + "bytes" + "fmt" + "strings" +) + +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("happy path", 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("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("happy path", 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("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 bash 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("mixed block types are each classified independently", 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("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) + }) + + 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") + }) +} 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 +