Current section

Files

Jump to
cuttlefish src cuttlefish_partial.erl
Raw

src/cuttlefish_partial.erl

%% -------------------------------------------------------------------
%%
%% cuttlefish_partial: load and rewrite a reusable mapping block
%%
%% Copyright (c) 2026 Broadcom. All Rights Reserved. The term Broadcom
%% refers to Broadcom Inc. and/or its subsidiaries.
%%
%% This file is provided to you under the Apache License,
%% Version 2.0 (the "License"); you may not use this file
%% except in compliance with the License. You may obtain
%% a copy of the License at
%%
%% http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing,
%% software distributed under the License is distributed on an
%% "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
%% KIND, either express or implied. See the License for the
%% specific language governing permissions and limitations
%% under the License.
%%
%% -------------------------------------------------------------------
-module(cuttlefish_partial).
-export([
load/3,
rewrite/2,
bind_translation/3,
is_partial_filename/1
]).
-type include_opt() :: {prefix, string()}
| {app_prefix, string()}
| {exclude, [string()]}
| {overrides, [{string(), proplists:property()}]}
| {disable_with, atom()}.
-type partial_term() ::
{mapping, string(), string(), [proplists:property()]}
| {validator, string(), string(), fun((term()) -> boolean())}
| {validator, string(), string(), fun((term()) -> boolean()),
[proplists:property()]}
| {partial_translation, string(),
fun((cuttlefish_conf:conf(), string(), string()) -> term())}.
-type emitted_term() ::
{mapping, string(), string(), [proplists:property()]}
| {validator, string(), string(), fun((term()) -> boolean())}
| {validator, string(), string(), fun((term()) -> boolean()),
[proplists:property()]}
| {translation, string(),
fun((cuttlefish_conf:conf()) -> term())}.
-export_type([include_opt/0, partial_term/0]).
-define(MAX_PARTIAL_BYTES, 8388608).
-spec load(atom(), string(), [include_opt()]) ->
{ok, [emitted_term()]} | cuttlefish_error:error().
load(App, Name, IncludeOpts) when is_atom(App), is_list(Name) ->
case validate_include_opts(App, Name, IncludeOpts) of
ok ->
case ensure_app_loaded(App) of
ok ->
case resolve_path(App, Name) of
{ok, Path} -> load_path(App, Name, Path, IncludeOpts);
Err -> Err
end;
Err -> Err
end;
Err -> Err
end.
-spec rewrite([partial_term()], [include_opt()]) ->
{ok, [emitted_term()]} | cuttlefish_error:error().
rewrite(Terms, IncludeOpts) ->
Prefix = proplists:get_value(prefix, IncludeOpts),
AppPrefix = proplists:get_value(app_prefix, IncludeOpts),
Exclude = proplists:get_value(exclude, IncludeOpts, []),
Overrides = proplists:get_value(overrides, IncludeOpts, []),
case rewrite_each(Terms, Prefix, AppPrefix, Exclude, Overrides, []) of
{ok, Rewritten} ->
case proplists:get_value(disable_with, IncludeOpts) of
undefined -> {ok, Rewritten};
Atom -> {ok, Rewritten ++ disable_with_terms(Prefix, AppPrefix, Atom)}
end;
Err -> Err
end.
-spec bind_translation(Fun3, string(), string()) ->
fun((cuttlefish_conf:conf()) -> term())
when Fun3 :: fun((cuttlefish_conf:conf(), string(), string()) -> term()).
bind_translation(Fun3, ConfPrefix, AppPrefix) ->
fun(Conf) -> Fun3(Conf, ConfPrefix, AppPrefix) end.
%% True for paths ending in `.partial`. Useful for the same kind of
%% pre-flight sanity checks `cuttlefish_schema` applies to `.schema`.
-spec is_partial_filename(file:filename_all()) -> boolean().
is_partial_filename(Filename) ->
filename:extension(Filename) =:= ".partial".
%% ===================================================================
%% Internal: include-opt validation
%% ===================================================================
validate_include_opts(App, Name, Opts) when is_list(Opts) ->
case lists:foldl(
fun(Opt, ok) -> validate_one_opt(Opt);
(_, Err) -> Err
end, ok, Opts) of
ok ->
check_required(App, Name, Opts);
Err -> Err
end;
validate_include_opts(_App, _Name, Other) ->
{error, {partial_unknown_include_opt, Other}}.
validate_one_opt({prefix, V}) when is_list(V) -> ok;
validate_one_opt({app_prefix, V}) when is_list(V) -> ok;
validate_one_opt({exclude, L}) when is_list(L) ->
case lists:all(fun(E) -> is_list(E) andalso E =/= [] end, L) of
true -> ok;
false -> {error, {partial_unknown_include_opt, {exclude, L}}}
end;
validate_one_opt({overrides, L}) when is_list(L) ->
case lists:all(fun({N, Opt}) ->
is_list(N) andalso N =/= []
andalso is_tuple(Opt) andalso tuple_size(Opt) >= 1;
(_) -> false
end, L) of
true -> ok;
false -> {error, {partial_unknown_include_opt, {overrides, L}}}
end;
validate_one_opt({disable_with, V}) when is_atom(V), V =/= undefined -> ok;
validate_one_opt(Other) ->
{error, {partial_unknown_include_opt, Other}}.
check_required(App, Name, Opts) ->
case proplists:is_defined(prefix, Opts) of
false -> {error, {partial_missing_prefix, App, Name}};
true ->
case proplists:is_defined(app_prefix, Opts) of
false -> {error, {partial_missing_app_prefix, App, Name}};
true ->
P = proplists:get_value(prefix, Opts),
AP = proplists:get_value(app_prefix, Opts),
case {P, AP} of
{"", _} -> {error, {partial_empty_prefix, App, Name}};
{_, ""} -> {error, {partial_empty_prefix, App, Name}};
_ -> ok
end
end
end.
%% ===================================================================
%% Internal: app + path resolution
%% ===================================================================
ensure_app_loaded(App) ->
case application:load(App) of
ok -> ok;
{error, {already_loaded, App}} -> ok;
{error, Reason} -> {error, {partial_app_not_loadable, App, Reason}}
end.
resolve_path(App, Name) ->
case code:priv_dir(App) of
{error, bad_name} ->
{error, {partial_app_no_priv_dir, App}};
PrivDir ->
{ok, filename:join([PrivDir, "schema", Name ++ ".partial"])}
end.
%% ===================================================================
%% Internal: file load + parse
%% ===================================================================
load_path(App, Name, Path, IncludeOpts) ->
case file:read_file(Path) of
{ok, Bytes} ->
decode_and_parse(App, Name, Path, Bytes, IncludeOpts);
{error, enoent} ->
{error, {partial_file_not_found, App, Name, Path}};
{error, Reason} ->
{error, {partial_file_read_error, App, Name, Path, Reason}}
end.
decode_and_parse(App, Name, Path, Bytes, IncludeOpts) ->
case byte_size(Bytes) > ?MAX_PARTIAL_BYTES of
true ->
{error, {partial_file_too_large,
{Path, byte_size(Bytes), ?MAX_PARTIAL_BYTES}}};
false ->
case unicode:characters_to_list(Bytes) of
{incomplete, _, _} ->
{error, {partial_file_invalid_unicode, Path}};
{error, _, _} ->
{error, {partial_file_invalid_unicode, Path}};
Chardata when is_list(Chardata) ->
scan_and_parse(App, Name, Chardata, IncludeOpts)
end
end.
scan_and_parse(App, Name, Chardata, IncludeOpts) ->
case erl_scan:string(Chardata) of
{ok, Tokens, _} ->
Comments = erl_comment_scan:string(Chardata),
Forms = split_forms(Tokens),
case parse_forms(Forms, Comments, []) of
{ok, Terms} ->
case sanitize(App, Name, Terms) of
ok ->
case validate_names_against_terms(App, Name, Terms, IncludeOpts) of
ok -> rewrite(Terms, IncludeOpts);
Err -> Err
end;
Err -> Err
end;
{error, Inner} ->
{error, {partial_parse_error, App, Name, Inner}}
end;
{error, {Line, erl_scan, _}, _} ->
{error, {partial_parse_error, App, Name, {erl_scan, Line}}}
end.
%% Pre-flight: every `exclude` and `overrides` name on the include
%% must match something in the partial. A typo (`{exclude,
%% ["versiosns"]}') would otherwise silently no-op, leaving the
%% author to discover the misconfiguration through a downstream test
%% (or in production).
validate_names_against_terms(App, Name, Terms, IncludeOpts) ->
MappingConfs = [K || {mapping, K, _, _} <- Terms],
TranslationApps = [K || {partial_translation, K, _} <- Terms],
Sections = [first_segment(K) || K <- MappingConfs],
ExcludeMatchable = MappingConfs ++ TranslationApps ++ Sections,
Exclude = proplists:get_value(exclude, IncludeOpts, []),
OverrideNames = [N || {N, _} <- proplists:get_value(overrides, IncludeOpts, [])],
case unmatched(Exclude, ExcludeMatchable) of
[] ->
case unmatched(OverrideNames, MappingConfs) of
[] -> ok;
Bad ->
{error, {partial_overrides_unmatched, App, Name, Bad}}
end;
Bad ->
{error, {partial_exclude_unmatched, App, Name, Bad}}
end.
unmatched(Names, Known) ->
[N || N <- Names, not lists:member(N, Known)].
first_segment(Key) ->
[Seg | _] = string:split(Key, "."),
Seg.
split_forms(Tokens) ->
split_forms(Tokens, [], []).
split_forms([], [], Acc) ->
lists:reverse(Acc);
split_forms([], Pending, Acc) ->
%% No trailing dot: keep the tail so `erl_parse` produces a
%% useful error rather than silently dropping it.
lists:reverse([lists:reverse(Pending) | Acc]);
split_forms([{dot, _} = Dot | Rest], Pending, Acc) ->
Form = lists:reverse([Dot | Pending]),
split_forms(Rest, [], [Form | Acc]);
split_forms([T | Rest], Pending, Acc) ->
split_forms(Rest, [T | Pending], Acc).
parse_forms([], _Comments, Acc) ->
{ok, lists:reverse(Acc)};
parse_forms([Tokens | Rest], Comments, Acc) ->
case erl_parse:parse_exprs(Tokens) of
{ok, Exprs} ->
{value, V, _} = erl_eval:exprs(Exprs, []),
parse_forms(Rest, Comments,
[attach_form_comments(V, Tokens, Comments) | Acc]);
{error, {Line, erl_parse, Reason}} ->
{error, {erl_parse, Line, format_parse_reason(Reason)}}
end.
%% For mapping forms, look up comments that sit above the form's first
%% token and merge them into the form's options proplist as `{doc, _}`
%% and `{see, _}`. Mirrors how `cuttlefish_schema:parse_schema/3` uses
%% `comment_parser/1` and `get_see/1` to attach annotations to inline
%% mappings.
attach_form_comments({mapping, Var, Map, Opts}, Tokens, Comments)
when is_list(Opts) ->
FormLine = first_token_line(Tokens),
Above = comments_above(FormLine, Comments),
case extract_doc_and_see(Above) of
{[], []} ->
{mapping, Var, Map, Opts};
{Doc, See} ->
{mapping, Var, Map, merge_doc_see(Opts, Doc, See)}
end;
attach_form_comments(V, _Tokens, _Comments) ->
V.
first_token_line([Tok | _]) ->
case erl_scan:location(Tok) of
{Line, _Col} -> Line;
Line when is_integer(Line) -> Line
end.
%% Returns flat text lines from every comment block whose line is
%% strictly less than `FormLine`.
comments_above(FormLine, Comments) ->
lists:flatmap(
fun({CLine, _Col, _N, Lines}) when CLine < FormLine -> Lines;
(_) -> []
end, Comments).
%% Tiny `@doc` / `@see` parser that mirrors
%% `cuttlefish_schema:comment_parser/1` but only keeps the two
%% annotations a partial cares about.
extract_doc_and_see(Lines) ->
Stripped = [strip_percent(L) || L <- Lines],
do_extract(Stripped, none, [], []).
do_extract([], _Mode, DocAcc, SeeAcc) ->
{lists:reverse(DocAcc), lists:reverse(SeeAcc)};
do_extract(["@doc " ++ Rest | Tail], _Mode, DocAcc, SeeAcc) ->
do_extract(Tail, doc, [Rest | DocAcc], SeeAcc);
do_extract(["@see " ++ Rest | Tail], _Mode, DocAcc, SeeAcc) ->
do_extract(Tail, none, DocAcc, [Rest | SeeAcc]);
do_extract(["@" ++ _ | Tail], _Mode, DocAcc, SeeAcc) ->
do_extract(Tail, none, DocAcc, SeeAcc);
do_extract([Line | Tail], doc, DocAcc, SeeAcc) ->
do_extract(Tail, doc, [Line | DocAcc], SeeAcc);
do_extract([_Line | Tail], Mode, DocAcc, SeeAcc) ->
do_extract(Tail, Mode, DocAcc, SeeAcc).
strip_percent([$% | T]) -> strip_percent(T);
strip_percent([$\s | T]) -> strip_percent(T);
strip_percent(L) -> L.
%% Prepend doc/see entries so they take precedence over anything
%% already in the proplist, matching the inline-schema convention
%% in `cuttlefish_schema:parse_schema/3`.
merge_doc_see(Opts, Doc, See) ->
DocOpt = case Doc of [] -> []; _ -> [{doc, Doc}] end,
SeeOpt = case See of [] -> []; _ -> [{see, See}] end,
DocOpt ++ SeeOpt ++ Opts.
format_parse_reason([H | _] = Reason) when is_list(H) ->
lists:flatten(Reason);
format_parse_reason(Reason) ->
io_lib:format("~tp", [Reason]).
%% Reject anything we can't safely rewrite. The errors here are the
%% loud half of the design: a typo'd head atom or a plain `translation`
%% inside a partial fails at load with a message naming the right form.
sanitize(_App, _Name, []) ->
ok;
sanitize(App, Name, [{mapping, _, _, Opts} | Rest]) when is_list(Opts) ->
sanitize(App, Name, Rest);
sanitize(App, Name, [{validator, _, _, _} | Rest]) ->
sanitize(App, Name, Rest);
sanitize(App, Name, [{validator, _, _, _, _} | Rest]) ->
sanitize(App, Name, Rest);
sanitize(App, Name, [{partial_translation, _, _} | Rest]) ->
sanitize(App, Name, Rest);
sanitize(_App, _Name, [{translation, _, _} | _]) ->
{error, {partial_unsupported_term, translation}};
sanitize(App, Name, [{include_partial, _, _} | _]) ->
{error, {partial_include_in_partial, App, Name}};
sanitize(_App, _Name, [Other | _]) when is_tuple(Other), tuple_size(Other) >= 1 ->
{error, {partial_unsupported_term, element(1, Other)}};
sanitize(_App, _Name, [Other | _]) ->
{error, {partial_unsupported_term, Other}}.
%% ===================================================================
%% Internal: term rewriting
%% ===================================================================
rewrite_each([], _Prefix, _AppPrefix, _Exclude, _Overrides, Acc) ->
{ok, lists:reverse(Acc)};
rewrite_each([Term | Rest], Prefix, AppPrefix, Exclude, Overrides, Acc) ->
case rewrite_term(Term, Prefix, AppPrefix, Exclude, Overrides) of
skip ->
rewrite_each(Rest, Prefix, AppPrefix, Exclude, Overrides, Acc);
{ok, Rewritten} ->
rewrite_each(Rest, Prefix, AppPrefix, Exclude, Overrides,
[Rewritten | Acc]);
{error, _} = Err -> Err
end.
rewrite_term({mapping, ConfKey, AppKey, Opts}, Prefix, AppPrefix, Exclude, Overrides) ->
case excluded(ConfKey, Exclude) of
true ->
skip;
false ->
NewOpts = apply_overrides(ConfKey,
rewrite_mapping_opts(Opts, Prefix),
Overrides),
{ok, {mapping,
join_dot(Prefix, ConfKey),
join_dot(AppPrefix, AppKey),
NewOpts}}
end;
rewrite_term({validator, _, _, _} = V, _Prefix, _AppPrefix, _Exclude, _Overrides) ->
{ok, V};
rewrite_term({validator, _, _, _, _} = V, _Prefix, _AppPrefix, _Exclude, _Overrides) ->
{ok, V};
rewrite_term({partial_translation, BareKey, Fun}, Prefix, AppPrefix, Exclude, _Overrides) ->
case excluded(BareKey, Exclude) of
true ->
skip;
false ->
case erlang:fun_info(Fun, arity) of
{arity, 3} ->
{ok, {translation,
join_dot(AppPrefix, BareKey),
bind_translation(Fun, Prefix, AppPrefix)}};
{arity, Other} ->
{error, {partial_translation_bad_arity, BareKey, Other}}
end
end.
%% For each override targeting this mapping's bare conf key, prepend
%% its option tuple to Opts. Proplist lookup returns the first match,
%% so prepended overrides win over the partial's defaults.
apply_overrides(ConfKey, Opts, Overrides) ->
ExtraOpts = [Opt || {Name, Opt} <- Overrides, Name =:= ConfKey],
ExtraOpts ++ Opts.
%% Emit the historical "<prefix> = <atom>" disable shortcut so each
%% consumer doesn't have to spell out the same guard mapping plus
%% translation. The translation accepts only the specified atom and
%% produces an empty proplist; any other value is rejected with the
%% standard `cuttlefish:invalid` message.
disable_with_terms(Prefix, AppPrefix, Atom) ->
[{mapping, Prefix, AppPrefix,
[{datatype, {enum, [Atom]}}]},
{translation, AppPrefix,
fun(Conf) ->
%% Guard equality against captured `Atom` (a pattern would
%% shadow it).
case cuttlefish:conf_get(Prefix, Conf, undefined) of
V when V =:= Atom -> [];
undefined -> cuttlefish:unset();
_ -> cuttlefish:invalid("Invalid " ++ Prefix)
end
end}].
%% `exclude` matches the bare key exactly OR matches the first dotted
%% segment of a mapping's bare conf key. A bare key with no dots is
%% both "exact" and "section" so this collapses to a single rule.
excluded(BareKey, Exclude) ->
lists:any(fun(E) -> matches_name_or_section(E, BareKey) end, Exclude).
matches_name_or_section(E, E) -> true;
matches_name_or_section(E, BareKey) ->
lists:prefix(E ++ ".", BareKey).
rewrite_mapping_opts(Opts, Prefix) ->
[rewrite_one_opt(Opt, Prefix) || Opt <- Opts].
rewrite_one_opt({see, List}, Prefix) when is_list(List) ->
{see, [rewrite_see_entry(E, Prefix) || E <- List]};
rewrite_one_opt(Other, _Prefix) ->
Other.
%% Bare names get the prefix prepended; dotted entries pass through
%% as absolute references. Either way, the result is tokenized so
%% that downstream `cuttlefish_mapping:see/1` returns the same
%% `[variable()]` shape it returns for `@see` comment annotations.
rewrite_see_entry(Entry, Prefix) when is_list(Entry) ->
Absolute = case lists:member($., Entry) of
true -> Entry;
false -> join_dot(Prefix, Entry)
end,
cuttlefish_variable:tokenize(Absolute);
rewrite_see_entry(Other, _Prefix) ->
Other.
join_dot([], Tail) -> Tail;
join_dot(Prefix, Tail) -> Prefix ++ "." ++ Tail.