hugo/hugofs/rootmapping_fs.go
Bjørn Erik Pedersen 7ff0a8ee9f Simplify page tree logic
This is preparation for #6041.

For historic reasons, the code for bulding the section tree and the taxonomies were very much separate.

This works, but makes it hard to extend, maintain, and possibly not so fast as it could be.

This simplification also introduces 3 slightly breaking changes, which I suspect most people will be pleased about. See referenced issues:

This commit also switches the radix tree dependency to a mutable implementation: github.com/armon/go-radix.

Fixes #6154
Fixes #6153
Fixes #6152
2019-08-08 20:13:39 +02:00

557 lines
12 KiB
Go

// Copyright 2019 The Hugo Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package hugofs
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/gohugoio/hugo/hugofs/files"
"github.com/pkg/errors"
radix "github.com/armon/go-radix"
"github.com/spf13/afero"
)
var filepathSeparator = string(filepath.Separator)
// NewRootMappingFs creates a new RootMappingFs on top of the provided with
// of root mappings with some optional metadata about the root.
// Note that From represents a virtual root that maps to the actual filename in To.
func NewRootMappingFs(fs afero.Fs, rms ...RootMapping) (*RootMappingFs, error) {
rootMapToReal := radix.New()
for _, rm := range rms {
(&rm).clean()
fromBase := files.ResolveComponentFolder(rm.From)
if fromBase == "" {
panic("unrecognised component folder in" + rm.From)
}
if len(rm.To) < 2 {
panic(fmt.Sprintf("invalid root mapping; from/to: %s/%s", rm.From, rm.To))
}
_, err := fs.Stat(rm.To)
if err != nil {
if os.IsNotExist(err) {
continue
}
return nil, err
}
// Extract "blog" from "content/blog"
rm.path = strings.TrimPrefix(strings.TrimPrefix(rm.From, fromBase), filepathSeparator)
key := rm.rootKey()
var mappings []RootMapping
v, found := rootMapToReal.Get(key)
if found {
// There may be more than one language pointing to the same root.
mappings = v.([]RootMapping)
}
mappings = append(mappings, rm)
rootMapToReal.Insert(key, mappings)
}
rfs := &RootMappingFs{Fs: fs,
virtualRoots: rms,
rootMapToReal: rootMapToReal}
return rfs, nil
}
// NewRootMappingFsFromFromTo is a convenicence variant of NewRootMappingFs taking
// From and To as string pairs.
func NewRootMappingFsFromFromTo(fs afero.Fs, fromTo ...string) (*RootMappingFs, error) {
rms := make([]RootMapping, len(fromTo)/2)
for i, j := 0, 0; j < len(fromTo); i, j = i+1, j+2 {
rms[i] = RootMapping{
From: fromTo[j],
To: fromTo[j+1],
}
}
return NewRootMappingFs(fs, rms...)
}
type RootMapping struct {
From string
To string
path string // The virtual mount point, e.g. "blog".
Meta FileMeta // File metadata (lang etc.)
}
func (rm *RootMapping) clean() {
rm.From = strings.Trim(filepath.Clean(rm.From), filepathSeparator)
rm.To = filepath.Clean(rm.To)
}
func (r RootMapping) filename(name string) string {
if name == "" {
return r.To
}
return filepath.Join(r.To, strings.TrimPrefix(name, r.From))
}
func (r RootMapping) rootKey() string {
return r.From
}
// A RootMappingFs maps several roots into one. Note that the root of this filesystem
// is directories only, and they will be returned in Readdir and Readdirnames
// in the order given.
type RootMappingFs struct {
afero.Fs
rootMapToReal *radix.Tree
virtualRoots []RootMapping
filter func(r RootMapping) bool
}
func (fs *RootMappingFs) Dirs(base string) ([]FileMetaInfo, error) {
roots := fs.getRootsWithPrefix(base)
if roots == nil {
return nil, nil
}
fss := make([]FileMetaInfo, len(roots))
for i, r := range roots {
bfs := afero.NewBasePathFs(fs.Fs, r.To)
bfs = decoratePath(bfs, func(name string) string {
p := strings.TrimPrefix(name, r.To)
if r.path != "" {
// Make sure it's mounted to a any sub path, e.g. blog
p = filepath.Join(r.path, p)
}
p = strings.TrimLeft(p, filepathSeparator)
return p
})
fs := decorateDirs(bfs, r.Meta)
fi, err := fs.Stat("")
if err != nil {
return nil, errors.Wrap(err, "RootMappingFs.Dirs")
}
fss[i] = fi.(FileMetaInfo)
}
return fss, nil
}
// LstatIfPossible returns the os.FileInfo structure describing a given file.
func (fs *RootMappingFs) LstatIfPossible(name string) (os.FileInfo, bool, error) {
fis, _, b, err := fs.doLstat(name, false)
if err != nil {
return nil, b, err
}
return fis[0], b, nil
}
func (fs *RootMappingFs) virtualDirOpener(name string, isRoot bool) func() (afero.File, error) {
return func() (afero.File, error) { return &rootMappingFile{name: name, isRoot: isRoot, fs: fs}, nil }
}
func (fs *RootMappingFs) doLstat(name string, allowMultiple bool) ([]FileMetaInfo, []FileMetaInfo, bool, error) {
if fs.isRoot(name) {
return []FileMetaInfo{newDirNameOnlyFileInfo(name, true, fs.virtualDirOpener(name, true))}, nil, false, nil
}
roots := fs.getRoots(name)
rootsWithPrefix := fs.getRootsWithPrefix(name)
hasRootMappingsBelow := len(rootsWithPrefix) != 0
if len(roots) == 0 {
if hasRootMappingsBelow {
// No exact matches, but we have root mappings below name,
// let's make it look like a directory.
return []FileMetaInfo{newDirNameOnlyFileInfo(name, true, fs.virtualDirOpener(name, false))}, nil, false, nil
}
return nil, nil, false, os.ErrNotExist
}
// We may have a mapping for both static and static/subdir.
// These will not show in any Readdir so append them
// manually.
rootsInDir := fs.filterRootsBelow(rootsWithPrefix, name)
var (
fis []FileMetaInfo
dirs []FileMetaInfo
b bool
fi os.FileInfo
root RootMapping
err error
)
for _, root = range roots {
fi, b, err = fs.statRoot(root, name)
if err != nil {
if os.IsNotExist(err) {
continue
}
return nil, nil, false, err
}
fim := fi.(FileMetaInfo)
fis = append(fis, fim)
}
for _, root = range rootsInDir {
fi, _, err := fs.statRoot(root, "")
if err != nil {
if os.IsNotExist(err) {
continue
}
return nil, nil, false, err
}
fim := fi.(FileMetaInfo)
dirs = append(dirs, fim)
}
if len(fis) == 0 && len(dirs) == 0 {
return nil, nil, false, os.ErrNotExist
}
if allowMultiple || len(fis) == 1 {
return fis, dirs, b, nil
}
// Open it in this composite filesystem.
opener := func() (afero.File, error) {
return fs.Open(name)
}
return []FileMetaInfo{decorateFileInfo(fi, fs, opener, "", "", root.Meta)}, nil, b, nil
}
// Open opens the namedrootMappingFile file for reading.
func (fs *RootMappingFs) Open(name string) (afero.File, error) {
if fs.isRoot(name) {
return &rootMappingFile{name: name, fs: fs, isRoot: true}, nil
}
fis, dirs, _, err := fs.doLstat(name, true)
if err != nil {
return nil, err
}
if len(fis) == 1 {
fi := fis[0]
meta := fi.(FileMetaInfo).Meta()
f, err := meta.Open()
if err != nil {
return nil, err
}
f = &rootMappingFile{File: f, fs: fs, name: name, meta: meta}
if len(dirs) > 0 {
return &readDirDirsAppender{File: f, dirs: dirs}, nil
}
return f, nil
}
f, err := fs.newUnionFile(fis...)
if err != nil {
return nil, err
}
if len(dirs) > 0 {
return &readDirDirsAppender{File: f, dirs: dirs}, nil
}
return f, nil
}
// Stat returns the os.FileInfo structure describing a given file. If there is
// an error, it will be of type *os.PathError.
func (fs *RootMappingFs) Stat(name string) (os.FileInfo, error) {
fi, _, err := fs.LstatIfPossible(name)
return fi, err
}
// Filter creates a copy of this filesystem with the applied filter.
func (fs RootMappingFs) Filter(f func(m RootMapping) bool) *RootMappingFs {
fs.filter = f
return &fs
}
func (fs *RootMappingFs) isRoot(name string) bool {
return name == "" || name == filepathSeparator
}
func (fs *RootMappingFs) getRoots(name string) []RootMapping {
name = filepath.Clean(name)
_, v, found := fs.rootMapToReal.LongestPrefix(name)
if !found {
return nil
}
rm := v.([]RootMapping)
return fs.applyFilterToRoots(rm)
}
func (fs *RootMappingFs) applyFilterToRoots(rm []RootMapping) []RootMapping {
if fs.filter == nil {
return rm
}
var filtered []RootMapping
for _, m := range rm {
if fs.filter(m) {
filtered = append(filtered, m)
}
}
return filtered
}
func (fs *RootMappingFs) getRootsWithPrefix(prefix string) []RootMapping {
if fs.isRoot(prefix) {
return fs.virtualRoots
}
prefix = filepath.Clean(prefix)
var roots []RootMapping
fs.rootMapToReal.WalkPrefix(prefix, func(b string, v interface{}) bool {
roots = append(roots, v.([]RootMapping)...)
return false
})
return fs.applyFilterToRoots(roots)
}
// Filter out the mappings inside the name directory.
func (fs *RootMappingFs) filterRootsBelow(roots []RootMapping, name string) []RootMapping {
if len(roots) == 0 {
return nil
}
sepCount := strings.Count(name, filepathSeparator)
var filtered []RootMapping
for _, x := range roots {
if name == x.From {
continue
}
if strings.Count(x.From, filepathSeparator)-sepCount != 1 {
continue
}
filtered = append(filtered, x)
}
return filtered
}
func (fs *RootMappingFs) newUnionFile(fis ...FileMetaInfo) (afero.File, error) {
meta := fis[0].Meta()
f, err := meta.Open()
if err != nil {
return nil, err
}
rf := &rootMappingFile{File: f, fs: fs, name: meta.Name(), meta: meta}
if len(fis) == 1 {
return rf, err
}
next, err := fs.newUnionFile(fis[1:]...)
if err != nil {
return nil, err
}
uf := &afero.UnionFile{Base: rf, Layer: next}
uf.Merger = func(lofi, bofi []os.FileInfo) ([]os.FileInfo, error) {
// Ignore duplicate directory entries
seen := make(map[string]bool)
var result []os.FileInfo
for _, fis := range [][]os.FileInfo{bofi, lofi} {
for _, fi := range fis {
if fi.IsDir() && seen[fi.Name()] {
continue
}
if fi.IsDir() {
seen[fi.Name()] = true
}
result = append(result, fi)
}
}
return result, nil
}
return uf, nil
}
func (fs *RootMappingFs) statRoot(root RootMapping, name string) (os.FileInfo, bool, error) {
filename := root.filename(name)
var b bool
var fi os.FileInfo
var err error
if ls, ok := fs.Fs.(afero.Lstater); ok {
fi, b, err = ls.LstatIfPossible(filename)
if err != nil {
return nil, b, err
}
} else {
fi, err = fs.Fs.Stat(filename)
if err != nil {
return nil, b, err
}
}
// Opens the real directory/file.
opener := func() (afero.File, error) {
return fs.Fs.Open(filename)
}
if fi.IsDir() {
if name == "" {
name = root.From
}
_, name = filepath.Split(name)
fi = newDirNameOnlyFileInfo(name, false, opener)
}
return decorateFileInfo(fi, fs.Fs, opener, "", "", root.Meta), b, nil
}
type rootMappingFile struct {
afero.File
fs *RootMappingFs
name string
meta FileMeta
isRoot bool
}
type readDirDirsAppender struct {
afero.File
dirs []FileMetaInfo
}
func (f *readDirDirsAppender) Readdir(count int) ([]os.FileInfo, error) {
fis, err := f.File.Readdir(count)
if err != nil {
return nil, err
}
for _, dir := range f.dirs {
fis = append(fis, dir)
}
return fis, nil
}
func (f *readDirDirsAppender) Readdirnames(count int) ([]string, error) {
fis, err := f.Readdir(count)
if err != nil {
return nil, err
}
return fileInfosToNames(fis), nil
}
func (f *rootMappingFile) Close() error {
if f.File == nil {
return nil
}
return f.File.Close()
}
func (f *rootMappingFile) Name() string {
return f.name
}
func (f *rootMappingFile) Readdir(count int) ([]os.FileInfo, error) {
if f.File == nil {
dirsn := make([]os.FileInfo, 0)
roots := f.fs.getRootsWithPrefix(f.name)
seen := make(map[string]bool)
j := 0
for _, rm := range roots {
if count != -1 && j >= count {
break
}
opener := func() (afero.File, error) {
return f.fs.Open(rm.From)
}
name := rm.From
if !f.isRoot {
_, name = filepath.Split(rm.From)
}
if seen[name] {
continue
}
seen[name] = true
j++
fi := newDirNameOnlyFileInfo(name, false, opener)
if rm.Meta != nil {
mergeFileMeta(rm.Meta, fi.Meta())
}
dirsn = append(dirsn, fi)
}
return dirsn, nil
}
if f.File == nil {
panic(fmt.Sprintf("no File for %q", f.name))
}
fis, err := f.File.Readdir(count)
if err != nil {
return nil, err
}
for i, fi := range fis {
fis[i] = decorateFileInfo(fi, f.fs, nil, "", "", f.meta)
}
return fis, nil
}
func (f *rootMappingFile) Readdirnames(count int) ([]string, error) {
dirs, err := f.Readdir(count)
if err != nil {
return nil, err
}
return fileInfosToNames(dirs), nil
}