///|
suberror SubclassCommandError {
Failure(String)
} derive(Debug)
///|
/// Raw CLI values. Optional overrides stay separate from the resolved command
/// so the common one-input invocation can discover every development detail.
struct SubclassCommandOptions {
inputs : Array[String]
config_path : String?
gir_dirs : Array[String]
output_dir : String?
symbol_prefix : String?
}
///|
struct SubclassCommand {
source : @subclass_model.SubclassSource
config_path : String
gir_search_path : GirSearchPath
output_dir : @path.Path
parent_import_alias : String
symbol_prefix : String
}
///|
async fn SubclassCommandOptions::read_text(
self : SubclassCommandOptions,
path : String,
) -> String raise SubclassCommandError {
ignore(self)
let data = @fs.read_file(path) catch {
error =>
raise SubclassCommandError::Failure("failed to read \{path}: \{error}")
}
data.text() catch {
error =>
raise SubclassCommandError::Failure(
"failed to decode \{path} as text: \{error}",
)
}
}
///|
/// Read all subclass declarations before resolving defaults. Qualified parent
/// aliases and the declared GType provide the stable inferred values.
fn SubclassCommandOptions::load_source(
self : SubclassCommandOptions,
) -> @subclass_model.SubclassSource raise SubclassCommandError {
let source = @subclass_model.SubclassSource::new()
for input in self.inputs {
let parser = @subclass_parser.SubclassParser(input)
let parsed = parser.parse_file(input) catch {
error => raise SubclassCommandError::Failure(error.message())
}
source.append(parsed)
}
source
}
///|
/// The command currently generates one parent binding family at a time. The
/// custom subclasses themselves remain types in the consuming MoonBit package;
/// this value only selects the external binding import used for their parents.
fn SubclassCommandOptions::resolve_parent_import_alias(
self : SubclassCommandOptions,
source : @subclass_model.SubclassSource,
) -> String raise SubclassCommandError {
ignore(self)
let mut import_alias : String? = None
for declaration in source.declarations {
guard declaration.parent.split_once(".") is Some((qualifier, parent_name)) &&
qualifier.has_prefix("@") &&
qualifier.length() > 1 &&
parent_name != "" &&
!parent_name.contains(".") else {
raise SubclassCommandError::Failure(
"\{declaration.name}: parent '\{declaration.parent}' must be qualified as @pkg.Type",
)
}
let next = qualifier[1:].to_owned()
if import_alias is Some(current) && current != next {
raise SubclassCommandError::Failure(
"one invocation cannot load parent bindings from both @\{current} and @\{next}",
)
}
import_alias = Some(next)
}
if import_alias is Some(resolved_alias) {
return resolved_alias
}
raise SubclassCommandError::Failure(
"cannot determine a parent binding package because the input has no #gobject.subclass declaration",
)
}
///|
fn SubclassCommandOptions::infer_symbol_prefix(
self : SubclassCommandOptions,
source : @subclass_model.SubclassSource,
) -> String raise SubclassCommandError {
ignore(self)
guard source.declarations.get(0) is Some(declaration) else {
raise SubclassCommandError::Failure(
"cannot infer a C symbol prefix because the input has no #gobject.subclass declaration",
)
}
let gtype_name = declaration.gtype_name.unwrap_or(
"MoonBit\{declaration.name}",
)
@case_conv.to_snake(gtype_name)
}
///|
fn SubclassCommandOptions::infer_output_dir(
self : SubclassCommandOptions,
) -> @path.Path raise SubclassCommandError {
guard self.inputs.get(0) is Some(first) else {
raise SubclassCommandError::Failure("at least one input file is required")
}
let output_dir = @path.Path(first).resolve().dirname()
for input in self.inputs[1:] {
let next = @path.Path(input).resolve().dirname()
if next != output_dir {
raise SubclassCommandError::Failure(
"input files are in different directories; pass --output",
)
}
}
output_dir
}
///|
fn SubclassCommandOptions::search_roots(
self : SubclassCommandOptions,
) -> Array[@path.Path] {
let roots : Array[@path.Path] = []
for input in self.inputs {
let root = @path.Path(input).resolve().dirname()
if !roots.contains(root) {
roots.push(root)
}
}
if @env.current_dir() is Some(current_dir) {
let root = @path.Path(current_dir).resolve()
if !roots.contains(root) {
roots.push(root)
}
}
roots
}
///|
async fn SubclassCommandOptions::path_exists(
self : SubclassCommandOptions,
path : @path.Path,
) -> Bool raise SubclassCommandError {
ignore(self)
@fs.exists(path.0) catch {
error =>
raise SubclassCommandError::Failure(
"failed to inspect \{path.0}: \{error}",
)
}
}
///|
/// Search both a bindings monorepo and dependency sources materialized under
/// `.mooncakes`, walking upward from the inputs and current directory.
async fn SubclassCommandOptions::find_config_path(
self : SubclassCommandOptions,
parent_binding : PackageImport,
) -> String raise SubclassCommandError {
for root in self.search_roots() {
let mut directory = root
for ;; {
let local_config = parent_binding.local_config_path(directory)
if self.path_exists(local_config) {
return local_config.0
}
let mooncakes = directory.join(@path.Path(".mooncakes"))
if self.path_exists(mooncakes) {
let dependency = parent_binding.materialized_config_path(mooncakes)
if self.path_exists(dependency) {
return dependency.0
}
}
let parent = directory.dirname()
if parent == directory {
break
}
directory = parent
}
}
raise SubclassCommandError::Failure(
"cannot find gir.json for @\{parent_binding.import_alias} imported from '\{parent_binding.import_path}'; pass --config",
)
}
///|
async fn SubclassCommandOptions::load_config(
self : SubclassCommandOptions,
config_path : String,
) -> @config.GirConfig raise SubclassCommandError {
let config_text = self.read_text(config_path)
@json.from_json(@json.parse(config_text)) catch {
error =>
raise SubclassCommandError::Failure(
"failed to parse \{config_path}: \{error}",
)
}
}
///|
/// Build the platform search order without assuming one package manager.
/// One or more explicit `--gir-dir` values bypass automatic discovery.
fn SubclassCommandOptions::gir_search_path(
self : SubclassCommandOptions,
) -> GirSearchPath {
if !self.gir_dirs.is_empty() {
return GirSearchPath(self.gir_dirs[:])
}
let directories : Array[String] = []
if @env.get_env_var("GI_GIR_PATH") is Some(paths) {
for path in paths.split("\{@path.delimiter}") {
directories.push(path.to_owned())
}
}
if @env.get_env_var("XDG_DATA_DIRS") is Some(paths) {
for path in paths.split("\{@path.delimiter}") {
directories.push(
@path.Path(path.to_owned()).join(@path.Path("gir-1.0")).0,
)
}
}
if @env.get_env_var("HOMEBREW_PREFIX") is Some(prefix) {
directories.push(@path.Path(prefix).join(@path.Path("share/gir-1.0")).0)
}
directories.push("/opt/homebrew/share/gir-1.0")
directories.push("/opt/local/share/gir-1.0")
directories.push("/usr/local/share/gir-1.0")
directories.push("/usr/share/gir-1.0")
GirSearchPath(directories[:])
}
///|
/// Resolve all optional CLI fields before any generated file is written.
async fn SubclassCommandOptions::resolve(
self : SubclassCommandOptions,
) -> SubclassCommand raise SubclassCommandError {
let source = self.load_source()
let parent_import_alias = self.resolve_parent_import_alias(source)
let config_path = if self.config_path is Some(path) {
path
} else {
let manifest = self.load_package_manifest()
let parent_binding = manifest.resolve_import(parent_import_alias)
self.find_config_path(parent_binding)
}
let config = self.load_config(config_path)
let gir_search_path = self.gir_search_path()
gir_search_path.validate(config)
let output_dir = if self.output_dir is Some(path) {
@path.Path(path)
} else {
self.infer_output_dir()
}
let symbol_prefix = if self.symbol_prefix is Some(prefix) {
prefix
} else {
self.infer_symbol_prefix(source)
}
{
source,
config_path,
gir_search_path,
output_dir,
parent_import_alias,
symbol_prefix,
}
}
///|
async fn SubclassCommand::read_text(
self : SubclassCommand,
path : String,
) -> String raise SubclassCommandError {
ignore(self)
let data = @fs.read_file(path) catch {
error =>
raise SubclassCommandError::Failure("failed to read \{path}: \{error}")
}
data.text() catch {
error =>
raise SubclassCommandError::Failure(
"failed to decode \{path} as text: \{error}",
)
}
}
///|
/// Formatting is optional, but failure to report a formatting problem is not.
async fn SubclassCommand::write_warning(
self : SubclassCommand,
message : String,
) -> Unit raise SubclassCommandError {
ignore(self)
@stdio.stderr.write("gir-subclass: warning: \{message}\n") catch {
error =>
raise SubclassCommandError::Failure(
"failed to report formatter warning: \{error}",
)
}
}
///|
async fn SubclassCommand::read_gir(
self : SubclassCommand,
gir_name : String,
) -> @gir_parser.GirNamespace raise SubclassCommandError {
let gir_path = self.gir_search_path.resolve_gir(gir_name)
let source = self.read_text(gir_path.0)
@gir_parser.parse_gir(source) catch {
error =>
raise SubclassCommandError::Failure(
"failed to parse \{gir_path.0}: \{error.to_string()}",
)
}
}
///|
async fn SubclassCommand::load_repository(
self : SubclassCommand,
) -> @subclass_generator.SubclassRepository raise SubclassCommandError {
let config_text = self.read_text(self.config_path)
let config : @config.GirConfig = @json.from_json(@json.parse(config_text)) catch {
error =>
raise SubclassCommandError::Failure(
"failed to parse \{self.config_path}: \{error}",
)
}
let parent_namespace = self.read_gir(config.gir)
let repository = @subclass_generator.SubclassRepository::new(
parent_namespace,
"@\{self.parent_import_alias}",
config.classes,
config.header,
selected_aliases=config.aliases,
selected_interfaces=config.interfaces,
selected_enums=config.enums,
selected_records=config.records,
record_lifecycles=config.record_lifecycles,
) catch {
error => raise SubclassCommandError::Failure(error.message())
}
for gir_name, dependency in config.deps {
let gir_namespace = self.read_gir(gir_name)
repository.register_namespace(
gir_namespace,
dependency.import_path,
dependency.classes,
selected_aliases=dependency.aliases,
selected_interfaces=dependency.interfaces,
selected_enums=dependency.enums,
selected_records=dependency.records,
) catch {
error => raise SubclassCommandError::Failure(error.message())
}
}
repository
}
///|
async fn SubclassCommand::write_files(
self : SubclassCommand,
files : Array[@subclass_generator.GeneratedFile],
) -> Unit raise SubclassCommandError {
let output_exists = @fs.exists(self.output_dir.0) catch {
error =>
raise SubclassCommandError::Failure(
"failed to inspect \{self.output_dir.0}: \{error}",
)
}
if !output_exists {
@fs.mkdir(self.output_dir.0, permission=0o755, recursive=true) catch {
error =>
raise SubclassCommandError::Failure(
"failed to create \{self.output_dir.0}: \{error}",
)
}
}
let c_files : Array[String] = []
let moonbit_files : Array[String] = []
for file in files {
let path = self.output_dir.join(@path.Path(file.filename))
@fs.write_file(
path.0,
file.content,
create_mode=CreateOrTruncate,
permission=0o644,
) catch {
error =>
raise SubclassCommandError::Failure(
"failed to write \{path.0}: \{error}",
)
}
@stdio.stdout.write(" \{path.0}\n") catch {
error =>
raise SubclassCommandError::Failure(
"failed to report generated file \{path.0}: \{error}",
)
}
let extension = path.extname()
if extension == ".c" || extension == ".h" {
c_files.push(path.0)
} else if extension == ".mbt" {
moonbit_files.push(path.0)
}
}
// Generated files are committed and may also be `dev_build` outputs. Format
// them when the tools are available, but do not turn clang-format into a
// runtime requirement for every consuming module.
if !c_files.is_empty() {
let arguments : Array[String] = ["-i"]
arguments.append(c_files[:])
let exit_code = @process.run("clang-format", arguments[:]) catch {
error => {
self.write_warning("failed to run clang-format: \{error}")
-1
}
}
if exit_code != 0 && exit_code != -1 {
self.write_warning("clang-format exited with status \{exit_code}")
}
}
for path in moonbit_files {
let (exit_code, output) = @process.collect_stdout("moonfmt", [path][:]) catch {
error => {
self.write_warning("failed to run moonfmt for \{path}: \{error}")
(-1, "")
}
}
if exit_code == 0 {
@fs.write_file(
path,
output,
create_mode=CreateOrTruncate,
permission=0o644,
) catch {
error =>
raise SubclassCommandError::Failure(
"failed to write formatted \{path}: \{error}",
)
}
} else if exit_code != -1 {
self.write_warning("moonfmt exited with status \{exit_code} for \{path}")
}
}
}
///|
async fn SubclassCommand::run(
self : SubclassCommand,
) -> Unit raise SubclassCommandError {
let repository = self.load_repository()
let generator = @subclass_generator.SubclassGenerator::new(
repository,
self.symbol_prefix,
) catch {
error => raise SubclassCommandError::Failure(error.message())
}
let files = generator.generate(self.source) catch {
error => raise SubclassCommandError::Failure(error.message())
}
self.write_files(files)
}
///|
fn SubclassCommandOptions::parse(
argv? : ArrayView[String],
) -> SubclassCommandOptions raise {
let command = @argparse.Command(
"gir-subclass",
about="Generate MoonBit and C definitions for GIR-backed GObject subclasses",
positionals=[
@argparse.PositionArg(
"inputs",
about="MoonBit source files containing subclass declarations and V implementations",
num_args=@argparse.ValueRange(lower=1),
),
],
options=[
@argparse.OptionArg(
"config",
long="config",
about="Override the inferred gir.json for the parent binding package",
),
@argparse.OptionArg(
"symbol-prefix",
long="symbol-prefix",
about="Override the C identifier prefix inferred from the subclass GType",
),
@argparse.OptionArg(
"output",
short='o',
long="output",
about="Override the input directory used for generated .mbt and .c files",
),
@argparse.OptionArg(
"gir-dir",
long="gir-dir",
action=@argparse.OptionAction::Append,
about="Add a GIR search directory; repeat to search multiple directories in order",
),
],
)
let matches = command.parse(argv?)
{
inputs: matches.values.get("inputs").unwrap_or([]),
config_path: matches.values.get("config").map(values => values[0]),
gir_dirs: matches.values.get("gir-dir").unwrap_or([]),
output_dir: matches.values.get("output").map(values => values[0]),
symbol_prefix: matches.values.get("symbol-prefix").map(values => values[0]),
}
}
///|
async fn main {
let options = SubclassCommandOptions::parse() catch {
error => {
@stdio.stderr.write("\{error}\n")
@sys.exit(2)
return
}
}
options.resolve().run() catch {
SubclassCommandError::Failure(message) => {
@stdio.stderr.write("gir-subclass: \{message}\n")
// `rule` / `dev_build` must observe generation failures, not just text.
@sys.exit(1)
}
}
}