src/init/plan.bend source
src/init/plan.bend on the hub · documented module
# init/plan: `ez init` as a pure planner. It asks nothing: its World is the# name, entry and description asked for and four texts, each None when the# file is not there: ez.toml, `.gitignore`, the entry file and `src/lib.bend`.# The interpreter (init/run.bend) reads them, calls `plan`, and runs what it# returns.## `ez init` starts a project and never writes over one. A ledger that is# already there, whatever it holds, is refused with no effect at all, so its# dependencies, tools and `bin` survive a second `ez init`. Otherwise the plan# writes, in order: a new ledger naming the package and its entry; the# ignore rules for what ez generates, added to `.gitignore` when it lacks# them; `src/lib.bend`, when the stub below is written at the project's top# and nothing is there yet; and a stub entry when there is none.## The layout is cargo's: the program at the top and a library under `src/`# that it imports, so the directory git would not keep empty holds a file. The# stub opens with `# <name>: <description>`, since the hub describes a package# by the first line of its first file by path (pub/blurb.bend), and the entry# comes before anything under `src/`.## The Effect, Plan and Outcome types are the lock's (lock/plan.bend), which# the add design moves to a shared plan/plan.bend later; see "Update (A1,# A4)" in docs/rfc/ez-add-planner.md.import Baseimport ../lock/plan.bend as Pimport ../ledger/manifest.bend as Mimport ../ledger/render.bend as Rendimport ../pub/blurb.bend as B# what `ez init` reads: the name, entry file and description asked for (""# for none), and the texts of ez.toml, `.gitignore`, the entry file and# `src/lib.bend`, None when absenttype World is Data: World{name: String, entry: String, desc: String, ledger: Maybe<&2, String>, ignore: Maybe<&2, String>, main: Maybe<&2, String>, lib: Maybe<&2, String>}# the ledger's path, which is fixeddef toml() -> String: "ez.toml"# where the ignore rules godef ignore.file() -> String: ".gitignore"# where the library a new project starts with goesdef lib.file() -> String: "src/lib.bend"# the name a new ledger takes: what was asked for, or `app`def name.or(+at: String) -> String: Bool.pick(String, String.is_empty(at), "app", at)# the entry a new ledger names: what was asked for, or `main.bend`def entry.or(+file: String) -> String: Bool.pick(String, String.is_empty(file), "main.bend", file)# everything ez writes into a project that is not the project's: the vendored# packages, the test gate's shadow and its cache all live under one `.ez/`, and# `ez build` drops a binary in `bin/<name>.out` (`build.named` in# ez/start.bend) unless it is told otherwise. A project whose first `ez check` leaves untracked output# is a project whose owner has to work out for themselves which of it was# theirs.## `.ez/` is ignored as its contents rather than as a directory, and# `.ez/lib` is let back in only to have its contents ignored again. That is# what lets a `!.ez/lib/<hash>` line keep a vendored tree: git cannot# re-include a file under a directory it has excluded, so under a bare# `.ez/` every such line would be inert.def rules(+name: String) -> List<&2, String>: [".ez/*", "!.ez/lib", ".ez/lib/*", "bin/" ++ name ++ ".out"]# one step of a scan that is looking for one hit, with the head's answer in# hand. `Bool.or` is an ordinary function and reduces both of its sides, so a# scan written with it reads the whole list even when the first line answers.# The rest of the scan arrives as a thunk and only the arm that wants it# applies it.def stated.step(here: Bool, rest: Unit -> Bool) -> Bool: match here: case True{}: True{} case False{}: rest(Unit{})# whether a file already states this rule, on a line of its own. A rule buried# in a longer line is not the rule, so the comparison is against whole lines.def stated(ls: List<&2, String>, +rule: String) -> Bool: match ls: case Nil{}: False{} case Con{+h, t}: stated.step(String.eq(String.trim(h), rule), _u => stated(t, rule))# the rules a file does not already state, in orderdef missing(rs: List<&2, String>, +ls: List<&2, String>) -> List<&2, String>: match rs: case Nil{}: Nil{} case Con{+h, t}: +more = missing(t, ls) Bool.pick(List<&2, String>, stated(ls, h), more, h <> more)# a file's text with a newline on the end, so what is appended to it starts on# a line of its own. An empty file gets none, or the append would open with a# blank line.def tail(+text: String) -> String: Bool.pick(String, Bool.or(String.is_empty(text), String.ends_with(text, "\n")), text, text ++ "\n")# the ignore file with the rules it lacks added under a line saying whose they# are. What is already there is kept: the file is the project's, not ez's.def ignored(+text: String, ms: List<&2, String>) -> String: tail(text) ++ "# written by ez\n" ++ String.join(ms, "\n") ++ "\n"# the line a new entry opens with, which the hub shows as the package's# description: the description asked for, or a placeholder that says one is# still to be written. cargo writes no description into a new Cargo.toml; the# hub has no field for one, so ours goes where the hub reads it.def header(+name: String, +desc: String) -> String: "# " ++ name ++ ": " ++ Bool.pick(String, String.is_empty(desc), "TODO describe " ++ name, desc)# the rest of a new entry: a program that checks and runs, saying what the# library it was laid out with gives it, or `hello` when there is nonedef body(lays: Bool) -> String: match lays: case True{}: "import Base\nimport ./src/lib.bend as Lib\n\ndef main() -> IO(Unit):\n IO.print(Lib.greeting())\n" case False{}: "import Base\n\ndef main() -> IO(Unit):\n IO.print(\"hello\")\n"# the entry of a new project: its line for the hub, then the programdef stub(+name: String, +desc: String, lays: Bool) -> String: header(name, desc) ++ "\n" ++ body(lays)# the library a new project starts with, which its entry importsdef lib() -> String: "import Base\n\n# what the program says\ndef greeting() -> String:\n \"hello\"\n"# a file's text, "" when it is not theredef text.of(got: Maybe<&2, String>) -> String: match got: case None{}: "" case Some{text}: text# `.gitignore` written ahead of the rest when it lacks a rule. Nothing to add# means nothing written, so an `ez init` in a directory whose ignore file# already has the rules leaves it exactly as it found it.def ignore.put(ms: List<&2, String>, +text: String, rest: List<&2, P.Effect>) -> List<&2, P.Effect>: match ms: case Nil{}: rest case Con{h, t}: P.Write{ignore.file(), ignored(text, h <> t)} <> rest# whether the stub is written: nothing is at the entry, so `ez init` does not# overwrite the program, and the entry is not ez.toml or `.gitignore`, so the# stub is not written over the file this plan just wrote theredef due(got: Maybe<&2, String>, +at: String) -> Bool: match got: case Some{_s}: False{} case None{}: Bool.not(Bool.or(String.eq(at, toml()), String.eq(at, ignore.file())))# whether `src/lib.bend` is laid out with the stub: nothing is there yet, so a# library already there is never written over, the stub is written, and it# sits at the project's top, where its `./src/lib.bend` import finds it. An# entry in a directory of its own gets the stub alone.def lays(got: Maybe<&2, String>, due: Bool, +at: String) -> Bool: match got: case Some{_s}: False{} case None{}: Bool.and(due, Bool.not(String.contains(at, "/")))# the stub at the entry when it is due, ahead of the restdef main.put(due: Bool, +at: String, +text: String, rest: List<&2, P.Effect>) -> List<&2, P.Effect>: match due: case True{}: P.Write{at, text} <> rest case False{}: rest# the library when it is laid out, ahead of the restdef lib.put(lays: Bool, rest: List<&2, P.Effect>) -> List<&2, P.Effect>: match lays: case True{}: P.Write{lib.file(), lib()} <> rest case False{}: rest# the files written after the ledger and the ignore rules: the library and# the stub, each when it is duedef files( +name: String, +at: String, +desc: String, main: Maybe<&2, String>, lib: Maybe<&2, String>) -> List<&2, P.Effect>: +d = due(main, at) +l = lays(lib, d, at) lib.put(l, main.put(d, at, stub(name, desc, l), []))# the ledger a new project starts from: this package, its entry, and nothing# else, so no `publish-as` or `version` and a project that publishes by hashdef model(+name: String, +at: String) -> M.Manifest: M.Manifest{name, at, "", "", "", "", [], []}# its textdef fresh(+name: String, +at: String) -> String: Rend.show(model(name, at))# whether a new project can be written: its ledger reads back (EZ-LED-4),# and the line its entry opens with is one line, so the description cannot# spill into the programdef fits(+name: String, +at: String, +desc: String) -> Bool: Bool.and(Rend.renderable(model(name, at)), Bool.not(B.has.nl(header(name, desc))))# why a new project that does not fit is refuseddef why.unfit(+name: String, +at: String) -> String: Bool.pick(String, Rend.renderable(model(name, at)), "ez: a description is one line, the first line of the entry, and this one holds a newline", Rend.unrenderable())# the new project's files, once it fits. A name or entry the ledger cannot# carry, such as one holding a quote, or a description holding a newline, is# refused with no effect at all.def plan.new( ok: Bool, +name: String, +at: String, +desc: String, ignore: Maybe<&2, String>, main: Maybe<&2, String>, lib: Maybe<&2, String>) -> P.Plan: match ok: case True{}: +text = text.of(ignore) P.Plan{P.Write{toml(), fresh(name, at)} <> ignore.put(missing(rules(name), String.lines(text)), text, files(name, at, desc, main, lib)), P.Success{}} case False{}: P.Plan{[], P.Refused{why.unfit(name, at)}}# the plan, once the name and entry are settled. A ledger on disk holds# dependencies, tools and a `bin` that a fresh one would lose, so `ez init`# writes over none of it, whatever the file holds, and refuses.def plan.go( led: Maybe<&2, String>, +name: String, +at: String, +desc: String, ignore: Maybe<&2, String>, main: Maybe<&2, String>, lib: Maybe<&2, String>) -> P.Plan: match led: case Some{_s}: P.Plan{[], P.Refused{"ez: " ++ toml() ++ " already exists; ez init writes a new project and leaves it alone"}} case None{}: plan.new(fits(name, at, desc), name, at, desc, ignore, main, lib)# `ez init` over a Worlddef plan(world: World) -> P.Plan: World{n, e, d, led, ig, mn, lb} = world plan.go(led, name.or(n), entry.or(e), d, ig, mn, lb)# whether a plan ends in a refusaldef refuses.plan(pl: P.Plan) -> Bool: P.Plan{_es, outcome} = pl match outcome: case P.Success{}: False{} case P.Refused{_why}: True{}# whether `ez init` refuses on a Worlddef refuses(world: World) -> Bool: refuses.plan(plan(world))