bakpakin / Fennel

Lua Lisp Language
https://fennel-lang.org
MIT License
2.42k stars 124 forks source link

"raise on no match" case documentation #437

Closed rktjmp closed 1 year ago

rktjmp commented 1 year ago

Speculative documentation if case was forced exhaustive.

I think in particular this it makes case-try more "reason-able" where it will just error consistently whilst match-try might return nil or the unmatched value.

I generally did not add a _ :otherwise pattern to the examples as generally it seems out of scope (as in, previously returning nil might have exploded the theoretical program, so it's much the same?) but unsure if it makes them less helpful or not.

There is an alternate idea floated where if you don't add a _ x clause the compiler yells at you, which is not documented here.

Pro's I see for a compiler error:

Cons:

technomancy commented 1 year ago

I think in particular this it makes case-try more "reason-able" where it will just error consistently whilst match-try might return nil or the unmatched value.

Can you elaborate on this? In my mind, this is maybe the best argument against case erroring out. The point of the case-try macro is to allow the failures to fall down to the catch clause, and if there's a runtime error whenever a mismatch occurs, that's no longer possible.

rktjmp commented 1 year ago

I meant that case could error if nothing matched, and case-try could error if nothing matched in the catch block -- and that it implicitly adds the (catch _ (error)) if needed. It would follow the "errors on no match" in spirit, but not a 1:1 implementation between case and case-try.

This was my reasoning, but I think its a flawed argument

```fennel (fn dump [prefix ...] (local {: view} (require :fennel)) (->> (case (icollect [_ x (ipairs [...])] (view x)) [nil] nil t (table.concat t ", ")) (print prefix))) (fn split-words-or-err [input] ;; some function that does an action or returns some error flag(s) (case (string.match input "(%w+) (%w+)") (a b) (values a b) _ "PC LOAD LETTER")) ;; happy but naive case, we gave some input and got some output, there were no ;; errors and everything is as expected. (->> (match-try "world hello" input (split-words-or-err input) (a b) (.. b " " a)) (dump :match-try-1)) ; => hello world ;; Sad case, but also maybe unexpected case in a large match-try sequence, ;; where a function mid-flow might return some ~*~strange~*~ value. ;; Here match-try returns the un-matched value. (->> (match-try "worldhello" input (split-words-or-err input) (a b) (.. b " " a)) (dump :match-try-2)) ; => PC LOAD LETTER, what does that mean? ;; Another sad case, but this has a different behaviour. ;; Input returned an error, but we didn't match it, but instead of returning ;; the value as above we return nil. ;; As I understand it, this *does* make sense as catch is sort of "unifying" ;; [sic] our error handling into "known errors -> known values otherwise nil" ;; where as without the catch we just dump out anything assuming the un-matched ;; values are handled outside us. (->> (match-try "worldhello" input (split-words-or-err input) (a b) (.. b " " a) (catch nil (.. "just one word"))) (dump :match-try-3)) ; => nil (->> (match-try "worldhello" input (split-words-or-err input) (a b) (.. b " " a) (catch "PC LOAD LETTER" "Please load paper casette with US Letter paper." nil (.. "just one word"))) (dump :match-try-4)) ; => "Please ..." ;; For completeness, here is a/the theoretical "no-match-always-error" via runtime error. ;; If it's a runtime error we probably have a harder time showing a reasonable ;; error message, (short of `(tostring x)` for (case x ...)) but gives user ;; freedom to not always include a generic `_ error` pattern if the default ;; "just crash" error is fine. ;; eg: ;; (case month ;; 1 :jan ;; 2 :feb ;; ... ;; 12 :dec) ;; the implicit _ error is OK, I just want it to crash if I get 13, making me ;; write: ;; (case month ;; 1 :jan ;; 2 :feb ;; ... ;; 12 :dec ;; _ (error "bad month")) ;; has *some* value, but does it have *enough* value in *all* cases to need a ;; compiler error? (macro case! [exp ...] (let [body (icollect [_ x (ipairs [...]) &into `(case ,exp)] x) _ (table.insert body (sym :_)) _ (table.insert body `(error "case expression matched no value"))] body)) ;; default error when no match, but we *can* write this expresssion, but accept ;; the contract of can-raise. (dump :case!-1 (pcall #(case! (split-words-or-err "worldhello") (a b) (.. b " " a)))) ;; or we can explicitly handle the error if we have a better choice than raising. (dump :case!-2 (pcall #(case! (split-words-or-err "worldhello") (a b) (.. b " " a) _ "unexpected item in the bagging area"))) ;; Theoretical case-try! behaviour ;; It still falls-down but it implicitly inserts a catch expression if none ;; present and implicitly appends `_ (error)` to the tail of catch. (macro case-try! [...] (let [body [...] last (. body (length body))] (case (. last 1 1) :catch (do (table.insert last (sym :_)) (table.insert last `(error "case expression matched no value"))) _ (do (table.insert body `(catch ,(sym :_) (error "case expression matched no value"))))) `(case-try ,(table.unpack body)))) ;; As before, happy but naive case, we gave some input and got some output, ;; there were no errors and everything is as expected. (->> (case-try! "world hello" input (split-words-or-err input) (a b) (.. b " " a)) (dump :case-try-1)) ; => hello world ;; Sad case, with an unexpected value, in this case `case-try` actually errors ;; out because some unexpected value appeared *anywhere* in the flow. (dump :case-try-2 (pcall #(case-try! "worldhello" input (split-words-or-err input) (a b) (.. b " " a)))) ; => false, case expression matched no value ;; Sad case, with an unexpected value, and we tried to catch our errors but ;; missed one or did not provide a default handler. Again, `case-try` ;; consistently raises for an unmatched value. (dump :case-try-3 (pcall #(case-try! "worldhello" input (split-words-or-err input) (a b) (.. b " " a) (catch nil (.. "just one word"))))) ; => false, case expression matched no value ;; finally we add some default handler (->> (case-try! "worldhello" input (split-words-or-err input) (a b) (.. b " " a) (catch nil (.. "just one word") _ "some-actual-error-handling")) (dump :case-try-4)) ; => "some-actual-error-handling" ;; match-try the reader learns: ;; ;; when match-try runs ;; if all-ok ;; return last-value ;; if a match fails (called failed-value) ;; if there is a catch expression ;; if the failed-value matches a catch pattern ;; return catch value ;; else ;; return nil ;; else ;; return failed-value ;; ;; vs case-try!, the reader learns: ;; ;; when case-try! runs ;; if all-ok ;; return last-value ;; if a match fails (called failed-value) ;; if there is a catch expression ;; if the failed-value matches a catch pattern ;; return catch-value ;; else ;; raises error ;; else ;; raises error ;; ;; The tree has the same complexity but the results are more consistent as ;; `value i wrote an expression for or error` vs `value i wrote an expression ;; for or nil or an inner expression value if there is no catch.` ```

After some thought, I think it's a bad idea because it forces all error handling inside the case-try, while it may be more appropriate to pass abnormal values up.

I think the most useful (if runtime-erroring) would be,

I think that leaves some ambiguity between what will raise "when you see the word case", but is correctly flexible.

Curious what your arguments for having a runtime error and having a compiler error are.

technomancy commented 1 year ago

Hey, sorry but I think we've decided after discussing it in chat that this is probably too disruptive. Thanks for writing it out to help us talk thru the reasoning. =)