///|
fn build_path(base : String, child : String) -> String {
let child_path : @path.Path = child
if child_path.is_absolute() {
return child_path.normalize().to_string()
}
let base_path : @path.Path = base
base_path.join(child_path).to_string()
}
///|
fn build_path_contains(parent : String, child : String) -> Bool {
let parent : @path.Path = parent
let child : @path.Path = child
let relative = child.resolve().relative(base=parent.resolve())
let text = relative.to_string()
text == "." ||
(
!relative.is_absolute() &&
text != ".." &&
!text.has_prefix(".." + @path.sep.to_string())
)
}
///|
fn build_check_output_owner(output : String, owner : String) -> Unit raise {
if !@fs.path_exists(output) {
return
}
if !@fs.is_dir(output) {
raise CliError("build output must be a dedicated directory", 2)
}
if @fs.read_dir(output).is_empty() {
return
}
let marker = build_path(output, ".smithy-build-output")
if !@fs.path_exists(marker) || @fs.read_file_to_string(marker) != owner {
raise CliError(
"build output contains files that do not belong to this configuration", 2,
)
}
}
///|
fn build_mkdir(path : String) -> Unit raise {
if @fs.path_exists(path) {
return
}
let current : @path.Path = path
let parent = current.dirname().to_string()
if parent != path && parent != "." && !@fs.path_exists(parent) {
build_mkdir(parent)
}
@fs.create_dir(path)
}
///|
fn build_remove_tree(path : String) -> Unit raise {
// unlink removes files and symlinks without following their targets.
let unlinked = try {
@fs.remove_file(path)
true
} catch {
_ => false
}
if unlinked {
return
}
if !@fs.path_exists(path) {
return
}
// On Windows a directory symlink is removed with rmdir. A real nonempty
// directory fails here and is traversed only after both removal attempts.
let removed_directory = try {
@fs.remove_dir(path)
true
} catch {
_ => false
}
if removed_directory {
return
}
for name in @fs.read_dir(path) {
let child = build_path(path, name)
build_remove_tree(child)
}
@fs.remove_dir(path)
}
///|
fn build_add_input(
loader : @smithy.Loader,
runner : @build.BuildRunner,
input : @build.BuildInput,
base : String,
cache~ : @resources.VerifiedCache? = None,
mode~ : @resources.AcquisitionMode = @resources.Online,
credentials~ : @resources.Credentials = @resources.Credentials::new().unwrap(),
source~ : Bool = false,
projection~ : String? = None,
) -> Unit raise {
if input is @build.Npm(archive, selection) {
let tree = @resources.register_npm(
@resources.Registry::new(),
@http_resources.transport,
expected_digest=archive.expected_digest,
cache~,
mode~,
credentials~,
)
.add_repository({
id: @resources.RepositoryId::parse("input").unwrap(),
provider: @resources.ProviderId::parse("npm").unwrap(),
location: input.location(),
})
.resolve({
repository: @resources.RepositoryId::parse("input").unwrap(),
key: archive.reference.key(),
}).tree catch {
@resources.ResourceError(code, origin, message) =>
raise @build.InvalidBuildConfig(
"Could not acquire resource `" +
origin.to_string() +
"` [" +
code.to_string() +
"]: " +
message,
)
}
build_add_selected(loader, runner, tree, selection, source~, projection~)
return
}
if input is @build.Maven(archive, selection) {
let tree = @resources.register_maven(
@resources.Registry::new(),
@http_resources.transport,
checksum=archive.checksum,
expected_digest=archive.expected_digest,
cache~,
mode~,
credentials~,
)
.add_repository({
id: @resources.RepositoryId::parse("input").unwrap(),
provider: @resources.ProviderId::parse("maven").unwrap(),
location: input.location(),
})
.resolve({
repository: @resources.RepositoryId::parse("input").unwrap(),
key: archive.coordinates.key(),
}).tree catch {
@resources.ResourceError(code, origin, message) =>
raise @build.InvalidBuildConfig(
"Could not acquire resource `" +
origin.to_string() +
"` [" +
code.to_string() +
"]: " +
message,
)
}
let resources = match selection {
Some(selection) => @build.selected_models(tree, selection)
None => @build.declared_models(tree)
}
build_add_resources(loader, runner, resources, source~, projection~)
return
}
if input is @build.GitHub(archive, selection, api) {
let tree = @resources.register_github(
@resources.Registry::new(),
@http_resources.transport,
api~,
format=archive.format,
expected_digest=archive.expected_digest,
cache~,
mode~,
credentials~,
)
.add_repository({
id: @resources.RepositoryId::parse("input").unwrap(),
provider: @resources.ProviderId::parse("github").unwrap(),
location: input.location(),
})
.resolve({
repository: @resources.RepositoryId::parse("input").unwrap(),
key: archive.reference.key(),
}).tree catch {
@resources.ResourceError(code, origin, message) =>
raise @build.InvalidBuildConfig(
"Could not acquire resource `" +
origin.to_string() +
"` [" +
code.to_string() +
"]: " +
message,
)
}
build_add_selected(loader, runner, tree, selection, source~, projection~)
return
}
if input is @build.Remote(archive, selection) {
let tree = try {
let registry = @resources.register_public_http(
@resources.Registry::new(),
@http_resources.transport,
cache~,
mode~,
credentials~,
format=archive.format,
expected_digest=archive.expected_digest,
).add_repository({
id: @resources.RepositoryId::parse("input").unwrap(),
provider: @resources.ProviderId::parse("public-http").unwrap(),
location: input.location(),
})
registry.resolve({
repository: @resources.RepositoryId::parse("input").unwrap(),
key: @resources.ArtifactKey::parse(".").unwrap(),
}).tree
} catch {
@resources.ResourceError(code, origin, message) =>
raise @build.InvalidBuildConfig(
"Could not acquire resource `" +
origin.to_string() +
"` [" +
code.to_string() +
"]: " +
message,
)
}
build_add_selected(loader, runner, tree, selection, source~, projection~)
return
}
let path = build_path(base, input.location().to_string())
let selection = match input {
@build.Local(_) | @build.Maven(_, None) => None
@build.Selected(_, selection)
| @build.Remote(_, selection)
| @build.GitHub(_, selection, _)
| @build.Npm(_, selection)
| @build.Maven(_, Some(selection)) => Some(selection)
}
build_add_files(
loader,
runner,
path,
if source {
Some(path)
} else {
None
},
selection~,
projection~,
)
}
///|
fn build_add_files(
loader : @smithy.Loader,
runner : @build.BuildRunner,
path : String,
source_root : String?,
projection~ : String? = None,
selection~ : @resources.Selection? = None,
) -> Unit raise {
build_add_files_impl(
loader,
runner,
path,
source_root,
projection~,
selection~,
) catch {
@resources.ResourceError(code, origin, message) =>
raise @build.InvalidBuildConfig(
"Could not load resource `" +
origin.to_string() +
"` [" +
code.to_string() +
"]: " +
message,
)
error => raise error
}
}
///|
fn build_add_files_impl(
loader : @smithy.Loader,
runner : @build.BuildRunner,
path : String,
source_root : String?,
projection~ : String?,
selection~ : @resources.Selection?,
) -> Unit raise {
guard @resources.RepositoryLocation::parse(path) is Some(location) else {
raise @build.InvalidBuildConfig(
"Expected a nonempty, control-free local input location",
)
}
if selection is None &&
(
path.has_suffix(".zip") ||
path.has_suffix(".tar") ||
path.has_suffix(".tar.gz") ||
path.has_suffix(".tgz")
) &&
!@fs.is_dir(path) {
raise @build.InvalidBuildConfig(
"Archive input `" + path + "` requires an explicit resource selection",
)
}
let registry = @local_resources.register(@resources.Registry::new()).add_repository({
id: @resources.RepositoryId::parse("input").unwrap(),
provider: @resources.ProviderId::parse("local").unwrap(),
location,
},
)
let artifact = registry.resolve({
repository: @resources.RepositoryId::parse("input").unwrap(),
key: @resources.ArtifactKey::parse(".").unwrap(),
})
let tree = artifact.tree
if selection is Some(selection) {
build_add_selected(
loader,
runner,
tree,
selection,
source=source_root is Some(_),
projection~,
)
return
}
if path.has_suffix(".jar") && !@fs.is_dir(path) {
build_add_archive(loader, runner, tree, path, source_root, projection~)
return
}
let declarations = tree
.entries()
.filter_map(entry => {
if entry.to_string().has_suffix(".smithy") ||
entry.to_string().has_suffix(".json") {
Some(@resources.Declaration::{
name: entry.resource_name(),
path: entry,
})
} else {
None
}
})
let models : Map[@resources.EntryPath, @resources.Resource] = Map([])
for resource in @resources.load_resources(tree, declarations) {
models[resource.path] = resource
}
for entry in tree.entries() {
let origin = tree.entry_origin(entry)
if entry.to_string().has_suffix(".jar") {
let archive = tree.open_zip(entry)
build_add_archive(
loader,
runner,
archive,
origin.to_string(),
source_root,
projection~,
)
} else if models.get(entry) is Some(resource) {
let text = resource.text()
build_add_model(loader, runner, origin.to_string(), text, projection~)
if projection is None {
build_record_source(runner, origin.to_string(), source_root, text)
}
}
}
}
///|
fn build_add_selected(
loader : @smithy.Loader,
runner : @build.BuildRunner,
tree : @resources.EntryTree,
selection : @resources.Selection,
source~ : Bool,
projection~ : String?,
) -> Unit raise {
build_add_resources(
loader,
runner,
@build.selected_models(tree, selection),
source~,
projection~,
)
}
///|
fn build_add_resources(
loader : @smithy.Loader,
runner : @build.BuildRunner,
resources : Array[@resources.Resource],
source~ : Bool,
projection~ : String?,
) -> Unit raise {
let added : Set[@resources.EntryPath] = Set([])
for resource in resources {
let text = resource.text()
let origin = if resource.provenance
.steps()
.any(step => step is @resources.AcquisitionStep(_)) {
resource.provenance.to_string()
} else {
resource.origin.to_string()
}
if !added.contains(resource.path) {
build_add_model(loader, runner, origin, text, projection~)
added.add(resource.path)
}
if projection is None && source {
ignore(
runner.add_source(
resource.name.to_string(),
text,
source_file=Some(origin),
),
)
}
}
}
///|
fn build_add_model(
loader : @smithy.Loader,
runner : @build.BuildRunner,
origin : String,
text : String,
projection~ : String?,
) -> Unit {
let input = if origin.has_suffix(".smithy") {
@smithy.ModelInput::Idl(name=origin, text~)
} else {
@smithy.ModelInput::Json(name=origin, text~)
}
if projection is Some(name) {
ignore(runner.add_projection_import(name, input))
} else {
ignore(loader.add(input))
}
}
///|
fn build_add_archive(
loader : @smithy.Loader,
runner : @build.BuildRunner,
tree : @resources.EntryTree,
path : String,
source_root : String?,
projection~ : String?,
) -> Unit raise {
let archive_path : @path.Path = path
let archive_name = archive_path.basename().to_owned()
let stem = archive_name[:archive_name.length() - 4].to_owned()
let prefix = match source_root {
Some(root) if root != path => {
let root_path : @path.Path = root
archive_path
.relative(base=root_path)
.to_string()
.replace_all(old="\\", new="/") +
"/" +
stem
}
_ => stem
}
for resource in @build.declared_models(tree) {
let text = resource.text()
build_add_model(
loader,
runner,
resource.origin.to_string(),
text,
projection~,
)
if projection is None && source_root is Some(_) {
ignore(
runner.add_source(
prefix + "/" + resource.name.to_string(),
text,
source_file=Some(resource.origin.to_string()),
),
)
}
}
}
///|
fn build_record_source(
runner : @build.BuildRunner,
path : String,
source_root : String?,
text : String,
) -> Unit {
if source_root is Some(root) {
let current : @path.Path = path
let root : @path.Path = root
let relative = if path == root.to_string() {
current.basename().to_owned()
} else {
current.relative(base=root).to_string().replace_all(old="\\", new="/")
}
ignore(runner.add_source(relative, text, source_file=Some(path)))
}
}
///|
fn build_setting_string(
settings : Json,
key : String,
default : String,
) -> String raise @build.BuildError {
guard settings is Object(fields) else {
raise @build.InvalidBuildConfig(
"JSON Schema plugin settings must be an object",
)
}
match fields.get(key) {
None => default
Some(String(value)) => value
_ =>
raise @build.InvalidBuildConfig(
"JSON Schema setting `" + key + "` must be a string",
)
}
}
///|
fn build_setting_bool(
settings : Json,
key : String,
) -> Bool raise @build.BuildError {
guard settings is Object(fields) else {
raise @build.InvalidBuildConfig(
"JSON Schema plugin settings must be an object",
)
}
match fields.get(key) {
None | Some(False) => false
Some(True) => true
_ =>
raise @build.InvalidBuildConfig(
"JSON Schema setting `" + key + "` must be a boolean",
)
}
}
///|
fn json_schema_plugin(
model : @smithy.Model,
settings : Json,
) -> Map[String, String] raise @build.BuildError {
let map_strategy = build_setting_string(
settings, "mapStrategy", "propertyNames",
)
let union_strategy = build_setting_string(settings, "unionStrategy", "oneOf")
let enum_strategy = build_setting_string(settings, "enumStrategy", "enum")
if !["propertyNames", "patternProperties"].contains(map_strategy) ||
!["oneOf", "object", "structure"].contains(union_strategy) ||
!["enum", "oneOf"].contains(enum_strategy) {
raise @build.InvalidBuildConfig("Invalid JSON Schema strategy")
}
let extensions = match settings {
Object(fields) =>
fields.get("schemaDocumentExtensions").unwrap_or(Json::object(Map([])))
_ => Json::object(Map([]))
}
guard extensions is Object(_) else {
raise @build.InvalidBuildConfig(
"schemaDocumentExtensions must be an object",
)
}
let schema_version = build_setting_string(
settings, "jsonSchemaVersion", "draft07",
)
if schema_version != "draft07" && schema_version != "draft2020-12" {
raise @build.InvalidBuildConfig(
"Unsupported JSON Schema version `" + schema_version + "`",
)
}
let default_pointer = if schema_version == "draft2020-12" {
"#/$defs"
} else {
"#/definitions"
}
let config = @jsonschema.Config::new(
map_strategy~,
union_strategy~,
enum_strategy~,
use_json_name=build_setting_bool(settings, "useJsonName"),
default_timestamp_format=build_setting_string(
settings, "defaultTimestampFormat", "date-time",
),
support_non_numeric_floats=build_setting_bool(
settings, "supportNonNumericFloats",
),
schema_document_extensions=extensions,
definition_pointer=build_setting_string(
settings, "definitionPointer", default_pointer,
),
add_reference_descriptions=build_setting_bool(
settings, "addReferenceDescriptions",
),
disable_default_values=build_setting_bool(settings, "disableDefaultValues"),
disable_int_enums=build_setting_bool(settings, "disableIntEnums"),
use_integer_type=build_setting_bool(settings, "useIntegerType"),
)
let service = build_setting_string(settings, "service", "")
let document = @jsonschema.convert(model, service~, config~) catch {
error =>
raise @build.InvalidBuildConfig(
"JSON Schema conversion failed: " + error.to_string(),
)
}
let files : Map[String, String] = Map([])
files["schema.json"] = document.stringify(indent=4) + "\n"
files
}
///|
fn run_build(
config_path : String,
clean : Bool,
offline~ : Bool = false,
cache_dir~ : String? = None,
) -> Int32 raise {
let environment = @env.get_env_vars()
let current : @path.Path = "."
environment["SMITHY_ROOT_DIR"] = current.resolve().to_string()
let config = @build.BuildConfig::parse(
@fs.read_file_to_string(config_path),
environment~,
)
let config_file : @path.Path = config_path
let base = config_file.dirname().to_string()
let base_path : @path.Path = base
let base = base_path.resolve().to_string()
let output = build_path(base, config.output_directory)
let normalized : @path.Path = output
let output = normalized.resolve().to_string()
let cwd : @path.Path = "."
let output_path : @path.Path = output
let owner = config_file.resolve().to_string() + "\n" + output + "\n"
if output == cwd.resolve().to_string() ||
build_path_contains(output, base) ||
output_path.dirname().to_string() == output {
raise CliError("build output directory must be a dedicated directory", 2)
}
let protected_paths = config.sources + config.imports
for projection in config.projections {
protected_paths.append(projection.imports)
}
for path in protected_paths {
if path is @build.Remote(_, _) ||
path is @build.GitHub(_, _, _) ||
path is @build.Npm(_, _) ||
path is @build.Maven(_, _) {
continue
}
let input = build_path(base, path.location().to_string())
if build_path_contains(output, input) || build_path_contains(input, output) {
raise CliError(
"build output directory must not overlap model sources or imports", 2,
)
}
}
let cache_text = cache_dir.unwrap_or(config.cache_directory.to_string())
guard @resources.CacheDirectory::parse(cache_text) is Some(_) else {
raise CliError(
"cache directory must be a portable filesystem directory path", 2,
)
}
let cache_path = build_path(base, cache_text)
if build_path_contains(output, cache_path) ||
build_path_contains(cache_path, output) {
raise CliError(
"build output directory must not overlap the artifact cache", 2,
)
}
guard @resources.CacheDirectory::parse(cache_path) is Some(cache_location) else {
raise CliError(
"cache directory must be a portable filesystem directory path", 2,
)
}
let cache = Some(@resource_cache.open(cache_location))
let mode = if offline || config.offline {
@resources.Offline
} else {
@resources.Online
}
if clean {
build_check_output_owner(output, owner)
build_remove_tree(output)
return 0
}
let credentials = config.resolve_credentials(environment)
let loader = @smithy.Loader::new()
let runner = @build.BuildRunner::new(config).register_plugin(
"jsonSchema", json_schema_plugin,
)
for input in config.sources {
build_add_input(
loader,
runner,
input,
base,
cache~,
mode~,
credentials~,
source=true,
)
}
for input in config.imports {
build_add_input(loader, runner, input, base, cache~, mode~, credentials~)
}
for projection in config.projections {
for input in projection.imports {
build_add_input(
loader,
runner,
input,
base,
cache~,
mode~,
credentials~,
projection=Some(projection.name),
)
}
}
let loaded = loader.assemble()
for d in loaded.diagnostics {
eprintln(diagnostic(d, Map([])))
}
guard loaded.model is Some(model) else { return 1 }
let built = runner.run_result(model)
if built.errors.is_empty() || !built.artifacts.is_empty() {
build_check_output_owner(output, owner)
build_remove_tree(output)
}
for relative, content in built.artifacts {
let path = build_path(output, relative)
let file : @path.Path = path
build_mkdir(file.dirname().to_string())
@fs.write_string_to_file(path, content)
}
if !built.artifacts.is_empty() {
@fs.write_string_to_file(build_path(output, ".smithy-build-output"), owner)
}
for message in built.errors {
eprintln(message)
}
if built.errors.is_empty() {
0
} else {
1
}
}