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

joerdav / xc / 16007621149

01 Jul 2025 06:46PM UTC coverage: 55.207% (+0.3%) from 54.868%
16007621149

Pull #140

github

ras0q
fix lint
Pull Request #140: feat: Allow a custom task heading with a Markdown comment

24 of 36 new or added lines in 2 files covered. (66.67%)

10 existing lines in 1 file now uncovered.

493 of 893 relevant lines covered (55.21%)

21.69 hits per line

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

93.24
/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
        maxSeps := 2
210✔
104
        s := strings.SplitN(t, " ", maxSeps)
210✔
105
        if len(s) != 2 || len(s[0]) < 1 || strings.Count(s[0], "#") != len(s[0]) {
358✔
106
                return false, 0, "", false
148✔
107
        }
148✔
108

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

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

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

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

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

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

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

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

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

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

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

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

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

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

340
                p.rootHeadingLevel = level
20✔
341
                return
20✔
342
        }
343
        err = ErrNoTasksHeading
1✔
344
        return
1✔
345
}
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