From 866daa5307f4067c3783cd161dd7d1d4618c47bd Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Tue, 5 May 2026 17:29:40 -0600 Subject: [PATCH 01/10] Add golangci-lint --- .github/workflows/ci.yml | 3 ++- .golangci.yml | 13 +++++++++++++ Makefile | 6 +++++- flake.nix | 3 ++- 4 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 .golangci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0a524b..abfef6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,5 +32,6 @@ jobs: - run: nix develop --ignore-environment --command make vendor - run: nix develop --ignore-environment --command make fmt-check + - run: nix develop --ignore-environment --command make lint - run: nix develop --ignore-environment --command make vet - - run: nix develop --ignore-environment --command make test \ No newline at end of file + - run: nix develop --ignore-environment --command make test diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..7a88653 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,13 @@ +version: "2" + +linters: + default: none + enable: + - godox + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + +run: + modules-download-mode: vendor diff --git a/Makefile b/Makefile index ecbc794..bdc198f 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ GO ?= go .PHONY: pre-pr -pre-pr: clean mock fmt-check vet test +pre-pr: clean mock fmt-check lint vet test .PHONY: fmt fmt: @@ -20,6 +20,10 @@ fmt-check: vet: @$(GO) vet ./... +.PHONY: lint +lint: vendor + @golangci-lint run ./... + .PHONY: mock mock: @mockery diff --git a/flake.nix b/flake.nix index ca92971..b81c8fb 100644 --- a/flake.nix +++ b/flake.nix @@ -14,6 +14,7 @@ devShells.default = pkgs.mkShell { packages = with pkgs; [ go + golangci-lint go-mockery cacert ]; @@ -27,4 +28,4 @@ }; } ); -} \ No newline at end of file +} From d2fce87c70ec9bad917bf09ce068b9455d244b63 Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Tue, 5 May 2026 17:34:28 -0600 Subject: [PATCH 02/10] Move BashCell and StaticCell into language-specific packages Cell types now live in internal/bash and internal/static. Classify dispatches all cell construction through a parsers map (keyed by language, with "static" as the reserved fallback), so internal has no dependency on any concrete cell type. --- cmd/file.go | 8 ++- internal/bash/cell.go | 52 ++++++++++++++++++ internal/bash/cell_test.go | 75 ++++++++++++++++++++++++++ internal/block.go | 4 +- internal/cell.go | 102 ++++++++++++----------------------- internal/cell_test.go | 97 ++++----------------------------- internal/file.go | 4 +- internal/file_test.go | 8 ++- internal/output.go | 2 +- internal/static/cell.go | 33 ++++++++++++ internal/static/cell_test.go | 36 +++++++++++++ 11 files changed, 257 insertions(+), 164 deletions(-) create mode 100644 internal/bash/cell.go create mode 100644 internal/bash/cell_test.go create mode 100644 internal/static/cell.go create mode 100644 internal/static/cell_test.go diff --git a/cmd/file.go b/cmd/file.go index e146666..48089eb 100644 --- a/cmd/file.go +++ b/cmd/file.go @@ -6,6 +6,8 @@ import ( "path/filepath" "litdoc/internal" + "litdoc/internal/bash" + "litdoc/internal/static" "github.com/spf13/cobra" ) @@ -18,7 +20,11 @@ var fileCmd = &cobra.Command{ Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { path := args[0] - data, err := internal.ProcessFile(path) + parsers := map[string]internal.CellParser{ + "static": static.ParseStaticCell, + "bash": bash.ParseBashCell, + } + data, err := internal.ProcessFile(path, parsers) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) diff --git a/internal/bash/cell.go b/internal/bash/cell.go new file mode 100644 index 0000000..8fbdecf --- /dev/null +++ b/internal/bash/cell.go @@ -0,0 +1,52 @@ +package bash + +import ( + "fmt" + + "litdoc/internal" +) + +type BashCell struct { + content string + indent string + output internal.Output +} + +func MakeBashCellFromRaw(content, indent string, output internal.Output) BashCell { + return BashCell{ + content: content, + indent: indent, + output: output, + } +} + +func ParseBashCell( + block internal.Block, + following []internal.Block, +) ( + internal.Cell, + int, + error, +) { + // todo: test this + output, consumed, err := internal.OutputFromBlocks(block, following) + if err != nil { + return nil, 0, fmt.Errorf("parsing output: %w", err) + } + return MakeBashCellFromRaw(block.Content(), block.Indent(), output), consumed, nil +} + +func (c BashCell) Execute() (internal.Cell, error) { + // todo: test this + return BashCell{ + content: c.content, + indent: c.indent, + output: internal.MakeOutput("output"), + }, nil +} + +func (c BashCell) Render() (string, error) { + // todo: test this + fencedCode := internal.RenderContent(c.content, c.indent) + return fencedCode + c.output.Render(internal.RenderIndent(c.indent)), nil +} diff --git a/internal/bash/cell_test.go b/internal/bash/cell_test.go new file mode 100644 index 0000000..8ba434d --- /dev/null +++ b/internal/bash/cell_test.go @@ -0,0 +1,75 @@ +package bash_test + +import ( + "strings" + "testing" + + "litdoc/internal" + "litdoc/internal/bash" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func joinLines(lines ...string) string { + return strings.Join(lines, "\n") +} + +func TestBashCell(t *testing.T) { + t.Run("without output", func(t *testing.T) { + // given + code := joinLines( + "```bash", + "echo hello", + "```", + "", + ) + cell := bash.MakeBashCellFromRaw(code, "", internal.Output{}) + + // 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 := joinLines( + "```bash", + "echo hello", + "```", + "", + ) + output := internal.MakeOutput("hello") + cell := bash.MakeBashCellFromRaw(fencedCode, "", output) + + // when + gotContent, err := cell.Render() + + // then + require.NoError(t, err) + assert.Equal(t, fencedCode+output.Render(""), gotContent) + }) + + t.Run("execute produces stub output", func(t *testing.T) { + // given + fencedCode := joinLines( + "```bash", + "echo hello", + "```", + "", + ) + cell := bash.MakeBashCellFromRaw(fencedCode, "", internal.Output{}) + + // when + gotCell, err := cell.Execute() + + // then + require.NoError(t, err) + rendered, err := gotCell.Render() + require.NoError(t, err) + assert.Equal(t, fencedCode+internal.MakeOutput("output").Render(""), rendered) + }) +} diff --git a/internal/block.go b/internal/block.go index d6ef63f..3f43a92 100644 --- a/internal/block.go +++ b/internal/block.go @@ -181,7 +181,7 @@ func (c *blockCollector) appendTextGap(end uint32, stripPrefix, indent []byte) { } gap := stripIndent(c.content[c.pos:end], stripPrefix) if len(bytes.TrimSpace(gap)) == 0 { - indent = []byte(renderIndent(string(indent))) + indent = []byte(RenderIndent(string(indent))) } c.appendBlock(BlockKindText, gap, indent) } @@ -291,7 +291,7 @@ func blockPrefixes( // 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))) + renderedIndent := []byte(RenderIndent(string(containerIndent))) if !bytes.HasPrefix(linePrefix, renderedIndent) { return nil, false } diff --git a/internal/cell.go b/internal/cell.go index 5d618a4..07fe5c6 100644 --- a/internal/cell.go +++ b/internal/cell.go @@ -10,47 +10,7 @@ type Cell interface { Render() (string, error) } -type StaticCell struct { - content string -} - -func MakeStaticCellFromRaw(content string) StaticCell { - return StaticCell{content: content} -} - -func (t StaticCell) Execute() (Cell, error) { - return t, nil -} - -func (t StaticCell) Render() (string, error) { - return t.content, nil -} - -type BashCell struct { - fencedCode string - indent string - 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 BashCell{ - fencedCode: c.fencedCode, - indent: c.indent, - output: MakeOutput("output"), - }, nil -} - -func (c BashCell) Render() (string, error) { - return c.fencedCode + c.output.Render(renderIndent(c.indent)), nil -} +type CellParser func(block Block, following []Block) (Cell, int, error) type InfoString struct { Lang string @@ -77,7 +37,12 @@ func ParseInfoString(b Block) InfoString { return InfoString{Lang: lang, Litdoc: litdoc} } -func Classify(blocks []Block) ([]Cell, error) { +func Classify(blocks []Block, parsers map[string]CellParser) ([]Cell, error) { + // todo: make sure all this gets tested + static, ok := parsers["static"] + if !ok { + return nil, fmt.Errorf("no static parser provided") + } var cells []Cell i := 0 for i < len(blocks) { @@ -86,39 +51,50 @@ func Classify(blocks []Block) ([]Cell, error) { case BlockKindFencedCode, BlockKindHTMLComment: info := ParseInfoString(b) switch { - case info.Litdoc && info.Lang == "bash": - output, consumed, err := OutputFromBlocks(b, blocks[i+1:]) + case info.Litdoc: + parser, ok := parsers[info.Lang] + if !ok { + return nil, fmt.Errorf("unsupported language: %q", info.Lang) + } + cell, consumed, err := parser(b, blocks[i+1:]) if err != nil { - return nil, fmt.Errorf("parsing output: %w", err) + return nil, err } - cells = append(cells, BashCell{ - fencedCode: renderStaticBlock(b), - indent: b.indent, - output: output, - }) + cells = append(cells, cell) i += 1 + consumed continue - case info.Litdoc: - return nil, fmt.Errorf("unsupported language: %q", info.Lang) default: - cells = append(cells, MakeStaticCellFromRaw(renderStaticBlock(b))) + cell, _, err := static(b, nil) + if err != nil { + return nil, err + } + cells = append(cells, cell) } default: - cells = append(cells, MakeStaticCellFromRaw(renderStaticBlock(b))) + cell, _, err := static(b, nil) + if err != nil { + return nil, err + } + cells = append(cells, cell) } i++ } return cells, nil } -func renderStaticBlock(b Block) string { +func RenderContent(content, indent string) string { + // todo: double check that I need this function + return RenderBlock(MakeBlockFromRaw(BlockKindFencedCode, content, indent, false)) +} + +func RenderBlock(b Block) string { if len(b.indent) == 0 { return b.content } lines := strings.Split(b.content, "\n") var rendered strings.Builder - renderedIndent := renderIndent(b.indent) + renderedIndent := RenderIndent(b.indent) blankLineIndent := blankBlockQuoteLinePrefix(b.indent) for i, line := range lines { if i == len(lines)-1 && len(line) == 0 { @@ -151,7 +127,7 @@ func blankBlockQuoteLinePrefix(indent string) string { return "" } -func renderIndent(indent string) string { +func RenderIndent(indent string) string { if idx := strings.LastIndex(indent, "> "); idx >= 0 { prefixLen := idx + len("> ") return indent[:prefixLen] + strings.Repeat(" ", len(indent)-prefixLen) @@ -159,18 +135,6 @@ 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 06fd766..917206f 100644 --- a/internal/cell_test.go +++ b/internal/cell_test.go @@ -4,37 +4,13 @@ import ( "testing" "litdoc/internal" + "litdoc/internal/bash" + "litdoc/internal/static" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestStaticCell(t *testing.T) { - t.Run("renders raw content", func(t *testing.T) { - // given - content := "hello" - cell := internal.MakeStaticCellFromRaw(content) - - // when - gotContent, err := cell.Render() - - // then - require.NoError(t, err) - assert.Equal(t, content, gotContent) - }) - - t.Run("executes to itself", func(t *testing.T) { - // given - cell := internal.MakeStaticCellFromRaw("hello") - - // when - gotCell, err := cell.Execute() - - // then - require.NoError(t, err) - assert.Equal(t, cell, gotCell) - }) -} func TestParseInfoString(t *testing.T) { tests := []struct { @@ -183,64 +159,6 @@ func TestParseInfoString(t *testing.T) { } } -func TestBashCell(t *testing.T) { - t.Run("without output", func(t *testing.T) { - // given - code := joinLines( - "```bash", - "echo hello", - "```", - "", - ) - cell := internal.MakeBashCellFromRaw(code, internal.Output{}) - - // 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 := joinLines( - "```bash", - "echo hello", - "```", - "", - ) - output := internal.MakeOutput("hello") - cell := internal.MakeBashCellFromRaw(fencedCode, output) - - // when - gotContent, err := cell.Render() - - // then - require.NoError(t, err) - assert.Equal(t, fencedCode+output.Render(""), gotContent) - }) - - t.Run("execute produces stub output", func(t *testing.T) { - // given - fencedCode := joinLines( - "```bash", - "echo hello", - "```", - "", - ) - cell := internal.MakeBashCellFromRaw(fencedCode, internal.Output{}) - - // when - gotCell, err := cell.Execute() - - // then - require.NoError(t, err) - rendered, err := gotCell.Render() - require.NoError(t, err) - assert.Equal(t, fencedCode+internal.MakeOutput("output").Render(""), rendered) - }) -} func TestExecute(t *testing.T) { t.Run("happy path", func(t *testing.T) { @@ -586,10 +504,15 @@ func TestClassify(t *testing.T) { }, } + parsers := map[string]internal.CellParser{ + "static": static.ParseStaticCell, + "bash": bash.ParseBashCell, + } + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // when - cells, err := internal.Classify(tt.blocks) + cells, err := internal.Classify(tt.blocks, parsers) // then if tt.wantErrText != "" { @@ -617,9 +540,9 @@ func TestClassify(t *testing.T) { func cellKind(cell internal.Cell) string { switch cell.(type) { - case internal.StaticCell: + case static.StaticCell: return "StaticCell" - case internal.BashCell: + case bash.BashCell: return "BashCell" default: return "unknown" diff --git a/internal/file.go b/internal/file.go index 314001a..5dba9a5 100644 --- a/internal/file.go +++ b/internal/file.go @@ -5,7 +5,7 @@ import ( "os" ) -func ProcessFile(srcFilePath string) (string, error) { +func ProcessFile(srcFilePath string, parsers map[string]CellParser) (string, error) { srcContent, err := os.ReadFile(srcFilePath) if err != nil { return "", fmt.Errorf("reading source file: %w", err) @@ -16,7 +16,7 @@ func ProcessFile(srcFilePath string) (string, error) { return "", fmt.Errorf("parsing source file: %w", err) } - cells, err := Classify(blocks) + cells, err := Classify(blocks, parsers) if err != nil { return "", fmt.Errorf("classifying blocks into cells: %w", err) } diff --git a/internal/file_test.go b/internal/file_test.go index 8e63423..1234c8b 100644 --- a/internal/file_test.go +++ b/internal/file_test.go @@ -6,6 +6,8 @@ import ( "testing" "litdoc/internal" + "litdoc/internal/bash" + "litdoc/internal/static" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -38,7 +40,9 @@ func TestProcessFile(t *testing.T) { require.NoError(t, err) // when - got, err := internal.ProcessFile(f.Name()) + // todo: pull these definitions to given + got, err := internal.ProcessFile( + f.Name(), map[string]internal.CellParser{"static": static.ParseStaticCell, "bash": bash.ParseBashCell}) // then require.NoError(t, err) @@ -58,7 +62,7 @@ func TestProcessFileNestedListIndent(t *testing.T) { require.NoError(t, err) // when - got, err := internal.ProcessFile(f.Name()) + got, err := internal.ProcessFile(f.Name(), map[string]internal.CellParser{"static": static.ParseStaticCell, "bash": bash.ParseBashCell}) // then require.NoError(t, err) diff --git a/internal/output.go b/internal/output.go index 3323f6d..cde5379 100644 --- a/internal/output.go +++ b/internal/output.go @@ -48,7 +48,7 @@ func isOutputEnd(b Block) bool { 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) + outputIndent := RenderIndent(litdoc.indent) i := 0 var err error diff --git a/internal/static/cell.go b/internal/static/cell.go new file mode 100644 index 0000000..60cff2e --- /dev/null +++ b/internal/static/cell.go @@ -0,0 +1,33 @@ +package static + +import "litdoc/internal" + +type StaticCell struct { + content string +} + +func MakeStaticCellFromRaw(content string) StaticCell { + return StaticCell{content: content} +} + +func ParseStaticCell( + block internal.Block, + _ []internal.Block, +) ( + internal.Cell, + int, + error, +) { + // todo: test this + return StaticCell{content: internal.RenderBlock(block)}, 0, nil +} + +func (c StaticCell) Execute() (internal.Cell, error) { + // todo: test this + return c, nil +} + +func (c StaticCell) Render() (string, error) { + // todo: test this + return c.content, nil +} diff --git a/internal/static/cell_test.go b/internal/static/cell_test.go new file mode 100644 index 0000000..549fb3f --- /dev/null +++ b/internal/static/cell_test.go @@ -0,0 +1,36 @@ +package static_test + +import ( + "testing" + + "litdoc/internal/static" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStaticCell(t *testing.T) { + t.Run("renders raw content", func(t *testing.T) { + // given + cell := static.MakeStaticCellFromRaw("hello") + + // when + gotContent, err := cell.Render() + + // then + require.NoError(t, err) + assert.Equal(t, "hello", gotContent) + }) + + t.Run("executes to itself", func(t *testing.T) { + // given + cell := static.MakeStaticCellFromRaw("hello") + + // when + gotCell, err := cell.Execute() + + // then + require.NoError(t, err) + assert.Equal(t, cell, gotCell) + }) +} From f9c1d45184442c982fc0d8f3ac85c097b320de33 Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Tue, 5 May 2026 18:17:45 -0600 Subject: [PATCH 03/10] Refactor rendering to use Block.Render() and simplify Output - Move rendering logic from free functions RenderBlock/RenderContent into Block.Render(), removing them from cell.go - StaticCell and BashCell now store a Block instead of raw content+indent strings, calling block.Render() directly - Output stores a Block (assembled from markers and content) instead of separate content/linePrefix fields; Render() delegates to Block.Render() - MakeOutput now takes a raw indent and computes RenderIndent internally, removing the need for callers to compute it --- internal/bash/cell.go | 20 ++++++++----------- internal/bash/cell_test.go | 10 +++++----- internal/block.go | 33 +++++++++++++++++++++++++++++-- internal/cell.go | 38 ------------------------------------ internal/output.go | 32 ++++++++++++------------------ internal/output_test.go | 30 ++++++++++++++-------------- internal/static/cell.go | 8 ++++---- internal/static/cell_test.go | 1 + 8 files changed, 77 insertions(+), 95 deletions(-) diff --git a/internal/bash/cell.go b/internal/bash/cell.go index 8fbdecf..87027d0 100644 --- a/internal/bash/cell.go +++ b/internal/bash/cell.go @@ -7,16 +7,14 @@ import ( ) type BashCell struct { - content string - indent string - output internal.Output + block internal.Block + output internal.Output } func MakeBashCellFromRaw(content, indent string, output internal.Output) BashCell { return BashCell{ - content: content, - indent: indent, - output: output, + block: internal.MakeBlockFromRaw(internal.BlockKindFencedCode, content, indent, false), + output: output, } } @@ -33,20 +31,18 @@ func ParseBashCell( if err != nil { return nil, 0, fmt.Errorf("parsing output: %w", err) } - return MakeBashCellFromRaw(block.Content(), block.Indent(), output), consumed, nil + return BashCell{block: block, output: output}, consumed, nil } func (c BashCell) Execute() (internal.Cell, error) { // todo: test this return BashCell{ - content: c.content, - indent: c.indent, - output: internal.MakeOutput("output"), + block: c.block, + output: internal.MakeOutput("output", c.block.Indent()), }, nil } func (c BashCell) Render() (string, error) { // todo: test this - fencedCode := internal.RenderContent(c.content, c.indent) - return fencedCode + c.output.Render(internal.RenderIndent(c.indent)), nil + return c.block.Render() + c.output.Render(), nil } diff --git a/internal/bash/cell_test.go b/internal/bash/cell_test.go index 8ba434d..b58bcc3 100644 --- a/internal/bash/cell_test.go +++ b/internal/bash/cell_test.go @@ -24,7 +24,7 @@ func TestBashCell(t *testing.T) { "```", "", ) - cell := bash.MakeBashCellFromRaw(code, "", internal.Output{}) + cell := bash.MakeBashCellFromRaw(code, "", internal.MakeOutput("", "")) // when gotContent, err := cell.Render() @@ -42,7 +42,7 @@ func TestBashCell(t *testing.T) { "```", "", ) - output := internal.MakeOutput("hello") + output := internal.MakeOutput("hello", "") cell := bash.MakeBashCellFromRaw(fencedCode, "", output) // when @@ -50,7 +50,7 @@ func TestBashCell(t *testing.T) { // then require.NoError(t, err) - assert.Equal(t, fencedCode+output.Render(""), gotContent) + assert.Equal(t, fencedCode+output.Render(), gotContent) }) t.Run("execute produces stub output", func(t *testing.T) { @@ -61,7 +61,7 @@ func TestBashCell(t *testing.T) { "```", "", ) - cell := bash.MakeBashCellFromRaw(fencedCode, "", internal.Output{}) + cell := bash.MakeBashCellFromRaw(fencedCode, "", internal.MakeOutput("", "")) // when gotCell, err := cell.Execute() @@ -70,6 +70,6 @@ func TestBashCell(t *testing.T) { require.NoError(t, err) rendered, err := gotCell.Render() require.NoError(t, err) - assert.Equal(t, fencedCode+internal.MakeOutput("output").Render(""), rendered) + assert.Equal(t, fencedCode+internal.MakeOutput("output", "").Render(), rendered) }) } diff --git a/internal/block.go b/internal/block.go index 3f43a92..c48b0d5 100644 --- a/internal/block.go +++ b/internal/block.go @@ -51,8 +51,37 @@ func (b Block) Content() string { return b.content } func (b Block) Continuation() bool { return b.continuation } -func (b Block) String() string { - return fmt.Sprintf("{%s %q}", b.kind, b.content) +func (b Block) Render() string { + if len(b.indent) == 0 { + return b.content + } + + 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(blankLineIndent) + continue + } + if i > 0 { + rendered.WriteString(renderedIndent) + } else if !b.continuation { + rendered.WriteString(b.indent) + } + rendered.WriteString(line) + } + if strings.HasSuffix(b.content, "\n") { + rendered.WriteByte('\n') + } + return rendered.String() } func MakeBlocksFromMarkdown(content []byte) ([]Block, error) { diff --git a/internal/cell.go b/internal/cell.go index 07fe5c6..625594a 100644 --- a/internal/cell.go +++ b/internal/cell.go @@ -82,44 +82,6 @@ func Classify(blocks []Block, parsers map[string]CellParser) ([]Cell, error) { return cells, nil } -func RenderContent(content, indent string) string { - // todo: double check that I need this function - return RenderBlock(MakeBlockFromRaw(BlockKindFencedCode, content, indent, false)) -} - -func RenderBlock(b Block) string { - if len(b.indent) == 0 { - return b.content - } - - 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(blankLineIndent) - continue - } - if i > 0 { - rendered.WriteString(renderedIndent) - } else if !b.continuation { - rendered.WriteString(b.indent) - } - rendered.WriteString(line) - } - if strings.HasSuffix(b.content, "\n") { - rendered.WriteByte('\n') - } - return rendered.String() -} - func blankBlockQuoteLinePrefix(indent string) string { if idx := strings.LastIndex(indent, ">"); idx >= 0 { return indent[:idx+1] diff --git a/internal/output.go b/internal/output.go index cde5379..967a2bc 100644 --- a/internal/output.go +++ b/internal/output.go @@ -11,28 +11,22 @@ const ( ) type Output struct { - content string + block Block } -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") +func MakeOutput(content, indent string) Output { + if content == "" { + return Output{} } - buf.WriteString(linePrefix + OutputEndMarker + "\n") + linePrefix := RenderIndent(indent) + assembled := "\n" + OutputBeginMarker + "\n" + + strings.TrimSuffix(content, "\n") + "\n" + + OutputEndMarker + "\n" + return Output{block: MakeBlockFromRaw(BlockKindText, assembled, linePrefix, false)} +} - return buf.String() +func (o Output) Render() string { + return o.block.Render() } func isOutputBegin(b Block) bool { @@ -98,7 +92,7 @@ func OutputFromBlocks(litdoc Block, blocks []Block) (Output, int, error) { // 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 + return MakeOutput(buf.String(), litdoc.indent), i, nil } content, err := outputContent(blocks[i], outputIndent) if err != nil { diff --git a/internal/output_test.go b/internal/output_test.go index d93e2f7..ce94650 100644 --- a/internal/output_test.go +++ b/internal/output_test.go @@ -14,10 +14,10 @@ func TestMakeOutput(t *testing.T) { content := "hello" // when - got := internal.MakeOutput(content) + got := internal.MakeOutput(content, "") // then - assert.Contains(t, got.Render(""), content) + assert.Contains(t, got.Render(), content) } func TestOutput_RenderWithIndent(t *testing.T) { @@ -26,10 +26,10 @@ func TestOutput_RenderWithIndent(t *testing.T) { indent := " " // when - got := internal.MakeOutput(content) + got := internal.MakeOutput(content, indent) // then - assert.Contains(t, got.Render(indent), indent+content) + assert.Contains(t, got.Render(), indent+content) } func TestOutput_Render(t *testing.T) { @@ -106,10 +106,10 @@ func TestOutput_Render(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // given - output := internal.MakeOutput(tt.content) + output := internal.MakeOutput(tt.content, tt.indent) // when - got := output.Render(tt.indent) + got := output.Render() // then assert.Equal(t, tt.want, got) @@ -119,7 +119,7 @@ func TestOutput_Render(t *testing.T) { func TestOutputFromBlocks(t *testing.T) { wantOutput := func(indent, content string) string { - return internal.MakeOutput(content).Render(indent) + return internal.MakeOutput(content, indent).Render() } t.Run("output block is scanned in", func(t *testing.T) { @@ -136,7 +136,7 @@ func TestOutputFromBlocks(t *testing.T) { // then require.NoError(t, err) - assert.Equal(t, wantOutput("", "hello"), output.Render("")) + assert.Equal(t, wantOutput("", "hello"), output.Render()) assert.Equal(t, 3, consumed) }) @@ -155,7 +155,7 @@ func TestOutputFromBlocks(t *testing.T) { // then require.NoError(t, err) - assert.Equal(t, wantOutput("", "hello"), output.Render("")) + assert.Equal(t, wantOutput("", "hello"), output.Render()) assert.Equal(t, 4, consumed) }) @@ -173,7 +173,7 @@ func TestOutputFromBlocks(t *testing.T) { // then require.NoError(t, err) - assert.Equal(t, wantOutput(" ", "hello\nworld"), output.Render(" ")) + assert.Equal(t, wantOutput(" ", "hello\nworld"), output.Render()) assert.Equal(t, 3, consumed) }) @@ -197,7 +197,7 @@ func TestOutputFromBlocks(t *testing.T) { // then require.NoError(t, err) - assert.Equal(t, wantOutput(" ", "output"), output.Render(" ")) + assert.Equal(t, wantOutput(" ", "output"), output.Render()) assert.Equal(t, 8, consumed) }) @@ -220,7 +220,7 @@ func TestOutputFromBlocks(t *testing.T) { // then require.NoError(t, err) - assert.Equal(t, wantOutput(" ", "hello\nworld"), output.Render(" ")) + assert.Equal(t, wantOutput(" ", "hello\nworld"), output.Render()) assert.Equal(t, 8, consumed) }) @@ -239,7 +239,7 @@ func TestOutputFromBlocks(t *testing.T) { // then require.NoError(t, err) - assert.Equal(t, wantOutput(" ", "hello"), output.Render(" ")) + assert.Equal(t, wantOutput(" ", "hello"), output.Render()) assert.Equal(t, 4, consumed) }) @@ -336,7 +336,7 @@ func TestOutputFromBlocks(t *testing.T) { // then require.NoError(t, err) - assert.Equal(t, "", output.Render("")) + assert.Equal(t, "", output.Render()) assert.Equal(t, 0, consumed) }) @@ -349,7 +349,7 @@ func TestOutputFromBlocks(t *testing.T) { // then require.NoError(t, err) - assert.Equal(t, "", output.Render("")) + assert.Equal(t, "", output.Render()) assert.Equal(t, 0, consumed) }) diff --git a/internal/static/cell.go b/internal/static/cell.go index 60cff2e..6632231 100644 --- a/internal/static/cell.go +++ b/internal/static/cell.go @@ -3,11 +3,11 @@ package static import "litdoc/internal" type StaticCell struct { - content string + block internal.Block } func MakeStaticCellFromRaw(content string) StaticCell { - return StaticCell{content: content} + return StaticCell{block: internal.MakeBlockFromRaw(internal.BlockKindText, content, "", false)} } func ParseStaticCell( @@ -19,7 +19,7 @@ func ParseStaticCell( error, ) { // todo: test this - return StaticCell{content: internal.RenderBlock(block)}, 0, nil + return StaticCell{block: block}, 0, nil } func (c StaticCell) Execute() (internal.Cell, error) { @@ -29,5 +29,5 @@ func (c StaticCell) Execute() (internal.Cell, error) { func (c StaticCell) Render() (string, error) { // todo: test this - return c.content, nil + return c.block.Render(), nil } diff --git a/internal/static/cell_test.go b/internal/static/cell_test.go index 549fb3f..359b0cf 100644 --- a/internal/static/cell_test.go +++ b/internal/static/cell_test.go @@ -34,3 +34,4 @@ func TestStaticCell(t *testing.T) { assert.Equal(t, cell, gotCell) }) } + From eaa5e4a8d2f53e5fc2b1cabaa01d63f071e8a2ca Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Wed, 6 May 2026 11:48:05 -0600 Subject: [PATCH 04/10] Convert CellParser to interface and add error tests for Classify CellParser is now an interface with a Parse method, with CellParserFunc as a function adapter. Classify wraps errors with context. Error paths in Classify are covered by new tests using a mockery-generated MockCellParser. --- .mockery.yaml | 4 ++ cmd/file.go | 4 +- internal/CellParser_mock_test.go | 112 +++++++++++++++++++++++++++++++ internal/cell.go | 23 +++++-- internal/cell_test.go | 78 +++++++++++++++++++-- internal/file_test.go | 4 +- 6 files changed, 210 insertions(+), 15 deletions(-) create mode 100644 internal/CellParser_mock_test.go diff --git a/.mockery.yaml b/.mockery.yaml index c1a8c3a..ca80148 100644 --- a/.mockery.yaml +++ b/.mockery.yaml @@ -4,6 +4,10 @@ packages: litdoc/internal: interfaces: Cell: + config: + dir: ./internal/ + pkgname: internal_test + CellParser: config: dir: ./internal/ pkgname: internal_test \ No newline at end of file diff --git a/cmd/file.go b/cmd/file.go index 48089eb..c67b27b 100644 --- a/cmd/file.go +++ b/cmd/file.go @@ -21,8 +21,8 @@ var fileCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { path := args[0] parsers := map[string]internal.CellParser{ - "static": static.ParseStaticCell, - "bash": bash.ParseBashCell, + "static": internal.CellParserFunc(static.ParseStaticCell), + "bash": internal.CellParserFunc(bash.ParseBashCell), } data, err := internal.ProcessFile(path, parsers) if err != nil { diff --git a/internal/CellParser_mock_test.go b/internal/CellParser_mock_test.go new file mode 100644 index 0000000..0e4317d --- /dev/null +++ b/internal/CellParser_mock_test.go @@ -0,0 +1,112 @@ +// 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" +) + +// NewMockCellParser creates a new instance of MockCellParser. 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 NewMockCellParser(t interface { + mock.TestingT + Cleanup(func()) +}) *MockCellParser { + mock := &MockCellParser{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockCellParser is an autogenerated mock type for the CellParser type +type MockCellParser struct { + mock.Mock +} + +type MockCellParser_Expecter struct { + mock *mock.Mock +} + +func (_m *MockCellParser) EXPECT() *MockCellParser_Expecter { + return &MockCellParser_Expecter{mock: &_m.Mock} +} + +// Parse provides a mock function for the type MockCellParser +func (_mock *MockCellParser) Parse(block internal.Block, following []internal.Block) (internal.Cell, int, error) { + ret := _mock.Called(block, following) + + if len(ret) == 0 { + panic("no return value specified for Parse") + } + + var r0 internal.Cell + var r1 int + var r2 error + if returnFunc, ok := ret.Get(0).(func(internal.Block, []internal.Block) (internal.Cell, int, error)); ok { + return returnFunc(block, following) + } + if returnFunc, ok := ret.Get(0).(func(internal.Block, []internal.Block) internal.Cell); ok { + r0 = returnFunc(block, following) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(internal.Cell) + } + } + if returnFunc, ok := ret.Get(1).(func(internal.Block, []internal.Block) int); ok { + r1 = returnFunc(block, following) + } else { + r1 = ret.Get(1).(int) + } + if returnFunc, ok := ret.Get(2).(func(internal.Block, []internal.Block) error); ok { + r2 = returnFunc(block, following) + } else { + r2 = ret.Error(2) + } + return r0, r1, r2 +} + +// MockCellParser_Parse_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Parse' +type MockCellParser_Parse_Call struct { + *mock.Call +} + +// Parse is a helper method to define mock.On call +// - block internal.Block +// - following []internal.Block +func (_e *MockCellParser_Expecter) Parse(block interface{}, following interface{}) *MockCellParser_Parse_Call { + return &MockCellParser_Parse_Call{Call: _e.mock.On("Parse", block, following)} +} + +func (_c *MockCellParser_Parse_Call) Run(run func(block internal.Block, following []internal.Block)) *MockCellParser_Parse_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 internal.Block + if args[0] != nil { + arg0 = args[0].(internal.Block) + } + var arg1 []internal.Block + if args[1] != nil { + arg1 = args[1].([]internal.Block) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockCellParser_Parse_Call) Return(cell internal.Cell, n int, err error) *MockCellParser_Parse_Call { + _c.Call.Return(cell, n, err) + return _c +} + +func (_c *MockCellParser_Parse_Call) RunAndReturn(run func(block internal.Block, following []internal.Block) (internal.Cell, int, error)) *MockCellParser_Parse_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/cell.go b/internal/cell.go index 625594a..e802bb7 100644 --- a/internal/cell.go +++ b/internal/cell.go @@ -10,7 +10,15 @@ type Cell interface { Render() (string, error) } -type CellParser func(block Block, following []Block) (Cell, int, error) +type CellParser interface { + Parse(block Block, following []Block) (Cell, int, error) +} + +type CellParserFunc func(block Block, following []Block) (Cell, int, error) + +func (f CellParserFunc) Parse(block Block, following []Block) (Cell, int, error) { + return f(block, following) +} type InfoString struct { Lang string @@ -56,24 +64,24 @@ func Classify(blocks []Block, parsers map[string]CellParser) ([]Cell, error) { if !ok { return nil, fmt.Errorf("unsupported language: %q", info.Lang) } - cell, consumed, err := parser(b, blocks[i+1:]) + cell, consumed, err := parser.Parse(b, blocks[i+1:]) if err != nil { - return nil, err + return nil, fmt.Errorf("parsing %q cell: %w", info.Lang, err) } cells = append(cells, cell) i += 1 + consumed continue default: - cell, _, err := static(b, nil) + cell, _, err := static.Parse(b, nil) if err != nil { - return nil, err + return nil, fmt.Errorf("parsing static, non-litdoc cell: %w", err) } cells = append(cells, cell) } default: - cell, _, err := static(b, nil) + cell, _, err := static.Parse(b, nil) if err != nil { - return nil, err + return nil, fmt.Errorf("parsing static cell: %w", err) } cells = append(cells, cell) } @@ -90,6 +98,7 @@ func blankBlockQuoteLinePrefix(indent string) string { } func RenderIndent(indent string) string { + // todo: should this be in this file? if idx := strings.LastIndex(indent, "> "); idx >= 0 { prefixLen := idx + len("> ") return indent[:prefixLen] + strings.Repeat(" ", len(indent)-prefixLen) diff --git a/internal/cell_test.go b/internal/cell_test.go index 917206f..1adafc9 100644 --- a/internal/cell_test.go +++ b/internal/cell_test.go @@ -8,10 +8,10 @@ import ( "litdoc/internal/static" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) - func TestParseInfoString(t *testing.T) { tests := []struct { name string @@ -159,7 +159,6 @@ func TestParseInfoString(t *testing.T) { } } - func TestExecute(t *testing.T) { t.Run("happy path", func(t *testing.T) { // given @@ -505,8 +504,8 @@ func TestClassify(t *testing.T) { } parsers := map[string]internal.CellParser{ - "static": static.ParseStaticCell, - "bash": bash.ParseBashCell, + "static": internal.CellParserFunc(static.ParseStaticCell), + "bash": internal.CellParserFunc(bash.ParseBashCell), } for _, tt := range tests { @@ -536,6 +535,77 @@ func TestClassify(t *testing.T) { assert.Equal(t, wantComposed, gotComposed) }) } + + t.Run("no static parser", func(t *testing.T) { + // when + _, err := internal.Classify(nil, map[string]internal.CellParser{}) + + // then + require.ErrorContains(t, err, "no static parser provided") + }) + + t.Run("litdoc parser fails", func(t *testing.T) { + // given + blocks := []internal.Block{ + code("", "```bash | litdoc\necho hello\n```\n", false), + } + failingParser := NewMockCellParser(t) + failingParser.EXPECT(). + Parse(mock.Anything, mock.Anything). + Return(nil, 0, assert.AnError) + + // when + _, err := internal.Classify(blocks, map[string]internal.CellParser{ + "static": internal.CellParserFunc(static.ParseStaticCell), + "bash": failingParser, + }) + + // then + require.ErrorContains(t, err, `parsing "bash" cell`) + require.ErrorIs(t, err, assert.AnError) + }) + + t.Run("static parser fails", func(t *testing.T) { + t.Run("non-litdoc fenced code", func(t *testing.T) { + // given + blocks := []internal.Block{ + code("", "```bash\necho hello\n```\n", false), + } + failingStatic := NewMockCellParser(t) + failingStatic.EXPECT(). + Parse(mock.Anything, mock.Anything). + Return(nil, 0, assert.AnError) + + // when + _, err := internal.Classify(blocks, map[string]internal.CellParser{ + "static": failingStatic, + "bash": internal.CellParserFunc(bash.ParseBashCell), + }) + + // then + require.ErrorContains(t, err, "parsing static, non-litdoc cell") + require.ErrorIs(t, err, assert.AnError) + }) + + t.Run("default block", func(t *testing.T) { + // given + blocks := []internal.Block{text("", "hello", false)} + failingStatic := NewMockCellParser(t) + failingStatic.EXPECT(). + Parse(mock.Anything, mock.Anything). + Return(nil, 0, assert.AnError) + + // when + _, err := internal.Classify(blocks, map[string]internal.CellParser{ + "static": failingStatic, + "bash": internal.CellParserFunc(bash.ParseBashCell), + }) + + // then + require.ErrorContains(t, err, "parsing static cell") + require.ErrorIs(t, err, assert.AnError) + }) + }) } func cellKind(cell internal.Cell) string { diff --git a/internal/file_test.go b/internal/file_test.go index 1234c8b..89c6111 100644 --- a/internal/file_test.go +++ b/internal/file_test.go @@ -42,7 +42,7 @@ func TestProcessFile(t *testing.T) { // when // todo: pull these definitions to given got, err := internal.ProcessFile( - f.Name(), map[string]internal.CellParser{"static": static.ParseStaticCell, "bash": bash.ParseBashCell}) + f.Name(), map[string]internal.CellParser{"static": internal.CellParserFunc(static.ParseStaticCell), "bash": internal.CellParserFunc(bash.ParseBashCell)}) // then require.NoError(t, err) @@ -62,7 +62,7 @@ func TestProcessFileNestedListIndent(t *testing.T) { require.NoError(t, err) // when - got, err := internal.ProcessFile(f.Name(), map[string]internal.CellParser{"static": static.ParseStaticCell, "bash": bash.ParseBashCell}) + got, err := internal.ProcessFile(f.Name(), map[string]internal.CellParser{"static": internal.CellParserFunc(static.ParseStaticCell), "bash": internal.CellParserFunc(bash.ParseBashCell)}) // then require.NoError(t, err) From 37e7be26ce867cc59514fccb12b412fe4711cc02 Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Wed, 6 May 2026 11:54:17 -0600 Subject: [PATCH 05/10] Move info string extraction to Block.rawInfoString and rename to InfoStringFromBlock --- internal/block.go | 15 +++++++++++++++ internal/cell.go | 18 ++++-------------- internal/cell_test.go | 4 ++-- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/internal/block.go b/internal/block.go index c48b0d5..8a7401d 100644 --- a/internal/block.go +++ b/internal/block.go @@ -51,6 +51,21 @@ func (b Block) Content() string { return b.content } func (b Block) Continuation() bool { return b.continuation } +func (b Block) rawInfoString() string { + firstLine := b.content + if i := strings.IndexByte(b.content, '\n'); i >= 0 { + firstLine = b.content[:i] + } + switch b.kind { + case BlockKindFencedCode: + return strings.TrimLeft(firstLine, "`~") + case BlockKindHTMLComment: + return strings.TrimSpace(strings.TrimPrefix(firstLine, "