1 # init/plan: `ez init` as a pure planner. It asks nothing: its World is the 2 # name, entry and description asked for and four texts, each None when the 3 # file is not there: ez.toml, `.gitignore`, the entry file and `src/lib.bend`. 4 # The interpreter (init/run.bend) reads them, calls `plan`, and runs what it 5 # returns. 6 # 7 # `ez init` starts a project and never writes over one. A ledger that is 8 # already there, whatever it holds, is refused with no effect at all, so its 9 # dependencies, tools and `bin` survive a second `ez init`. Otherwise the plan 10 # writes, in order: a new ledger naming the package and its entry; the 11 # ignore rules for what ez generates, added to `.gitignore` when it lacks 12 # them; `src/lib.bend`, when the stub below is written at the project's top 13 # and nothing is there yet; and a stub entry when there is none. 14 # 15 # The layout is cargo's: the program at the top and a library under `src/` 16 # that it imports, so the directory git would not keep empty holds a file. The 17 # stub opens with `# : `, since the hub describes a package 18 # by the first line of its first file by path (pub/blurb.bend), and the entry 19 # comes before anything under `src/`. 20 # 21 # The Effect, Plan and Outcome types are the lock's (lock/plan.bend), which 22 # the add design moves to a shared plan/plan.bend later; see "Update (A1, 23 # A4)" in docs/rfc/ez-add-planner.md. 24 import Base 25 import ../lock/plan.bend as P 26 import ../ledger/manifest.bend as M 27 import ../ledger/render.bend as Rend 28 import ../pub/blurb.bend as B 29 30 # what `ez init` reads: the name, entry file and description asked for ("" 31 # for none), and the texts of ez.toml, `.gitignore`, the entry file and 32 # `src/lib.bend`, None when absent 33 type World is Data: 34 World{name: String, entry: String, desc: String, ledger: Maybe<&2, String>, 35 ignore: Maybe<&2, String>, main: Maybe<&2, String>, lib: Maybe<&2, String>} 36 37 # the ledger's path, which is fixed 38 def toml() -> String: 39 "ez.toml" 40 41 # where the ignore rules go 42 def ignore.file() -> String: 43 ".gitignore" 44 45 # where the library a new project starts with goes 46 def lib.file() -> String: 47 "src/lib.bend" 48 49 # the name a new ledger takes: what was asked for, or `app` 50 def name.or(+at: String) -> String: 51 Bool.pick(String, String.is_empty(at), "app", at) 52 53 # the entry a new ledger names: what was asked for, or `main.bend` 54 def entry.or(+file: String) -> String: 55 Bool.pick(String, String.is_empty(file), "main.bend", file) 56 57 # everything ez writes into a project that is not the project's: the vendored 58 # packages, the test gate's shadow and its cache all live under one `.ez/`, and 59 # `ez build` drops a binary in `bin/.out` (`build.named` in 60 # ez/start.bend) unless it is told otherwise. A project whose first `ez check` leaves untracked output 61 # is a project whose owner has to work out for themselves which of it was 62 # theirs. 63 # 64 # `.ez/` is ignored as its contents rather than as a directory, and 65 # `.ez/lib` is let back in only to have its contents ignored again. That is 66 # what lets a `!.ez/lib/` line keep a vendored tree: git cannot 67 # re-include a file under a directory it has excluded, so under a bare 68 # `.ez/` every such line would be inert. 69 def rules(+name: String) -> List<&2, String>: 70 [".ez/*", "!.ez/lib", ".ez/lib/*", "bin/" ++ name ++ ".out"] 71 72 # one step of a scan that is looking for one hit, with the head's answer in 73 # hand. `Bool.or` is an ordinary function and reduces both of its sides, so a 74 # scan written with it reads the whole list even when the first line answers. 75 # The rest of the scan arrives as a thunk and only the arm that wants it 76 # applies it. 77 def stated.step(here: Bool, rest: Unit -> Bool) -> Bool: 78 match here: 79 case True{}: 80 True{} 81 case False{}: 82 rest(Unit{}) 83 84 # whether a file already states this rule, on a line of its own. A rule buried 85 # in a longer line is not the rule, so the comparison is against whole lines. 86 def stated(ls: List<&2, String>, +rule: String) -> Bool: 87 match ls: 88 case Nil{}: 89 False{} 90 case Con{+h, t}: 91 stated.step(String.eq(String.trim(h), rule), _u => stated(t, rule)) 92 93 # the rules a file does not already state, in order 94 def missing(rs: List<&2, String>, +ls: List<&2, String>) -> List<&2, String>: 95 match rs: 96 case Nil{}: 97 Nil{} 98 case Con{+h, t}: 99 +more = missing(t, ls) 100 Bool.pick(List<&2, String>, stated(ls, h), more, h <> more) 101 102 # a file's text with a newline on the end, so what is appended to it starts on 103 # a line of its own. An empty file gets none, or the append would open with a 104 # blank line. 105 def tail(+text: String) -> String: 106 Bool.pick(String, Bool.or(String.is_empty(text), String.ends_with(text, "\n")), 107 text, text ++ "\n") 108 109 # the ignore file with the rules it lacks added under a line saying whose they 110 # are. What is already there is kept: the file is the project's, not ez's. 111 def ignored(+text: String, ms: List<&2, String>) -> String: 112 tail(text) ++ "# written by ez\n" ++ String.join(ms, "\n") ++ "\n" 113 114 # the line a new entry opens with, which the hub shows as the package's 115 # description: the description asked for, or a placeholder that says one is 116 # still to be written. cargo writes no description into a new Cargo.toml; the 117 # hub has no field for one, so ours goes where the hub reads it. 118 def header(+name: String, +desc: String) -> String: 119 "# " ++ name ++ ": " ++ Bool.pick(String, String.is_empty(desc), "TODO describe " ++ name, desc) 120 121 # the rest of a new entry: a program that checks and runs, saying what the 122 # library it was laid out with gives it, or `hello` when there is none 123 def body(lays: Bool) -> String: 124 match lays: 125 case True{}: 126 "import Base\nimport ./src/lib.bend as Lib\n\ndef main() -> IO(Unit):\n IO.print(Lib.greeting())\n" 127 case False{}: 128 "import Base\n\ndef main() -> IO(Unit):\n IO.print(\"hello\")\n" 129 130 # the entry of a new project: its line for the hub, then the program 131 def stub(+name: String, +desc: String, lays: Bool) -> String: 132 header(name, desc) ++ "\n" ++ body(lays) 133 134 # the library a new project starts with, which its entry imports 135 def lib() -> String: 136 "import Base\n\n# what the program says\ndef greeting() -> String:\n \"hello\"\n" 137 138 # a file's text, "" when it is not there 139 def text.of(got: Maybe<&2, String>) -> String: 140 match got: 141 case None{}: 142 "" 143 case Some{text}: 144 text 145 146 # `.gitignore` written ahead of the rest when it lacks a rule. Nothing to add 147 # means nothing written, so an `ez init` in a directory whose ignore file 148 # already has the rules leaves it exactly as it found it. 149 def ignore.put(ms: List<&2, String>, +text: String, rest: List<&2, P.Effect>) -> List<&2, P.Effect>: 150 match ms: 151 case Nil{}: 152 rest 153 case Con{h, t}: 154 P.Write{ignore.file(), ignored(text, h <> t)} <> rest 155 156 # whether the stub is written: nothing is at the entry, so `ez init` does not 157 # overwrite the program, and the entry is not ez.toml or `.gitignore`, so the 158 # stub is not written over the file this plan just wrote there 159 def due(got: Maybe<&2, String>, +at: String) -> Bool: 160 match got: 161 case Some{_s}: 162 False{} 163 case None{}: 164 Bool.not(Bool.or(String.eq(at, toml()), String.eq(at, ignore.file()))) 165 166 # whether `src/lib.bend` is laid out with the stub: nothing is there yet, so a 167 # library already there is never written over, the stub is written, and it 168 # sits at the project's top, where its `./src/lib.bend` import finds it. An 169 # entry in a directory of its own gets the stub alone. 170 def lays(got: Maybe<&2, String>, due: Bool, +at: String) -> Bool: 171 match got: 172 case Some{_s}: 173 False{} 174 case None{}: 175 Bool.and(due, Bool.not(String.contains(at, "/"))) 176 177 # the stub at the entry when it is due, ahead of the rest 178 def main.put(due: Bool, +at: String, +text: String, rest: List<&2, P.Effect>) -> List<&2, P.Effect>: 179 match due: 180 case True{}: 181 P.Write{at, text} <> rest 182 case False{}: 183 rest 184 185 # the library when it is laid out, ahead of the rest 186 def lib.put(lays: Bool, rest: List<&2, P.Effect>) -> List<&2, P.Effect>: 187 match lays: 188 case True{}: 189 P.Write{lib.file(), lib()} <> rest 190 case False{}: 191 rest 192 193 # the files written after the ledger and the ignore rules: the library and 194 # the stub, each when it is due 195 def files( 196 +name: String, 197 +at: String, 198 +desc: String, 199 main: Maybe<&2, String>, 200 lib: Maybe<&2, String> 201 ) -> List<&2, P.Effect>: 202 +d = due(main, at) 203 +l = lays(lib, d, at) 204 lib.put(l, main.put(d, at, stub(name, desc, l), [])) 205 206 # the ledger a new project starts from: this package, its entry, and nothing 207 # else, so no `publish-as` or `version` and a project that publishes by hash 208 def model(+name: String, +at: String) -> M.Manifest: 209 M.Manifest{name, at, "", "", "", "", [], []} 210 211 # its text 212 def fresh(+name: String, +at: String) -> String: 213 Rend.show(model(name, at)) 214 215 # whether a new project can be written: its ledger reads back (EZ-LED-4), 216 # and the line its entry opens with is one line, so the description cannot 217 # spill into the program 218 def fits(+name: String, +at: String, +desc: String) -> Bool: 219 Bool.and(Rend.renderable(model(name, at)), Bool.not(B.has.nl(header(name, desc)))) 220 221 # why a new project that does not fit is refused 222 def why.unfit(+name: String, +at: String) -> String: 223 Bool.pick(String, Rend.renderable(model(name, at)), 224 "ez: a description is one line, the first line of the entry, and this one holds a newline", 225 Rend.unrenderable()) 226 227 # the new project's files, once it fits. A name or entry the ledger cannot 228 # carry, such as one holding a quote, or a description holding a newline, is 229 # refused with no effect at all. 230 def plan.new( 231 ok: Bool, 232 +name: String, 233 +at: String, 234 +desc: String, 235 ignore: Maybe<&2, String>, 236 main: Maybe<&2, String>, 237 lib: Maybe<&2, String> 238 ) -> P.Plan: 239 match ok: 240 case True{}: 241 +text = text.of(ignore) 242 P.Plan{P.Write{toml(), fresh(name, at)} <> 243 ignore.put(missing(rules(name), String.lines(text)), text, 244 files(name, at, desc, main, lib)), P.Success{}} 245 case False{}: 246 P.Plan{[], P.Refused{why.unfit(name, at)}} 247 248 # the plan, once the name and entry are settled. A ledger on disk holds 249 # dependencies, tools and a `bin` that a fresh one would lose, so `ez init` 250 # writes over none of it, whatever the file holds, and refuses. 251 def plan.go( 252 led: Maybe<&2, String>, 253 +name: String, 254 +at: String, 255 +desc: String, 256 ignore: Maybe<&2, String>, 257 main: Maybe<&2, String>, 258 lib: Maybe<&2, String> 259 ) -> P.Plan: 260 match led: 261 case Some{_s}: 262 P.Plan{[], P.Refused{"ez: " ++ toml() ++ 263 " already exists; ez init writes a new project and leaves it alone"}} 264 case None{}: 265 plan.new(fits(name, at, desc), name, at, desc, ignore, main, lib) 266 267 # `ez init` over a World 268 def plan(world: World) -> P.Plan: 269 World{n, e, d, led, ig, mn, lb} = world 270 plan.go(led, name.or(n), entry.or(e), d, ig, mn, lb) 271 272 # whether a plan ends in a refusal 273 def refuses.plan(pl: P.Plan) -> Bool: 274 P.Plan{_es, outcome} = pl 275 match outcome: 276 case P.Success{}: 277 False{} 278 case P.Refused{_why}: 279 True{} 280 281 # whether `ez init` refuses on a World 282 def refuses(world: World) -> Bool: 283 refuses.plan(plan(world))