brink-ir 0.0.17

Intermediate representations for inkle's ink narrative scripting language
Documentation
//! Choice and gather lowering.
//!
//! Implements [`LowerChoice`] on `ast::Choice` and provides gather lowering.

use brink_syntax::SyntaxKind;
use brink_syntax::ast::{self, AstNode};

use crate::provenance::{KindToken, NodeClass, Provenance};
use crate::{
    Block, Choice, Content, ContentPart, DiagnosticCode, Divert, Expr, InfixExpr, InfixOp, Stmt,
    Tag,
};

use super::backbone::{BodyChild, classify_body_child};
use super::content::{ContentAccumulator, DirectBackend, lower_content_node_children, lower_tags};
use super::context::{LowerScope, LowerSink, Lowered};
use super::divert::{LowerDivert, lower_divert_target_with_args};
use super::expr::LowerExpr;
use super::helpers::{content_ends_with_glue, name_from_ident};

// ─── LowerChoice trait ──────────────────────────────────────────────

/// Extension trait for lowering a choice AST node.
pub trait LowerChoice {
    fn lower_choice(&self, scope: &LowerScope, sink: &mut impl LowerSink) -> Lowered<Choice>;
}

/// Provenance for a choice content region's own node — `None` when the
/// node's range is empty (issue #3181 review finding). A region node the
/// parser hands back can be zero-width (a choice with no start text before
/// its `[bracket]`), and B0.3 admission's E124 rejects an empty range
/// unconditionally (`docs/hir-admission-contract.md` §1.3) — stamping one
/// anyway would turn a harmless `None` into an admission failure on
/// perfectly valid source. `None` here is the honest answer: a zero-width
/// node has no real span to point at.
fn content_region_ptr(scope: &LowerScope, node: &brink_syntax::SyntaxNode) -> Option<Provenance> {
    if node.text_range().is_empty() {
        return None;
    }
    Some(scope.prov(NodeClass::Content, node))
}

/// True when a choice content region (`start`/`bracket`/`inner`) carries no
/// real text or tags — either the region never parsed at all (`None`), or
/// it parsed to an empty node (e.g. `* []`'s `CHOICE_BRACKET_CONTENT`, a
/// zero-width node the parser still creates for grammar uniformity). Used
/// only by the `E195` "completely empty choice" check (#3365) below.
fn content_region_is_textless(content: Option<&Content>) -> bool {
    content.is_none_or(|c| c.parts.is_empty() && c.tags.is_empty())
}

