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 }