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

joerdav / xc / 16234850356

12 Jul 2025 05:28AM UTC coverage: 60.178% (+5.1%) from 55.107%
16234850356

Pull #143

github

ryanprior
Adds flag -type to explicitly set file type (md, org)
Pull Request #143: WIP: Add `parseorg` module

186 of 260 new or added lines in 2 files covered. (71.54%)

15 existing lines in 1 file now uncovered.

677 of 1125 relevant lines covered (60.18%)

22.19 hits per line

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

91.18
/parser/parseorg/parseorg.go
1
package parseorg
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 = "#+begin_src"
19
        codeBlockEnd     = "#+end_src"
20
        defaultHeading   = "tasks"
21
        headingMarkerTag = ":xc_heading:"
22
)
23

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

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

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

59
func (p *parser) skipComment(commentLevel int) (ok bool, level int, text string, markerFound bool) {
1✔
60
        loop := true
1✔
61
        for loop {
10✔
62
                ok, level, text, markerFound = p.parseHeading(false)
9✔
63
                if ok && level <= commentLevel {
10✔
64
                        return
1✔
65
                }
1✔
66
                loop = p.scan()
8✔
67
        }
NEW
68
        return
×
69
}
70

71
func (p *parser) parseHeading(advance bool) (ok bool, level int, text string, markerFound bool) {
147✔
72
        if strings.Contains(p.currentLine, headingMarkerTag) {
147✔
NEW
73
                markerFound = true
×
NEW
74
        }
×
75
        t := strings.TrimSpace(p.currentLine)
147✔
76
        s := strings.Fields(t)
147✔
77
        if len(s) < 2 || len(s[0]) < 1 || strings.Count(s[0], "*") != len(s[0]) {
244✔
78
                return
97✔
79
        }
97✔
80
        level = len(s[0])
50✔
81
        if s[1] == "COMMENT" {
51✔
82
                p.scan()
1✔
83
                ok, level, text, markerFound = p.skipComment(level)
1✔
84
        } else {
50✔
85
                ok = true
49✔
86
                text = strings.Join(s[1:], " ")
49✔
87
        }
49✔
88
        if !advance {
57✔
89
                return
7✔
90
        }
7✔
91
        p.scan()
43✔
92
        return
43✔
93
}
94

95
// AttributeType represents metadata related to a Task.
96
//
97
//        # Tasks
98
//        ## Task1
99
//        AttributeName: AttributeValue
100
//        ```
101
//        script
102
//        ```
103
type AttributeType int
104

105
const (
106
        // AttributeTypeEnv sets the environment variables for a Task.
107
        // It can be represented by an attribute with name `environment` or `env`.
108
        AttributeTypeEnv AttributeType = iota
109
        // AttributeTypeDir sets the working directory for a Task.
110
        // It can be represented by an attribute with name `directory` or `dir`.
111
        AttributeTypeDir
112
        // AttributeTypeReq sets the required Tasks for a Task, they will run
113
        // prior to the execution of the selected task.
114
        // It can be represented by an attribute with name `requires` or `req`.
115
        AttributeTypeReq
116
        // AttributeTypeInp sets the required inputs for a Task, inputs can be provided
117
        // as commandline arguments or environment variables.
118
        AttributeTypeInp
119
        // AttributeTypeRun sets the tasks requiredBehaviour, can be always or once.
120
        // Default is always
121
        AttributeTypeRun
122
        // AttributeTypeRunDeps sets the tasks dependenciesBehaviour, can be sync or async.
123
        AttributeTypeRunDeps
124
        // AttributeTypeInteractive indicates if this is an interactive task
125
        // if it is, then logs are not prefixed and the stdout/stderr are passed directly
126
        // from the OS
127
        AttributeTypeInteractive
128
)
129

130
var attMap = map[string]AttributeType{
131
        "req":             AttributeTypeReq,
132
        "requires":        AttributeTypeReq,
133
        "env":             AttributeTypeEnv,
134
        "environment":     AttributeTypeEnv,
135
        "dir":             AttributeTypeDir,
136
        "directory":       AttributeTypeDir,
137
        "inputs":          AttributeTypeInp,
138
        "run":             AttributeTypeRun,
139
        "rundeps":         AttributeTypeRunDeps,
140
        "rundependencies": AttributeTypeRunDeps,
141
        "interactive":     AttributeTypeInteractive,
142
}
143

