brink-analyzer 0.0.16

Cross-file semantic analysis for inkle's ink narrative scripting language
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
//! FS-2 `await`-condition **purity gate** (docs/flow-suspension-spec.md
//! §3/§5, issue #928), built on the effects machinery (#859).
//!
//! An `await <cond>` / `while await <cond>` condition is captured as a
//! compiler-synthesized *pure* function (spec §5): the runtime re-evaluates it
//! to decide whether to wake, so it must be **read-only**. Reads are the wake
//! map's dependency set and are fine; a transitive **write** to a global cell,
//! or an effectful host **call**, makes re-evaluation itself observable, which
//! the wake contract forbids — that is the sole thing this pass rejects
//! (`E105`).
//!
//! The condition's effect is computed from the whole-project effect-row table
//! ([`crate::infer::effects_project`], already transitively closed): every
//! call in the condition is resolved through the same [`ResolutionMap`] every
//! other reference uses, and its callee's row is consulted. A call to a
//! pure knot/stitch contributes an empty row (fine); a call to one that writes
//! a global or performs an effectful call carries that through; a direct
//! `EXTERNAL` call is itself a call-atom (not read-only). An **unresolved**
//! single-segment callee — the stdlib-intrinsic shadow-fallback shape — is
//! judged against the one shared intrinsic effect table
//! ([`crate::infer::intrinsic_effects`], issue #1128): a draw-bearing
//! (`await chance(0.5)`) or fault-bearing (`await pop(a)`) intrinsic
//! directly in the condition is rejected exactly like a callee whose row
//! carries those atoms. A bare fn-value *reference* used as a dynamic
//! condition (`await some_fn_value`, no call syntax) contributes no call
//! atom and is read-only by construction — spec §3 lists it as a valid
//! form, so it is never flagged.
//!
//! Brink-only, same posture as the other effect passes: under strict-ink the
//! whole `await` is already rejected (`E051`), so critiquing its condition
//! would be noise.

use std::collections::BTreeMap;

use brink_format::DefinitionId;
use brink_ir::{
    Block, BlockStmt, Diagnostic, DiagnosticCode, Expr, FileId, HirFile, ResolutionMap, Stmt,
    SymbolIndex, SymbolKind,
};
use rowan::TextRange;

use crate::infer::EffectRow;

/// Check every `await` condition in `hir` against the whole-project effect
/// rows `rows` (docs/flow-suspension-spec.md §3/§5). Returns an `E105` for
/// each condition that is not effect-free (read-only).
#[must_use]
pub fn check(
    file: FileId,
    hir: &HirFile,
    index: &SymbolIndex,
    resolutions: &ResolutionMap,
    rows: &BTreeMap<DefinitionId, EffectRow>,
) -> Vec<Diagnostic> {
    // This file's use-site → definition map, keyed by range (the same shape
    // the effect harvester builds).
    let by_range: BTreeMap<(u32, u32), DefinitionId> = resolutions
        .iter()
        .filter(|r| r.file == file)
        .map(|r| ((r.range.start().into(), r.range.end().into()), r.target))
        .collect();

    let ctx = Ctx {
        index,
        rows,
        by_range,
    };
    let mut sites: Vec<AwaitSite<'_>> = Vec::new();
    for knot in &hir.knots {
        collect_block(&knot.body, &mut sites);
        for stitch in &knot.stitches {
            collect_block(&stitch.body, &mut sites);
        }
    }

    let mut out = Vec::new();
    for site in sites {
        if ctx.condition_is_effectful(site.condition) {
            out.push(Diagnostic {
                file,
                range: site.range,
                message: format!(
                    "{}: an `await` suspension point re-evaluates its condition to decide when \
                     to wake, so the condition must be read-only (docs/flow-suspension-spec.md \
                     §5)",
                    DiagnosticCode::E105.title(),
                ),
                code: DiagnosticCode::E105,
            });
        }
    }
    out
}

/// Cheap structural scan: does any knot/stitch body in `hir` contain an
/// `await` suspension point? The laziness gate for the whole-project purity
/// pass — an await-free project never triggers effect inference for this
/// pass, mirroring the `#@effects` exceedance pass's own laziness gate.
#[must_use]
pub fn hir_has_await(hir: &HirFile) -> bool {
    let mut sites = Vec::new();
    for knot in &hir.knots {
        collect_block(&knot.body, &mut sites);
        for stitch in &knot.stitches {
            collect_block(&stitch.body, &mut sites);
        }
        if !sites.is_empty() {
            return true;
        }
    }
    !sites.is_empty()
}

