@@ -3,6 +3,7 @@ package cmd
33import (
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+
31114var (
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+
216330func 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+
327635func formatListLines (values []string , perLine int ) []string {
328636 if len (values ) == 0 {
329637 return []string {" none" }
0 commit comments