///|
priv enum DoctorProbeKind {
DashVersion
DoubleDashVersion
SwiftCompilerVersion
WaylandPackages
XcodeDeveloperPath
}
///|
priv struct DoctorProbeResult {
status : Int
output : String
}
///|
/// Run a fixed, read-only doctor probe via `process_exec_argv`. Replaces the
/// previous `moui_cli_run_doctor_probe` C FFI. The argv is built from the
/// `DoctorProbeKind` so the probe semantics stay in MoonBit.
fn run_doctor_probe(
executable : String,
kind : DoctorProbeKind,
) -> DoctorProbeResult {
let argv : Array[String] = match kind {
DashVersion => [executable, "-version"]
DoubleDashVersion => [executable, "--version"]
SwiftCompilerVersion => [executable, "swiftc", "--version"]
WaylandPackages =>
[
executable, "--exists", "wayland-client", "wayland-cursor", "wayland-protocols",
]
XcodeDeveloperPath => [executable, "-p"]
}
match process_exec_argv(argv, capture=true, timeout_ms=30000) {
Ok(result) =>
{
status: result.exit_code,
output: (result.stdout + "\n" + result.stderr).trim().to_owned(),
}
Err(_message) => { status: 102, output: "", }
}
}
///|
fn numeric_version_at(text : String, start : Int) -> Array[Int]? {
if start < 0 ||
start >= text.length() ||
text[start] < '0' ||
text[start] > '9' {
return None
}
let parts : Array[Int] = []
let number = Ref(0)
let digits = Ref(0)
for index in start..= '0' && unit <= '9' {
number.val = number.val * 10 + unit.to_int() - '0'.to_int()
digits.val += 1
} else if unit == '.' {
if digits.val == 0 {
return None
}
parts.push(number.val)
number.val = 0
digits.val = 0
} else {
if digits.val > 0 {
parts.push(number.val)
}
return if parts.is_empty() { None } else { Some(parts) }
}
}
if digits.val > 0 {
parts.push(number.val)
} else {
return None
}
Some(parts)
}
///|
fn first_numeric_version(text : String, start? : Int = 0) -> Array[Int]? {
let start = if start < 0 { 0 } else { start }
for index in start..= '0' &&
unit <= '9' &&
(index == start || text[index - 1] < '0' || text[index - 1] > '9') {
match numeric_version_at(text, index) {
Some(version) => return Some(version)
None => ()
}
}
}
None
}
///|
fn numeric_version_after(text : String, marker : String) -> Array[Int]? {
match text.find(marker) {
Some(offset) => first_numeric_version(text, start=offset + marker.length())
None => None
}
}
///|
fn numeric_version_at_least(actual : Array[Int], required : Array[Int]) -> Bool {
let length = actual.length().max(required.length())
for index in 0.. right {
return true
}
if left < right {
return false
}
}
true
}
///|
fn numeric_version_has_prefix(
actual : Array[Int],
expected : Array[Int],
) -> Bool {
if actual.length() < expected.length() {
return false
}
for index, part in expected {
if actual[index] != part {
return false
}
}
true
}
///|
fn numeric_version_text(version : Array[Int]) -> String {
version.map(part => part.to_string()).join(".")
}
///|
fn detected_version(output : String, marker : String) -> Array[Int]? {
match numeric_version_after(output, marker) {
Some(version) => Some(version)
None => first_numeric_version(output)
}
}
///|
fn probe_version_floor(
executable : String,
kind : DoctorProbeKind,
marker : String,
required : Array[Int],
label : String,
) -> Result[String, String] {
let probe = run_doctor_probe(executable, kind)
if probe.status != 0 {
return Err(
label +
" version probe failed (status=" +
probe.status.to_string() +
")" +
(if probe.output == "" { "" } else { ": " + probe.output }),
)
}
let version = match detected_version(probe.output, marker) {
Some(version) => version
None => return Err(label + " version could not be parsed: " + probe.output)
}
let actual = numeric_version_text(version)
let floor = numeric_version_text(required)
if !numeric_version_at_least(version, required) {
return Err(label + " " + actual + " is below required " + floor)
}
Ok(actual)
}
///|
fn java_major_version(output : String) -> Int? {
let version = match detected_version(output, "version") {
Some(version) => version
None => return None
}
if version[0] == 1 && version.length() > 1 {
Some(version[1])
} else {
Some(version[0])
}
}
///|
fn probe_java_floor(executable : String) -> Result[String, String] {
let probe = run_doctor_probe(executable, DashVersion)
if probe.status != 0 {
return Err(
"Java version probe failed (status=" + probe.status.to_string() + ")",
)
}
let major = match java_major_version(probe.output) {
Some(value) => value
None => return Err("Java version could not be parsed: " + probe.output)
}
if major < 17 {
return Err("Java " + major.to_string() + " is below required Java 17")
}
Ok(major.to_string())
}
///|
fn property_version(text : String, key : String) -> Array[Int]? {
for line in text.split("\n") {
let line = line.to_owned()
if line.trim().has_prefix(key) {
match line.find("=") {
Some(offset) =>
match first_numeric_version(line, start=offset + 1) {
Some(version) => return Some(version)
None => ()
}
None => ()
}
}
}
None
}
///|
fn ndk_revision(root : String) -> Array[Int]? {
let properties = @fs.read_file_to_string(join_path(root, "source.properties")) catch {
_ => return None
}
property_version(properties, "Pkg.Revision")
}
///|
fn gradle_wrapper_version(properties : String) -> Array[Int]? {
numeric_version_after(properties, "gradle-")
}
///|
fn gradle_declared_version_floor(
version : Array[Int],
source : String,
) -> Result[String, String] {
let actual = numeric_version_text(version)
if !numeric_version_at_least(version, [9, 6, 1]) {
return Err("Gradle " + actual + " is below required 9.6.1 (" + source + ")")
}
Ok(actual + " (" + source + ")")
}
///|
fn gradle_version_floor(
project_root : String,
executable : String,
host : HostPlatform,
) -> Result[String, String]? {
let wrapper = normalized_path(executable) ==
normalized_path(join_path(project_root, "gradlew")) ||
normalized_path(executable) ==
normalized_path(join_path(project_root, "gradlew.bat"))
if wrapper {
let properties_path = join_path(
project_root, "gradle/wrapper/gradle-wrapper.properties",
)
let configured = @env.get_env_var("MOUI_GRADLE_VERSION")
match configured {
Some(value) =>
match first_numeric_version(value) {
Some(version) =>
return Some(
gradle_declared_version_floor(version, "MOUI_GRADLE_VERSION"),
)
None =>
return Some(Err("MOUI_GRADLE_VERSION is not a numeric version"))
}
None => ()
}
let properties = @fs.read_file_to_string(properties_path) catch { _ => "" }
match gradle_wrapper_version(properties) {
Some(version) =>
return Some(gradle_declared_version_floor(version, "wrapper metadata"))
None => ()
}
let wrapper_text = @fs.read_file_to_string(executable) catch { _ => "" }
match numeric_version_after(wrapper_text, "MOUI_GRADLE_VERSION:-") {
Some(version) =>
return Some(
gradle_declared_version_floor(version, "wrapper declaration"),
)
None => ()
}
return None
}
if host == Windows &&
(
executable.to_lower().has_suffix(".bat") ||
executable.to_lower().has_suffix(".cmd")
) {
return None
}
Some(
probe_version_floor(
executable,
DoubleDashVersion,
"Gradle",
[9, 6, 1],
"Gradle",
),
)
}
///|
fn versioned_android_executable(
root : String,
relative_executable : String,
required : Array[Int],
) -> (String, String)? {
if !path_is_dir(root) {
return None
}
let entries : ArrayView[String] = @fs.read_dir(root) catch { _ => [] }
let best_version : Ref[Array[Int]?] = Ref(None)
let best_path = Ref("")
for entry in entries {
let version = match first_numeric_version(entry) {
Some(value) if numeric_version_at_least(value, required) => value
_ => continue
}
let executable = first_executable([
join_path(root, entry + "/" + relative_executable),
join_path(root, entry + "/" + relative_executable + ".exe"),
])
guard executable is Some(executable) else { continue }
let newer = match best_version.val {
Some(current) => numeric_version_at_least(version, current)
None => true
}
if newer {
best_version.val = Some(version)
best_path.val = executable
}
}
match best_version.val {
Some(version) => Some((best_path.val, numeric_version_text(version)))
None => None
}
}
///|
fn json_int(value : Json?) -> Int? {
match value {
Some(Number(number, repr=_)) if number.to_int().to_double() == number =>
Some(number.to_int())
Some(String(text)) =>
match first_numeric_version(text) {
Some([number]) => Some(number)
_ => None
}
_ => None
}
}
///|
fn harmony_sdk_metadata_text(text : String) -> Result[(Int, String), String] {
let parsed = @json.parse(text) catch {
_ => return Err("HarmonyOS SDK metadata is not valid JSON")
}
guard parsed is Object(root) else {
return Err("HarmonyOS SDK metadata must be an object")
}
let data = match root.get("data") {
Some(Object(fields)) => Some(fields)
_ => None
}
let api = match json_int(root.get("apiVersion")) {
Some(value) => Some(value)
None =>
match data {
Some(fields) => json_int(fields.get("apiVersion"))
None => None
}
}
let model = match root.get("platformVersion") {
Some(String(value)) => Some(value)
_ =>
match data {
Some(fields) =>
match fields.get("platformVersion") {
Some(String(value)) => Some(value)
_ => None
}
None => None
}
}
guard api is Some(api) else {
return Err("HarmonyOS SDK metadata is missing apiVersion/platformVersion")
}
guard model is Some(model) else {
return Err("HarmonyOS SDK metadata is missing apiVersion/platformVersion")
}
Ok((api, model))
}
///|
fn harmony_sdk_metadata(root : String) -> Result[(Int, String, String), String] {
let candidates = [
join_path(root, "sdk-pkg.json"),
join_path(root, "default/sdk-pkg.json"),
join_path(parent_dir(root), "sdk-pkg.json"),
]
let invalid = Ref("")
for path in candidates {
if !path_is_nonempty_file(path) {
continue
}
let text = @fs.read_file_to_string(path) catch { _ => "" }
if text == "" {
invalid.val = path
continue
}
match harmony_sdk_metadata_text(text) {
Ok((api, model)) => return Ok((api, model, path))
Err(_) => invalid.val = path
}
}
Err(
if invalid.val == "" {
"HarmonyOS SDK apiVersion/platformVersion metadata was not found"
} else {
"HarmonyOS SDK metadata is incomplete: " + invalid.val
},
)
}
///|
fn harmony_project_contract_text(
mobile_text : String,
profile_text : String,
package_text : String,
) -> Result[String, String] {
let mobile = @json.parse(mobile_text) catch {
_ => return Err("moui.mobile.json is not valid JSON")
}
guard mobile is Object(mobile_root) else {
return Err("moui.mobile.json must be an object")
}
guard mobile_root.get("harmonyos") is Some(Object(harmony)) else {
return Err("moui.mobile.json must declare HarmonyOS platform metadata")
}
guard json_int(harmony.get("compatibleSdkVersion")) is Some(20) else {
return Err(
"moui.mobile.json must declare HarmonyOS compatibleSdkVersion 20",
)
}
let profile = @json.parse(profile_text) catch {
_ => return Err("HarmonyOS build-profile.json5 is not parseable JSON")
}
guard profile is Object(profile_root) else {
return Err("HarmonyOS build profile must be an object")
}
guard profile_root.get("app") is Some(Object(app)) else {
return Err("HarmonyOS build profile must contain app metadata")
}
guard app.get("products") is Some(Array([Object(product), ..])) else {
return Err("HarmonyOS build profile must contain a product")
}
guard product.get("targetSdkVersion") is Some(String("6.0.1(21)")) &&
product.get("compatibleSdkVersion") is Some(String("6.0.0(20)")) else {
return Err(
"HarmonyOS build profile must target 6.0.1(21) with compatibility 6.0.0(20)",
)
}
let package_json = @json.parse(package_text) catch {
_ => return Err("HarmonyOS oh-package.json5 is not parseable JSON")
}
guard package_json is Object(package_root) else {
return Err("HarmonyOS package metadata must be an object")
}
guard package_root.get("modelVersion") is Some(String("6.0.1")) else {
return Err("HarmonyOS project modelVersion must be 6.0.1")
}
Ok("compatibleApi=20 targetApi=21 model=6.0.1")
}
///|
fn harmony_project_contract(project_root : String) -> Result[String, String] {
let mobile_path = join_path(project_root, "moui.mobile.json")
let mobile_text = @fs.read_file_to_string(mobile_path) catch {
_ => return Err("HarmonyOS project metadata is missing: " + mobile_path)
}
let template_root = window_template_root(project_root, "harmonyos")
let profile_path = join_path(template_root, "build-profile.json5")
let package_path = join_path(template_root, "oh-package.json5")
let profile_text = @fs.read_file_to_string(profile_path) catch {
_ => return Err("HarmonyOS build profile is missing: " + profile_path)
}
let package_text = @fs.read_file_to_string(package_path) catch {
_ => return Err("HarmonyOS package metadata is missing: " + package_path)
}
harmony_project_contract_text(mobile_text, profile_text, package_text)
}