• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

joerdav / xc / 16007380713

01 Jul 2025 06:32PM UTC coverage: 55.157% (+0.3%) from 54.868%
16007380713

Pull #140

github

ras0q
doc: add `<!-- xc-heading -->` to task syntax
Pull Request #140: feat: Allow a custom task heading with a Markdown comment

23 of 35 new or added lines in 2 files covered. (65.71%)

5 existing lines in 1 file now uncovered.

492 of 892 relevant lines covered (55.16%)

21.48 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.21
/parser/parser.go
1
package parser
2

3
import (
4
        "bufio"
5
        "errors"
6
        "fmt"
7
        "io"
8
        "strings"
9

10
        "github.com/joerdav/xc/models"
11
)
12

13
// ErrNoTasksHeading is returned if the markdown contains no xc block
14
var ErrNoTasksHeading = errors.New("no xc block found")
15

16
const (
17
        trimValues           = "_*` "
18
        codeBlockStarter     = "```"
19
        defaultHeading       = "tasks"
20
        headingMarkerComment = "<!-- xc-heading -->"
21
)
22

23
type parser struct {
24
        scanner               *bufio.Scanner
25
        tasks                 models.Tasks
26
        currTask              models.Task
27
        rootHeadingLevel      int
28
        nextLine, currentLine string
29
        reachedEnd            bool
30
}
31

32
func (p *parser) Parse() (tasks models.Tasks, err error) {
5✔
33
        ok := true
5✔
34
        for ok {
15✔
35
                ok, err = p.parseTask()
10✔
36
                if err != nil || !ok {
15✔
37
                        break
5✔
38
                }
39
        }
40
        tasks = p.tasks
5✔
41
        return
5✔
42
}
43

44
func (p *parser) scan() bool {
396✔
45
        p.currentLine = p.nextLine
396✔
46
        if p.reachedEnd {
448✔
47
                return false
52✔
48
        }
52✔
49
        if !p.scanner.Scan() {
389✔
50
                p.reachedEnd = true
45✔
51
                p.nextLine = ""
45✔
52
                return true
45✔
53
        }
45✔
54
        p.nextLine = p.scanner.Text()
299✔
55
        return true
299✔
56
}
57

58
func stringOnlyContains(input string, matcher rune) bool {
428✔
59
        if len(input) == 0 {
598✔
60
                return false
170✔
61
        }
170✔
62
        for i := range input {
522✔
63
                if []rune(input)[i] != matcher {
518✔
64
                        return false
254✔
65
                }
254✔
66
        }
67
        return true
4✔
68
}
69

70
func (p *parser) parseAltHeading(advance bool) (ok bool, level int, text string) {
214✔
71
        t := strings.TrimSpace(p.currentLine)
214✔
72
        n := strings.TrimSpace(p.nextLine)
214✔
73
        if stringOnlyContains(n, '-') {
216✔
74
                ok = true
2✔
75
                level = 2
2✔
76
                text = t
2✔
77
        }
2✔
78
        if stringOnlyContains(n, '=') {
216✔
79
                ok = true
2✔
80
                level = 1
2✔
81
                text = t
2✔
82
        }
2✔
83
        if !advance || !ok {
424✔
84
                return
210✔
85
        }
210✔
86
        p.scan()
4✔
87
        p.scan()
4✔
88
        return
4✔
89
}
90

91
func (p *parser) parseHeading(advance bool) (ok bool, level int, text string, markerFound bool) {
214✔
92
        if p.currentLine == headingMarkerComment {
215✔
93
                markerFound = true
1✔
94
                p.scan()
1✔
95
        }
1✔
96

97
        ok, level, text = p.parseAltHeading(advance)
214✔
98
        if ok {
218✔
99
                return
4✔
100
        }
4✔
101

102
        t := strings.TrimSpace(p.currentLine)
210✔
103
        s := strings.SplitN(t, " ", 2)
210✔
104
        if len(s) != 2 || len(s[0]) < 1 || strings.Count(s[0], "#") != len(s[0]) {
358✔
105
                return false, 0, "", false
148✔
106
        }
148✔
107

108
        ok = true
62✔
109
        level = len(s[0])
62✔
110
        cutText, markerFoundAfterHeading := strings.CutSuffix(s[1], headingMarkerComment)
62✔
111
        text = strings.TrimSpace(cutText)
62✔
112
        markerFound = markerFound || markerFoundAfterHeading
62✔
113

62✔
114
        if !advance {
70✔
115
                return
8✔
116
        }
8✔
117
        p.scan()
54✔
118
        return
54✔
119
}
120

121
// AttributeType represents metadata related to a Task.
122
//
123
//        # Tasks
124
//        ## Task1
125
//        AttributeName: AttributeValue
126
//        ```
127
//        script
128
//        ```
129
type AttributeType int
130

