From fc2e666bb2c5b1d63291f0af1dcc562e3d6655f1 Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Wed, 6 May 2026 14:17:41 -0600 Subject: [PATCH 1/6] Execute bash cells and capture output --- .mockery.yaml | 6 +- internal/bash/Runner_mock_test.go | 108 ++++++++++++++++++++++++++++++ internal/bash/cell.go | 34 ++++++++-- internal/bash/cell_test.go | 61 +++++++++++++---- internal/bash/runner.go | 9 +++ internal/runner.go | 34 ++++++++++ internal/testdata/output.md | 54 +++++++-------- 7 files changed, 260 insertions(+), 46 deletions(-) create mode 100644 internal/bash/Runner_mock_test.go create mode 100644 internal/bash/runner.go create mode 100644 internal/runner.go diff --git a/.mockery.yaml b/.mockery.yaml index ca80148..609938a 100644 --- a/.mockery.yaml +++ b/.mockery.yaml @@ -10,4 +10,8 @@ packages: CellParser: config: dir: ./internal/ - pkgname: internal_test \ No newline at end of file + pkgname: internal_test + Runner: + config: + dir: ./internal/bash/ + pkgname: bash_test \ No newline at end of file diff --git a/internal/bash/Runner_mock_test.go b/internal/bash/Runner_mock_test.go new file mode 100644 index 0000000..6535a40 --- /dev/null +++ b/internal/bash/Runner_mock_test.go @@ -0,0 +1,108 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package bash_test + +import ( + mock "github.com/stretchr/testify/mock" +) + +// NewMockRunner creates a new instance of MockRunner. 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 NewMockRunner(t interface { + mock.TestingT + Cleanup(func()) +}) *MockRunner { + mock := &MockRunner{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockRunner is an autogenerated mock type for the Runner type +type MockRunner struct { + mock.Mock +} + +type MockRunner_Expecter struct { + mock *mock.Mock +} + +func (_m *MockRunner) EXPECT() *MockRunner_Expecter { + return &MockRunner_Expecter{mock: &_m.Mock} +} + +// Run provides a mock function for the type MockRunner +func (_mock *MockRunner) Run(script string) (string, string, int, error) { + ret := _mock.Called(script) + + if len(ret) == 0 { + panic("no return value specified for Run") + } + + var r0 string + var r1 string + var r2 int + var r3 error + if returnFunc, ok := ret.Get(0).(func(string) (string, string, int, error)); ok { + return returnFunc(script) + } + if returnFunc, ok := ret.Get(0).(func(string) string); ok { + r0 = returnFunc(script) + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func(string) string); ok { + r1 = returnFunc(script) + } else { + r1 = ret.Get(1).(string) + } + if returnFunc, ok := ret.Get(2).(func(string) int); ok { + r2 = returnFunc(script) + } else { + r2 = ret.Get(2).(int) + } + if returnFunc, ok := ret.Get(3).(func(string) error); ok { + r3 = returnFunc(script) + } else { + r3 = ret.Error(3) + } + return r0, r1, r2, r3 +} + +// MockRunner_Run_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Run' +type MockRunner_Run_Call struct { + *mock.Call +} + +// Run is a helper method to define mock.On call +// - script string +func (_e *MockRunner_Expecter) Run(script interface{}) *MockRunner_Run_Call { + return &MockRunner_Run_Call{Call: _e.mock.On("Run", script)} +} + +func (_c *MockRunner_Run_Call) Run(run func(script string)) *MockRunner_Run_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 string + if args[0] != nil { + arg0 = args[0].(string) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockRunner_Run_Call) Return(stdout string, stderr string, exitCode int, err error) *MockRunner_Run_Call { + _c.Call.Return(stdout, stderr, exitCode, err) + return _c +} + +func (_c *MockRunner_Run_Call) RunAndReturn(run func(script string) (string, string, int, error)) *MockRunner_Run_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/bash/cell.go b/internal/bash/cell.go index 0292b1c..34501a6 100644 --- a/internal/bash/cell.go +++ b/internal/bash/cell.go @@ -2,6 +2,7 @@ package bash import ( "fmt" + "strings" "litdoc/internal" ) @@ -9,12 +10,18 @@ import ( type Cell struct { block internal.Block output internal.Output + runner internal.Runner } -func MakeCellFromRaw(content, indent string, output internal.Output) Cell { +func MakeCellFromRaw( + content, indent string, + output internal.Output, + runner internal.Runner, +) Cell { return Cell{ block: internal.MakeBlockFromRaw(internal.BlockKindFencedCode, content, indent, false), output: output, + runner: runner, } } @@ -26,13 +33,14 @@ func ParseCell( int, error, ) { - return parseCellWith(block, following, internal.OutputFromBlocks) + return parseCellWith(block, following, internal.OutputFromBlocks, Runner{}) } func parseCellWith( block internal.Block, following []internal.Block, parseOutput func(internal.Block, []internal.Block) (internal.Output, int, error), + runner internal.Runner, ) ( internal.Cell, int, @@ -42,16 +50,34 @@ func parseCellWith( if err != nil { return nil, 0, fmt.Errorf("parsing output: %w", err) } - return Cell{block: block, output: output}, consumed, nil + return Cell{block: block, output: output, runner: runner}, consumed, nil } func (c Cell) Execute() (internal.Cell, error) { + stdout, stderr, exitCode, err := c.runner.Run(codeBody(c.block.Content())) + if err != nil { + return nil, err + } + if exitCode != 0 { + return nil, fmt.Errorf("exit status %d: %s", exitCode, strings.TrimSpace(stderr)) + } return Cell{ block: c.block, - output: internal.MakeOutput("output", c.block.Indent()), + runner: c.runner, + output: internal.MakeOutput(stdout, c.block.Indent()), }, nil } func (c Cell) Render() (string, error) { return c.block.Render() + c.output.Render(), nil } + +// codeBody extracts the script lines from a fenced code block's content, +// stripping the opening and closing fence lines. +func codeBody(content string) string { + lines := strings.Split(content, "\n") + if len(lines) < 3 { + return "" + } + return strings.Join(lines[1:len(lines)-2], "\n") + "\n" +} diff --git a/internal/bash/cell_test.go b/internal/bash/cell_test.go index b7ad715..a605b28 100644 --- a/internal/bash/cell_test.go +++ b/internal/bash/cell_test.go @@ -15,6 +15,7 @@ func joinLines(lines ...string) string { return strings.Join(lines, "\n") } + func TestMakeCellFromRaw(t *testing.T) { // given code := joinLines( @@ -24,7 +25,7 @@ func TestMakeCellFromRaw(t *testing.T) { "", ) output := internal.MakeOutput("hello", "") - cell := bash.MakeCellFromRaw(code, "", output) + cell := bash.MakeCellFromRaw(code, "", output, nil) // when got, err := cell.Render() @@ -50,7 +51,7 @@ func TestParseCellWith(t *testing.T) { } // when - cell, consumed, err := bash.ParseCellWith(block, nil, parseOutput) + cell, consumed, err := bash.ParseCellWith(block, nil, parseOutput, nil) // then require.NoError(t, err) @@ -67,7 +68,7 @@ func TestParseCellWith(t *testing.T) { } // when - _, _, err := bash.ParseCellWith(block, nil, parseOutput) + _, _, err := bash.ParseCellWith(block, nil, parseOutput, nil) // then require.ErrorContains(t, err, "parsing output") @@ -84,7 +85,7 @@ func TestRender(t *testing.T) { "```", "", ) - cell := bash.MakeCellFromRaw(code, "", internal.MakeOutput("", "")) + cell := bash.MakeCellFromRaw(code, "", internal.MakeOutput("", ""), nil) // when gotContent, err := cell.Render() @@ -103,7 +104,7 @@ func TestRender(t *testing.T) { "", ) output := internal.MakeOutput("hello", "") - cell := bash.MakeCellFromRaw(fencedCode, "", output) + cell := bash.MakeCellFromRaw(fencedCode, "", output, nil) // when gotContent, err := cell.Render() @@ -115,21 +116,53 @@ func TestRender(t *testing.T) { } func TestExecute(t *testing.T) { - // given fencedCode := joinLines( "```bash", "echo hello", "```", "", ) - cell := bash.MakeCellFromRaw(fencedCode, "", internal.MakeOutput("", "")) - // when - gotCell, err := cell.Execute() + t.Run("success", func(t *testing.T) { + // given + runner := NewMockRunner(t) + runner.EXPECT().Run("echo hello\n").Return("hello\n", "", 0, nil) + cell := bash.MakeCellFromRaw(fencedCode, "", internal.MakeOutput("", ""), runner) - // then - require.NoError(t, err) - rendered, err := gotCell.Render() - require.NoError(t, err) - assert.Equal(t, fencedCode+internal.MakeOutput("output", "").Render(), rendered) + // when + gotCell, err := cell.Execute() + + // then + require.NoError(t, err) + rendered, err := gotCell.Render() + require.NoError(t, err) + assert.Equal(t, fencedCode+internal.MakeOutput("hello\n", "").Render(), rendered) + }) + + t.Run("non-zero exit code", func(t *testing.T) { + // given + runner := NewMockRunner(t) + runner.EXPECT().Run("echo hello\n").Return("", "bash: command not found\n", 127, nil) + cell := bash.MakeCellFromRaw(fencedCode, "", internal.MakeOutput("", ""), runner) + + // when + _, err := cell.Execute() + + // then + require.ErrorContains(t, err, "exit status 127") + require.ErrorContains(t, err, "bash: command not found") + }) + + t.Run("exec error", func(t *testing.T) { + // given + runner := NewMockRunner(t) + runner.EXPECT().Run("echo hello\n").Return("", "", 0, assert.AnError) + cell := bash.MakeCellFromRaw(fencedCode, "", internal.MakeOutput("", ""), runner) + + // when + _, err := cell.Execute() + + // then + require.ErrorIs(t, err, assert.AnError) + }) } diff --git a/internal/bash/runner.go b/internal/bash/runner.go new file mode 100644 index 0000000..b3791b3 --- /dev/null +++ b/internal/bash/runner.go @@ -0,0 +1,9 @@ +package bash + +import "litdoc/internal" + +type Runner struct{} + +func (r Runner) Run(script string) (string, string, int, error) { + return internal.RunCmd("bash", "-c", script) +} diff --git a/internal/runner.go b/internal/runner.go new file mode 100644 index 0000000..a9a5441 --- /dev/null +++ b/internal/runner.go @@ -0,0 +1,34 @@ +package internal + +import ( + "bytes" + "errors" + "fmt" + "os/exec" +) + +type Runner interface { + Run(script string) (stdout, stderr string, exitCode int, err error) +} + +func RunCmd(name string, args ...string) (string, string, int, error) { + var outBuf, errBuf bytes.Buffer + var exitCode int + + cmd := exec.Command(name, args...) + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + + err := cmd.Run() + + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + exitCode = exitErr.ExitCode() + } else { + return "", "", 0, fmt.Errorf("running command: %w", err) + } + } + + return outBuf.String(), errBuf.String(), exitCode, nil +} diff --git a/internal/testdata/output.md b/internal/testdata/output.md index 5e08ea5..83bbaae 100644 --- a/internal/testdata/output.md +++ b/internal/testdata/output.md @@ -33,7 +33,7 @@ echo "hello, world" ``` -output +hello, world - HTML comment @@ -43,7 +43,7 @@ echo "something to run" --> -output +something to run - Fenced code block with previously generated output @@ -53,7 +53,7 @@ echo "hello, world" ``` -output +hello, world - Indented code block @@ -63,7 +63,7 @@ output ``` - output + hello, world - Indented code block with previously generated output @@ -73,7 +73,7 @@ output ``` - output + hello, world > Block quoted fenced code block with previously generated output @@ -83,7 +83,7 @@ output > ``` > > -> output +> hello, world > ### Fenced code block indentation cases @@ -95,7 +95,7 @@ Fenced code block indented one space: ``` - output + hello, world Fenced code block indented three spaces: @@ -105,7 +105,7 @@ Fenced code block indented three spaces: ``` - output + hello, world > Block quoted fenced code block @@ -115,7 +115,7 @@ Fenced code block indented three spaces: > ``` > > -> output +> hello, world > > Nested block quoted fenced code block @@ -125,7 +125,7 @@ Fenced code block indented three spaces: > > ``` > > > > -> > output +> > hello, world > > Fenced code block in an unordered list: @@ -135,7 +135,7 @@ Fenced code block in an unordered list: ``` - output + hello, world Fenced code block in a nested unordered list: @@ -145,7 +145,7 @@ Fenced code block in a nested unordered list: ``` - output + hello, world Fenced code block in a plus list with a tilde fence: @@ -155,7 +155,7 @@ Fenced code block in a plus list with a tilde fence: ~~~ - output + hello, world Fenced code block in an ordered list: @@ -165,7 +165,7 @@ Fenced code block in an ordered list: ``` - output + hello, world Fenced code block in a nested ordered list: @@ -175,7 +175,7 @@ Fenced code block in a nested ordered list: ``` - output + hello, world Fenced code block in a list blockquote: @@ -185,7 +185,7 @@ Fenced code block in a list blockquote: > ``` > > - > output + > hello, world > > Fenced code block in a blockquote nested list: @@ -195,7 +195,7 @@ Fenced code block in a list blockquote: > ``` > > -> output +> hello, world > > Fenced code block in a blockquote ordered list: @@ -205,7 +205,7 @@ Fenced code block in a list blockquote: > ``` > > -> output +> hello, world > ### HTML comment indentation cases @@ -217,7 +217,7 @@ Fenced code block in a list blockquote: > --> > > -> output +> hello, world > > Nested block quoted HTML comment @@ -227,7 +227,7 @@ Fenced code block in a list blockquote: > > --> > > > > -> > output +> > hello, world > > HTML comment in an unordered list: @@ -237,7 +237,7 @@ HTML comment in an unordered list: --> - output + hello, world HTML comment in a nested unordered list: @@ -247,7 +247,7 @@ HTML comment in a nested unordered list: --> - output + hello, world HTML comment in a star list: @@ -257,7 +257,7 @@ HTML comment in a star list: --> - output + hello, world HTML comment in an ordered list: @@ -267,7 +267,7 @@ HTML comment in an ordered list: --> - output + hello, world > HTML comment in a blockquote list: @@ -277,7 +277,7 @@ HTML comment in an ordered list: > --> > > -> output +> hello, world > > HTML comment in a nested blockquote list: @@ -287,7 +287,7 @@ HTML comment in an ordered list: > > --> > > > > -> > output +> > hello, world > > HTML comment in an ordered-list blockquote: @@ -297,5 +297,5 @@ HTML comment in an ordered-list blockquote: > --> > > - > output + > hello, world > From 867a3d97927c123c7d1397939f060c9a6caade4e Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Wed, 6 May 2026 14:34:54 -0600 Subject: [PATCH 2/6] [Refactor] Introduce Parser structs for bash and static, remove CellParserFunc --- cmd/file.go | 4 ++-- internal/bash/cell.go | 33 ++++++++++++++++++--------------- internal/bash/cell_test.go | 9 +++++---- internal/bash/export_test.go | 3 ++- internal/cell.go | 5 ----- internal/cell_test.go | 10 +++++----- internal/file_test.go | 4 ++-- internal/static/cell.go | 10 ++++++++-- internal/static/cell_test.go | 4 ++-- internal/static/export_test.go | 3 +++ 10 files changed, 47 insertions(+), 38 deletions(-) create mode 100644 internal/static/export_test.go diff --git a/cmd/file.go b/cmd/file.go index 9c2de53..aa76c29 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": internal.CellParserFunc(static.ParseCell), - "bash": internal.CellParserFunc(bash.ParseCell), + "static": static.NewParser(), + "bash": bash.NewParser(bash.Runner{}), } data, err := internal.ProcessFile(path, parsers) if err != nil { diff --git a/internal/bash/cell.go b/internal/bash/cell.go index 34501a6..4868e52 100644 --- a/internal/bash/cell.go +++ b/internal/bash/cell.go @@ -13,7 +13,7 @@ type Cell struct { runner internal.Runner } -func MakeCellFromRaw( +func makeCellFromRaw( content, indent string, output internal.Output, runner internal.Runner, @@ -25,32 +25,35 @@ func MakeCellFromRaw( } } -func ParseCell( - block internal.Block, - following []internal.Block, -) ( - internal.Cell, - int, - error, -) { - return parseCellWith(block, following, internal.OutputFromBlocks, Runner{}) +type Parser struct { + runner internal.Runner + parseOutput func(internal.Block, []internal.Block) (internal.Output, int, error) } -func parseCellWith( +func NewParser(runner internal.Runner) Parser { + return Parser{runner: runner, parseOutput: internal.OutputFromBlocks} +} + +func newParserWith( + runner internal.Runner, + parseOutput func(internal.Block, []internal.Block) (internal.Output, int, error), +) Parser { + return Parser{runner: runner, parseOutput: parseOutput} +} + +func (p Parser) Parse( block internal.Block, following []internal.Block, - parseOutput func(internal.Block, []internal.Block) (internal.Output, int, error), - runner internal.Runner, ) ( internal.Cell, int, error, ) { - output, consumed, err := parseOutput(block, following) + output, consumed, err := p.parseOutput(block, following) if err != nil { return nil, 0, fmt.Errorf("parsing output: %w", err) } - return Cell{block: block, output: output, runner: runner}, consumed, nil + return Cell{block: block, output: output, runner: p.runner}, consumed, nil } func (c Cell) Execute() (internal.Cell, error) { diff --git a/internal/bash/cell_test.go b/internal/bash/cell_test.go index a605b28..0579eb4 100644 --- a/internal/bash/cell_test.go +++ b/internal/bash/cell_test.go @@ -15,7 +15,6 @@ func joinLines(lines ...string) string { return strings.Join(lines, "\n") } - func TestMakeCellFromRaw(t *testing.T) { // given code := joinLines( @@ -35,7 +34,7 @@ func TestMakeCellFromRaw(t *testing.T) { assert.Equal(t, "```bash\necho hello\n```\n"+output.Render(), got) } -func TestParseCellWith(t *testing.T) { +func TestParse(t *testing.T) { block := internal.MakeBlockFromRaw(internal.BlockKindFencedCode, joinLines( "```bash", "echo hello", @@ -49,9 +48,10 @@ func TestParseCellWith(t *testing.T) { parseOutput := func(internal.Block, []internal.Block) (internal.Output, int, error) { return output, 3, nil } + parser := bash.NewParserWith(nil, parseOutput) // when - cell, consumed, err := bash.ParseCellWith(block, nil, parseOutput, nil) + cell, consumed, err := parser.Parse(block, nil) // then require.NoError(t, err) @@ -66,9 +66,10 @@ func TestParseCellWith(t *testing.T) { parseOutput := func(internal.Block, []internal.Block) (internal.Output, int, error) { return internal.Output{}, 0, assert.AnError } + parser := bash.NewParserWith(nil, parseOutput) // when - _, _, err := bash.ParseCellWith(block, nil, parseOutput, nil) + _, _, err := parser.Parse(block, nil) // then require.ErrorContains(t, err, "parsing output") diff --git a/internal/bash/export_test.go b/internal/bash/export_test.go index 4d90090..ca9231a 100644 --- a/internal/bash/export_test.go +++ b/internal/bash/export_test.go @@ -1,3 +1,4 @@ package bash -var ParseCellWith = parseCellWith +var MakeCellFromRaw = makeCellFromRaw +var NewParserWith = newParserWith diff --git a/internal/cell.go b/internal/cell.go index 0d71913..217d2fa 100644 --- a/internal/cell.go +++ b/internal/cell.go @@ -14,11 +14,6 @@ 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 diff --git a/internal/cell_test.go b/internal/cell_test.go index b1feced..fb549ba 100644 --- a/internal/cell_test.go +++ b/internal/cell_test.go @@ -440,8 +440,8 @@ func TestClassify(t *testing.T) { } parsers := map[string]internal.CellParser{ - "static": internal.CellParserFunc(static.ParseCell), - "bash": internal.CellParserFunc(bash.ParseCell), + "static": static.NewParser(), + "bash": bash.NewParser(bash.Runner{}), } for _, tt := range tests { @@ -492,7 +492,7 @@ func TestClassify(t *testing.T) { // when _, err := internal.Classify(blocks, map[string]internal.CellParser{ - "static": internal.CellParserFunc(static.ParseCell), + "static": static.NewParser(), "bash": failingParser, }) @@ -515,7 +515,7 @@ func TestClassify(t *testing.T) { // when _, err := internal.Classify(blocks, map[string]internal.CellParser{ "static": failingStatic, - "bash": internal.CellParserFunc(bash.ParseCell), + "bash": bash.NewParser(bash.Runner{}), }) // then @@ -534,7 +534,7 @@ func TestClassify(t *testing.T) { // when _, err := internal.Classify(blocks, map[string]internal.CellParser{ "static": failingStatic, - "bash": internal.CellParserFunc(bash.ParseCell), + "bash": bash.NewParser(bash.Runner{}), }) // then diff --git a/internal/file_test.go b/internal/file_test.go index 9b8bf4d..2c1df06 100644 --- a/internal/file_test.go +++ b/internal/file_test.go @@ -42,8 +42,8 @@ func TestProcessFile(t *testing.T) { // when got, err := internal.ProcessFile( f.Name(), map[string]internal.CellParser{ - "static": internal.CellParserFunc(static.ParseCell), - "bash": internal.CellParserFunc(bash.ParseCell), + "static": static.NewParser(), + "bash": bash.NewParser(bash.Runner{}), }) // then diff --git a/internal/static/cell.go b/internal/static/cell.go index c374e05..83eac9a 100644 --- a/internal/static/cell.go +++ b/internal/static/cell.go @@ -6,11 +6,17 @@ type Cell struct { block internal.Block } -func MakeCellFromRaw(content string) Cell { +func makeCellFromRaw(content string) Cell { return Cell{block: internal.MakeBlockFromRaw(internal.BlockKindText, content, "", false)} } -func ParseCell( +type Parser struct{} + +func NewParser() Parser { + return Parser{} +} + +func (p Parser) Parse( block internal.Block, _ []internal.Block, ) ( diff --git a/internal/static/cell_test.go b/internal/static/cell_test.go index e594e04..a7119b7 100644 --- a/internal/static/cell_test.go +++ b/internal/static/cell_test.go @@ -22,12 +22,12 @@ func TestMakeCellFromRaw(t *testing.T) { assert.Equal(t, "hello", got) } -func TestParseCell(t *testing.T) { +func TestParse(t *testing.T) { // given block := internal.MakeBlockFromRaw(internal.BlockKindText, "hello", "", false) // when - cell, consumed, err := static.ParseCell(block, nil) + cell, consumed, err := static.NewParser().Parse(block, nil) // then require.NoError(t, err) diff --git a/internal/static/export_test.go b/internal/static/export_test.go new file mode 100644 index 0000000..8d59a41 --- /dev/null +++ b/internal/static/export_test.go @@ -0,0 +1,3 @@ +package static + +var MakeCellFromRaw = makeCellFromRaw From e499058a39611c3a0a3073c5da179feeeadfb80f Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Wed, 6 May 2026 15:02:10 -0600 Subject: [PATCH 3/6] [Refactor] Clean up parser API and test conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MakeParser() replaces NewParser(runner) — callers no longer need to import or reference bash.Runner directly. The internal constructor makeParserFromRaw (formerly newParserWith) is the single path for building a Parser, used by both MakeParser and the test export. Constructor tests (TestMakeCellFromRaw, TestMakeParserFromRaw) move to cell_private_test.go files in package bash/static so they can assert unexported fields directly rather than going through Render(). Test functions renamed to the TestTypeName_MethodName convention: TestParser_Parse, TestCell_Render, TestCell_Execute. --- cmd/file.go | 2 +- internal/bash/cell.go | 7 +- internal/bash/cell_private_test.go | 38 +++++++++ internal/bash/cell_test.go | 113 ++++++++++++--------------- internal/bash/export_test.go | 2 +- internal/cell.go | 1 - internal/cell_test.go | 6 +- internal/file_test.go | 2 +- internal/static/cell_private_test.go | 21 +++++ internal/static/cell_test.go | 16 +--- 10 files changed, 123 insertions(+), 85 deletions(-) create mode 100644 internal/bash/cell_private_test.go create mode 100644 internal/static/cell_private_test.go diff --git a/cmd/file.go b/cmd/file.go index aa76c29..7af2913 100644 --- a/cmd/file.go +++ b/cmd/file.go @@ -22,7 +22,7 @@ var fileCmd = &cobra.Command{ path := args[0] parsers := map[string]internal.CellParser{ "static": static.NewParser(), - "bash": bash.NewParser(bash.Runner{}), + "bash": bash.MakeParser(), } data, err := internal.ProcessFile(path, parsers) if err != nil { diff --git a/internal/bash/cell.go b/internal/bash/cell.go index 4868e52..55164fe 100644 --- a/internal/bash/cell.go +++ b/internal/bash/cell.go @@ -30,11 +30,11 @@ type Parser struct { parseOutput func(internal.Block, []internal.Block) (internal.Output, int, error) } -func NewParser(runner internal.Runner) Parser { - return Parser{runner: runner, parseOutput: internal.OutputFromBlocks} +func MakeParser() Parser { + return makeParserFromRaw(Runner{}, internal.OutputFromBlocks) } -func newParserWith( +func makeParserFromRaw( runner internal.Runner, parseOutput func(internal.Block, []internal.Block) (internal.Output, int, error), ) Parser { @@ -78,6 +78,7 @@ func (c Cell) Render() (string, error) { // codeBody extracts the script lines from a fenced code block's content, // stripping the opening and closing fence lines. func codeBody(content string) string { + // todo: does this need to be more robust? lines := strings.Split(content, "\n") if len(lines) < 3 { return "" diff --git a/internal/bash/cell_private_test.go b/internal/bash/cell_private_test.go new file mode 100644 index 0000000..a70f139 --- /dev/null +++ b/internal/bash/cell_private_test.go @@ -0,0 +1,38 @@ +package bash + +import ( + "testing" + + "litdoc/internal" + + "github.com/stretchr/testify/assert" +) + +func TestMakeCellFromRaw(t *testing.T) { + // given + content := "```bash\necho hello\n```\n" + indent := " " + output := internal.MakeOutput("hello", "") + runner := Runner{} + block := internal.MakeBlockFromRaw(internal.BlockKindFencedCode, content, indent, false) + + // when + c := makeCellFromRaw(content, indent, output, runner) + + // then + assert.Equal(t, block, c.block) + assert.Equal(t, output, c.output) + assert.Equal(t, runner, c.runner) +} + +func TestMakeParserFromRaw(t *testing.T) { + // given + runner := Runner{} + + // when + p := makeParserFromRaw(runner, internal.OutputFromBlocks) + + // then + assert.Equal(t, runner, p.runner) + assert.NotNil(t, p.parseOutput) +} diff --git a/internal/bash/cell_test.go b/internal/bash/cell_test.go index 0579eb4..7011fce 100644 --- a/internal/bash/cell_test.go +++ b/internal/bash/cell_test.go @@ -15,26 +15,7 @@ func joinLines(lines ...string) string { return strings.Join(lines, "\n") } -func TestMakeCellFromRaw(t *testing.T) { - // given - code := joinLines( - "```bash", - "echo hello", - "```", - "", - ) - output := internal.MakeOutput("hello", "") - cell := bash.MakeCellFromRaw(code, "", output, nil) - - // when - got, err := cell.Render() - - // then - require.NoError(t, err) - assert.Equal(t, "```bash\necho hello\n```\n"+output.Render(), got) -} - -func TestParse(t *testing.T) { +func TestParser_Parse(t *testing.T) { block := internal.MakeBlockFromRaw(internal.BlockKindFencedCode, joinLines( "```bash", "echo hello", @@ -45,7 +26,11 @@ func TestParse(t *testing.T) { t.Run("assembles cell from block and output", func(t *testing.T) { // given output := internal.MakeOutput("hello", "") - parseOutput := func(internal.Block, []internal.Block) (internal.Output, int, error) { + parseOutput := func(internal.Block, []internal.Block) ( + internal.Output, + int, + error, + ) { return output, 3, nil } parser := bash.NewParserWith(nil, parseOutput) @@ -63,7 +48,11 @@ func TestParse(t *testing.T) { t.Run("output parsing error is wrapped", func(t *testing.T) { // given - parseOutput := func(internal.Block, []internal.Block) (internal.Output, int, error) { + parseOutput := func(internal.Block, []internal.Block) ( + internal.Output, + int, + error, + ) { return internal.Output{}, 0, assert.AnError } parser := bash.NewParserWith(nil, parseOutput) @@ -77,46 +66,7 @@ func TestParse(t *testing.T) { }) } -func TestRender(t *testing.T) { - t.Run("without output", func(t *testing.T) { - // given - code := joinLines( - "```bash", - "echo hello", - "```", - "", - ) - cell := bash.MakeCellFromRaw(code, "", internal.MakeOutput("", ""), nil) - - // 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.MakeCellFromRaw(fencedCode, "", output, nil) - - // when - gotContent, err := cell.Render() - - // then - require.NoError(t, err) - assert.Equal(t, fencedCode+output.Render(), gotContent) - }) -} - -func TestExecute(t *testing.T) { +func TestCell_Execute(t *testing.T) { fencedCode := joinLines( "```bash", "echo hello", @@ -167,3 +117,42 @@ func TestExecute(t *testing.T) { require.ErrorIs(t, err, assert.AnError) }) } + +func TestCell_Render(t *testing.T) { + t.Run("without output", func(t *testing.T) { + // given + code := joinLines( + "```bash", + "echo hello", + "```", + "", + ) + cell := bash.MakeCellFromRaw(code, "", internal.MakeOutput("", ""), nil) + + // 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.MakeCellFromRaw(fencedCode, "", output, nil) + + // when + gotContent, err := cell.Render() + + // then + require.NoError(t, err) + assert.Equal(t, fencedCode+output.Render(), gotContent) + }) +} diff --git a/internal/bash/export_test.go b/internal/bash/export_test.go index ca9231a..e70aa1e 100644 --- a/internal/bash/export_test.go +++ b/internal/bash/export_test.go @@ -1,4 +1,4 @@ package bash var MakeCellFromRaw = makeCellFromRaw -var NewParserWith = newParserWith +var NewParserWith = makeParserFromRaw diff --git a/internal/cell.go b/internal/cell.go index 217d2fa..940367e 100644 --- a/internal/cell.go +++ b/internal/cell.go @@ -14,7 +14,6 @@ type CellParser interface { Parse(block Block, following []Block) (Cell, int, error) } - type InfoString struct { Lang string Litdoc bool diff --git a/internal/cell_test.go b/internal/cell_test.go index fb549ba..71cdedc 100644 --- a/internal/cell_test.go +++ b/internal/cell_test.go @@ -441,7 +441,7 @@ func TestClassify(t *testing.T) { parsers := map[string]internal.CellParser{ "static": static.NewParser(), - "bash": bash.NewParser(bash.Runner{}), + "bash": bash.MakeParser(), } for _, tt := range tests { @@ -515,7 +515,7 @@ func TestClassify(t *testing.T) { // when _, err := internal.Classify(blocks, map[string]internal.CellParser{ "static": failingStatic, - "bash": bash.NewParser(bash.Runner{}), + "bash": bash.MakeParser(), }) // then @@ -534,7 +534,7 @@ func TestClassify(t *testing.T) { // when _, err := internal.Classify(blocks, map[string]internal.CellParser{ "static": failingStatic, - "bash": bash.NewParser(bash.Runner{}), + "bash": bash.MakeParser(), }) // then diff --git a/internal/file_test.go b/internal/file_test.go index 2c1df06..40fbca0 100644 --- a/internal/file_test.go +++ b/internal/file_test.go @@ -43,7 +43,7 @@ func TestProcessFile(t *testing.T) { got, err := internal.ProcessFile( f.Name(), map[string]internal.CellParser{ "static": static.NewParser(), - "bash": bash.NewParser(bash.Runner{}), + "bash": bash.MakeParser(), }) // then diff --git a/internal/static/cell_private_test.go b/internal/static/cell_private_test.go new file mode 100644 index 0000000..ff29c1b --- /dev/null +++ b/internal/static/cell_private_test.go @@ -0,0 +1,21 @@ +package static + +import ( + "testing" + + "litdoc/internal" + + "github.com/stretchr/testify/assert" +) + +func TestMakeCellFromRaw(t *testing.T) { + // given + content := "hello" + block := internal.MakeBlockFromRaw(internal.BlockKindText, content, "", false) + + // when + c := makeCellFromRaw(content) + + // then + assert.Equal(t, block, c.block) +} diff --git a/internal/static/cell_test.go b/internal/static/cell_test.go index a7119b7..fc6c36b 100644 --- a/internal/static/cell_test.go +++ b/internal/static/cell_test.go @@ -10,19 +10,9 @@ import ( "github.com/stretchr/testify/require" ) -func TestMakeCellFromRaw(t *testing.T) { - // given - cell := static.MakeCellFromRaw("hello") - - // when - got, err := cell.Render() - // then - require.NoError(t, err) - assert.Equal(t, "hello", got) -} -func TestParse(t *testing.T) { +func TestParser_Parse(t *testing.T) { // given block := internal.MakeBlockFromRaw(internal.BlockKindText, "hello", "", false) @@ -37,7 +27,7 @@ func TestParse(t *testing.T) { assert.Equal(t, block.Render(), rendered) } -func TestRender(t *testing.T) { +func TestCell_Render(t *testing.T) { // given cell := static.MakeCellFromRaw("hello") @@ -49,7 +39,7 @@ func TestRender(t *testing.T) { assert.Equal(t, "hello", got) } -func TestExecute(t *testing.T) { +func TestCell_Execute(t *testing.T) { // given cell := static.MakeCellFromRaw("hello") From ec76473864cbad2bf958e1f2932ca398295eb488 Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Wed, 6 May 2026 15:23:42 -0600 Subject: [PATCH 4/6] [Refactor] Replace parseOutput func field with OutputParser interface Introduces internal.OutputParser interface and OutputParserFunc adapter so the output parsing dependency in bash.Parser can be injected as an interface and mocked with mockery, replacing the ad-hoc inline func approach previously used in tests. Changes: - internal/output.go: add OutputParser interface (Parse method) and OutputParserFunc adapter that satisfies it - internal/bash/cell.go: rename Parser.parseOutput func field to outputParser internal.OutputParser; update MakeParser, makeParserFromRaw, and Parse call site accordingly - .mockery.yaml: add OutputParser to generate MockOutputParser in bash_test - internal/bash/OutputParser_mock_test.go: generated mock - internal/bash/cell_test.go: replace inline parseOutput closures with NewMockOutputParser; apply three-line EXPECT chain style to all mocks - internal/bash/cell_private_test.go: wrap OutputFromBlocks in OutputParserFunc; assert p.outputParser (renamed field) - internal/cell_test.go: apply three-line EXPECT chain style throughout --- .mockery.yaml | 4 + internal/bash/OutputParser_mock_test.go | 110 ++++++++++++++++++++++++ internal/bash/cell.go | 12 +-- internal/bash/cell_private_test.go | 4 +- internal/bash/cell_test.go | 38 ++++---- internal/cell_test.go | 20 +++-- internal/output.go | 10 +++ 7 files changed, 166 insertions(+), 32 deletions(-) create mode 100644 internal/bash/OutputParser_mock_test.go diff --git a/.mockery.yaml b/.mockery.yaml index 609938a..01d5365 100644 --- a/.mockery.yaml +++ b/.mockery.yaml @@ -11,6 +11,10 @@ packages: config: dir: ./internal/ pkgname: internal_test + OutputParser: + config: + dir: ./internal/bash/ + pkgname: bash_test Runner: config: dir: ./internal/bash/ diff --git a/internal/bash/OutputParser_mock_test.go b/internal/bash/OutputParser_mock_test.go new file mode 100644 index 0000000..717340d --- /dev/null +++ b/internal/bash/OutputParser_mock_test.go @@ -0,0 +1,110 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package bash_test + +import ( + "litdoc/internal" + + mock "github.com/stretchr/testify/mock" +) + +// NewMockOutputParser creates a new instance of MockOutputParser. 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 NewMockOutputParser(t interface { + mock.TestingT + Cleanup(func()) +}) *MockOutputParser { + mock := &MockOutputParser{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockOutputParser is an autogenerated mock type for the OutputParser type +type MockOutputParser struct { + mock.Mock +} + +type MockOutputParser_Expecter struct { + mock *mock.Mock +} + +func (_m *MockOutputParser) EXPECT() *MockOutputParser_Expecter { + return &MockOutputParser_Expecter{mock: &_m.Mock} +} + +// Parse provides a mock function for the type MockOutputParser +func (_mock *MockOutputParser) Parse(litdoc internal.Block, following []internal.Block) (internal.Output, int, error) { + ret := _mock.Called(litdoc, following) + + if len(ret) == 0 { + panic("no return value specified for Parse") + } + + var r0 internal.Output + var r1 int + var r2 error + if returnFunc, ok := ret.Get(0).(func(internal.Block, []internal.Block) (internal.Output, int, error)); ok { + return returnFunc(litdoc, following) + } + if returnFunc, ok := ret.Get(0).(func(internal.Block, []internal.Block) internal.Output); ok { + r0 = returnFunc(litdoc, following) + } else { + r0 = ret.Get(0).(internal.Output) + } + if returnFunc, ok := ret.Get(1).(func(internal.Block, []internal.Block) int); ok { + r1 = returnFunc(litdoc, following) + } else { + r1 = ret.Get(1).(int) + } + if returnFunc, ok := ret.Get(2).(func(internal.Block, []internal.Block) error); ok { + r2 = returnFunc(litdoc, following) + } else { + r2 = ret.Error(2) + } + return r0, r1, r2 +} + +// MockOutputParser_Parse_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Parse' +type MockOutputParser_Parse_Call struct { + *mock.Call +} + +// Parse is a helper method to define mock.On call +// - litdoc internal.Block +// - following []internal.Block +func (_e *MockOutputParser_Expecter) Parse(litdoc interface{}, following interface{}) *MockOutputParser_Parse_Call { + return &MockOutputParser_Parse_Call{Call: _e.mock.On("Parse", litdoc, following)} +} + +func (_c *MockOutputParser_Parse_Call) Run(run func(litdoc internal.Block, following []internal.Block)) *MockOutputParser_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 *MockOutputParser_Parse_Call) Return(output internal.Output, n int, err error) *MockOutputParser_Parse_Call { + _c.Call.Return(output, n, err) + return _c +} + +func (_c *MockOutputParser_Parse_Call) RunAndReturn(run func(litdoc internal.Block, following []internal.Block) (internal.Output, int, error)) *MockOutputParser_Parse_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/bash/cell.go b/internal/bash/cell.go index 55164fe..cc01fbf 100644 --- a/internal/bash/cell.go +++ b/internal/bash/cell.go @@ -26,19 +26,19 @@ func makeCellFromRaw( } type Parser struct { - runner internal.Runner - parseOutput func(internal.Block, []internal.Block) (internal.Output, int, error) + runner internal.Runner + outputParser internal.OutputParser } func MakeParser() Parser { - return makeParserFromRaw(Runner{}, internal.OutputFromBlocks) + return makeParserFromRaw(Runner{}, internal.OutputParserFunc(internal.OutputFromBlocks)) } func makeParserFromRaw( runner internal.Runner, - parseOutput func(internal.Block, []internal.Block) (internal.Output, int, error), + outputParser internal.OutputParser, ) Parser { - return Parser{runner: runner, parseOutput: parseOutput} + return Parser{runner: runner, outputParser: outputParser} } func (p Parser) Parse( @@ -49,7 +49,7 @@ func (p Parser) Parse( int, error, ) { - output, consumed, err := p.parseOutput(block, following) + output, consumed, err := p.outputParser.Parse(block, following) if err != nil { return nil, 0, fmt.Errorf("parsing output: %w", err) } diff --git a/internal/bash/cell_private_test.go b/internal/bash/cell_private_test.go index a70f139..2505066 100644 --- a/internal/bash/cell_private_test.go +++ b/internal/bash/cell_private_test.go @@ -30,9 +30,9 @@ func TestMakeParserFromRaw(t *testing.T) { runner := Runner{} // when - p := makeParserFromRaw(runner, internal.OutputFromBlocks) + p := makeParserFromRaw(runner, internal.OutputParserFunc(internal.OutputFromBlocks)) // then assert.Equal(t, runner, p.runner) - assert.NotNil(t, p.parseOutput) + assert.NotNil(t, p.outputParser) } diff --git a/internal/bash/cell_test.go b/internal/bash/cell_test.go index 7011fce..8ead2a0 100644 --- a/internal/bash/cell_test.go +++ b/internal/bash/cell_test.go @@ -26,14 +26,11 @@ func TestParser_Parse(t *testing.T) { t.Run("assembles cell from block and output", func(t *testing.T) { // given output := internal.MakeOutput("hello", "") - parseOutput := func(internal.Block, []internal.Block) ( - internal.Output, - int, - error, - ) { - return output, 3, nil - } - parser := bash.NewParserWith(nil, parseOutput) + outputParser := NewMockOutputParser(t) + outputParser.EXPECT(). + Parse(block, []internal.Block(nil)). + Return(output, 3, nil) + parser := bash.NewParserWith(nil, outputParser) // when cell, consumed, err := parser.Parse(block, nil) @@ -48,14 +45,11 @@ func TestParser_Parse(t *testing.T) { t.Run("output parsing error is wrapped", func(t *testing.T) { // given - parseOutput := func(internal.Block, []internal.Block) ( - internal.Output, - int, - error, - ) { - return internal.Output{}, 0, assert.AnError - } - parser := bash.NewParserWith(nil, parseOutput) + outputParser := NewMockOutputParser(t) + outputParser.EXPECT(). + Parse(block, []internal.Block(nil)). + Return(internal.Output{}, 0, assert.AnError) + parser := bash.NewParserWith(nil, outputParser) // when _, _, err := parser.Parse(block, nil) @@ -77,7 +71,9 @@ func TestCell_Execute(t *testing.T) { t.Run("success", func(t *testing.T) { // given runner := NewMockRunner(t) - runner.EXPECT().Run("echo hello\n").Return("hello\n", "", 0, nil) + runner.EXPECT(). + Run("echo hello\n"). + Return("hello\n", "", 0, nil) cell := bash.MakeCellFromRaw(fencedCode, "", internal.MakeOutput("", ""), runner) // when @@ -93,7 +89,9 @@ func TestCell_Execute(t *testing.T) { t.Run("non-zero exit code", func(t *testing.T) { // given runner := NewMockRunner(t) - runner.EXPECT().Run("echo hello\n").Return("", "bash: command not found\n", 127, nil) + runner.EXPECT(). + Run("echo hello\n"). + Return("", "bash: command not found\n", 127, nil) cell := bash.MakeCellFromRaw(fencedCode, "", internal.MakeOutput("", ""), runner) // when @@ -107,7 +105,9 @@ func TestCell_Execute(t *testing.T) { t.Run("exec error", func(t *testing.T) { // given runner := NewMockRunner(t) - runner.EXPECT().Run("echo hello\n").Return("", "", 0, assert.AnError) + runner.EXPECT(). + Run("echo hello\n"). + Return("", "", 0, assert.AnError) cell := bash.MakeCellFromRaw(fencedCode, "", internal.MakeOutput("", ""), runner) // when diff --git a/internal/cell_test.go b/internal/cell_test.go index 71cdedc..7ff8592 100644 --- a/internal/cell_test.go +++ b/internal/cell_test.go @@ -560,7 +560,9 @@ func TestExecute(t *testing.T) { // given result := NewMockCell(t) cell := NewMockCell(t) - cell.EXPECT().Execute().Return(result, nil) + cell.EXPECT(). + Execute(). + Return(result, nil) cells := []internal.Cell{cell} // when @@ -575,7 +577,9 @@ func TestExecute(t *testing.T) { t.Run("cell.Execute fails", func(t *testing.T) { // given cell := NewMockCell(t) - cell.EXPECT().Execute().Return(nil, assert.AnError) + cell.EXPECT(). + Execute(). + Return(nil, assert.AnError) cells := []internal.Cell{cell} // when @@ -591,9 +595,13 @@ func TestCompose(t *testing.T) { t.Run("happy path", func(t *testing.T) { // given cell1 := NewMockCell(t) - cell1.EXPECT().Render().Return("hello", nil) + cell1.EXPECT(). + Render(). + Return("hello", nil) cell2 := NewMockCell(t) - cell2.EXPECT().Render().Return(" world", nil) + cell2.EXPECT(). + Render(). + Return(" world", nil) cells := []internal.Cell{cell1, cell2} // when @@ -607,7 +615,9 @@ func TestCompose(t *testing.T) { t.Run("cell.Render fails", func(t *testing.T) { // given cell := NewMockCell(t) - cell.EXPECT().Render().Return("", assert.AnError) + cell.EXPECT(). + Render(). + Return("", assert.AnError) cells := []internal.Cell{cell} // when diff --git a/internal/output.go b/internal/output.go index 967a2bc..fe50a8c 100644 --- a/internal/output.go +++ b/internal/output.go @@ -10,6 +10,16 @@ const ( OutputEndMarker = "" ) +type OutputParser interface { + Parse(litdoc Block, following []Block) (Output, int, error) +} + +type OutputParserFunc func(litdoc Block, following []Block) (Output, int, error) + +func (f OutputParserFunc) Parse(litdoc Block, following []Block) (Output, int, error) { + return f(litdoc, following) +} + type Output struct { block Block } From 496da7bfbc9d3f5e24b829bb3db986e157c14ae7 Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Wed, 6 May 2026 16:55:36 -0600 Subject: [PATCH 5/6] [Refactor] Introduce internal/input.go for block content parsing Consolidates the two functions that interpret block content (info string and body extraction) into a single new file, separating them from the cell lifecycle logic in cell.go and the bash-specific execution in bash/cell.go. Changes: - internal/input.go (new): move InfoString and InfoStringFromBlock from cell.go; add CodeBody with a generic regex that strips the opening and closing delimiter lines from any block type (fenced code, tilde fence, HTML comment) without encoding format-specific knowledge - internal/input_test.go (new): table-driven TestCodeBody covering backtick fences, tilde fences, and HTML comments across 14 cases including edge cases (empty body, missing closing delimiter, body lines containing backticks) - internal/cell.go: remove InfoString and InfoStringFromBlock (now in input.go); drop strings import from that section - internal/bash/cell.go: replace local codeBody call with internal.CodeBody; wrap runner errors with "running cell:" context; remove codeBody and its regexp var; drop regexp import - internal/bash/cell_test.go: assert "running cell" wrapping on exec errors to match the updated Execute error path --- internal/bash/cell.go | 14 +- internal/bash/cell_test.go | 1 + internal/cell.go | 16 -- internal/cell_test.go | 147 ------------------ internal/input.go | 35 +++++ internal/input_test.go | 302 +++++++++++++++++++++++++++++++++++++ 6 files changed, 340 insertions(+), 175 deletions(-) create mode 100644 internal/input.go create mode 100644 internal/input_test.go diff --git a/internal/bash/cell.go b/internal/bash/cell.go index cc01fbf..cbc0532 100644 --- a/internal/bash/cell.go +++ b/internal/bash/cell.go @@ -57,9 +57,9 @@ func (p Parser) Parse( } func (c Cell) Execute() (internal.Cell, error) { - stdout, stderr, exitCode, err := c.runner.Run(codeBody(c.block.Content())) + stdout, stderr, exitCode, err := c.runner.Run(internal.CodeBody(c.block.Content())) if err != nil { - return nil, err + return nil, fmt.Errorf("running cell: %w", err) } if exitCode != 0 { return nil, fmt.Errorf("exit status %d: %s", exitCode, strings.TrimSpace(stderr)) @@ -75,13 +75,3 @@ func (c Cell) Render() (string, error) { return c.block.Render() + c.output.Render(), nil } -// codeBody extracts the script lines from a fenced code block's content, -// stripping the opening and closing fence lines. -func codeBody(content string) string { - // todo: does this need to be more robust? - lines := strings.Split(content, "\n") - if len(lines) < 3 { - return "" - } - return strings.Join(lines[1:len(lines)-2], "\n") + "\n" -} diff --git a/internal/bash/cell_test.go b/internal/bash/cell_test.go index 8ead2a0..340ec8b 100644 --- a/internal/bash/cell_test.go +++ b/internal/bash/cell_test.go @@ -114,6 +114,7 @@ func TestCell_Execute(t *testing.T) { _, err := cell.Execute() // then + require.ErrorContains(t, err, "running cell") require.ErrorIs(t, err, assert.AnError) }) } diff --git a/internal/cell.go b/internal/cell.go index 940367e..d23ed1c 100644 --- a/internal/cell.go +++ b/internal/cell.go @@ -14,22 +14,6 @@ type CellParser interface { Parse(block Block, following []Block) (Cell, int, error) } -type InfoString struct { - Lang string - Litdoc bool -} - -func InfoStringFromBlock(b Block) InfoString { - raw := b.headerLine() - if raw == "" { - return InfoString{} - } - parts := strings.SplitN(raw, " | ", 2) - lang := strings.TrimSpace(parts[0]) - litdoc := len(parts) > 1 && strings.HasPrefix(strings.TrimSpace(parts[1]), "litdoc") - return InfoString{Lang: lang, Litdoc: litdoc} -} - func Classify(blocks []Block, parsers map[string]CellParser) ([]Cell, error) { static, ok := parsers["static"] if !ok { diff --git a/internal/cell_test.go b/internal/cell_test.go index 7ff8592..70612ff 100644 --- a/internal/cell_test.go +++ b/internal/cell_test.go @@ -12,153 +12,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestInfoStringFromBlock(t *testing.T) { - tests := []struct { - name string - block internal.Block - want internal.InfoString - }{ - { - name: "text block", - block: text("", "hello", false), - want: internal.InfoString{}, - }, - { - name: "fenced code/backtick/without-litdoc", - block: code( - "", - joinLines( - "```bash", - "echo hello", - "```", - "", - ), - false, - ), - want: internal.InfoString{Lang: "bash"}, - }, - { - name: "fenced code/backtick/with-litdoc", - block: code( - "", - joinLines( - "```bash | litdoc", - "echo hello", - "```", - "", - ), - false, - ), - want: internal.InfoString{Lang: "bash", Litdoc: true}, - }, - { - name: "fenced code/tilde/with-litdoc", - block: code( - "", - joinLines( - "~~~sh | litdoc", - "echo hello", - "~~~", - "", - ), - false, - ), - want: internal.InfoString{Lang: "sh", Litdoc: true}, - }, - { - name: "fenced code/no-info-string", - block: code( - "", - joinLines( - "```", - "echo hello", - "```", - "", - ), - false, - ), - want: internal.InfoString{}, - }, - { - name: "fenced code/trims-language", - block: code( - "", - joinLines( - "``` bash | litdoc", - "echo hello", - "```", - "", - ), - false, - ), - want: internal.InfoString{Lang: "bash", Litdoc: true}, - }, - { - name: "fenced code/litdoc-prefix", - block: code( - "", - joinLines( - "```bash | litdoc-output", - "echo hello", - "```", - "", - ), - false, - ), - want: internal.InfoString{Lang: "bash", Litdoc: true}, - }, - { - name: "html comment/without-litdoc", - block: cmnt( - "", - joinLines( - "", - "", - ), - false, - ), - want: internal.InfoString{Lang: "bash"}, - }, - { - name: "html comment/with-litdoc", - block: cmnt( - "", - joinLines( - "", - "", - ), - false, - ), - want: internal.InfoString{Lang: "bash", Litdoc: true}, - }, - { - name: "html comment/unsupported-litdoc-language", - block: cmnt( - "", - joinLines( - "", - "", - ), - false, - ), - want: internal.InfoString{Lang: "go", Litdoc: true}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := internal.InfoStringFromBlock(tt.block) - assert.Equal(t, tt.want, got) - }) - } -} - func TestClassify(t *testing.T) { type wantCell struct { kind string diff --git a/internal/input.go b/internal/input.go new file mode 100644 index 0000000..c73392c --- /dev/null +++ b/internal/input.go @@ -0,0 +1,35 @@ +package internal + +import ( + "regexp" + "strings" +) + +// codeBodyRe strips the opening and closing delimiter lines from any block +// type. (?s) lets .* span newlines; greedy backtracking anchors the closing +// delimiter to the last non-empty line before end-of-string. +var codeBodyRe = regexp.MustCompile("(?s)^[^\n]*\n(.*)\n[^\n]+\n?$") + +type InfoString struct { + Lang string + Litdoc bool +} + +func InfoStringFromBlock(b Block) InfoString { + raw := b.headerLine() + if raw == "" { + return InfoString{} + } + parts := strings.SplitN(raw, " | ", 2) + lang := strings.TrimSpace(parts[0]) + litdoc := len(parts) > 1 && strings.HasPrefix(strings.TrimSpace(parts[1]), "litdoc") + return InfoString{Lang: lang, Litdoc: litdoc} +} + +func CodeBody(content string) string { + m := codeBodyRe.FindStringSubmatch(content) + if m == nil { + return "" + } + return m[1] + "\n" +} diff --git a/internal/input_test.go b/internal/input_test.go new file mode 100644 index 0000000..89907ac --- /dev/null +++ b/internal/input_test.go @@ -0,0 +1,302 @@ +package internal_test + +import ( + "testing" + + "litdoc/internal" + + "github.com/stretchr/testify/assert" +) + +func TestInfoStringFromBlock(t *testing.T) { + tests := []struct { + name string + block internal.Block + want internal.InfoString + }{ + { + name: "text block", + block: text("", "hello", false), + want: internal.InfoString{}, + }, + { + name: "fenced code/backtick/without-litdoc", + block: code( + "", + joinLines( + "```bash", + "echo hello", + "```", + "", + ), + false, + ), + want: internal.InfoString{Lang: "bash"}, + }, + { + name: "fenced code/backtick/with-litdoc", + block: code( + "", + joinLines( + "```bash | litdoc", + "echo hello", + "```", + "", + ), + false, + ), + want: internal.InfoString{Lang: "bash", Litdoc: true}, + }, + { + name: "fenced code/tilde/with-litdoc", + block: code( + "", + joinLines( + "~~~sh | litdoc", + "echo hello", + "~~~", + "", + ), + false, + ), + want: internal.InfoString{Lang: "sh", Litdoc: true}, + }, + { + name: "fenced code/no-info-string", + block: code( + "", + joinLines( + "```", + "echo hello", + "```", + "", + ), + false, + ), + want: internal.InfoString{}, + }, + { + name: "fenced code/trims-language", + block: code( + "", + joinLines( + "``` bash | litdoc", + "echo hello", + "```", + "", + ), + false, + ), + want: internal.InfoString{Lang: "bash", Litdoc: true}, + }, + { + name: "fenced code/litdoc-prefix", + block: code( + "", + joinLines( + "```bash | litdoc-output", + "echo hello", + "```", + "", + ), + false, + ), + want: internal.InfoString{Lang: "bash", Litdoc: true}, + }, + { + name: "html comment/without-litdoc", + block: cmnt( + "", + joinLines( + "", + "", + ), + false, + ), + want: internal.InfoString{Lang: "bash"}, + }, + { + name: "html comment/with-litdoc", + block: cmnt( + "", + joinLines( + "", + "", + ), + false, + ), + want: internal.InfoString{Lang: "bash", Litdoc: true}, + }, + { + name: "html comment/unsupported-litdoc-language", + block: cmnt( + "", + joinLines( + "", + "", + ), + false, + ), + want: internal.InfoString{Lang: "go", Litdoc: true}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, internal.InfoStringFromBlock(tt.block)) + }) + } +} + +func TestCodeBody(t *testing.T) { + tests := []struct { + name string + content string + want string + }{ + { + name: "single line", + content: joinLines( + "```bash", + "echo hello", + "```", + "", + ), + want: "echo hello\n", + }, + { + name: "multi-line body", + content: joinLines( + "```bash", + "line1", + "line2", + "line3", + "```", + "", + ), + want: "line1\nline2\nline3\n", + }, + { + name: "no info string", + content: joinLines( + "```", + "echo hello", + "```", + "", + ), + want: "echo hello\n", + }, + { + name: "longer fence", + content: joinLines( + "````bash", + "echo hello", + "````", + "", + ), + want: "echo hello\n", + }, + { + name: "no trailing newline on content", + content: joinLines( + "```bash", + "echo hello", + "```", + ), + want: "echo hello\n", + }, + { + name: "body line containing backticks", + content: joinLines( + "```bash", + "echo 'hello ```'", + "```", + "", + ), + want: "echo 'hello ```'\n", + }, + { + name: "empty body", + content: joinLines( + "```bash", + "```", + "", + ), + want: "", + }, + { + name: "missing closing fence", + content: joinLines( + "```bash", + "echo hello", + "", + ), + want: "", + }, + { + name: "not a fenced block", + content: "echo hello", + want: "", + }, + { + name: "tilde fence", + content: joinLines( + "~~~bash", + "echo hello", + "~~~", + "", + ), + want: "echo hello\n", + }, + { + name: "tilde fence with info string", + content: joinLines( + "~~~bash | litdoc", + "echo hello", + "~~~", + "", + ), + want: "echo hello\n", + }, + { + name: "html comment", + content: joinLines( + "", + "", + ), + want: "echo hello\n", + }, + { + name: "html comment multi-line", + content: joinLines( + "", + "", + ), + want: "line1\nline2\n", + }, + { + name: "html comment no trailing newline", + content: joinLines( + "", + ), + want: "echo hello\n", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, internal.CodeBody(tt.content)) + }) + } +} From d3743fc078dfac877e343b98ae5064f5fe9f4736 Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Wed, 6 May 2026 17:04:39 -0600 Subject: [PATCH 6/6] [Chore] go fmt --- internal/bash/cell.go | 1 - internal/static/cell_test.go | 2 -- 2 files changed, 3 deletions(-) diff --git a/internal/bash/cell.go b/internal/bash/cell.go index cbc0532..055be1d 100644 --- a/internal/bash/cell.go +++ b/internal/bash/cell.go @@ -74,4 +74,3 @@ func (c Cell) Execute() (internal.Cell, error) { func (c Cell) Render() (string, error) { return c.block.Render() + c.output.Render(), nil } - diff --git a/internal/static/cell_test.go b/internal/static/cell_test.go index fc6c36b..92d422f 100644 --- a/internal/static/cell_test.go +++ b/internal/static/cell_test.go @@ -10,8 +10,6 @@ import ( "github.com/stretchr/testify/require" ) - - func TestParser_Parse(t *testing.T) { // given block := internal.MakeBlockFromRaw(internal.BlockKindText, "hello", "", false)