Skip to content

Commit ae6d749

Browse files
committed
feat: add vale test
Refs #1122 Signed-off-by: Joseph Kato <joseph@jdkato.io>
1 parent e5a0286 commit ae6d749

11 files changed

Lines changed: 1036 additions & 57 deletions

File tree

cmd/vale/command.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ var commandInfo = map[string]string{
3737
"host-install": "Install the Vale native messaging host for the given browser.",
3838
"host-uninstall": "Uninstall the Vale native messaging host for the given browser.",
3939
"fix": "Attempt to automatically fix the given alert.",
40+
"test": "Run the test cases kept beside a configuration's rules.",
4041
}
4142

4243
// Actions are the available CLI commands.
@@ -46,6 +47,7 @@ var Actions = map[string]func(args []string, flags *core.CLIFlags) error{
4647
"ls-dirs": printDirs,
4748
"ls-vars": printVars,
4849
"sync": sync,
50+
"test": runTests,
4951

5052
// private
5153
"host-install": installNativeHost,

cmd/vale/main.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"errors"
45
"fmt"
56
"io"
67
"os"
@@ -104,10 +105,18 @@ func main() {
104105
if argc > 0 {
105106
cmd, exists := Actions[args[0]]
106107
if exists {
107-
if err := cmd(args[1:], &Flags); err != nil {
108+
err := cmd(args[1:], &Flags)
109+
stopProfiling()
110+
111+
// Failing test cases mean Vale worked and the configuration did
112+
// not, which is the same distinction `vale file.md` draws between
113+
// exiting 1 and exiting 2. The command has already reported them.
114+
if errors.Is(err, errTestFailed) {
115+
os.Exit(1)
116+
} else if err != nil {
108117
handleError(err)
109118
}
110-
stopProfiling()
119+
111120
os.Exit(0)
112121
}
113122
}

cmd/vale/test.go

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
package main
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
10+
"github.com/pterm/pterm"
11+
12+
"github.com/errata-ai/vale/v3/internal/core"
13+
"github.com/errata-ai/vale/v3/internal/testsuite"
14+
)
15+
16+
// errTestFailed reports cases that ran and did not pass.
17+
//
18+
// It is not an error in the sense the others are -- Vale worked, the
19+
// configuration did not -- so main exits 1 with it rather than printing it as
20+
// a failure of Vale's own.
21+
var errTestFailed = errors.New("test cases failed")
22+
23+
// testReport is the JSON form of a run.
24+
type testReport struct {
25+
Passed int `json:"passed"`
26+
Failed int `json:"failed"`
27+
Results []testResult `json:"results"`
28+
}
29+
30+
type testResult struct {
31+
File string `json:"file"`
32+
Name string `json:"name"`
33+
Passed bool `json:"passed"`
34+
Reason string `json:"reason,omitempty"`
35+
Got string `json:"got,omitempty"`
36+
Want string `json:"want,omitempty"`
37+
}
38+
39+
// runTests runs the test cases in the given files or directories.
40+
func runTests(args []string, flags *core.CLIFlags) error {
41+
paths, err := testsuite.Find(args)
42+
if err != nil {
43+
return core.NewE100("test", err)
44+
} else if len(paths) == 0 {
45+
return core.NewE100("test", errors.New("no test files found"))
46+
}
47+
48+
runner := testsuite.NewRunner(flags)
49+
50+
var results []testsuite.Result
51+
for _, path := range paths {
52+
cases, lErr := testsuite.Load(path)
53+
if lErr != nil {
54+
return core.NewE100("test", lErr)
55+
}
56+
57+
for _, c := range cases {
58+
results = append(results, runner.Run(c))
59+
}
60+
}
61+
62+
// A case that could not be run at all is a broken configuration, not a
63+
// failing assertion: report it as Vale's own error and stop.
64+
for _, r := range results {
65+
if r.Err != nil {
66+
return core.NewE100(filepath.Base(r.Case.Path), r.Err)
67+
}
68+
}
69+
70+
if flags.Output == "JSON" {
71+
return reportTestsJSON(results)
72+
}
73+
74+
return reportTests(results, len(paths))
75+
}
76+
77+
func reportTests(results []testsuite.Result, files int) error {
78+
failed := 0
79+
80+
for _, r := range results {
81+
if !r.Failed() {
82+
continue
83+
}
84+
failed++
85+
86+
fmt.Printf("\n%s %s %s\n\n",
87+
pterm.Red("✗"),
88+
pterm.Bold.Sprint(r.Case.Name),
89+
pterm.Gray("— "+r.Reason))
90+
91+
if r.Case.About != "" {
92+
fmt.Printf(" %s %s\n", pterm.Gray("about"), r.Case.About)
93+
}
94+
fmt.Printf(" %s %s\n\n", pterm.Gray("from"), relPath(r.Case.Path))
95+
96+
if r.Case.Want != nil {
97+
fmt.Print(renderDiff(testsuite.Diff(*r.Case.Want, r.Got)))
98+
} else {
99+
fmt.Print(indentBlock(blockOrNone(r.Got)))
100+
}
101+
}
102+
103+
summary := fmt.Sprintf("%d %s — %d passed, %d failed",
104+
files, pluralize("file", files), len(results)-failed, failed)
105+
106+
if failed > 0 {
107+
fmt.Println()
108+
pterm.Error.Println(summary)
109+
return errTestFailed
110+
}
111+
112+
pterm.Success.Println(summary)
113+
return nil
114+
}
115+
116+
func reportTestsJSON(results []testsuite.Result) error {
117+
report := testReport{Results: make([]testResult, 0, len(results))}
118+
119+
for _, r := range results {
120+
if r.Failed() {
121+
report.Failed++
122+
} else {
123+
report.Passed++
124+
}
125+
126+
out := testResult{
127+
File: r.Case.Path,
128+
Name: r.Case.Name,
129+
Passed: !r.Failed(),
130+
Reason: r.Reason,
131+
Got: r.Got,
132+
}
133+
if r.Case.Want != nil {
134+
out.Want = *r.Case.Want
135+
}
136+
137+
report.Results = append(report.Results, out)
138+
}
139+
140+
if err := printJSON(report); err != nil {
141+
return err
142+
}
143+
if report.Failed > 0 {
144+
return errTestFailed
145+
}
146+
147+
return nil
148+
}
149+
150+
// renderDiff colours a diff and indents it under its heading.
151+
func renderDiff(diff []testsuite.Line) string {
152+
if len(diff) == 0 {
153+
return indentBlock("(no alerts)")
154+
}
155+
156+
var b strings.Builder
157+
for _, line := range diff {
158+
text := fmt.Sprintf("%c %s", line.Op, line.Text)
159+
160+
switch line.Op {
161+
case testsuite.Del:
162+
text = pterm.Red(text)
163+
case testsuite.Add:
164+
text = pterm.Green(text)
165+
case testsuite.Same:
166+
text = pterm.Gray(text)
167+
}
168+
169+
fmt.Fprintf(&b, " %s\n", text)
170+
}
171+
172+
fmt.Fprintf(&b, "\n %s %s\n",
173+
pterm.Red("- expected"), pterm.Green("+ actual"))
174+
175+
return b.String()
176+
}
177+
178+
// relPath shortens a path against the working directory, since that is where
179+
// the reader is standing.
180+
func relPath(path string) string {
181+
wd, err := os.Getwd()
182+
if err != nil {
183+
return path
184+
}
185+
186+
rel, err := filepath.Rel(wd, path)
187+
if err != nil || strings.HasPrefix(rel, "..") {
188+
return path
189+
}
190+
191+
return rel
192+
}
193+
194+
// indentBlock indents a rendered block so it sits under its heading.
195+
func indentBlock(s string) string {
196+
var b strings.Builder
197+
for _, line := range strings.Split(strings.TrimRight(s, "\n"), "\n") {
198+
fmt.Fprintf(&b, " %s\n", line)
199+
}
200+
return b.String()
201+
}
202+
203+
func blockOrNone(s string) string {
204+
if strings.TrimSpace(s) == "" {
205+
return "(no alerts)"
206+
}
207+
return s
208+
}

internal/check/manager.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,9 +156,15 @@ func (mgr *Manager) addStyle(path string) error {
156156

157157
var sources []source
158158
err := system.Walk(path, func(fp string, info fs.FileInfo, err error) error {
159-
if err != nil {
159+
switch {
160+
case err != nil:
160161
return err
161-
} else if info.IsDir() || !strings.HasSuffix(info.Name(), ".yml") {
162+
case info.IsDir() || !strings.HasSuffix(info.Name(), ".yml"):
163+
return nil
164+
case core.IsTestFile(info.Name()):
165+
// A rule's cases live beside it, so the style directory holds YAML
166+
// that is not a rule. Loaded as one it fails on `extends`, and the
167+
// whole configuration stops. See #1122.
162168
return nil
163169
}
164170
sources = append(sources, source{name: info.Name(), path: fp})

internal/core/format.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,26 @@ import (
66
"strings"
77
)
88

9+
// TestFileSuffixes name the YAML that holds a rule's test cases rather than a
10+
// rule. `.yml` is the spelling Vale uses elsewhere and the one to document;
11+
// `.yaml` is accepted so that a configuration written the other way still
12+
// works.
13+
//
14+
// A rule's cases live beside it, which puts them inside the StylesPath: the
15+
// loader that has to skip them and the runner that has to find them must agree
16+
// on which is which. See #1122.
17+
var TestFileSuffixes = []string{".test.yml", ".test.yaml"}
18+
19+
// IsTestFile reports whether a file name holds test cases rather than a rule.
20+
func IsTestFile(name string) bool {
21+
for _, suffix := range TestFileSuffixes {
22+
if strings.HasSuffix(name, suffix) {
23+
return true
24+
}
25+
}
26+
return false
27+
}
28+
929
// CommentsByNormedExt determines what parts of a file we should lint -- e.g.,
1030
// we only want to lint ; comments in a Clojure file.
1131
//

internal/e2e/report_test.go

Lines changed: 12 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99
"sync"
1010
"testing"
1111
"time"
12+
13+
"github.com/errata-ai/vale/v3/internal/testsuite"
1214
)
1315

1416
// Colors are used only when the output is a terminal that asked for them.
@@ -211,30 +213,20 @@ func report(t *testing.T, s *suite, tc testCase, reason, body string) {
211213
}
212214

213215
// diffLines renders a line diff of want against got.
216+
//
217+
// The comparison is testsuite's, which `vale test` uses for the same job on
218+
// the same kind of output. Only the colouring is this suite's own.
214219
func diffLines(want, got string) string {
215-
w, g := lines(want), lines(got)
216-
common := lcs(w, g)
217-
218220
var b strings.Builder
219-
var i, j int
220221

221-
for _, c := range common {
222-
for i < len(w) && w[i] != c {
223-
fmt.Fprintf(&b, " %s- %s%s\n", red, w[i], reset)
224-
i++
225-
}
226-
for j < len(g) && g[j] != c {
227-
fmt.Fprintf(&b, " %s+ %s%s\n", green, g[j], reset)
228-
j++
222+
for _, line := range testsuite.Diff(want, got) {
223+
color := dim
224+
if line.Op == testsuite.Del {
225+
color = red
226+
} else if line.Op == testsuite.Add {
227+
color = green
229228
}
230-
fmt.Fprintf(&b, " %s %s%s\n", dim, c, reset)
231-
i, j = i+1, j+1
232-
}
233-
for ; i < len(w); i++ {
234-
fmt.Fprintf(&b, " %s- %s%s\n", red, w[i], reset)
235-
}
236-
for ; j < len(g); j++ {
237-
fmt.Fprintf(&b, " %s+ %s%s\n", green, g[j], reset)
229+
fmt.Fprintf(&b, " %s%c %s%s\n", color, line.Op, line.Text, reset)
238230
}
239231

240232
fmt.Fprintf(&b, "\n %s%s- expected %s+ actual%s\n", dim, red, green, reset)
@@ -263,39 +255,6 @@ func lines(s string) []string {
263255
return strings.Split(s, "\n")
264256
}
265257

266-
// lcs returns the longest common subsequence of a and b.
267-
func lcs(a, b []string) []string {
268-
n := make([][]int, len(a)+1)
269-
for i := range n {
270-
n[i] = make([]int, len(b)+1)
271-
}
272-
273-
for i := len(a) - 1; i >= 0; i-- {
274-
for j := len(b) - 1; j >= 0; j-- {
275-
if a[i] == b[j] {
276-
n[i][j] = n[i+1][j+1] + 1
277-
} else {
278-
n[i][j] = max(n[i+1][j], n[i][j+1])
279-
}
280-
}
281-
}
282-
283-
var out []string
284-
for i, j := 0, 0; i < len(a) && j < len(b); {
285-
switch {
286-
case a[i] == b[j]:
287-
out = append(out, a[i])
288-
i, j = i+1, j+1
289-
case n[i+1][j] >= n[i][j+1]:
290-
i++
291-
default:
292-
j++
293-
}
294-
}
295-
296-
return out
297-
}
298-
299258
// rel shortens a path for display, relative to the repository root.
300259
func rel(path string) string {
301260
root := filepath.Dir(testdata)

0 commit comments

Comments
 (0)