diff --git a/cmd/htpacker/content_type_glob.go b/cmd/htpacker/content_type_glob.go new file mode 100644 index 0000000..e461908 --- /dev/null +++ b/cmd/htpacker/content_type_glob.go @@ -0,0 +1,105 @@ +package main + +import ( + "fmt" + "path/filepath" + "strings" + + "src.lwithers.me.uk/go/htpack/cmd/htpacker/packer" +) + +type ctGlobEntry struct { + pattern, contentType string + pathComponents int +} + +type ctGlobList []ctGlobEntry + +func parseGlobs(flags []string) (ctGlobList, error) { + var ctGlobs ctGlobList + for _, flag := range flags { + // split pattern:content-type + pos := strings.LastIndexByte(flag, ':') + if pos == -1 { + return nil, &parseGlobError{ + Value: flag, + Err: "must be pattern:content-type", + } + } + pattern, ct := flag[:pos], flag[pos+1:] + + // patterns starting with "/" must match the entire directory + // prefix; otherwise, an arbitrary number of path components are + // allowed prior to the prefix + var pathComponents int + if strings.HasPrefix(pattern, "/") { + pathComponents = -1 + pattern = strings.TrimPrefix(pattern, "/") + } else { + pathComponents = 1 + strings.Count(pattern, "/") + } + + // test that the pattern's syntax is valid + if _, err := filepath.Match(pattern, "test"); err != nil { + return nil, &parseGlobError{ + Value: flag, + Err: err.Error(), + } + } + + ctGlobs = append(ctGlobs, ctGlobEntry{ + pattern: pattern, + contentType: ct, + pathComponents: pathComponents, + }) + } + + return ctGlobs, nil +} + +// ApplyContentTypes will scan the list of files to pack, matching by filename, +// and on match will apply the given content type. +func (ctGlobs ctGlobList) ApplyContentTypes(ftp packer.FilesToPack) { + for name := range ftp { + for _, entry := range ctGlobs { + testName := trimPathComponents(name, entry.pathComponents) + matched, _ := filepath.Match(entry.pattern, testName) + if matched { + f := ftp[name] + f.ContentType = entry.contentType + ftp[name] = f + break + } + } + } +} + +func trimPathComponents(name string, components int) string { + name = strings.TrimPrefix(name, "/") // FilesToPack keys = absolute path + + // if we are matching the full prefix, don't otherwise manipulate the + // name + if components < 0 { + return name + } + + // otherwise, trim the number of components remaining in the path so + // that we are only matching the trailing path components from the + // FilesToPack key + parts := 1 + strings.Count(name, "/") + for ; parts > components; parts-- { + pos := strings.IndexByte(name, '/') + name = name[pos+1:] + } + return name +} + +// parseGlobError is returned from parseGlobs on error. +type parseGlobError struct { + Value string + Err string +} + +func (pge *parseGlobError) Error() string { + return fmt.Sprintf("--content-type entry %q: %s", pge.Value, pge.Err) +} diff --git a/cmd/htpacker/content_type_glob_test.go b/cmd/htpacker/content_type_glob_test.go new file mode 100644 index 0000000..7cc2822 --- /dev/null +++ b/cmd/htpacker/content_type_glob_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "testing" + + "src.lwithers.me.uk/go/htpack/cmd/htpacker/packer" +) + +func TestParseGlobs(t *testing.T) { + ctGlobs, err := parseGlobs([]string{ + "*.foo:text/html", + "*.bar:text/plain", + "baz/qux/*.js:application/javascript", + "/abs/file:image/png", + }) + if err != nil { + t.Fatal(err) + } + + check := func(pos int, pattern, contentType string, pathComponents int) { + if pos >= len(ctGlobs) { + t.Errorf("entry %d not present", pos) + return + } + + if pattern != ctGlobs[pos].pattern { + t.Errorf("entry %d: expected pattern %q but got %q", + pos, pattern, ctGlobs[pos].pattern) + } + + if contentType != ctGlobs[pos].contentType { + t.Errorf("entry %d: expected content type %q but got %q", + pos, contentType, ctGlobs[pos].contentType) + } + + if pathComponents != ctGlobs[pos].pathComponents { + t.Errorf("entry %d: expected num. path components %d but got %d", + pos, pathComponents, ctGlobs[pos].pathComponents) + } + } + + check(0, "*.foo", "text/html", 1) + check(1, "*.bar", "text/plain", 1) + check(2, "baz/qux/*.js", "application/javascript", 3) + check(3, "abs/file", "image/png", -1) +} + +func TestParseGlobsErrSep(t *testing.T) { + const badValue = "hello/dave.js" // missing ":" separator + _, err := parseGlobs([]string{badValue}) + switch err := err.(type) { + case *parseGlobError: + if err.Value != badValue { + t.Errorf("expected value %q but got %q", badValue, err.Value) + } + case nil: + t.Fatal("expected error") + default: + t.Errorf("unexpected error type %T (value %v)", err, err) + } +} + +func TestParseGlobsErrPattern(t *testing.T) { + const badValue = "[-z]:foo/bar" // malformed character class + _, err := parseGlobs([]string{badValue}) + switch err := err.(type) { + case *parseGlobError: + if err.Value != badValue { + t.Errorf("expected value %q but got %q", badValue, err.Value) + } + case nil: + t.Fatal("expected error") + default: + t.Errorf("unexpected error type %T (value %v)", err, err) + } +} + +func TestApplyContentTypes(t *testing.T) { + // XXX: we program our _expectation_ of content-type into the Filename field + ftp := packer.FilesToPack{ + "foo.txt": packer.FileToPack{Filename: "text/plain"}, + "baz/foo.txt": packer.FileToPack{Filename: "text/plain"}, + + "baz/qux.png": packer.FileToPack{Filename: "image/png"}, + "foo/qux.png": packer.FileToPack{}, + "foo/baz/qux.png": packer.FileToPack{Filename: "image/png"}, + + "bar.jpeg": packer.FileToPack{}, + "foo/baz/bar.jpeg": packer.FileToPack{}, + "baz/bar.jpeg": packer.FileToPack{Filename: "image/jpeg"}, + } + + ctGlobs, err := parseGlobs([]string{ + "*.txt:text/plain", // should match anywhere + "baz/qux.png:image/png", // won't match /foo/qux.png + "/baz/bar.jpeg:image/jpeg", // exact prefix match + }) + if err != nil { + t.Fatal(err) + } + + ctGlobs.ApplyContentTypes(ftp) + for k, v := range ftp { + if v.Filename != v.ContentType { + t.Errorf("filename %q: expected content type %q but got %q", + k, v.Filename, v.ContentType) + } + } +} diff --git a/cmd/htpacker/pack.go b/cmd/htpacker/pack.go index f584616..86d2791 100644 --- a/cmd/htpacker/pack.go +++ b/cmd/htpacker/pack.go @@ -16,6 +16,20 @@ import ( var packCmd = &cobra.Command{ Use: "pack", Short: "creates a packfile from a YAML spec or set of files/dirs", + Long: `When given a YAML spec file (a template for which can be generated +with the "yaml" command), files will be packed exactly as per the spec. The +--content-type flag cannot be used and no extra files can be specified. + +When given a list of files and directories to pack, the content type for each +file will be automatically detected. It is possible to override the content +type by specifying one or more --content-type flags. These take an argument in +the form "pattern:content/type". The pattern is matched using common glob +(* = wildcard), very similar to .gitignore. If the pattern contains any +directory names, these must match the final components of the file to pack's +path. If the pattern starts with a "/", then the full path must be matched +exactly. +`, + RunE: func(c *cobra.Command, args []string) error { // convert "out" to an absolute path, so that it will still // work after chdir @@ -51,6 +65,16 @@ var packCmd = &cobra.Command{ } } + // parse content-type globs + ctGlobList, err := c.Flags().GetStringArray("content-type") + if err != nil { + return err + } + ctGlobs, err := parseGlobs(ctGlobList) + if err != nil { + return err + } + // if "spec" is not present, then we expect a list of input // files, and we'll build a spec from them if spec == "" { @@ -58,12 +82,16 @@ var packCmd = &cobra.Command{ return errors.New("need --yaml, " + "or one or more filenames") } - err = PackFiles(c, args, out) + err = PackFiles2(c, args, ctGlobs, out) } else { if len(args) != 0 { return errors.New("cannot specify files " + "when using --yaml") } + if ctGlobs != nil { + return errors.New("cannot specify --content-type " + + "when using --yaml") + } err = PackSpec(c, spec, out) } if err != nil { @@ -83,13 +111,20 @@ func init() { "YAML specification file (if not present, just pack files)") packCmd.Flags().StringP("chdir", "C", "", "Change to directory before searching for input files") + packCmd.Flags().StringArrayP("content-type", "", nil, + "Override content type for pattern, e.g. \"*.foo=bar/baz\" (like .gitignore)") } func PackFiles(c *cobra.Command, args []string, out string) error { + return PackFiles2(c, args, nil, out) +} + +func PackFiles2(c *cobra.Command, args []string, ctGlobs ctGlobList, out string) error { ftp, err := filesFromList(args) if err != nil { return err } + ctGlobs.ApplyContentTypes(ftp) return doPack(ftp, out) }