Source file src/simd/archsimd/_gen/cmd/spectasks/main.go

     1  // Copyright 2026 The Go Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  // Spectasks renders SPEC-TRANSITION.md's task dependencies as a Graphviz
     6  // graph, and checks the document's copies of those dependencies against each
     7  // other.
     8  //
     9  //	go run ./cmd/spectasks -w        # write SPEC-TASKS.dot
    10  //	go run ./cmd/spectasks           # check only; write the graph to stdout
    11  //
    12  // SPEC-TRANSITION.md states its dependencies three times: once in Part 2's
    13  // table, which this tool treats as the source of truth; once per task in
    14  // Part 3's "Needs:"/"Prefers:" header lines; and a third time, reversed, in
    15  // Part 3's "Blocks:" lines. go test ./cmd/spectasks fails if they disagree, and
    16  // also fails if SPEC-TASKS.dot has not been regenerated since. Adding the graph
    17  // as a fourth hand-maintained copy would have made that worse, so the graph is
    18  // generated.
    19  //
    20  // Two things the graph shows that the table cannot. The critical path is
    21  // computed, not asserted: a task is on it when delaying it delays the whole
    22  // project, which is the set of tasks whose longest-chain-in plus
    23  // longest-chain-out spans the graph. And milestones -- the points worth
    24  // announcing -- are drawn as nodes, so it is visible which tasks feed them.
    25  // Milestone state is derived from the task checkboxes and is never written
    26  // down separately.
    27  package main
    28  
    29  import (
    30  	"bytes"
    31  	"cmp"
    32  	"flag"
    33  	"fmt"
    34  	"log"
    35  	"os"
    36  	"path/filepath"
    37  	"regexp"
    38  	"slices"
    39  	"strings"
    40  
    41  	"simd/archsimd/_gen/gentools"
    42  	"simd/archsimd/_gen/specgen"
    43  )
    44  
    45  // paths returns the plan document and the graph generated from it.
    46  func paths(goroot string) (md, dot string) {
    47  	specDir := specgen.MustFindSpecDir(goroot)
    48  	genDir := filepath.Join(filepath.Clean(filepath.Join(specDir, "..", "..")), "archsimd", "_gen")
    49  	return filepath.Join(genDir, "SPEC-TRANSITION.md"), filepath.Join(genDir, "SPEC-TASKS.dot")
    50  }
    51  
    52  // load parses the plan document.
    53  func load(md string) *doc {
    54  	data, err := os.ReadFile(md)
    55  	if err != nil {
    56  		log.Fatal(err)
    57  	}
    58  	return parse(string(data))
    59  }
    60  
    61  func main() {
    62  	log.SetFlags(0)
    63  	log.SetPrefix("spectasks: ")
    64  	genOpts := gentools.RegisterFlags(nil)
    65  	flag.Parse()
    66  
    67  	md, _ := paths(genOpts.GOROOT)
    68  	d := load(md)
    69  
    70  	// The test is where inconsistency is reported properly; this is only a
    71  	// guard, so that a graph is never written from a document that disagrees
    72  	// with itself.
    73  	if probs := d.problems(); len(probs) > 0 {
    74  		for _, s := range probs {
    75  			fmt.Fprintf(os.Stderr, "spectasks: %s\n", s)
    76  		}
    77  		log.Fatalf("%s disagrees with itself; go test ./cmd/spectasks reports this properly", filepath.Base(md))
    78  	}
    79  
    80  	var files gentools.Files
    81  	defer files.FlushOrExit()
    82  
    83  	buf := files.NewRawFile("simd/archsimd/_gen/SPEC-TASKS.dot")
    84  	d.dot(buf)
    85  }
    86  
    87  // A dep is one edge: the id of the task depended on.
    88  type dep struct {
    89  	id string
    90  }
    91  
    92  type task struct {
    93  	id             string
    94  	done           bool
    95  	track          string
    96  	title          string
    97  	needs, prefers []dep
    98  
    99  	critical bool // on some longest chain through the graph
   100  }
   101  
   102  type track struct {
   103  	letter, name string
   104  	tasks        []string
   105  }
   106  
   107  type milestone struct {
   108  	name  string
   109  	needs []dep
   110  }
   111  
   112  type doc struct {
   113  	tasks      map[string]*task
   114  	order      []string // task ids, in table order, which must be topological
   115  	tracks     []track
   116  	milestones []milestone
   117  
   118  	// part4 is what Part 4's header lines claim, for cross-checking.
   119  	part4 map[string]struct{ needs, prefers, blocks []dep }
   120  }
   121  
   122  // --- parsing ---------------------------------------------------------------
   123  
   124  // A markdown table, as a header row and body rows of trimmed cells.
   125  type mdTable struct {
   126  	head []string
   127  	rows [][]string
   128  }
   129  
   130  // cells splits one "| a | b |" line. Leading and trailing empties from the
   131  // bounding pipes are dropped.
   132  func cells(line string) []string {
   133  	s := strings.TrimSpace(line)
   134  	if !strings.HasPrefix(s, "|") || !strings.HasSuffix(s, "|") {
   135  		return nil
   136  	}
   137  	parts := strings.Split(s[1:len(s)-1], "|")
   138  	for i, p := range parts {
   139  		parts[i] = strings.TrimSpace(p)
   140  	}
   141  	return parts
   142  }
   143  
   144  // findTable returns the first table in lines whose header row equals head.
   145  // The header must match exactly: this is a format we control, and a silent
   146  // mismatch would silently drop rows.
   147  func findTable(lines []string, head ...string) *mdTable {
   148  	for i, line := range lines {
   149  		got := cells(line)
   150  		if !slices.Equal(got, head) {
   151  			continue
   152  		}
   153  		if i+1 >= len(lines) || !strings.HasPrefix(strings.TrimSpace(lines[i+1]), "|-") {
   154  			log.Fatalf("table %q at line %d has no separator row", head, i+1)
   155  		}
   156  		t := &mdTable{head: got}
   157  		for _, row := range lines[i+2:] {
   158  			c := cells(row)
   159  			if c == nil {
   160  				break
   161  			}
   162  			if len(c) != len(head) {
   163  				log.Fatalf("line %d: got %d cells, want %d", i+1, len(c), len(head))
   164  			}
   165  			t.rows = append(t.rows, c)
   166  		}
   167  		return t
   168  	}
   169  	log.Fatalf("no table with header %q", head)
   170  	return nil
   171  }
   172  
   173  var (
   174  	// A dependency list entry: a task id.
   175  	depRE = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
   176  	// A Part 4 task heading: "### `spec-common` — Spec the common operations".
   177  	headingRE = regexp.MustCompile("^### `([a-z0-9]+(?:-[a-z0-9]+)*)` — ")
   178  	// A Part 4 header field: "**Needs:** 3, 5".
   179  	fieldRE = regexp.MustCompile(`\*\*(Needs|Prefers|Blocks):\*\* ([^·]*)`)
   180  	// Markdown emphasis and code spans, for extracting plain text.
   181  	markupRE = regexp.MustCompile("[`*]")
   182  )
   183  
   184  // parseDeps parses a dependency cell: "—" for none, else a comma-separated
   185  // list of dep entries.
   186  func parseDeps(s string) []dep {
   187  	s = strings.TrimSpace(markupRE.ReplaceAllString(s, ""))
   188  	if s == "" || s == "—" || s == "-" {
   189  		return nil
   190  	}
   191  	var out []dep
   192  	for _, f := range strings.Split(s, ",") {
   193  		f = strings.TrimSpace(f)
   194  		if !depRE.MatchString(f) {
   195  			log.Fatalf("cannot parse dependency %q in %q; want a task id", f, s)
   196  		}
   197  		out = append(out, dep{f})
   198  	}
   199  	// Sorted so that the three copies of the edge set compare equal
   200  	// regardless of the order each happens to list them in.
   201  	slices.SortFunc(out, func(a, b dep) int { return cmp.Compare(a.id, b.id) })
   202  	return out
   203  }
   204  
   205  func parse(text string) *doc {
   206  	lines := strings.Split(text, "\n")
   207  	d := &doc{
   208  		tasks: make(map[string]*task),
   209  		part4: make(map[string]struct{ needs, prefers, blocks []dep }),
   210  	}
   211  
   212  	for _, r := range findTable(lines, "", "Track", "What it is", "Tasks").rows {
   213  		var tasks []string
   214  		for _, dep := range parseDeps(r[3]) {
   215  			tasks = append(tasks, dep.id)
   216  		}
   217  		d.tracks = append(d.tracks, track{
   218  			letter: markupRE.ReplaceAllString(r[0], ""),
   219  			name:   r[1],
   220  			tasks:  tasks,
   221  		})
   222  	}
   223  
   224  	for _, r := range findTable(lines, "✓", "T", "Task", "What it is", "Needs", "Prefers").rows {
   225  		id := markupRE.ReplaceAllString(r[2], "")
   226  		if !depRE.MatchString(id) {
   227  			log.Fatalf("bad task id %q; want lowercase words joined by hyphens", r[2])
   228  		}
   229  		if d.tasks[id] != nil {
   230  			log.Fatalf("task %q listed twice", id)
   231  		}
   232  		d.tasks[id] = &task{
   233  			id:      id,
   234  			done:    strings.Contains(r[0], "x"),
   235  			track:   r[1],
   236  			title:   markupRE.ReplaceAllString(r[3], ""),
   237  			needs:   parseDeps(r[4]),
   238  			prefers: parseDeps(r[5]),
   239  		}
   240  		d.order = append(d.order, id)
   241  	}
   242  
   243  	for _, r := range findTable(lines, "Milestone", "Reached after", "What it means").rows {
   244  		d.milestones = append(d.milestones, milestone{
   245  			name:  markupRE.ReplaceAllString(r[0], ""),
   246  			needs: parseDeps(r[1]),
   247  		})
   248  	}
   249  
   250  	// Part 4 header lines, for cross-checking.
   251  	cur := ""
   252  	for _, line := range lines {
   253  		if m := headingRE.FindStringSubmatch(line); m != nil {
   254  			cur = m[1]
   255  			continue
   256  		}
   257  		if cur == "" || !strings.HasPrefix(line, "**Done:** [") {
   258  			continue
   259  		}
   260  		var e struct{ needs, prefers, blocks []dep }
   261  		for _, m := range fieldRE.FindAllStringSubmatch(line, -1) {
   262  			switch m[1] {
   263  			case "Needs":
   264  				e.needs = parseDeps(m[2])
   265  			case "Prefers":
   266  				e.prefers = parseDeps(m[2])
   267  			case "Blocks":
   268  				e.blocks = parseDeps(m[2])
   269  			}
   270  		}
   271  		d.part4[cur] = e
   272  		cur = ""
   273  	}
   274  	return d
   275  }
   276  
   277  // --- checking --------------------------------------------------------------
   278  
   279  func ids(ds []dep) []string {
   280  	out := make([]string, len(ds))
   281  	for i, d := range ds {
   282  		out[i] = d.id
   283  	}
   284  	return out
   285  }
   286  
   287  func fmtIDs(xs []string) string {
   288  	if len(xs) == 0 {
   289  		return "—"
   290  	}
   291  	return strings.Join(xs, ", ")
   292  }
   293  
   294  // problems returns everything inconsistent about the document, as one message
   295  // per problem, in a stable order. It reports rather than fails, so that the
   296  // test can attribute each one and the tool can use it as a guard.
   297  func (d *doc) problems() []string {
   298  	var probs []string
   299  	fail := func(format string, args ...any) {
   300  		probs = append(probs, fmt.Sprintf(format, args...))
   301  	}
   302  
   303  	// Every task belongs to exactly one track, and the two lists agree.
   304  	seen := make(map[string]string)
   305  	for _, tr := range d.tracks {
   306  		for _, id := range tr.tasks {
   307  			if prev, ok := seen[id]; ok {
   308  				fail("task %s is in tracks %s and %s", id, prev, tr.letter)
   309  			}
   310  			seen[id] = tr.letter
   311  			if t := d.tasks[id]; t == nil {
   312  				fail("track %s lists task %s, which the dependency table does not have", tr.letter, id)
   313  			} else if t.track != tr.letter {
   314  				fail("task %s is in track %s per the track table, %s per the dependency table", id, tr.letter, t.track)
   315  			}
   316  		}
   317  	}
   318  
   319  	// The table is in topological order, so that reading it top to bottom is
   320  	// a legal order to do the work in. This is what task ids buy: identity no
   321  	// longer encodes position, so rows can be moved and inserted freely.
   322  	pos := make(map[string]int, len(d.order))
   323  	for i, id := range d.order {
   324  		pos[id] = i
   325  	}
   326  
   327  	// Edges point at real tasks; Part 4 agrees with the table; Blocks is the
   328  	// exact reverse of Needs.
   329  	blocks := make(map[string][]string)
   330  	for _, id := range d.order {
   331  		t := d.tasks[id]
   332  		if seen[id] == "" {
   333  			fail("task %s is in no track", id)
   334  		}
   335  		for _, e := range slices.Concat(t.needs, t.prefers) {
   336  			if d.tasks[e.id] == nil {
   337  				fail("task %s depends on task %s, which does not exist", id, e.id)
   338  			}
   339  		}
   340  		for _, e := range t.needs {
   341  			if d.tasks[e.id] != nil && pos[e.id] > pos[id] {
   342  				fail("task %s needs %s but comes before it; the table must be in topological order", id, e.id)
   343  			}
   344  			blocks[e.id] = append(blocks[e.id], id)
   345  		}
   346  		for _, e := range t.prefers {
   347  			if d.tasks[e.id] != nil && pos[e.id] > pos[id] {
   348  				fail("task %s prefers %s but comes before it; the table must be in topological order", id, e.id)
   349  			}
   350  		}
   351  		p4, ok := d.part4[id]
   352  		if !ok {
   353  			fail("task %s has no Part 4 entry", id)
   354  			continue
   355  		}
   356  		if !slices.Equal(ids(t.needs), ids(p4.needs)) {
   357  			fail("task %s Needs: table says %s, Part 4 says %s", id, fmtIDs(ids(t.needs)), fmtIDs(ids(p4.needs)))
   358  		}
   359  		if !slices.Equal(ids(t.prefers), ids(p4.prefers)) {
   360  			fail("task %s Prefers: table says %s, Part 4 says %s", id, fmtIDs(ids(t.prefers)), fmtIDs(ids(p4.prefers)))
   361  		}
   362  	}
   363  	for _, id := range d.order {
   364  		want := blocks[id]
   365  		slices.Sort(want)
   366  		if got := ids(d.part4[id].blocks); !slices.Equal(got, want) {
   367  			fail("task %s Blocks: header says %s, reversing Needs gives %s", id, fmtIDs(got), fmtIDs(want))
   368  		}
   369  	}
   370  
   371  	// Unrelated tasks sort by track: if task A (track X) precedes task B
   372  	// (track Y) and X > Y, then B must transitively depend on A through
   373  	// Needs or Prefers edges.
   374  	tdeps := make(map[string]map[string]bool, len(d.order))
   375  	var computeDeps func(string) map[string]bool
   376  	computeDeps = func(id string) map[string]bool {
   377  		if r, ok := tdeps[id]; ok {
   378  			return r
   379  		}
   380  		r := make(map[string]bool)
   381  		tdeps[id] = r
   382  		for _, e := range slices.Concat(d.tasks[id].needs, d.tasks[id].prefers) {
   383  			if d.tasks[e.id] != nil {
   384  				r[e.id] = true
   385  				for dep := range computeDeps(e.id) {
   386  					r[dep] = true
   387  				}
   388  			}
   389  		}
   390  		return r
   391  	}
   392  	for _, id := range d.order {
   393  		computeDeps(id)
   394  	}
   395  	for i, id := range d.order {
   396  		for _, id2 := range d.order[i+1:] {
   397  			if d.tasks[id].track > d.tasks[id2].track && !tdeps[id2][id] {
   398  				fail("task %s (track %s) precedes %s (track %s) but %s does not depend on %s; unrelated tasks sort by track",
   399  					id, d.tasks[id].track, id2, d.tasks[id2].track, id2, id)
   400  			}
   401  		}
   402  	}
   403  
   404  	for _, m := range d.milestones {
   405  		for _, e := range m.needs {
   406  			if d.tasks[e.id] == nil {
   407  				fail("milestone %q depends on task %s, which does not exist", m.name, e.id)
   408  			}
   409  		}
   410  	}
   411  
   412  	return probs
   413  }
   414  
   415  // mark sets task.critical on every task that lies on some longest chain of
   416  // hard dependencies: delaying it delays the whole project. Ties are kept --
   417  // there is usually more than one longest chain, and picking one of them
   418  // arbitrarily is how a hand-written "critical path" understates the work.
   419  func (d *doc) mark() {
   420  	var into, outOf func(string) int
   421  	memo := func(f func(string) int) func(string) int {
   422  		m := make(map[string]int)
   423  		return func(id string) int {
   424  			if v, ok := m[id]; ok {
   425  				return v
   426  			}
   427  			m[id] = 1 // cycles fall back to 1 rather than recursing forever
   428  			v := f(id)
   429  			m[id] = v
   430  			return v
   431  		}
   432  	}
   433  	// Longest chain ending at id, counted in tasks.
   434  	into = memo(func(id string) int {
   435  		best := 0
   436  		for _, e := range d.tasks[id].needs {
   437  			if d.tasks[e.id] != nil {
   438  				best = max(best, into(e.id))
   439  			}
   440  		}
   441  		return best + 1
   442  	})
   443  	// Longest chain starting at id.
   444  	outOf = memo(func(id string) int {
   445  		best := 0
   446  		for _, other := range d.order {
   447  			for _, e := range d.tasks[other].needs {
   448  				if e.id == id {
   449  					best = max(best, outOf(other))
   450  				}
   451  			}
   452  		}
   453  		return best + 1
   454  	})
   455  
   456  	span := 0
   457  	for _, id := range d.order {
   458  		span = max(span, into(id))
   459  	}
   460  	for _, id := range d.order {
   461  		d.tasks[id].critical = into(id)+outOf(id)-1 == span
   462  	}
   463  }
   464  
   465  // reached reports whether every task feeding m is ticked.
   466  func (d *doc) reached(m milestone) bool {
   467  	for _, e := range m.needs {
   468  		if !d.tasks[e.id].done {
   469  			return false
   470  		}
   471  	}
   472  	return len(m.needs) > 0
   473  }
   474  
   475  // --- rendering -------------------------------------------------------------
   476  
   477  // Node fills, in track order. Light enough to read black text on.
   478  var trackFill = []string{
   479  	"#dce9f7", "#d9f2e2", "#fbe6cd", "#ebdcf7", "#f7dce3", "#d8f0f0", "#e8e8e8",
   480  }
   481  
   482  // wrap breaks a title into Graphviz label lines of at most width runes,
   483  // without breaking words.
   484  func wrap(s string, width int) string {
   485  	var lines []string
   486  	line := ""
   487  	for _, w := range strings.Fields(s) {
   488  		switch {
   489  		case line == "":
   490  			line = w
   491  		case len([]rune(line))+1+len([]rune(w)) <= width:
   492  			line += " " + w
   493  		default:
   494  			lines = append(lines, line)
   495  			line = w
   496  		}
   497  	}
   498  	return strings.Join(append(lines, line), `\n`)
   499  }
   500  
   501  // node is a task id as a Graphviz node name. Ids contain hyphens, which are
   502  // not legal in a bare Graphviz ID, so they are always quoted.
   503  func node(id string) string { return quote(id) }
   504  
   505  func quote(s string) string {
   506  	return `"` + strings.NewReplacer(`"`, `\"`, "\n", `\n`).Replace(s) + `"`
   507  }
   508  
   509  func (d *doc) dot(w *bytes.Buffer) {
   510  	d.mark()
   511  	p := func(format string, args ...any) { fmt.Fprintf(w, format+"\n", args...) }
   512  
   513  	p(`// Task graph for SPEC-TRANSITION.md. Do not edit.`)
   514  	p(`// Generated by archsimd/_gen/cmd/spectasks; regenerate with:`)
   515  	p(`//     go run ./cmd/spectasks -w`)
   516  	p(`// Render with, e.g.:`)
   517  	p(`//     dot -Tsvg SPEC-TASKS.dot -o SPEC-TASKS.svg`)
   518  	p(`//`)
   519  	p(`// The key in the rendered graph explains the notation.`)
   520  	p(``)
   521  	p(`digraph spectransition {`)
   522  	p(`  rankdir=LR;`)
   523  	p(`  newrank=true;`)
   524  	p(`  ranksep=0.55; nodesep=0.22;`)
   525  	p(`  fontname="Helvetica"; fontsize=11;`)
   526  	p(`  node [shape=box, style="rounded,filled", fillcolor=white,`)
   527  	p(`        fontname="Helvetica", fontsize=10, margin="0.10,0.06"];`)
   528  	p(`  edge [color="#666666", arrowsize=0.7];`)
   529  	p(``)
   530  
   531  	// Tracks are shown as node colour, not as clusters: a cluster has to be
   532  	// laid out contiguously, which fights the topological ranking and drags
   533  	// long edges across the whole graph. Colour costs a legend and keeps the
   534  	// left-to-right reading of dependency order.
   535  	emit := func(t *task, fill string) {
   536  		attrs := []string{
   537  			"label=" + quote(t.id+`\n`+wrap(t.title, 22)),
   538  			"fillcolor=" + quote(fill),
   539  			`color="#999999"`,
   540  		}
   541  		if t.done {
   542  			attrs = append(attrs, `fillcolor="#d0d0d0"`, `fontcolor="#777777"`)
   543  		}
   544  		p(`  %s [%s];`, node(t.id), strings.Join(attrs, ", "))
   545  	}
   546  	// Emitted track by track: declaration order is one of the things dot breaks
   547  	// ties on, and grouping by track lays out better than topological order
   548  	// does. Any task no track claims is emitted afterwards rather than dropped,
   549  	// so a document that problems() would reject still renders.
   550  	seen := make(map[string]bool, len(d.order))
   551  	for i, tr := range d.tracks {
   552  		for _, id := range tr.tasks {
   553  			if t := d.tasks[id]; t != nil && !seen[id] {
   554  				seen[id] = true
   555  				emit(t, trackFill[i%len(trackFill)])
   556  			}
   557  		}
   558  	}
   559  	for _, id := range d.order {
   560  		if !seen[id] {
   561  			emit(d.tasks[id], "white")
   562  		}
   563  	}
   564  
   565  	p(``)
   566  
   567  	for _, id := range d.order {
   568  		t := d.tasks[id]
   569  		for _, e := range t.needs {
   570  			var attrs []string
   571  			if t.critical && d.tasks[e.id].critical {
   572  				attrs = append(attrs, `color="#c0392b"`, `penwidth=1.8`)
   573  			}
   574  			p(`  %s -> %s%s;`, node(e.id), node(id), attrList(attrs))
   575  		}
   576  		for _, e := range t.prefers {
   577  			p(`  %s -> %s [style=dotted, color="#aaaaaa", arrowsize=0.5];`, node(e.id), node(id))
   578  		}
   579  	}
   580  	p(``)
   581  
   582  	for i, m := range d.milestones {
   583  		fill := "#fff8dc"
   584  		if d.reached(m) {
   585  			fill = "#d7d7d7"
   586  		}
   587  		p(`  m%d [label=%s, shape=box, style="filled,bold", fillcolor=%s, color="#8a6d3b"];`,
   588  			i, quote(wrap(m.name, 22)), quote(fill))
   589  		for _, e := range m.needs {
   590  			p(`  %s -> m%d [color="#8a6d3b", style=bold, arrowsize=0.7];`, node(e.id), i)
   591  		}
   592  	}
   593  
   594  	// The key is a single node with an HTML label rather than a cluster of
   595  	// nodes: a cluster chained together spans as many ranks as it has entries,
   596  	// and dot then aligns those ranks with the graph proper, which distorts the
   597  	// layout it is supposed to annotate.
   598  	p(``)
   599  	p(`  key [shape=plaintext, style="", label=<`)
   600  	p(`    <TABLE BORDER="0" CELLBORDER="0" CELLSPACING="1" CELLPADDING="3" BGCOLOR="#fafafa">`)
   601  	p(`    <TR><TD ALIGN="LEFT" COLSPAN="2"><B>Tracks</B></TD></TR>`)
   602  	for i, tr := range d.tracks {
   603  		p(`    <TR><TD BGCOLOR="%s" WIDTH="18"> </TD><TD ALIGN="LEFT">%s</TD></TR>`,
   604  			trackFill[i%len(trackFill)], html(tr.letter+" — "+tr.name))
   605  	}
   606  	p(`    <TR><TD COLSPAN="2"><FONT POINT-SIZE="5"> </FONT></TD></TR>`)
   607  	p(`    <TR><TD ALIGN="LEFT" COLSPAN="2"><B>Reading the graph</B></TD></TR>`)
   608  	// Each row shows the thing it names, rather than describing it: an arrow in
   609  	// the edge's own colour, a swatch in the node's own fill.
   610  	arrow := func(color, glyph string) string {
   611  		return fmt.Sprintf(`<FONT COLOR="%s" POINT-SIZE="14">%s</FONT>`, color, glyph)
   612  	}
   613  	swatch := func(fill, border string) string {
   614  		return fmt.Sprintf(`<TABLE BORDER="1" CELLBORDER="0" CELLSPACING="0" CELLPADDING="0" `+
   615  			`COLOR="%s" BGCOLOR="%s"><TR><TD WIDTH="16" HEIGHT="9"> </TD></TR></TABLE>`, border, fill)
   616  	}
   617  	for _, row := range [][2]string{
   618  		{arrow("#666666", "&#8594;"), "must be done first"},
   619  		{arrow("#aaaaaa", "&#8674;"), "better done first, but not required"},
   620  		{arrow("#c0392b", "&#8594;"), "on a longest chain: slipping it slips the project"},
   621  		{swatch("#fff8dc", "#8a6d3b"), "milestone: reached, not worked on"},
   622  		{swatch("#d0d0d0", "#999999"), "done"},
   623  	} {
   624  		p(`    <TR><TD ALIGN="RIGHT">%s</TD><TD ALIGN="LEFT">%s</TD></TR>`, row[0], html(row[1]))
   625  	}
   626  	p(`    </TABLE>`)
   627  	p(`  >];`)
   628  	p(`}`)
   629  }
   630  
   631  // html escapes text for a Graphviz HTML-like label.
   632  func html(s string) string {
   633  	return strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;").Replace(s)
   634  }
   635  
   636  func attrList(attrs []string) string {
   637  	if len(attrs) == 0 {
   638  		return ""
   639  	}
   640  	return " [" + strings.Join(attrs, ", ") + "]"
   641  }
   642  

View as plain text