alef 0.67.3

Opinionated polyglot binding generator for Rust libraries
Documentation
use crate::core::hash::{self, CommentStyle};

/// Strip trailing whitespace from every line and ensure the file ends with a single newline.
pub(super) fn strip_trailing_whitespace(content: &str) -> String {
    let mut result: String = content
        .lines()
        .map(|line| line.trim_end())
        .collect::<Vec<_>>()
        .join("\n");
    if !result.ends_with('\n') {
        result.push('\n');
    }
    result
}

/// Generate C# file header with hash and nullable-enable pragma.
pub(super) fn csharp_file_header() -> String {
    let mut out = hash::header(CommentStyle::DoubleSlash);
    out.push_str("#nullable enable\n\n");
    out
}

/// Generate Directory.Build.props with Nullable=enable and LangVersion=latest.
/// This is auto-generated (overwritten on each build) so it doesn't require user maintenance.
pub(super) fn gen_directory_build_props() -> String {
    "<!-- auto-generated by alef (generate_bindings) -->\n\
<Project>\n  \
<PropertyGroup>\n    \
<Nullable>enable</Nullable>\n    \
<LangVersion>latest</LangVersion>\n    \
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>\n  \
</PropertyGroup>\n\
</Project>\n"
        .to_string()
}

/// Visitor support files earlier alef releases wrote at the namespace root and that
/// `gen_visitor_files` no longer emits under any configuration — the modern configured-bridge
/// path folds both into `TraitBridges.cs`.
const VISITOR_SUPPORT_FILES: [&str; 2] = ["IVisitor.cs", "VisitorCallbacks.cs"];

/// Filenames a `visitor_callbacks`-enabled run supersedes: the two support files
/// `TraitBridges.cs` now carries.
pub(super) fn superseded_visitor_filenames() -> Vec<String> {
    VISITOR_SUPPORT_FILES.iter().map(|name| (*name).to_string()).collect()
}

/// Filenames a run without visitor callbacks does not emit: the two support files plus every
/// configured trait bridge's `context_type` / `result_type` class.
///
/// Note what the second group is: names taken straight out of the consumer's own
/// `[[trait_bridges]]` entries. `{ContextType}.cs` under the consumer's own namespace directory
/// is exactly as likely to be a file a human wrote as one alef did, which is why nothing here is
/// eligible for unlinking on a filename match. ~keep
pub(super) fn stale_visitor_filenames(config: &crate::core::config::ResolvedCrateConfig) -> Vec<String> {
    let mut stale_files = superseded_visitor_filenames();
    stale_files.extend(config.trait_bridges.iter().filter_map(|bridge| {
        bridge
            .context_type
            .as_deref()
            .map(|name| format!("{}.cs", crate::codegen::naming::csharp_type_name(name)))
    }));
    stale_files.extend(config.trait_bridges.iter().filter_map(|bridge| {
        bridge
            .result_type
            .as_deref()
            .map(|name| format!("{}.cs", crate::codegen::naming::csharp_type_name(name)))
    }));
    stale_files
}

/// Report visitor support files present under `base_path` that this run did not emit.
/// **Reports only — never deletes.** Returns the reported paths so a test can assert on the
/// surface without reading the log.
///
/// ## What this replaces
///
/// Two `fs::remove_file` loops (`delete_superseded_visitor_files`, `delete_stale_visitor_files`)
/// called from `generate_bindings` — that is, from inside the stage
/// `bin_cli::helpers::collect_managed_surface` documents as "a pure in-memory render; nothing
/// here writes to disk". `alef verify` and `alef adopt` both compose that stage, and `alef diff`
/// calls `pipeline::generate` directly, so three read-only commands unlinked files in the
/// consumer's tree. `base_path` is also relative (`resolve_output_dir` returns the configured
/// `[crates.output]` string verbatim), so the unlink resolved against the process working
/// directory rather than the project root it was pointed at.
///
/// ## Why reporting, and not a narrower delete
///
/// `cli::pipeline::generate::orphans::report_disk_scan_candidates` already settled this trade in
/// this codebase. Its candidates cleared five gates — alef marker, git-tracked, under an owned
/// output root, absent from the run's keep set, non-degenerate manifest for that root — and it
/// still only reports, because a consumer's hand-written 408-line Java public API class cleared
/// all five. The deletes removed here cleared none of them: a filename match was the entire test.
///
/// The disabled branch's trigger was weaker again. `config.ffi` is an `Option`, and an absent
/// `[ffi]` section read as `visitor_callbacks == false`, so a consumer who had simply never
/// written an `[ffi]` section was treated identically to one who had explicitly disabled the
/// feature. Requiring an explicit `false` would fix only that third fault and leave the other two
/// standing; a marker/ownership gate would leave a read-only command deleting, and the precedent
/// above has already found that gate insufficient. The asymmetry decides it exactly as it did
/// there: a file left behind costs a CS8632 warning and a line in the log naming the path; a file
/// wrongly removed costs a consumer source file nobody reads by hand. ~keep
///
/// ~keep `emitted` is the set of paths this run is actually writing, and it is the difference
/// between a true statement and a false one. The check used to be `path.is_file()` alone, run
/// before the type and enum emitters had pushed anything: in the branch where `visitor_callbacks`
/// is off (which includes a consumer having no `[ffi]` section at all, since `unwrap_or(false)`
/// cannot tell that from an explicit `false`) the candidate list is `{context_type}.cs` and
/// `{result_type}.cs` from `[[trait_bridges]]` -- and those emitters, whose skip is gated on
/// `has_visitor_callbacks`, go on to write exactly those files. So the run emitted the files and
/// reported them as unemitted in the same breath, on every generate, adopt, verify and diff.
pub(super) fn report_unemitted_visitor_files(
    base_path: &std::path::Path,
    filenames: &[String],
    emitted: &std::collections::HashSet<std::path::PathBuf>,
) -> Vec<std::path::PathBuf> {
    let present: Vec<std::path::PathBuf> = filenames
        .iter()
        .map(|filename| base_path.join(filename))
        .filter(|path| path.is_file() && !emitted.contains(path))
        .collect();
    if present.is_empty() {
        return present;
    }
    tracing::warn!(
        "gen_bindings(csharp): {} visitor support file(s) under {} were not emitted by this run. These are NOT \
         deleted: this generator cannot tell a file an older alef release wrote from one a human wrote at the same \
         path, and an absent `[ffi]` config section is not a decision to disable visitor callbacks. Review each and \
         remove by hand if genuinely stale:",
        present.len(),
        base_path.display()
    );
    for path in &present {
        tracing::warn!("  unemitted visitor support file: {}", path.display());
    }
    present
}