///|
/// The dependency usage context recorded in a MoonBit package manifest.
pub(all) enum DependencyScope {
Runtime
Test
WhiteboxTest
Other
} derive(Debug, Eq)
///|
impl ToJson for DependencyScope with fn to_json(self) {
Json::string(dependency_scope_name(self))
}
///|
/// A package import declared in a modern `moon.pkg` manifest.
pub struct PackageImport {
package_name : String
alias_name : String
scope : DependencyScope
source : String
} derive(Debug, Eq, ToJson)
///|
pub fn PackageImport::PackageImport(
package_name~ : String,
alias_name~ : String,
scope~ : DependencyScope,
source~ : String,
) -> PackageImport {
{ package_name, alias_name, scope, source, }
}
///|
priv enum ManifestToken {
Word(String)
Literal(String)
LeftBrace
RightBrace
At
Comma
Newline
Other
}
///|
priv struct LocatedManifestToken {
token : ManifestToken
line : Int32
}
///|
priv struct ManifestEntry {
value : String
alias_name : String
line : Int32
}
///|
priv struct ManifestImportBlock {
entries : Array[ManifestEntry]
scope : String
has_scope : Bool
line : Int32
}
///|
pub(all) suberror ManifestParseError {
InvalidManifest(source~ : String, line~ : Int32, reason~ : String)
} derive(Debug, Eq)
///|
/// Parse direct module dependencies from modern `moon.mod` syntax.
///
/// Versions absent from a declaration are retained as empty so matching reports
/// them as uncomparable instead of silently considering them safe.
pub fn parse_moon_mod(
text : String,
source~ : String,
) -> Array[Dependency] raise {
let blocks = parse_import_blocks(text, source~)
let dependencies : Array[Dependency] = []
for block in blocks {
if block.has_scope {
raise InvalidManifest(
source~,
line=block.line,
reason="module dependencies cannot have a package import scope",
)
}
for entry in block.entries {
if entry.alias_name != "" {
raise InvalidManifest(
source~,
line=entry.line,
reason="module dependencies cannot have a package alias",
)
}
let pieces = entry.value.split("@").to_array()
let name = pieces[0].to_owned()
let version = if pieces.length() > 1 { pieces[1].to_owned() } else { "" }
if pieces.length() > 2 ||
!valid_import_path(name) ||
(pieces.length() == 2 && version == "") {
raise InvalidManifest(
source~,
line=entry.line,
reason="invalid module dependency: \{entry.value}",
)
}
dependencies.push(Dependency(name~, version~, source~))
}
}
dependencies
}
///|
/// Parse package imports, aliases, and test scope from modern `moon.pkg` syntax.
pub fn parse_moon_pkg(
text : String,
source~ : String,
) -> Array[PackageImport] raise {
let blocks = parse_import_blocks(text, source~)
let imports : Array[PackageImport] = []
for block in blocks {
let scope = dependency_scope(block.scope)
for entry in block.entries {
if !valid_import_path(entry.value) {
raise InvalidManifest(
source~,
line=entry.line,
reason="invalid package import: \{entry.value}",
)
}
let alias_name = if entry.alias_name != "" {
entry.alias_name
} else {
entry.value.split("/").to_array().last().unwrap_or("").to_owned()
}
imports.push({ package_name: entry.value, alias_name, scope, source, })
}
}
imports
}
///|
/// Attach package usage scopes to their declared module dependencies.
///
/// Package aliases never affect the canonical module identity. A package import
/// is associated with the longest matching declared module path.
pub fn apply_package_imports(
dependencies : Array[Dependency],
imports : Array[PackageImport],
) -> Array[Dependency] {
dependencies.map(fn(dependency) {
let scopes = merge_scopes(dependency.scopes, [])
let aliases = merge_strings(dependency.aliases, [])
for package_import in imports {
match matching_module(dependencies, package_import.package_name) {
Some(name) if name == dependency.name => {
if !scopes.any(fn(scope) { scope == package_import.scope }) {
scopes.push(package_import.scope)
}
if package_import.alias_name != "" &&
!aliases.contains(package_import.alias_name) {
aliases.push(package_import.alias_name)
}
}
_ => ()
}
}
aliases.sort_by(lexicographic_compare)
Dependency(
name=dependency.name,
version=dependency.version,
source=dependency.source,
aliases~,
scopes=merge_scopes(scopes, []),
)
})
}
///|
pub fn dependency_scope_name(scope : DependencyScope) -> String {
match scope {
Runtime => "runtime"
Test => "test"
WhiteboxTest => "wbtest"
Other => "other"
}
}
///|
fn matching_module(
dependencies : Array[Dependency],
package_name : String,
) -> String? {
let mut best : String? = None
for dependency in dependencies {
let is_match = package_name == dependency.name ||
package_name.has_prefix("\{dependency.name}/")
if is_match {
match best {
Some(name) if name.length() >= dependency.name.length() => ()
_ => best = Some(dependency.name)
}
}
}
best
}
///|
fn dependency_scope(value : String) -> DependencyScope {
match value {
"runtime" => Runtime
"test" => Test
"wbtest" => WhiteboxTest
_ => Other
}
}
///|
fn parse_import_blocks(
text : String,
source~ : String,
) -> Array[ManifestImportBlock] raise ManifestParseError {
let tokens = tokenize_manifest(text, source~)
let blocks : Array[ManifestImportBlock] = []
let mut index = 0
while index < tokens.length() {
match tokens[index].token {
Word(word) if word == "import" => {
let opening = skip_newlines(tokens, index + 1)
if opening < tokens.length() && tokens[opening].token is LeftBrace {
let entries : Array[ManifestEntry] = []
let mut cursor = skip_newlines(tokens, opening + 1)
let mut closed = false
while cursor < tokens.length() && !closed {
match tokens[cursor].token {
RightBrace => {
closed = true
cursor += 1
}
Literal(value) => {
let entry_line = tokens[cursor].line
if value == "" {
raise InvalidManifest(
source~,
line=entry_line,
reason="empty import entry",
)
}
cursor += 1
let after_value = skip_newlines(tokens, cursor)
let alias_name = if after_value < tokens.length() &&
tokens[after_value].token is At {
let alias_index = skip_newlines(tokens, after_value + 1)
match tokens.get(alias_index) {
Some({ token: Word(name), .. }) if valid_import_alias(name) => {
cursor = alias_index + 1
name
}
_ =>
raise manifest_token_error(
tokens,
after_value,
source~,
reason="expected an alias after @",
)
}
} else {
cursor = after_value
""
}
entries.push({ value, alias_name, line: entry_line, })
cursor = skip_newlines(tokens, cursor)
match tokens.get(cursor) {
Some({ token: Comma, .. }) =>
cursor = skip_newlines(tokens, cursor + 1)
Some({ token: RightBrace, .. }) => ()
None => ()
_ =>
raise manifest_token_error(
tokens,
cursor,
source~,
reason="expected a comma or } after import entry",
)
}
}
_ =>
raise manifest_token_error(
tokens,
cursor,
source~,
reason="expected a quoted import entry",
)
}
}
if !closed {
raise InvalidManifest(
source~,
line=tokens[opening].line,
reason="unterminated import block",
)
}
let after_block = skip_newlines(tokens, cursor)
let (scope, has_scope, next_index) = match tokens.get(after_block) {
Some({ token: Word(keyword), .. }) if keyword == "for" => {
let scope_index = skip_newlines(tokens, after_block + 1)
match tokens.get(scope_index) {
Some({ token: Literal(value), .. }) if value != "" =>
(value, true, scope_index + 1)
_ =>
raise manifest_token_error(
tokens,
after_block,
source~,
reason="expected a quoted scope after for",
)
}
}
_ => ("runtime", false, after_block)
}
match tokens.get(next_index) {
Some({ token: Newline, .. }) | None => ()
Some({ token: Word(keyword), .. }) if keyword != "for" => ()
_ =>
raise manifest_token_error(
tokens,
next_index,
source~,
reason="unexpected token after import block",
)
}
blocks.push({ entries, scope, has_scope, line: tokens[index].line, })
index = next_index
} else {
raise manifest_token_error(
tokens,
index,
source~,
reason="expected { after import",
)
}
}
_ => index += 1
}
}
blocks
}
///|
fn skip_newlines(tokens : Array[LocatedManifestToken], start : Int32) -> Int32 {
let mut index = start
while index < tokens.length() && tokens[index].token is Newline {
index += 1
}
index
}
///|
fn tokenize_manifest(
text : String,
source~ : String,
) -> Array[LocatedManifestToken] raise ManifestParseError {
let chars = text.to_array()
let tokens : Array[LocatedManifestToken] = []
let mut index = 0
let mut line = 1
while index < chars.length() {
let char = chars[index]
if char == '/' && index + 1 < chars.length() && chars[index + 1] == '/' {
while index < chars.length() && chars[index] != '\n' {
index += 1
}
} else if char == '/' &&
index + 1 < chars.length() &&
chars[index + 1] == '*' {
index += 2
let mut closed = false
while index + 1 < chars.length() && !closed {
if chars[index] == '*' && chars[index + 1] == '/' {
index += 2
closed = true
} else {
if chars[index] == '\n' {
line += 1
}
index += 1
}
}
if !closed {
raise InvalidManifest(
source~,
line~,
reason="unterminated block comment",
)
}
} else if char == '\n' {
tokens.push({ token: Newline, line, })
line += 1
index += 1
} else if char == '"' {
let string_line = line
index += 1
let value = StringBuilder()
value.write_char('"')
let mut closed = false
while index < chars.length() && !closed {
match chars[index] {
'"' => {
value.write_char('"')
closed = true
index += 1
}
'\\' => {
if index + 1 >= chars.length() {
raise InvalidManifest(
source~,
line~,
reason="unterminated string escape",
)
}
value.write_char('\\')
value.write_char(chars[index + 1])
index += 2
}
'\n' =>
raise InvalidManifest(
source~,
line~,
reason="newline in string literal",
)
other => {
value.write_char(other)
index += 1
}
}
}
if !closed {
raise InvalidManifest(
source~,
line~,
reason="unterminated string literal",
)
}
// Manifest strings use JSON escapes, including Unicode and escaped '/'.
// Decode them once instead of dropping unknown escape characters.
let decoded = @json.parse(value.to_string()) catch {
_ =>
raise InvalidManifest(
source~,
line=string_line,
reason="invalid string literal",
)
}
match decoded {
String(value) =>
tokens.push({ token: Literal(value), line: string_line, })
_ =>
raise InvalidManifest(
source~,
line=string_line,
reason="invalid string literal",
)
}
} else if char == '{' {
tokens.push({ token: LeftBrace, line, })
index += 1
} else if char == '}' {
tokens.push({ token: RightBrace, line, })
index += 1
} else if char == '@' {
tokens.push({ token: At, line, })
index += 1
} else if char == ',' {
tokens.push({ token: Comma, line, })
index += 1
} else if char.is_whitespace() {
index += 1
} else if is_identifier_char(char) {
let word = StringBuilder()
while index < chars.length() && is_identifier_char(chars[index]) {
word.write_char(chars[index])
index += 1
}
tokens.push({ token: Word(word.to_string()), line, })
} else {
tokens.push({ token: Other, line, })
index += 1
}
}
tokens
}
///|
fn is_identifier_char(char : Char) -> Bool {
match char {
'a'..<='z' | 'A'..<='Z' | '0'..<='9' | '_' | '-' => true
_ => false
}
}
///|
fn valid_import_alias(value : String) -> Bool {
let chars = value.to_array()
chars.length() > 0 &&
(chars[0].is_ascii_alphabetic() || chars[0] == '_') &&
chars.all(is_identifier_char)
}
///|
fn valid_import_path(value : String) -> Bool {
value != "" &&
!value
.to_array()
.any(fn(char) { char.is_whitespace() || char == '\\' || char == '@' }) &&
value.split("/").all(fn(part) { part != "" && part != "." && part != ".." })
}
///|
fn manifest_token_error(
tokens : Array[LocatedManifestToken],
index : Int32,
source~ : String,
reason~ : String,
) -> ManifestParseError {
let line = match tokens.get(index) {
Some(token) => token.line
None => 1
}
InvalidManifest(source~, line~, reason~)
}