Skip to content

Commit 5f3b08d

Browse files
authored
[debug] add module versions (#6)
1 parent 78ddec2 commit 5f3b08d

2 files changed

Lines changed: 524 additions & 9 deletions

File tree

cmd/extensions.go

Lines changed: 317 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cmd
33
import (
44
"bytes"
55
"context"
6+
"encoding/json"
67
"fmt"
78
"log/slog"
89
"os"
@@ -28,6 +29,88 @@ const (
2829
pageCacheExclusionURL = "https://www.drupal.org/project/page_cache_exclusion"
2930
)
3031

32+
var drupalCoreModules = map[string]struct{}{
33+
"action": {},
34+
"announcements_feed": {},
35+
"automated_cron": {},
36+
"ban": {},
37+
"basic_auth": {},
38+
"big_pipe": {},
39+
"block": {},
40+
"block_content": {},
41+
"book": {},
42+
"breakpoint": {},
43+
"ckeditor5": {},
44+
"comment": {},
45+
"config": {},
46+
"config_translation": {},
47+
"contact": {},
48+
"content_moderation": {},
49+
"content_translation": {},
50+
"contextual": {},
51+
"datetime": {},
52+
"datetime_range": {},
53+
"dblog": {},
54+
"dynamic_page_cache": {},
55+
"editor": {},
56+
"field": {},
57+
"field_layout": {},
58+
"field_ui": {},
59+
"file": {},
60+
"filter": {},
61+
"forum": {},
62+
"help": {},
63+
"help_topics": {},
64+
"history": {},
65+
"image": {},
66+
"inline_form_errors": {},
67+
"jsonapi": {},
68+
"language": {},
69+
"layout_builder": {},
70+
"layout_discovery": {},
71+
"link": {},
72+
"locale": {},
73+
"media": {},
74+
"media_library": {},
75+
"menu_link_content": {},
76+
"menu_ui": {},
77+
"migrate": {},
78+
"migrate_drupal": {},
79+
"migrate_drupal_ui": {},
80+
"mysql": {},
81+
"navigation": {},
82+
"node": {},
83+
"options": {},
84+
"page_cache": {},
85+
"path": {},
86+
"path_alias": {},
87+
"pgsql": {},
88+
"phpass": {},
89+
"responsive_image": {},
90+
"rest": {},
91+
"sdc": {},
92+
"search": {},
93+
"serialization": {},
94+
"settings_tray": {},
95+
"shortcut": {},
96+
"sqlite": {},
97+
"statistics": {},
98+
"syslog": {},
99+
"system": {},
100+
"taxonomy": {},
101+
"telephone": {},
102+
"text": {},
103+
"toolbar": {},
104+
"tour": {},
105+
"tracker": {},
106+
"update": {},
107+
"user": {},
108+
"views": {},
109+
"views_ui": {},
110+
"workflows": {},
111+
"workspaces": {},
112+
}
113+
31114
var (
32115
debugPanelStyle = lipgloss.NewStyle().
33116
Background(lipgloss.Color("#112235")).
@@ -161,6 +244,14 @@ func renderDrupalDebug(runCtx context.Context) (string, error) {
161244
if err != nil {
162245
return "", err
163246
}
247+
moduleVersionInfo, err := readComposerLockModuleVersions(runCtx, files, drupalRoot, ctx.ProjectDir)
248+
if err != nil {
249+
return "", err
250+
}
251+
composerPatches, err := readComposerPatches(runCtx, files, drupalRoot, ctx.ProjectDir)
252+
if err != nil {
253+
return "", err
254+
}
164255
slog.Debug("read installed extensions", "plugin", "drupal", "modules", len(modules), "themes", len(themes))
165256
slog.Debug("rendering cache_page summary", "plugin", "drupal")
166257
cachePageSummary, err := renderCachePageSummary(runCtx)
@@ -173,13 +264,26 @@ func renderDrupalDebug(runCtx context.Context) (string, error) {
173264
body = append(body, "", debugDivider(), "", debugTitleStyle.Render("Cache Page"), "", cachePageSummary)
174265
}
175266

267+
moduleLines, err := renderModuleList(runCtx, files, drupalRoot, modules, moduleVersionInfo)
268+
if err != nil {
269+
return "", err
270+
}
271+
176272
configLines := []string{debugDivider(), "", debugTitleStyle.Render("Installed Extensions"), "", fmt.Sprintf("Installed modules (%d):", len(modules))}
177-
configLines = append(configLines, formatListLines(modules, 3)...)
273+
configLines = append(configLines, moduleLines...)
178274
configLines = append(configLines, "")
179275
configLines = append(configLines, fmt.Sprintf("Installed themes (%d):", len(themes)))
180276
configLines = append(configLines, formatListLines(themes, 3)...)
181277
body = append(body, "", strings.Join(configLines, "\n"))
182278

279+
patchLines := []string{debugDivider(), "", debugTitleStyle.Render("Composer Patches"), ""}
280+
if strings.TrimSpace(composerPatches) == "" {
281+
patchLines = append(patchLines, " none")
282+
} else {
283+
patchLines = append(patchLines, indentLines(composerPatches, " "))
284+
}
285+
body = append(body, "", strings.Join(patchLines, "\n"))
286+
183287
slog.Debug("finished plugin debug", "plugin", "drupal")
184288
return renderDebugPanel("drupal", strings.Join(body, "\n")), nil
185289
}
@@ -191,28 +295,38 @@ func renderCachePageSummary(runCtx context.Context) (string, error) {
191295
}
192296
defer cli.Close()
193297

194-
query := "SELECT COALESCE(data_length + index_length, 0) FROM information_schema.TABLES WHERE table_schema = DATABASE() AND table_name = 'cache_page';"
195-
output, err := execDrupalCommandCapture(runCtx, cli, containerName, ctx.EffectiveDrupalContainerRoot(), []string{"drush", "sql:query", query, "--extra=--batch", "--extra=--skip-column-names"})
298+
cachePageSize, err := readDrupalCacheTableSize(runCtx, cli, containerName, ctx.EffectiveDrupalContainerRoot(), "cache_page")
196299
if err != nil {
197300
return "", err
198301
}
199-
200-
size, err := parseFirstInt(output)
302+
cacheRenderSize, err := readDrupalCacheTableSize(runCtx, cli, containerName, ctx.EffectiveDrupalContainerRoot(), "cache_render")
201303
if err != nil {
202304
return "", err
203305
}
204306

205307
rows := []debugRow{
206308
{Label: "Status", Value: renderStatus("ok")},
207-
{Label: "cache_page", Value: humanBytes(size)},
309+
{Label: "cache_page", Value: humanBytes(cachePageSize)},
310+
{Label: "cache_render", Value: humanBytes(cacheRenderSize)},
208311
}
209-
if size >= cachePageWarningThreshold {
312+
if cachePageSize >= cachePageWarningThreshold || cacheRenderSize >= cachePageWarningThreshold {
210313
rows[0].Value = renderStatus("warning")
314+
}
315+
if cachePageSize >= cachePageWarningThreshold {
211316
rows = append(rows, debugRow{Label: "Recommendation", Value: pageCacheExclusionURL})
212317
}
213318
return formatDebugRows(rows), nil
214319
}
215320

321+
func readDrupalCacheTableSize(runCtx context.Context, cli *docker.DockerClient, containerName, containerRoot, tableName string) (int64, error) {
322+
query := fmt.Sprintf("SELECT COALESCE(data_length + index_length, 0) FROM information_schema.TABLES WHERE table_schema = DATABASE() AND table_name = '%s';", strings.TrimSpace(tableName))
323+
output, err := execDrupalCommandCapture(runCtx, cli, containerName, containerRoot, []string{"drush", "sql:query", query, "--extra=--batch", "--extra=--skip-column-names"})
324+
if err != nil {
325+
return 0, err
326+
}
327+
return parseFirstInt(output)
328+
}
329+
216330
func getDrupalContainerForSDK(runCtx context.Context) (ctx *config.Context, cli *docker.DockerClient, containerName string, err error) {
217331
if sdk == nil {
218332
return nil, nil, "", fmt.Errorf("plugin sdk is not initialized")
@@ -302,8 +416,8 @@ func readCoreExtension(runCtx context.Context, files *plugin.FileAccessor, path
302416
}
303417

304418
var extension struct {
305-
Module map[string]int `yaml:"module"`
306-
Theme map[string]int `yaml:"theme"`
419+
Module map[string]any `yaml:"module"`
420+
Theme map[string]any `yaml:"theme"`
307421
}
308422
if err := yaml.Unmarshal(data, &extension); err != nil {
309423
return nil, nil, err
@@ -324,6 +438,200 @@ func readCoreExtension(runCtx context.Context, files *plugin.FileAccessor, path
324438
return modules, themes, nil
325439
}
326440

441+
func readComposerLockModuleVersions(runCtx context.Context, files *plugin.FileAccessor, drupalRoot, projectDir string) (composerLockVersionInfo, error) {
442+
path, err := findComposerLockPath(runCtx, files, drupalRoot, projectDir)
443+
if err != nil {
444+
return composerLockVersionInfo{}, err
445+
}
446+
if path == "" {
447+
return composerLockVersionInfo{}, nil
448+
}
449+
450+
data, err := files.ReadFileContext(runCtx, path)
451+
if err != nil {
452+
if os.IsNotExist(err) {
453+
return composerLockVersionInfo{}, nil
454+
}
455+
return composerLockVersionInfo{}, err
456+
}
457+
458+
var lock struct {
459+
Packages []composerLockPackage `json:"packages"`
460+
PackagesDev []composerLockPackage `json:"packages-dev"`
461+
}
462+
if err := json.Unmarshal(data, &lock); err != nil {
463+
return composerLockVersionInfo{}, err
464+
}
465+
466+
info := composerLockVersionInfo{ModuleVersions: make(map[string]string)}
467+
for _, pkg := range append(lock.Packages, lock.PackagesDev...) {
468+
name, version := strings.TrimSpace(pkg.Name), strings.TrimSpace(pkg.Version)
469+
if version == "" {
470+
continue
471+
}
472+
switch name {
473+
case "drupal/core", "drupal/drupal":
474+
if info.CoreVersion == "" {
475+
info.CoreVersion = version
476+
}
477+
}
478+
if pkg.Type != "drupal-module" {
479+
continue
480+
}
481+
moduleName := strings.TrimSpace(pkg.ModuleName())
482+
if moduleName == "" {
483+
continue
484+
}
485+
info.ModuleVersions[moduleName] = version
486+
}
487+
return info, nil
488+
}
489+
490+
func readComposerPatches(runCtx context.Context, files *plugin.FileAccessor, drupalRoot, projectDir string) (string, error) {
491+
path, err := findComposerFilePath(runCtx, files, drupalRoot, projectDir, "composer.json")
492+
if err != nil {
493+
return "", err
494+
}
495+
if path == "" {
496+
return "", nil
497+
}
498+
499+
data, err := files.ReadFileContext(runCtx, path)
500+
if err != nil {
501+
if os.IsNotExist(err) {
502+
return "", nil
503+
}
504+
return "", err
505+
}
506+
507+
var composer struct {
508+
Extra struct {
509+
Patches json.RawMessage `json:"patches"`
510+
} `json:"extra"`
511+
}
512+
if err := json.Unmarshal(data, &composer); err != nil {
513+
return "", err
514+
}
515+
if len(composer.Extra.Patches) == 0 || string(composer.Extra.Patches) == "null" {
516+
return "", nil
517+
}
518+
519+
var pretty bytes.Buffer
520+
if err := json.Indent(&pretty, composer.Extra.Patches, "", " "); err != nil {
521+
return "", err
522+
}
523+
return pretty.String(), nil
524+
}
525+
526+
func findComposerLockPath(runCtx context.Context, files *plugin.FileAccessor, drupalRoot, projectDir string) (string, error) {
527+
return findComposerFilePath(runCtx, files, drupalRoot, projectDir, "composer.lock")
528+
}
529+
530+
func findComposerFilePath(runCtx context.Context, files *plugin.FileAccessor, drupalRoot, projectDir, fileName string) (string, error) {
531+
candidates := []string{
532+
filepath.Join(strings.TrimSpace(drupalRoot), fileName),
533+
}
534+
if projectDir != drupalRoot {
535+
candidates = append(candidates, filepath.Join(strings.TrimSpace(projectDir), fileName))
536+
}
537+
538+
for _, candidate := range candidates {
539+
if strings.TrimSpace(candidate) == "" {
540+
continue
541+
}
542+
if _, err := files.ReadFileContext(runCtx, candidate); err == nil {
543+
return candidate, nil
544+
} else if !os.IsNotExist(err) {
545+
return "", err
546+
}
547+
}
548+
return "", nil
549+
}
550+
551+
type composerLockVersionInfo struct {
552+
CoreVersion string
553+
ModuleVersions map[string]string
554+
}
555+
556+
type composerLockPackage struct {
557+
Name string `json:"name"`
558+
Version string `json:"version"`
559+
Type string `json:"type"`
560+
}
561+
562+
func (p composerLockPackage) ModuleName() string {
563+
name := strings.TrimSpace(p.Name)
564+
if !strings.HasPrefix(name, "drupal/") {
565+
return ""
566+
}
567+
return strings.TrimPrefix(name, "drupal/")
568+
}
569+
570+
func renderModuleList(runCtx context.Context, files *plugin.FileAccessor, drupalRoot string, modules []string, versionInfo composerLockVersionInfo) ([]string, error) {
571+
coreModules := make([]string, 0)
572+
contribModules := make([]string, 0)
573+
unknownModules := make([]string, 0)
574+
575+
for _, module := range modules {
576+
module = strings.TrimSpace(module)
577+
if module == "" {
578+
continue
579+
}
580+
if isStaticCoreDrupalModule(module) {
581+
if version := strings.TrimSpace(versionInfo.CoreVersion); version != "" {
582+
coreModules = append(coreModules, fmt.Sprintf("%s@%s", module, version))
583+
} else {
584+
coreModules = append(coreModules, module)
585+
}
586+
continue
587+
}
588+
if version := strings.TrimSpace(versionInfo.ModuleVersions[module]); version != "" {
589+
contribModules = append(contribModules, fmt.Sprintf("%s@%s", module, version))
590+
continue
591+
}
592+
unknownModules = append(unknownModules, module)
593+
}
594+
595+
sort.Strings(coreModules)
596+
sort.Strings(contribModules)
597+
sort.Strings(unknownModules)
598+
599+
sections := []struct {
600+
Title string
601+
Values []string
602+
}{
603+
{Title: "Core modules:", Values: coreModules},
604+
{Title: "Contrib modules:", Values: contribModules},
605+
{Title: "Custom or submodules:", Values: unknownModules},
606+
}
607+
608+
lines := make([]string, 0)
609+
for idx, section := range sections {
610+
lines = append(lines, " "+section.Title)
611+
lines = append(lines, formatListLines(section.Values, 3)...)
612+
if idx < len(sections)-1 {
613+
lines = append(lines, "")
614+
}
615+
}
616+
return lines, nil
617+
}
618+
619+
func isStaticCoreDrupalModule(module string) bool {
620+
_, ok := drupalCoreModules[strings.TrimSpace(module)]
621+
return ok
622+
}
623+
624+
func indentLines(value, prefix string) string {
625+
if strings.TrimSpace(value) == "" {
626+
return strings.TrimSpace(prefix)
627+
}
628+
parts := strings.Split(value, "\n")
629+
for i, part := range parts {
630+
parts[i] = prefix + part
631+
}
632+
return strings.Join(parts, "\n")
633+
}
634+
327635
func formatListLines(values []string, perLine int) []string {
328636
if len(values) == 0 {
329637
return []string{" none"}

0 commit comments

Comments
 (0)