///|
/// The operating system of a run.
pub(all) enum Platform {
Linux
MacOs
Windows
} derive(Eq, Debug)
///|
pub extend Platform with Eq::{equal, not_equal}
///|
pub extend Platform with @debug.Debug::{to_repr}
///|
/// The inputs that a source provider gets during a load. `platform` is `None`
/// when no host gave it, and `env_vars` is empty when the builder holds no
/// variable set. `working_dir` is the parsed working directory of the builder.
pub(all) struct LoadContext {
tree : FileTree
platform : Platform?
env_vars : Map[String, String]
working_dir : FileTreePath?
formats : Array[Format]
}
///|
/// The sources that a provider gives, in merge order, and its warnings.
pub struct Expansion {
priv sources : Array[ConfigSource]
priv warnings : Array[ConfigWarning]
priv profiles : ProfileResolution?
}
///|
/// Provider output. Omit `profiles` for an ordinary source provider.
pub fn Expansion::new(
sources : Array[ConfigSource],
warnings? : Array[ConfigWarning] = [],
profiles? : ProfileResolution,
) -> Expansion {
{ sources: sources.copy(), warnings: warnings.copy(), profiles }
}
///|
/// A copy of this provider's sources, for a provider decorator.
pub fn Expansion::sources(self : Expansion) -> Array[ConfigSource] { self.sources.copy() }
///|
/// A copy of this provider's warnings, for a provider decorator.
pub fn Expansion::warnings(self : Expansion) -> Array[ConfigWarning] { self.warnings.copy() }
///|
/// The immutable optional resolution, for a provider decorator.
pub fn Expansion::profiles(self : Expansion) -> ProfileResolution? { self.profiles }
///|
/// A step of the builder that gives sources during the load. `expand` gives
/// the file requests that it still needs, or its sources. With a complete tree
/// it must give its sources.
pub(all) struct SourceProvider {
name : String
expand : (LoadContext) -> LoadStep[Expansion] raise ConfigError
}
///|
/// Load the configuration from the builder and the file tree. This is pure: it
/// reads only its inputs and gives the same result on every target.
///
/// It runs each provider at its position, then checks the priorities over the
/// expanded list and raises `SourceOrder` when a priority decreases. When a
/// provider or a file source needs a path that the tree does not know, it
/// gives all such requests in one `Need`. Otherwise it parses, excludes, and
/// merges the sources in list order and gives `Done`.
pub fn load(
builder : ConfigBuilder,
tree : FileTree,
) -> LoadStep[Config] raise ConfigError {
let working_dir = match builder.working_dir {
Some(text) => Some(input_path(text, builder.platform))
None => None
}
let context : LoadContext = {
tree,
platform: builder.platform,
env_vars: builder.env_vars.map(vars => vars.copy()).unwrap_or(Map([])),
working_dir,
formats: builder.formats(),
}
let sources : Array[ConfigSource] = []
let notes : Array[ConfigWarning] = []
let needs : Array[FileRequest] = []
let mut expanded = true
let mut profiles : ProfileState = Off
let mut profile_provider : String? = None
for entry in builder.entries {
match entry {
Source(source) => sources.push(source)
Provider(p) =>
match (p.expand)(context) {
Need(requests) => {
add_needs(needs, requests)
expanded = false
}
Done(expansion) => {
if expansion.profiles is Some(resolution) {
if profile_provider is Some(previous) {
raise Profile(failure=ProfileFailure::new(p.name, CompetingProviders(previous, p.name)))
}
profile_provider = Some(p.name)
profiles = Resolved(resolution)
}
sources.append(expansion.sources)
notes.append(expansion.warnings)
}
}
}
}
let priorities = if expanded { source_priorities(sources) } else { [] }
let env_given = builder.env_vars is Some(_)
let results = sources.map(source => resolve(source, context, needs, env_given))
guard expanded && needs.is_empty() else { return Need(needs) }
let secret_syntax = builder.secret_syntax
let mut root = fields([])
let provenance : Map[KeyPath, Array[Origin]] = Map([])
let reports : Array[SourceReport] = []
for index, source in sources {
// A source gives `None` only with a request, and no request is open.
guard results[index] is Some(result) else { continue }
let origin : Origin = {
kind: source.kind,
source: source.name,
priority: priorities[index],
location: result.path,
}
let warnings : Array[ConfigWarning] = []
let pieces = result.pieces.copy()
// Profile controls are reserved across the whole pipeline. Check each raw
// source before application filters can remove a forbidden subtree.
if profiles is Resolved(resolution) {
for piece_index, piece in pieces {
let key = KeyPath::parse("profiles")
if piece.0.at(key.names) is Some(_) {
let piece_origin = { ..origin, location: piece.1 }
let authoritative = resolution.permits_controls(piece_origin)
if !resolution.disabled() && !authoritative {
raise Profile(failure=ProfileFailure::new("profiles", ForbiddenLocation, origin=Control(piece_origin, key)))
}
if remove_key(piece.0, key.names) is Some(rest) {
pieces[piece_index] = (rest, piece.1)
}
}
}
}
for excluded in source.excluded {
let (key, warn) = excluded
let mut removed : Origin? = None
for piece_index, piece in pieces {
if remove_key(piece.0, key.names) is Some(rest) {
pieces[piece_index] = (rest, piece.1)
if removed is None {
removed = Some({ ..origin, location: piece.1 })
}
}
}
if warn && removed is Some(removed_origin) {
warnings.push(KeyRemoved(key, removed_origin))
}
}
// Each piece has its own location, for example the variable of an
// environment source.
for piece in pieces {
let piece_origin = piece.1.unwrap_or(source.name)
check_secret_payloads(piece.0, piece_origin, secret_syntax)
let previous = root
root = merge(
root,
piece.0,
policy=source.policy,
key_policies=source.key_policies,
source=source.name,
secret_syntax=secret_syntax,
)
record_origins(
provenance,
piece.0,
{ ..origin, location: piece.1 },
source_policy=source.policy,
key_policies=source.key_policies,
secret_syntax=secret_syntax,
previous=previous,
)
}
reports.push({
kind: source.kind,
name: source.name,
priority: priorities[index],
path: result.path,
candidates: result.candidates,
status: result.status,
warnings,
})
}
// The configuration holds no null field, and a record only for each leaf.
root = without_nulls(root)
let leaf_origins : Map[KeyPath, Array[Origin]] = Map([])
for leaf in leaves(root, secret_syntax=secret_syntax) {
if provenance.get(leaf.0) is Some(origins) {
leaf_origins[leaf.0] = origins
}
}
Done({ root, reports, notes, provenance: leaf_origins, secret_syntax, profiles })
}
///|
/// What a load resolved for one source.
priv struct Resolved {
/// The trees of the source in merge order, each with its location.
pieces : Array[(ConfigValue, String?)]
path : String?
candidates : Array[String]
status : SourceStatus
}
///|
/// Resolve the input of `source` over the tree. Give `None` and add to
/// `needs` when the tree does not know a path that the source needs.
fn resolve(
source : ConfigSource,
context : LoadContext,
needs : Array[FileRequest],
env_given : Bool,
) -> Resolved? raise ConfigError {
match source.input {
Value(value) =>
Some({ pieces: [(value, None)], path: None, candidates: [], status: Loaded })
NoInput => Some({ pieces: [], path: None, candidates: [], status: Skipped })
Parsed(given, value) => {
let path = source_path(context, given).to_string()
Some({ pieces: [(value, Some(path))], path: Some(path), candidates: [], status: Loaded })
}
Env(selection, prefix, reserved) =>
match selection {
Skip => Some({ pieces: [], path: None, candidates: [], status: Skipped })
Explicit(vars) =>
Some({
pieces: env_pieces(vars, prefix, reserved),
path: None,
candidates: [],
status: Loaded,
})
Discover if env_given =>
Some({
pieces: env_pieces(context.env_vars, prefix, reserved),
path: None,
candidates: [],
status: Loaded,
})
Discover =>
Some({ pieces: [], path: None, candidates: [], status: NotFound })
}
Path(given) => {
let file = source_path(context, given)
let path = file.to_string()
let format = select_format(context.formats, path)
match context.tree.file(file) {
Found(text) =>
Some({
pieces: [(parse_with(format, path, text), Some(path))],
path: Some(path),
candidates: [],
status: Loaded,
})
Missing => raise FileNotFound(path~)
Unknown => {
add_needs(needs, [ReadFile(file)])
None
}
}
}
Candidates(given, display) => {
let paths : Array[FileTreePath] = []
for text in given {
let path = source_path(context, text)
if !paths.contains(path) {
paths.push(path)
}
}
let candidates = paths.map(path => path.to_string())
let existing : Array[FileTreePath] = []
let mut unknown = false
for candidate in paths {
match context.tree.is_file(candidate) {
Found(true) => existing.push(candidate)
Found(false) | Missing => ()
Unknown => {
add_needs(needs, [context.tree.file_request(candidate)])
unknown = true
}
}
}
// The host answers for each candidate first, so the error of an
// inaccessible candidate passes out even when two others exist.
guard !unknown else { return None }
if existing.length() > 1 {
raise Ambiguous(
display~,
location=FileTreePath::common_dir(paths)
.map(dir => dir.to_string())
.unwrap_or("."),
candidates=existing.map(path => path.to_string()),
)
}
guard existing is [file] else {
return Some({ pieces: [], path: None, candidates, status: NotFound })
}
let path = file.to_string()
let format = select_format(context.formats, path)
match context.tree.file(file) {
Found(text) =>
Some({
pieces: [(parse_with(format, path, text), Some(path))],
path: Some(path),
candidates,
status: Loaded,
})
Missing => raise FileNotFound(path~)
Unknown => {
add_needs(needs, [ReadFile(file)])
None
}
}
}
}
}
///|
fn parse_with(
format : Format,
path : String,
text : String,
) -> ConfigValue raise ConfigError {
let value = (format.parse)(text, path)
value.check_root(path)
value
}
///|
/// The display priority of each source. A source with no priority takes the
/// default of its kind and role. An in-memory source with no priority takes
/// the priority of the source before it, or 0 when it is first. A priority
/// lower than the one before it raises `SourceOrder`.
fn source_priorities(
sources : Array[ConfigSource],
) -> Array[Int] raise ConfigError {
let priorities = []
for index, source in sources {
let previous = if index > 0 { Some(priorities[index - 1]) } else { None }
let priority = match (source.priority, source.kind) {
(Some(priority), _) => priority
(None, InMemory) => previous.unwrap_or(0)
(None, kind) => default_priority(kind)
}
if previous is Some(previous_priority) && priority < previous_priority {
raise SourceOrder(
source=source.name,
priority~,
previous=sources[index - 1].name,
previous_priority~,
)
}
priorities.push(priority)
}
priorities
}
///|
/// The default display priority of a kind and role. An in-memory source has
/// no default; it follows the source before it.
fn default_priority(kind : SourceKind) -> Int {
match kind {
Defaults | InMemory => 0
File(System) => 100
File(Global) => 200
File(Workspace | Explicit | Legacy) => 300
File(Member) => 350
File(EnvironmentFile) => 375
File(ProfileFile(_)) => 380
File(LocalOverride) => 390
File(UserOverride) => 400
Environment => 600
Overrides => 700
}
}
///|
/// Add each request of `requests` that `needs` does not hold yet.
fn add_needs(needs : Array[FileRequest], requests : Array[FileRequest]) -> Unit {
for request in requests {
if !needs.contains(request) {
needs.push(request)
}
}
}
///|
/// The parsed form of the path text `text`. With a platform, the text is in
/// the native form of that platform, so `\` separators work on Windows.
fn input_path(
text : String,
platform : Platform?,
) -> FileTreePath raise ConfigError {
match platform {
Some(platform) => FileTreePath::from_native(text, platform)
None => FileTreePath::parse(text)
}
}
///|
/// The path of the source text `text`: parsed one time, and below the working
/// directory when it is relative.
fn source_path(
context : LoadContext,
text : String,
) -> FileTreePath raise ConfigError {
let path = input_path(text, context.platform)
match context.working_dir {
Some(dir) => dir.resolve(path)
None => path
}
}