///|
/// Portable project settings shared by local checks and CI. Paths are relative
/// to the explicit workspace root, never to the process running the CLI.
pub(all) struct DocumentProjectConfig {
version : Int
paths : Array[String]
exclude : Array[String]
baseline : String?
strict : Bool
online : Bool
} derive(ToJson)
///|
pub extend DocumentProjectConfig with ToJson::{to_json}
///|
pub(all) struct DocumentProjectConfigResult {
config : DocumentProjectConfig?
diagnostics : Array[Diagnostic]
} derive(ToJson)
///|
pub extend DocumentProjectConfigResult with ToJson::{to_json}
///|
/// Read version 1 project configuration. Reject misspelled keys and malformed
/// values so a broken configuration cannot silently widen the scan or disable
/// strict checks. New configurations default to strict, offline checks.
pub fn parse_document_project_config(
input : String,
) -> DocumentProjectConfigResult {
let diagnostics : Array[Diagnostic] = []
let value = @json.parse(input) catch {
_ => {
add_diagnostic(
diagnostics, 1, "INVALID_PROJECT_CONFIG", "configuration must be valid JSON",
)
return { config: None, diagnostics, }
}
}
if value is Object(object) {
for key, _ in object {
if !["version", "paths", "exclude", "baseline", "strict", "online"].contains(
key,
) {
add_diagnostic(
diagnostics,
1,
"UNKNOWN_CONFIG_FIELD",
"unknown configuration field: " + key,
)
}
}
} else {
add_diagnostic(
diagnostics, 1, "INVALID_PROJECT_CONFIG", "configuration must be an object",
)
return { config: None, diagnostics, }
}
match field(value, "version") {
Some(Number(version, ..)) if version == 1.0 => ()
_ =>
add_diagnostic(
diagnostics, 1, "UNSUPPORTED_CONFIG_VERSION", "configuration requires version: 1",
)
}
let paths = document_config_paths(value, "paths", ["."], false, diagnostics)
let exclude = document_config_paths(value, "exclude", [], true, diagnostics)
let baseline = match field(value, "baseline") {
None => None
Some(Null) => None
Some(String(path)) if valid_document_config_path(path) &&
path.to_lower().has_suffix(".json") => Some(path)
_ => {
add_diagnostic(
diagnostics, 1, "INVALID_CONFIG_BASELINE", "baseline must be a root-relative .json file path",
)
None
}
}
let strict = document_config_bool(value, "strict", true, diagnostics)
let online = document_config_bool(value, "online", false, diagnostics)
{
config: if diagnostics.is_empty() {
Some({ version: 1, paths, exclude, baseline, strict, online, })
} else {
None
},
diagnostics,
}
}
///|
fn document_config_bool(
value : Json,
key : String,
default : Bool,
diagnostics : Array[Diagnostic],
) -> Bool {
match field(value, key) {
None => default
Some(True) => true
Some(False) => false
_ => {
add_diagnostic(
diagnostics,
1,
"INVALID_CONFIG_FIELD",
"expected boolean: " + key,
)
default
}
}
}
///|
fn document_config_paths(
value : Json,
key : String,
default : Array[String],
allow_empty : Bool,
diagnostics : Array[Diagnostic],
) -> Array[String] {
match field(value, key) {
None => default
Some(Array(items)) => {
let paths : Array[String] = []
for item in items {
if item is String(path) && valid_document_config_path(path) {
paths.push(normalize_document_config_path(path))
} else {
add_diagnostic(
diagnostics,
1,
"INVALID_CONFIG_PATH",
"expected a nonempty root-relative path in " + key,
)
}
}
if paths.is_empty() && !allow_empty {
add_diagnostic(
diagnostics, 1, "EMPTY_CONFIG_PATHS", "paths must contain at least one scan target",
)
}
paths
}
_ => {
add_diagnostic(
diagnostics,
1,
"INVALID_CONFIG_FIELD",
"expected an array of paths: " + key,
)
[]
}
}
}
///|
fn valid_document_config_path(path : String) -> Bool {
if path.trim() == "" ||
path.has_prefix("/") ||
path.contains("\\") ||
path.contains(":") ||
path.contains("\n") ||
path.contains("\r") {
return false
}
let mut depth = 0
for part in path.split("/") {
if part == ".." {
if depth == 0 {
return false
}
depth = depth - 1
} else if part != "" && part != "." {
depth = depth + 1
}
}
true
}
///|
fn normalize_document_config_path(path : String) -> String {
let parts : Array[String] = []
for part in path.split("/") {
if part == ".." {
ignore(parts.pop())
} else if part != "" && part != "." {
parts.push(part.to_owned())
}
}
if parts.is_empty() {
"."
} else {
parts.join("/")
}
}