///|
/// A nonempty file extension, without a dot or path separator.
pub struct Extension {
priv text : String
} derive(Eq, Hash, Debug)
///|
pub extend Extension with Eq::{equal, not_equal}
///|
pub extend Extension with Hash::{hash, hash_combine}
///|
pub extend Extension with @debug.Debug::{to_repr}
///|
/// Parse ASCII letters, digits, underscores and hyphens. Case is preserved
/// and must match a registered format's extension.
pub fn Extension::parse(text : String) -> Extension raise @config.ConfigError {
guard !text.is_empty() else {
raise @config.ConventionPolicy(input=text, rule=EmptyExtension)
}
guard text.iter().all(char => char.is_ascii_alphabetic() || char.is_ascii_digit() || char is ('_' | '-')) else {
raise @config.ConventionPolicy(input=text, rule=ExtensionCharacters)
}
{ text, }
}
///|
/// The extension without a dot.
pub fn Extension::to_string(self : Extension) -> String { self.text }
///|
/// One application-owned source below the ordinary primary of its format
/// group. Its transform must be pure. Discovery reuses its parsed value for
/// classification and the final source, rather than running it twice.
pub struct SupplementalFile {
priv name : String
priv extension : Extension
priv transform : (@config.ConfigValue) -> @config.ConfigValue raise @config.ConfigError
}
///|
/// Parse one file name with a declared extension. Paths, . and .. are refused.
pub fn SupplementalFile::new(
name : String,
extension : Extension,
transform? : (@config.ConfigValue) -> @config.ConfigValue raise @config.ConfigError = value => value,
) -> SupplementalFile raise @config.ConfigError {
guard !name.is_empty() && name != "." && name != ".." &&
!name.iter().any(char => char is ('/' | '\\' | '\u{0}')) else {
raise @config.ConventionPolicy(input=name, rule=SupplementalName)
}
guard name.has_suffix("." + extension.text) else {
raise @config.ConventionPolicy(input=name, rule=SupplementalExtension)
}
{ name, extension, transform }
}
///|
/// The validated file name.
pub fn SupplementalFile::name(self : SupplementalFile) -> String { self.name }
///|
/// Alternatives at one precedence step. At most one ordinary candidate may
/// exist across these extensions and layouts. A supplemental source is below it.
pub struct FormatGroup {
priv extensions : Array[Extension]
priv supplemental : SupplementalFile?
}
///|
/// Make a nonempty group with unique extensions. Its optional supplemental
/// source must use one of those extensions.
pub fn FormatGroup::new(
extensions : Array[Extension],
supplemental? : SupplementalFile,
) -> FormatGroup raise @config.ConfigError {
guard !extensions.is_empty() else {
raise @config.ConventionPolicy(input="format group", rule=EmptyFormatGroup)
}
let seen : @hashset.HashSet[Extension] = @hashset.HashSet([])
for extension in extensions {
if seen.contains(extension) {
raise @config.ConventionPolicy(input=extension.text, rule=DuplicateExtension)
}
seen.add(extension)
}
if supplemental is Some(file) && !seen.contains(file.extension) {
raise @config.ConventionPolicy(input=file.name, rule=SupplementalOutsideGroup)
}
{ extensions: extensions.copy(), supplemental }
}
///|
/// A copy of the alternatives in this group.
pub fn FormatGroup::extensions(self : FormatGroup) -> Array[Extension] {
self.extensions.copy()
}
///|
/// Explicit precedence order with each extension in one group. Later groups
/// win shared keys. Parser registration order does not select precedence.
pub struct FormatLayers {
priv groups : Array[FormatGroup]
}
///|
/// Make a nonempty order. Repeated extensions, also across groups, are refused.
pub fn FormatLayers::new(groups : Array[FormatGroup]) -> FormatLayers raise @config.ConfigError {
guard !groups.is_empty() else {
raise @config.ConventionPolicy(input="format order", rule=EmptyFormatOrder)
}
let seen : @hashset.HashSet[Extension] = @hashset.HashSet([])
for group in groups {
for extension in group.extensions {
if seen.contains(extension) {
raise @config.ConventionPolicy(input=extension.text, rule=DuplicateExtension)
}
seen.add(extension)
}
}
{ groups: groups.copy() }
}
///|
/// Select ordered format layers. Omitting this step keeps the existing
/// cross-format ambiguity rule. Every extension must have a registered parser.
pub fn format_layers(conventions : Conventions, layers : FormatLayers) -> Conventions {
{ ..conventions, layers: Some(layers) }
}
///|
/// Check final parser availability before the discovery pass requests data.
fn Conventions::check_formats(self : Conventions, formats : Array[@config.Format]) -> Unit raise @config.ConfigError {
guard self.layers is Some(layers) else { return }
let registered = searched_extensions(formats)
for group in layers.groups {
if group.supplemental is Some(file) &&
(self.has(Layouts) || self.has(SystemDir) || self.has(UserConfigDir) || self.has(AppHome)) &&
file.name == "\{self.name}.\{file.extension.text}" {
raise @config.ConventionPolicy(input=file.name, rule=SupplementalPrimaryCollision)
}
for extension in group.extensions {
if !registered.contains(extension.text) {
raise @config.ConventionPolicy(input=extension.text, rule=UnregisteredExtension)
}
}
}
}