github.com/exercism/v2-configlet@v3.9.2+incompatible/cmd/tree.go (about) 1 package cmd 2 3 import ( 4 "bytes" 5 "fmt" 6 "os" 7 "path" 8 "strings" 9 "unicode/utf8" 10 11 "github.com/spf13/cobra" 12 13 "github.com/exercism/configlet/track" 14 "github.com/exercism/configlet/ui" 15 ) 16 17 // Characters and spacing for the tree structure output: 18 const ( 19 indent = 2 // how much to indent depth in the tree 20 trunk = "│" // descent in the tree 21 branch = "─" // prefix for an exercise 22 fork = "├" // normal trunk where a branch then starts (an exercise is listed) 23 terminator = "└" // special fork where there is nothing below it 24 ) 25 26 // configurationWarning is boilerplate that will be appended to any warning 27 // regarding potential mis-configuration for nextercism. 28 const configurationWarning = ", this track may be missing a nextercism compatible configuration." 29 30 // configPathExample is used in the Cobra usage output where the configfile path 31 // would be. 32 const configPathExample = "<path/to/track-root-or-config.json>" 33 34 // treeSpacing and treeBranching will be used over and over again 35 // in the tree creation, so create them once here. 36 var treeSpacing = strings.Repeat(" ", indent) 37 var treeBranching = strings.Repeat(branch, indent-1) 38 39 // withDifficulty holds --with-difficulty flag value to indicate that we 40 // should display exercise difficulty after slug, by default we do not. 41 var withDifficulty bool 42 43 // treeCmd defines the tree command. 44 var treeCmd = &cobra.Command{ 45 Use: "tree " + configPathExample, 46 Short: "View the track structure as a tree", 47 Long: `The tree command displays the track in a tree format, with core 48 exercises at root and unlocks located under their locking exercises. You 49 may choose to use the track root as the argument and tree will find that 50 track's config.json file, alternatively you may use a full path to a track 51 configuration file. 52 53 Bonus exercises are left in a list at the bottom after the tree display. 54 55 Example output: 56 57 Go 58 -- 59 60 ├── hello-world 61 │ 62 ├── hamming 63 │ ├── nucleotide-count 64 │ └── rna-transcription 65 ... 66 67 `, 68 Example: fmt.Sprintf(" %s tree %s --with-difficulty", binaryName, configPathExample), 69 Run: runTree, 70 Args: cobra.ExactArgs(1), 71 } 72 73 // slugToExercise is a global lookup table of slugs to exercises. 74 var slugToExercise = map[string]*exerciseParent{} 75 76 // exerciseParent is an extension of the exercise metatata type with the 77 // exercises it unlocks. 78 type exerciseParent struct { 79 track.ExerciseMetadata 80 // childSlugs will be used to store the slugs of exercises unlocked by 81 // this parent. 82 childSlugs []string 83 } 84 85 // description is a utility that will return the description for 86 // an exerciseUnlock with the difficulty appended if the --difficulty 87 // flag was set. 88 func (e exerciseParent) description() string { 89 if withDifficulty { 90 return fmt.Sprintf("%s [%d]", e.Slug, e.Difficulty) 91 } 92 93 return e.Slug 94 } 95 96 // writeLines is a convenience wrapper around printing the line(s) directly 97 // in case we (eventually) need to do something else with the string 98 // or writer beforehand. Plus it is nice to be able to use this rather 99 // than multiple write statements. 100 func writeLines(ss ...string) { 101 for _, s := range ss { 102 fmt.Fprintln(os.Stdout, s) 103 } 104 } 105 106 // runTree kicks off the visualization and will print any 107 // errors from the process. 108 func runTree(cmd *cobra.Command, args []string) { 109 for _, arg := range args { 110 if err := treeTrack(arg); err != nil { 111 ui.PrintError(err) 112 } 113 } 114 } 115 116 // printConfigurationWarning is a utility that will print s to Error, 117 // but will prefix it with a nextercism specific configuration warning 118 // use it if you expect something via nextercism but it is not present in 119 // the configuration. 120 func printConfigurationWarning(s string) { 121 ui.PrintError(s + configurationWarning) 122 } 123 124 // tree is responsible for outputting the actual tree structure 125 // it will operate recursively on the unlocks and send contents directly 126 // to output. 127 // 128 // isLast is a special indicator, callers can use this to note that 129 // the exercise being processed is the last in a sequence, this will 130 // make some special tweaks to the output format to look a little 131 // more pleasant. 132 func tree(e *exerciseParent, depth int, isLast bool) { 133 var buffer bytes.Buffer // Holds for the generated output of this exercise. 134 135 numChildren := len(e.childSlugs) // Unlocks are children in the tree context. 136 hasChildren := numChildren > 0 137 138 // Create the pre-fixing for this exercise using depth to move 139 // this exercise further and further to the right. 140 for i := 0; i < depth; i++ { 141 buffer.WriteString(trunk) // We continue trunks from the parent exercise(s). 142 buffer.WriteString(treeSpacing) 143 } 144 145 // Normally show the fork indicating a peer below unless there is none. 146 if !hasChildren && isLast { 147 buffer.WriteString(terminator) 148 } else { 149 buffer.WriteString(fork) 150 } 151 152 // Show the exercise name, it will have the standard branch prefix. 153 buffer.WriteString(treeBranching) 154 buffer.WriteString(" ") 155 buffer.WriteString(e.description()) 156 writeLines(buffer.String()) 157 158 // Now go into the children unlocks and do this all over again. 159 for i, slug := range e.childSlugs { 160 child := slugToExercise[slug] 161 tree(child, depth+1, i == (numChildren-1)) 162 } 163 164 // If depth is 0 (we are at root of the tree) we will add a little 165 // extra spacing between this and the next exercise... 166 // ...except for the last element because there is nothing below it 167 // to space out. 168 if depth == 0 && !isLast { 169 writeLines(trunk) 170 } 171 } 172 173 func treeTrack(configFilepath string) error { 174 // exercises is a list of all non-deprecated exercises, in config order. 175 exercises := make([]exerciseParent, 0) 176 177 // coreExercises are slugs of exercises in core, in config order. 178 coreExercises := make([]string, 0) 179 180 // bonusExercises are slugs of exercises determined to be bonuses: 181 // non-core, no unlocks, in config order. 182 bonusExercises := make([]string, 0) 183 184 // Check to see if the path ends with .json this is a good indicator 185 // it is not the path-to-track but an arbitrary config file 186 // otherwise assume it is a path-to-track and add config.json to it. 187 if !strings.HasSuffix(configFilepath, ".json") { 188 configFilepath = path.Join(configFilepath, "config.json") 189 } 190 config, err := track.NewConfig(configFilepath) 191 if err != nil { 192 return err 193 } 194 195 // Print a header: the language name with markdown style h1 underlining. 196 writeLines(config.Language) 197 writeLines(strings.Repeat("=", utf8.RuneCountInString(config.Language))) 198 199 // Initial scan through the exercises for this track: filter out deprecated 200 // exercises and setup the data structures from above. 201 for _, e := range config.Exercises { 202 // Completely ignore deprecated exercises. They are dead to us. 203 if e.IsDeprecated { 204 continue 205 } 206 // Create the container for this exercise. 207 ep := exerciseParent{ 208 e, 209 make([]string, 0), // Our unlock slugs, filled in on second pass. 210 } 211 212 exercises = append(exercises, ep) 213 214 // Add to slug based global lookup table. 215 slugToExercise[e.Slug] = &ep 216 217 if ep.IsCore { 218 coreExercises = append(coreExercises, ep.Slug) 219 } else if ep.UnlockedBy == nil { 220 bonusExercises = append(bonusExercises, ep.Slug) 221 } 222 } 223 224 // Second pass through exercises, fill out the unlocks. 225 // Look to see if there are no unlocks, (unlocksPresent is never set to 226 // true) if so issue a nextercism warning. 227 unlocksPresent := false 228 for _, e := range exercises { 229 if e.UnlockedBy == nil { 230 continue 231 } 232 233 parent, unlockExists := slugToExercise[*e.UnlockedBy] 234 // An unlocked_by slug that does not exist and is referenced can crash 235 // the program, see #102. If an non-existent slug exists issue warning. 236 if !unlockExists { 237 printConfigurationWarning( 238 fmt.Sprintf("Exercise %q has an invalid unlocked_by slug: %q", e.Slug, *e.UnlockedBy)) 239 continue 240 } 241 242 unlocksPresent = true 243 244 parent.childSlugs = append(parent.childSlugs, e.Slug) 245 } 246 247 if !unlocksPresent { 248 printConfigurationWarning("Cannot find any unlockable exercises") 249 } 250 251 // If we have core exercises add markdown-style secondary header then 252 // loop through and show in tree format. 253 // If we don't have any core exercises, warn about configuration. 254 numCore := len(coreExercises) 255 if numCore > 0 { 256 writeLines("", "core", "----") 257 258 lastSlug := coreExercises[numCore-1] // Used to set the isLast hint. 259 for _, slug := range coreExercises { 260 e := slugToExercise[slug] 261 tree(e, 0, (slug == lastSlug)) 262 } 263 } else { 264 printConfigurationWarning("Cannot find any core exercises") 265 } 266 267 // This is not a tree structure, so unlike core above we do not use the 268 // tree output. Just a normal listing. Otherwise this is like the core loop. 269 numBonus := len(bonusExercises) 270 if numBonus > 0 { 271 writeLines("", "bonus", "-----") 272 273 for _, slug := range bonusExercises { 274 e := slugToExercise[slug] 275 writeLines(e.description()) 276 } 277 } else { 278 printConfigurationWarning("Cannot find any bonus exercises") 279 } 280 281 return nil 282 } 283 284 func init() { 285 RootCmd.AddCommand(treeCmd) 286 treeCmd.Flags().BoolVar(&withDifficulty, "with-difficulty", false, "display the difficulty of the exercises") 287 }