devtools/hackdoc: init
This is hackdoc, a documentation rendering tool for monorepos.
This is the first code iteration, that can only serve from a local git
checkout.
The code is incomplete, and is WIP.
Change-Id: I68ef7a991191c1bb1b0fdd2a8d8353aba642e28f
diff --git a/devtools/hackdoc/config/BUILD.bazel b/devtools/hackdoc/config/BUILD.bazel
new file mode 100644
index 0000000..fecf638
--- /dev/null
+++ b/devtools/hackdoc/config/BUILD.bazel
@@ -0,0 +1,20 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
+
+go_library(
+ name = "go_default_library",
+ srcs = ["config.go"],
+ importpath = "code.hackerspace.pl/hscloud/devtools/hackdoc/config",
+ visibility = ["//visibility:public"],
+ deps = [
+ "//devtools/hackdoc/source:go_default_library",
+ "@com_github_burntsushi_toml//:go_default_library",
+ "@com_github_golang_glog//:go_default_library",
+ ],
+)
+
+go_test(
+ name = "go_default_test",
+ srcs = ["config_test.go"],
+ embed = [":go_default_library"],
+ deps = ["@com_github_go_test_deep//:go_default_library"],
+)
diff --git a/devtools/hackdoc/config/config.go b/devtools/hackdoc/config/config.go
new file mode 100644
index 0000000..70b7b46
--- /dev/null
+++ b/devtools/hackdoc/config/config.go
@@ -0,0 +1,152 @@
+package config
+
+import (
+ "fmt"
+ "html/template"
+ "strings"
+
+ "github.com/BurntSushi/toml"
+ "github.com/golang/glog"
+
+ "code.hackerspace.pl/hscloud/devtools/hackdoc/source"
+)
+
+// Config is a configuration concerning a given path of the source. It is built
+// from files present in the source, and from global configuration.
+type Config struct {
+ // DefaultIndex is the filenames that should attempt to be rendered if no exact path is given
+ DefaultIndex []string
+ // Templates are the templates available to render markdown files, keyed by template name.
+ Templates map[string]*template.Template
+
+ // Errors that occured while building this config (due to config file errors, etc).
+ Errors map[string]error
+}
+
+type configToml struct {
+ DefaultIndex []string `toml:"default_index"`
+ Templates map[string]*configTomlTemplate `toml:"template"`
+}
+
+type configTomlTemplate struct {
+ Sources []string `toml:"sources"`
+}
+
+func parseToml(data []byte) (*configToml, error) {
+ var c configToml
+ err := toml.Unmarshal(data, &c)
+ if err != nil {
+ return nil, err
+ }
+ if c.Templates == nil {
+ c.Templates = make(map[string]*configTomlTemplate)
+ }
+ return &c, nil
+}
+
+func configFileLocations(path string) []string {
+ // Support for unix-style filesystem prefix (/foo/bar/baz) and
+ // perforce-depot-style prefix (//foo/bar/baz).
+ // Also support relative paths.
+ pathTrimmed := strings.TrimLeft(path, "/")
+ prefixLen := len(path) - len(pathTrimmed)
+ prefix := path[:prefixLen]
+ path = pathTrimmed
+ if len(prefix) > 2 {
+ return nil
+ }
+
+ // Turn path into possible directory names, including root.
+ path = strings.Trim(path, "/")
+ parts := strings.Split(path, "/")
+ if parts[0] != "" {
+ parts = append([]string{""}, parts...)
+ }
+
+ locations := []string{}
+ for i, _ := range parts {
+ p := strings.Join(parts[:i+1], "/")
+ p += "/hackdoc.toml"
+ p = prefix + strings.Trim(p, "/")
+ locations = append(locations, p)
+ }
+ return locations
+}
+
+func ForPath(s source.Source, path string) (*Config, error) {
+ if path != "//" {
+ path = strings.TrimRight(path, "/")
+ }
+
+ // Try cache.
+ cacheKey := fmt.Sprintf("config:%s", path)
+ if v := s.CacheGet(cacheKey); v != nil {
+ cfg, ok := v.(*Config)
+ if !ok {
+ glog.Errorf("Cache key %q corrupted, deleting", cacheKey)
+ s.CacheSet([]string{}, cacheKey, nil)
+ } else {
+ return cfg, nil
+ }
+ }
+
+ // Feed cache.
+ cfg := &Config{
+ Templates: make(map[string]*template.Template),
+ Errors: make(map[string]error),
+ }
+
+ tomlPaths := configFileLocations(path)
+ for _, p := range tomlPaths {
+ file, err := s.IsFile(p)
+ if err != nil {
+ return nil, fmt.Errorf("IsFile(%q): %w", path, err)
+ }
+ if !file {
+ continue
+ }
+ data, err := s.ReadFile(p)
+ if err != nil {
+ return nil, fmt.Errorf("ReadFile(%q): %w", path, err)
+ }
+
+ c, err := parseToml(data)
+ if err != nil {
+ cfg.Errors[p] = err
+ continue
+ }
+
+ err = cfg.updateFromToml(p, s, c)
+ if err != nil {
+ return nil, fmt.Errorf("updating from %q: %w", p, err)
+ }
+ }
+
+ return cfg, nil
+}
+
+func (c *Config) updateFromToml(p string, s source.Source, t *configToml) error {
+ if t.DefaultIndex != nil {
+ c.DefaultIndex = t.DefaultIndex
+ }
+
+ for k, v := range t.Templates {
+ tmpl := template.New(k)
+
+ for _, source := range v.Sources {
+ data, err := s.ReadFile(source)
+ if err != nil {
+ c.Errors[p] = fmt.Errorf("reading template file %q: %w", source, err)
+ return nil
+ }
+ tmpl, err = tmpl.Parse(string(data))
+ if err != nil {
+ c.Errors[p] = fmt.Errorf("parsing template file %q: %w", source, err)
+ return nil
+ }
+ }
+ c.Templates[k] = tmpl
+ }
+
+ return nil
+}
diff --git a/devtools/hackdoc/config/config_test.go b/devtools/hackdoc/config/config_test.go
new file mode 100644
index 0000000..ba542fe
--- /dev/null
+++ b/devtools/hackdoc/config/config_test.go
@@ -0,0 +1,99 @@
+package config
+
+import (
+ "testing"
+
+ "github.com/go-test/deep"
+)
+
+func TestParse(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ data string
+ want *configToml
+ }{
+ {
+ name: "normal config",
+ data: `
+ default_index = ["foo.md", "bar.md"]
+ [template.default]
+ sources = ["hackdoc/bar.html", "hackdoc/baz.html"]
+ [template.foo]
+ sources = ["foo/bar.html", "foo/baz.html"]
+ `,
+ want: &configToml{
+ DefaultIndex: []string{"foo.md", "bar.md"},
+ Templates: map[string]*configTomlTemplate{
+ "default": &configTomlTemplate{
+ Sources: []string{"hackdoc/bar.html", "hackdoc/baz.html"},
+ },
+ "foo": &configTomlTemplate{
+ Sources: []string{"foo/bar.html", "foo/baz.html"},
+ },
+ },
+ },
+ }, {
+ name: "empty config",
+ data: "",
+ want: &configToml{
+ DefaultIndex: nil,
+ Templates: map[string]*configTomlTemplate{},
+ },
+ },
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ got, err := parseToml([]byte(test.data))
+ if err != nil {
+ t.Fatalf("could not parse config: %w", err)
+ }
+ if diff := deep.Equal(test.want, got); diff != nil {
+ t.Fatal(diff)
+ }
+ })
+ }
+}
+
+func TestLocations(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ path string
+ want []string
+ }{
+ {
+ name: "perforce-style path",
+ path: "//foo/bar/baz",
+ want: []string{"//hackdoc.toml", "//foo/hackdoc.toml", "//foo/bar/hackdoc.toml", "//foo/bar/baz/hackdoc.toml"},
+ }, {
+ name: "unix-style path",
+ path: "/foo/bar/baz",
+ want: []string{"/hackdoc.toml", "/foo/hackdoc.toml", "/foo/bar/hackdoc.toml", "/foo/bar/baz/hackdoc.toml"},
+ }, {
+ name: "relative-style path",
+ path: "foo/bar/baz",
+ want: []string{"hackdoc.toml", "foo/hackdoc.toml", "foo/bar/hackdoc.toml", "foo/bar/baz/hackdoc.toml"},
+ }, {
+ name: "root perforce-style path",
+ path: "//",
+ want: []string{"//hackdoc.toml"},
+ }, {
+ name: "root unix-style path",
+ path: "/",
+ want: []string{"/hackdoc.toml"},
+ }, {
+ name: "empty path",
+ path: "",
+ want: []string{"hackdoc.toml"},
+ }, {
+ name: "weird path",
+ path: "///what/is///this///",
+ want: nil,
+ },
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ got := configFileLocations(test.path)
+ if diff := deep.Equal(test.want, got); diff != nil {
+ t.Fatal(diff)
+ }
+ })
+ }
+}