modernc.org/knuth@v0.0.4/kpath/kpath.go (about)

     1  // Copyright 2023 The Knuth 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  // Package kpath provides tools to locate TeX related files.
     6  //
     7  // It loosely mimicks Kpathsea, as described in:
     8  //   - https://texdoc.org/serve/kpathsea/0
     9  package kpath // import "modernc.org/knuth/kpath"
    10  
    11  import (
    12  	"fmt"
    13  	"io"
    14  	"io/fs"
    15  	"os"
    16  	stdpath "path"
    17  	"path/filepath"
    18  	"strings"
    19  	"sync"
    20  
    21  	"modernc.org/knuth/internal/tds"
    22  )
    23  
    24  var (
    25  	once   sync.Once
    26  	tdsCtx Context
    27  )
    28  
    29  // New returns a minimal kpath context initialized with the content of
    30  // a minimal TeX Directory Structure.
    31  func New() Context {
    32  	once.Do(func() {
    33  		tdsCtx, _ = NewFromFS(tds.FS)
    34  	})
    35  	return tdsCtx
    36  }
    37  
    38  // Context holds state to efficiently search for files in a TDS
    39  // (TeX Directory Structure), as described in:
    40  //   - http://tug.org/tds/tds.pdf
    41  type Context struct {
    42  	exts strset              // known common suffices
    43  	db   map[string][]string // db of filename->dirs
    44  	fs   fs.FS
    45  }
    46  
    47  func (ctx *Context) init(root fs.FS) {
    48  	if ctx.exts.db == nil {
    49  		ctx.exts = strsets["tex"]
    50  	}
    51  	if ctx.db == nil {
    52  		ctx.db = make(map[string][]string)
    53  	}
    54  	ctx.fs = root
    55  }
    56  
    57  // // NewFromDB creates a kpath search from a TeX .cnf configuration file.
    58  // func NewFromConfig(cfg io.Reader) (Context, error) {
    59  // 	ctx, err := parseConfig(cfg)
    60  // 	if err != nil {
    61  // 		return Context{}, fmt.Errorf("kpath: could not parse config: %w", err)
    62  // 	}
    63  //
    64  // 	ctx.init()
    65  // 	return ctx, nil
    66  // }
    67  
    68  // NewFromDB creates a kpath search from a TeX ls-R db file.
    69  func NewFromDB(r io.Reader) (Context, error) {
    70  	return newFromDB(os.DirFS("/"), r)
    71  }
    72  
    73  func newFromDB(root fs.FS, r io.Reader) (Context, error) {
    74  	dir := "/"
    75  	if f, ok := r.(interface{ Name() string }); ok {
    76  		dir = stdpath.Dir(filepath.ToSlash(f.Name()))
    77  	}
    78  	ctx, err := parseDB(dir, r)
    79  	if err != nil {
    80  		return Context{}, fmt.Errorf("kpath: could not parse db file: %w", err)
    81  	}
    82  
    83  	ctx.init(root)
    84  	return ctx, nil
    85  }
    86  
    87  // NewFromFS creates a kpath search context from the provided filesystem.
    88  //
    89  // NewFromFS checks first whether an ls-R database exists at the root of the
    90  // provided filesystem, and otherwise walks the whole fs.
    91  func NewFromFS(fsys fs.FS) (Context, error) {
    92  	var ctx Context
    93  	ctx.init(fsys)
    94  
    95  	if _, err := fs.Stat(fsys, "ls-R"); err == nil {
    96  		db, err := fsys.Open("ls-R")
    97  		if err != nil {
    98  			return ctx, fmt.Errorf("kpath: could not open db file: %w", err)
    99  		}
   100  		defer db.Close()
   101  		return newFromDB(fsys, db)
   102  	}
   103  
   104  	err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
   105  		if err != nil {
   106  			return err
   107  		}
   108  		if d.IsDir() {
   109  			return nil
   110  		}
   111  		fname := stdpath.Base(filepath.ToSlash(path))
   112  		ctx.db[fname] = append(ctx.db[fname], path)
   113  		return nil
   114  	})
   115  	if err != nil {
   116  		return ctx, fmt.Errorf("kpath: could not walk fs: %w", err)
   117  	}
   118  
   119  	return ctx, nil
   120  }
   121  
   122  // FS returns the underlying filesystem this context is using.
   123  func (ctx Context) FS() fs.FS {
   124  	return ctx.fs
   125  }
   126  
   127  // Open opens the named file for reading.
   128  func (ctx Context) Open(name string) (fs.File, error) {
   129  	f, err := ctx.fs.Open(name)
   130  	if err == nil {
   131  		return f, nil
   132  	}
   133  	// FIXME(sbinet): ctx.fs.Open may fail to open the named file because
   134  	// of absolute vs relative path issues.
   135  	// e.g.:
   136  	//  - ctx.fs is rooted at /usr/share/texmf-dist
   137  	//  - one requests /usr/share/texmf-dist/foo.txt (which exists)
   138  	// Name is thus "/usr/share/texmf-dist/foo.txt",
   139  	// but from the POV of ctx.fs, only "foo.txt" exists.
   140  	// Giving the absolute path from "/" won't work.
   141  	//
   142  	// In the meantime, resort to just calling to os.Open.
   143  	return os.Open(name)
   144  }
   145  
   146  // Find returns the full path to the named file if it could be found within the
   147  // TeXMF distribution system.
   148  // Find returns an error if no file or more than one file were found.
   149  func (ctx Context) Find(name string) (string, error) {
   150  	names, err := ctx.FindAll(name)
   151  	if err != nil {
   152  		return "", err
   153  	}
   154  
   155  	switch n := len(names); n {
   156  	case 1:
   157  		return names[0], nil
   158  	case 0:
   159  		return "", fmt.Errorf("kpath: could not find a match for %q", name)
   160  	default:
   161  		return "", fmt.Errorf("kpath: too many hits for file %q (n=%d)", name, n)
   162  	}
   163  }
   164  
   165  // FindAll returns the full path to all the files matching name that could be
   166  // found within the TeXMF distribution system.
   167  // Find returns an error if no file was found.
   168  func (ctx Context) FindAll(name string) ([]string, error) {
   169  	// TODO(sbinet): handle (all) standard exts.
   170  	// TODO(sbinet): handle multi-root TEXMFs
   171  
   172  	orig := name
   173  	name = filepath.ToSlash(name)
   174  	var (
   175  		subdir = strings.Contains(name, "/")
   176  		ext    = stdpath.Ext(name)
   177  	)
   178  	switch ext {
   179  	case "":
   180  		// try some extensions.
   181  		for _, ext := range ctx.exts.ks {
   182  			names, ok := ctx.lookup(name+ext, subdir)
   183  			if ok {
   184  				return names, nil
   185  			}
   186  		}
   187  
   188  		names, ok := ctx.lookup(name, subdir)
   189  		if ok {
   190  			return names, nil
   191  		}
   192  
   193  	default:
   194  
   195  		if !ctx.exts.has(ext) {
   196  			for _, ext := range ctx.exts.ks {
   197  				names, ok := ctx.lookup(name+ext, subdir)
   198  				if ok {
   199  					return names, nil
   200  				}
   201  			}
   202  		}
   203  
   204  		names, ok := ctx.lookup(name, subdir)
   205  		if ok {
   206  			return names, nil
   207  		}
   208  	}
   209  
   210  	return nil, fmt.Errorf("kpath: could not find file %q", orig)
   211  }
   212  
   213  func (ctx Context) lookup(name string, subdir bool) ([]string, bool) {
   214  	if !subdir {
   215  		names, ok := ctx.db[name]
   216  		return names, ok
   217  	}
   218  
   219  	var (
   220  		ok    = false
   221  		names = make([]string, 0, 16)
   222  	)
   223  	for _, vs := range ctx.db {
   224  		for _, v := range vs {
   225  			if !strings.HasSuffix(v, name) {
   226  				continue
   227  			}
   228  			names = append(names, v)
   229  			ok = true
   230  		}
   231  	}
   232  
   233  	return names, ok
   234  }