131
const (
132
        // AttributeTypeEnv sets the environment variables for a Task.
133
        // It can be represented by an attribute with name `environment` or `env`.
134
        AttributeTypeEnv AttributeType = iota
135
        // AttributeTypeDir sets the working directory for a Task.
136
        // It can be represented by an attribute with name `directory` or `dir`.
137
        AttributeTypeDir
138
        // AttributeTypeReq sets the required Tasks for a Task, they will run
139
        // prior to the execution of the selected task.
140
        // It can be represented by an attribute with name `requires` or `req`.
141
        AttributeTypeReq
142
        // AttributeTypeInp sets the required inputs for a Task, inputs can be provided
143
        // as commandline arguments or environment variables.
144
        AttributeTypeInp
145
        // AttributeTypeRun sets the tasks requiredBehaviour, can be always or once.
146
        // Default is always
147
        AttributeTypeRun
148
        // AttributeTypeRunDeps sets the tasks dependenciesBehaviour, can be sync or async.
149
        AttributeTypeRunDeps
150
        // AttributeTypeInteractive indicates if this is an interactive task
151
        // if it is, then logs are not prefixed and the stdout/stderr are passed directly
152
        // from the OS
153
        AttributeTypeInteractive
154
)
155

156
var attMap = map[string]AttributeType{
157
        "req":             AttributeTypeReq,
158
        "requires":        AttributeTypeReq,
159
        "env":             AttributeTypeEnv,
160
        "environment":     AttributeTypeEnv,
161
        "dir":             AttributeTypeDir,
162
        "directory":       AttributeTypeDir,
163
        "inputs":          AttributeTypeInp,
164
        "run":             AttributeTypeRun,
165
        "rundeps":         AttributeTypeRunDeps,
166
        "rundependencies": AttributeTypeRunDeps,
167
        "interactive":     AttributeTypeInteractive,
168
}
169

170
func (p *parser) parseAttribute() (bool, error) {
99✔
171
        a, rest, found := strings.Cut(p.currentLine, ":")
99✔
172
        if !found {
162✔
173
                return false, nil
63✔
174
        }
63✔
175
        ty, ok := attMap[strings.ToLower(strings.Trim(a, trimValues))]
36✔
176
        if !ok {
39✔
177
                return false, nil
3✔
178
        }
3✔
179
        switch ty {
33✔
180
        case AttributeTypeInp:
5✔
181
                vs := strings.Split(rest, ",")
5✔
182
                for _, v := range vs {
11✔
183
                        p.currTask.Inputs = append(p.currTask.Inputs, strings.Trim(v, trimValues))
6✔
184
                }
6✔
185
        case AttributeTypeReq:
9✔
186
                vs := strings.Split(rest, ",")
9✔
187
                for _, v := range vs {
22✔
188
                        p.currTask.DependsOn = append(p.currTask.DependsOn, strings.Trim(v, trimValues))
13✔
189
                }
13✔
190
        case AttributeTypeEnv:
5✔
191
                vs := strings.Split(rest, ",")
5✔
192
                for _, v := range vs {
10✔
193
                        p.currTask.Env = append(p.currTask.Env, strings.Trim(v, trimValues))
5✔
194
                }
5✔
195
        case AttributeTypeDir:
6✔
196
                if p.currTask.Dir != "" {
7✔
197
                        return false, fmt.Errorf("directory appears more than once for %s", p.currTask.Name)
1✔
198
                }
1✔
199
                s := strings.Trim(rest, trimValues)
5✔
200
                p.currTask.Dir = s
5✔
201
        case AttributeTypeRun:
4✔
202
                s := strings.Trim(rest, trimValues)
4✔
203
                r, ok := models.ParseRequiredBehaviour(s)
4✔
204
                if !ok {
5✔
205
                        return false, fmt.Errorf("run contains invalid behaviour %q should be (always, once): %s", s, p.currTask.Name)
1✔
206
                }
1✔
207
                p.currTask.RequiredBehaviour = r
3✔
208
        case AttributeTypeRunDeps:
4✔
209
                s := strings.Trim(rest, trimValues)
4✔
210
                r, ok := models.ParseDepsBehaviour(s)
4✔
211
                if !ok {
4✔
212
                        return false, fmt.Errorf("runDeps contains invalid behaviour %q should be (sync, async): %s", s, p.currTask.Name)
×
UNCOV
213
                }
×
214
                p.currTask.DepsBehaviour = r
4✔
215
        case AttributeTypeInteractive:
×
216
                s := strings.Trim(rest, trimValues)
×
UNCOV
217
                p.currTask.Interactive = s == "true"
×
218
        }
219
        p.scan()
31✔
220
        return true, nil
31✔
221
}
222

