diff --git a/build/docker/debian.Dockerfile b/build/docker/debian.Dockerfile index 35bea2c9..f00d4aef 100644 --- a/build/docker/debian.Dockerfile +++ b/build/docker/debian.Dockerfile @@ -106,7 +106,35 @@ RUN apt -y update && apt -y install ca-certificates && \ python3-pip \ openjdk-17-jdk -RUN dotnet --version && go version +# Install Swift toolchain for Swift Package Manager resolution in CI. +ARG SWIFT_VERSION="6.3.3" +ENV SWIFTLY_HOME_DIR="/root/.local/share/swiftly" +ENV PATH="$SWIFTLY_HOME_DIR/bin:$PATH" +RUN apt -y update && apt -y install --no-install-recommends \ + clang \ + curl \ + libcurl4 \ + libedit2 \ + libgcc-s1 \ + libncurses6 \ + libpython3-dev \ + libsqlite3-0 \ + libstdc++6 \ + libxml2 \ + libz3-4 \ + pkg-config \ + tar \ + xz-utils \ + zlib1g && \ + curl -O https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz && \ + tar zxf swiftly-$(uname -m).tar.gz && \ + ./swiftly init --quiet-shell-followup && \ + . "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh" && \ + swiftly install "$SWIFT_VERSION" && \ + swiftly use "$SWIFT_VERSION" && \ + rm -f swiftly swiftly-$(uname -m).tar.gz + +RUN dotnet --version && go version && swift --version RUN apt update -y && \ apt install lsb-release apt-transport-https ca-certificates software-properties-common -y && \ diff --git a/internal/file/exclusion.go b/internal/file/exclusion.go index cf01c9d7..5bfc6923 100644 --- a/internal/file/exclusion.go +++ b/internal/file/exclusion.go @@ -22,6 +22,7 @@ var defaultExclusions = DefaultExclusionList{ "obj", // nuget "bower_components", // bower ".vscode-test", // excluding testing framework + ".build", // swiftpm checkouts and build artifacts }, } diff --git a/internal/file/exclusion_test.go b/internal/file/exclusion_test.go index 7e05a827..94301967 100644 --- a/internal/file/exclusion_test.go +++ b/internal/file/exclusion_test.go @@ -60,6 +60,7 @@ func TestExclusionsWithEmptyTokenEnvVariable(t *testing.T) { "**/obj/**", "**/bower_components/**", "**/.vscode-test/**", + "**/.build/**", } defaultExclusions := Exclusions() assert.Equal(t, gt, defaultExclusions) diff --git a/internal/file/finder.go b/internal/file/finder.go index 6ea0b04c..e39ec4d3 100644 --- a/internal/file/finder.go +++ b/internal/file/finder.go @@ -219,7 +219,14 @@ func (finder *Finder) GetSupportedFormats() ([]*CompiledFormat, error) { LockFileRegexes: []string{""}, } + swiftEntry := &Format{ + ManifestFileRegex: "^Package\\.swift$", + DocumentationUrl: "https://docs.debricked.com/overview/language-support/swift", + LockFileRegexes: []string{"^Package\\.resolved$", "^\\.spm\\.debricked\\.lock$"}, + } + formats = append(formats, sbtEntry) + formats = append(formats, swiftEntry) var compiledDependencyFileFormats []*CompiledFormat for _, format := range formats { diff --git a/internal/resolution/pm/pm.go b/internal/resolution/pm/pm.go index f298a751..960cc5f7 100644 --- a/internal/resolution/pm/pm.go +++ b/internal/resolution/pm/pm.go @@ -13,6 +13,7 @@ import ( "github.com/debricked/cli/internal/resolution/pm/poetry" "github.com/debricked/cli/internal/resolution/pm/pub" "github.com/debricked/cli/internal/resolution/pm/sbt" + "github.com/debricked/cli/internal/resolution/pm/swift" "github.com/debricked/cli/internal/resolution/pm/uv" "github.com/debricked/cli/internal/resolution/pm/yarn" ) @@ -38,5 +39,6 @@ func Pms() []IPm { composer.NewPm(), sbt.NewPm(), pub.NewPm(), + swift.NewPm(), } } diff --git a/internal/resolution/pm/pm_test.go b/internal/resolution/pm/pm_test.go index 9e9a76c2..7ba82abe 100644 --- a/internal/resolution/pm/pm_test.go +++ b/internal/resolution/pm/pm_test.go @@ -14,6 +14,7 @@ func TestPms(t *testing.T) { "gradle", "composer", "pub", + "swift", } for _, pmName := range pmNames { diff --git a/internal/resolution/pm/swift/cmd_factory.go b/internal/resolution/pm/swift/cmd_factory.go new file mode 100644 index 00000000..2a47f59a --- /dev/null +++ b/internal/resolution/pm/swift/cmd_factory.go @@ -0,0 +1,63 @@ +package swift + +import ( + "os" + "os/exec" + "path/filepath" +) + +const ( + resolveCmd = "resolve" + showDeps = "show-dependencies" +) + +type ICmdFactory interface { + MakeResolveCmd(manifestFile string) (*exec.Cmd, error) + MakeDepsCmd(manifestFile string) (*exec.Cmd, error) +} + +type IExecPath interface { + LookPath(file string) (string, error) +} + +type ExecPath struct{} + +func (_ ExecPath) LookPath(file string) (string, error) { + return exec.LookPath(file) +} + +type CmdFactory struct { + execPath IExecPath +} + +func (cmdf CmdFactory) MakeResolveCmd(manifestFile string) (*exec.Cmd, error) { + swiftPath, err := cmdf.execPath.LookPath("swift") + if err != nil { + return nil, err + } + + workingDir := filepath.Dir(filepath.Clean(manifestFile)) + + return &exec.Cmd{ + Path: swiftPath, + Args: []string{"swift", "package", resolveCmd}, + Dir: workingDir, + Env: os.Environ(), + }, nil +} + +func (cmdf CmdFactory) MakeDepsCmd(manifestFile string) (*exec.Cmd, error) { + swiftPath, err := cmdf.execPath.LookPath("swift") + if err != nil { + return nil, err + } + + workingDir := filepath.Dir(filepath.Clean(manifestFile)) + + return &exec.Cmd{ + Path: swiftPath, + Args: []string{"swift", "package", showDeps, "--format", "json"}, + Dir: workingDir, + Env: os.Environ(), + }, nil +} diff --git a/internal/resolution/pm/swift/cmd_factory_test.go b/internal/resolution/pm/swift/cmd_factory_test.go new file mode 100644 index 00000000..28d1ffbb --- /dev/null +++ b/internal/resolution/pm/swift/cmd_factory_test.go @@ -0,0 +1,69 @@ +package swift + +import ( + "errors" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +type execPathMock struct{} + +type failingExecPathMock struct{} + +func (execPathMock) LookPath(file string) (string, error) { + return "/usr/bin/" + file, nil +} + +func (failingExecPathMock) LookPath(_ string) (string, error) { + return "", errors.New("swift executable not found") +} + +func TestMakeResolveCmd(t *testing.T) { + factory := CmdFactory{execPath: execPathMock{}} + manifest := filepath.Join("some", "path", "Package.swift") + + cmd, err := factory.MakeResolveCmd(manifest) + assert.NoError(t, err) + assert.NotNil(t, cmd) + assert.Equal(t, "/usr/bin/swift", cmd.Path) + assert.Contains(t, cmd.Args, "swift") + assert.Contains(t, cmd.Args, "package") + assert.Contains(t, cmd.Args, "resolve") + assert.Equal(t, filepath.Dir(manifest), cmd.Dir) +} + +func TestMakeDepsCmd(t *testing.T) { + factory := CmdFactory{execPath: execPathMock{}} + manifest := filepath.Join("some", "path", "Package.swift") + + cmd, err := factory.MakeDepsCmd(manifest) + assert.NoError(t, err) + assert.NotNil(t, cmd) + assert.Equal(t, "/usr/bin/swift", cmd.Path) + assert.Contains(t, cmd.Args, "swift") + assert.Contains(t, cmd.Args, "package") + assert.Contains(t, cmd.Args, "show-dependencies") + assert.Contains(t, cmd.Args, "--format") + assert.Contains(t, cmd.Args, "json") + assert.Equal(t, filepath.Dir(manifest), cmd.Dir) +} + +func TestMakeResolveCmdLookupError(t *testing.T) { + factory := CmdFactory{execPath: failingExecPathMock{}} + + cmd, err := factory.MakeResolveCmd("Package.swift") + + assert.Nil(t, cmd) + assert.EqualError(t, err, "swift executable not found") +} + +func TestMakeDepsCmdLookupError(t *testing.T) { + factory := CmdFactory{execPath: failingExecPathMock{}} + + cmd, err := factory.MakeDepsCmd("Package.swift") + + assert.Nil(t, cmd) + assert.EqualError(t, err, "swift executable not found") +} diff --git a/internal/resolution/pm/swift/job.go b/internal/resolution/pm/swift/job.go new file mode 100644 index 00000000..4ed01576 --- /dev/null +++ b/internal/resolution/pm/swift/job.go @@ -0,0 +1,186 @@ +package swift + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "regexp" + "strings" + + "github.com/debricked/cli/internal/resolution/job" + "github.com/debricked/cli/internal/resolution/pm/util" +) + +const ( + executableNotFoundErrRegex = `executable file not found` + depsFileName = ".spm.debricked.lock" +) + +// dependencyNode mirrors the `swift package show-dependencies --format json` +// tree, which carries every field the backend needs to rebuild the transitive +// dependency tree: package identity, name, source URL, version and children. +type dependencyNode struct { + Identity string `json:"identity"` + Name string `json:"name"` + URL string `json:"url"` + Version string `json:"version"` + Path string `json:"path"` + Dependencies []dependencyNode `json:"dependencies"` +} + +// Job resolves Swift dependencies and emits a debricked lock file containing +// the full dependency tree from `swift package show-dependencies --format json`. +type Job struct { + job.BaseJob + cmdFactory ICmdFactory +} + +func NewJob(file string, cmdFactory ICmdFactory) *Job { + return &Job{ + BaseJob: job.NewBaseJob(file), + cmdFactory: cmdFactory, + } +} + +func (j *Job) Run() { + status := "generating Package.resolved" + j.SendStatus(status) + + resolveCmd, err := j.cmdFactory.MakeResolveCmd(j.GetFile()) + if err != nil { + j.handleError(j.createError(err.Error(), "", status)) + + return + } + + if output, err := resolveCmd.CombinedOutput(); err != nil { + exitErr := j.GetExitError(err, string(output)) + errorMessage := strings.Join([]string{string(output), exitErr.Error()}, "") + hint := j.symlinkErrorHint(string(output)) + if hint != "" { + errorMessage = errorMessage + "\n" + hint + } + j.handleError(j.createError(errorMessage, resolveCmd.String(), status)) + + return + } + + status = "generating .spm.debricked.lock" + j.SendStatus(status) + + depsCmd, err := j.cmdFactory.MakeDepsCmd(j.GetFile()) + if err != nil { + j.handleError(j.createError(err.Error(), "", status)) + + return + } + + depsOutput, err := depsCmd.CombinedOutput() + if err != nil { + exitErr := j.GetExitError(err, string(depsOutput)) + errorMessage := strings.Join([]string{string(depsOutput), exitErr.Error()}, "") + hint := j.symlinkErrorHint(string(depsOutput)) + if hint != "" { + errorMessage = errorMessage + "\n" + hint + } + j.handleError(j.createError(errorMessage, depsCmd.String(), status)) + + return + } + + status = "writing .spm.debricked.lock" + j.SendStatus(status) + + tree, err := extractDependencyTree(depsOutput) + if err != nil { + j.handleError(j.createError(err.Error(), depsCmd.String(), status)) + + return + } + + err = os.WriteFile(util.MakePathFromManifestFile(j.GetFile(), depsFileName), tree, 0600) + if err != nil { + j.handleError(j.createError(err.Error(), "", status)) + + return + } +} + +// extractDependencyTree strips non-JSON command noise and verifies that the +// output holds a complete dependency tree before it is persisted for upload. +func extractDependencyTree(output []byte) ([]byte, error) { + start := bytes.IndexByte(output, '{') + end := bytes.LastIndexByte(output, '}') + if start < 0 || end < start { + return nil, errors.New("swift package show-dependencies did not return a JSON dependency tree") + } + + tree := bytes.TrimSpace(output[start : end+1]) + + var root dependencyNode + if err := json.Unmarshal(tree, &root); err != nil { + return nil, err + } + + if root.Identity == "" && root.Name == "" { + return nil, errors.New("swift dependency tree is missing root package information") + } + + return tree, nil +} + +func (j *Job) symlinkErrorHint(output string) string { + if !strings.Contains(output, "unable to create symlink") { + return "" + } + + hint := "\nSymlink creation failed. On Windows, this typically requires:\n" + + " 1. Enable Developer Mode (Settings > Update & Security > For developers)\n" + + " 2. Run with elevated permissions (Administrator), or\n" + + " 3. Use WSL2 with Swift toolchain\n" + + "Reference: https://github.com/apple/swift/issues/61947" + + return hint +} + +func (j *Job) createError(errorStr string, cmd string, status string) job.IError { + cmdError := util.NewPMJobError(errorStr) + cmdError.SetCommand(cmd) + cmdError.SetStatus(status) + + return cmdError +} + +func (j *Job) handleError(cmdError job.IError) { + expressions := []string{ + executableNotFoundErrRegex, + } + + for _, expression := range expressions { + regex := regexp.MustCompile(expression) + matches := regex.FindAllStringSubmatch(cmdError.Error(), -1) + + if len(matches) > 0 { + cmdError = j.addDocumentation(expression, matches, cmdError) + j.Errors().Append(cmdError) + + return + } + } + + j.Errors().Append(cmdError) +} + +func (j *Job) addDocumentation(expr string, _ [][]string, cmdError job.IError) job.IError { + documentation := cmdError.Documentation() + + switch expr { + case executableNotFoundErrRegex: + documentation = j.GetExecutableNotFoundErrorDocumentation("Swift") + } + + cmdError.SetDocumentation(documentation) + + return cmdError +} diff --git a/internal/resolution/pm/swift/job_test.go b/internal/resolution/pm/swift/job_test.go new file mode 100644 index 00000000..cd0dd6a7 --- /dev/null +++ b/internal/resolution/pm/swift/job_test.go @@ -0,0 +1,123 @@ +package swift + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + + jobTestdata "github.com/debricked/cli/internal/resolution/job/testdata" + "github.com/debricked/cli/internal/resolution/pm/swift/testdata" + "github.com/stretchr/testify/assert" +) + +func TestNewJob(t *testing.T) { + j := NewJob("Package.swift", testdata.CmdFactoryMock{}) + assert.Equal(t, "Package.swift", j.GetFile()) + assert.False(t, j.Errors().HasError()) +} + +func TestRunCmdErrExecutableNotFound(t *testing.T) { + execErr := errors.New("exec: \"swift\": executable file not found in $PATH") + j := NewJob("Package.swift", testdata.CmdFactoryMock{LockErr: execErr}) + + go jobTestdata.WaitStatus(j) + j.Run() + + errs := j.Errors().GetAll() + assert.Len(t, errs, 1) + assert.Contains(t, errs[0].Error(), "executable file not found") + assert.Contains(t, errs[0].Documentation(), "Swift wasn't found") +} + +func TestRunDepsCmdErrExecutableNotFound(t *testing.T) { + execErr := errors.New("exec: \"swift\": executable file not found in $PATH") + j := NewJob("Package.swift", testdata.CmdFactoryMock{Name: "echo", Arg: "ok", DepsErr: execErr}) + + go jobTestdata.WaitStatus(j) + j.Run() + + errs := j.Errors().GetAll() + assert.Len(t, errs, 1) + assert.Contains(t, errs[0].Error(), "executable file not found") + assert.Contains(t, errs[0].Documentation(), "Swift wasn't found") +} + +func TestRunSuccess(t *testing.T) { + tmpDir := t.TempDir() + manifest := filepath.Join(tmpDir, "Package.swift") + assert.NoError(t, os.WriteFile(manifest, []byte("// swift-tools-version: 5.9\n"), 0600)) + + depsFile, err := filepath.Abs(filepath.Join("testdata", "dependencies.json")) + assert.NoError(t, err) + + j := NewJob(manifest, testdata.CmdFactoryMock{Name: "echo", Arg: "ok", DepsFile: depsFile}) + go jobTestdata.WaitStatus(j) + j.Run() + + assert.False(t, j.Errors().HasError()) + lockContent, statErr := os.ReadFile(filepath.Join(tmpDir, ".spm.debricked.lock")) + assert.NoError(t, statErr) + + var root dependencyNode + assert.NoError(t, json.Unmarshal(lockContent, &root)) + assert.Equal(t, "example-app", root.Identity) + assert.Len(t, root.Dependencies, 2) + assert.Equal(t, "swift-nio", root.Dependencies[0].Identity) + assert.Equal(t, "2.65.0", root.Dependencies[0].Version) + assert.Len(t, root.Dependencies[0].Dependencies, 1) + assert.Equal(t, "swift-collections", root.Dependencies[0].Dependencies[0].Identity) +} + +func TestRunInvalidDependencyTree(t *testing.T) { + tmpDir := t.TempDir() + manifest := filepath.Join(tmpDir, "Package.swift") + assert.NoError(t, os.WriteFile(manifest, []byte("// swift-tools-version: 5.9\n"), 0600)) + + j := NewJob(manifest, testdata.CmdFactoryMock{Name: "echo", Arg: "ok"}) + go jobTestdata.WaitStatus(j) + j.Run() + + assert.True(t, j.Errors().HasError()) + assert.Contains(t, j.Errors().GetAll()[0].Error(), "did not return a JSON dependency tree") + _, statErr := os.Stat(filepath.Join(tmpDir, ".spm.debricked.lock")) + assert.Error(t, statErr) +} + +func TestExtractDependencyTree(t *testing.T) { + cases := []struct { + name string + output string + wantErr string + }{ + {name: "plain tree", output: `{"identity":"app","name":"App","version":"unspecified","dependencies":[]}`}, + {name: "tree with surrounding command noise", output: "Fetching package\n{\"identity\":\"app\",\"name\":\"App\",\"dependencies\":[]}\n"}, + {name: "no json", output: "error: could not resolve dependencies", wantErr: "did not return a JSON dependency tree"}, + {name: "malformed json", output: `{"identity": "app",}`, wantErr: "invalid character"}, + {name: "missing root package", output: `{"dependencies":[]}`, wantErr: "missing root package information"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + tree, err := extractDependencyTree([]byte(c.output)) + if c.wantErr != "" { + assert.ErrorContains(t, err, c.wantErr) + assert.Nil(t, tree) + + return + } + assert.NoError(t, err) + assert.True(t, json.Valid(tree)) + }) + } +} + +func TestSymlinkErrorHint(t *testing.T) { + j := NewJob("Package.swift", testdata.CmdFactoryMock{}) + hint := j.symlinkErrorHint("error: unable to create symlink foo: Permission denied") + assert.Contains(t, hint, "Developer Mode") + assert.Contains(t, hint, "Administrator") + assert.Contains(t, hint, "WSL2") + assert.Empty(t, j.symlinkErrorHint("error: another failure")) +} diff --git a/internal/resolution/pm/swift/pm.go b/internal/resolution/pm/swift/pm.go new file mode 100644 index 00000000..ce3fc965 --- /dev/null +++ b/internal/resolution/pm/swift/pm.go @@ -0,0 +1,23 @@ +package swift + +const Name = "swift" + +type Pm struct { + name string +} + +func NewPm() Pm { + return Pm{ + name: Name, + } +} + +func (pm Pm) Name() string { + return pm.name +} + +func (_ Pm) Manifests() []string { + return []string{ + `Package\.swift$`, + } +} diff --git a/internal/resolution/pm/swift/pm_test.go b/internal/resolution/pm/swift/pm_test.go new file mode 100644 index 00000000..a27d1d9f --- /dev/null +++ b/internal/resolution/pm/swift/pm_test.go @@ -0,0 +1,19 @@ +package swift + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestName(t *testing.T) { + pm := NewPm() + assert.Equal(t, Name, pm.Name()) +} + +func TestManifests(t *testing.T) { + pm := NewPm() + manifests := pm.Manifests() + assert.Len(t, manifests, 1) + assert.Equal(t, `Package\.swift$`, manifests[0]) +} diff --git a/internal/resolution/pm/swift/strategy.go b/internal/resolution/pm/swift/strategy.go new file mode 100644 index 00000000..be7db735 --- /dev/null +++ b/internal/resolution/pm/swift/strategy.go @@ -0,0 +1,20 @@ +package swift + +import "github.com/debricked/cli/internal/resolution/job" + +type Strategy struct { + files []string +} + +func NewStrategy(files []string) Strategy { + return Strategy{files: files} +} + +func (s Strategy) Invoke() ([]job.IJob, error) { + var jobs []job.IJob + for _, file := range s.files { + jobs = append(jobs, NewJob(file, CmdFactory{execPath: ExecPath{}})) + } + + return jobs, nil +} diff --git a/internal/resolution/pm/swift/strategy_test.go b/internal/resolution/pm/swift/strategy_test.go new file mode 100644 index 00000000..1d8972e0 --- /dev/null +++ b/internal/resolution/pm/swift/strategy_test.go @@ -0,0 +1,35 @@ +package swift + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewStrategy(t *testing.T) { + s := NewStrategy(nil) + assert.NotNil(t, s) + assert.Len(t, s.files, 0) + + s = NewStrategy([]string{"file"}) + assert.Len(t, s.files, 1) +} + +func TestStrategyInvoke(t *testing.T) { + cases := [][]string{ + {}, + {"Package.swift"}, + {"a/Package.swift", "b/Package.swift"}, + } + + for _, files := range cases { + filesCopy := append([]string{}, files...) + name := "len=" + string(rune(len(filesCopy))) + t.Run(name, func(t *testing.T) { + s := NewStrategy(filesCopy) + jobs, err := s.Invoke() + assert.NoError(t, err) + assert.Len(t, jobs, len(filesCopy)) + }) + } +} diff --git a/internal/resolution/pm/swift/testdata/cmd_factory_mock.go b/internal/resolution/pm/swift/testdata/cmd_factory_mock.go new file mode 100644 index 00000000..9db746b5 --- /dev/null +++ b/internal/resolution/pm/swift/testdata/cmd_factory_mock.go @@ -0,0 +1,49 @@ +package testdata + +import ( + "os/exec" + "path/filepath" + "runtime" +) + +type CmdFactoryMock struct { + LockErr error + DepsErr error + Name string + Arg string + // DepsFile makes MakeDepsCmd print the contents of the given file, which + // allows tests to feed a realistic dependency tree to the job. + DepsFile string +} + +func (f CmdFactoryMock) MakeResolveCmd(_ string) (*exec.Cmd, error) { + if len(f.Arg) == 0 { + f.Arg = `"MakeResolveCmd"` + } + + if runtime.GOOS == "windows" && f.Name == "echo" { + return exec.Command("cmd", "/C", f.Name, f.Arg), f.LockErr + } + + return exec.Command(f.Name, f.Arg), f.LockErr +} + +func (f CmdFactoryMock) MakeDepsCmd(_ string) (*exec.Cmd, error) { + if len(f.DepsFile) > 0 { + if runtime.GOOS == "windows" { + return exec.Command("cmd", "/C", "type", filepath.FromSlash(f.DepsFile)), f.DepsErr + } + + return exec.Command("cat", f.DepsFile), f.DepsErr + } + + if len(f.Arg) == 0 { + f.Arg = `"MakeDepsCmd"` + } + + if runtime.GOOS == "windows" && f.Name == "echo" { + return exec.Command("cmd", "/C", f.Name, f.Arg), f.DepsErr + } + + return exec.Command(f.Name, f.Arg), f.DepsErr +} diff --git a/internal/resolution/pm/swift/testdata/dependencies.json b/internal/resolution/pm/swift/testdata/dependencies.json new file mode 100644 index 00000000..5d0549ad --- /dev/null +++ b/internal/resolution/pm/swift/testdata/dependencies.json @@ -0,0 +1,36 @@ +{ + "identity" : "example-app", + "name" : "ExampleApp", + "url" : "/workspace/example-app", + "version" : "unspecified", + "path" : "/workspace/example-app", + "dependencies" : [ + { + "identity" : "swift-nio", + "name" : "swift-nio", + "url" : "https://github.com/apple/swift-nio.git", + "version" : "2.65.0", + "path" : "/workspace/example-app/.build/checkouts/swift-nio", + "dependencies" : [ + { + "identity" : "swift-collections", + "name" : "swift-collections", + "url" : "https://github.com/apple/swift-collections.git", + "version" : "1.1.0", + "path" : "/workspace/example-app/.build/checkouts/swift-collections", + "dependencies" : [ + ] + } + ] + }, + { + "identity" : "swift-log", + "name" : "swift-log", + "url" : "https://github.com/apple/swift-log.git", + "version" : "1.6.1", + "path" : "/workspace/example-app/.build/checkouts/swift-log", + "dependencies" : [ + ] + } + ] +} diff --git a/internal/resolution/resolver.go b/internal/resolution/resolver.go index 4bf12cc9..4e41dc00 100644 --- a/internal/resolution/resolver.go +++ b/internal/resolution/resolver.go @@ -276,6 +276,9 @@ func shouldGenerateLock(fileGroup file.Group, regenerate int) bool { if !fileGroup.HasFile() { return false } + if isSwiftManifest(fileGroup.ManifestFile) { + return shouldGenerateSwiftLock(fileGroup, regenerate) + } switch regenerate { case 0: return !fileGroup.HasLockFiles() || shouldGeneratePubDepsFile(fileGroup) @@ -288,6 +291,29 @@ func shouldGenerateLock(fileGroup file.Group, regenerate int) bool { return false } +func isSwiftManifest(manifestFile string) bool { + return strings.EqualFold(filepath.Base(manifestFile), "Package.swift") +} + +func shouldGenerateSwiftLock(fileGroup file.Group, regenerate int) bool { + if regenerate == 2 { + return true + } + + nativeLockExists := false + debrickedLockExists := false + for _, lockFile := range fileGroup.LockFiles { + switch filepath.Base(lockFile) { + case "Package.resolved": + nativeLockExists = true + case ".spm.debricked.lock": + debrickedLockExists = true + } + } + + return !nativeLockExists || !debrickedLockExists +} + func onlyNonNativeLockFiles(lockFiles []string) bool { debrickedLockFilePattern := regexp.MustCompile(`.*\.debricked\.lock`) for _, lockFile := range lockFiles { diff --git a/internal/resolution/resolver_test.go b/internal/resolution/resolver_test.go index 8f4584a8..14be761f 100644 --- a/internal/resolution/resolver_test.go +++ b/internal/resolution/resolver_test.go @@ -199,6 +199,35 @@ func TestResolveDirWithManifestFiles(t *testing.T) { } } +func TestShouldGenerateSwiftLockUntilBothFilesExist(t *testing.T) { + tests := []struct { + name string + lockFiles []string + want bool + }{ + {name: "no lock files", want: true}, + {name: "native lock only", lockFiles: []string{"Package.resolved"}, want: true}, + {name: "debricked lock only", lockFiles: []string{".spm.debricked.lock"}, want: true}, + {name: "both lock files", lockFiles: []string{"Package.resolved", ".spm.debricked.lock"}, want: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + group := file.Group{ + ManifestFile: "Package.swift", + LockFiles: test.lockFiles, + } + assert.Equal(t, test.want, shouldGenerateLock(group, 0)) + }) + } + + group := file.Group{ + ManifestFile: "Package.swift", + LockFiles: []string{"Package.resolved", ".spm.debricked.lock"}, + } + assert.True(t, shouldGenerateLock(group, 2)) +} + func TestResolveDirWithExclusions(t *testing.T) { f := testdata.NewFinderMock() groups := file.Groups{} diff --git a/internal/resolution/strategy/strategy_factory.go b/internal/resolution/strategy/strategy_factory.go index 1e2b30be..d4e2afa2 100644 --- a/internal/resolution/strategy/strategy_factory.go +++ b/internal/resolution/strategy/strategy_factory.go @@ -16,6 +16,7 @@ import ( "github.com/debricked/cli/internal/resolution/pm/poetry" "github.com/debricked/cli/internal/resolution/pm/pub" "github.com/debricked/cli/internal/resolution/pm/sbt" + "github.com/debricked/cli/internal/resolution/pm/swift" "github.com/debricked/cli/internal/resolution/pm/uv" "github.com/debricked/cli/internal/resolution/pm/yarn" ) @@ -62,6 +63,8 @@ func (sf Factory) Make(pmFileBatch file.IBatch, paths []string) (IStrategy, erro return sbt.NewStrategy(pmFileBatch.Files()), nil case pub.Name: return pub.NewStrategy(pmFileBatch.Files()), nil + case swift.Name: + return swift.NewStrategy(pmFileBatch.Files()), nil default: return nil, fmt.Errorf("failed to make strategy from %s", name) } diff --git a/internal/resolution/strategy/strategy_factory_test.go b/internal/resolution/strategy/strategy_factory_test.go index 7c2f8b90..1886b11c 100644 --- a/internal/resolution/strategy/strategy_factory_test.go +++ b/internal/resolution/strategy/strategy_factory_test.go @@ -13,6 +13,7 @@ import ( "github.com/debricked/cli/internal/resolution/pm/poetry" "github.com/debricked/cli/internal/resolution/pm/pub" "github.com/debricked/cli/internal/resolution/pm/sbt" + "github.com/debricked/cli/internal/resolution/pm/swift" "github.com/debricked/cli/internal/resolution/pm/testdata" "github.com/debricked/cli/internal/resolution/pm/yarn" "github.com/stretchr/testify/assert" @@ -43,6 +44,7 @@ func TestMake(t *testing.T) { composer.Name: composer.NewStrategy(nil), sbt.Name: sbt.NewStrategy(nil), pub.Name: pub.NewStrategy(nil), + swift.Name: swift.NewStrategy(nil), } f := NewStrategyFactory() var batch file.IBatch