///|
pub(all) suberror Invalid {
Invalid(String)
}
///|
/// Values and merge decisions belong to Moon-Lander. The origin projection keeps
/// Morphir's existing path strings at command and Workbench boundaries.
pub(all) struct Document {
priv configuration : @lander.Config
provenance : Map[String, String]
}
///|
pub fn from_framework(configuration : @lander.Config) -> Document {
let provenance = Map([])
for (key, origins) in configuration.provenance() {
if origins.last() is Some(origin) {
provenance[key.to_string()] = origin.location.unwrap_or(origin.source)
}
}
{ configuration, provenance, }
}
///|
pub fn Document::framework(self : Document) -> @lander.Config {
self.configuration
}
///|
/// Project already resolved host documents into framework file sources without
/// losing a winning leaf's original file. No merge decision is repeated here.
pub fn Document::sources(
self : Document,
role : @lander.FileRole,
priority : Int32,
fallback : String,
) -> Array[@lander.ConfigSource] raise @lander.ConfigError {
let groups : Map[String, Array[(String, @lander.ConfigValue)]] = Map([])
for (key, _) in self.configuration.provenance() {
if self.configuration.get(key) is Some(value) {
let path = self.provenance.get(key.to_string()).unwrap_or(fallback)
let pairs = groups.get(path).unwrap_or([])
pairs.push((key.to_string(), value))
groups[path] = pairs
}
}
fn empty_tables(
value : @ion.Ion,
names : Array[String],
) -> Unit raise @lander.ConfigError {
if value.kind() is Struct(fields) {
if fields.is_empty() && !names.is_empty() {
let pairs = groups.get(fallback).unwrap_or([])
pairs.push(
(@lander.KeyPath::from_names(names).to_string(), @lander.fields([])),
)
groups[fallback] = pairs
} else {
for field in fields {
empty_tables(field.value(), [..names, field.name().text().unwrap()])
}
}
}
}
empty_tables(self.value().to_ion(), [])
let names = groups.keys().collect()
names.sort()
names.map(path => {
let value = @lander.build([@lander.ConfigSource::overrides(groups[path])]).value()
@lander.ConfigSource::parsed_file(path, value, role~, priority~)
})
}
///|
pub fn Document::value(self : Document) -> @lander.ConfigValue {
self.configuration.value()
}
///|
pub fn parse(text : String, origin~ : String = "") -> Document raise {
from_framework(
@lander.build([
@lander.ConfigSource::parsed_file(
origin,
@lander_toml.parse(text, origin~),
),
]),
) catch {
error => raise Invalid(error.message())
}
}
///|
pub fn empty() -> Document {
from_framework(@lander.build([])) catch {
_ => abort("empty configuration")
}
}
///|
pub fn from_json(
value : Json,
origin~ : String = "",
) -> Document raise {
from_framework(
@lander.build([
@lander.ConfigSource::parsed_file(
origin,
@lander.ConfigValue::from_json(value, origin~),
),
]),
) catch {
error => raise Invalid(error.message())
}
}
///|
pub fn Document::get(self : Document, path : String) -> @ion.Ion? {
let key = @lander.KeyPath::parse(path) catch { _ => return None }
self.configuration.get(key).map(value => value.to_ion())
}
///|
pub fn Document::json(self : Document, path : String) -> Json? raise {
let value = if path == "" {
Some(self.value().to_ion())
} else {
self.get(path)
}
value.map(value => @ion_json.to_json(value)) catch {
error =>
raise Invalid(
"Cannot represent configuration as JSON at " +
path +
": " +
error.message(),
)
}
}
///|
pub fn Document::text(
self : Document,
path : String,
fallback : String,
) -> String raise {
match self.get(path) {
None => fallback
Some(value) =>
match value.kind() {
String(text) => text
_ => raise Invalid("Expected string at " + path)
}
}
}
///|
pub fn Document::strings(self : Document, path : String) -> Array[String] raise {
match self.get(path) {
None => []
Some(value) =>
match value.kind() {
List(items) =>
items.map(item => {
match item.kind() {
String(text) => text
_ => raise Invalid("Expected string array at " + path)
}
})
_ => raise Invalid("Expected array at " + path)
}
}
}
///|
pub fn Document::boolean(
self : Document,
path : String,
fallback : Bool,
) -> Bool raise {
match self.get(path) {
None => fallback
Some(value) =>
match value.kind() {
Bool(flag) => flag
_ => raise Invalid("Expected boolean at " + path)
}
}
}
///|
/// Compatibility composition delegates all value and provenance decisions to
/// the framework, then projects the winning source back to its original label.
pub fn merge(base : Document, overlay : Document) -> Document raise {
let resolved = @lander.build([
@lander.ConfigSource::parsed_file("base", base.value(), priority=0),
@lander.ConfigSource::parsed_file("overlay", overlay.value(), priority=1),
]) catch {
error => raise Invalid(error.message())
}
let document = from_framework(resolved)
for (key, origins) in resolved.provenance() {
let path = key.to_string()
let source = if origins.last().map(origin => origin.location) ==
Some(Some("overlay")) {
overlay
} else {
base
}
if source.provenance.get(path) is Some(origin) {
document.provenance[path] = origin
}
}
document
}
///|
pub fn environment(values : Map[String, String]) -> Document raise {
from_framework(
@lander.build([
@lander.ConfigSource::env(
prefix="MORPHIR",
reserved=["MORPHIR_HOME", "MORPHIR_PROFILES"],
selection=Explicit(values),
),
]),
) catch {
error => raise Invalid(error.message())
}
}
///|
pub fn Document::shared(self : Document) -> Document raise {
let source = @lander.ConfigSource::parsed_file("inherited", self.value()).exclude(
[@lander.KeyPath::parse("project"), @lander.KeyPath::parse("workspace")],
warn=false,
)
let result = from_framework(@lander.build([source]))
for path, _ in result.provenance {
if self.provenance.get(path) is Some(origin) {
result.provenance[path] = origin
}
}
result
}
///|
/// TOML remains the only written format. This is a representation adapter for
/// the writer, not a configuration parser or merge engine.
fn written_value(value : @ion.Ion) -> @toml.TomlValue raise {
match value.kind() {
String(text) =>
if value
.annotations()
.any(annotation => annotation.text() == Some("toml_local_time")) {
match @toml.parse("value = " + text) {
TomlTable(fields) => fields["value"]
_ => raise Invalid("Invalid TOML local time")
}
} else {
@toml.TomlString(text)
}
Int(number) => {
let narrowed = number.to_int64()
guard narrowed.to_string() == number.to_string() else {
raise Invalid("Integer is outside the TOML range")
}
@toml.TomlInteger(narrowed)
}
Float(number) => @toml.TomlFloat(number)
Bool(flag) => @toml.TomlBoolean(flag)
List(items) => @toml.TomlArray(items.map(written_value))
Struct(fields) => {
let result = Map([])
for field in fields {
result[field.name().text().unwrap()] = written_value(field.value())
}
@toml.TomlTable(result)
}
Timestamp(_) =>
match @toml.parse("value = " + @ion_text.write_ion(value)) {
TomlTable(fields) => fields["value"]
_ => raise Invalid("Invalid TOML timestamp")
}
_ => raise Invalid("Configuration value cannot be written as TOML")
}
}
///|
pub fn Document::encode(self : Document) -> String raise {
written_value(self.value().to_ion()).to_string()
}
///|
pub fn Document::validate(self : Document) -> Unit raise {
for
section in [
"workspace", "project", "frontend", "ir", "pipeline", "backends",
] {
match self.get(section) {
None => ()
Some(value) if value.kind() is Struct(_) => ()
_ => raise Invalid("Expected table at " + section)
}
}
for
path in [
"workspace.name", "workspace.root", "workspace.out_dir", "workspace.default_member",
"project.name", "project.version", "project.source_directory", "project.module_prefix",
"frontend.language", "frontend.provider", "frontend.profile", "ir.layout",
"ir.format", "ir.mode", "pipeline.backend", "pipeline.checkpoint_format", "pipeline.inference",
] {
ignore(self.text(path, ""))
}
for
path in [
"workspace.members", "workspace.exclude", "project.exposed_modules", "project.authors",
] {
ignore(self.strings(path))
}
ignore(self.boolean("ir.strict_mode", false))
ignore(self.boolean("pipeline.strict_naming", false))
ignore(self.metadata_provider_request())
}