/// Every [`DefinitionId`] called (directly) from any `await` condition in
/// `hir`, resolved through `resolutions`. The salsa path
/// (`brink-db`'s `await_purity_diagnostics_query`) uses this to fetch exactly
/// those defs' memoized per-def effect rows — the incremental analogue of the
/// monolithic path handing [`check`] the whole-project [`crate::infer::effects_project`]
/// table. A callee that resolves to a non-inferable symbol (an `EXTERNAL`, a
/// VAR fn-value) simply has no per-def row; [`check`] handles it via the same
/// resolution it does here, so the two paths agree.
#[must_use]
pub fn condition_callees(
    file: FileId,
    hir: &HirFile,
    resolutions: &ResolutionMap,
) -> std::collections::BTreeSet<DefinitionId> {
    let by_range: BTreeMap<(u32, u32), DefinitionId> = resolutions
        .iter()
        .filter(|r| r.file == file)
        .map(|r| ((r.range.start().into(), r.range.end().into()), r.target))
        .collect();

    let mut sites: Vec<AwaitSite<'_>> = Vec::new();
    for knot in &hir.knots {
        collect_block(&knot.body, &mut sites);
        for stitch in &knot.stitches {
            collect_block(&stitch.body, &mut sites);
        }
    }

    let mut out = std::collections::BTreeSet::new();
    for site in sites {
        collect_call_callees(site.condition, &by_range, &mut out);
    }
    out
}

fn collect_call_callees(
    expr: &Expr,
    by_range: &BTreeMap<(u32, u32), DefinitionId>,
    out: &mut std::collections::BTreeSet<DefinitionId>,
) {
    match expr {
        Expr::Call(path, args) => {
            let key = (path.range.start().into(), path.range.end().into());
            if let Some(&def) = by_range.get(&key) {
                out.insert(def);
            }
            for a in args {
                collect_call_callees(a, by_range, out);
            }
        }
        Expr::Prefix(_, inner) | Expr::Postfix(inner, _) => {
            collect_call_callees(inner, by_range, out);
        }
        Expr::Infix(ie) => {
            collect_call_callees(&ie.lhs, by_range, out);
            collect_call_callees(&ie.rhs, by_range, out);
        }
        Expr::Index(idx) => {
            collect_call_callees(&idx.base, by_range, out);
            collect_call_callees(&idx.index, by_range, out);
        }
        Expr::FieldAccess(fa) => collect_call_callees(&fa.base, by_range, out),
        Expr::Range(r) => {
            collect_call_callees(&r.start, by_range, out);
            collect_call_callees(&r.end, by_range, out);
        }
        Expr::ArrayLiteral(a) => {
            for e in &a.elements {
                collect_call_callees(e, by_range, out);
            }
        }
        Expr::MapLiteral(m) => {
            for (k, v) in &m.entries {
                collect_call_callees(k, by_range, out);
                collect_call_callees(v, by_range, out);
            }
        }
        // A struct-construction condition (`await Flag#{on: f()}`) evaluates
        // each field initializer, so a call nested in one is a real call atom
        // of the condition and must be recursed into (PR #935 review). The
        // `Name` keys are not expressions.
        Expr::StructLiteral(sl) => {
            for (_, v) in &sl.fields {
                collect_call_callees(v, by_range, out);
            }
        }
        // Neither fn-value shape is recursed: a `FnLiteral`'s target and a
        // lambda's body (issue #1685) are not invoked during condition
        // re-evaluation, only the surrounding expression is. `Fragment`
        // (issue #1839) is never a syntactic part of an `await` condition
        // either — block capture only ever appears inside a `Stmt::Content`
        // a claim/dispatch rewrite produces.
        Expr::Int(_)
        | Expr::Float(_)
        | Expr::Bool(_)
        | Expr::String(_)
        | Expr::Null
        | Expr::Path(_)
        | Expr::DivertTarget(_)
        | Expr::ListLiteral(_)
        | Expr::FnLiteral(_)
        | Expr::Lambda(_)
        | Expr::RefArg(_)
        | Expr::Fragment(_) => {}
    }
}

/// One `await`/`while await` site: the statement range (the diagnostic
/// anchor) and its condition expression.
struct AwaitSite<'a> {
    range: TextRange,
    condition: &'a Expr,
}

