///|
/// Host controls are separate from the existing workspace wire request.
pub(all) struct ConfigurationOptions {
directory : @lander.FileTreePath
selected : String?
profiles : @lander.ProfileSelection
isolated : Bool
suppress : Array[String]
external_files : Map[String, String]
platform : @lander.Platform
}
///|
pub fn ConfigurationOptions::new(
directory? : @lander.FileTreePath,
selected? : String,
profiles~ : @lander.ProfileSelection = Unspecified,
isolated~ : Bool = false,
suppress~ : Array[String] = [],
external_files~ : Map[String, String] = Map([]),
platform~ : @lander.Platform = Linux,
) -> ConfigurationOptions {
let directory = directory.unwrap_or(
@lander.FileTreePath::parse("/__morphir_workspace") catch {
_ => abort("workspace path")
},
)
{
directory,
selected,
profiles,
isolated,
suppress: suppress.copy(),
external_files: external_files.copy(),
platform,
}
}
///|
let configuration_groups : Array[Array[String]] = [
["json", "json5"],
["yaml", "yml"],
["toml"],
]
///|
/// Morphir owns names and policy. All directory discovery and ambiguity checks
/// are performed by the published conventions and candidate source APIs.
pub fn configuration_conventions(
boundary? : String,
) -> @conventions.Conventions raise @lander.ConfigError {
let groups = configuration_groups.mapi((index, extensions) => {
let extensions = extensions.map(@conventions.Extension::parse)
if index == 0 {
@conventions.FormatGroup::new(
extensions,
supplemental=@conventions.SupplementalFile::new(
"morphir-workspace.json",
@conventions.Extension::parse("json"),
),
)
} else {
@conventions.FormatGroup::new(extensions)
}
})
let result = @conventions.app_dirs("morphir", display="Morphir")
|> @conventions.without(LocalOverrides)
|> @conventions.format_layers(@conventions.FormatLayers::new(groups))
|> @conventions.root_policy(
WorkspaceFirst(@lander.KeyPath::parse("workspace")),
)
|> @conventions.selected_file_policy(Overlay)
match boundary {
Some(path) => result |> @conventions.boundary(path)
None => result
}
}
///|
priv struct ConfigurationParsers {
values : Map[String, @lander.ConfigValue]
manifests : Map[String, Bool]
notes : Array[String]
}
///|
fn ConfigurationParsers::new() -> ConfigurationParsers {
{ values: Map([]), manifests: Map([]), notes: [], }
}
///|
fn manifest_error(path : String, detail : String) -> @lander.ConfigError {
@lander.Parse(
format="Morphir JSON",
origin=path,
message=detail,
line=None,
column=None,
)
}
///|
fn ConfigurationParsers::manifest(
self : ConfigurationParsers,
value : @lander.ConfigValue,
path : String,
) -> @lander.ConfigValue raise @lander.ConfigError {
let json = @ion_json.to_json(value.to_ion()) catch {
_ => raise manifest_error(path, "Expected JSON configuration values")
}
guard json is Object(original) else {
raise manifest_error(path, "Expected configuration object")
}
let fields = original.copy()
let legacy = ["name", "sourceDirectory", "exposedModules"].any(key => {
fields.contains(key)
})
if legacy {
let project = match fields.get("project") {
None => Map([])
Some(Object(project)) => project.copy()
_ => raise manifest_error(path, "project must be an object")
}
for
(old, canonical) in [
("name", "name"),
("sourceDirectory", "source_directory"),
("exposedModules", "exposed_modules"),
] {
if fields.contains(old) && project.contains(canonical) {
raise manifest_error(path, old + " conflicts with project." + canonical)
}
}
let name = match fields.get("name") {
None => "user"
Some(String(text)) => text
_ => raise manifest_error(path, "name must be a string")
}
ignore(@ids.SourcePackageName::parse(name)) catch {
_ => raise manifest_error(path, "name must be a valid package name")
}
let source = match fields.get("sourceDirectory") {
None => "src"
Some(String(text)) => text
_ => raise manifest_error(path, "sourceDirectory must be a string")
}
guard fields.get("exposedModules") is Some(Array(exposed)) else {
raise manifest_error(
path, "exposedModules must be a list of module names",
)
}
for item in exposed {
guard item is String(text) else {
raise manifest_error(
path, "exposedModules must be a list of module names",
)
}
ignore(@ids.SourceModuleName::parse(text)) catch {
_ =>
raise manifest_error(
path, "exposedModules must contain valid module names",
)
}
}
if !project.contains("name") {
project["name"] = Json::string(name)
}
if !project.contains("source_directory") {
project["source_directory"] = Json::string(source)
}
if !project.contains("exposed_modules") {
project["exposed_modules"] = Json::array(exposed)
}
fields["project"] = Json::object(project)
let frontend = match fields.get("frontend") {
None => Map([])
Some(Object(frontend)) => frontend.copy()
_ => raise manifest_error(path, "frontend must be an object")
}
if !frontend.contains("language") {
frontend["language"] = Json::string("elm")
}
fields["frontend"] = Json::object(frontend)
for name in ["name", "sourceDirectory", "exposedModules"] {
fields.remove(name)
}
self.manifests[path] = true
}
for key in ["dependencies", "localDependencies", "decorations"] {
let present = match fields.get(key) {
None | Some(Null) => false
Some(Array(items)) => !items.is_empty()
Some(Object(items)) => !items.is_empty()
_ => true
}
if present {
self.notes.push(
"Ignoring morphir.json " + key + " (not supported): " + path,
)
}
fields.remove(key)
}
@lander.ConfigValue::from_json(Json::object(fields), origin=path)
}
///|
fn ConfigurationParsers::formats(
self : ConfigurationParsers,
) -> Array[@lander.Format] {
[@json5.format(), @yaml.format(), @toml_format.format()].map(format => {
..format,
parse: (text, path) => {
if self.values.get(path) is Some(value) {
return value
}
let value = (format.parse)(text, path)
let value = if format.name == "JSON5" &&
!path.has_suffix("morphir-workspace.json") {
self.manifest(value, path)
} else {
value
}
self.values[path] = value
value
},
})
}
///|
pub fn configuration_formats() -> Array[@lander.Format] {
ConfigurationParsers::new().formats()
}
///|
fn configuration_path(
directory : @lander.FileTreePath,
path : String,
) -> @lander.FileTreePath raise @lander.ConfigError {
if path == "" {
directory
} else {
directory.join(path)
}
}
///|
fn config_tree(
tree : Tree,
options : ConfigurationOptions,
) -> @lander.FileTree raise @lander.ConfigError {
let files : Map[@lander.FileTreePath, String] = Map([])
for path, text in tree.files {
files[configuration_path(options.directory, path)] = text
}
for path, text in options.external_files {
files[@lander.FileTreePath::parse(path)] = text
}
let listings : Map[@lander.FileTreePath, Array[@lander.DirEntry]] = Map([])
let add = fn(path : @lander.FileTreePath, kind : @lander.EntryKind) {
if path.parent() is Some(parent) && path.name() is Some(name) {
let entries = listings.get(parent).unwrap_or([])
if !entries.any(entry => entry.name == name) {
entries.push({ name, kind, })
}
listings[parent] = entries
}
}
for path, _ in files {
add(path, File)
let mut parent = path.parent()
while parent is Some(directory) {
add(directory, Directory)
parent = directory.parent()
}
}
for directory in tree.directories {
let path = configuration_path(options.directory, directory)
add(path, Directory)
if !listings.contains(path) {
listings[path] = []
}
}
let mut result = @lander.FileTree::new(complete=true)
for path, text in files {
result = result.with_file(path, text)
}
for path, entries in listings {
result = result.with_listing(path, entries)
}
result
}
///|
fn relative_origin(path : String, directory : @lander.FileTreePath) -> String {
let prefix = directory.to_string() + "/"
if path.has_prefix(prefix) {
path[prefix.length():].to_owned()
} else {
path
}
}
///|
priv struct ConfigurationFile {
path : String
value : @lander.ConfigValue
role : @lander.FileRole
}
///|
fn location_files(
context : @lander.LoadContext,
directory : @lander.FileTreePath,
parsers : ConfigurationParsers,
isolated : Bool,
) -> Array[ConfigurationFile] raise @lander.ConfigError {
let conventions = configuration_conventions(boundary=directory.to_string())
|> @conventions.without(SystemDir)
|> @conventions.without(UserConfigDir)
|> @conventions.without(AppHome)
let builder = context.formats.fold(
init=@lander.builder()
|> @lander.working_dir(directory.to_string())
|> @lander.platform(context.platform.unwrap_or(Linux))
|> @lander.env_vars(context.env_vars),
(builder, format) => builder |> @lander.format(format),
)
|> @conventions.add(
conventions,
options=@conventions.DiscoveryOptions::new(isolated~),
)
guard @lander.load(builder, context.tree) is Done(resolved) else {
raise manifest_error(
directory.to_string(),
"Incomplete configuration snapshot",
)
}
resolved
.reports()
.filter_map(report => {
if report.status is Loaded &&
report.path is Some(path) &&
report.kind is File(role) &&
parsers.values.get(path) is Some(value) {
Some({ path, value, role, })
} else {
None
}
})
}
///|
fn machine_files(
context : @lander.LoadContext,
parsers : ConfigurationParsers,
isolated : Bool,
) -> Array[ConfigurationFile] raise @lander.ConfigError {
if isolated {
return []
}
let builder = context.formats.fold(
init=@lander.builder()
|> @lander.working_dir(context.working_dir.unwrap().to_string())
|> @lander.platform(context.platform.unwrap_or(Linux))
|> @lander.env_vars(context.env_vars),
(builder, format) => builder |> @lander.format(format),
)
|> @conventions.add(
configuration_conventions(),
options=@conventions.DiscoveryOptions::new(config=Skip),
)
guard @lander.load(builder, context.tree) is Done(resolved) else {
raise manifest_error("host", "Incomplete host configuration snapshot")
}
resolved
.reports()
.filter_map(report => {
if report.status is Loaded &&
report.path is Some(path) &&
report.kind is File(role) &&
parsers.values.get(path) is Some(value) {
Some({ path, value, role, })
} else {
None
}
})
}
///|
fn source(
file : ConfigurationFile,
role : @lander.FileRole,
priority : Int32,
exclude : Array[String],
) -> @lander.ConfigSource raise @lander.ConfigError {
@lander.ConfigSource::parsed_file(file.path, file.value, role~, priority~).exclude(
exclude.map(@lander.KeyPath::parse),
warn=role is Member,
)
}
///|
fn profile_stems(
files : Array[ConfigurationFile],
directory : @lander.FileTreePath,
) -> Array[String] {
let stems = []
for file in files {
if !(file.role is Workspace) {
continue
}
let name = if file.path.has_suffix("morphir-workspace.json") {
file.path[:file.path.length() - "morphir-workspace.json".length()].to_owned() +
"morphir"
} else {
file.path[:file.path.length() -
file.path.split(".").last().unwrap().length() -
1].to_owned()
}
if !stems.contains(name) {
stems.push(name)
}
}
if stems.is_empty() {
stems.push(directory.to_string() + "/morphir")
}
stems
}
///|
priv struct ConfigurationResult {
document : @config.Document
primary : @config.Document
anchor : String
warnings : Array[String]
}
///|
fn resolve_configuration(
tree : Tree,
root : String,
member_path : String?,
options : ConfigurationOptions,
system : @config.Document,
global : @config.Document,
environment : Map[String, String],
overlay : @config.Document,
) -> ConfigurationResult raise @lander.ConfigError {
let parsers = ConfigurationParsers::new()
let directory = configuration_path(options.directory, root)
let context : @lander.LoadContext = {
tree: config_tree(tree, options),
working_dir: Some(directory),
platform: Some(options.platform),
env_vars: environment,
formats: parsers.formats(),
}
let primaries = location_files(context, directory, parsers, options.isolated)
let member_directory = member_path.map(path => {
configuration_path(options.directory, path)
})
let member_files = match member_directory {
Some(path) => location_files(context, path, parsers, options.isolated)
None => []
}
let machine = machine_files(context, parsers, options.isolated)
let base = primaries.filter(file => file.role is Workspace)
let own_primary = if member_path is Some(_) {
member_files.filter(file => file.role is Workspace)
} else {
base
}
let primary = @config.from_framework(
@lander.build(own_primary.map(file => source(file, Workspace, 300, []))),
)
let variable = environment
.get("MORPHIR_PROFILES")
.map(value => ("MORPHIR_PROFILES", value))
let selection = @profiles.SelectionInput::new(options.profiles, variable?)
let controls = if selection.disabled() {
[]
} else {
base.map(file => {
@profiles.ControlLayer::new(file.value, {
kind: File(Workspace),
source: file.path,
priority: 300,
location: Some(file.path),
})
})
}
let mut resolution = @profiles.resolve_layers(
@profiles.Policy::new(),
selection,
@profiles.ControlLayers::new(controls),
)
let selected = options.selected.map(path => {
let parsed = @lander.FileTreePath::parse(path)
if parsed.is_absolute() {
parsed.to_string()
} else {
directory.resolve(parsed).to_string()
}
})
if selected is Some(path) && base.any(file => file.path == path) {
resolution = resolution.with_control_authority({
kind: File(Explicit),
source: path,
priority: 325,
location: Some(path),
})
}
let defaults = @toml_format.parse(
"[workspace]\nout_dir = '.morphir/out'\n[frontend]\nlanguage = 'scheme'\n[project]\nsource_directory = 'src'\n[ir]\nformat_version = 4\nlayout = 'single-file'\nformat = 'json'\n",
origin="defaults",
)
let sources = [
@lander.ConfigSource::defaults_value(defaults, name="defaults", priority=0),
]
let own = if member_path is Some(_) { member_files } else { primaries }
if own
.iter()
.find_first(file => {
file.role is Workspace && parsers.manifests.contains(file.path)
})
is Some(manifest) {
sources.push(
@lander.ConfigSource::defaults(
{
"pipeline": {
"backend": "checkpoint",
"checkpoint_format": "morphir-json",
},
},
name=manifest.path,
priority=1,
),
)
}
if !options.isolated {
for
(document, role, priority, name) in [
(system, @lander.FileRole::System, 100, "system"),
(global, Global, 200, "global"),
] {
sources.append(
document
.sources(role, priority, name)
.map(source => {
source.exclude(
[
@lander.KeyPath::parse("project"),
@lander.KeyPath::parse("workspace"),
],
warn=false,
)
}),
)
}
}
for file in machine {
if selected == Some(file.path) {
continue
}
sources.push(
source(file, file.role, if file.role is System { 100 } else { 200 }, [
"project", "workspace",
]),
)
}
let inherited = if member_path is Some(_) {
["project", "workspace"]
} else {
[]
}
let scoped = ["workspace.out_dir", "workspace.output_dir"]
for file in base {
if selected != Some(file.path) {
sources.push(source(file, Workspace, 300, inherited))
}
}
if selected is Some(path) {
let value = match context.tree.file(@lander.FileTreePath::parse(path)) {
Found(text) => @lander.parse_file(context.formats, path, text)
_ => raise @lander.FileNotFound(path~)
}
let as_member = member_files.any(file => file.path == path)
sources.push(
source(
{ path, value, role: Explicit, },
Explicit,
325,
if as_member {
scoped
} else {
inherited
},
),
)
}
for file in member_files {
if file.role is Workspace && selected != Some(file.path) {
sources.push(source(file, Member, 350, scoped))
}
}
if !resolution.disabled() {
let root_stems = profile_stems(primaries, directory)
let member_stems = member_directory
.map(path => profile_stems(member_files, path))
.unwrap_or([])
for name in resolution.active().names() {
for (stems, filters) in [(root_stems, inherited), (member_stems, scoped)] {
for group in configuration_groups {
let paths = stems
.map(stem => {
group.map(extension => {
stem + ".profile." + name.to_string() + "." + extension
})
})
.flatten()
if !paths.is_empty() {
sources.push(
@lander.ConfigSource::candidates(
paths,
display="Morphir profile " + name.to_string(),
role=ProfileFile(name),
priority=380,
).exclude(
[
..filters, "workspace.root", "workspace.members", "workspace.exclude",
"workspace.default_member",
].map(@lander.KeyPath::parse),
warn=false,
),
)
}
}
}
}
}
for (files, filters) in [(primaries, inherited), (member_files, scoped)] {
for file in files {
if file.role is UserOverride && selected != Some(file.path) {
sources.push(source(file, UserOverride, 400, filters))
}
}
}
sources.push(
@lander.ConfigSource::env(
prefix="MORPHIR",
reserved=["MORPHIR_HOME", "MORPHIR_PROFILES"],
selection=Explicit(environment),
priority=600,
),
)
sources.push(
@lander.ConfigSource::overrides_value(
overlay.value(),
name="CLI",
priority=700,
),
)
let builder = context.formats.fold(
init=@lander.builder() |> @lander.working_dir(directory.to_string()),
(builder, format) => builder |> @lander.format(format),
)
|> @lander.provider({
name: "Morphir configuration",
expand: _ => Done(@lander.Expansion::new(sources, profiles=resolution)),
})
guard @lander.load(builder, context.tree) is Done(configuration) else {
raise manifest_error(
directory.to_string(),
"Incomplete configuration snapshot",
)
}
let document = @config.from_framework(configuration)
for path, origin in document.provenance {
document.provenance[path] = relative_origin(origin, options.directory)
}
let warnings = parsers.notes.map(note => {
note.replace_all(old=options.directory.to_string() + "/", new="")
})
let suppressed = options.suppress.copy()
for
value in environment
.get("MORPHIR_DIAGNOSTICS__SUPPRESS")
.unwrap_or("")
.split(",") {
if value.trim() != "" {
suppressed.push(value.trim().to_owned())
}
}
let host_values = @config.from_framework(
@lander.build(
machine.map(file => {
source(file, file.role, if file.role is System { 100 } else { 200 }, [])
}),
),
)
for host in [system, global, host_values] {
if host.get("diagnostics.suppress") is Some(_) {
let codes : Array[String] = @lander.bind(
host.framework(),
"diagnostics.suppress",
)
suppressed.append(codes)
}
}
for code in suppressed {
if !["config.yaml_beside_toml", "config.json_keys_below_toml"].contains(
code,
) &&
!warnings.contains("Unknown configuration warning code: " + code) {
warnings.push("Unknown configuration warning code: " + code)
}
}
for files in [primaries, member_files] {
let base = files.filter(file => file.role is Workspace)
let tomls = base.filter(file => file.path.has_suffix(".toml"))
if tomls.is_empty() {
continue
}
if !suppressed.contains("config.yaml_beside_toml") {
for
yaml in base.filter(file => {
file.path.has_suffix(".yaml") || file.path.has_suffix(".yml")
}) {
warnings.push(
"config.yaml_beside_toml: " +
relative_origin(yaml.path, options.directory) +
" beside " +
relative_origin(tomls[0].path, options.directory),
)
}
}
if !suppressed.contains("config.json_keys_below_toml") {
let merged = @lander.build(
base.map(file => source(file, Workspace, 300, [])),
)
for
json in base.filter(file => {
file.path.has_suffix(".json") || file.path.has_suffix(".json5")
}) {
let keys = merged
.provenance()
.filter_map(pair => {
if !pair.0.to_string().has_prefix("profiles.") &&
pair.1.last().map(origin => origin.location) ==
Some(Some(json.path)) {
Some(pair.0.to_string())
} else {
None
}
})
if !keys.is_empty() {
warnings.push(
"config.json_keys_below_toml: " +
relative_origin(json.path, options.directory) +
" supplies " +
keys.join(", "),
)
}
}
}
}
let anchor = own
.filter(file => file.role is Workspace)
.last()
.map(file => relative_origin(file.path, options.directory))
.unwrap_or("")
{ document, primary, anchor, warnings, }
}