diff --git a/topology/swiftpm_parser.go b/topology/swiftpm_parser.go new file mode 100644 index 0000000..5355369 --- /dev/null +++ b/topology/swiftpm_parser.go @@ -0,0 +1,526 @@ +package topology + +import ( + "fmt" + "path/filepath" + "strings" +) + +type swiftPMManifest struct { + manifest string + root string + targets []swiftPMTarget + products []swiftPMProduct + issues []Issue +} + +type swiftPMTarget struct { + name string + kind NodeKind + root string + sourceRoots []string + testRoots []string + memberRoots []string + excludes []string + dependencies []swiftPMDependency +} + +type swiftPMProduct struct { + name string + targets []string +} + +type swiftPMDependency struct { + kind string + name string + packageName string + conditional bool +} + +func parseSwiftPMManifest(manifest string, data []byte) swiftPMManifest { + parsed := swiftPMManifest{ + manifest: filepath.Clean(manifest), + root: filepath.Dir(filepath.Clean(manifest)), + } + text := stripSwiftComments(string(data)) + packageBody, ok := swiftCallBody(text, "Package") + if !ok { + parsed.issues = append(parsed.issues, swiftPMIssue(manifest, "computed-swiftpm-package", "Package declaration is not a literal call")) + return parsed + } + targetsText, ok := swiftNamedArray(packageBody, "targets") + if !ok { + parsed.issues = append(parsed.issues, swiftPMIssue(manifest, "computed-swiftpm-targets", "targets is not a literal array")) + } else { + for _, element := range splitSwiftTopLevel(targetsText) { + target, issue := parseSwiftPMTarget(parsed.root, element) + if issue != nil { + issue.Provider = "swiftpm" + issue.Message = manifest + ": " + issue.Message + parsed.issues = append(parsed.issues, *issue) + continue + } + if target.name != "" { + parsed.targets = append(parsed.targets, target) + } + } + } + if productsText, ok := swiftNamedArray(packageBody, "products"); ok { + for _, element := range splitSwiftTopLevel(productsText) { + product, issue := parseSwiftPMProduct(element) + if issue != nil { + issue.Provider = "swiftpm" + issue.Message = manifest + ": " + issue.Message + parsed.issues = append(parsed.issues, *issue) + continue + } + if product.name != "" { + parsed.products = append(parsed.products, product) + } + } + } + return parsed +} + +func parseSwiftPMTarget(packageRoot, element string) (swiftPMTarget, *Issue) { + call, body, ok := swiftElementCall(element) + kinds := map[string]NodeKind{ + "target": "swift-target", + "executableTarget": "swift-executable", + "testTarget": "swift-test", + "macro": "swift-macro", + "plugin": "swift-plugin", + "systemLibrary": "swift-system-library", + "binaryTarget": "swift-binary", + } + kind, supported := kinds[call] + if !ok || !supported { + return swiftPMTarget{}, &Issue{Code: "computed-swiftpm-target", Message: "target declaration is not a supported literal call"} + } + arguments := swiftNamedArguments(body) + name, ok := swiftLiteralString(arguments["name"]) + if !ok { + return swiftPMTarget{}, &Issue{Code: "computed-swiftpm-target-name", Message: "target name is not a literal string"} + } + target := swiftPMTarget{name: name, kind: kind} + explicitPath, hasPath := swiftLiteralString(arguments["path"]) + if arguments["path"] != "" && !hasPath { + return swiftPMTarget{}, &Issue{Code: "computed-swiftpm-target-path", Message: fmt.Sprintf("target %q path is not a literal string", name)} + } + if hasPath { + target.root = filepath.Clean(filepath.Join(packageRoot, filepath.FromSlash(explicitPath))) + } else { + target.root = swiftPMConventionalRoot(packageRoot, call, name) + } + + sources, sourcesOK := swiftLiteralStringArray(arguments["sources"]) + if arguments["sources"] != "" && !sourcesOK { + return swiftPMTarget{}, &Issue{Code: "computed-swiftpm-sources", Message: fmt.Sprintf("target %q sources is not a literal string array", name)} + } + excludes, excludesOK := swiftLiteralStringArray(arguments["exclude"]) + if arguments["exclude"] != "" && !excludesOK { + return swiftPMTarget{}, &Issue{Code: "computed-swiftpm-excludes", Message: fmt.Sprintf("target %q exclude is not a literal string array", name)} + } + for _, exclude := range excludes { + target.excludes = append(target.excludes, filepath.Clean(filepath.Join(target.root, filepath.FromSlash(exclude)))) + } + if len(sources) == 0 { + target.memberRoots = []string{target.root} + } else { + for _, source := range sources { + target.memberRoots = append(target.memberRoots, filepath.Clean(filepath.Join(target.root, filepath.FromSlash(source)))) + } + } + switch call { + case "testTarget": + target.testRoots = append([]string(nil), target.memberRoots...) + case "binaryTarget": + default: + target.sourceRoots = append([]string(nil), target.memberRoots...) + } + + dependencies, dependenciesOK := swiftNamedArray(body, "dependencies") + if arguments["dependencies"] != "" && !dependenciesOK { + return swiftPMTarget{}, &Issue{Code: "computed-swiftpm-dependencies", Message: fmt.Sprintf("target %q dependencies is not a literal array", name)} + } + if dependenciesOK { + for _, dependencyText := range splitSwiftTopLevel(dependencies) { + dependency, issue := parseSwiftPMDependency(dependencyText) + if issue != nil { + return swiftPMTarget{}, issue + } + target.dependencies = append(target.dependencies, dependency) + } + } + return target, nil +} + +func parseSwiftPMProduct(element string) (swiftPMProduct, *Issue) { + call, body, ok := swiftElementCall(element) + if !ok || (call != "library" && call != "executable") { + return swiftPMProduct{}, &Issue{Code: "computed-swiftpm-product", Message: "product declaration is not a supported literal call"} + } + arguments := swiftNamedArguments(body) + name, ok := swiftLiteralString(arguments["name"]) + if !ok { + return swiftPMProduct{}, &Issue{Code: "computed-swiftpm-product-name", Message: "product name is not a literal string"} + } + targets, ok := swiftLiteralStringArray(arguments["targets"]) + if !ok { + return swiftPMProduct{}, &Issue{Code: "computed-swiftpm-product-targets", Message: fmt.Sprintf("product %q targets is not a literal string array", name)} + } + return swiftPMProduct{name: name, targets: targets}, nil +} + +func parseSwiftPMDependency(text string) (swiftPMDependency, *Issue) { + if name, ok := swiftLiteralString(text); ok { + return swiftPMDependency{kind: "byName", name: name}, nil + } + call, body, ok := swiftElementCall(text) + if !ok || (call != "target" && call != "byName" && call != "product") { + return swiftPMDependency{}, &Issue{Code: "computed-swiftpm-dependency", Message: "target dependency is not a supported literal form"} + } + arguments := swiftNamedArguments(body) + name, ok := swiftLiteralString(arguments["name"]) + if !ok { + return swiftPMDependency{}, &Issue{Code: "computed-swiftpm-dependency-name", Message: "target dependency name is not a literal string"} + } + packageName, _ := swiftLiteralString(arguments["package"]) + return swiftPMDependency{ + kind: call, + name: name, + packageName: packageName, + conditional: strings.TrimSpace(arguments["condition"]) != "", + }, nil +} + +func swiftCallBody(text, name string) (string, bool) { + for index := 0; index < len(text); index++ { + if end, recognized := swiftStringEnd(text, index); recognized { + index = end - 1 + continue + } + if text[index] != name[0] || !strings.HasPrefix(text[index:], name) { + continue + } + end := index + len(name) + if (index == 0 || !isSwiftIdentifierByte(text[index-1])) && + (end == len(text) || !isSwiftIdentifierByte(text[end])) { + cursor := skipSwiftSpace(text, end) + if cursor < len(text) && text[cursor] == '(' { + if body, _, ok := swiftBalancedBody(text, cursor, '(', ')'); ok { + return body, true + } + } + } + } + return "", false +} + +func swiftNamedArray(body, name string) (string, bool) { + value := swiftNamedArguments(body)[name] + value = strings.TrimSpace(value) + if value == "" || value[0] != '[' { + return "", false + } + array, end, ok := swiftBalancedBody(value, 0, '[', ']') + return array, ok && strings.TrimSpace(value[end:]) == "" +} + +func swiftNamedArguments(body string) map[string]string { + arguments := make(map[string]string) + for _, part := range splitSwiftTopLevel(body) { + if index := swiftTopLevelColon(part); index >= 0 { + arguments[strings.TrimSpace(part[:index])] = strings.TrimSpace(part[index+1:]) + } + } + return arguments +} + +func swiftElementCall(text string) (string, string, bool) { + text = strings.TrimSpace(text) + if !strings.HasPrefix(text, ".") { + return "", "", false + } + cursor := 1 + for cursor < len(text) && isSwiftIdentifierByte(text[cursor]) { + cursor++ + } + name := text[1:cursor] + cursor = skipSwiftSpace(text, cursor) + if name == "" || cursor >= len(text) || text[cursor] != '(' { + return "", "", false + } + body, end, ok := swiftBalancedBody(text, cursor, '(', ')') + return name, body, ok && strings.TrimSpace(text[end:]) == "" +} + +func splitSwiftTopLevel(text string) []string { + var parts []string + start := 0 + var parentheses, brackets, braces int + for index := 0; index < len(text); index++ { + char := text[index] + if end, recognized := swiftStringEnd(text, index); recognized { + index = end - 1 + continue + } + switch char { + case '(': + parentheses++ + case ')': + parentheses-- + case '[': + brackets++ + case ']': + brackets-- + case '{': + braces++ + case '}': + braces-- + case ',': + if parentheses == 0 && brackets == 0 && braces == 0 { + if part := strings.TrimSpace(text[start:index]); part != "" { + parts = append(parts, part) + } + start = index + 1 + } + } + } + if part := strings.TrimSpace(text[start:]); part != "" { + parts = append(parts, part) + } + return parts +} + +func swiftTopLevelColon(text string) int { + var parentheses, brackets, braces int + for index := 0; index < len(text); index++ { + if end, recognized := swiftStringEnd(text, index); recognized { + index = end - 1 + continue + } + char := text[index] + switch char { + case '(': + parentheses++ + case ')': + parentheses-- + case '[': + brackets++ + case ']': + brackets-- + case '{': + braces++ + case '}': + braces-- + case ':': + if parentheses == 0 && brackets == 0 && braces == 0 { + return index + } + } + } + return -1 +} + +func swiftBalancedBody(text string, start int, open, close byte) (string, int, bool) { + depth := 0 + for index := start; index < len(text); index++ { + if end, recognized := swiftStringEnd(text, index); recognized { + index = end - 1 + continue + } + char := text[index] + switch char { + case open: + depth++ + case close: + depth-- + if depth == 0 { + return text[start+1 : index], index + 1, true + } + } + } + return "", start, false +} + +func swiftLiteralString(text string) (string, bool) { + text = strings.TrimSpace(text) + valueStart, valueEnd, end, ok := swiftStringInfo(text, 0) + if !ok || end != len(text) || swiftHasInterpolation(text[valueStart:valueEnd]) { + return "", false + } + value := text[valueStart:valueEnd] + if text[0] == '"' { + value = strings.ReplaceAll(value, `\"`, `"`) + value = strings.ReplaceAll(value, `\\`, `\`) + } + return value, true +} + +func swiftLiteralStringArray(text string) ([]string, bool) { + text = strings.TrimSpace(text) + if len(text) < 2 || text[0] != '[' { + return nil, false + } + body, end, ok := swiftBalancedBody(text, 0, '[', ']') + if !ok || strings.TrimSpace(text[end:]) != "" { + return nil, false + } + var values []string + for _, part := range splitSwiftTopLevel(body) { + value, ok := swiftLiteralString(part) + if !ok { + return nil, false + } + values = append(values, value) + } + return values, true +} + +func stripSwiftComments(text string) string { + var result strings.Builder + lineComment, blockComment := false, false + for index := 0; index < len(text); index++ { + char := text[index] + next := byte(0) + if index+1 < len(text) { + next = text[index+1] + } + switch { + case lineComment: + if char == '\n' { + lineComment = false + result.WriteByte(char) + } else { + result.WriteByte(' ') + } + case blockComment: + if char == '*' && next == '/' { + result.WriteString(" ") + index++ + blockComment = false + } else if char == '\n' { + result.WriteByte('\n') + } else { + result.WriteByte(' ') + } + case char == '/' && next == '/': + result.WriteString(" ") + index++ + lineComment = true + case char == '/' && next == '*': + result.WriteString(" ") + index++ + blockComment = true + default: + if end, recognized := swiftStringEnd(text, index); recognized { + result.WriteString(text[index:end]) + index = end - 1 + continue + } + result.WriteByte(char) + } + } + return result.String() +} + +// swiftStringInfo recognizes normal, raw, and multiline Swift string literals. +// The parser only needs their boundaries so punctuation inside a literal cannot +// change package structure while still rejecting interpolated values below. +func swiftStringInfo(text string, start int) (valueStart, valueEnd, end int, ok bool) { + quote, hashes, multiline, recognized := swiftStringStart(text, start) + if !recognized { + return 0, 0, start, false + } + valueStart = quote + 1 + close := `"` + strings.Repeat("#", hashes) + if multiline { + valueStart = quote + 3 + close = `"""` + strings.Repeat("#", hashes) + } + for index := valueStart; index < len(text); index++ { + if text[index] == '\\' { + index++ + for index < len(text) && text[index] == '#' { + index++ + } + continue + } + if strings.HasPrefix(text[index:], close) { + return valueStart, index, index + len(close), true + } + } + return valueStart, len(text), len(text), false +} + +func swiftStringEnd(text string, start int) (int, bool) { + _, _, end, ok := swiftStringInfo(text, start) + if end == start { + return start, false + } + return end, ok || end == len(text) +} + +func swiftStringStart(text string, start int) (quote, hashes int, multiline, ok bool) { + if start >= len(text) { + return 0, 0, false, false + } + quote = start + if text[start] == '#' { + quote = start + for quote < len(text) && text[quote] == '#' { + quote++ + } + hashes = quote - start + if quote >= len(text) || text[quote] != '"' { + return 0, 0, false, false + } + } else if text[start] != '"' { + return 0, 0, false, false + } + multiline = quote+2 < len(text) && text[quote:quote+3] == `"""` + return quote, hashes, multiline, true +} + +func swiftHasInterpolation(value string) bool { + for index := 0; index < len(value); index++ { + if value[index] != '\\' { + continue + } + index++ + for index < len(value) && value[index] == '#' { + index++ + } + if index < len(value) && value[index] == '(' { + return true + } + } + return false +} + +func swiftPMConventionalRoot(packageRoot, call, name string) string { + base := "Sources" + switch call { + case "testTarget": + base = "Tests" + case "plugin": + base = "Plugins" + } + return filepath.Clean(filepath.Join(packageRoot, base, name)) +} + +func swiftPMIssue(manifest, code, message string) Issue { + return Issue{Provider: "swiftpm", Code: code, Message: manifest + ": " + message} +} + +func isSwiftIdentifierByte(char byte) bool { + return char == '_' || char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' +} + +func skipSwiftSpace(text string, cursor int) int { + for cursor < len(text) && (text[cursor] == ' ' || text[cursor] == '\t' || text[cursor] == '\n' || text[cursor] == '\r') { + cursor++ + } + return cursor +} diff --git a/topology/swiftpm_provider.go b/topology/swiftpm_provider.go new file mode 100644 index 0000000..762f85b --- /dev/null +++ b/topology/swiftpm_provider.go @@ -0,0 +1,228 @@ +package topology + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "codemap/scanner" +) + +type swiftPMProvider struct{} + +func init() { + RegisterProvider(swiftPMProvider{}) +} + +func (swiftPMProvider) Name() string { return "swiftpm" } +func (swiftPMProvider) Version() string { return "1" } +func (swiftPMProvider) Languages() []string { return []string{"swift"} } +func (swiftPMProvider) Manifests() ManifestSelector { + return ManifestSelector{Names: []string{"Package.swift"}} +} + +func (swiftPMProvider) Build(ctx context.Context, inventory Inventory) (Fragment, error) { + manifests := make([]string, 0, len(inventory.Manifests)) + for _, manifest := range inventory.Manifests { + if filepath.Base(manifest) == "Package.swift" { + manifests = append(manifests, filepath.Clean(manifest)) + } + } + sort.Strings(manifests) + if len(manifests) == 0 { + return Fragment{Provider: "swiftpm", Coverage: Coverage{Status: CoverageUnavailable}}, nil + } + + fragment := Fragment{ + Provider: "swiftpm", + Members: make(map[ID][]string), + Coverage: Coverage{Status: CoverageComplete}, + } + for _, manifest := range manifests { + if err := ctx.Err(); err != nil { + return Fragment{}, err + } + data, err := os.ReadFile(filepath.Join(inventory.Root, manifest)) + if err != nil { + return Fragment{}, err + } + parsed := parseSwiftPMManifest(manifest, data) + packageFragment := buildSwiftPMPackageFragment(inventory.Files, parsed) + fragment.Nodes = append(fragment.Nodes, packageFragment.Nodes...) + fragment.Edges = append(fragment.Edges, packageFragment.Edges...) + for id, members := range packageFragment.Members { + fragment.Members[id] = append(fragment.Members[id], members...) + } + fragment.Coverage.Issues = append(fragment.Coverage.Issues, packageFragment.Coverage.Issues...) + } + if len(fragment.Coverage.Issues) > 0 { + fragment.Coverage.Status = CoveragePartial + } + return fragment, nil +} + +func buildSwiftPMPackageFragment(files []scanner.FileInfo, parsed swiftPMManifest) Fragment { + fragment := Fragment{ + Provider: "swiftpm", + Members: make(map[ID][]string), + Coverage: Coverage{Status: CoverageComplete, Issues: append([]Issue(nil), parsed.issues...)}, + } + targets := make(map[string][]ID) + for _, target := range parsed.targets { + id := swiftPMID(parsed.manifest, target.name) + targets[target.name] = append(targets[target.name], id) + fragment.Nodes = append(fragment.Nodes, Node{ + ID: id, + Kind: target.kind, + Name: target.name, + Manifest: parsed.manifest, + Root: target.root, + SourceRoots: uniqueSortedStrings(target.sourceRoots), + TestSourceRoots: uniqueSortedStrings(target.testRoots), + Provider: "swiftpm", + }) + fragment.Members[id] = swiftPMMembers(files, target.memberRoots, target.excludes) + } + + products := make(map[string][]swiftPMProduct) + for _, product := range parsed.products { + products[product.name] = append(products[product.name], product) + } + for _, target := range parsed.targets { + sourceID := swiftPMID(parsed.manifest, target.name) + for _, dependency := range target.dependencies { + edges, issue := resolveSwiftPMDependency(parsed.manifest, sourceID, dependency, targets, products) + fragment.Edges = append(fragment.Edges, edges...) + if issue != nil { + fragment.Coverage.Issues = append(fragment.Coverage.Issues, *issue) + } + } + } + if len(fragment.Coverage.Issues) > 0 { + fragment.Coverage.Status = CoveragePartial + } + return fragment +} + +func resolveSwiftPMDependency( + manifest string, + sourceID ID, + dependency swiftPMDependency, + targets map[string][]ID, + products map[string][]swiftPMProduct, +) ([]Edge, *Issue) { + template := Edge{ + Kind: EdgeDependency, Scope: EdgeScope(dependency.kind), + Evidence: Evidence{Manifest: manifest}, Conditional: dependency.conditional, + } + if dependency.kind == "product" { + if dependency.packageName != "" { + return nil, nil + } + definitions := products[dependency.name] + if len(definitions) == 0 { + return nil, nil + } + var candidates []ID + for _, product := range definitions { + for _, targetName := range product.targets { + candidates = append(candidates, targets[targetName]...) + } + } + if len(definitions) > 1 { + return nil, &Issue{ + Provider: "swiftpm", Code: "ambiguous-swiftpm-product", + Message: fmt.Sprintf("%s product %q has multiple local definitions", manifest, dependency.name), + Candidates: uniqueSortedIDs(candidates), + } + } + if len(candidates) != len(definitions[0].targets) { + return nil, &Issue{ + Provider: "swiftpm", Code: "unresolved-swiftpm-product-target", + Message: fmt.Sprintf("%s product %q references an unknown target", manifest, dependency.name), + Candidates: uniqueSortedIDs(candidates), + } + } + return ExpandReference(sourceID, template, ReferenceResolution{ + Status: ResolutionResolved, Targets: candidates, + }) + } + + candidates := uniqueSortedIDs(targets[dependency.name]) + if len(candidates) == 0 && dependency.kind == "byName" { + definitions := products[dependency.name] + if len(definitions) == 1 { + for _, targetName := range definitions[0].targets { + candidates = append(candidates, targets[targetName]...) + } + candidates = uniqueSortedIDs(candidates) + } else if len(definitions) > 1 { + for _, product := range definitions { + for _, targetName := range product.targets { + candidates = append(candidates, targets[targetName]...) + } + } + return nil, &Issue{ + Provider: "swiftpm", Code: "ambiguous-swiftpm-product", + Message: fmt.Sprintf("%s dependency %q matches multiple local products", manifest, dependency.name), + Candidates: uniqueSortedIDs(candidates), + } + } + } + if len(candidates) == 0 { + return nil, nil + } + status := ResolutionResolved + if len(candidates) > 1 { + status = ResolutionAmbiguous + } + edges, issue := ExpandReference(sourceID, template, ReferenceResolution{ + Status: status, Targets: candidates, Candidates: candidates, + Note: fmt.Sprintf("%s dependency %q is ambiguous", manifest, dependency.name), + }) + if issue != nil { + issue.Provider = "swiftpm" + issue.Code = "ambiguous-swiftpm-target" + } + return edges, issue +} + +func swiftPMID(manifest, target string) ID { + return ID("swiftpm:" + filepath.ToSlash(filepath.Clean(manifest)) + ":" + target) +} + +func swiftPMMembers(files []scanner.FileInfo, roots, excludes []string) []string { + var members []string + for _, file := range files { + included := false + for _, root := range roots { + if swiftPMPathWithin(file.Path, root) { + included = true + break + } + } + if !included { + continue + } + excluded := false + for _, exclude := range excludes { + if swiftPMPathWithin(file.Path, exclude) { + excluded = true + break + } + } + if !excluded { + members = append(members, filepath.Clean(file.Path)) + } + } + return uniqueSortedStrings(members) +} + +func swiftPMPathWithin(path, root string) bool { + path = filepath.Clean(path) + root = filepath.Clean(root) + return path == root || strings.HasPrefix(path, root+string(filepath.Separator)) +} diff --git a/topology/swiftpm_test.go b/topology/swiftpm_test.go new file mode 100644 index 0000000..04a8efb --- /dev/null +++ b/topology/swiftpm_test.go @@ -0,0 +1,192 @@ +package topology + +import ( + "context" + "path/filepath" + "reflect" + "testing" + + "codemap/scanner" +) + +func TestSwiftPMBuildsTargetsMembershipAndDependencies(t *testing.T) { + root := t.TempDir() + writeTopologyFixture(t, root, "Package.swift", ` +import PackageDescription +let package = Package( + name: "Demo", + products: [ + .library(name: "CoreBundle", targets: ["Core", "Support"]), + .executable(name: "demo", targets: ["App"]), + ], + targets: [ + .target(name: "Core", dependencies: ["Support"], exclude: ["Ignored.swift"]), + .target(name: "Support", path: "Sources/Shared", sources: ["Support.swift"]), + .executableTarget( + name: "App", + dependencies: [ + .target(name: "Core", condition: .when(platforms: [.macOS])), + .product(name: "Remote", package: "remote-package"), + ] + ), + .testTarget(name: "CoreTests", dependencies: [.product(name: "CoreBundle")]), + .macro(name: "DemoMacros"), + .plugin(name: "DemoPlugin", capability: .buildTool()), + .systemLibrary(name: "CLibrary"), + .binaryTarget(name: "BinaryKit", path: "Binaries/BinaryKit.xcframework"), + ] +)`) + files := []scanner.FileInfo{ + {Path: filepath.FromSlash("Sources/Core/Core.swift"), Ext: ".swift"}, + {Path: filepath.FromSlash("Sources/Core/Ignored.swift"), Ext: ".swift"}, + {Path: filepath.FromSlash("Sources/Shared/Support.swift"), Ext: ".swift"}, + {Path: filepath.FromSlash("Sources/Shared/Unlisted.swift"), Ext: ".swift"}, + {Path: filepath.FromSlash("Sources/App/main.swift"), Ext: ".swift"}, + {Path: filepath.FromSlash("Tests/CoreTests/CoreTests.swift"), Ext: ".swift"}, + {Path: filepath.FromSlash("Sources/DemoMacros/Macro.swift"), Ext: ".swift"}, + {Path: filepath.FromSlash("Plugins/DemoPlugin/plugin.swift"), Ext: ".swift"}, + {Path: filepath.FromSlash("Sources/CLibrary/include/library.h"), Ext: ".h"}, + {Path: filepath.FromSlash("Binaries/BinaryKit.xcframework/Info.plist"), Ext: ".plist"}, + } + for _, file := range files { + writeTopologyFixture(t, root, filepath.ToSlash(file.Path), "// fixture\n") + } + + fragment, err := (swiftPMProvider{}).Build(context.Background(), Inventory{ + Root: root, Files: files, Manifests: []string{"Package.swift"}, + }) + if err != nil { + t.Fatal(err) + } + graph := MergeFragments(root, []Fragment{fragment}) + if graph.Coverage.Status != CoverageComplete { + t.Fatalf("coverage = %q: %#v", graph.Coverage.Status, graph.Coverage.Issues) + } + for name, kind := range map[string]NodeKind{ + "Core": "swift-target", "Support": "swift-target", "App": "swift-executable", + "CoreTests": "swift-test", "DemoMacros": "swift-macro", "DemoPlugin": "swift-plugin", + "CLibrary": "swift-system-library", "BinaryKit": "swift-binary", + } { + id := swiftPMID("Package.swift", name) + if node, ok := graph.Nodes[id]; !ok || node.Kind != kind { + t.Fatalf("node %s = %#v", id, node) + } + } + coreID := swiftPMID("Package.swift", "Core") + supportID := swiftPMID("Package.swift", "Support") + appID := swiftPMID("Package.swift", "App") + testsID := swiftPMID("Package.swift", "CoreTests") + assertSwiftPMEdge(t, graph.Dependencies[coreID], supportID, EdgeDependency, "byName") + edge := findTopologyEdge(graph.Dependencies[appID], coreID, "target") + if edge == nil || !edge.Conditional { + t.Fatalf("conditional target edge = %#v", edge) + } + assertSwiftPMEdge(t, graph.Dependencies[testsID], coreID, EdgeDependency, "product") + assertSwiftPMEdge(t, graph.Dependencies[testsID], supportID, EdgeDependency, "product") + if got := graph.Members[coreID]; !reflect.DeepEqual(got, []string{filepath.FromSlash("Sources/Core/Core.swift")}) { + t.Fatalf("Core members = %#v", got) + } + if got := graph.Members[supportID]; !reflect.DeepEqual(got, []string{filepath.FromSlash("Sources/Shared/Support.swift")}) { + t.Fatalf("Support members = %#v", got) + } +} + +func TestSwiftPMFailsClosedForAmbiguousComputedAndEscapingDeclarations(t *testing.T) { + root := t.TempDir() + writeTopologyFixture(t, root, "Package.swift", ` +import PackageDescription +let computed = "Computed" +let package = Package( + name: "Demo", + products: [ + .library(name: "Duplicate", targets: ["One"]), + .library(name: "Duplicate", targets: ["Two"]), + ], + targets: [ + .target(name: "One"), + .target(name: "Two"), + .target(name: "App", dependencies: [.product(name: "Duplicate")]), + .target(name: computed), + .target(name: "Outside", path: "../outside"), + ] +)`) + fragment, err := (swiftPMProvider{}).Build(context.Background(), Inventory{ + Root: root, Manifests: []string{"Package.swift"}, + }) + if err != nil { + t.Fatal(err) + } + graph := MergeFragments(root, []Fragment{fragment}) + if graph.Coverage.Status != CoveragePartial { + t.Fatalf("coverage = %q, want partial", graph.Coverage.Status) + } + for _, code := range []string{"ambiguous-swiftpm-product", "computed-swiftpm-target-name", "invalid-node-path"} { + if !hasIssueCode(graph.Coverage.Issues, code) { + t.Fatalf("issues = %#v, want %s", graph.Coverage.Issues, code) + } + } + appID := swiftPMID("Package.swift", "App") + if len(graph.Dependencies[appID]) != 0 { + t.Fatalf("ambiguous product emitted edges: %#v", graph.Dependencies[appID]) + } + if _, ok := graph.Nodes[swiftPMID("Package.swift", "Outside")]; ok { + t.Fatal("escaping target retained") + } +} + +func TestSwiftPMParserSkipsStringsAndAcceptsLiteralStringForms(t *testing.T) { + parsed := parseSwiftPMManifest("Package.swift", []byte(` +let documentation = """Package(name: "Fake", targets: ["Fake"])""" +let package = Package( + name: #"Demo"#, + targets: [ + .target(name: #"Core"#, sources: ["""Core.swift"""]), + ] +) +`)) + if len(parsed.issues) != 0 { + t.Fatalf("issues = %#v", parsed.issues) + } + if len(parsed.targets) != 1 || parsed.targets[0].name != "Core" { + t.Fatalf("targets = %#v", parsed.targets) + } + if got := parsed.targets[0].memberRoots; !reflect.DeepEqual(got, []string{"Sources/Core/Core.swift"}) { + t.Fatalf("member roots = %#v", got) + } + + if _, ok := swiftLiteralString(`"Core" + "Other"`); ok { + t.Fatal("accepted a computed string as a literal") + } + if got, ok := swiftLiteralString(`"a\"b"`); !ok || got != `a"b` { + t.Fatalf("escaped literal = %q, %v", got, ok) + } +} + +func TestSwiftPMProviderMetadata(t *testing.T) { + provider := swiftPMProvider{} + if provider.Name() != "swiftpm" || provider.Version() == "" { + t.Fatalf("provider metadata = %q %q", provider.Name(), provider.Version()) + } + if got := provider.Manifests().Names; !reflect.DeepEqual(got, []string{"Package.swift"}) { + t.Fatalf("manifests = %#v", got) + } +} + +func findTopologyEdge(edges []Edge, target ID, scope EdgeScope) *Edge { + for i := range edges { + if edges[i].To == target && edges[i].Kind == EdgeDependency && edges[i].Scope == scope { + return &edges[i] + } + } + return nil +} + +func assertSwiftPMEdge(t *testing.T, edges []Edge, target ID, kind EdgeKind, scope EdgeScope) { + t.Helper() + for _, edge := range edges { + if edge.To == target && edge.Kind == kind && edge.Scope == scope { + return + } + } + t.Fatalf("edge to %q kind=%q scope=%q missing from %#v", target, kind, scope, edges) +}