diff --git a/NEWS.rst b/NEWS.rst index 5c736184..7706c885 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -1,5 +1,15 @@ .. default-role:: code +Unreleased +====================================================== + +New Features +------------------------------ +* New macro `defmacro-kwargs`. +* New macro `parse-fn-params`. +* New function `sign`. +* New function `thru`. + 0.5.0 (released 2024-01-05; uses Hy 0.28.*) ====================================================== diff --git a/docs/index.rst b/docs/index.rst index 2098ceec..f526281d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -91,15 +91,19 @@ Reference .. hy:autofunction:: drop-last .. hy:autofunction:: flatten .. hy:autofunction:: rest +.. hy:autofunction:: thru ``macrotools`` — Tools for writing and handling macros ---------------------------------------------------------------------- .. hy:automodule:: hyrule.macrotools .. hy:autotag:: / +.. hy:automacro:: defmacro-kwargs .. hy:automacro:: defmacro/g! .. hy:automacro:: defmacro! .. hy:autofunction:: macroexpand-all +.. hy:autofunction:: map-model +.. hy:autofunction:: match-fn-params .. hy:automacro:: with-gensyms ``pprint`` — Pretty-printing data structures @@ -133,6 +137,7 @@ Reference .. hy:autofunction:: parse-args .. hy:automacro:: profile/calls .. hy:automacro:: profile/cpu +.. hy:autofunction:: sign .. hy:automacro:: smacrolet .. hy:autofunction:: xor diff --git a/hyrule/iterables.hy b/hyrule/iterables.hy index dfaca919..20fd3bbb 100644 --- a/hyrule/iterables.hy +++ b/hyrule/iterables.hy @@ -161,3 +161,20 @@ [] " (islice coll 1 None)) + + +(defn thru [a [b None] [step 1]] + "A doubly inclusive version of :py:class:`range`. It takes the same + arguments as ``range``, but includes the endpoint (given a + compatible start point and step size). :: + + (thru 3) + ; => [0 1 2 3] + (thru 0 10 2) + ; => [0 2 4 6 8 10] + (thru 0 9 2) + ; => [0 2 4 6 8]" + + (when (is b None) + (setv [a b] [0 a])) + (range a (+ b (if (> step 0) 1 -1)) step)) diff --git a/hyrule/macrotools.hy b/hyrule/macrotools.hy index 5070d9cf..1662e050 100644 --- a/hyrule/macrotools.hy +++ b/hyrule/macrotools.hy @@ -4,6 +4,151 @@ hyrule.collections [walk]) +(defmacro defmacro-kwargs [name params #* body] + + #[=[Define a macro that can take keyword arguments. When the macro + is called, :hy:func:`match-fn-params` is used to match the + arguments against ``params``, and the parameters are assigned to + local variables that can be used in the macro body. :: + + (defmacro-kwargs do10times [form [print-iteration 'False]] + (setv i (hy.gensym)) + `(for [~i (range 10)] + (when ~print-iteration + (print "Now on iteration:" ~i)) + ~form)) + + (setv x []) + (do10times + (.append x 1)) + ; Nothing is printed. + (do10times + :print-iteration (> (len x) 17) + (.append x 1)) + ; Iterations 8 and 9 are printed.]=] + + (setv [ps p-rest p-kwargs] (parse-fn-params params)) + (setv docstring None) + (when (and body (isinstance (get body 0) hy.models.String)) + (setv [docstring #* body] body)) + (setv g (hy.gensym)) + `(defmacro ~name [#* ~g] + ~@(if docstring [docstring] []) + (setv ~g (hy.I.hyrule.match-fn-params ~g '~params)) + ~@(gfor + k [ + #* (.keys ps) + #* (if p-rest [p-rest] []) + #* (if p-kwargs [p-kwargs] [])] + `(setv ~(hy.models.Symbol k) (get ~g ~k))) + ~@body)) + +(defn match-fn-params [args params] + #[[Match an iterable of arguments against a parameter list in the + style of a :hy:func:`defn` lambda list. The parameter-list syntax + here is somewhat restricted: annotations are forbiddden, ``/`` and + ``*`` aren't recognized, and nothing is allowed after ``#* args`` + other than ``#** kwargs``. Return a dictionary of the parameters and + their values. :: + + (match-fn-params + [1 :foo "x"] + '[a [b 2] [c 3] #* args #** kwargs]) + ; => {"a" 1 "b" 2 "c" 3 "args" #() "kwargs" {"foo" "x"}} + + If a default argument is a :ref:`model `, it's evaluated. + The evaluation occurs in a minimal environment, with no access to + surrounding global or local Python-level objects or macros. If this + is too restrictive, use ``None`` as the default value and compute the + real default value in other code. + + This function exists mostly to implement :hy:macro:`defmacro-kwargs`.]] + + (setv [ps p-rest p-kwargs] (parse-fn-params params)) + + ; Loop over `args`. + (setv args (list args) collected-rest [] collected-kwargs {} i-pos 0) + (while args + (setv x (.pop args 0)) + (cond + + (and + (isinstance x hy.models.Expression) + x + (isinstance (get x 0) hy.models.Symbol) + (in (hy.mangle (get x 0)) ["unpack_iterable" "unpack_mapping"])) + ; Unpacking would require evaluating the elements of `args`, which we + ; want to avoid. + (raise (TypeError "unpacking is not allowed in `args`")) + + (isinstance x hy.models.Keyword) (do + ; A keyword argument + (setv x (hy.mangle x.name)) + (when (or + (in x collected-kwargs) + (and (in x ps) (is-not (get ps x "value") None))) + (raise (TypeError (+ "keyword argument repeated: " x)))) + (setv v (.pop args 0)) + (cond + (in x ps) + (setv (get ps x "value") v) + p-kwargs + (setv (get collected-kwargs x) v) + True + (raise (TypeError f"unexpected keyword argument '{x}'")))) + + True (do + ; A positional argument + (cond + (< i-pos (len ps)) (do + (setv [k d] (get (list (.items ps)) i-pos)) + (if (is (get d "value") None) + (setv (get d "value") x) + (raise (TypeError f"got multiple values for argument '{k}'")))) + p-rest + (.append collected-rest x) + True + (raise (TypeError f"takes {(len ps)} positional arguments but more were given"))) + (+= i-pos 1)))) + + ; Return the result. + (dict + #** (dfor + [p d] (.items ps) + p (cond + (is-not (get d "value") None) + (get d "value") + (is-not (get d "default") None) + (get d "default") + True + (raise (TypeError f"missing a required positional argument: '{p}'")))) + #** (if p-rest {p-rest (tuple collected-rest)} {}) + #** (if p-kwargs {p-kwargs collected-kwargs} {}))) + +(defn parse-fn-params [params] + "A subroutine for `defmacro-kwargs` and `match-params`." + (import + funcparserlib.parser [maybe many] + hy.model-patterns [SYM FORM sym brackets pexpr]) + + (setv msym (>> SYM hy.mangle)) + (defn pvalue [root wanted] + (>> (pexpr (+ (sym root) wanted)) (fn [x] (get x 0)))) + (setv [ps p-rest p-kwargs] (.parse + (+ + (many (| msym (brackets msym FORM))) + (maybe (pvalue "unpack-iterable" msym)) + (maybe (pvalue "unpack-mapping" msym))) + params)) + (setv ps (dfor + p ps + :setv [k dv] (if (isinstance p hy.models.List) p [p None]) + k (dict :value None :default (if (isinstance dv hy.models.Object) + (hy.eval dv {} :macros {}) + dv)))) + [ps p-rest p-kwargs]) + + (defmacro defmacro/g! [name args #* body] "Like `defmacro`, but symbols prefixed with 'g!' are gensymed. @@ -126,6 +271,52 @@ (expand form)) +(defn map-model [x f] + #[[Recursively apply a callback to some code. The unary function ``f`` is called on the object ``x``, converting it to a :ref:`model ` first if it isn't one already. If the return value isn't ``None``, it's converted to a model and used as the result. But if the return value is ``None``, and ``x`` isn't a :ref:`sequential model `, then ``x`` is used as the result instead. :: + + (defn f [x] + (when (= x 'b) + 'B)) + (map-model 'a f) ; => 'a + (map-model 'b f) ; => 'B + + Recursive descent occurs when ``f`` returns ``None`` and ``x`` is sequential. Then ``map-model`` is called on all the elements of ``x`` and the results are bound up in the same model type as ``x``. :: + + (map-model '[a [b c] d] f) ; => '[a [B c] d] + + The typical use of ``map-model`` is to write a macro that replaces models of a selected kind, however deeply they're nested in a tree of models. :: + + (defmacro lowercase-syms [#* body] + "Evaluate `body` with all symbols downcased." + (hy.I.hyrule.map-model `(do ~@body) (fn [x] + (when (isinstance x hy.models.Symbol) + (hy.models.Symbol (.lower (str x))))))) + (lowercase-syms + (SETV FOO 15) + (+= FOO (ABS -5))) + (print foo) ; => 20 + + That's why the parameters of ``map-model`` are backwards compared to ``map``: in user code, ``x`` is typically a symbol or other simple form whereas ``f`` is a multi-line anonymous function.]] + + (when (not (isinstance x hy.models.Object)) + (setv x (hy.as-model x))) + (cond + (is-not (setx value (f x)) None) + (hy.as-model value) + (isinstance x hy.models.Sequence) + ((type x) + (gfor elem x (map-model elem f)) + #** (cond + (isinstance x hy.models.FString) + {"brackets" x.brackets} + (isinstance x hy.models.FComponent) + {"conversion" x.conversion} + True + {})) + True + x)) + + (defmacro with-gensyms [args #* body] "Execute `body` with `args` as bracket of names to gensym for use in macros. diff --git a/hyrule/misc.hy b/hyrule/misc.hy index 168dc29e..cfbbb0ec 100644 --- a/hyrule/misc.hy +++ b/hyrule/misc.hy @@ -256,6 +256,26 @@ (print (.getvalue ~g!hy-s)))) +(do-mac (do + (setv code " + (cond + (< x 0) -1 + (> x 0) 1 + (= x 0) 0 + True (raise TypeError))") + + `(defn sign [x] + ~f"Return -1 for negative ``x``, 1 for positive ``x``, and 0 for + ``x`` equal to 0. The implementation is exactly :: + + {code} + + with the corresponding consequences for special cases like negative + zero and NaN." + + ~(hy.read code)))) + + (defn xor [a b] "Perform exclusive or between `a` and `b`. diff --git a/tests/test_iterables.hy b/tests/test_iterables.hy index d147b2df..38afa70f 100644 --- a/tests/test_iterables.hy +++ b/tests/test_iterables.hy @@ -1,6 +1,7 @@ (import itertools [count islice] - hyrule [butlast coll? distinct drop-last flatten rest]) + hyrule [butlast coll? distinct drop-last flatten rest thru] + pytest) (defn test-butlast [] @@ -79,3 +80,28 @@ (assert (= (list (rest [1 2 3 4 5])) [2 3 4 5])) (assert (= (list (islice (rest (count 8)) 3)) [9 10 11])) (assert (= (list (rest [])) []))) + + +(defn test-thru [] + (assert (is (type (thru 5)) (type (range 5)))) + + (defn check [args values] + (assert (= (list (thru #* args)) values))) + (check [3] [0 1 2 3]) + (check [-1 3] [-1 0 1 2 3]) + (check [-1] []) + (check [3 1] []) + (check [3 1 -1] [3 2 1]) + (check [0 5 2] [0 2 4]) + (check [0 6 2] [0 2 4 6]) + (check [5 0 -2] [5 3 1]) + (check [6 0 -2] [6 4 2 0]) + + (assert [(pytest.raises TypeError)] + (thru)) + (assert [(pytest.raises TypeError)] + (thru 3.0)) + (assert [(pytest.raises TypeError)] + (thru "3")) + (assert [(pytest.raises ValueError)] + (thru 1 10 0))) diff --git a/tests/test_macrotools.hy b/tests/test_macrotools.hy index 819e495c..3bb89132 100644 --- a/tests/test_macrotools.hy +++ b/tests/test_macrotools.hy @@ -1,9 +1,32 @@ (require - hyrule [defmacro! defmacro/g! with-gensyms ->] + hyrule [defmacro-kwargs defmacro! defmacro/g! with-gensyms ->] :readers [/]) (import pytest - hyrule [macroexpand-all]) + hyrule [macroexpand-all map-model match-fn-params]) + + +(defn test-defmacro-kwargs [] + + (defmacro-kwargs m [a b [c "default-c"] #* rest #** kw] + "m docstring" + [a b c rest kw]) + (assert (= + (m 1 2) + [1 2 "default-c" #() {}])) + (assert (= + (m :b "bb" :a "aa" :foo "hello") + ["aa" "bb" "default-c" #() {"foo" "hello"}])) + (assert (= (. (get-macro m) __doc__) "m docstring")) + + ; Make sure we avoid spurious extra quoting. + (defmacro-kwargs m2 [[x 15]] + ; `x` should be an `int` here, not `hy.models.Integer`. + (global x-is-plain-int?) + (setv x-is-plain-int? (is (type x) int)) + x) + (assert (= (m2) 15)) + (assert (do-mac x-is-plain-int?))) (defmacro example--with-gensyms [] @@ -81,6 +104,105 @@ (assert (= (get (macroexpand-all '(require-macro)) -1) '(setv blah 1)))) + +(defn test-map-model [] + + ; When the callback returns `None`, the element is recursed into, or + ; left alone if non-sequential. + (assert (= + (map-model + '[foo "bar" 3 ["bing" baz]] + (fn [x] + (when (isinstance x hy.models.Symbol) + (hy.models.Symbol (.upper (str x)))))) + '[FOO "bar" 3 ["bing" BAZ]])) + + ; `hy.as-model` is called on the input, as well as the callback's + ; output. + (assert (= + (map-model + ["hello"] + (fn [x] + (cond + (= x "hello") "wrong" + (= x '"hello") "right"))) + '["right"])) + + ; String and byte models aren't recursed into. (They're iterable, + ; but not sequential models.) + (assert (= + (map-model + '["a" "apple" #("a")] + (fn [x] + (when (= (str x) "a") + "b"))) + '["b" "apple" #("b")])) + + ; We can recurse into f-strings, and their properties (like brackets + ; and conversion specifiers) are preserved. + (setv x (map-model + '(+ #[f-x[a{1 !r :9}b{2 !r :9}c]f-x] "hello") + (fn [x] + (when (= x '2) + '3)))) + (assert (= x '(+ #[f-x[a{1 !r :9}b{3 !r :9}c]f-x] "hello"))) + (assert (= (. x [1] brackets) "f-x")) + (assert (= (. x [1] [1] conversion) "r")) + + ; Try a macro implemented with `map-model`. + (defmacro lowercase-syms [#* body] + (hy.I.hyrule.map-model `(do ~@body) (fn [x] + (when (isinstance x hy.models.Symbol) + (hy.models.Symbol (.lower (str x))))))) + (lowercase-syms + (SETV FOO 15) + (+= FOO (ABS -5))) + (assert (= foo 20))) + + +(defn test-match-fn-params [] + + (defn f [args] + (match-fn-params args '[a b [c "default-c"] #* rest #** kw])) + (assert (= + (f [1 2]) + (dict :a 1 :b 2 :c "default-c" :rest #() :kw {}))) + (assert (= + (f '[1 2]) + (dict :a '1 :b '2 :c "default-c" :rest #() :kw {}))) + (assert (= + (f '[1 2 3 4 (+ 4 1)]) + (dict :a '1 :b '2 :c '3 :rest #('4 '(+ 4 1)) :kw {}))) + (assert (= + (f '[:a 1 :b 2 :c 3 :extra 4]) + (dict :a '1 :b '2 :c '3 :rest #() :kw {"extra" '4}))) + (assert (= + (f '[:b 2 1]) + (dict :a '1 :b '2 :c "default-c" :rest #() :kw {}))) + (assert (= + (f '[:b 2 :extra "foo" :a 1]) + (dict :a '1 :b '2 :c "default-c" :rest #() :kw {"extra" '"foo"}))) + (assert (= + (f '[1 2 3 4 5 6 7 :x 10 :y 11]) + (dict :a '1 :b '2 :c '3 :rest #('4 '5 '6 '7) :kw {"x" '10 "y" '11}))) + + ; Mangling + (assert (= + (match-fn-params + '[1 :⬢ ☤ :⚘ 3 :☘ 4] + '[a-b ⬢ #** ✈]) + (dict + :a_b '1 + :hyx_Xblack_hexagonX '☤ + :hyx_XairplaneX {"hyx_XflowerX" '3 "hyx_XshamrockX" '4}))) + + ; Unpacking + (with [(pytest.raises TypeError :match "^unpacking is not allowed in `args`$")] + (f '[1 2 3 #* [1 2]])) + (with [(pytest.raises TypeError :match "^unpacking is not allowed in `args`$")] + (f '[1 2 3 #** {"qq" 1 "xx" 2}]))) + + (defn test-slash-import [] (defmacro no-name [name] `(with [(pytest.raises NameError)] ~name)) diff --git a/tests/test_misc.hy b/tests/test_misc.hy index 035c152f..7ffee5d4 100644 --- a/tests/test_misc.hy +++ b/tests/test_misc.hy @@ -3,7 +3,7 @@ (import pytest typing [List Dict] - hyrule [constantly dec inc parse-args xor]) + hyrule [constantly dec inc parse-args sign xor]) (defn test-constantly [] @@ -68,6 +68,19 @@ (assert (= (xor False 0) 0)) (assert (= (xor 0 False) False))) + +(defn test-sign [] + (assert (= (sign -9) -1)) + (assert (= (sign -0.1) -1)) + (assert (= (sign 0) 0)) + (assert (= (sign (hy.I.fractions.Fraction 2 3) 1))) + (assert (= (sign (hy.I.decimal.Decimal 7.1) 1))) + (with [(pytest.raises TypeError)] + (sign "3")) + (with [(pytest.raises TypeError)] + (sign 3j))) + + (defn test-smacrolet [] (with [exc (pytest.raises UnboundLocalError)] (smacrolet [b c]