diff --git a/doc/content/task-syntax/scripts.md b/doc/content/task-syntax/scripts.md index 0c01e12..ca23134 100644 --- a/doc/content/task-syntax/scripts.md +++ b/doc/content/task-syntax/scripts.md @@ -37,6 +37,20 @@ echo "Hello ертөнц!" #+end_src ``` +## Indented code blocks + +A script can also be written as an indented code block, using four spaces or a tab +instead of a ``` fence. This is handy when the surrounding document already uses +fenced blocks for something else. + +````markdown +## Tasks +### Task1 + + echo "Hello 世界!" + echo "Hello العالمية!" +```` + ## Shebangs To define an alternative interpreter such as python, then include a shebang, similar to the unix style. diff --git a/parser/parsemd/parsemd.go b/parser/parsemd/parsemd.go index c09c636..3094115 100644 --- a/parser/parsemd/parsemd.go +++ b/parser/parsemd/parsemd.go @@ -16,6 +16,7 @@ var ErrNoTasksHeading = errors.New("no xc block found") const ( trimValues = "_*` " codeBlockStarter = "```" + indentedCodeIndent = " " defaultHeading = "tasks" headingMarkerComment = "" ) @@ -244,6 +245,42 @@ func (p *parser) parseCodeBlock() error { return nil } +func isIndentedCode(line string) bool { + return strings.HasPrefix(line, indentedCodeIndent) || strings.HasPrefix(line, "\t") +} + +func trimCodeIndent(line string) string { + if strings.HasPrefix(line, "\t") { + return line[1:] + } + return strings.TrimPrefix(line, indentedCodeIndent) +} + +// parseIndentedCodeBlock handles a script written as an indented code block, +// where each line is indented with four spaces or a tab instead of being +// wrapped in a ``` fence. A blank line does not end the block if more indented +// code follows it, so chunks separated by a blank line are treated as one +// script, the same way a fenced block ignores blank lines. +func (p *parser) parseIndentedCodeBlock() error { + if !isIndentedCode(p.currentLine) { + return nil + } + if len(p.currTask.Script) > 0 { + return fmt.Errorf("command block already exists for task %s", p.currTask.Name) + } + for { + if isIndentedCode(p.currentLine) { + p.currTask.Script += trimCodeIndent(p.currentLine) + "\n" + } else if strings.TrimSpace(p.currentLine) != "" { + break + } + if !p.scan() { + break + } + } + return nil +} + func (p *parser) findTaskHeading() (heading string, done bool, err error) { for { tok, level, text, markerFound := p.parseHeading(true) @@ -283,6 +320,9 @@ func (p *parser) parseTaskBody() (bool, error) { if err != nil { return false, err } + if err = p.parseIndentedCodeBlock(); err != nil { + return false, err + } tok, level, _, _ := p.parseHeading(false) if tok && level <= p.rootHeadingLevel { return false, nil diff --git a/parser/parsemd/parsemd_test.go b/parser/parsemd/parsemd_test.go index 035bcb0..004e7b7 100644 --- a/parser/parsemd/parsemd_test.go +++ b/parser/parsemd/parsemd_test.go @@ -24,6 +24,9 @@ var tillEOF string //go:embed testdata/notasks.md var e string +//go:embed testdata/indented.md +var indented string + func assertTask(t *testing.T, expected, actual models.Task) { t.Helper() if expected.Name != actual.Name { @@ -282,6 +285,61 @@ some code } } +func TestParseIndentedScripts(t *testing.T) { + p, err := NewParser(strings.NewReader(indented), nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + result, err := p.Parse() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := models.Tasks{ + {Name: "list", Description: []string{"Lists files"}, Script: "ls\n"}, + { + Name: "greet", + Description: []string{"Print a message"}, + Script: "echo \"Hello, world!\"\necho \"Hello, world2!\"\n", + }, + } + if len(result) != len(expected) { + t.Fatalf("want %d tasks got %d", len(expected), len(result)) + } + for i, exp := range expected { + assertTask(t, exp, result[i]) + } +} + +func TestIndentedBlockSeparatedByBlankLine(t *testing.T) { + p, _ := NewParser(strings.NewReader(` +# Tasks +## a task + echo one + + echo two +`), nil) + _, err := p.parseTask() + if err != nil { + t.Fatal(err) + } + want := "echo one\necho two\n" + if p.currTask.Script != want { + t.Fatalf("script want=%q got=%q", want, p.currTask.Script) + } +} + +func TestIndentedBlockAfterFencedBlock(t *testing.T) { + var p parser + p.scanner = bufio.NewScanner(strings.NewReader(" echo hi")) + p.scan() + p.scan() + p.currTask.Script = "an existing script" + err := p.parseIndentedCodeBlock() + if err == nil { + t.Fatal("expected error got nil") + } +} + func TestMultipleCodeBlocks(t *testing.T) { var p parser p.scanner = bufio.NewScanner(strings.NewReader("```\ncode\n```")) diff --git a/parser/parsemd/testdata/indented.md b/parser/parsemd/testdata/indented.md new file mode 100644 index 0000000..fb89ce6 --- /dev/null +++ b/parser/parsemd/testdata/indented.md @@ -0,0 +1,16 @@ +# My readme + +## Tasks + +### list + +Lists files + + ls + +### greet + +Print a message + + echo "Hello, world!" + echo "Hello, world2!"