struct Ctx<'a> {
    index: &'a SymbolIndex,
    rows: &'a BTreeMap<DefinitionId, EffectRow>,
    by_range: BTreeMap<(u32, u32), DefinitionId>,
}

impl Ctx<'_> {
    /// Whether `cond` performs any non-read-only effect: a transitive write,
    /// an effectful/opaque call, or a direct `EXTERNAL` call.
    fn condition_is_effectful(&self, cond: &Expr) -> bool {
        let mut effectful = false;
        self.walk_expr(cond, &mut effectful);
        effectful
    }

    fn walk_expr(&self, expr: &Expr, effectful: &mut bool) {
        if *effectful {
            return; // short-circuit — one violation is enough.
        }
        match expr {
            Expr::Call(path, args) => {
                if self.call_is_effectful(path, args.len()) {
                    *effectful = true;
                    return;
                }
                for a in args {
                    self.walk_expr(a, effectful);
                }
            }
            Expr::Prefix(_, inner) | Expr::Postfix(inner, _) => self.walk_expr(inner, effectful),
            Expr::Infix(ie) => {
                self.walk_expr(&ie.lhs, effectful);
                self.walk_expr(&ie.rhs, effectful);
            }
            Expr::Index(idx) => {
                self.walk_expr(&idx.base, effectful);
                self.walk_expr(&idx.index, effectful);
            }
            Expr::FieldAccess(fa) => self.walk_expr(&fa.base, effectful),
            // Range bounds evaluate on every condition re-check, so a call
            // nested in one is a real atom of the condition.
            Expr::Range(r) => {
                self.walk_expr(&r.start, effectful);
                self.walk_expr(&r.end, effectful);
            }
            Expr::ArrayLiteral(a) => {
                for e in &a.elements {
                    self.walk_expr(e, effectful);
                }
            }
            Expr::MapLiteral(m) => {
                for (k, v) in &m.entries {
                    self.walk_expr(k, effectful);
                    self.walk_expr(v, effectful);
                }
            }
            // A struct-construction condition (`await Flag#{on: f()}`)
            // evaluates each field initializer, so an effectful call nested in
            // one makes the condition effectful — it must be recursed into (PR
            // #935 review). The `Name` keys are not expressions.
            Expr::StructLiteral(sl) => {
                for (_, v) in &sl.fields {
                    self.walk_expr(v, effectful);
                }
            }
            // Leaves and other expression kinds carry no call atoms of their
            // own (a bare `Path` — including a fn-value reference used as a
            // dynamic condition — is read-only by construction, spec §3).
            // Neither fn-value shape is recursed: a `FnLiteral`'s target
            // and a lambda's body (issue #1685) are not invoked during
            // condition re-evaluation.
            Expr::Int(_)
            | Expr::Float(_)
            | Expr::Bool(_)
            | Expr::String(_)
            | Expr::Null
            | Expr::Path(_)
            | Expr::DivertTarget(_)
            | Expr::ListLiteral(_)
            | Expr::FnLiteral(_)
            | Expr::Lambda(_)
            | Expr::RefArg(_)
            // Internal-only (issue #1839) — see `collect_call_callees`'s
            // identical arm above for why this can never appear here.
            | Expr::Fragment(_) => {}
        }
    }

    /// Whether a call whose callee is `path` (with `arg_count` arguments)
    /// performs a non-read-only effect. Resolves the callee through the
    /// resolution map: a knot/stitch callee is judged by its (transitively
    /// closed) effect row; a direct `EXTERNAL` callee is a call-atom and
    /// therefore not read-only.
    ///
    /// An **unresolved single-segment** callee is the stdlib-intrinsic
    /// shadow-fallback shape (a real def always wins resolution first, the
    /// same dispatch rule `infer::body::infer_intrinsic` follows) — judged
    /// against the ONE shared intrinsic effect table (issue #1128,
    /// [`crate::infer::intrinsic_effects`]): a draw (`await chance(0.5)` —
    /// an RNG-cell write, NS-A6's "draws are writes") or a fault-bearing
    /// verb (`await pop(a)`) makes condition re-evaluation observable,
    /// exactly the class the resolved-callee row check already rejects.
    /// Before this consult the direct-intrinsic shape silently escaped E105
    /// because only resolved callees' rows were judged. Any other
    /// unresolved callee (a multi-segment path — already an error
    /// elsewhere) or a fn-value call is left to the LIR fence and not
    /// double-reported here.
    fn call_is_effectful(&self, path: &brink_ir::Path, arg_count: usize) -> bool {
        let range = path.range;
        let key = (range.start().into(), range.end().into());
        let Some(&def) = self.by_range.get(&key) else {
            if let [seg] = path.segments.as_slice() {
                let fx = crate::infer::intrinsic_effects(&seg.text, arg_count);
                return fx.rng_write || fx.faults;
            }
            return false;
        };
        if let Some(row) = self.rows.get(&def) {
            // `is_pessimal`, not the intrinsic `opaque` bit: a row still
            // carrying a §6.1 row variable (issue #1680) is unbounded too.
            return row.is_pessimal() || !row.writes.is_empty() || !row.calls.is_empty();
        }
        // Not an inferable knot/stitch — is it a declared EXTERNAL?
        matches!(
            self.index.symbols.get(&def).map(|s| s.kind),
            Some(SymbolKind::External)
        )
    }
}