144
func (p *parser) parseAttribute() (bool, error) {
83✔
145
        a, rest, found := strings.Cut(p.currentLine, ":")
83✔
146
        if !found {
131✔
147
                return false, nil
48✔
148
        }
48✔
149
        ty, ok := attMap[strings.ToLower(strings.Trim(a, trimValues))]
35✔
150
        if !ok {
38✔
151
                return false, nil
3✔
152
        }
3✔
153
        switch ty {
32✔
154
        case AttributeTypeInp:
5✔
155
                vs := strings.Split(rest, ",")
5✔
156
                for _, v := range vs {
11✔
157
                        p.currTask.Inputs = append(p.currTask.Inputs, strings.Trim(v, trimValues))
6✔
158
                }
6✔
159
        case AttributeTypeReq:
8✔
160
                vs := strings.Split(rest, ",")
8✔
161
                for _, v := range vs {
19✔
162
                        p.currTask.DependsOn = append(p.currTask.DependsOn, strings.Trim(v, trimValues))
11✔
163
                }
11✔
164
        case AttributeTypeEnv:
5✔
165
                vs := strings.Split(rest, ",")
5✔
166
                for _, v := range vs {
10✔
167
                        p.currTask.Env = append(p.currTask.Env, strings.Trim(v, trimValues))
5✔
168
                }
5✔
169
        case AttributeTypeDir:
6✔
170
                if p.currTask.Dir != "" {
7✔
171
                        return false, fmt.Errorf("directory appears more than once for %s", p.currTask.Name)
1✔
172
                }
1✔
173
                s := strings.Trim(rest, trimValues)
5✔
174
                p.currTask.Dir = s
5✔
175
        case AttributeTypeRun:
4✔
176
                s := strings.Trim(rest, trimValues)
4✔
177
                r, ok := models.ParseRequiredBehaviour(s)
4✔
178
                if !ok {
5✔
179
                        return false, fmt.Errorf("run contains invalid behaviour %q should be (always, once): %s", s, p.currTask.Name)
1✔
180
                }
1✔
181
                p.currTask.RequiredBehaviour = r
3✔
182
        case AttributeTypeRunDeps:
4✔
183
                s := strings.Trim(rest, trimValues)
4✔
184
                r, ok := models.ParseDepsBehaviour(s)
4✔
185
                if !ok {
4✔
NEW
186
                        return false, fmt.Errorf("runDeps contains invalid behaviour %q should be (sync, async): %s", s, p.currTask.Name)
×
NEW
187
                }
×
188
                p.currTask.DepsBehaviour = r
4✔
NEW
189
        case AttributeTypeInteractive:
×
NEW
190
                s := strings.Trim(rest, trimValues)
×
NEW
191
                p.currTask.Interactive = s == "true"
×
192
        }
193
        p.scan()
30✔
194
        return true, nil
30✔
195
}
196

197
func (p *parser) parseCodeBlock() error {
45✔
198
        t := p.currentLine
45✔
199
        if !strings.HasPrefix(t, codeBlockStarter) {
71✔
200
                return nil
26✔
201
        }
26✔
202
        if len(p.currTask.Script) > 0 {
20✔
203
                return fmt.Errorf("command block already exists for task %s", p.currTask.Name)
1✔
204
        }
1✔
205
        var ended bool
18✔
206
        codeBlockIndent := -1
18✔
207
        for p.scan() {
55✔
208
                if codeBlockIndent < 0 {
55✔
209
                        codeBlockIndent = len(p.currentLine) - len(strings.TrimLeft(p.currentLine, " "))
18✔
210
                }
18✔
211
                if strings.HasPrefix(p.currentLine, codeBlockEnd) {
54✔
212
                        ended = true
17✔
213
                        break
17✔
214
                }
215
                if strings.TrimSpace(p.currentLine) != "" {
40✔
216
                        p.currTask.Script += strings.TrimPrefix(p.currentLine, strings.Repeat(" ", codeBlockIndent)) + "\n"
20✔
217
                }
20✔
218
        }
219
        if !ended {
19✔
220
                return fmt.Errorf("command block in task %s was not ended", p.currTask.Name)
1✔
221
        }
1✔
222
        p.scan()
17✔
223
        return nil
17✔
224
}
225