#[expect(
    clippy::too_many_lines,
    reason = "choice lowering has many CST regions"
)]
impl LowerChoice for ast::Choice {
    fn lower_choice(&self, scope: &LowerScope, sink: &mut impl LowerSink) -> Lowered<Choice> {
        // The parser only builds a CHOICE node after seeing a bullet token,
        // so self.bullets() always returns Some (lane-A audit, #709: E019 is
        // unreachable).
        let Some(bullets) = self.bullets() else {
            unreachable!("parser guarantees bullets in CHOICE node")
        };
        let is_sticky = bullets.is_sticky();

        let label = self.label().and_then(|l| name_from_ident(&l.identifier()?));

        let is_fallback = self.start_content().is_none()
            && self.bracket_content().is_none()
            && self.inner_content().is_none();

        // Several `{cond}` guards on one choice fold into an `and` chain.
        // The folded nodes are *synthesized* — no single CST node spells
        // them — but they still carry real provenance (issue #1517): the
        // range covering the two operands, in this file, with the synthetic
        // raw kind that marks "no live syntax node to resolve back to".
        let condition = self
            .conditions()
            .filter_map(|c| {
                let expr = c.expr()?;
                let range = expr.syntax().text_range();
                expr.lower_expr(scope, sink).ok().map(|e| (range, e))
            })
            .reduce(|(a_range, a), (b_range, b)| {
                let range = a_range.cover(b_range);
                let ptr =
                    Provenance::new(scope.file_id, range, KindToken::synthetic(NodeClass::Infix));
                (range, Expr::Infix(InfixExpr::new(ptr, a, InfixOp::And, b)))
            })
            .map(|(_, e)| e);

        // `ptr: Some(...)`, not `None` (issue #3181): each region's own CST
        // node (`sc`/`bc`/`ic`) carries a real range right here — same as
        // `content_line.rs`'s top-level `ContentLineOutput::Content` stamps
        // `NodeClass::Content` from `self.syntax()`. Before this fix these
        // three were the one HIR-level case (of the sites the issue's
        // codegen investigation traced back) where the location was
        // genuinely available and simply never captured, not a threading
        // gap downstream — every `lir::Content::source_location` computed
        // from a choice region was `None` purely because of this, no
        // matter how far codegen's own fix threaded it.
        //
        // `content_region_ptr` guards the one real trap here (review
        // finding, #3181): a choice with no visible start text before a
        // `[bracket]` (e.g. `* [The wager.] -> …`) still gets a
        // `CHOICE_START_CONTENT` node from the parser — zero-width, for
        // grammar uniformity — and B0.3 admission's E124 loudly rejects an
        // *empty* provenance range (`docs/hir-admission-contract.md` §1.3),
        // exactly the failure `fogg_passage_exhibit_lowers_and_is_admission
        // _clean` caught. `None` for a zero-width region is the honest
        // answer (there is no real span to point at), not a regression of
        // this fix.
        let mut start_content = self.start_content().map(|sc| {
            let ptr = content_region_ptr(scope, sc.syntax());
            let mut parts = lower_content_node_children(sc.syntax(), scope, sink);
            replace_trailing_ws_with_spring(&mut parts);
            Content {
                ptr,
                parts,
                tags: Vec::new(),
            }
        });

        let bracket_content = self.bracket_content().map(|bc| {
            let bracket_tags: Vec<Tag> = bc
                .syntax()
                .children()
                .filter_map(ast::Tags::cast)
                .flat_map(|t| lower_tags(Some(t), scope, sink))
                .collect();
            Content {
                ptr: content_region_ptr(scope, bc.syntax()),
                parts: lower_content_node_children(bc.syntax(), scope, sink),
                tags: bracket_tags,
            }
        });

        let mut inner_content = self.inner_content().map(|ic| Content {
            ptr: content_region_ptr(scope, ic.syntax()),
            parts: lower_content_node_children(ic.syntax(), scope, sink),
            tags: Vec::new(),
        });

        // Distribute choice-level tags to the appropriate content region.
        {
            let mut last_region = "start";
            for child in self.syntax().children() {
                match child.kind() {
                    SyntaxKind::CHOICE_START_CONTENT => last_region = "start",
                    SyntaxKind::CHOICE_BRACKET_CONTENT => last_region = "bracket",
                    SyntaxKind::CHOICE_INNER_CONTENT => last_region = "inner",
                    SyntaxKind::TAGS => {
                        let tags_node = ast::Tags::cast(child);
                        let lowered = lower_tags(tags_node, scope, sink);
                        match last_region {
                            "start" => {
                                if let Some(ref mut sc) = start_content {
                                    sc.tags.extend(lowered);
                                }
                            }
                            _ => {
                                if let Some(ref mut ic) = inner_content {
                                    ic.tags.extend(lowered);
                                } else if let Some(ref mut sc) = start_content {
                                    sc.tags.extend(lowered);
                                }
                            }
                        }
                    }
                    _ => {}
                }
            }
        }

        let inline_divert = self.divert().and_then(|d| {
            let target = d
                .simple_divert()?
                .targets()
                .next()
                .and_then(|t| lower_divert_target_with_args(&t, scope, sink))?;
            Some(Divert {
                ptr: Some(scope.prov(NodeClass::Divert, d.syntax())),
                target,
            })
        });

        let tags = Vec::new();
        let has_empty_simple_divert = self.divert().is_some_and(|d| {
            d.simple_divert()
                .is_some_and(|sd| sd.targets().next().is_none())
        });
        let skip_divert = inline_divert.is_some() || has_empty_simple_divert;
        let mut body = lower_choice_body(self, skip_divert, scope, sink);

        let mut preamble = Vec::new();
        if let Some(d) = inline_divert {
            preamble.push(Stmt::Divert(d));
        }
        preamble.push(Stmt::EndOfLine);
        preamble.append(&mut body.stmts);
        body.stmts = preamble;

        // #3365: warn when this choice offers nothing to distinguish it —
        // no same-line divert (with or without a target), no tags directly
        // on the choice line, and no real text in any of its three content
        // regions — matching inklecate's "Choice is completely empty"
        // warning (`InkParser/InkParser_Choices.cs:84-86`; line 90's
        // "Blank choice" warning, guarded by a different condition on the
        // `* [] some text` shape, is a deliberate non-goal here).
        //
        // A `(label)` or `{condition}` guard does NOT exempt a choice from
        // this check — the reference's own `emptyContent` computation
        // (`startContent`/`innerContent`/`optionOnlyContent`) has no such
        // carve-out, and measurement against inklecate confirms it: both
        // `* (opt)` and `VAR x = true\n* {x}`, each followed by a blank
        // line, still emit the warning.
        //
        // Must check `self.divert()` here, on the AST, rather than as a
        // later pass over the lowered `Choice`: an explicit-but-empty
        // divert (`* ->`) and no divert at all (`* []`) are
        // indistinguishable once lowered — both leave no `Stmt::Divert` in
        // `body.stmts` above — so the raw token's presence is the only
        // place this evidence survives (`docs/diagnostics/E195.md`).
        //
        // `self.all_tags()` (not each content region's own `.tags`) is what
        // actually catches a tag directly on the choice line (`* #tag`):
        // when there is no preceding `CHOICE_START_CONTENT`/bracket/inner
        // region node at all, the tag-distribution loop above has nowhere
        // to attribute the lowered tag to, so a per-region `.tags.is_empty()`
        // check alone is dead code for that shape. Matches inklecate, which
        // does not warn on a tag-only choice either (measured).
        if self.divert().is_none()
            && self.all_tags().next().is_none()
            && content_region_is_textless(start_content.as_ref())
            && content_region_is_textless(bracket_content.as_ref())
            && content_region_is_textless(inner_content.as_ref())
        {
            sink.diagnose(self.syntax().text_range(), DiagnosticCode::E195);
        }

        Ok(Choice {
            ptr: scope.prov(NodeClass::Choice, self.syntax()),
            is_sticky,
            is_fallback,
            label,
            condition,
            // The ink grammar has no `as` binding anywhere — `AS_BINDING`
            // is a `brink-syntax-native`-only node (`hir::Choice::binding`'s
            // doc) — so this is always `None` for an ink-sourced choice.
            binding: None,
            start_content,
            bracket_content,
            inner_content,
            tags,
            body,
            container_id: None,
        })
    }
}

