diff --git a/cmd/scip/main.go b/cmd/scip/main.go index c801b029..d9fc5441 100644 --- a/cmd/scip/main.go +++ b/cmd/scip/main.go @@ -26,7 +26,8 @@ func commands() []*cli.Command { stats := statsCommand() test := testCommand() convert := convertCommand() - return []*cli.Command{&lint, &print, &snapshot, &stats, &test, &convert} + merge := mergeCommand() + return []*cli.Command{&lint, &print, &snapshot, &stats, &test, &convert, &merge} } //go:embed version.txt diff --git a/cmd/scip/merge.go b/cmd/scip/merge.go new file mode 100644 index 00000000..428c5d13 --- /dev/null +++ b/cmd/scip/merge.go @@ -0,0 +1,258 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net/url" + "os" + "path" + "strings" + + "github.com/urfave/cli/v3" + "google.golang.org/protobuf/proto" + + "github.com/scip-code/scip/bindings/go/scip" +) + +type mergeFlags struct { + output string + projectRootOverride string +} + +func mergeCommand() cli.Command { + var flags mergeFlags + return cli.Command{ + Name: "merge", + Usage: "Merge multiple SCIP indexes into a single SCIP index", + Description: `Merges two or more SCIP indexes into one. + +The output project_root is inferred as the common URI ancestor of the input +indexes' project_root values. Each input document's relative_path is rewritten +to be relative to that common root. + +For example, given: + + a.scip with project_root file:///repo/frontend, document src/a.ts + b.scip with project_root file:///repo/backend, document src/b.go + +The merged index will have: + + project_root file:///repo + documents frontend/src/a.ts and backend/src/b.go + +Documents and symbols are deduplicated after rewriting. + +Use --project-root to override the inferred root (each input's root must then +be a descendant of the override). + +Example usage: + + scip merge --output merged.scip a.scip b.scip c.scip`, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "output", + Usage: "Path to write the merged SCIP index", + Destination: &flags.output, + Value: "index.scip", + }, + &cli.StringFlag{ + Name: "project-root", + Usage: "Override the inferred output project_root URI. Each input project_root must be a descendant of this URI.", + Destination: &flags.projectRootOverride, + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + inputs := cmd.Args().Slice() + if len(inputs) == 0 { + return errors.New("at least one input SCIP index path is required") + } + return mergeMain(inputs, flags) + }, + } +} + +func mergeMain(inputs []string, flags mergeFlags) error { + indexes := make([]*scip.Index, len(inputs)) + for i, p := range inputs { + idx, err := readFromOption(p) + if err != nil { + return fmt.Errorf("reading %s: %w", p, err) + } + indexes[i] = idx + } + + merged, err := mergeIndexes(indexes, flags.projectRootOverride) + if err != nil { + return err + } + + data, err := proto.Marshal(merged) + if err != nil { + return fmt.Errorf("marshaling merged index: %w", err) + } + if err := os.WriteFile(flags.output, data, 0o644); err != nil { + return fmt.Errorf("writing %s: %w", flags.output, err) + } + return nil +} + +func mergeIndexes(indexes []*scip.Index, projectRootOverride string) (*scip.Index, error) { + if len(indexes) == 0 { + return nil, errors.New("no indexes to merge") + } + + // Validate metadata. All inputs must agree exactly on ProtocolVersion and + // TextDocumentEncoding (Unspecified is treated as a distinct value, not a + // wildcard, to avoid silently mislabeling an index whose encoding was + // unknown). + if indexes[0].Metadata == nil { + return nil, errors.New("index 0 has no metadata") + } + protoVersion := indexes[0].Metadata.Version + encoding := indexes[0].Metadata.TextDocumentEncoding + roots := make([]*url.URL, len(indexes)) + for i, idx := range indexes { + if idx.Metadata == nil { + return nil, fmt.Errorf("index %d has no metadata", i) + } + if idx.Metadata.Version != protoVersion { + return nil, fmt.Errorf( + "index %d has incompatible protocol version %v (expected %v)", + i, idx.Metadata.Version, protoVersion) + } + if idx.Metadata.TextDocumentEncoding != encoding { + return nil, fmt.Errorf( + "index %d has incompatible text encoding %v (expected %v)", + i, idx.Metadata.TextDocumentEncoding, encoding) + } + u, err := parseRootURI(idx.Metadata.ProjectRoot) + if err != nil { + return nil, fmt.Errorf("index %d: %w", i, err) + } + roots[i] = u + } + + // Determine the output project_root and the per-input prefix. + outputURL, prefixes, err := planPaths(roots, projectRootOverride) + if err != nil { + return nil, err + } + + // Aggregate documents (rewriting relative_path) and external symbols. + var allDocuments []*scip.Document + var allExternalSymbols []*scip.SymbolInformation + for i, idx := range indexes { + for _, doc := range idx.Documents { + if prefixes[i] != "" { + doc.RelativePath = path.Join(prefixes[i], doc.RelativePath) + } + allDocuments = append(allDocuments, doc) + } + allExternalSymbols = append(allExternalSymbols, idx.ExternalSymbols...) + } + + return &scip.Index{ + Metadata: &scip.Metadata{ + Version: protoVersion, + ToolInfo: &scip.ToolInfo{ + Name: "scip", + Version: strings.TrimSpace(version), + Arguments: []string{"merge"}, + }, + ProjectRoot: outputURL.String(), + TextDocumentEncoding: encoding, + }, + Documents: scip.FlattenDocuments(allDocuments), + ExternalSymbols: scip.FlattenSymbols(allExternalSymbols), + }, nil +} + +// parseRootURI parses a project_root URI and normalizes its path component +// (trailing slashes trimmed, "." / ".." resolved, "" replaced with "/"). +func parseRootURI(raw string) (*url.URL, error) { + u, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("parsing project_root %q: %w", raw, err) + } + if u.Path == "" { + u.Path = "/" + } else { + u.Path = path.Clean(u.Path) + } + return u, nil +} + +// planPaths returns the output project_root URL and the prefix that must be +// prepended to each input's document relative_paths. If override is non-empty +// it becomes the output root and every input root must be a descendant of it; +// otherwise the output root is the common URI ancestor of all inputs. +func planPaths(inputs []*url.URL, override string) (*url.URL, []string, error) { + var root *url.URL + if override != "" { + u, err := parseRootURI(override) + if err != nil { + return nil, nil, fmt.Errorf("invalid --project-root: %w", err) + } + root = u + } else { + root = inputs[0] + for _, u := range inputs[1:] { + if u.Scheme != root.Scheme || u.Host != root.Host { + return nil, nil, fmt.Errorf( + "inputs have incompatible URI scheme/host: %q vs %q; "+ + "pass --project-root to override", + root, u) + } + root = &url.URL{Scheme: root.Scheme, Host: root.Host, Path: commonPath(root.Path, u.Path)} + } + } + + prefixes := make([]string, len(inputs)) + for i, u := range inputs { + if u.Scheme != root.Scheme || u.Host != root.Host { + return nil, nil, fmt.Errorf( + "index %d project_root %q has different scheme/host than output root %q", + i, u, root) + } + rel, err := relativeTo(root.Path, u.Path) + if err != nil { + return nil, nil, fmt.Errorf("index %d project_root %q: %w", i, u, err) + } + prefixes[i] = rel + } + return root, prefixes, nil +} + +// commonPath returns the longest slash-bounded ancestor of both a and b. +// Inputs must be absolute (start with "/") and cleaned. +func commonPath(a, b string) string { + for !isAncestor(a, b) { + a = path.Dir(a) + } + return a +} + +// isAncestor reports whether parent is an ancestor of (or equal to) child. +// Inputs must be cleaned. +func isAncestor(parent, child string) bool { + if parent == "/" { + return strings.HasPrefix(child, "/") + } + return child == parent || strings.HasPrefix(child, parent+"/") +} + +// relativeTo returns child's path relative to parent (no leading slash). +// Returns an error if parent is not an ancestor of child. +func relativeTo(parent, child string) (string, error) { + if child == parent { + return "", nil + } + if parent == "/" { + return strings.TrimPrefix(child, "/"), nil + } + if rel, ok := strings.CutPrefix(child, parent+"/"); ok { + return rel, nil + } + return "", fmt.Errorf("%q is not under %q", child, parent) +} diff --git a/cmd/scip/merge_test.go b/cmd/scip/merge_test.go new file mode 100644 index 00000000..20e3d961 --- /dev/null +++ b/cmd/scip/merge_test.go @@ -0,0 +1,461 @@ +package main + +import ( + "net/url" + "os" + "path/filepath" + "sort" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + "github.com/scip-code/scip/bindings/go/scip" +) + +func TestMerge_PlanPaths(t *testing.T) { + cases := []struct { + name string + roots []string + override string + wantRoot string + wantPrefixes []string + wantErr bool + }{ + { + name: "identical roots", + roots: []string{"file:///repo", "file:///repo"}, + wantRoot: "file:///repo", + wantPrefixes: []string{"", ""}, + }, + { + name: "sibling subdirs", + roots: []string{"file:///repo/frontend", "file:///repo/backend"}, + wantRoot: "file:///repo", + wantPrefixes: []string{"frontend", "backend"}, + }, + { + name: "one is ancestor of the other", + roots: []string{"file:///repo", "file:///repo/sub"}, + wantRoot: "file:///repo", + wantPrefixes: []string{"", "sub"}, + }, + { + name: "deep nesting", + roots: []string{"file:///repo/a/b/c", "file:///repo/a/b/d", "file:///repo/a/e"}, + wantRoot: "file:///repo/a", + wantPrefixes: []string{"b/c", "b/d", "e"}, + }, + { + name: "common component is filesystem root", + roots: []string{"file:///alpha/x", "file:///beta/y"}, + wantRoot: "file:///", + wantPrefixes: []string{"alpha/x", "beta/y"}, + }, + { + name: "trailing slashes normalized", + roots: []string{"file:///repo/", "file:///repo/sub"}, + wantRoot: "file:///repo", + wantPrefixes: []string{"", "sub"}, + }, + { + name: "prefix-like names don't share parent", + roots: []string{"file:///repo/frontend", "file:///repo/frontend-v2"}, + wantRoot: "file:///repo", + wantPrefixes: []string{"frontend", "frontend-v2"}, + }, + { + name: "different schemes error", + roots: []string{"file:///repo", "https://example.com/repo"}, + wantErr: true, + }, + { + name: "different hosts error", + roots: []string{"https://a.com/repo", "https://b.com/repo"}, + wantErr: true, + }, + { + name: "override at a common ancestor", + roots: []string{"file:///workspace/repo/frontend", "file:///workspace/repo/backend"}, + override: "file:///workspace", + wantRoot: "file:///workspace", + wantPrefixes: []string{"repo/frontend", "repo/backend"}, + }, + { + name: "override must be ancestor of all inputs", + roots: []string{"file:///repo/frontend", "file:///elsewhere/backend"}, + override: "file:///repo", + wantErr: true, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + parsed := make([]*url.URL, len(c.roots)) + for i, r := range c.roots { + u, err := parseRootURI(r) + require.NoError(t, err) + parsed[i] = u + } + gotURL, gotPrefixes, err := planPaths(parsed, c.override) + if c.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, c.wantRoot, gotURL.String()) + require.Equal(t, c.wantPrefixes, gotPrefixes) + }) + } +} + +func TestMerge_RelativeTo(t *testing.T) { + cases := []struct { + name string + parent string + child string + want string + wantErr bool + }{ + {"same", "/repo", "/repo", "", false}, + {"direct child", "/repo", "/repo/sub", "sub", false}, + {"deep child", "/repo", "/repo/a/b/c", "a/b/c", false}, + {"parent is filesystem root", "/", "/alpha/x", "alpha/x", false}, + {"parent equals root and child equals root", "/", "/", "", false}, + {"input is ancestor (error)", "/repo/sub", "/repo", "", true}, + {"siblings (error)", "/repo/frontend", "/repo/backend", "", true}, + {"prefix-like but not ancestor", "/repo/frontend", "/repo/frontend-v2", "", true}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := relativeTo(c.parent, c.child) + if c.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, c.want, got) + }) + } +} + +func TestMergeIndexes_AutoInferredRoot(t *testing.T) { + a := &scip.Index{ + Metadata: &scip.Metadata{ + ProjectRoot: "file:///repo/frontend", + TextDocumentEncoding: scip.TextEncoding_UTF8, + }, + Documents: []*scip.Document{ + { + RelativePath: "src/a.ts", + Occurrences: []*scip.Occurrence{ + {Symbol: "scip-ts npm pkg-a 1.0 A#", Range: []int32{0, 0, 1}, SymbolRoles: int32(scip.SymbolRole_Definition)}, + }, + Symbols: []*scip.SymbolInformation{{Symbol: "scip-ts npm pkg-a 1.0 A#"}}, + }, + }, + ExternalSymbols: []*scip.SymbolInformation{ + {Symbol: "scip-ts npm shared 1.0 Util#"}, + }, + } + b := &scip.Index{ + Metadata: &scip.Metadata{ + ProjectRoot: "file:///repo/backend", + TextDocumentEncoding: scip.TextEncoding_UTF8, + }, + Documents: []*scip.Document{ + { + RelativePath: "src/b.go", + Occurrences: []*scip.Occurrence{ + {Symbol: "scip-go go . . pkg/B#", Range: []int32{0, 0, 1}, SymbolRoles: int32(scip.SymbolRole_Definition)}, + }, + Symbols: []*scip.SymbolInformation{{Symbol: "scip-go go . . pkg/B#"}}, + }, + }, + ExternalSymbols: []*scip.SymbolInformation{ + {Symbol: "scip-ts npm shared 1.0 Util#"}, + }, + } + + merged, err := mergeIndexes([]*scip.Index{a, b}, "") + require.NoError(t, err) + + require.NotNil(t, merged.Metadata) + require.Equal(t, "file:///repo", merged.Metadata.ProjectRoot) + require.Equal(t, scip.TextEncoding_UTF8, merged.Metadata.TextDocumentEncoding) + require.NotNil(t, merged.Metadata.ToolInfo) + require.Equal(t, "scip", merged.Metadata.ToolInfo.Name) + + paths := docPaths(merged) + require.ElementsMatch(t, []string{"frontend/src/a.ts", "backend/src/b.go"}, paths) + + // External symbols should be deduped. + require.Len(t, merged.ExternalSymbols, 1) + require.Equal(t, "scip-ts npm shared 1.0 Util#", merged.ExternalSymbols[0].Symbol) +} + +func TestMergeIndexes_SameRootDeduplicatesDocuments(t *testing.T) { + a := &scip.Index{ + Metadata: &scip.Metadata{ + ProjectRoot: "file:///repo", + TextDocumentEncoding: scip.TextEncoding_UTF8, + }, + Documents: []*scip.Document{ + { + RelativePath: "src/shared.go", + Symbols: []*scip.SymbolInformation{{Symbol: "shared.A"}}, + Occurrences: []*scip.Occurrence{ + {Symbol: "shared.A", Range: []int32{0, 0, 1}, SymbolRoles: int32(scip.SymbolRole_Definition)}, + }, + }, + }, + } + b := &scip.Index{ + Metadata: &scip.Metadata{ + ProjectRoot: "file:///repo", + TextDocumentEncoding: scip.TextEncoding_UTF8, + }, + Documents: []*scip.Document{ + { + RelativePath: "src/shared.go", + Symbols: []*scip.SymbolInformation{{Symbol: "shared.B"}}, + Occurrences: []*scip.Occurrence{ + {Symbol: "shared.B", Range: []int32{1, 0, 1}, SymbolRoles: int32(scip.SymbolRole_Definition)}, + }, + }, + }, + } + + merged, err := mergeIndexes([]*scip.Index{a, b}, "") + require.NoError(t, err) + + require.Equal(t, "file:///repo", merged.Metadata.ProjectRoot) + require.Len(t, merged.Documents, 1, "documents with the same relative_path should be merged") + doc := merged.Documents[0] + require.Equal(t, "src/shared.go", doc.RelativePath) + require.Len(t, doc.Symbols, 2) + require.Len(t, doc.Occurrences, 2) +} + +func TestMergeIndexes_OverrideProjectRoot(t *testing.T) { + a := &scip.Index{ + Metadata: &scip.Metadata{ + ProjectRoot: "file:///workspace/repo/frontend", + TextDocumentEncoding: scip.TextEncoding_UTF8, + }, + Documents: []*scip.Document{ + {RelativePath: "x.ts"}, + }, + } + b := &scip.Index{ + Metadata: &scip.Metadata{ + ProjectRoot: "file:///workspace/repo/backend", + TextDocumentEncoding: scip.TextEncoding_UTF8, + }, + Documents: []*scip.Document{ + {RelativePath: "y.go"}, + }, + } + + merged, err := mergeIndexes([]*scip.Index{a, b}, "file:///workspace") + require.NoError(t, err) + + require.Equal(t, "file:///workspace", merged.Metadata.ProjectRoot) + paths := docPaths(merged) + require.ElementsMatch(t, []string{"repo/frontend/x.ts", "repo/backend/y.go"}, paths) +} + +func TestMergeIndexes_OverrideMustBeAncestor(t *testing.T) { + a := &scip.Index{ + Metadata: &scip.Metadata{ + ProjectRoot: "file:///repo/frontend", + TextDocumentEncoding: scip.TextEncoding_UTF8, + }, + } + b := &scip.Index{ + Metadata: &scip.Metadata{ + ProjectRoot: "file:///elsewhere/backend", + TextDocumentEncoding: scip.TextEncoding_UTF8, + }, + } + + _, err := mergeIndexes([]*scip.Index{a, b}, "file:///repo") + require.Error(t, err) +} + +func TestMergeIndexes_IncompatibleEncoding(t *testing.T) { + a := &scip.Index{ + Metadata: &scip.Metadata{ + ProjectRoot: "file:///repo", + TextDocumentEncoding: scip.TextEncoding_UTF8, + }, + } + b := &scip.Index{ + Metadata: &scip.Metadata{ + ProjectRoot: "file:///repo", + TextDocumentEncoding: scip.TextEncoding_UTF16, + }, + } + + _, err := mergeIndexes([]*scip.Index{a, b}, "") + require.Error(t, err) +} + +func TestMergeIndexes_IncompatibleProtocolVersion(t *testing.T) { + a := &scip.Index{ + Metadata: &scip.Metadata{ + ProjectRoot: "file:///repo", + TextDocumentEncoding: scip.TextEncoding_UTF8, + Version: scip.ProtocolVersion_UnspecifiedProtocolVersion, + }, + } + b := &scip.Index{ + Metadata: &scip.Metadata{ + ProjectRoot: "file:///repo", + TextDocumentEncoding: scip.TextEncoding_UTF8, + Version: scip.ProtocolVersion(42), // imaginary future version + }, + } + + _, err := mergeIndexes([]*scip.Index{a, b}, "") + require.Error(t, err) + require.Contains(t, err.Error(), "protocol version") +} + +func TestMergeIndexes_MissingMetadata(t *testing.T) { + a := &scip.Index{ + Metadata: &scip.Metadata{ + ProjectRoot: "file:///repo", + TextDocumentEncoding: scip.TextEncoding_UTF8, + }, + } + b := &scip.Index{Metadata: nil} + + _, err := mergeIndexes([]*scip.Index{a, b}, "") + require.Error(t, err) + require.Contains(t, err.Error(), "metadata") +} + +func TestMergeIndexes_EmptyRelativePathGetsPrefix(t *testing.T) { + a := &scip.Index{ + Metadata: &scip.Metadata{ + ProjectRoot: "file:///repo/frontend", + TextDocumentEncoding: scip.TextEncoding_UTF8, + }, + Documents: []*scip.Document{ + {RelativePath: ""}, // empty -- represents the project root itself + }, + } + b := &scip.Index{ + Metadata: &scip.Metadata{ + ProjectRoot: "file:///repo/backend", + TextDocumentEncoding: scip.TextEncoding_UTF8, + }, + Documents: []*scip.Document{ + {RelativePath: "main.go"}, + }, + } + + merged, err := mergeIndexes([]*scip.Index{a, b}, "") + require.NoError(t, err) + require.Equal(t, "file:///repo", merged.Metadata.ProjectRoot) + require.ElementsMatch(t, + []string{"frontend", "backend/main.go"}, + docPaths(merged)) +} + +func TestMergeIndexes_UnspecifiedAndConcreteEncodingMismatch(t *testing.T) { + // Unspecified is treated as a distinct value, not a wildcard: mixing it + // with a concrete encoding must error rather than silently labeling the + // merged output with the concrete encoding (which could mislabel the + // Unspecified index's source files). + a := &scip.Index{ + Metadata: &scip.Metadata{ + ProjectRoot: "file:///repo", + TextDocumentEncoding: scip.TextEncoding_UnspecifiedTextEncoding, + }, + } + b := &scip.Index{ + Metadata: &scip.Metadata{ + ProjectRoot: "file:///repo", + TextDocumentEncoding: scip.TextEncoding_UTF8, + }, + } + + _, err := mergeIndexes([]*scip.Index{a, b}, "") + require.Error(t, err) + require.Contains(t, err.Error(), "text encoding") +} + +func TestMergeIndexes_AllUnspecifiedEncodingPreserved(t *testing.T) { + // When every input has the same encoding (including Unspecified), the + // merged output preserves it. + a := &scip.Index{ + Metadata: &scip.Metadata{ + ProjectRoot: "file:///repo", + TextDocumentEncoding: scip.TextEncoding_UnspecifiedTextEncoding, + }, + } + b := &scip.Index{ + Metadata: &scip.Metadata{ + ProjectRoot: "file:///repo", + TextDocumentEncoding: scip.TextEncoding_UnspecifiedTextEncoding, + }, + } + + merged, err := mergeIndexes([]*scip.Index{a, b}, "") + require.NoError(t, err) + require.Equal(t, scip.TextEncoding_UnspecifiedTextEncoding, merged.Metadata.TextDocumentEncoding) +} + +func TestMergeMain_EndToEnd(t *testing.T) { + tempDir := t.TempDir() + + write := func(name string, idx *scip.Index) string { + p := filepath.Join(tempDir, name) + data, err := proto.Marshal(idx) + require.NoError(t, err) + require.NoError(t, os.WriteFile(p, data, 0o644)) + return p + } + + aPath := write("a.scip", &scip.Index{ + Metadata: &scip.Metadata{ + ProjectRoot: "file:///repo/frontend", + TextDocumentEncoding: scip.TextEncoding_UTF8, + }, + Documents: []*scip.Document{{RelativePath: "src/a.ts"}}, + }) + bPath := write("b.scip", &scip.Index{ + Metadata: &scip.Metadata{ + ProjectRoot: "file:///repo/backend", + TextDocumentEncoding: scip.TextEncoding_UTF8, + }, + Documents: []*scip.Document{{RelativePath: "src/b.go"}}, + }) + + outPath := filepath.Join(tempDir, "merged.scip") + err := mergeMain( + []string{aPath, bPath}, + mergeFlags{output: outPath}, + ) + require.NoError(t, err) + + merged, err := readFromOption(outPath) + require.NoError(t, err) + + require.Equal(t, "file:///repo", merged.Metadata.ProjectRoot) + require.ElementsMatch(t, + []string{"frontend/src/a.ts", "backend/src/b.go"}, + docPaths(merged)) +} + +func docPaths(idx *scip.Index) []string { + out := make([]string, 0, len(idx.Documents)) + for _, d := range idx.Documents { + out = append(out, d.RelativePath) + } + sort.Strings(out) + return out +} diff --git a/docs/CLI.md b/docs/CLI.md index 719fe0f4..051529b5 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -8,6 +8,7 @@ - [`scip snapshot`](#scip-snapshot) - [`scip stats`](#scip-stats) - [`scip expt-convert`](#scip-convert) + - [`scip merge`](#scip-merge) ``` @@ -32,6 +33,7 @@ COMMANDS: stats Output useful statistics about a SCIP index test Validate a SCIP index against test files expt-convert [EXPERIMENTAL] Convert a SCIP index to a SQLite database + merge Merge multiple SCIP indexes into a single SCIP index help, h Shows a list of commands or help for one command GLOBAL OPTIONS: @@ -177,3 +179,44 @@ OPTIONS: --cpu-profile string Path to output prof file --help, -h show help ``` + +## `scip merge` + +``` +NAME: + scip merge - Merge multiple SCIP indexes into a single SCIP index + +USAGE: + scip merge [options] + +DESCRIPTION: + Merges two or more SCIP indexes into one. + + The output project_root is inferred as the common URI ancestor of the input + indexes' project_root values. Each input document's relative_path is rewritten + to be relative to that common root. + + For example, given: + + a.scip with project_root file:///repo/frontend, document src/a.ts + b.scip with project_root file:///repo/backend, document src/b.go + + The merged index will have: + + project_root file:///repo + documents frontend/src/a.ts and backend/src/b.go + + Documents and symbols are deduplicated after rewriting. + + Use --project-root to override the inferred root (each input's root must then + be a descendant of the override). + + Example usage: + + scip merge --output merged.scip a.scip b.scip c.scip + +OPTIONS: + --output string Path to write the merged SCIP index (default: "index.scip") + --project-root string Override the inferred output project_root URI. Each input project_root must be a descendant of this URI. + --help, -h show help +```