github.com/mitranim/gg@v0.1.17/file_graph.go (about)

     1  package gg
     2  
     3  import (
     4  	"io/fs"
     5  	"path/filepath"
     6  	"strings"
     7  )
     8  
     9  /*
    10  Shortcut for making `GraphDir` with the given path and fully initializing it via
    11  `.Init`.
    12  */
    13  func GraphDirInit(path string) GraphDir {
    14  	var out GraphDir
    15  	out.Path = path
    16  	out.Init()
    17  	return out
    18  }
    19  
    20  /*
    21  Represents a directory where the files form a graph by "importing" each other,
    22  by using special annotations understood by this tool. Supports reading files
    23  from the filesystem, validating the dependency graph, and calculating valid
    24  execution order for the resulting graph. Mostly designed and suited for
    25  emulating a module system for SQL files. May be useful in other similar cases.
    26  
    27  The import annotation is currently not customizable and must look like the
    28  following example. Each entry must be placed at the beginning of a line. In
    29  files that contain code, do this within multi-line comments without any prefix.
    30  
    31  	@import some_file_name_0
    32  	@import some_file_name_1
    33  
    34  Current limitations:
    35  
    36  	* The import annotation is non-customizable.
    37  	* No support for file filtering.
    38  	* No support for relative paths. Imports must refer to files by base names.
    39  	* No support for `fs.FS` or other ways to customize reading.
    40  	  Always uses the OS filesystem.
    41  */
    42  type GraphDir struct {
    43  	Path  string
    44  	Files Coll[string, GraphFile]
    45  }
    46  
    47  /*
    48  Reads the files in the directory specified by `.Path`, then builds and validates
    49  the dependency graph. After calling this method, the files in `.Files.Slice`
    50  represent valid execution order.
    51  */
    52  func (self *GraphDir) Init() {
    53  	defer Detailf(`unable to build dependency graph for %q`, self.Path)
    54  	self.read()
    55  	self.validateExisting()
    56  	self.walk()
    57  	self.validateEntryFile()
    58  }
    59  
    60  // Returns the names of `.Files`, in the same order.
    61  func (self GraphDir) Names() []string {
    62  	return Map(self.Files.Slice, GraphFile.Pk)
    63  }
    64  
    65  /*
    66  Returns the `GraphFile` indexed by the given key.
    67  Panics if the file is not found.
    68  */
    69  func (self GraphDir) File(key string) GraphFile {
    70  	val, ok := self.Files.Got(key)
    71  	if !ok {
    72  		panic(Errf(`missing file %q`, key))
    73  	}
    74  	if val.Pk() != key {
    75  		panic(Errf(`invalid index for %q, found %q instead`, key, val.Pk()))
    76  	}
    77  	return val
    78  }
    79  
    80  func (self *GraphDir) read() {
    81  	self.Files = CollFrom[string, GraphFile](ConcMap(
    82  		MapCompact(ReadDir(self.Path), dirEntryToFileName),
    83  		self.initFile,
    84  	))
    85  }
    86  
    87  func (self GraphDir) initFile(name string) (out GraphFile) {
    88  	out.Init(self.Path, name)
    89  	return
    90  }
    91  
    92  // Technically redundant because `graphWalk` also validates this.
    93  func (self GraphDir) validateExisting() {
    94  	Each(self.Files.Slice, self.validateExistingDeps)
    95  }
    96  
    97  func (self GraphDir) validateExistingDeps(file GraphFile) {
    98  	defer Detailf(`dependency error for %q`, file.Pk())
    99  
   100  	for _, dep := range file.Deps {
   101  		Nop1(self.File(dep))
   102  	}
   103  }
   104  
   105  func (self *GraphDir) walk() {
   106  	// Forbids cycles and finds valid execution order.
   107  	var walk graphWalk
   108  	walk.Dir = self
   109  	walk.Run()
   110  
   111  	// Internal sanity check. If walk is successful, it must build an equivalent
   112  	// set of files. We could also compare the actual elements, but this should
   113  	// be enough to detect mismatches.
   114  	valid := walk.Valid
   115  	len0 := self.Files.Len()
   116  	len1 := valid.Len()
   117  	if len0 != len1 {
   118  		panic(Errf(`internal error: mismatch between original files (length %v) and walked files (length %v)`, len0, len1))
   119  	}
   120  
   121  	self.Files = valid
   122  }
   123  
   124  /*
   125  Ensures that the resulting graph is either empty, or contains exactly one "entry
   126  file", a file with no dependencies, and that this file has been sorted to the
   127  beginning of the collection. Every other file must explicitly specify its
   128  dependencies. This helps ensure canonical order.
   129  */
   130  func (self GraphDir) validateEntryFile() {
   131  	if self.Files.IsEmpty() {
   132  		return
   133  	}
   134  
   135  	head := Head(self.Files.Slice)
   136  	deps := len(head.Deps)
   137  	if deps != 0 {
   138  		panic(Errf(`expected to begin with a dependency-free entry file, found %q with %v dependencies`, head.Pk(), deps))
   139  	}
   140  
   141  	if None(Tail(self.Files.Slice), GraphFile.isEntry) {
   142  		return
   143  	}
   144  
   145  	panic(Errf(
   146  		`expected to find exactly one dependency-free entry file, found multiple: %q`,
   147  		Map(Filter(self.Files.Slice, GraphFile.isEntry), GraphFile.Pk),
   148  	))
   149  }
   150  
   151  /*
   152  Represents a file in a graph of files that import each other by using special
   153  import annotations understood by this tool. See `GraphDir` for explanation.
   154  */
   155  type GraphFile struct {
   156  	Path string   // Valid FS path. Directory must match parent `GraphDir`.
   157  	Body string   // Read from disk by `.Init`.
   158  	Deps []string // Parsed from `.Body` by `.Init`.
   159  }
   160  
   161  // Implement `Pker` for compatibility with `Coll`. See `GraphDir.Files`.
   162  func (self GraphFile) Pk() string { return filepath.Base(self.Path) }
   163  
   164  /*
   165  Sets `.Path` to the combination of the given directory and base name, reads the
   166  file from FS into `.Body`, and parses the import annotations into `.Deps`.
   167  Called automatically by `GraphDir.Init`.
   168  */
   169  func (self *GraphFile) Init(dir, name string) {
   170  	self.Path = filepath.Join(dir, name)
   171  	self.read()
   172  	self.parse()
   173  }
   174  
   175  func (self *GraphFile) read() { self.Body = ReadFile[string](self.Path) }
   176  
   177  func (self *GraphFile) parse() {
   178  	var deps []string
   179  
   180  	for _, line := range SplitLines(self.Body) {
   181  		rest := strings.TrimPrefix(line, `@import `)
   182  		if rest != line {
   183  			deps = append(deps, strings.TrimSpace(rest))
   184  		}
   185  	}
   186  
   187  	invalid := Reject(deps, isBaseName)
   188  	if IsNotEmpty(invalid) {
   189  		panic(Errf(`invalid imports in %q, every import must be a base name, found %q`, self.Pk(), invalid))
   190  	}
   191  
   192  	self.Deps = deps
   193  }
   194  
   195  func (self GraphFile) isEntry() bool { return IsEmpty(self.Deps) }
   196  
   197  func isBaseName(val string) bool { return filepath.Base(val) == val }
   198  
   199  /*
   200  Features:
   201  
   202  	* Determines valid execution order.
   203  
   204  	* Forbids cycles. In other words, ensures that our graph is a "multitree".
   205  	  See https://en.wikipedia.org/wiki/Multitree.
   206  */
   207  type graphWalk struct {
   208  	Dir   *GraphDir
   209  	Valid Coll[string, GraphFile]
   210  }
   211  
   212  func (self *graphWalk) Run() {
   213  	for _, val := range self.Dir.Files.Slice {
   214  		self.walk(nil, val)
   215  	}
   216  }
   217  
   218  func (self *graphWalk) walk(tail *node[string], file GraphFile) {
   219  	key := file.Pk()
   220  	if self.Valid.Has(key) {
   221  		return
   222  	}
   223  
   224  	pending := tail != nil && tail.has(key)
   225  	head := tail.cons(key)
   226  
   227  	if pending {
   228  		panic(Errf(`dependency cycle: %q`, Reversed(head.vals())))
   229  	}
   230  
   231  	for _, dep := range file.Deps {
   232  		self.walk(&head, self.Dir.File(dep))
   233  	}
   234  	self.Valid.Add(file)
   235  }
   236  
   237  func dirEntryToFileName(src fs.DirEntry) (_ string) {
   238  	if src == nil || src.IsDir() {
   239  		return
   240  	}
   241  	return src.Name()
   242  }