// ─── Choice body ────────────────────────────────────────────────────

/// Lower the body of a choice using the classifier + accumulator pattern.
///
/// The choice's structural children (bullets, label, content regions, tags)
/// are skipped by the classifier (they're `Structural`/`Trivia`). Only
/// body-level children (content lines, logic lines, diverts, inline logic)
/// are dispatched through the accumulator.
fn lower_choice_body(
    choice: &ast::Choice,
    skip_divert: bool,
    scope: &LowerScope,
    sink: &mut impl LowerSink,
) -> Block {
    let choice_divert_range = if skip_divert {
        choice.divert().map(|d| d.syntax().text_range())
    } else {
        None
    };

    let mut acc = ContentAccumulator::new(DirectBackend::new(), scope.file_id);

    for child in choice.syntax().children() {
        // Skip the inline divert if it was already captured.
        if choice_divert_range.is_some_and(|r| r == child.text_range()) {
            continue;
        }

        match classify_body_child(&child) {
            BodyChild::ContentLine(cl) => {
                acc.handle(&cl, scope, sink);
            }
            BodyChild::LogicLine(ll) => {
                acc.handle(&ll, scope, sink);
            }
            BodyChild::DivertNode(dn) => {
                acc.handle(&dn, scope, sink);
            }
            BodyChild::InlineLogic(il) => {
                acc.handle(&il, scope, sink);
            }
            BodyChild::MultilineBlock(mb) => {
                acc.handle(&mb, scope, sink);
            }
            BodyChild::TagLine(tl) => {
                acc.handle(&tl, scope, sink);
            }
            BodyChild::AnnotationLine(al) => {
                // NS-A2: never a recognized placement inside a choice body.
                super::directive::handle_annotation_line(&al, sink);
            }
            // Choice structural parts + weave items are skipped.
            BodyChild::Choice(_)
            | BodyChild::Gather(_)
            | BodyChild::Structural
            | BodyChild::Trivia => {}
        }
    }

    acc.finish()
}

// ─── Gather ─────────────────────────────────────────────────────────

/// Lower an AST gather into a continuation `Block`.
pub fn lower_gather_to_block(
    gather: &ast::Gather,
    scope: &LowerScope,
    sink: &mut impl LowerSink,
) -> Block {
    let label = gather
        .label()
        .and_then(|l| name_from_ident(&l.identifier()?));

    let content = gather.mixed_content().map(|mc| Content {
        ptr: None,
        parts: lower_content_node_children(mc.syntax(), scope, sink),
        tags: Vec::new(),
    });

    let divert_stmt = gather
        .divert()
        .and_then(|d| d.lower_divert(scope, sink).ok());
    let tags = lower_tags(gather.tags(), scope, sink);

    let mut stmts = Vec::new();
    let has_content = content
        .as_ref()
        .is_some_and(|c| !c.parts.is_empty() || !tags.is_empty());
    let ends_glue = content
        .as_ref()
        .is_some_and(|c| content_ends_with_glue(&c.parts));
    if let Some(c) = content
        && has_content
    {
        stmts.push(Stmt::Content(Content {
            ptr: None,
            parts: c.parts,
            tags,
        }));
    }
    if let Some(d) = divert_stmt {
        stmts.push(d);
    } else if has_content && !ends_glue {
        stmts.push(Stmt::EndOfLine);
    }

    let tail = crate::tail_from_stmts(&stmts);
    Block {
        label,
        stmts,
        container_id: None,
        tail,
    }
}

// ─── Helpers ────────────────────────────────────────────────────────

fn replace_trailing_ws_with_spring(parts: &mut Vec<ContentPart>) {
    if let Some(ContentPart::Text(t)) = parts.last_mut()
        && t.ends_with(char::is_whitespace)
    {
        let trimmed = t.trim_end().to_string();
        if trimmed.is_empty() {
            parts.pop();
        } else {
            *t = trimmed;
        }
        parts.push(ContentPart::Spring);
    }
}