diff --git a/CLAUDE.md b/CLAUDE.md index a7b2722..b29cee6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,9 +47,9 @@ Three-layer separation — do not collapse layers for convenience: `errors.Is` / `errors.As` — never string-match error messages. - **Concurrency:** use `golang.org/x/sync/errgroup`. Always honor `context.Context` cancellation. -- **Logging:** use `github.com/rs/zerolog` (via the package-level `log` - global), not stdlib `log` (depguard enforces). Stdlib - `io/ioutil` is denied — use `os` / `io`. +- **Logging:** use `github.com/rs/zerolog` (via the package-level `log` global), + not stdlib `log` (depguard enforces). Stdlib `io/ioutil` is denied — use `os` + / `io`. - **CLI reference docs** (`docs/cli/*.md`) have an auto-generated marker block (`` / ``). **Never edit inside the markers.** Only `## Description` and `## Examples` are read by the diff --git a/cli/command/ls/ls.go b/cli/command/ls/ls.go index 723f9a0..4c39dcd 100644 --- a/cli/command/ls/ls.go +++ b/cli/command/ls/ls.go @@ -1,6 +1,7 @@ package ls import ( + "bufio" "errors" "fmt" "io" @@ -14,23 +15,50 @@ import ( "go.wpm.so/cli/cli" "go.wpm.so/cli/cli/command" + "go.wpm.so/cli/cli/command/completion" "go.wpm.so/cli/pkg/pm/wpmjson" + "go.wpm.so/cli/pkg/pm/wpmjson/types" "go.wpm.so/cli/pkg/pm/wpmlock" ) +const ( + unknownType = "unknown" + + connectorMid = "├── " + connectorEnd = "└── " + indentMid = "│ " + indentEnd = " " +) + +// typeOrder is the display order for known package types; others follow sorted. +var typeOrder = []string{types.TypePlugin.String(), types.TypeTheme.String()} + type lsOptions struct { - depth int + depth int + filterType string } func NewLsCommand(wpmCli command.Cli) *cobra.Command { var opts lsOptions cmd := &cobra.Command{ - Use: "ls", - Short: "List installed dependencies", - Args: cli.NoArgs, + Use: "ls [OPTIONS] [plugin|theme]", + Short: "List installed dependencies", + Args: cli.RequiresMaxArgs(1), + ValidArgsFunction: completion.PackageTypes(), + Aliases: []string{"list", "tree"}, RunE: func(cmd *cobra.Command, args []string) error { - return runLs(wpmCli, opts) + if len(args) == 1 { + opts.filterType = args[0] + if !types.PackageType(opts.filterType).Valid() { + return fmt.Errorf("invalid type %q: must be theme or plugin", opts.filterType) + } + } + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current working directory: %w", err) + } + return runLs(wpmCli, cwd, opts) }, } @@ -39,12 +67,7 @@ func NewLsCommand(wpmCli command.Cli) *cobra.Command { return cmd } -func runLs(wpmCli command.Cli, opts lsOptions) error { - cwd, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get current working directory: %w", err) - } - +func runLs(wpmCli command.Cli, cwd string, opts lsOptions) (err error) { config, err := wpmjson.Read(cwd) if err != nil { return err @@ -55,7 +78,7 @@ func runLs(wpmCli command.Cli, opts lsOptions) error { lock, err := wpmlock.Read(cwd) if err != nil { - return fmt.Errorf("failed to read lockfile: %w", err) + return err } if lock == nil { return errors.New("no wpm.lock found, you need to run `wpm install` first") @@ -66,7 +89,12 @@ func runLs(wpmCli command.Cli, opts lsOptions) error { root = filepath.Base(cwd) } - rootDeps := make(map[string]string) + capHint := depLen(config.Dependencies) + depLen(config.DevDependencies) + if capHint == 0 { + return errors.New("no dependencies found in wpm.json") + } + + rootDeps := make(map[string]string, capHint) if config.Dependencies != nil { maps.Copy(rootDeps, *config.Dependencies) } @@ -74,33 +102,111 @@ func runLs(wpmCli command.Cli, opts lsOptions) error { maps.Copy(rootDeps, *config.DevDependencies) } - if len(rootDeps) == 0 { - return errors.New("no dependencies found in wpm.json") + groups := groupByType(rootDeps, lock, opts.filterType) + if len(groups) == 0 { + return fmt.Errorf("no %s dependencies found", opts.filterType) } - wpmCli.Out().WriteString(root + "\n") + out := bufio.NewWriter(wpmCli.Out()) + defer func() { + if flushErr := out.Flush(); flushErr != nil && err == nil { + err = flushErr + } + }() printer := &treePrinter{ - out: wpmCli.Out(), + out: out, lock: lock, maxDepth: opts.depth, + colorize: wpmCli.Out().IsColorEnabled(), } + _, _ = fmt.Fprintln(out, root) + visited := make(map[string]bool) - printer.printLevel(rootDeps, wpmCli.Out().IsColorEnabled(), "", 0, visited) + var prefix []byte + for i, g := range groups { + connector, indent := connectorMid, indentMid + if i == len(groups)-1 { + connector, indent = connectorEnd, indentEnd + } + + label := g.label + if printer.colorize { + label = aec.Bold.Apply(label) + } + _, _ = fmt.Fprintf(out, "%s%s\n", connector, label) + + prefix = append(prefix[:0], indent...) + printer.printLevel(g.deps, prefix, 0, visited) + } return nil } +func depLen(d *types.Dependencies) int { + if d == nil { + return 0 + } + return len(*d) +} + +// depGroup is a set of direct dependencies that share a package type. +type depGroup struct { + label string + deps map[string]string +} + +// groupByType partitions direct dependencies by their locked package type, +// known types first then any others sorted. +func groupByType(deps map[string]string, lock *wpmlock.Lockfile, filter string) []depGroup { + buckets := make(map[string]map[string]string) + for name, version := range deps { + label := unknownType + if pkg, ok := lock.Packages[name]; ok && pkg.Type != "" { + label = pkg.Type.String() + } + if filter != "" && label != filter { + continue + } + bucket := buckets[label] + if bucket == nil { + bucket = make(map[string]string) + buckets[label] = bucket + } + bucket[name] = version + } + + groups := make([]depGroup, 0, len(buckets)) + for _, label := range typeOrder { + if bucket := buckets[label]; len(bucket) > 0 { + groups = append(groups, depGroup{label, bucket}) + delete(buckets, label) + } + } + + rest := make([]string, 0, len(buckets)) + for label := range buckets { + rest = append(rest, label) + } + sort.Strings(rest) + for _, label := range rest { + groups = append(groups, depGroup{label, buckets[label]}) + } + + return groups +} + type treePrinter struct { out io.Writer lock *wpmlock.Lockfile maxDepth int + colorize bool } -// printLevel recursively prints dependencies. -func (p *treePrinter) printLevel(deps map[string]string, colorize bool, prefix string, currentDepth int, visited map[string]bool) { - if p.maxDepth >= 0 && currentDepth > p.maxDepth { +// printLevel renders one level of the tree. +func (p *treePrinter) printLevel(deps map[string]string, prefix []byte, depth int, visited map[string]bool) { + if p.maxDepth >= 0 && depth > p.maxDepth { return } @@ -112,69 +218,65 @@ func (p *treePrinter) printLevel(deps map[string]string, colorize bool, prefix s for i, name := range keys { isLast := i == len(keys)-1 - connector := "├── " + connector := connectorMid if isLast { - connector = "└── " + connector = connectorEnd } isCycle := visited[name] - info, subDeps, isMissing := p.formatNode(name, deps[name], colorize, isCycle) + info, subDeps, isMissing := p.formatNode(name, deps[name], isCycle) _, _ = fmt.Fprintf(p.out, "%s%s%s\n", prefix, connector, info) - // Recurse only if: - // 1. It's not a missing package - // 2. It has dependencies - // 3. We haven't seen this package in the current stack (Cycle detection) - if !isMissing && len(subDeps) > 0 && !isCycle { - childPrefix := prefix - if isLast { - childPrefix += " " - } else { - childPrefix += "│ " - } + if isMissing || isCycle || len(subDeps) == 0 { + continue + } - // Add current to stack, recurse, then remove (backtracking) - visited[name] = true - p.printLevel(subDeps, colorize, childPrefix, currentDepth+1, visited) - delete(visited, name) + indent := indentMid + if isLast { + indent = indentEnd } + + mark := len(prefix) + prefix = append(prefix, indent...) + visited[name] = true + p.printLevel(subDeps, prefix, depth+1, visited) + delete(visited, name) + prefix = prefix[:mark] } } -// formatNode renders the display string for a single tree node and returns its -// sub-dependencies along with whether the node is missing from the lockfile. -func (p *treePrinter) formatNode(name, requestedVersion string, colorize, isCycle bool) (info string, subDeps map[string]string, isMissing bool) { +// formatNode renders a single node and returns its sub-dependencies and whether +// the package is missing from the lockfile. +func (p *treePrinter) formatNode(name, requestedVersion string, isCycle bool) (info string, subDeps map[string]string, isMissing bool) { pkg, ok := p.lock.Packages[name] if !ok { - if colorize { - info = fmt.Sprintf("%s@%s %s", name, requestedVersion, aec.RedF.Apply("UNMET DEPENDENCY")) - } else { - info = fmt.Sprintf("%s@%s UNMET DEPENDENCY", name, requestedVersion) + unmet := "UNMET DEPENDENCY" + if p.colorize { + unmet = aec.RedF.Apply(unmet) } - return info, nil, true + return name + "@" + requestedVersion + " " + unmet, nil, true } - info = fmt.Sprintf("%s@%s", name, pkg.Version) - if colorize { - info = fmt.Sprintf("%s%s%s", name, aec.LightBlackF.Apply("@"), aec.LightBlackF.Apply(pkg.Version)) + if p.colorize { + info = name + aec.LightBlackF.Apply("@"+pkg.Version) + } else { + info = name + "@" + pkg.Version } if pkg.Version != requestedVersion && requestedVersion != "*" { - invalidMsg := fmt.Sprintf("(invalid: \"%s\")", requestedVersion) - if colorize { - info += " " + aec.RedF.Apply(invalidMsg) - } else { - info += " " + invalidMsg + invalid := "(invalid: \"" + requestedVersion + "\")" + if p.colorize { + invalid = aec.RedF.Apply(invalid) } + info += " " + invalid } if isCycle { - cycleMsg := "(cycle)" - if colorize { - info += " " + aec.MagentaF.Apply(cycleMsg) - } else { - info += " " + cycleMsg + cycle := "(cycle)" + if p.colorize { + cycle = aec.MagentaF.Apply(cycle) } + info += " " + cycle } if pkg.Dependencies != nil { diff --git a/docs/reference/cli/ls.md b/docs/reference/cli/ls.md index 37a640e..77bd319 100644 --- a/docs/reference/cli/ls.md +++ b/docs/reference/cli/ls.md @@ -4,6 +4,10 @@ List installed dependencies +### Aliases + +`wpm ls`, `wpm list`, `wpm tree` + ### Options | Name | Type | Default | Description | @@ -36,16 +40,23 @@ Both `wpm.json` and `wpm.lock` must exist. If you have never installed, run ### Reading the output The first line is the package name from `wpm.json` (or the directory name if -`name` is unset). Each child line is one resolved package, drawn with standard -tree connectors: +`name` is unset). Direct dependencies are grouped by their package type +(`plugin` or `theme`), and each resolved package is drawn beneath its type with +standard tree connectors: ``` my-plugin -├── akismet@5.3.1 -│ └── jetpack@13.0.0 -└── query-monitor@3.20.2 +├── plugin +│ ├── akismet@5.3.1 +│ │ └── jetpack@13.0.0 +│ └── query-monitor@3.20.2 +└── theme + └── twentytwentyfour@1.0.0 ``` +Dependencies missing from the lockfile have no known type and appear under an +`unknown` group. + The annotations on the right of each line tell you about the package's state: | Annotation | Meaning | @@ -61,8 +72,15 @@ The `(invalid: ...)` marker is skipped when `wpm.json` pins the package to `*` ### Limiting depth By default `wpm ls` expands the tree as deep as the lockfile goes. Pass -`-d`/`--depth` to cap the depth. Depth `0` shows only direct dependencies; `1` -adds their immediate children; and so on. The default, `-1`, means unlimited. +`-d`/`--depth` to cap the depth. Depth `0` shows only direct dependencies (still +grouped by type); `1` adds their immediate children; and so on. The default, +`-1`, means unlimited. The type headings are always shown. + +### Filtering by type + +Pass `theme` or `plugin` as a positional argument to restrict the output to a +single type. Grouping and depth behave the same; only the matching type is +shown. ### Comparison with related commands @@ -88,10 +106,23 @@ adds their immediate children; and so on. The default, `-1`, means unlimited. ```console $ wpm ls my-plugin -├── akismet@5.3.1 -│ └── jetpack@13.0.0 -├── hello-dolly@1.7.2 -└── query-monitor@3.20.2 +├── plugin +│ ├── akismet@5.3.1 +│ │ └── jetpack@13.0.0 +│ ├── hello-dolly@1.7.2 +│ └── query-monitor@3.20.2 +└── theme + └── twentytwentyfour@1.0.0 +``` + +### List only one type + +```console +$ wpm ls plugin +my-plugin +└── plugin + ├── akismet@5.3.1 + └── query-monitor@3.20.2 ``` ### Limit the tree to direct dependencies @@ -99,9 +130,12 @@ my-plugin ```console $ wpm ls --depth 0 my-plugin -├── akismet@5.3.1 -├── hello-dolly@1.7.2 -└── query-monitor@3.20.2 +├── plugin +│ ├── akismet@5.3.1 +│ ├── hello-dolly@1.7.2 +│ └── query-monitor@3.20.2 +└── theme + └── twentytwentyfour@1.0.0 ``` ### Show one level of transitive dependencies @@ -109,19 +143,25 @@ my-plugin ```console $ wpm ls -d 1 my-plugin -├── akismet@5.3.1 -│ └── jetpack@13.0.0 -├── hello-dolly@1.7.2 -└── query-monitor@3.20.2 +└── plugin + ├── akismet@5.3.1 + │ └── jetpack@13.0.0 + ├── hello-dolly@1.7.2 + └── query-monitor@3.20.2 ``` ### Spot an unmet dependency +A package listed in `wpm.json` but missing from the lockfile has no known type, +so it appears under `unknown`. + ```console $ wpm ls my-plugin -├── akismet@5.3.1 -└── hello-dolly@1.7.2 UNMET DEPENDENCY +├── plugin +│ └── akismet@5.3.1 +└── unknown + └── hello-dolly@1.7.2 UNMET DEPENDENCY ``` ### Spot a version drift @@ -132,7 +172,8 @@ The lockfile resolved `1.7.2`, but `wpm.json` now requests `1.7.3`. Run ```console $ wpm ls my-plugin -└── hello-dolly@1.7.2 (invalid: "1.7.3") +└── plugin + └── hello-dolly@1.7.2 (invalid: "1.7.3") ``` ### Spot a cycle @@ -142,7 +183,8 @@ A cycle is annotated and not expanded again under itself. ```console $ wpm ls my-plugin -└── package-a@1.0.0 - └── package-b@1.0.0 - └── package-a@1.0.0 (cycle) +└── plugin + └── package-a@1.0.0 + └── package-b@1.0.0 + └── package-a@1.0.0 (cycle) ```