oximo-gams 0.5.1

GAMS writer and backend for oximo
Documentation
//! Code generation for GAMS sub-solver option structs.
//!
//! Reads each `option-snapshots/<solver>.txt` and emits, into `$OUT_DIR/gams_generated.rs`:
//!
//! - one `gams_params! { ... }` invocation per solver, expanding to the option
//!   struct, one setter per option, and an enum per enumerated option, and
//! - the `GamsSolverConfig` enum with a variant per solver plus `Named`/`Raw`,
//!   and its `gams_name`/`write_opt_file`/`From<GamsSolver>` impls.
//!
//! Snapshot line format, one option per line:
//!
//! ```text
//! (int, threads, "threads"),
//! (any, solver, "solver", ["choose", "simplex", "ipm"]),
//! ```
//!
//! `kind` is the type GAMS declares (`int`/`dbl`/`bool`/`str`), or `any` when
//! the solver's manual publishes no detailed entry to declare one. A trailing
//! list marks an option enumerated over strings, which becomes an enum.

use std::collections::HashSet;
use std::fmt::Write as _;
use std::path::Path;
use std::{env, fs};

fn main() {
    let manifest = env::var("CARGO_MANIFEST_DIR").unwrap();
    let snap_dir = Path::new(&manifest).join("option-snapshots");
    println!("cargo:rerun-if-changed=build.rs");
    println!("cargo:rerun-if-changed=option-snapshots");

    let mut files: Vec<_> = fs::read_dir(&snap_dir)
        .expect("option-snapshots directory")
        .filter_map(Result::ok)
        .map(|e| e.path())
        .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("txt"))
        .collect();
    files.sort();

    let solvers: Vec<Solver> = files
        .iter()
        .map(|path| {
            let text = fs::read_to_string(path).expect("read snapshot");
            let stem = path.file_stem().and_then(|s| s.to_str()).expect("utf8 stem");
            Solver::new(stem, &text)
        })
        .collect();

    let mut out = String::new();
    out.push_str("// @generated by build.rs from option-snapshots/*.txt - do not edit.\n\n");

    for s in &solvers {
        writeln!(
            out,
            "gams_params! {{\n    #[doc = \"Options for GAMS/{name}. \
             Reference: <https://www.gams.com/latest/docs/S_{name}.html>\"]\n    \
             {struct_}, {sep:?},",
            name = s.gams_name,
            struct_ = s.struct_ident,
            sep = s.sep,
        )
        .unwrap();

        out.push_str("    options: [\n");
        for o in s.options.iter().filter(|o| o.values.is_empty()) {
            writeln!(out, "        ({}, {}, {:?}),", o.kind, o.method, o.key).unwrap();
        }
        out.push_str("    ],\n    enums: [\n");
        for o in s.options.iter().filter(|o| !o.values.is_empty()) {
            let variants = o
                .values
                .iter()
                .map(|(ident, value)| format!("{ident} = {value:?}"))
                .collect::<Vec<_>>()
                .join(", ");
            writeln!(
                out,
                "        ({}, {}, {:?}, [{}]),",
                o.enum_ident.as_ref().unwrap(),
                o.method,
                o.key,
                variants
            )
            .unwrap();
        }
        out.push_str("    ],\n}\n\n");
    }

    emit_solver_config(&mut out, &solvers);

    let dest = Path::new(&env::var("OUT_DIR").unwrap()).join("gams_generated.rs");
    fs::write(&dest, out).expect("write generated file");
}

/// Emit the `GamsSolverConfig` enum, its dispatch impls, and the test metadata.
fn emit_solver_config(out: &mut String, solvers: &[Solver]) {
    out.push_str(
        "/// Selects a GAMS sub-solver and optionally carries typed options written\n\
         /// to a `<solver>.opt` file before invoking GAMS.\n\
         ///\n\
         /// One variant per solver oximo supports from a modelling standpoint\n\
         /// (generated from `option-snapshots/`), plus [`Named`](Self::Named) to\n\
         /// pick any solver by name and [`Raw`](Self::Raw) to write verbatim\n\
         /// option lines for a named solver.\n\
         #[derive(Clone, Debug)]\n\
         pub enum GamsSolverConfig {\n",
    );
    for s in solvers {
        writeln!(out, "    {}({}),", s.variant, s.struct_ident).unwrap();
    }
    out.push_str(
        "    /// Any solver selectable by name with no typed option file.\n\
        \x20   Named(GamsSolver),\n\
         \x20   /// A solver selected by name with raw option-file lines written verbatim.\n\
        \x20   Raw(GamsSolver, Vec<String>),\n\
         }\n\n",
    );

    out.push_str("impl GamsSolverConfig {\n");
    out.push_str(
        "    /// GAMS solver keyword for `option {LP|MIP} = ...;` and the\n\
        \x20   /// `<name>.opt` file name.\n\
        \x20   #[must_use]\n\
        \x20   pub fn gams_name(&self) -> &str {\n        match self {\n",
    );
    for s in solvers {
        writeln!(out, "            Self::{}(_) => {:?},", s.variant, s.gams_name).unwrap();
    }
    out.push_str("            Self::Named(s) | Self::Raw(s, _) => s.name(),\n        }\n    }\n\n");
    out.push_str(
        "    /// Write the `.opt` file body into `buf`. Returns `true` if anything\n\
        \x20   /// was written (the caller then sets `model.optfile = 1`).\n\
        \x20   #[must_use]\n\
        \x20   pub fn write_opt_file(&self, buf: &mut String) -> bool {\n        \
         use std::fmt::Write as _;\n        match self {\n",
    );
    for s in solvers {
        writeln!(out, "            Self::{}(o) => o.render(buf),", s.variant).unwrap();
    }
    out.push_str(
        "            Self::Named(_) => false,\n\
        \x20           Self::Raw(_, lines) => {\n\
        \x20               for line in lines {\n\
        \x20                   let _ = writeln!(buf, \"{line}\");\n\
        \x20               }\n\
        \x20               !lines.is_empty()\n\
        \x20           }\n        }\n    }\n}\n\n",
    );

    out.push_str(
        "impl From<GamsSolver> for GamsSolverConfig {\n\
        \x20   fn from(s: GamsSolver) -> Self {\n        Self::Named(s)\n    }\n}\n\n",
    );

    out.push_str(
        "/// `(gams_name, separator, option_count)` for every generated solver.\n\
         /// Test-only: lets the suite cross-check codegen against the snapshots.\n\
         #[cfg(test)]\n\
         pub(crate) const GENERATED_SOLVERS: &[(&str, &str, usize)] = &[\n",
    );
    for s in solvers {
        writeln!(out, "    ({:?}, {:?}, {}),", s.gams_name, s.sep, s.options.len()).unwrap();
    }
    out.push_str("];\n");
}

