1 # ez/target: what `ez add`, `ez tool run`, `install` and `upgrade` were asked 2 # for, decided from the word alone. `owner/repo` is GitHub. A URL is that URL. 3 # Anything else is a path, and a path written `./…`, `../…`, `/…` or `~/…` is 4 # a path even when it would otherwise read as a repository. For `ez add`, a 5 # word bend reads as a hub package's `@` names that package, 6 # where it would otherwise be a path (`aim`). 7 import Base 8 import ../pkg/pkg.bend as K 9 10 # where a target names, or why it names nothing 11 type Place is Data: 12 Bad{why: String} 13 Local{path: String} 14 Remote{url: String, slug: String} 15 16 # a char a GitHub owner or repository may hold besides a dot: a letter, a 17 # digit, `-` or `_` 18 def plain.at(+ch: Char) -> Bool: 19 Bool.or(Char.is_digit(ch), Bool.or(Char.is_alpha(ch), 20 Bool.or(Char.is_eq(ch, '-'), Char.is_eq(ch, '_')))) 21 22 # what one char of a target is, as far as telling a shorthand from a path 23 # goes. A colon is asked about first, so a char that is anything but `Other` 24 # is known not to be one: that is what lets a proof say a shorthand never 25 # contains `://`. 26 type Mark is Data: 27 Slash{} 28 Dot{} 29 Tilde{} 30 Word{} 31 Other{} 32 33 def kind.word(word: Bool) -> Mark: 34 match word: 35 case True{}: 36 Word{} 37 case False{}: 38 Other{} 39 40 def kind.tilde(tilde: Bool, word: Bool) -> Mark: 41 match tilde: 42 case True{}: 43 Tilde{} 44 case False{}: 45 kind.word(word) 46 47 def kind.dot(dot: Bool, tilde: Bool, word: Bool) -> Mark: 48 match dot: 49 case True{}: 50 Dot{} 51 case False{}: 52 kind.tilde(tilde, word) 53 54 def kind.slash(slash: Bool, dot: Bool, tilde: Bool, word: Bool) -> Mark: 55 match slash: 56 case True{}: 57 Slash{} 58 case False{}: 59 kind.dot(dot, tilde, word) 60 61 def kind.colon(colon: Bool, slash: Bool, dot: Bool, tilde: Bool, word: Bool) -> Mark: 62 match colon: 63 case True{}: 64 Other{} 65 case False{}: 66 kind.slash(slash, dot, tilde, word) 67 68 # a char's kind 69 def kind(+ch: Char) -> Mark: 70 kind.colon(Char.is_eq(ch, ':'), Char.is_eq(ch, '/'), Char.is_eq(ch, '.'), 71 Char.is_eq(ch, '~'), plain.at(ch)) 72 73 # one char of a name, with the rest of the name still to be asked about. 74 # The rest arrives as a thunk, so a char that ends the name ends the walk. 75 def name.keep(mark: Mark, rest: Unit -> Bool) -> Bool: 76 match mark: 77 case Word{}: 78 rest(Unit{}) 79 case Dot{}: 80 rest(Unit{}) 81 case Slash{}: 82 False{} 83 case Tilde{}: 84 False{} 85 case Other{}: 86 False{} 87 88 # every char a letter, a digit, `-`, `_` or `.` 89 def name.all(text: String) -> Bool: 90 match text: 91 case SNil{}: 92 True{} 93 case SCon{+h, t}: 94 name.keep(kind(h), _u => name.all(t)) 95 96 # after `..`: one more name char, so the segment is not `..` 97 def seg.dots(text: String) -> Bool: 98 match text: 99 case SNil{}: 100 False{} 101 case SCon{+h, t}: 102 name.keep(kind(h), _u => name.all(t)) 103 104 # the second char after a leading dot 105 def seg.dot2(mark: Mark, rest: String) -> Bool: 106 match mark: 107 case Word{}: 108 name.all(rest) 109 case Dot{}: 110 seg.dots(rest) 111 case Slash{}: 112 False{} 113 case Tilde{}: 114 False{} 115 case Other{}: 116 False{} 117 118 # after a leading `.`: more, so the segment is not `.` 119 def seg.dot(text: String) -> Bool: 120 match text: 121 case SNil{}: 122 False{} 123 case SCon{+h, t}: 124 seg.dot2(kind(h), t) 125 126 # the first char of a segment 127 def seg.first(mark: Mark, rest: String) -> Bool: 128 match mark: 129 case Word{}: 130 name.all(rest) 131 case Dot{}: 132 seg.dot(rest) 133 case Slash{}: 134 False{} 135 case Tilde{}: 136 False{} 137 case Other{}: 138 False{} 139 140 # one segment of a GitHub shorthand: letters, digits, `-`, `_` and `.`, at 141 # least one of them, and not `.` or `..`, which name a directory 142 def seg(text: String) -> Bool: 143 match text: 144 case SNil{}: 145 False{} 146 case SCon{+h, t}: 147 seg.first(kind(h), t) 148 149 # a word cut at its first slash, or not cut because a char before any slash 150 # could not be in a shorthand 151 type Halves is Data: 152 Uncut{} 153 Two{left: String, right: String} 154 155 # a char put back in front of the left half 156 def halve.put(ch: Char, halves: Halves) -> Halves: 157 match halves: 158 case Uncut{}: 159 Uncut{} 160 case Two{left, right}: 161 Two{SCon{ch, left}, right} 162 163 # one char of the first segment. A slash ends it; the rest of the word is 164 # asked about only past a char a segment may hold. 165 def halve.at(mark: Mark, ch: Char, +tail: String, rest: Unit -> Halves) -> Halves: 166 match mark: 167 case Slash{}: 168 Two{"", tail} 169 case Word{}: 170 halve.put(ch, rest(Unit{})) 171 case Dot{}: 172 halve.put(ch, rest(Unit{})) 173 case Tilde{}: 174 Uncut{} 175 case Other{}: 176 Uncut{} 177 178 # the word before its first slash and the word after it 179 def halve(text: String) -> Halves: 180 match text: 181 case SNil{}: 182 Uncut{} 183 case SCon{+h, +t}: 184 halve.at(kind(h), h, t, _u => halve(t)) 185 186 # a word naming a Bend file. As the second of two segments it is the last 187 # component of a path, `src/main.bend`, and not a repository. 188 def bend.file(+text: String) -> Bool: 189 String.ends_with(text, ".bend") 190 191 # the second segment of a shorthand: a segment that is not a Bend file 192 def repo.seg(+text: String) -> Bool: 193 Bool.and(seg(text), Bool.not(bend.file(text))) 194 195 # both segments of a shorthand, where there are two 196 def github.halves(halves: Halves) -> Bool: 197 match halves: 198 case Uncut{}: 199 False{} 200 case Two{left, right}: 201 Bool.and(seg(left), repo.seg(right)) 202 203 # `owner/repo`: exactly two segments, each a `seg`, the second not ending in 204 # `.bend`. A second slash leaves the right half with a slash in it, which no 205 # segment holds. 206 def github.ok(text: String) -> Bool: 207 github.halves(halve(text)) 208 209 # one step of a scan looking for `..`. A slug with one would leave the cache. 210 def climb.step(here: Bool, rest: Unit -> Bool) -> Bool: 211 match here: 212 case True{}: 213 True{} 214 case False{}: 215 rest(Unit{}) 216 217 # whether any component is a climb 218 def climb(ps: List<&2, String>) -> Bool: 219 match ps: 220 case []: 221 False{} 222 case +h <> t: 223 climb.step(String.eq(h, ".."), _u => climb(t)) 224 225 # a slug that stays under the cache: non-empty, relative, and with no `..` 226 def slug.ok(+text: String) -> Bool: 227 Bool.and(Bool.not(String.is_empty(text)), 228 Bool.and(Bool.not(String.starts_with(text, "/")), 229 Bool.not(climb(String.split(text, '/'))))) 230 231 # a remote whose slug can be a directory, or a refusal when it cannot 232 def remote(+url: String, +slug: String) -> Place: 233 Bool.pick(Place, slug.ok(slug), Remote{url, slug}, Bad{"not a repository"}) 234 235 # `s` with a suffix of `n` chars taken off when it ends in that suffix. 236 # `Bool.pick` builds both arms, which is the same trade `pkg/path.unbend` makes. 237 def cut.end(+text: String, +suf: String, len: Nat) -> String: 238 Bool.pick(String, String.ends_with(text, suf), 239 String.take(text, Nat.sub(String.length(text), len)), text) 240 241 # a trailing slash is not part of the cache directory 242 def unslash(+text: String) -> String: 243 cut.end(text, "/", 1n) 244 245 # a trailing `.git` is not part of the cache directory 246 def ungit(+text: String) -> String: 247 cut.end(text, ".git", 4n) 248 249 # everything after the first colon. `https://host/path` splits into the 250 # scheme and `//host/path`. 251 def body(ps: List<&2, String>) -> String: 252 match ps: 253 case Nil{}: 254 "" 255 case _h <> t: 256 String.join(t, ":") 257 258 # the `//` a scheme leaves in front of the host 259 def drop.slashes(+text: String) -> String: 260 Bool.pick(String, String.starts_with(text, "//"), String.drop(text, 2n), text) 261 262 # a URL with no host, `file:///srv/repo`, is on this host, and is cached 263 # where `file://localhost/srv/repo` is, rather than at an absolute slug 264 def hostless(+text: String) -> String: 265 Bool.pick(String, String.starts_with(text, "/"), "localhost" ++ text, text) 266 267 # `scheme://host/path` as a cache directory 268 def slug.url(+text: String) -> String: 269 ungit(unslash(hostless(drop.slashes(body(String.split(text, ':')))))) 270 271 # `git@host:path` as a cache directory. The colon is the only separator. 272 def slug.scp(+text: String) -> String: 273 ungit(unslash(String.join(String.split(String.drop(text, 4n), ':'), "/"))) 274 275 # the char after `~` or `..`: a slash 276 def looks.slash.at(mark: Mark) -> Bool: 277 match mark: 278 case Slash{}: 279 True{} 280 case Dot{}: 281 False{} 282 case Tilde{}: 283 False{} 284 case Word{}: 285 False{} 286 case Other{}: 287 False{} 288 289 def looks.slash(text: String) -> Bool: 290 match text: 291 case SNil{}: 292 False{} 293 case SCon{+h, _t}: 294 looks.slash.at(kind(h)) 295 296 # the char after a leading dot: `./` or `../` 297 def looks.dot.at(mark: Mark, rest: String) -> Bool: 298 match mark: 299 case Slash{}: 300 True{} 301 case Dot{}: 302 looks.slash(rest) 303 case Tilde{}: 304 False{} 305 case Word{}: 306 False{} 307 case Other{}: 308 False{} 309 310 def looks.dot(text: String) -> Bool: 311 match text: 312 case SNil{}: 313 False{} 314 case SCon{+h, t}: 315 looks.dot.at(kind(h), t) 316 317 # the first char: `/`, or the start of `./`, `../` or `~/` 318 def looks.first(mark: Mark, rest: String) -> Bool: 319 match mark: 320 case Slash{}: 321 True{} 322 case Dot{}: 323 looks.dot(rest) 324 case Tilde{}: 325 looks.slash(rest) 326 case Word{}: 327 False{} 328 case Other{}: 329 False{} 330 331 # a word that already says it is a path: it starts `/`, `./`, `../` or `~/` 332 def looks.path(text: String) -> Bool: 333 match text: 334 case SNil{}: 335 False{} 336 case SCon{+h, t}: 337 looks.first(kind(h), t) 338 339 # `git@host:path`, or a path 340 def classify.scp(scp: Bool, +text: String) -> Place: 341 match scp: 342 case True{}: 343 remote(text, slug.scp(text)) 344 case False{}: 345 Local{text} 346 347 # `owner/repo`. Its slug is safe without the check a URL's gets: neither 348 # segment is empty, `.`, `..` or holds a slash, so it cannot climb out of the 349 # cache. A trailing `.git` is not part of the cache directory, as for a URL. 350 def classify.hub(ok: Bool, +text: String) -> Place: 351 match ok: 352 case True{}: 353 Remote{"https://github.com/" ++ text, "github.com/" ++ ungit(text)} 354 case False{}: 355 classify.scp(String.starts_with(text, "git@"), text) 356 357 # a path prefix, or a possible shorthand 358 def classify.path(local: Bool, +text: String) -> Place: 359 match local: 360 case True{}: 361 Local{text} 362 case False{}: 363 classify.hub(github.ok(text), text) 364 365 # a URL, or not 366 def classify.url(url: Bool, +text: String) -> Place: 367 match url: 368 case True{}: 369 remote(text, slug.url(text)) 370 case False{}: 371 classify.path(looks.path(text), text) 372 373 # nothing was asked for 374 def classify.empty(empty: Bool, +text: String) -> Place: 375 match empty: 376 case True{}: 377 Bad{"a target is needed"} 378 case False{}: 379 classify.url(String.contains(text, "://"), text) 380 381 # what a target word names. A word with `://` in it is a URL; one starting 382 # `/`, `./`, `../` or `~/` is a path; `owner/repo` is GitHub; one starting 383 # `git@` is a URL; anything else is a path. The shorthand is asked about 384 # before `git@` because no shorthand holds an `@`, and in that order the 385 # shorthand's law needs nothing said about `git@`. 386 def classify(+text: String) -> Place: 387 classify.empty(String.is_empty(text), text) 388 389 # what `ez add` was asked for: a hub package by its `@`, or a 390 # place as `classify` reads it 391 type Aim is Data: 392 Named{nv: String} 393 Placed{place: Place} 394 395 # a word `classify` reads as a path names a hub package when bend's NAMED rule 396 # rules it in (`K.name.ok`); otherwise it is that path 397 def aim.local(named: Bool, +text: String, +path: String) -> Aim: 398 match named: 399 case True{}: 400 Named{text} 401 case False{}: 402 Placed{Local{path}} 403 404 # the place read, with only a path asked whether it is a name. A URL, a 405 # `git@host:path` and an `owner/repo` are never read again, so nothing that 406 # was a remote becomes a name. 407 def aim.place(place: Place, +text: String) -> Aim: 408 match place: 409 case Bad{why}: 410 Placed{Bad{why}} 411 case Local{path}: 412 aim.local(K.name.ok(text), text, path) 413 case Remote{url, slug}: 414 Placed{Remote{url, slug}} 415 416 # what an `ez add` target names. A name holds no `/` and no `:`, and starts 417 # with a letter, so a word bend reads as a name is one `classify` reads as a 418 # path, and only such a word is asked. 419 def aim(+text: String) -> Aim: 420 aim.place(classify(text), text) 421 422 # whether a target names a hub package 423 def aim.named(aim: Aim) -> Bool: 424 match aim: 425 case Named{_nv}: 426 True{} 427 case Placed{_place}: 428 False{} 429 430 # `~` and `~/…` expanded. Anything else is already a path. 431 def expand(+home: String, +path: String) -> String: 432 Bool.pick(String, String.eq(path, "~"), home, 433 Bool.pick(String, String.starts_with(path, "~/"), 434 home ++ String.drop(path, 1n), path)) 435 436 # the entry, or `main.bend` when a ledger names none 437 def entry.or(+path: String) -> String: 438 Bool.pick(String, String.is_empty(path), "main.bend", path) 439 440 # the file that is built: `bin` when the ledger names one, otherwise the entry 441 def file.of(+bin: String, +entry: String) -> String: 442 Bool.pick(String, String.is_empty(bin), entry.or(entry), bin) 443 444 # the binary's name: the package's, or `app` when the ledger names none 445 def out.name(+name: String) -> String: 446 Bool.pick(String, String.is_empty(name), "app", name) 447 448 # a place on one line, for a proof to read 449 def show(place: Place) -> String: 450 match place: 451 case Bad{why}: 452 "bad " ++ why 453 case Local{path}: 454 "local " ++ path 455 case Remote{url, slug}: 456 "remote " ++ url ++ " " ++ slug