src/init/plan.bend
ezx@1.4.0.0 · 283 lines · 0 definitions
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 `# <name>: <description>`, 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.
24import Base
25import ezx@1.4.0.0/src/lock/plan.bend as P
26import ezx@1.4.0.0/src/ledger/manifest.bend as M
27import ezx@1.4.0.0/src/ledger/render.bend as Rend
28import ezx@1.4.0.0/src/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
33type 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
38def toml() -> String:
39 "ez.toml"
40
41# where the ignore rules go
42def ignore.file() -> String:
43 ".gitignore"
44
45# where the library a new project starts with goes
46def lib.file() -> String:
47 "src/lib.bend"
48
49# the name a new ledger takes: what was asked for, or `app`
50def 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`
54def 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/<name>.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/<hash>` 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.
69def 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.
77def 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.
86def 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
94def 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.
105def 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.
111def 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.
118def 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
123def 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
131def 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
135def 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
139def 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.
149def 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
159def 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.
170def 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
178def 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
186def 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
195def 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
208def model(+name: String, +at: String) -> M.Manifest:
209 M.Manifest{name, at, "", "", "", "", [], []}
210
211# its text
212def 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
218def 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
222def 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.
230def 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.
251def 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
268def 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
273def 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
282def refuses(world: World) -> Bool:
283 refuses.plan(plan(world))