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  }