///|
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_files(
loader : @smithy.Loader,
runner : @build.BuildRunner,
path : String,
source_root : String?,
projection~ : String? = None,
) -> Unit raise {
if @fs.is_dir(path) {
for name in @fs.read_dir(path) {
build_add_files(
loader,
runner,
build_path(path, name),
source_root,
projection~,
)
}
} else if path.has_suffix(".jar") {
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 entry, text in build_archive_models(path) {
let origin = path + "!/META-INF/smithy/" + entry
let input = if entry.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))
if source_root is Some(_) {
ignore(
runner.add_source(
prefix + "/" + entry,
text,
source_file=Some(origin),
),
)
}
}
}
} else if path.has_suffix(".smithy") {
let text = @fs.read_file_to_string(path)
if projection is Some(name) {
ignore(
runner.add_projection_import(
name,
@smithy.ModelInput::Idl(name=path, text~),
),
)
} else {
ignore(loader.add_idl(path, text))
}
if projection is None {
build_record_source(runner, path, source_root, text)
}
} else if path.has_suffix(".json") {
let text = @fs.read_file_to_string(path)
if projection is Some(name) {
ignore(
runner.add_projection_import(
name,
@smithy.ModelInput::Json(name=path, text~),
),
)
} else {
ignore(loader.add_json(path, text))
}
if projection is None {
build_record_source(runner, path, source_root, text)
}
}
}
///|
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) -> 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 {
let input = build_path(base, path)
if build_path_contains(output, input) || build_path_contains(input, output) {
raise CliError(
"build output directory must not overlap model sources or imports", 2,
)
}
}
if clean {
build_check_output_owner(output, owner)
build_remove_tree(output)
return 0
}
let loader = @smithy.Loader::new()
let runner = @build.BuildRunner::new(config).register_plugin(
"jsonSchema", json_schema_plugin,
)
for name in config.sources {
let path = build_path(base, name)
if @fs.is_dir(path) {
build_add_files(loader, runner, path, Some(path))
} else {
build_add_files(loader, runner, path, Some(path))
}
}
for name in config.imports {
build_add_files(loader, runner, build_path(base, name), None)
}
for projection in config.projections {
for name in projection.imports {
build_add_files(
loader,
runner,
build_path(base, name),
None,
projection=Some(projection.name),
)
}
}
let loaded = loader.assemble()
for diagnostic in loaded.diagnostics {
eprintln(diagnostic.message)
}
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
}
}