///|
/// Drop every row in which a gating column has a null cell. The schema
/// (column names, dtypes, order) is preserved verbatim; only the row
/// count shrinks.
///
/// `subset` selects the gating columns — MoonFrame's single null-dropping
/// verb (Polars' `df.drop_nulls(subset)`):
/// - **omitted** → every column gates (drop a row null in **any** column).
///   An `N×0` frame is the degenerate case: no column's validity can fail, so
///   all `N` rows pass and the output is structurally identical to the input.
///   (`DataFrame::DataFrame([])` is the `N = 0` instance of that, not a rule of
///   its own — a column-less frame built through `select([])` keeps its height.)
/// - **`Some(keys)`** → only the columns named by `keys` gate; a row null in
///   an unlisted column is kept. Each key is an `Expr` resolved to a column
///   name through `Expr::output_name` — a bare
///   `col("x")` names `"x"`. The `Array[Expr]` container mirrors `drop` and
///   leaves room for a future column selector; today only `col` / aliased
///   keys are meaningful and the expression is never evaluated.
/// - **`Some([])`** → no gating columns, so every row passes: a no-op
///   identity, schema preserved.
///
/// Duplicate keys are tolerated and act idempotently; asking the same
/// column to gate twice is the same as asking once.
///
/// Raises:
/// - `ColumnNotFound(name)` — a resolved name does not exist in `self`.
///   Reported on the first offending key in `subset` order. (Cannot arise
///   when `subset` is omitted: the names come straight from the frame.)
pub fn DataFrame::drop_nulls(
  self : DataFrame,
  subset? : Array[@expr.Expr],
) -> DataFrame raise @types.DataError {
  // Resolve the gating columns, in order: an omitted `subset` gates on every
  // column (always resolvable); a present `subset` resolves each key by its
  // `output_name`, raising `ColumnNotFound` on the first unknown name so a bad
  // name surfaces before any per-row work. A repeated key resolves the same
  // column twice, which the AND below absorbs — drops stay idempotent.
  let gating = match subset {
    None => self.column_series()
    Some(keys) => self.resolve_subset_columns(keys)
  }
  // No gating column carries a null (so also: an empty gate set) → no row can
  // be dropped. Return `self` rather than materialising masks and gathering
  // an identical copy; `null_count` is O(1) for a `Numeric` column.
  if gating.all(c => c.null_count() == 0) {
    return self
  }
  // Materialise each gating column's validity mask (`validity_bools` skips the
  // throwaway bitmap for any `Numeric` gating column in the mix).
  let masks = gating.map(c => validity_bools(c))
  // Keep row `i` when every gating mask is valid there. The kept indices come
  // straight from `[0, nrows)`, so `gather` cannot fail.
  let kept = Array::makei(self.nrows(), i => i).filter(i => masks.all(m => m[i]))
  self.gather(kept)
}