// Copyright 2026 moonpddl contributors
//
// SPDX-License-Identifier: MIT
///|
const VERSION : String = "0.2.0"
///|
fn usage() -> String {
let text =
#|moonpddl - PDDL 2.1 Level 1 planning engine and independent plan validator
#|Usage:
#| moonpddl solve [options]
#| moonpddl validate [--json]
#| moonpddl check [--json]
#| moonpddl ground [--json]
#| moonpddl version
#|
#|Solve options:
#| -s, --search Search strategy (default: gbfs)
#| -H, --heuristic blind, goal-count, hmax, hadd, hff
#| (default: hff)
#| --weight Weight for wastar (default: 5)
#| --max-expanded Expansion limit (default: 1000000)
#| --json Machine-readable output
#| --out Write the plan to a file
#| -q, --quiet Do not print solver statistics
#| -h, --help Show this help
#|
#|Exit codes: 0 success, 1 no plan or invalid plan, 2 usage/model error.
#|
text
}
///|
fn usage_error(message : String) -> Int32 {
println("error: \{message}")
println("")
println(usage())
2
}
///|
fn json_escape(text : String) -> String {
let sb = StringBuilder::StringBuilder()
for c in text.to_array() {
match c {
'"' => sb.write_string("\\\"")
'\\' => sb.write_string("\\\\")
'\n' => sb.write_string("\\n")
'\r' => sb.write_string("\\r")
'\t' => sb.write_string("\\t")
_ => sb.write_char(c)
}
}
sb.to_string()
}
///|
fn report_error(json : Bool, message : String) -> Int32 {
if json {
println("{\"error\":\"\{json_escape(message)}\"}")
} else {
println("error: \{message}")
}
2
}
///|
fn usage_error_mode(json : Bool, message : String) -> Int32 {
if json {
report_error(true, message)
} else {
usage_error(message)
}
}
///|
fn parse_count(text : String) -> Int32? {
if text == "" {
return None
}
let mut value = 0
for c in text.to_array() {
if c < '0' || c > '9' {
return None
}
let digit = c.to_int() - 48
if value > (1_000_000_000 - digit) / 10 {
return None
}
value = value * 10 + digit
}
Some(value)
}
///|
fn load_grounded(
domain_path : String,
problem_path : String,
) -> @pddl.GroundProblem raise {
let domain = @pddl.parse_domain(@fs.read_file_to_string(domain_path))
let problem = @pddl.parse_problem(@fs.read_file_to_string(problem_path))
@pddl.ground(domain, problem)
}
///|
fn cost_mode(problem : @pddl.GroundProblem) -> String {
if problem.uses_action_costs {
"total-cost"
} else {
"unit"
}
}
///|
fn search_status_name(status : @pddl.SearchStatus) -> String {
match status {
@pddl.Solved => "solved"
@pddl.Exhausted => "exhausted"
@pddl.NodeLimitReached => "limit"
}
}
///|
fn search_json(
result : @pddl.SearchResult,
problem : @pddl.GroundProblem,
heuristic : @pddl.HeuristicKind,
search : @pddl.SearchAlgorithm,
) -> String {
let sb = StringBuilder::StringBuilder()
sb.write_string("{\"status\":\"")
sb.write_string(search_status_name(result.status))
sb.write_string("\",\"cost\":")
sb.write_string(@pddl.format_number(result.cost))
sb.write_string(",\"steps\":")
sb.write_string(result.plan.length().to_string())
sb.write_string(",\"expanded\":")
sb.write_string(result.expanded.to_string())
sb.write_string(",\"generated\":")
sb.write_string(result.generated.to_string())
sb.write_string(",\"heuristic\":\"")
sb.write_string(heuristic.name())
sb.write_string("\",\"search\":\"")
sb.write_string(search.name())
sb.write_string("\",\"plan\":[")
for index in 0.. 0 {
sb.write_char(',')
}
sb.write_char('"')
sb.write_string(json_escape(problem.actions[result.plan[index]].render()))
sb.write_char('"')
}
sb.write_string("]}")
sb.to_string()
}
///|
fn model_json(problem : @pddl.GroundProblem, detailed : Bool) -> String {
let sb = StringBuilder::StringBuilder()
sb.write_string("{\"domain\":\"")
sb.write_string(json_escape(problem.domain_name))
sb.write_string("\",\"problem\":\"")
sb.write_string(json_escape(problem.problem_name))
sb.write_string("\",\"objects\":")
sb.write_string(problem.objects.length().to_string())
sb.write_string(",\"atoms\":")
sb.write_string(problem.atoms.length().to_string())
sb.write_string(",\"actions\":")
sb.write_string(problem.actions.length().to_string())
sb.write_string(",\"goal_disjuncts\":")
sb.write_string(problem.goal.length().to_string())
sb.write_string(",\"fluents\":")
sb.write_string(problem.funcs.length().to_string())
sb.write_string(",\"initial_cost\":")
sb.write_string(@pddl.format_number(problem.initial_cost))
sb.write_string(",\"costs\":\"")
sb.write_string(cost_mode(problem))
sb.write_char('"')
if detailed {
sb.write_string(",\"ground_actions\":[")
for index in 0.. 0 {
sb.write_char(',')
}
let action = problem.actions[index]
sb.write_string("{\"id\":")
sb.write_string(action.id.to_string())
sb.write_string(",\"action\":\"")
sb.write_string(json_escape(action.render()))
sb.write_string("\",\"cost\":")
sb.write_string(@pddl.format_number(action.cost))
sb.write_char('}')
}
sb.write_char(']')
}
sb.write_char('}')
sb.to_string()
}
///|
fn cmd_solve(args : Array[String]) -> Int32 {
let positionals : Array[String] = []
let mut heuristic : @pddl.HeuristicKind = @pddl.HFF
let mut search : @pddl.SearchAlgorithm = @pddl.GBFS
let mut weight : Int32? = None
let mut max_expanded = 1_000_000
let mut json = args.contains("--json")
let mut out_path = ""
let mut quiet = false
let mut index = 2
while index < args.length() {
let arg = args[index]
match arg {
"-h" | "--help" => {
println(usage())
return 0
}
"-s" | "--search" => {
if index + 1 >= args.length() {
return usage_error_mode(json, "option '\{arg}' needs a value")
}
index += 1
match @pddl.SearchAlgorithm::parse(args[index]) {
Some(value) => search = value
None =>
return usage_error_mode(
json,
"unknown search strategy '\{args[index]}'; use gbfs, astar or wastar",
)
}
}
"-H" | "--heuristic" => {
if index + 1 >= args.length() {
return usage_error_mode(json, "option '\{arg}' needs a value")
}
index += 1
match @pddl.HeuristicKind::parse(args[index]) {
Some(value) => heuristic = value
None =>
return usage_error_mode(
json,
"unknown heuristic '\{args[index]}'; use blind, goal-count, hmax, hadd or hff",
)
}
}
"--weight" => {
if index + 1 >= args.length() {
return usage_error_mode(json, "option '--weight' needs a value")
}
index += 1
match parse_count(args[index]) {
Some(value) =>
if value <= 0 {
return usage_error_mode(
json, "--weight must be a positive integer",
)
} else {
weight = Some(value)
}
None =>
return usage_error_mode(json, "--weight expects a positive integer")
}
}
"--max-expanded" => {
if index + 1 >= args.length() {
return usage_error_mode(json, "option '--max-expanded' needs a value")
}
index += 1
match parse_count(args[index]) {
Some(value) => max_expanded = value
None =>
return usage_error_mode(json, "--max-expanded expects an integer")
}
}
"--out" => {
if index + 1 >= args.length() {
return usage_error_mode(json, "option '--out' needs a value")
}
index += 1
out_path = args[index]
}
"--json" => json = true
"-q" | "--quiet" => quiet = true
_ => {
if arg.has_prefix("-") {
return usage_error_mode(json, "unknown option '\{arg}'")
}
positionals.push(arg)
}
}
index += 1
}
if positionals.length() != 2 {
return usage_error_mode(
json, "solve expects a domain file and a problem file",
)
}
match weight {
Some(value) =>
match search {
@pddl.WeightedAStar(_) => search = @pddl.WeightedAStar(value)
_ =>
return usage_error_mode(
json, "--weight can only be used with --search wastar",
)
}
None => ()
}
let domain_path = positionals[0]
let problem_path = positionals[1]
try {
let grounded = load_grounded(domain_path, problem_path)
let result = @pddl.solve(grounded, heuristic, search, max_expanded~)
let plan_text = result.render_plan(grounded)
if result.is_solved() && out_path != "" {
let content = "; cost = \{@pddl.format_number(result.cost)}\n" + plan_text
@fs.write_string_to_file(out_path, content) catch {
@fs.IOError(message) =>
return report_error(json, "cannot write plan: \{message}")
}
}
if json {
println(search_json(result, grounded, heuristic, search))
} else if result.is_solved() {
if !quiet {
println("; moonpddl: solved")
println("; cost = \{@pddl.format_number(result.cost)}")
println(
"; expanded = \{result.expanded}, generated = \{result.generated}",
)
println("; heuristic = \{heuristic.name()}, search = \{search.name()}")
if out_path != "" {
println("; plan written to \{out_path}")
}
}
println(plan_text.trim_end().to_owned())
} else {
let reason = match result.status {
@pddl.Exhausted => "search space exhausted"
@pddl.NodeLimitReached => "node limit reached"
_ => "no plan"
}
println(
"no plan found: \{reason} (expanded \{result.expanded}, generated \{result.generated})",
)
}
if result.is_solved() {
0
} else {
1
}
} catch {
@fs.IOError(message) => report_error(json, "cannot read input: \{message}")
err => report_error(json, err.to_string())
}
}
///|
fn cmd_validate(args : Array[String]) -> Int32 {
let positionals : Array[String] = []
let mut json = args.contains("--json")
let mut index = 2
while index < args.length() {
let arg = args[index]
match arg {
"-h" | "--help" => {
println(usage())
return 0
}
"--json" => json = true
_ => {
if arg.has_prefix("-") {
return usage_error_mode(json, "unknown option '\{arg}'")
}
positionals.push(arg)
}
}
index += 1
}
if positionals.length() != 3 {
return usage_error_mode(
json, "validate expects a domain file, a problem file and a plan file",
)
}
let domain_path = positionals[0]
let problem_path = positionals[1]
let plan_path = positionals[2]
try {
let report = @pddl.validate(
@pddl.parse_domain(@fs.read_file_to_string(domain_path)),
@pddl.parse_problem(@fs.read_file_to_string(problem_path)),
@fs.read_file_to_string(plan_path),
)
if json {
println(report.to_json())
} else {
println(report.message())
for issue in report.issues {
if issue.line > 0 {
println(
" line \{issue.line}: [\{issue.kind.name()}] \{issue.message}",
)
} else {
println(" [\{issue.kind.name()}] \{issue.message}")
}
}
}
if report.valid {
0
} else {
1
}
} catch {
@fs.IOError(message) => report_error(json, "cannot read input: \{message}")
err => report_error(json, err.to_string())
}
}
///|
fn model_summary(args : Array[String], detailed : Bool) -> Int32 {
let positionals : Array[String] = []
let mut json = args.contains("--json")
let mut index = 2
while index < args.length() {
let arg = args[index]
match arg {
"-h" | "--help" => {
println(usage())
return 0
}
"--json" => json = true
_ => {
if arg.has_prefix("-") {
return usage_error_mode(json, "unknown option '\{arg}'")
}
positionals.push(arg)
}
}
index += 1
}
if positionals.length() != 2 {
return usage_error_mode(
json, "this command expects a domain file and a problem file",
)
}
try {
let grounded = load_grounded(positionals[0], positionals[1])
if json {
println(model_json(grounded, detailed))
} else {
println("model ok")
println(" domain: \{grounded.domain_name}")
println(" problem: \{grounded.problem_name}")
println(" objects: \{grounded.objects.length()}")
println(" atoms: \{grounded.atoms.length()}")
println(" fluents: \{grounded.funcs.length()}")
println(" actions: \{grounded.actions.length()}")
println(" goal disjuncts: \{grounded.goal.length()}")
println(" initial cost: \{@pddl.format_number(grounded.initial_cost)}")
println(" cost mode: \{cost_mode(grounded)}")
if grounded.has_metric {
let direction = if grounded.metric_minimize {
"minimize"
} else {
"maximize"
}
println(" metric: \{direction}")
}
if detailed {
println("")
println("ground actions:")
for action in grounded.actions {
println(
"#\{action.id} cost=\{@pddl.format_number(action.cost)} \{action.render()}",
)
}
}
}
0
} catch {
@fs.IOError(message) => report_error(json, "cannot read input: \{message}")
err => report_error(json, err.to_string())
}
}
///|
fn run(args : Array[String]) -> Int32 {
if args.length() < 2 {
println(usage())
return 0
}
let command = args[1]
match command {
"solve" => cmd_solve(args)
"validate" => cmd_validate(args)
"check" => model_summary(args, false)
"ground" => model_summary(args, true)
"version" | "--version" => {
println("moonpddl \{VERSION}")
0
}
"help" | "--help" | "-h" => {
println(usage())
0
}
_ => {
println("error: unknown command '\{command}'")
println("")
println(usage())
2
}
}
}
///|
fn main {
@sys.exit(run(@env.args()))
}