226
func (p *parser) findTaskHeading() (heading string, done bool, err error) {
22✔
227
        for {
46✔
228
                tok, level, text, markerFound := p.parseHeading(true)
24✔
229
                if !tok || level > p.rootHeadingLevel+1 {
26✔
230
                        if !p.scan() {
2✔
NEW
231
                                return "", false, fmt.Errorf("failed to read file: %w", p.scanner.Err())
×
NEW
232
                        }
×
233
                        continue
2✔
234
                }
235
                if level <= p.rootHeadingLevel {
22✔
NEW
236
                        return "", true, nil
×
NEW
237
                }
×
238

239
                if markerFound {
22✔
NEW
240
                        fmt.Printf("%s found in %s, but this will not be used as a tasks heading.\n", headingMarkerTag, text)
×
NEW
241
                }
×
242
                return strings.Trim(text, trimValues), false, nil
22✔
243
        }
244
}
245

246
func (p *parser) parseTaskBody() (bool, error) {
22✔
247
        for {
75✔
248
                ok, err := p.parseAttribute()
53✔
249
                if err != nil {
53✔
NEW
250
                        return false, err
×
NEW
251
                }
×
252
                if p.reachedEnd {
56✔
253
                        // parse attribute again in case it is on the last line
3✔
254
                        _, err = p.parseAttribute()
3✔
255
                        return false, err
3✔
256
                }
3✔
257
                if ok {
56✔
258
                        continue
6✔
259
                }
260
                err = p.parseCodeBlock()
44✔
261
                if err != nil {
45✔
262
                        return false, err
1✔
263
                }
1✔
264
                tok, level, _, _ := p.parseHeading(false)
43✔
265
                if tok && level <= p.rootHeadingLevel {
44✔
266
                        return false, nil
1✔
267
                }
1✔
268
                if tok && level == p.rootHeadingLevel+1 {
47✔
269
                        return true, nil
5✔
270
                }
5✔
271
                if strings.TrimSpace(p.currentLine) != "" && !strings.HasPrefix(p.currentLine, "#+") {
41✔
272
                        // TODO skip content in property drawers
4✔
273
                        p.currTask.Description = append(p.currTask.Description, strings.Trim(p.currentLine, trimValues))
4✔
274
                }
4✔
275
                if !p.scan() {
49✔
276
                        return false, nil
12✔
277
                }
12✔
278
        }
279
}
280

281
func (p *parser) parseTask() (ok bool, err error) {
22✔
282
        p.currTask = models.Task{}
22✔
283
        heading, done, err := p.findTaskHeading()
22✔
284
        if err != nil || done {
22✔
NEW
285
                return
×
NEW
286
        }
×
287
        p.currTask.Name = heading
22✔
288
        ok, err = p.parseTaskBody()
22✔
289
        if err != nil {
23✔
290
                return
1✔
291
        }
1✔
292
        if len(p.currTask.Script) < 1 && len(p.currTask.DependsOn) < 1 {
22✔
293
                err = fmt.Errorf("task %s has no commands or required tasks", p.currTask.Name)
1✔
294
                return
1✔
295
        }
1✔
296
        p.tasks = append(p.tasks, p.currTask)
20✔
297
        return
20✔
298
}
299

300
// NewParser will read from r until it finds a valid xc heading block.
301
// If no block is found an error is returned.
302
func NewParser(r io.Reader, heading *string) (p parser, err error) {
18✔
303
        p.scanner = bufio.NewScanner(r)
18✔
304
        for p.scan() {
89✔
305
                ok, level, text, markerFound := p.parseHeading(true)
71✔
306
                if !ok {
121✔
307
                        continue
50✔
308
                }
309

310
                parsedHeading := strings.TrimSpace(text)
21✔
311
                specifiedHeadingFound := heading != nil && strings.EqualFold(parsedHeading, strings.TrimSpace(*heading))
21✔
312
                unspecifiedHeadingFound := heading == nil && (markerFound || strings.EqualFold(parsedHeading, defaultHeading))
21✔
313
                if !specifiedHeadingFound && !unspecifiedHeadingFound {
25✔
314
                        continue
4✔
315
                }
316

317
                p.rootHeadingLevel = level
17✔
318
                return
17✔
319
        }
320
        err = ErrNoTasksHeading
1✔
321
        return
1✔
322
}
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