///|
/// A canonical project root relative to the workspace. The empty path identifies the root project.
#derive(Eq, Compare, Hash, Debug)
pub struct ProjectPath(String)
///|
pub extend ProjectPath with Eq::{equal, not_equal}
///|
pub extend ProjectPath with Compare::{op_lt, op_le, op_ge, compare, op_gt}
///|
pub extend ProjectPath with Hash::{hash, hash_combine}
///|
pub extend ProjectPath with @debug.Debug::{to_repr}
///|
pub fn ProjectPath::parse(path : String) -> ProjectPath raise {
ProjectPath(normalize(path))
}
///|
pub impl Show for ProjectPath with fn output(self, logger) {
logger.write_string(self.0)
}
///|
pub extend ProjectPath with Show::{to_string, output}
///|
pub(all) struct NewProject {
name : @ids.SourcePackageName
language : @ids.LanguageId?
profile : @ids.ProfileId?
source_directory : String
}
///|
/// A pure edit. Deletes identify subtrees, and are only included when explicitly requested.
pub(all) struct Edit {
writes : Map[String, String]
directories : Array[String]
deletes : Array[String]
warnings : Array[String]
}
///|
/// Infer directories from canonical file paths, for memory workspaces and browser imports.
pub fn Tree::from_files(files : Map[String, String]) -> Tree raise {
let directories : Map[String, Bool] = Map::from_array([("", true)])
for path, _ in files {
if path == "" || normalize(path) != path {
raise @config.Invalid("Expected a canonical file path: " + path)
}
let mut directory = parent(path)
while directory != "" {
if files.contains(directory) {
raise @config.Invalid("File is also a directory: " + directory)
}
directories[directory] = true
directory = parent(directory)
}
}
let paths = directories.keys().collect()
paths.sort()
{ files: files.copy(), directories: paths, }
}
///|
pub fn Edit::apply(self : Edit, tree : Tree) -> Tree raise {
let deletes = self.deletes.map(normalize)
if deletes.contains("") {
raise @config.Invalid("Cannot delete the workspace root")
}
let files = tree.files.filter(fn(path, _) {
!deletes.any(d => within(d, path))
})
for path, text in self.writes {
files[path] = text
}
let result = Tree::from_files(files)
let directories = result.directories.copy()
for directory in tree.directories + self.directories {
let directory = normalize(directory)
if !deletes.any(d => within(d, directory)) &&
!directories.contains(directory) {
if files.contains(directory) {
raise @config.Invalid("File is also a directory: " + directory)
}
directories.push(directory)
}
}
directories.sort()
{ files: result.files, directories, }
}
///|
fn quoted(text : String) -> String {
Json::string(text).stringify()
}
///|
fn edit_toml(
text : String,
edit : (@toml_edit.Document) -> @toml_edit.Document raise,
) -> String raise {
edit(@toml_edit.parse(text)).encode() catch {
@toml_edit.Diagnostic(_, message, position) =>
raise @config.Invalid(
message +
" at " +
position.line.to_string() +
":" +
position.column.to_string(),
)
error => raise error
}
}
///|
fn new_project_files(
path : String,
project : NewProject,
language : String,
checkpoint~ : Bool = true,
) -> Map[String, String] raise {
let source = normalize(project.source_directory)
if source == "" {
raise @config.Invalid("Source directory must be nonempty")
}
let (filename, starter) = match language {
"scheme" => ("main.scm", "(define (identity value) value)\n")
"elm" => {
let module_prefix = project.name.to_string().replace_all(old="/", new=".")
if !module_prefix
.split(".")
.all(part => {
let chars = part.to_array()
!chars.is_empty() &&
chars[0] >= 'A' &&
chars[0] <= 'Z' &&
chars.all(c => {
(c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') ||
c == '_'
})
}) {
raise @config.Invalid(
"An Elm project name must use capitalized module segments, for example My.Project",
)
}
(
module_prefix.replace_all(old=".", new="/") + "/Main.elm",
"module " +
module_prefix +
".Main exposing (identity)\n\nidentity value =\n value\n",
)
}
"moonbit" =>
("main.mbt", "pub fn identity(value : Int) -> Int {\n value\n}\n")
_ => raise @config.Invalid("No project starter for language: " + language)
}
let mut config = "[project]\nname = " +
quoted(project.name.to_string()) +
"\nsource_directory = " +
quoted(source) +
"\n"
if language == "elm" {
config += "exposed_modules = [\"Main\"]\n"
}
if project.language is Some(language) {
config += "\n[frontend]\nlanguage = " + quoted(language.to_string()) + "\n"
}
if project.profile is Some(profile) {
if project.language is None {
config += "\n[frontend]\n"
}
config += "profile = " + quoted(profile.to_string()) + "\n"
}
if checkpoint && (language == "elm" || language == "moonbit") {
config += "\n[pipeline]\nbackend = \"checkpoint\"\ncheckpoint_format = \"ion-binary\"\n"
}
Map::from_array([
(join(path, "morphir.toml"), config),
(join(join(path, source), filename), starter),
])
}
///|
fn ensure_new_config(tree : Tree, path : String) -> Unit raise {
let candidates = primary_paths(path)
if candidates.any(p => tree.files.contains(p)) {
raise @config.Invalid("Project configuration already exists: " + path)
}
}
///|
pub fn init_workspace(
tree : Tree,
project~ : NewProject? = None,
members~ : Array[String] = [],
) -> Edit raise {
ensure_new_config(tree, "")
for pattern in members {
ignore(matches(pattern, ""))
}
let config = "[workspace]\nmembers = [" +
members.map(quoted).join(", ") +
"]\n"
let writes = match project {
Some(project) =>
new_project_files(
"",
project,
project.language.map(l => l.to_string()).unwrap_or("scheme"),
)
None => Map([])
}
writes["morphir.toml"] = config +
"\n" +
writes.get("morphir.toml").unwrap_or("")
let edit : Edit = { writes, directories: [], deletes: [], warnings: [], }
// A starter never overwrites an existing source file.
for path, _ in writes {
if tree.files.contains(path) || tree.directories.contains(path) {
raise @config.Invalid("File already exists: " + path)
}
}
ignore(edit.apply(tree))
edit
}
///|
/// Keep the primary layout and create its TOML counterpart beside lower formats.
pub fn toml_path(anchor : String) -> String raise {
let anchor = normalize(anchor)
if anchor == "" {
return "morphir.toml"
}
if anchor.has_suffix(".toml") {
return anchor
}
if anchor.has_suffix("morphir-workspace.json") {
return anchor[:anchor.length() - "morphir-workspace.json".length()].to_owned() +
"morphir.toml"
}
let extension = anchor.split(".").last().unwrap().length()
anchor[:anchor.length() - extension].to_owned() + "toml"
}
///|
fn workspace_edit_config(
workspace : Workspace,
tree : Tree,
) -> (String, String) raise {
if workspace.primary.get("workspace") is None {
raise @config.Invalid("Project changes require a workspace section")
}
let target = toml_path(workspace.anchor)
let text = tree.files.get(target).unwrap_or("[workspace]\n")
let text = if @config.parse(text).get("workspace") is None {
edit_toml(text, doc => doc.add_table(["workspace"]))
} else {
text
}
(target, text)
}
///|
fn workspace_primary_paths(workspace : Workspace) -> Array[String] raise {
// Hidden layouts contain ordinary primary names, so match the longest
// known suffix when finding the discovery root of a nested snapshot.
let names = primary_paths("")
names.sort_by((left, right) => right.length() - left.length())
let mut root = ""
for name in names {
if workspace.anchor == name {
break
}
if workspace.anchor.has_suffix("/" + name) {
root = workspace.anchor[:workspace.anchor.length() - name.length() - 1].to_owned()
break
}
}
primary_paths(root)
}
///|
fn lower_root_project(workspace : Workspace, tree : Tree) -> String? raise {
let formats = configuration_formats()
for path in workspace_primary_paths(workspace) {
if path.has_suffix(".toml") {
continue
}
if tree.files.get(path) is Some(text) {
let parsed = @lander.parse_file(formats, path, text)
let document = @lander.build([
@lander.ConfigSource::overrides_value(parsed, name=path),
])
if document.get(@lander.KeyPath::parse("project")) is Some(_) {
return Some(path)
}
}
}
None
}
///|
pub fn add_project(
workspace : Workspace,
tree : Tree,
path : ProjectPath,
project : NewProject,
) -> Edit raise {
let (target, config) = workspace_edit_config(workspace, tree)
let relative = path.to_string()
let root = join(workspace.root, relative)
if workspace.projects.any(p => p.root == root) {
raise @config.Invalid("Project already exists: " + relative)
}
let excludes = workspace.document.strings("workspace.exclude")
if excludes.any(pattern => matches(normalize(pattern), relative)) {
raise @config.Invalid(
"Project is excluded by workspace configuration: " + relative,
)
}
let language = project.language
.map(l => l.to_string())
.unwrap_or(workspace.document.text("frontend.language", "scheme"))
let writes = new_project_files(
root,
project,
language,
checkpoint=workspace.document.get("pipeline.backend") is None,
)
if relative == "" {
// The virtual workspace's existing config remains the source of shared defaults.
let project_config = @config.parse(writes[join(root, "morphir.toml")])
let has_frontend = @config.parse(config).get("frontend") is Some(_)
let has_pipeline = @config.parse(config).get("pipeline") is Some(_)
let updated = edit_toml(config, fn(doc) raise {
let mut doc = doc.add_table(["project"])
for key in ["name", "source_directory", "exposed_modules"] {
if project_config.json("project." + key) is Some(value) {
doc = doc.set(["project", key], value.stringify())
}
}
if !has_frontend &&
(project.language is Some(_) || project.profile is Some(_)) {
doc = doc.add_table(["frontend"])
}
if project.language is Some(language) {
doc = doc.set(["frontend", "language"], quoted(language.to_string()))
}
if project.profile is Some(profile) {
doc = doc.set(["frontend", "profile"], quoted(profile.to_string()))
}
if project_config.get("pipeline") is Some(_) {
if !has_pipeline {
doc = doc.add_table(["pipeline"])
}
doc = doc.set(["pipeline", "backend"], "\"checkpoint\"")
doc = doc.set(["pipeline", "checkpoint_format"], "\"ion-binary\"")
}
doc
})
writes.remove(join(root, "morphir.toml"))
writes[target] = updated
} else {
ensure_new_config(tree, root)
let members = workspace.document.strings("workspace.members")
if !members.any(pattern => matches(normalize(pattern), relative)) {
writes[target] = edit_toml(config, fn(doc) raise {
if doc.get(["workspace", "members"]) is Some(_) &&
@config.parse(config).strings("workspace.members") == members {
doc.append(["workspace", "members"], quoted(relative))
} else {
doc.set(
["workspace", "members"],
Json::array([..members, relative].map(Json::string)).stringify(),
)
}
})
}
}
for file, _ in writes {
if file != target &&
(tree.files.contains(file) || tree.directories.contains(file)) {
raise @config.Invalid("File already exists: " + file)
}
}
let warnings = if workspace.projects.any(p => {
p.name == project.name.to_string()
}) {
["Duplicate project name: " + project.name.to_string()]
} else {
[]
}
let edit : Edit = {
writes,
directories: [join(root, project.source_directory)],
deletes: [],
warnings,
}
ignore(edit.apply(tree))
edit
}
///|
pub fn remove_project(
workspace : Workspace,
tree : Tree,
path : ProjectPath,
delete_files~ : Bool = false,
) -> Edit raise {
let relative = path.to_string()
if relative == "" && lower_root_project(workspace, tree) is Some(source) {
raise @config.Invalid(
"Cannot remove root project declared in " +
source +
"; edit that file to remove its project declaration",
)
}
let (target, config) = workspace_edit_config(workspace, tree)
let root = join(workspace.root, relative)
if !workspace.projects.any(p => p.root == root) {
raise @config.Invalid("Unknown project: " + relative)
}
if relative == "" && delete_files {
raise @config.Invalid("Cannot delete the workspace root")
}
if delete_files &&
workspace.projects.any(p => p.root != root && within(root, p.root)) {
raise @config.Invalid(
"Remove nested projects before deleting their containing project",
)
}
let members = workspace.document.strings("workspace.members")
let written = @config.parse(config)
let excludes = workspace.document.strings("workspace.exclude")
let lower_primary = workspace_primary_paths(workspace).any(path => {
path != target && tree.files.contains(path)
})
let updated = edit_toml(config, fn(doc) raise {
let mut doc = doc
if relative == "" {
doc = doc.remove_table(["project"])
} else {
if written.strings("workspace.members") == members &&
written.get("workspace.members") is Some(_) {
for i = members.length() - 1; i >= 0; i = i - 1 {
if normalize(members[i]) == relative {
doc = doc.remove_item(["workspace", "members"], i)
}
}
} else {
doc = doc.set(
["workspace", "members"],
Json(members.filter(pattern => normalize(pattern) != relative)).stringify(),
)
}
if members.any(pattern => {
normalize(pattern) != relative &&
matches(normalize(pattern), relative)
}) {
doc = if written.get("workspace.exclude") is Some(_) &&
written.strings("workspace.exclude") == excludes {
doc.append(["workspace", "exclude"], quoted(relative))
} else {
doc.set(
["workspace", "exclude"],
Json([..excludes, relative]).stringify(),
)
}
}
}
if normalize(workspace.document.text("workspace.default_member", "")) ==
relative {
doc = if lower_primary {
// Empty is the existing representation for no default selection.
doc.set(["workspace", "default_member"], "\"\"")
} else if doc.get(["workspace", "default_member"]) is Some(_) {
doc.remove(["workspace", "default_member"])
} else {
doc
}
}
doc
})
{
writes: Map::from_array([(target, updated)]),
directories: [],
deletes: if delete_files {
[root]
} else {
[]
},
warnings: [],
}
}