%%%------------------------------------------------------------------- %%% @doc %%% A library for creating "optics", a composable traversal over %%% arbitrary containers with the possibility of error. %%% %%% The traversal is modeled as an opaque type containing both a fold %%% and mapfold type. As mapfold is a superset of fold, fold is %%% implemented for efficiency only. The usual fold and mapfold return %%% types are wrapped in ok/error tuples to represent the possibility %%% of failure, with the provided compositions being responsible for %%% propagating errors back out and skipping further execution. %%% %%% These optics can then be composed to read and update nested data %%% structures. Three types of composition are possible. A wrap, which %%% modifies an existing optic into a new form. A chain, which %%% combines two optics so that one focuses on the value the previous %%% focuses on. Finally, a merge, which combines two optics to allow %%% both to focus over the same data. %%% @end %%%------------------------------------------------------------------- -module(optic). -record(optic, {fold :: optic_fold(), mapfold :: optic_mapfold()}). -type error() :: {error, term()}. %% The expected error format. -type option(Success) :: {ok, Success} | error(). %% A result type for operations that might fail. -type callback_map() :: fun((Elem :: term()) -> NewElem :: term()). %% Callback function invoked by map for each element of a container. -type callback_fold() :: fun((Elem :: term(), NewAcc :: term()) -> Acc :: term()). %% Callback function invoked by fold for each element of a container. -type callback_mapfold() :: fun((Elem :: term(), Acc :: term()) -> {NewElem :: term(), NewAcc :: term()}). %% Callback function invoked by mapfold for each element of a %% container. -type callback_filter() :: fun((Elem :: term()) -> boolean()). %% Callback function invoked by filter optics for each element of a %% container. -type callback_new() :: fun((Data :: term(), Template :: term()) -> NewData :: term()). %% Callback function invoked to create new containers. -type optic_fold() :: fun((Fold :: callback_fold(), Acc :: term(), Data :: term()) -> option(NewAcc :: term())). %% A fold function to be used as part of an optic. -type optic_mapfold() :: fun((MapFold :: callback_mapfold(), Acc :: term(), Data :: term()) -> option({NewData :: term(), NewAcc :: term()})). %% A mapfold function to be used as part of an optic. -type optic_wrap(Over) :: fun((Over) -> Over). %% A mapping function to wrap optics. -type optic_wrap_fold() :: optic_wrap(optic_fold()). %% A mapping function over optic folds. -type optic_wrap_mapfold() :: optic_wrap(optic_mapfold()). %% A mapping function over optic mapfolds. -type variations() :: #{create=>term(), strict=>boolean(), filter=>callback_filter(), require=>callback_filter()} | [proplists:property()]. %% Shared options to control optic construction. -opaque optic() :: #optic{}. %% A composable traversal over an arbitrary container. -type optics() :: [optic()]. %% A list of traversals to be composed. %% API -export([% Optic creation. new/1, new/2, wrap/2, wrap/3, chain/1, merge/1, is_optic/1, variations/3, create/3, lax/1, % Optic application. fold/4, get/2, mapfold/4, map/3, put/3, % Optics. id/0, error/1, filter/1, require/1]). -export_type([option/1, callback_map/0, callback_fold/0, callback_mapfold/0, callback_filter/0, optic_fold/0, optic_mapfold/0, optic_wrap_fold/0, optic_wrap_mapfold/0, variations/0, optic/0, optics/0]). %%%=================================================================== %%% API - Optic Creation & Composition %%%=================================================================== %% @doc %% Create a new optic for traversing a data structure. %% %% This is the less efficient form of optic construction and will %% infer a fold function from the given mapfold function. %% @end %% @returns An opaque optic record. %% @see new/2 -spec new(MapFold :: optic_mapfold()) -> optic(). new(MapFold) -> Fold = fun (Fun, Acc, Data) -> case MapFold( fun (Elem, InnerAcc) -> {Elem, Fun(Elem, InnerAcc)} end, Acc, Data) of {ok, {_, NewAcc}} -> {ok, NewAcc}; {error, _} = Error -> Error end end, new(MapFold, Fold). %% @doc %% Create a new optic for traversing a data structure. %% %% Well behaved optics should implement the following properties: %% %% %% %% Optics which are not well behaved will be more difficult to use and %% compose with other optics. Their behaviour will change depending on %% the order in which they are applied and the number of times they %% are applied. %% %% @end %% @param MapFold %% At a minimum, an optic requires a mapfold function to be provided %% for both collecting and modifying values. This function must take %% three arguments; a callback function, an initial accumulator value %% and an arbitrary structure to traverse. The callback function will %% expect two values; an element and the current accumulator. It will %% return a two item tuple with the modified element and the modified %% accumulator. The mapfold function will return a two item tuple with %% the modified structure and the final accumulator value, wrapped in %% an ok or error tagged tuple. %% @end %% @param Fold %% A fold function can also be provided for more efficient traversal %% without modification. If one is not provided, it will be %% inefficiently inferred from the MapFold function. This function %% must take three arguments; a callback function, an initial %% accumulator value and an arbitrary structure to traverse. The %% callback function will expect two values; an element and the %% current accumulator. It will return the modified accumulator. The %% fold function will return a final accumulator value, wrapped in an %% ok or error tagged tuple. %% @end %% @returns An opaque optic record. -spec new(MapFold :: optic_mapfold(), Fold :: optic_fold()) -> optic(). new(MapFold, Fold) -> #optic{fold=Fold, mapfold=MapFold}. %% @doc %% Wrap an existing optic. %% %% This is the less efficient form of optic construction and will %% infer a fold wrapper from the given mapfold wrapper. %% @end %% @returns An opaque optic record. %% @see wrap/3 -spec wrap(Optic :: optic(), WrapMapFold :: optic_wrap_mapfold()) -> optic(). wrap(#optic{} = Optic, WrapMapFold) -> WrapFold = fun (Fold) -> MapFold = fun (Fun, Acc, Data) -> case Fold(Fun, Acc, Data) of {ok, NewAcc} -> {ok, {Data, NewAcc}}; {error, _} = Error -> Error end end, WrappedMapFold = WrapMapFold(MapFold), fun (Fun, Acc, Data) -> case WrappedMapFold(Fun, Acc, Data) of {ok, {_NewData, NewAcc}} -> {ok, NewAcc}; {error, _} = Error -> Error end end end, wrap(Optic, WrapMapFold, WrapFold). %% @doc %% Wrap an existing optic. %% %% This allows for modifying or replacing the methods of an existing %% optic by applying a mapping function to each of the mapfold and %% fold methods. %% @end %% @param Optic An existing optic to modify. %% @param WrapMapFold %% A mapping function to apply to the optic's mapfold function. %% @end %% @param WrapFold %% A mapping function to apply to the optic's fold function. %% @end %% @returns An opaque optic record. -spec wrap(Optic :: optic(), WrapMapFold :: optic_wrap_mapfold(), WrapFold :: optic_wrap_fold()) -> optic(). wrap(#optic{fold=Fold, mapfold=MapFold}, WrapMapFold, WrapFold) -> NewMapFold = WrapMapFold(MapFold), NewFold = WrapFold(Fold), new(NewMapFold, NewFold). %% @doc %% Combine existing optics into a chain. In left to right order, each %% optic then focuses on the result of the previous optic. The result %% of this composition is itself an optic. %% %% This is the default composition method used for functions which %% accept optics. It is the optic sum type. %% @end %% @param Optics The list of optics to compose. %% @returns An opaque optic record. -spec chain(Optics :: optics()) -> optic(). chain(#optic{} = Optic) -> Optic; chain([]) -> id(); chain([Head | Tail]) -> lists:foldl(fun sum/2, Head, Tail). %% @doc %% Merge existing optics into a single optic. In left to right order, %% each optic focuses on the same data. The result of this composition %% is itself an optic. %% %% It is the optic product type. %% @end %% @param Optics The list of optics to compose. %% @returns An opaque optic record. -spec merge(Optics :: optics()) -> optic(). merge(#optic{} = Optic) -> Optic; merge([]) -> id(); merge([Head | Tail]) -> lists:foldl(fun product/2, Head, Tail). %% @doc %% Check if a term is an optic. %% @end %% @param Candidate The term to test. %% @returns A boolean flag. -spec is_optic(Candidate :: term()) -> boolean(). is_optic(#optic{}) -> true; is_optic(_) -> false. %% @private %% @doc %% Internal interface for generating optics which support a range of %% optional behaviours. The optic must return {error, undefined} when %% it encounters an unknown type for these options to work. Due to the %% restrictions this places on optic behaviour it is intended only for %% internal use. %% %% Optics with the "create" option enabled are not well behaved, and %% may exhibit unexpected behaviour when composed. It is also possible %% for "filter" and "require" to no longer be well behaved, depending %% on the filter function used. %% @end %% @param Optic The base optic to modify. %% @param Options %% The selected options. Expected options are a boolean "strict" for %% if type errors should be reported or ignored, an arbitrarily valued %% "create" for if type errors should force container creation, a %% "filter" function to restrict the elements selected and a "require" %% function to error when requirements are not met. %% @param New %% When the "create" option is selected, the function to invoke to %% perform the creation. %% @end %% @returns An opaque optic record. %% @see create/3 %% @see filter/1 %% @see lax/1 %% @see require/1 -spec variations(Optic :: optic(), Options :: variations(), New :: callback_new()) -> optic(). variations(#optic{} = Optic, Options, New) when is_list(Options) -> % Normalize proplist option form to map form. Strict = proplists:get_bool(strict, Options), RequiredOptions = #{strict=>Strict}, OptionalOptions = lists:foldl( fun (Option, Acc) -> case proplists:lookup(Option, Options) of {Option, Value} -> Acc#{Option=>Value}; none -> Acc end end, RequiredOptions, [create, filter, require]), variations(Optic, OptionalOptions, New); variations(#optic{} = Optic, #{} = Options, New) -> CreateOptic = case maps:is_key(create, Options) of true -> #{create:=Template} = Options, create(Optic, New, Template); false -> Optic end, LaxOptic = case maps:get(strict, Options, false) of true -> CreateOptic; false -> lax(CreateOptic); InvalidStrict -> erlang:error({invalid_strict_value, InvalidStrict}) end, RequireOptic = case maps:get(require, Options, undefined) of undefined -> LaxOptic; Require when is_function(Require) -> chain([LaxOptic, require(Require)]); InvalidRequire -> erlang:error({invalid_require_value, InvalidRequire}) end, FilterOptic = case maps:get(filter, Options, undefined) of undefined -> RequireOptic; Filter when is_function(Filter) -> chain([RequireOptic, filter(Filter)]); InvalidFilter -> erlang:error({invalid_filter_value, InvalidFilter}) end, FilterOptic. %% @doc %% Wrap an existing optic to cause it to create a new container when %% the optic would otherwise return `{error, undefined}' or %% `{error, required}' during a mapfold operation. %% @end %% @param Optic The existing optic to wrap. %% @param New %% The callback function to apply when the mapfold fails. %% Must take two arguments, the existing data and a template argument %% to use to populate the new data. Should return the new container, %% which will immediately have the wrapped mapfold function re-applied %% after creation. %% @end %% @param Template %% The template value to be given to the callback function. %% @end %% @returns An opaque optic record. -spec create(Optic :: optic(), New :: callback_new(), Template :: term()) -> optic(). create(Optic, New, Template) -> WrapFold = fun (Fold) -> Fold end, WrapMapFold = fun (MapFold) -> fun (Fun, Acc, Data) -> case MapFold(Fun, Acc, Data) of {error, Reason} when Reason == undefined; Reason == required -> MapFold(Fun, Acc, New(Data, Template)); Result -> Result end end end, optic:wrap(Optic, WrapMapFold, WrapFold). %% @doc %% Wrap an existing optic to cause it to skip an element when the %% optic would otherwise return `{error, undefined}' or %% `{error, required}' during a fold or mapfold operation. %% @end %% @param Optic The existing optic to wrap. %% @returns An opaque optic record. -spec lax(optic()) -> optic(). lax(Optic) -> WrapFold = fun (Fold) -> fun (Fun, Acc, Data) -> case Fold(Fun, Acc, Data) of {error, Reason} when Reason == undefined; Reason == required -> {ok, Acc}; Result -> Result end end end, WrapMapFold = fun (MapFold) -> fun (Fun, Acc, Data) -> case MapFold(Fun, Acc, Data) of {error, Reason} when Reason == undefined; Reason == required -> {ok, {Data, Acc}}; Result -> Result end end end, wrap(Optic, WrapMapFold, WrapFold). %%%=================================================================== %%% API - Optic Application %%%=================================================================== %% @doc %% Given a list of optics, performs a recursive fold over the result %% of focusing on the given data structure. The order of traversal is %% determined by the optics used. %% @end %% @param Optics A list of optics to apply. Leftmost is applied first. %% @param Data The container to apply the optics to. %% @param Fold %% The callback function to invoke on the focused elements and %% accumulator. Expected to return the modified accumulator. %% @end %% @param Acc The initial accumulator value. %% @returns %% On success, returns a tuple of ok and the final accumulator value. %% On failure, returns an error tuple. %% @end -spec fold(Optics :: optics(), Data :: term(), Fold :: callback_fold(), Acc :: term()) -> option(NewAcc :: term()). fold(Optics, Data, Fold, Acc) -> #optic{fold=OpticFold} = optic:chain(Optics), OpticFold(Fold, Acc, Data). %% @doc %% Given a list of optics, returns a list of the values focused on by %% the final optic. %% @end %% @param Optics A list of optics to apply. Leftmost is applied first. %% @param Data The container to apply the optics to. %% @returns A list of the focused values. -spec get(Optics :: optics(), Data :: term()) -> option(Values :: [term()]). get(Optics, Data) -> case fold(Optics, Data, fun (Elem, Acc) -> [Elem | Acc] end, []) of {ok, Acc} -> {ok, lists:reverse(Acc)}; {error, _} = Error -> Error end. %% @doc %% Given a list of optics, performs a recursive map and fold over the %% result of focusing on the given data structure. %% @end %% @param Optics A list of optics to apply. Leftmost is applied first. %% @param Data The container to apply the optics to. %% @param MapFold %% The callback function to invoke on the focused elements and %% accumulator. Expected to return a tuple of the modified element and %% accumulator. %% @end %% @param Acc The initial accumulator value. %% @returns %% On success, returns a tuple of ok and a tuple of the modified %% container and the final accumulator value. On failure, returns an %% error tuple. %% @end -spec mapfold(Optics :: optics(), Data :: term(), MapFold :: callback_mapfold(), Acc :: term()) -> option({NewData :: term(), NewAcc :: term()}). mapfold(Optics, Data, MapFold, Acc) -> #optic{mapfold=OpticMapFold} = optic:chain(Optics), OpticMapFold(MapFold, Acc, Data). %% @doc %% Given a list of optics, performs a recursive map over the result of %% focusing on the given data structure. %% @end %% @param Optics A list of optics to apply. Leftmost is applied first. %% @param Data The container to apply the optics to. %% @param Map %% The callback function to invoke on the focused elements. Expected %% to return a modified element. %% @end %% @returns %% On success, returns a tuple of ok and the modified container. %% On failure, returns an error tuple. %% @end -spec map(Optics :: optics(), Data :: term(), Map :: callback_map()) -> option(NewData :: term()). map(Optics, Data, Map) -> case mapfold(Optics, Data, fun (Elem, undefined) -> {Map(Elem), undefined} end, undefined) of {ok, {Updated, undefined}} -> {ok, Updated}; {error, _} = Error -> Error end. %% @doc %% Given a list of optics, modifies the values focused on by %% the final optic. %% @end %% @param Optics A list of optics to apply. Leftmost is applied first. %% @param Data The container to apply the optics to. %% @returns %% On success, returns a tuple of ok and the modified container. %% On failure, returns an error tuple. %% @end -spec put(Optics :: optics(), Data :: term(), Value :: term()) -> option(NewData :: term()). put(Optics, Data, Value) -> map(Optics, Data, fun (_) -> Value end). %%%=================================================================== %%% API - Optics %%%=================================================================== %% @doc %% Focus on what was given. %% %% This is the identity optic, it can be chained with any other optic %% and will return the same optic. %% %% Example: %% %% ``` %% > optic:get([optic:id()], anything). %% {ok,[anything]} %% ''' %% @end %% @returns An opaque optic record. -spec id() -> optic(). id() -> Fold = fun (Fun, Acc, Data) -> {ok, Fun(Data, Acc)} end, new(Fold, Fold). %% @doc %% Always errors with the given reason. %% %% Example: %% %% ``` %% > optic:get([optic:error(reason)], anything). %% {error, reason} %% ''' %% @end %% @param Reason The error description to return. %% @returns An opaque optic record. -spec error(Reason :: term()) -> optic:optic(). error(Reason) -> Fold = fun (_Fun, _Acc, _Data) -> {error, Reason} end, new(Fold, Fold). %% @doc %% Only focuses on the current data if the given filter function %% returns true. Otherwise the data is skipped. %% %% Can fail to be well behaved depending on if the filter criteria is %% part of the focus. %% %% Example: %% %% ``` %% > IsOdd = fun (Elem) -> Elem % 2 == 1 end, %% > optic:get([optic:filter(IsOdd)], [1,2,3]). %% {ok, [1,3]} %% ''' %% @end %% @param Filter %% The filter function to invoke to determine if the element should be %% focused. Takes the current data as an argument, returns a boolean %% true or false. %% @end %% @returns An opaque optic record. -spec filter(callback_filter()) -> optic:optic(). filter(Filter) -> Fold = fun (Fun, Acc, Data) -> case Filter(Data) of true -> {ok, Fun(Data, Acc)}; false -> {ok, Acc} end end, MapFold = fun (Fun, Acc, Data) -> case Filter(Data) of true -> {ok, Fun(Data, Acc)}; false -> {ok, {Data, Acc}} end end, new(MapFold, Fold). %% @doc %% Only focuses on the current data if the given filter function %% returns true. Otherwise an `{error, required}` is returned. %% %% Can fail to be well behaved depending on if the filter criteria is %% part of the focus. %% %% Example: %% %% ``` %% > IsOdd = fun (Elem) -> Elem % 2 == 1 end, %% > optic:get([optic:require(IsOdd)], [1,2,3]). %% {error, required} %% ''' %% @end %% @param Filter %% The filter function to invoke to determine if the element should be %% focused. Takes the current data as an argument, returns a boolean %% true or false. %% @end %% @returns An opaque optic record. -spec require(callback_filter()) -> optic:optic(). require(Filter) -> Fold = fun (Fun, Acc, Data) -> case Filter(Data) of true -> {ok, Fun(Data, Acc)}; false -> {error, required} end end, MapFold = fun (Fun, Acc, Data) -> case Filter(Data) of true -> {ok, Fun(Data, Acc)}; false -> {error, required} end end, new(MapFold, Fold). %%%=================================================================== %%% Internal Functions %%%=================================================================== sum(#optic{fold=Fold1, mapfold=MapFold1}, #optic{fold=Fold2, mapfold=MapFold2}) -> Fold = fun (Fun, Acc, Data) -> case Fold2( fun (Elem, {ok, InnerAcc}) -> Fold1(Fun, InnerAcc, Elem); (_Elem, {error, _} = Error) -> Error end, {ok, Acc}, Data) of {ok, {ok, _} = Result} -> Result; {ok, {error, _} = Error} -> Error; {error, _} = Error -> Error end end, MapFold = fun (Fun, Acc, Data) -> case MapFold2( fun (Elem, {ok, InnerAcc}) -> case MapFold1(Fun, InnerAcc, Elem) of {ok, {NewElem, NewInnerAcc}} -> {NewElem, {ok, NewInnerAcc}}; {error, _} = Error -> {Elem, Error} end; (Elem, {error, _} = Error) -> {Elem, Error} end, {ok, Acc}, Data) of {ok, {NewData, {ok, NewAcc}}} -> {ok, {NewData, NewAcc}}; {ok, {_, {error, _} = Error}} -> Error; {error, _} = Error -> Error end end, new(MapFold, Fold). product(#optic{fold=Fold1, mapfold=MapFold1}, #optic{fold=Fold2, mapfold=MapFold2}) -> Fold = fun (Fun, Acc, Data) -> case Fold2(Fun, Acc, Data) of {ok, NewAcc} -> Fold1(Fun, NewAcc, Data); {error, _} = Error -> Error end end, MapFold = fun (Fun, Acc, Data) -> case MapFold2(Fun, Acc, Data) of {ok, {NewData, NewAcc}} -> MapFold1(Fun, NewAcc, NewData); {error, _} = Error -> Error end end, new(MapFold, Fold).