///|
/// One file of a workbench project, with its path relative to the project root.
priv struct ProjectFile {
path : @ids.SourcePath
content : String
}
///|
/// A compile request after parsing at the worker boundary. A `source` request becomes a project of one file.
priv struct ProjectRequest {
name : @ids.SourcePackageName
language : @ids.LanguageId
provider : @ids.ProviderId?
profile : @ids.ProfileId?
files : Array[ProjectFile]
exposed : Array[@ids.SourceModuleName]
}
///|
fn optional_text(value : Json, key : String) -> String? raise {
match @wire.optional(value, key) {
None => None
Some(String(text)) => Some(text)
_ =>
raise @wire.Invalid(
"workbench.invalid_request",
key + " must be a string",
)
}
}
///|
/// A package name for a generated morphir.toml: letters, digits, `_`, `.`, `/` and `-` only.
fn package_name(text : String) -> @ids.SourcePackageName raise {
let safe = text
.to_array()
.all(c => {
(c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '_' ||
c == '.' ||
c == '/' ||
c == '-'
})
if !safe {
raise @wire.Invalid(
"workbench.invalid_package",
"Invalid package name: " + text,
)
}
@ids.SourcePackageName::parse(text)
}
///|
/// The module name in an Elm module header (`module A.B exposing (..)`, `port module` or `effect module`). It skips
/// white space, `--` comments and nested `{- -}` comments before the header.
fn elm_module_name(source : String) -> String? {
let chars = source.to_array()
let n = chars.length()
let words : Array[String] = []
let mut i = 0
let mut depth = 0
while i < n && words.length() < 3 {
let c = chars[i]
let next = if i + 1 < n { Some(chars[i + 1]) } else { None }
if depth > 0 {
if c == '-' && next == Some('}') {
depth -= 1
i += 2
} else if c == '{' && next == Some('-') {
depth += 1
i += 2
} else {
i += 1
}
} else if c == '{' && next == Some('-') {
depth = 1
i += 2
} else if c == '-' && next == Some('-') {
while i < n && chars[i] != '\n' {
i += 1
}
} else if c == ' ' || c == '\t' || c == '\r' || c == '\n' {
i += 1
} else {
let word = StringBuilder()
while i < n &&
(
(chars[i] >= 'a' && chars[i] <= 'z') ||
(chars[i] >= 'A' && chars[i] <= 'Z') ||
(chars[i] >= '0' && chars[i] <= '9') ||
chars[i] == '_' ||
chars[i] == '.'
) {
word.write_char(chars[i])
i += 1
}
if word.is_empty() {
return None
}
words.push(word.to_string())
}
}
match words {
["module", name, ..] => Some(name)
["port" | "effect", "module", name] => Some(name)
_ => None
}
}
///|
/// The one-module Elm project of the single editor: the header `A.B.C` gives package `A.B` and module `C`.
fn elm_shorthand(
source : String,
) -> (@ids.SourcePackageName, ProjectFile, @ids.SourceModuleName) raise {
let fix = "Name the module Package.Module, for example `module Workbench.Main exposing (..)`"
guard elm_module_name(source) is Some(name) else {
raise @wire.Invalid(
"elm.missing_module_header",
"The Elm source has no module header. " + fix,
)
}
let segments = name.split(".").map(StringView::to_owned).collect()
guard segments.length() >= 2 else {
raise @wire.Invalid(
"elm.module_needs_package",
"The module " + name + " has no package. " + fix,
)
}
let pkg_name = segments[:segments.length() - 1].to_owned().join(".")
(
package_name(pkg_name),
{
path: @ids.SourcePath::parse("src/" + segments.join("/") + ".elm"),
content: source,
},
@ids.SourceModuleName::parse(segments[segments.length() - 1]),
)
}
///|
fn project_request(
request : Json,
offers : Array[@engine.LanguageOffer],
) -> ProjectRequest raise {
let project = @wire.optional(request, "project")
let fields = project.unwrap_or(request)
let language = @ids.LanguageId::parse(
optional_text(fields, "languageId").unwrap_or("scheme"),
)
let offer = offers.filter(o => o.language == language)
guard offer is [_, ..] else {
raise @wire.Invalid(
"workbench.unavailable_language",
"This local input language is unavailable: " + language.to_string(),
)
}
let provider = match optional_text(fields, "frontendProvider") {
Some(text) => Some(@ids.ProviderId::parse(text))
None => None
}
let profile = match optional_text(fields, "frontendProfile") {
Some(text) => Some(@ids.ProfileId::parse(text))
None => None
}
match project {
Some(_) => {
let files = @wire.array(@wire.field(fields, "files")).map(file => {
(
{
path: @ids.SourcePath::parse(
@wire.string(@wire.field(file, "path")),
),
content: @wire.string(@wire.field(file, "content")),
} : ProjectFile)
})
let exposed = match @wire.optional(fields, "exposedModules") {
Some(Array(items)) =>
items.map(item => @ids.SourceModuleName::parse(@wire.string(item)))
_ => []
}
{
name: package_name(@wire.string(@wire.field(fields, "name"))),
language,
provider,
profile,
files,
exposed,
}
}
None => {
let source = @wire.string(@wire.field(request, "source"))
if language.to_string() == "elm" {
let (name, file, module_name) = elm_shorthand(source)
{
name,
language,
provider,
profile,
files: [file],
exposed: [module_name],
}
} else {
// The requested provider's offer gives the extension. A provider no offer has falls to the engine's
// `frontend.unknown_provider` refusal.
let chosen = match provider {
Some(wanted) => offer.search_by(o => o.provider == wanted)
None => None
}
let extension = offer[chosen.unwrap_or(0)].extensions
.get(0)
.map(e => e.to_string())
.unwrap_or("")
{
name: package_name("user"),
language,
provider,
profile,
files: [
{
path: @ids.SourcePath::parse("src/main" + extension),
content: source,
},
],
exposed: [],
}
}
}
}
}
///|
/// A TOML basic string for any text: `\`, `"` and every control character are escaped, so the text cannot end
/// the string or start a new key.
fn toml_string(text : String) -> String {
let out = StringBuilder()
out.write_char('"')
for c in text {
match c {
'\\' => out.write_string("\\\\")
'"' => out.write_string("\\\"")
_ =>
if c.to_int() < 0x20 || c.to_int() == 0x7F {
let hex = c.to_int().to_string(radix=16)
out.write_string("\\u" + "0000"[:4 - hex.length()].to_owned() + hex)
} else {
out.write_char(c)
}
}
}
out.write_char('"')
out.to_string()
}
///|
/// The morphir.toml of a project without a manifest: its name, the `src` source directory, its exposed modules
/// (whole-project languages) and its language.
fn manifest(project : ProjectRequest) -> String {
let text = StringBuilder()
text.write_string(
"[project]\nname = " +
toml_string(project.name.to_string()) +
"\nsource_directory = \"src\"\n",
)
if !project.exposed.is_empty() {
text.write_string(
"exposed_modules = [" +
project.exposed.map(m => toml_string(m.to_string())).join(", ") +
"]\n",
)
}
text.write_string(
"[frontend]\nlanguage = " + toml_string(project.language.to_string()) + "\n",
)
text.to_string()
}
///|
fn project_tree(project : ProjectRequest) -> @workspace.Tree {
let files : Map[String, String] = Map([])
for file in project.files {
files[file.path.to_string()] = file.content
}
if !files.contains("morphir.json") && !files.contains("morphir.toml") {
files["morphir.toml"] = manifest(project)
}
let directories = [""]
for path, _ in files {
let parts = path.split("/").map(StringView::to_owned).collect()
for i in 1.. @config.Document raise {
let text = StringBuilder()
text.write_string(
"[frontend]\nlanguage = " + toml_string(project.language.to_string()) + "\n",
)
if project.provider is Some(provider) {
text.write_string("provider = " + toml_string(provider.to_string()) + "\n")
}
if project.profile is Some(profile) {
text.write_string("profile = " + toml_string(profile.to_string()) + "\n")
}
text.write_string(
"[pipeline]\nbackend = " +
toml_string(backend) +
"\nvalidation = \"source-only\"\ncheckpoint_format = \"ion-binary\"\n",
)
text.to_string() |> @config.parse()
}