223
func (p *parser) parseCodeBlock() error {
60✔
224
        t := p.currentLine
60✔
225
        if len(t) < 3 || t[:3] != codeBlockStarter {
98✔
226
                return nil
38✔
227
        }
38✔
228
        if len(p.currTask.Script) > 0 {
23✔
229
                return fmt.Errorf("command block already exists for task %s", p.currTask.Name)
1✔
230
        }
1✔
231
        var ended bool
21✔
232
        for p.scan() {
64✔
233
                if len(p.currentLine) >= 3 && p.currentLine[:3] == codeBlockStarter {
63✔
234
                        ended = true
20✔
235
                        break
20✔
236
                }
237
                if strings.TrimSpace(p.currentLine) != "" {
46✔
238
                        p.currTask.Script += p.currentLine + "\n"
23✔
239
                }
23✔
240
        }
241
        if !ended {
22✔
242
                return fmt.Errorf("command block in task %s was not ended", p.currTask.Name)
1✔
243
        }
1✔
244
        p.scan()
20✔
245
        return nil
20✔
246
}
247

248
func (p *parser) findTaskHeading() (heading string, done bool, err error) {
25✔
249
        for {
56✔
250
                tok, level, text, markerFound := p.parseHeading(true)
31✔
251
                if !tok || level > p.rootHeadingLevel+1 {
37✔
252
                        if !p.scan() {
6✔
253
                                return "", false, fmt.Errorf("failed to read file: %w", p.scanner.Err())
×
UNCOV
254
                        }
×
255
                        continue
6✔
256
                }
257
                if level <= p.rootHeadingLevel {
25✔
258
                        return "", true, nil
×
259
                }
×
260

261
                if markerFound {
25✔
NEW
262
                        fmt.Printf("%s found in %s, but this will not be used as a tasks heading.\n", headingMarkerComment, text)
×
NEW
263
                }
×
264

265
                return strings.Trim(text, trimValues), false, nil
25✔
266
        }
267
}
268

269
func (p *parser) parseTaskBody() (bool, error) {
25✔
270
        for {
94✔
271
                ok, err := p.parseAttribute()
69✔
272
                if err != nil {
69✔
273
                        return false, err
×
UNCOV
274
                }
×
275
                if p.reachedEnd {
72✔
276
                        // parse attribute again in case it is on the last line
3✔
277
                        _, err = p.parseAttribute()
3✔
278
                        return false, err
3✔
279
                }
3✔
280
                if ok {
73✔
281
                        continue
7✔
282
                }
283
                err = p.parseCodeBlock()
59✔
284
                if err != nil {
60✔
285
                        return false, err
1✔
286
                }
1✔
287
                tok, level, _, _ := p.parseHeading(false)
58✔
288
                if tok && level <= p.rootHeadingLevel {
61✔
289
                        return false, nil
3✔
290
                }
3✔
291
                if tok && level == p.rootHeadingLevel+1 {
60✔
292
                        return true, nil
5✔
293
                }
5✔
294
                if strings.TrimSpace(p.currentLine) != "" {
57✔
295
                        p.currTask.Description = append(p.currTask.Description, strings.Trim(p.currentLine, trimValues))
7✔
296
                }
7✔
297
                if !p.scan() {
63✔
298
                        return false, nil
13✔
299
                }
13✔
300
        }
301
}
302

303
func (p *parser) parseTask() (ok bool, err error) {
25✔
304
        p.currTask = models.Task{}
25✔
305
        heading, done, err := p.findTaskHeading()
25✔
306
        if err != nil || done {
25✔
307
                return
×
UNCOV
308
        }
×
309
        p.currTask.Name = heading
25✔
310
        ok, err = p.parseTaskBody()
25✔
311
        if err != nil {
26✔
312
                return
1✔
313
        }
1✔
314
        if len(p.currTask.Script) < 1 && len(p.currTask.DependsOn) < 1 {
25✔
315
                err = fmt.Errorf("task %s has no commands or required tasks", p.currTask.Name)
1✔
316
                return
1✔
317
        }
1✔
318
        p.tasks = append(p.tasks, p.currTask)
23✔
319
        return
23✔
320
}
321

322
// NewParser will read from r until it finds a valid xc heading block.
323
// If no block is found an error is returned.
324
func NewParser(r io.Reader, heading *string) (p parser, err error) {
21✔
325
        p.scanner = bufio.NewScanner(r)
21✔
326
        for p.scan() {
146✔
327
                ok, level, text, markerFound := p.parseHeading(true)
125✔
328
                if !ok {
217✔
329
                        continue
92✔
330
                }
331

332
                parsedHeading := strings.TrimSpace(text)
33✔
333
                specifiedHeadingFound := heading != nil && strings.EqualFold(parsedHeading, strings.TrimSpace(*heading))
33✔
334
                unspecifiedHeadingFound := heading == nil && (markerFound || strings.EqualFold(parsedHeading, defaultHeading))
33✔
335
                if !specifiedHeadingFound && !unspecifiedHeadingFound {
46✔
336
                        continue
13✔
337
                }
338

339
                p.rootHeadingLevel = level
20✔
340
                return
20✔
341
        }
342
        err = ErrNoTasksHeading
1✔
343
        return
1✔
344
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc