Skip to content

Commit d426ca6

Browse files
authored
fix(cli): resolve import path resolution (#1545)
* fix(cli): fix import resolution * chore(cli): fix integration test * chore(cli): compile every proto file separatelly to avoid import and compilation collisions * chore(cli): add integration test for thrid_party dir scenario * chore(cli): simplify test * chore(cli): expand comment
1 parent 2d250d2 commit d426ca6

4 files changed

Lines changed: 138 additions & 27 deletions

File tree

cmd/api-linter/cli.go

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"strings"
2525

2626
"github.com/bufbuild/protocompile"
27+
"github.com/bufbuild/protocompile/linker"
2728
"github.com/bufbuild/protocompile/reporter"
2829
"github.com/googleapis/api-linter/v2/internal"
2930
"github.com/googleapis/api-linter/v2/lint"
@@ -183,25 +184,33 @@ func (c *cli) lint(rules lint.RuleRegistry, configs lint.Configs) error {
183184
Reporter: rep,
184185
}
185186

186-
// Compile files.
187-
files, err := compiler.Compile(context.Background(), c.ProtoFiles...)
188-
189-
// After compilation, check if the handler collected any errors.
190-
// This is the primary source of truth for parse errors when using a
191-
// custom reporter that continues on error.
192-
if len(collectedErrors) > 0 {
193-
errorStrings := make([]string, len(collectedErrors))
194-
for i, e := range collectedErrors {
195-
errorStrings[i] = e.Error()
187+
// Compile each file individually to avoid possible collisions
188+
// between a linted file that imports other files that are also being linted.
189+
// Otherwise, both the import resolver and the file will be "duplicated".
190+
var compiledFiles linker.Files
191+
for _, protoFile := range c.ProtoFiles {
192+
// The compiler returns a slice of files, even for a single input file.
193+
f, err := compiler.Compile(context.Background(), protoFile)
194+
// After compilation, check if the handler collected any errors.
195+
// This is the primary source of truth for parse errors when using a
196+
// custom reporter that continues on error.
197+
if len(collectedErrors) > 0 {
198+
errorStrings := make([]string, len(collectedErrors))
199+
for i, e := range collectedErrors {
200+
errorStrings[i] = e.Error()
201+
}
202+
return errors.New(strings.Join(errorStrings, "\n"))
196203
}
197-
return errors.New(strings.Join(errorStrings, "\n"))
198-
}
199204

200-
// If the reporter has no errors, but the compiler still returned one,
201-
// it's a fatal, non-recoverable error.
202-
if err != nil {
203-
return err
205+
// If the reporter has no errors, but the compiler still returned one,
206+
// it's a fatal, non-recoverable error.
207+
if err != nil {
208+
return err
209+
}
210+
// Append the compiled file(s) to the slice.
211+
compiledFiles = append(compiledFiles, f...)
204212
}
213+
files := compiledFiles
205214

206215
// The compiler returns a slice of `*linker.File`, which is the compiler's
207216
// internal representation. We convert this to a slice of the standard
@@ -413,9 +422,9 @@ func resolveImports(imports []string) []string {
413422
evaluatedAbsPath = filepath.Clean(absPath)
414423
}
415424

416-
// Check if the current import path's canonical form is the CWD's canonical form
417-
// or a subdirectory of it. If so, it's covered by ".", so we skip it.
418-
if evaluatedAbsPath == evaluatedCwd || strings.HasPrefix(evaluatedAbsPath, evaluatedCwd+string(os.PathSeparator)) {
425+
// Check if the current import path's canonical form is the CWD's canonical form.
426+
// If so, it's covered by ".", so we skip it.
427+
if evaluatedAbsPath == evaluatedCwd {
419428
continue
420429
}
421430

cmd/api-linter/cli_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ func TestResolveImports(t *testing.T) {
109109
}
110110
},
111111
want: func(_ string) []string {
112-
return []string{"."} // "test_dir" should be covered by "."
112+
return []string{".", "test_dir"}
113113
},
114114
},
115115
{
@@ -123,7 +123,7 @@ func TestResolveImports(t *testing.T) {
123123
}
124124
},
125125
want: func(_ string) []string {
126-
return []string{"."}
126+
return []string{".", "test_dir"}
127127
},
128128
},
129129
{
@@ -150,7 +150,7 @@ func TestResolveImports(t *testing.T) {
150150
}
151151
},
152152
want: func(externalDir string) []string {
153-
return []string{".", externalDir}
153+
return []string{".", "./relative_dir", "test_dir", externalDir}
154154
},
155155
},
156156
{
@@ -160,7 +160,7 @@ func TestResolveImports(t *testing.T) {
160160
},
161161
setup: defaultSetup,
162162
want: func(_ string) []string {
163-
return []string{"."} // Should still resolve to just "." as non_existent_dir is relative to CWD
163+
return []string{".", "non_existent_dir"}
164164
},
165165
},
166166
{

cmd/api-linter/integration_test.go

Lines changed: 105 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -233,8 +233,8 @@ func TestMultipleFilesFromParentDir(t *testing.T) {
233233
// a.proto imports b.proto.
234234
if err := writeFile(filepath.Join(protoDir, "a.proto"), `
235235
syntax = "proto3";
236-
package grandparent.parent;
237-
import "grandparent/parent/b.proto";
236+
package parent;
237+
import "parent/b.proto";
238238
message A {
239239
B b_field = 1;
240240
}
@@ -244,7 +244,7 @@ func TestMultipleFilesFromParentDir(t *testing.T) {
244244

245245
if err := writeFile(filepath.Join(protoDir, "b.proto"), `
246246
syntax = "proto3";
247-
package grandparent.parent;
247+
package parent;
248248
message B {}
249249
`); err != nil {
250250
t.Fatal(err)
@@ -277,6 +277,108 @@ func TestMultipleFilesFromParentDir(t *testing.T) {
277277
}
278278
}
279279

280+
func TestImportFromAnotherRoot(t *testing.T) {
281+
// This test case is based on a scenario described in:
282+
// https://github.com/googleapis/api-linter/pull/1519
283+
//
284+
// It checks that the linter can correctly resolve imports when
285+
// one import is in a directory provided by `-I` and another
286+
// is relative to the working directory. i.e.
287+
//
288+
// .
289+
// ├── api
290+
// │   ├── common
291+
// │   │   └── common.proto
292+
// │   └── v1
293+
// │   └── test.proto
294+
// └── third_party
295+
// └── google
296+
// └── api
297+
// └── field_behavior.proto
298+
299+
projDir, err := os.MkdirTemp("", "proj")
300+
if err != nil {
301+
t.Fatal(err)
302+
}
303+
defer os.RemoveAll(projDir)
304+
305+
// Create the subdirectory for protos.
306+
apiV1Dir := filepath.Join(projDir, "api", "v1")
307+
if err := os.MkdirAll(apiV1Dir, 0755); err != nil {
308+
t.Fatal(err)
309+
}
310+
apiCommonDir := filepath.Join(projDir, "api", "common")
311+
if err := os.MkdirAll(apiCommonDir, 0755); err != nil {
312+
t.Fatal(err)
313+
}
314+
thirdPartyDir := filepath.Join(projDir, "third_party", "other", "api")
315+
if err := os.MkdirAll(thirdPartyDir, 0755); err != nil {
316+
t.Fatal(err)
317+
}
318+
319+
// Write the proto files.
320+
if err := writeFile(filepath.Join(apiV1Dir, "test.proto"), `
321+
syntax = "proto3";
322+
323+
package api.v1;
324+
325+
import "other/api/useful.proto";
326+
import "api/common/common.proto";
327+
328+
message Test {
329+
other.api.Bar bar = 1;
330+
api.common.Foo foo = 2;
331+
}
332+
`); err != nil {
333+
t.Fatal(err)
334+
}
335+
336+
if err := writeFile(filepath.Join(apiCommonDir, "common.proto"), `
337+
syntax = "proto3";
338+
339+
package api.common;
340+
341+
message Foo {}
342+
`); err != nil {
343+
t.Fatal(err)
344+
}
345+
346+
if err := writeFile(filepath.Join(thirdPartyDir, "useful.proto"), `
347+
syntax = "proto3";
348+
349+
package other.api;
350+
351+
message Bar{}
352+
`); err != nil {
353+
t.Fatal(err)
354+
}
355+
356+
// Change the working directory to the project root.
357+
oldWD, err := os.Getwd()
358+
if err != nil {
359+
t.Fatal(err)
360+
}
361+
if err := os.Chdir(projDir); err != nil {
362+
t.Fatal(err)
363+
}
364+
defer os.Chdir(oldWD)
365+
366+
args := []string{
367+
"-I", "third_party",
368+
filepath.Join("api", "v1", "test.proto"),
369+
}
370+
371+
err = runCLI(args)
372+
373+
if err != nil && !errors.Is(err, ExitForLintFailure) {
374+
if strings.Contains(err.Error(), "not found") {
375+
t.Errorf("Linter failed with unexpected 'file not found' error: %v", err)
376+
} else {
377+
t.Fatalf("Linter failed with unexpected error: %v", err)
378+
}
379+
}
380+
}
381+
280382
func TestExitStatusForLintFailure(t *testing.T) {
281383
type testCase struct{ testName, rule, proto string }
282384
failCase := testCase{

internal/version.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,4 @@
1414
package internal
1515

1616
// Version is the current tagged release of the library.
17-
const Version = "2.0.0-beta.3"
17+
const Version = "2.0.0-beta.4"

0 commit comments

Comments
 (0)