Current section

Files

Jump to
caffeine_lang src caffeine_lang parser expectations.gleam
Raw

src/caffeine_lang/parser/expectations.gleam

import caffeine_lang/common/decoders
import caffeine_lang/common/errors.{type CompilationError, ParserDuplicateError}
import caffeine_lang/common/helpers
import caffeine_lang/common/validations
import caffeine_lang/middle_end/semantic_analyzer.{
type IntermediateRepresentation,
}
import caffeine_lang/parser/blueprints.{type Blueprint}
import gleam/dict
import gleam/dynamic.{type Dynamic}
import gleam/dynamic/decode
import gleam/json
import gleam/list
import gleam/option
import gleam/result
import gleam/string
pub type Expectation {
Expectation(
name: String,
blueprint_ref: String,
inputs: dict.Dict(String, Dynamic),
)
}
/// Parse expectations from a file, leveraging the given file path for metadata extraction.
pub fn parse_from_file(
file_path: String,
blueprints: List(Blueprint),
) -> Result(List(IntermediateRepresentation), CompilationError) {
use json_string <- result.try(helpers.json_from_file(file_path))
parse_from_string(json_string, file_path, blueprints)
}
/// Parse expectations from a JSON string with a given path for metadata extraction.
/// This is public so it can be used by browser.gleam for in-browser compilation.
/// Furthermore, internally we use this as the base from which parse_from_file also
/// uses to parse.
pub fn parse_from_string(
json_string: String,
file_path: String,
blueprints: List(Blueprint),
) -> Result(List(IntermediateRepresentation), CompilationError) {
// actually parse
use expectations <- result.try(
case expectations_from_json(json_string, blueprints) {
Ok(expectations) -> Ok(expectations)
Error(err) -> Error(errors.format_json_decode_error(err))
},
)
validate_and_build_irs(expectations, blueprints, file_path)
}
/// Validate expectations and build intermediate representations.
fn validate_and_build_irs(
expectations: List(Expectation),
blueprints: List(Blueprint),
file_path: String,
) -> Result(List(IntermediateRepresentation), CompilationError) {
// map expectations to blueprints since we'll reuse that numerous times
// and we've already validated all blueprint_refs
let expectations_blueprint_collection =
helpers.map_reference_to_referrer_over_collection(
references: blueprints,
referrers: expectations,
reference_name: fn(b) { b.name },
referrer_reference: fn(e) { e.blueprint_ref },
)
// validate that expectation inputs don't overshadow blueprint inputs
use _ <- result.try(check_input_overshadowing(
expectations_blueprint_collection,
))
// validate that expectation.inputs provides params NOT already provided by blueprint.inputs
use _ <- result.try(
validations.validate_inputs_for_collection(
expectations_blueprint_collection,
fn(expectation) { expectation.inputs },
fn(blueprint) {
let blueprint_input_keys = blueprint.inputs |> dict.keys
blueprint.params
|> dict.filter(fn(key, _) { !list.contains(blueprint_input_keys, key) })
},
),
)
// validate unique names within a file
use _ <- result.try(validations.validate_relevant_uniqueness(
expectations,
fn(e) { e.name },
"expectation names",
))
// at this point we're completely validated, now build IR
build_ir(expectations_blueprint_collection, file_path)
}
@internal
pub fn build_ir(
expectations_blueprint_collection: List(#(Expectation, Blueprint)),
file_path: String,
) {
expectations_blueprint_collection
|> list.map(fn(expectation_and_blueprint_pair) {
let #(expectation, blueprint) = expectation_and_blueprint_pair
// merge blueprint inputs with expectation inputs
// Expectation inputs override blueprint inputs for the same key
let merged_inputs = dict.merge(blueprint.inputs, expectation.inputs)
// Build value tuples from provided inputs
let provided_value_tuples =
merged_inputs
|> dict.keys
|> list.map(fn(label) {
// safe assertions since we're already validated everything
let assert Ok(value) = merged_inputs |> dict.get(label)
let assert Ok(typ) = blueprint.params |> dict.get(label)
helpers.ValueTuple(label:, typ:, value:)
})
// Also include Optional and Defaulted params that weren't provided
// These need to be in value_tuples so the templatizer can resolve them
let unprovided_optional_value_tuples =
blueprint.params
|> dict.to_list
|> list.filter_map(fn(param) {
let #(label, typ) = param
case dict.has_key(merged_inputs, label) {
True -> Error(Nil)
False ->
case typ {
helpers.Optional(_) | helpers.Defaulted(_, _) ->
Ok(helpers.ValueTuple(label:, typ:, value: dynamic.nil()))
_ -> Error(Nil)
}
}
})
let value_tuples =
list.append(provided_value_tuples, unprovided_optional_value_tuples)
// build unique expectation name by combining path prefix with name
let #(org, team, service) = extract_path_prefix(file_path)
let service_name = service
let unique_name = org <> "_" <> service_name <> "_" <> expectation.name
semantic_analyzer.IntermediateRepresentation(
metadata: semantic_analyzer.IntermediateRepresentationMetaData(
friendly_label: expectation.name,
org_name: org,
service_name: service_name,
blueprint_name: blueprint.name,
team_name: team,
),
unique_identifier: unique_name,
artifact_ref: blueprint.artifact_ref,
values: value_tuples,
vendor: option.None,
)
})
|> Ok
}
/// Extract a meaningful prefix from the source path
/// e.g., "examples/org/platform_team/authentication.json" -> #("org", "platform_team", "authentication")
@internal
pub fn extract_path_prefix(path: String) -> #(String, String, String) {
case
path
|> string.split("/")
|> list.reverse
|> list.take(3)
|> list.reverse
|> list.map(fn(segment) {
// Remove .json extension if present
case string.ends_with(segment, ".json") {
True -> string.drop_end(segment, 5)
False -> segment
}
})
{
[org, team, service] -> #(org, team, service)
// this is not actually a possible state, however for pattern matching completeness we
// inlcude it here
_ -> #("unknown", "unknown", "unknown")
}
}
fn check_input_overshadowing(
expectations_blueprint_collection: List(#(Expectation, Blueprint)),
) -> Result(Bool, CompilationError) {
let overshadow_errors =
expectations_blueprint_collection
|> list.filter_map(fn(pair) {
let #(expectation, blueprint) = pair
case
validations.check_collection_key_overshadowing(
expectation.inputs,
blueprint.inputs,
"Expectation '"
<> expectation.name
<> "' overshadowing inputs from blueprint: ",
)
{
Ok(_) -> Error(Nil)
Error(msg) -> Ok(msg)
}
})
|> string.join(", ")
case overshadow_errors {
"" -> Ok(True)
_ -> Error(ParserDuplicateError(msg: overshadow_errors))
}
}
/// Parse expectations from a JSON string.
/// This is public so it can be used by browser.gleam for in-browser compilation.
pub fn expectations_from_json(
json_string: String,
blueprints: List(Blueprint),
) -> Result(List(Expectation), json.DecodeError) {
let expectation_decoded = {
use name <- decode.field("name", decoders.non_empty_string_decoder())
use blueprint_ref <- decode.field(
"blueprint_ref",
decoders.named_reference_decoder(blueprints, fn(b) { b.name }),
)
use inputs <- decode.field(
"inputs",
decode.dict(decode.string, decode.dynamic),
)
decode.success(Expectation(name:, blueprint_ref:, inputs:))
}
let expectations_decoded = {
use expectations <- decode.field(
"expectations",
decode.list(expectation_decoded),
)
decode.success(expectations)
}
json.parse(from: json_string, using: expectations_decoded)
}