fn collect_block<'a>(block: &'a Block, out: &mut Vec<AwaitSite<'a>>) {
    for stmt in &block.stmts {
        collect_stmt(stmt, out);
    }
}

fn collect_stmt<'a>(stmt: &'a Stmt, out: &mut Vec<AwaitSite<'a>>) {
    match stmt {
        Stmt::Await(a) => {
            if let Some(cond) = &a.condition {
                out.push(AwaitSite {
                    range: a.ptr.text_range(),
                    condition: cond,
                });
            }
        }
        Stmt::LogicBlock(lb) => {
            for bs in &lb.stmts {
                collect_block_stmt(bs, out);
            }
        }
        Stmt::ChoiceSet(cs) => {
            for choice in &cs.choices {
                collect_block(&choice.body, out);
            }
            collect_block(&cs.continuation, out);
        }
        Stmt::LabeledBlock(b) => collect_block(b, out),
        Stmt::Conditional(c) => {
            for branch in &c.branches {
                collect_block(&branch.body, out);
            }
        }
        Stmt::Sequence(s) => {
            for branch in &s.branches {
                collect_block(&branch.body, out);
            }
        }
        Stmt::Content(_)
        | Stmt::Divert(_)
        | Stmt::TunnelCall(_)
        | Stmt::ThreadStart(_)
        | Stmt::TempDecl(_)
        | Stmt::Assignment(_)
        | Stmt::Return(_)
        | Stmt::ExprStmt(_)
        | Stmt::EndOfLine
        // Issue #2108: `AttachElement`'s call expression can't embed an
        // `await` (a statement-level construct, never an expression), and
        // `EndElementRun` carries no expression at all.
        | Stmt::AttachElement(_)
        | Stmt::EndElementRun => {}
    }
}

fn collect_block_stmt<'a>(bs: &'a BlockStmt, out: &mut Vec<AwaitSite<'a>>) {
    match bs {
        BlockStmt::Await(a) => {
            if let Some(cond) = &a.condition {
                out.push(AwaitSite {
                    range: a.ptr.text_range(),
                    condition: cond,
                });
            }
        }
        BlockStmt::While(w) => {
            if w.is_await {
                out.push(AwaitSite {
                    range: w.ptr.text_range(),
                    condition: &w.condition,
                });
            }
            for s in &w.body {
                collect_block_stmt(s, out);
            }
        }
        BlockStmt::If(i) => collect_if(i, out),
        BlockStmt::For(f) => {
            for s in &f.body {
                collect_block_stmt(s, out);
            }
        }
        BlockStmt::TempDecl(_)
        | BlockStmt::Assignment(_)
        | BlockStmt::Return(_)
        | BlockStmt::ExprStmt(_)
        | BlockStmt::Break(_)
        | BlockStmt::Continue(_) => {}
    }
}

fn collect_if<'a>(i: &'a brink_ir::IfStmt, out: &mut Vec<AwaitSite<'a>>) {
    for s in &i.body {
        collect_block_stmt(s, out);
    }
    match &i.else_branch {
        Some(brink_ir::ElseBranch::ElseIf(inner)) => collect_if(inner, out),
        Some(brink_ir::ElseBranch::Else(stmts)) => {
            for s in stmts {
                collect_block_stmt(s, out);
            }
        }
        None => {}
    }
}