struct Option_ {
    kind: String,
    method: String,
    key: String,
    values: Vec<(String, String)>,
    enum_ident: Option<String>,
}

struct Solver {
    gams_name: String,
    variant: String,
    struct_ident: String,
    sep: &'static str,
    options: Vec<Option_>,
}

impl Solver {
    fn new(stem: &str, text: &str) -> Self {
        let mut lines = text.lines();
        let header = lines.next().unwrap_or("");
        let gams_name = header.trim_start_matches("//").trim().to_string();
        let gams_name = if gams_name.is_empty() { stem.to_uppercase() } else { gams_name };

        let variant = capitalize(stem);
        let struct_ident = format!("Gams{variant}Options");

        // Some solvers share one option-file reader, documented identically
        // as "an option name and value separated by an equal sign (=) and
        // any amount of white space".
        let sep = match gams_name.as_str() {
            "HIGHS" | "SCIP" | "SOPLEX" | "SHOT" => " = ",
            _ => " ",
        };

        let options = lines
            .filter(|l| l.trim_start().starts_with('('))
            .map(|l| parse_line(l.trim(), &variant))
            .collect();

        Self { gams_name, variant, struct_ident, sep, options }
    }
}

/// Parse `(kind, method, "key"),` or `(kind, method, "key", ["a", "b"]),`.
fn parse_line(line: &str, solver_variant: &str) -> Option_ {
    let body = line.trim_start_matches('(').trim_end_matches(',').trim_end_matches(')');

    let (kind, rest) = body.split_once(',').expect("kind");
    let (method, rest) = rest.split_once(',').expect("method");
    let kind = kind.trim().to_string();
    let method = method.trim().to_string();

    let rest = rest.trim();
    let (key, list) = match rest.strip_prefix('"').and_then(|r| r.find('"').map(|i| (r, i))) {
        Some((r, end)) => (r[..end].to_string(), r[end + 1..].trim().to_string()),
        None => (rest.trim_matches('"').to_string(), String::new()),
    };

    // Trailing `, [ "a", "b" ]` marks a string enumeration.
    let mut values: Vec<(String, String)> = Vec::new();
    if let Some(inner) = list.trim_start_matches(',').trim().strip_prefix('[') {
        let inner = inner.trim_end_matches(']');
        let mut seen: HashSet<String> = HashSet::new();
        let mut ok = true;
        for raw in inner.split(',') {
            let value = raw.trim().trim_matches('"');
            if value.is_empty() {
                continue;
            }
            let ident = variant_ident(value);
            if ident.is_empty() || !seen.insert(ident.clone()) {
                ok = false;
                break;
            }
            values.push((ident, value.to_string()));
        }
        if !ok || values.len() < 2 {
            values.clear();
        }
    }

    let enum_ident =
        (!values.is_empty()).then(|| format!("Gams{solver_variant}{}", pascal(&method)));

    Option_ { kind, method, key, values, enum_ident }
}

/// PascalCase identifier for an enum variant built from an option value, e.g.
/// `MSK_OPTIMIZER_FREE` -> `MskOptimizerFree`, `simplex` -> `Simplex`.
fn variant_ident(value: &str) -> String {
    let cleaned: String =
        value.chars().map(|c| if c.is_alphanumeric() { c } else { '_' }).collect();
    let ident = pascal(&cleaned);
    if ident.is_empty() {
        return String::new();
    }
    if ident.starts_with(|c: char| c.is_ascii_digit()) { format!("V{ident}") } else { ident }
}

fn pascal(s: &str) -> String {
    s.split('_').filter(|p| !p.is_empty()).map(capitalize_lower).collect()
}

fn capitalize_lower(s: &str) -> String {
    let mut c = s.chars();
    match c.next() {
        Some(f) => f.to_ascii_uppercase().to_string() + &c.as_str().to_ascii_lowercase(),
        None => String::new(),
    }
}

fn capitalize(s: &str) -> String {
    let mut c = s.chars();
    match c.next() {
        Some(f) => f.to_ascii_uppercase().to_string() + c.as_str(),
        None => String::new(),
    }
}