|
| 1 | +/* |
| 2 | +Copyright 2025 The Kubernetes Authors. |
| 3 | +
|
| 4 | +Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | +you may not use this file except in compliance with the License. |
| 6 | +You may obtain a copy of the License at |
| 7 | +
|
| 8 | + http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | +
|
| 10 | +Unless required by applicable law or agreed to in writing, software |
| 11 | +distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | +See the License for the specific language governing permissions and |
| 14 | +limitations under the License. |
| 15 | +*/ |
| 16 | +package runner |
| 17 | + |
| 18 | +import ( |
| 19 | + "encoding/json" |
| 20 | + "fmt" |
| 21 | + "go/parser" |
| 22 | + "go/token" |
| 23 | + "maps" |
| 24 | + "os" |
| 25 | + "path/filepath" |
| 26 | + "regexp" |
| 27 | + "sort" |
| 28 | + "strconv" |
| 29 | + "strings" |
| 30 | + "text/scanner" |
| 31 | + |
| 32 | + "github.com/golangci/golangci-lint/v2/pkg/result" |
| 33 | + . "github.com/onsi/ginkgo/v2" |
| 34 | + . "github.com/onsi/gomega" |
| 35 | +) |
| 36 | + |
| 37 | +const keyword = "want" |
| 38 | + |
| 39 | +type jsonResult struct { |
| 40 | + Issues []*result.Issue |
| 41 | +} |
| 42 | + |
| 43 | +type expectation struct { |
| 44 | + kind string // either "fact" or "diagnostic" |
| 45 | + name string // name of object to which fact belongs, or "package" ("fact" only) |
| 46 | + rx *regexp.Regexp |
| 47 | +} |
| 48 | + |
| 49 | +type key struct { |
| 50 | + file string |
| 51 | + line int |
| 52 | +} |
| 53 | + |
| 54 | +// Analyze analyzes the test expectations ('want'). |
| 55 | +// Inspired by: |
| 56 | +// https://github.com/golang/tools/blob/1261a24ceb1867ea7439eda244e53e7ace4ad777/go/analysis/analysistest/analysistest.go#L655-L672 |
| 57 | +func analyze(sourcePath string, rawData []byte) { |
| 58 | + GinkgoHelper() |
| 59 | + |
| 60 | + want, err := getWantFromSourcePath(sourcePath) |
| 61 | + Expect(err).ToNot(HaveOccurred()) |
| 62 | + |
| 63 | + var reportData jsonResult |
| 64 | + |
| 65 | + Expect(json.Unmarshal(rawData, &reportData)).To(Succeed()) |
| 66 | + |
| 67 | + var failures []string |
| 68 | + for _, issue := range reportData.Issues { |
| 69 | + failures = append(failures, checkMessage(want, issue.Pos, "diagnostic", issue.FromLinter, issue.Text)...) |
| 70 | + } |
| 71 | + |
| 72 | + var surplus []string |
| 73 | + |
| 74 | + for key, expects := range want { |
| 75 | + for _, exp := range expects { |
| 76 | + err := fmt.Sprintf("%s:%d: no %s was reported matching %#q", key.file, key.line, exp.kind, exp.rx) |
| 77 | + surplus = append(surplus, err) |
| 78 | + } |
| 79 | + } |
| 80 | + |
| 81 | + sort.Strings(surplus) |
| 82 | + failures = append(failures, surplus...) |
| 83 | + |
| 84 | + Expect(failures).To(BeEmpty()) |
| 85 | +} |
| 86 | + |
| 87 | +// Inspired by: |
| 88 | +// https://github.com/golang/tools/blob/1261a24ceb1867ea7439eda244e53e7ace4ad777/go/analysis/analysistest/analysistest.go#L524-L553 |
| 89 | +func parseComments(sourcePath string, fileData []byte) (map[key][]expectation, error) { |
| 90 | + fset := token.NewFileSet() |
| 91 | + |
| 92 | + // the error is ignored to let 'typecheck' handle compilation error |
| 93 | + f, _ := parser.ParseFile(fset, sourcePath, fileData, parser.ParseComments) |
| 94 | + |
| 95 | + want := make(map[key][]expectation) |
| 96 | + |
| 97 | + for _, comment := range f.Comments { |
| 98 | + for _, c := range comment.List { |
| 99 | + text := strings.TrimPrefix(c.Text, "//") |
| 100 | + if text == c.Text { // not a //-comment. |
| 101 | + text = strings.TrimPrefix(text, "/*") |
| 102 | + text = strings.TrimSuffix(text, "*/") |
| 103 | + } |
| 104 | + |
| 105 | + if i := strings.Index(text, "// "+keyword); i >= 0 { |
| 106 | + text = text[i+len("// "):] |
| 107 | + } |
| 108 | + |
| 109 | + posn := fset.Position(c.Pos()) |
| 110 | + |
| 111 | + text = strings.TrimSpace(text) |
| 112 | + |
| 113 | + if rest := strings.TrimPrefix(text, keyword); rest != text { |
| 114 | + delta, expects, err := parseExpectations(rest) |
| 115 | + if err != nil { |
| 116 | + return nil, err |
| 117 | + } |
| 118 | + |
| 119 | + want[key{sourcePath, posn.Line + delta}] = expects |
| 120 | + } |
| 121 | + } |
| 122 | + } |
| 123 | + |
| 124 | + return want, nil |
| 125 | +} |
| 126 | + |
| 127 | +// Inspired by: |
| 128 | +// https://github.com/golang/tools/blob/1261a24ceb1867ea7439eda244e53e7ace4ad777/go/analysis/analysistest/analysistest.go#L685-L745 |
| 129 | +// |
| 130 | +//nolint:cyclop |
| 131 | +func parseExpectations(text string) (lineDelta int, expects []expectation, err error) { |
| 132 | + var scanErr string |
| 133 | + |
| 134 | + sc := new(scanner.Scanner).Init(strings.NewReader(text)) |
| 135 | + sc.Error = func(_ *scanner.Scanner, msg string) { |
| 136 | + scanErr = msg // e.g. bad string escape |
| 137 | + } |
| 138 | + sc.Mode = scanner.ScanIdents | scanner.ScanStrings | scanner.ScanRawStrings | scanner.ScanInts |
| 139 | + |
| 140 | + scanRegexp := func(tok rune) (*regexp.Regexp, error) { |
| 141 | + if tok != scanner.String && tok != scanner.RawString { |
| 142 | + return nil, fmt.Errorf("got %s, want regular expression", |
| 143 | + scanner.TokenString(tok)) |
| 144 | + } |
| 145 | + |
| 146 | + pattern, _ := strconv.Unquote(sc.TokenText()) // can't fail |
| 147 | + |
| 148 | + return regexp.Compile(pattern) |
| 149 | + } |
| 150 | + |
| 151 | + for { |
| 152 | + tok := sc.Scan() |
| 153 | + switch tok { |
| 154 | + case '+': |
| 155 | + tok = sc.Scan() |
| 156 | + if tok != scanner.Int { |
| 157 | + return 0, nil, fmt.Errorf("got +%s, want +Int", scanner.TokenString(tok)) |
| 158 | + } |
| 159 | + |
| 160 | + lineDelta, _ = strconv.Atoi(sc.TokenText()) |
| 161 | + case scanner.String, scanner.RawString: |
| 162 | + rx, err := scanRegexp(tok) |
| 163 | + if err != nil { |
| 164 | + return 0, nil, err |
| 165 | + } |
| 166 | + |
| 167 | + expects = append(expects, expectation{"diagnostic", "", rx}) |
| 168 | + |
| 169 | + case scanner.Ident: |
| 170 | + name := sc.TokenText() |
| 171 | + |
| 172 | + tok = sc.Scan() |
| 173 | + if tok != ':' { |
| 174 | + return 0, nil, fmt.Errorf("got %s after %s, want ':'", |
| 175 | + scanner.TokenString(tok), name) |
| 176 | + } |
| 177 | + |
| 178 | + tok = sc.Scan() |
| 179 | + |
| 180 | + rx, err := scanRegexp(tok) |
| 181 | + if err != nil { |
| 182 | + return 0, nil, err |
| 183 | + } |
| 184 | + |
| 185 | + expects = append(expects, expectation{"diagnostic", name, rx}) |
| 186 | + |
| 187 | + case scanner.EOF: |
| 188 | + if scanErr != "" { |
| 189 | + return 0, nil, fmt.Errorf("%s", scanErr) |
| 190 | + } |
| 191 | + |
| 192 | + return lineDelta, expects, nil |
| 193 | + |
| 194 | + default: |
| 195 | + return 0, nil, fmt.Errorf("unexpected %s", scanner.TokenString(tok)) |
| 196 | + } |
| 197 | + } |
| 198 | +} |
| 199 | + |
| 200 | +// Inspired by: |
| 201 | +// https://github.com/golang/tools/blob/1261a24ceb1867ea7439eda244e53e7ace4ad777/go/analysis/analysistest/analysistest.go#L594-L617 |
| 202 | +func checkMessage(want map[key][]expectation, posn token.Position, kind, name, message string) []string { |
| 203 | + GinkgoHelper() |
| 204 | + |
| 205 | + if !filepath.IsAbs(posn.Filename) { |
| 206 | + // Output from golangci-lint prefixes the filename with several layers of "../" |
| 207 | + // and then has the absolute path to the file. |
| 208 | + // Strip the "../" so that we use the absolute path. |
| 209 | + posn.Filename = "/" + strings.ReplaceAll(posn.Filename, "../", "") |
| 210 | + } |
| 211 | + |
| 212 | + k := key{posn.Filename, posn.Line} |
| 213 | + expects := want[k] |
| 214 | + |
| 215 | + var unmatched []string |
| 216 | + |
| 217 | + for i, exp := range expects { |
| 218 | + if exp.kind == kind && (exp.name == "" || exp.name == name) { |
| 219 | + if exp.rx.MatchString(message) { |
| 220 | + // matched: remove the expectation. |
| 221 | + expects[i] = expects[len(expects)-1] |
| 222 | + expects = expects[:len(expects)-1] |
| 223 | + want[k] = expects |
| 224 | + |
| 225 | + return []string{} |
| 226 | + } |
| 227 | + |
| 228 | + unmatched = append(unmatched, fmt.Sprintf("%#q", exp.rx)) |
| 229 | + } |
| 230 | + } |
| 231 | + |
| 232 | + if unmatched == nil { |
| 233 | + return []string{fmt.Sprintf("%v: unexpected %s: %v", posn, kind, message)} |
| 234 | + } else { |
| 235 | + return []string{fmt.Sprintf("%v: %s %q does not match pattern %s", posn, kind, message, strings.Join(unmatched, " or "))} |
| 236 | + } |
| 237 | +} |
| 238 | + |
| 239 | +// getWantFromSourcePath combines all source files from the directory to get a complete list of `want` directives. |
| 240 | +func getWantFromSourcePath(sourcePath string) (map[key][]expectation, error) { |
| 241 | + var err error |
| 242 | + |
| 243 | + // Make everything absolute so that we always compare absolute paths with the wants. |
| 244 | + sourcePath, err = filepath.Abs(sourcePath) |
| 245 | + if err != nil { |
| 246 | + return nil, fmt.Errorf("error getting absolute path: %w", err) |
| 247 | + } |
| 248 | + |
| 249 | + var sourcePaths []string |
| 250 | + |
| 251 | + sourcePathInfo, err := os.Stat(sourcePath) |
| 252 | + if err != nil { |
| 253 | + return nil, fmt.Errorf("error getting file info: %w", err) |
| 254 | + } |
| 255 | + |
| 256 | + if sourcePathInfo.IsDir() { |
| 257 | + sourcePaths, err = filepath.Glob(sourcePath + "/*.go") |
| 258 | + if err != nil { |
| 259 | + return nil, fmt.Errorf("error searching for go files in dir %s: %w", sourcePathInfo.Name(), err) |
| 260 | + } |
| 261 | + } else { |
| 262 | + sourcePaths = []string{sourcePath} |
| 263 | + } |
| 264 | + |
| 265 | + want := make(map[key][]expectation) |
| 266 | + |
| 267 | + for _, path := range sourcePaths { |
| 268 | + fileData, err := os.ReadFile(path) |
| 269 | + if err != nil { |
| 270 | + return nil, fmt.Errorf("error reading file %s: %w", path, err) |
| 271 | + } |
| 272 | + |
| 273 | + fileWant, err := parseComments(path, fileData) |
| 274 | + if err != nil { |
| 275 | + return nil, fmt.Errorf("could not parse file %s comments: %w", path, err) |
| 276 | + } |
| 277 | + |
| 278 | + maps.Copy(want, fileWant) |
| 279 | + } |
| 280 | + |
| 281 | + return want, nil |
| 282 | +} |
0 commit comments