diff --git a/__tests__/branch-guards-languages.test.ts b/__tests__/branch-guards-languages.test.ts index 3f26844..8fa37d1 100644 --- a/__tests__/branch-guards-languages.test.ts +++ b/__tests__/branch-guards-languages.test.ts @@ -7,7 +7,7 @@ */ import { describe, it, expect, beforeAll } from 'vitest'; import { initGrammars } from '../src/extraction/grammars'; -import { callSiteInSource, decoratorsInSource, guardsInSource, guardLabel, memberTypesInSource, supportsBranchGuards } from '../src/graph/branch-guards'; +import { callSiteInSource, decoratorsInSource, guardsInSource, guardLabel, loopsInSource, memberTypesInSource, supportsBranchGuards } from '../src/graph/branch-guards'; import type { Language } from '../src/types'; beforeAll(async () => { @@ -334,3 +334,118 @@ public class OrderService : IOrderService { expect(Object.fromEntries(types)).toEqual({ _orderRepository: 'IRepository', Mailer: 'IEmailSender', orderRepository: 'IRepository', uriComposer: 'IUriComposer' }); }); }); + +describe('loops a site is written inside', () => { + /** The loop headers at the site, outermost first, as ` `. */ + async function loopsAt(src: string, needle: string, language: Language) { + const line = lineOf(src, needle); + const column = src.split('\n')[line - 1]!.indexOf(needle); + return (await loopsInSource(src, language, line, column)).map((l) => `${l.kind} ${l.text}`); + } + + it('reads a JS for-of and a while', async () => { + const src = ` +function run(items) { + for (const item of items) { + save(item) + } + while (queue.length > 0) { + drain() + } +}`; + expect(await loopsAt(src, 'save(item)', 'javascript')).toEqual(['each item of items']); + expect(await loopsAt(src, 'drain()', 'javascript')).toEqual(['while queue.length > 0']); + }); + + it('reads nested loops outermost first', async () => { + const src = ` +function run(rows) { + for (const row of rows) { + for (const cell of row) { + draw(cell) + } + } +}`; + expect(await loopsAt(src, 'draw(cell)', 'javascript')).toEqual(['each row of rows', 'each cell of row']); + }); + + it('reads nothing for a site outside every loop', async () => { + const src = ` +function run(items) { + begin() + for (const item of items) { save(item) } +}`; + expect(await loopsAt(src, 'begin()', 'javascript')).toEqual([]); + }); + + it('reads a Python for and a while', async () => { + const src = ` +def run(items): + for item in items: + save(item) + while pending: + drain() +`; + expect(await loopsAt(src, 'save(item)', 'python')).toEqual(['each item in items']); + expect(await loopsAt(src, 'drain()', 'python')).toEqual(['while pending']); + }); + + it('reads a Java enhanced for', async () => { + const src = ` +class A { + void run(List items) { + for (Item item : items) { + save(item); + } + } +}`; + expect(await loopsAt(src, 'save(item)', 'java')).toEqual(['each Item item : items']); + }); + + it('reads a Go range loop', async () => { + const src = ` +func run(items []Item) { + for _, item := range items { + save(item) + } +}`; + expect(await loopsAt(src, 'save(item)', 'go')).toEqual(['each _, item := range items']); + }); + + it('reads a C# foreach', async () => { + const src = ` +class A { + void Run(List items) { + foreach (var item in items) { + Save(item); + } + } +}`; + // The binding word is noise in a header a person reads: `var` goes. + expect(await loopsAt(src, 'Save(item)', 'csharp')).toEqual(['each item in items']); + }); + + it('reads a Swift for-in', async () => { + const src = ` +func run(items: [Item]) { + for item in items { + save(item) + } +}`; + expect(await loopsAt(src, 'save(item)', 'swift')).toEqual(['each item in items']); + }); + + it('reads a Kotlin for', async () => { + const src = ` +fun run(items: List) { + for (item in items) { + save(item) + } +}`; + expect(await loopsAt(src, 'save(item)', 'kotlin')).toEqual(['each item in items']); + }); + + it('reads nothing for a language without rules', async () => { + expect(await loopsInSource('def f\n xs.each { save }\nend\n', 'ruby', 2, 2)).toEqual([]); + }); +}); diff --git a/__tests__/ui-steps-api-servers.test.ts b/__tests__/ui-steps-api-servers.test.ts index 4009c3e..6d267c3 100644 --- a/__tests__/ui-steps-api-servers.test.ts +++ b/__tests__/ui-steps-api-servers.test.ts @@ -13,6 +13,7 @@ import * as path from 'path'; import { CodeGraph } from '../src'; import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; import { buildSteps, projectKind } from '../src/ui-server/api/steps'; +import type { WireBlock } from '../src/ui-server/api/program'; import { routeRoots } from '../src/ui-server/api/route-roots'; let tmpDir: string; @@ -279,6 +280,110 @@ const route = (name: string) => { }; const effect = (p: Awaited>, category: string) => p.steps.find((s) => s.kind === 'effect' && s.effect?.category === category); +/** + * The `program` reading, one line per item, indented — the rail as a reader + * meets it. A step prints its label, a fork its condition and each arm's, a + * bracketed run its words. + */ +function inOrder(p: Awaited>): string[] { + const label = (id: string) => p.steps.find((s) => s.id === id)?.label ?? id; + const out: string[] = []; + const walk = (block: WireBlock, indent: string): void => { + for (const item of block) { + if (item.kind === 'step') { + out.push(`${indent}${label(item.step)}${item.again ? ' (again)' : ''}`); + if (item.body) walk(item.body, `${indent} `); + } else if (item.kind === 'fork') { + out.push(`${indent}${item.form} ${item.on}`); + for (const arm of item.arms) { + out.push(`${indent} ${arm.when}${arm.ends ? ` → ${arm.ends}` : ''}`); + walk(arm.body, `${indent} `); + } + } else if (item.kind === 'block') { + out.push(`${indent}${item.block} ${item.via?.name ?? item.by ?? ''}`.trimEnd()); + walk(item.body, `${indent} `); + } else out.push(`${indent}cut ${item.why}`); + } + }; + if (p.program) walk(p.program.root, ''); + return out; +} + +describe('in the code’s order', () => { + it('reads an Express handler as it is written, helper and all', async () => { + // `create` → `emailQueue.add` → `if (!user.verified) sendVerification(user)` + // → `res.status(201).json({ token: signToken(user.id) })`. The token is + // signed INSIDE the reply, so it is drawn before the 201, not beside it. + const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /users').id })); + expect(p.defaultView).toBe('order'); + expect(inOrder(p)).toEqual([ + 'prisma.user.create({ data })', + "emailQueue.add('welcome', { userId })", + 'if !user.verified', + ' !user.verified', + ' inline sendVerification', + ' transporter.sendMail({ to })', + 'inline signToken', + ' jwt.sign({ id }, process.env.JWT_SECRET, { expiresIn })', + '201', + ]); + }); + + it('reads a Python handler, its raise ending the arm it is in', async () => { + // `session.add` / `session.commit` are one database box drawn at both its + // sites; `raise HTTPException(422)` is an early exit, so the arm that + // raises answers there and the Celery job is on the other one. + const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /items').id })); + expect(inOrder(p)).toEqual([ + 'session.add +1', + 'session.add +1', + 'if item.price < 0', + ' item.price < 0 → reply', + ' 422', + ' !(item.price < 0)', + ' send_welcome.delay(item.id)', + ]); + }); + + it('reads a Java handler, its early return as the fork’s other arm', async () => { + // `if (owner.getName() == null) return badRequest();` — one comparison + // flips rather than wrapping, so the arm that runs on says `!= null`. + const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /owners/new').id })); + expect(inOrder(p)).toEqual([ + 'if owner.getName() == null', + ' owner.getName() == null → reply', + ' 400', + ' owner.getName() != null → reply', + ' owners.save(owner)', + ' 201', + ]); + }); + + it('reads a C# handler’s two outcomes', async () => { + const p = await buildSteps(cg, tmpDir, q({ anchor: route('PUT /api/TodoItems/{id}').id })); + expect(inOrder(p)).toEqual([ + 'if id != command.Id', + ' id != command.Id → reply', + ' 400', + ' id == command.Id → reply', + ' 204', + ]); + }); + + it('reads a Nest handler through the service it delegates to', async () => { + const p = await buildSteps(cg, tmpDir, q({ anchor: route('POST /cats').id })); + expect(inOrder(p)).toEqual(['inline create', ' this.catsRepository.save(dto)', ' handleIndex']); + }); + + it('has no order to read for a screen, and says so', async () => { + // A route with an inline handler still has a body; what has none is a + // symbol nothing in the picture happens inside. + const p = await buildSteps(cg, tmpDir, q({ symbol: 'RolesGuard' })); + expect(p.program).toBeNull(); + expect(p.defaultView).toBe('tree'); + }); +}); + describe('route roots', () => { it('names the handler an API route runs, the route itself for an inline handler', () => { const roots = routeRoots(cg, cg.getNodesByKind('route')); diff --git a/__tests__/ui-steps-program.test.ts b/__tests__/ui-steps-program.test.ts index b8c35cb..692f787 100644 --- a/__tests__/ui-steps-program.test.ts +++ b/__tests__/ui-steps-program.test.ts @@ -9,7 +9,7 @@ */ import { describe, it, expect } from 'vitest'; -import type { BranchGuard } from '../src/graph/branch-guards'; +import type { BranchGuard, SiteLoop } from '../src/graph/branch-guards'; import { buildProgram, type ProgramInput, type ProgramSite, type WireBlock, type WireItem } from '../src/ui-server/api/program'; /* ------------------------------------------------------------ material -- */ @@ -27,6 +27,11 @@ function at(step: string, guards: BranchGuard[] = [], extra: Partial = {}): ProgramSite { const line = nextLine++; @@ -235,6 +240,28 @@ describe('buildProgram', () => { expect(shape(p!.root)).toEqual(['together Promise.all', ' a inside Promise.all', ' b inside Promise.all', 'c']); }); + it('says a run of calls happens once per item', () => { + const p = program({ + root: [at('before'), at('each', [], { loops: [loop('item of items')] }), at('after')], + }); + expect(shape(p!.root)).toEqual(['before', 'loop item of items', ' each', 'after']); + }); + + it('nests a loop and a fork by which one is written outside the other', () => { + // `for (…) { if (ready) { go() } }` — the loop starts first, so it is + // outside; the guard's own branch position is what decides, not its order + // in the chain. + const inner = g('ready', { branch: '9:4' }); + const p = program({ root: [at('go', [inner], { loops: [loop('item of items', 'each', '8:2')] })] }); + expect(shape(p!.root)).toEqual(['loop item of items', ' if ready', ' arm ready', ' go']); + + // `if (ready) { for (…) { go() } }` — the same two constructs, the other + // way round, told apart by where each begins. + const outer = g('ready', { branch: '8:2' }); + const q = program({ root: [at('go', [outer], { loops: [loop('item of items', 'each', '9:4')] })] }); + expect(shape(q!.root)).toEqual(['if ready', ' arm ready', ' loop item of items', ' go']); + }); + it('closes a fork when the code leaves it', () => { const cond = g('ready'); const p = program({ root: [at('inside', [cond]), at('after')] }); diff --git a/src/graph/branch-guards.ts b/src/graph/branch-guards.ts index 0d1e304..c1df7c3 100644 --- a/src/graph/branch-guards.ts +++ b/src/graph/branch-guards.ts @@ -947,6 +947,114 @@ export async function guardsInSource( } } +/** Loops for one site in source text — the test seam; production reads files. */ +export async function loopsInSource(source: string, language: Language, line: number, column: number | null = null): Promise { + if (!supportsBranchGuards(language)) return []; + const tree = await parse(source, language); + if (!tree) return []; + try { + return loopsInTree(tree.rootNode, source, language, line, column); + } finally { + tree.delete(); + } +} + +/** A loop a site is written inside: its header as written, and where the loop starts. */ +export interface SiteLoop { + /** `const item of items`, `i = 0; i < n; i++`, `queue.length > 0` — the header, without its keyword. */ + text: string; + /** `each` for a `for` / `foreach` / `for … in`, `while` for a `while` / `do` / `repeat`. */ + kind: 'each' | 'while'; + /** Where the loop starts, `line:column` — the same identity a guard's `branch` carries. */ + branch: string; +} + +/** Loop node types across the grammars with rules here. A type absent yields nothing, never a wrong label. */ +const LOOP_TYPES: ReadonlyMap = new Map([ + ['for_statement', 'each'], + ['for_in_statement', 'each'], + ['for_of_statement', 'each'], + ['for_each_statement', 'each'], + ['enhanced_for_statement', 'each'], + ['foreach_statement', 'each'], + ['for_range_loop', 'each'], + ['for_expression', 'each'], + ['while_statement', 'while'], + ['while_expression', 'while'], + ['do_statement', 'while'], + ['do_while_statement', 'while'], + ['repeat_while_statement', 'while'], +]); + +/** + * The loops a site is written inside, outermost first — what tells a reading in + * the code's order that a run of calls happens once PER ITEM rather than once. + * The climb is the guards' climb (the same boundaries, the same transparent + * inline functions), so a callback's body is read in its own function and a + * `.forEach` body under the loop it is written in. + */ +export function loopsInTree(root: SyntaxNode, source: string, language: Language, line: number, column: number | null): SiteLoop[] { + const rules = RULES_BY_LANGUAGE.get(language); + if (!rules) return []; + const row = line - 1; + if (row < 0) return []; + let col = column ?? 0; + if (column === null) { + const text = source.split('\n')[row] ?? ''; + const first = text.search(/\S/); + col = first < 0 ? 0 : first; + } + let node: SyntaxNode | null = innermostAt(root, row, col); + if (!node) return []; + const found: SiteLoop[] = []; + while (node) { + const parent: SyntaxNode | null = node.parent; + if (!parent || rules.boundaries.has(parent.type)) break; + if (rules.inlineFunctions.has(parent.type)) { + const holder = parent.parent?.type ?? ''; + if (rules.bindingParents.has(holder)) break; + node = parent; + continue; + } + const kind = LOOP_TYPES.get(parent.type); + // The header, not the body: a site is in the loop only when it is under it. + if (kind && !isField(parent, 'condition', node) && !isField(parent, 'value', node)) { + const text = loopHeader(parent); + if (text) found.push({ text, kind, branch: branchKey(parent) }); + } + node = parent; + } + found.reverse(); + return found; +} + +/** Loops for many sites in one file, keyed by {@link siteKey}. */ +export async function loopsForFile(absPath: string, language: Language, sites: readonly CallSite[]): Promise> { + const out = new Map(); + if (!supportsBranchGuards(language) || sites.length === 0) return out; + const cached = await treeFor(absPath, language); + if (!cached) return out; + for (const site of sites) { + const key = siteKey(site); + if (!out.has(key)) out.set(key, loopsInTree(cached.tree.rootNode, cached.source, language, site.line, site.column ?? null)); + } + return out; +} + +/** A loop's header as written, keyword and braces dropped: `item of items`, `queue.length > 0`. */ +function loopHeader(loop: SyntaxNode): string { + const body = loop.childForFieldName('body') ?? namedChildren(loop).find((c) => BLOCKISH.has(c.type)) ?? null; + const raw = body && body.startIndex > loop.startIndex ? loop.text.slice(0, body.startIndex - loop.startIndex) : loop.text; + let text = raw.replace(/\s+/g, ' ').trim(); + text = text.replace(/^(?:for|foreach|while|do|repeat)\b\s*/i, ''); + text = text.replace(/[{:]\s*$/, '').trim(); + const inner = /^\((.*)\)$/s.exec(text); + if (inner) text = inner[1]!.trim(); + // `const item of items` reads as `item of items`; the binding word is noise here. + text = text.replace(/^(?:const|let|var|val|final)\s+/, ''); + return cut(text, 60); +} + /** * The walk. `line` is 1-based, `column` 0-based (null → first non-blank). * Returns the guards outermost first — execution order, the way a reader diff --git a/src/ui-server/api/program.ts b/src/ui-server/api/program.ts index d90181f..81cef32 100644 --- a/src/ui-server/api/program.ts +++ b/src/ui-server/api/program.ts @@ -26,7 +26,7 @@ * string can never say. */ -import { guardLabel, type BranchGuard } from '../../graph/branch-guards'; +import { guardLabel, type BranchGuard, type SiteLoop } from '../../graph/branch-guards'; import type { WireNodeRef } from './wire'; // ============================================================================= @@ -63,7 +63,17 @@ export type WireItem = * after this function returns (`later`), or calls started together * (`together`). */ - | { kind: 'block'; block: 'inline' | 'loop' | 'later' | 'together'; by?: string; via?: WireNodeRef; within?: string; body: WireBlock; again?: true } + | { + kind: 'block'; + block: 'inline' | 'loop' | 'later' | 'together'; + by?: string; + /** For a loop: whether it runs once per item or while a condition holds. */ + loop?: 'each' | 'while'; + via?: WireNodeRef; + within?: string; + body: WireBlock; + again?: true; + } /** Where the reading stopped: a helper that calls itself, or a cap the walk hit. */ | { kind: 'cut'; why: 'folded' | 'depth' }; @@ -91,6 +101,8 @@ export interface ProgramSite { within?: string; /** The conditions it runs under, outermost first. */ guards: BranchGuard[]; + /** The loops it is written inside, outermost first. */ + loops?: SiteLoop[]; /** What fires it, when something binds it — a callback runs LATER. */ trigger?: { kind: string; name: string; of?: string | null }; } @@ -151,50 +163,36 @@ function blockFor(input: ProgramInput, fn: string, path: readonly string[], stat if (sites.length === 0) return []; const root: WireBlock = []; - /** The forks open at the site being placed, outermost first. */ - const stack: Array<{ branch: string; fork: Extract; armKey: string; arm: WireArm; exit?: WireArmEnd }> = []; - const blockAt = (depth: number): WireBlock => (depth === 0 ? root : stack[depth - 1]!.arm.body); + /** The constructs open at the site being placed, outermost first. */ + const stack: Open[] = []; + const bodyAt = (depth: number): WireBlock => (depth === 0 ? root : stack[depth - 1]!.body); for (const site of sites) { - const guards = site.guards; + const scopes = scopesOf(site); - // The longest prefix of open forks the site still sits under, arm and all. + // The longest run of open constructs the site still sits inside — same + // construct AND, for a fork, the same arm of it. let keep = 0; - while (keep < stack.length && keep < guards.length && stack[keep]!.branch === guards[keep]!.branch && stack[keep]!.armKey === armKey(guards[keep]!)) { - keep++; - } - // The level after it may still be the SAME decision taken the other way — - // an `else`, another `case`, the code after an early exit. That keeps the - // fork and opens its other arm; anything deeper is closed either way. - if (keep < stack.length && keep < guards.length && stack[keep]!.branch === guards[keep]!.branch) { + while (keep < stack.length && keep < scopes.length && sameScope(stack[keep]!, scopes[keep]!)) keep++; + // The construct after it may still be the SAME decision taken the other + // way — an `else`, another `case`, the code after an early exit. That keeps + // the fork and opens its other arm; anything deeper is closed either way. + const open = stack[keep]; + const scope = scopes[keep]; + if (open && scope && open.branch === scope.branch && open.fork && scope.kind === 'guard') { stack.length = keep + 1; - const open = stack[keep]!; - open.armKey = armKey(guards[keep]!); - open.arm = armFor(open.fork, guards[keep]!); + open.armKey = armKey(scope.guard); + open.body = armFor(open.fork, scope.guard).body; keep++; } else { stack.length = keep; } - for (let i = keep; i < guards.length; i++) { - const g = guards[i]!; - // The decision as a reader says it, not as the guard stores it: a - // disjunction keeps the parentheses that stop `a || b` from reading as - // two ways of arriving (`guardLabel` is the one place that decides). - const fork: Extract = { kind: 'fork', on: guardLabel([{ ...g, negated: false }]), form: formOf(g), arms: [] }; - // An early exit is a fork whose OTHER arm left before this site could - // run: `if (!product) throw` — the throw is written first, so it is the - // first arm, and it is empty because nothing in the picture happens there. - if (g.form === 'guard' && g.negated) { - fork.arms.push({ when: guardLabel([{ ...g, negated: false }]), ends: g.exit ?? 'return', body: [] }); - } - blockAt(i).push(fork); - stack.push({ branch: g.branch, fork, armKey: armKey(g), arm: armFor(fork, g) }); - } + for (let i = keep; i < scopes.length; i++) stack.push(openScope(bodyAt(i), scopes[i]!)); const item = itemFor(input, site, path, state); if (item !== null) { state.items++; - place(blockAt(guards.length), item, site); + place(bodyAt(scopes.length), item, site); } } @@ -203,6 +201,66 @@ function blockFor(input: ProgramInput, fn: string, path: readonly string[], stat return root; } +/** A construct the reading is inside: a loop, or one arm of a fork. */ +interface Open { + branch: string; + /** Set for a fork; a loop has only its body. */ + fork?: Extract; + armKey?: string; + /** Where items at this level go. */ + body: WireBlock; +} + +type Scope = { kind: 'guard'; branch: string; guard: BranchGuard } | { kind: 'loop'; branch: string; loop: SiteLoop }; + +/** + * The constructs a site is written inside, outermost first: its guards and its + * loops merged by where each one STARTS. Both were read by the same climb up + * the same ancestors, and on one ancestor chain an outer construct always + * begins before an inner one — so the positions alone rebuild the nesting, + * without either reading having to know about the other. + */ +function scopesOf(site: ProgramSite): Scope[] { + const guards: Scope[] = site.guards.map((guard) => ({ kind: 'guard' as const, branch: guard.branch, guard })); + const loops: Scope[] = (site.loops ?? []).map((loop) => ({ kind: 'loop' as const, branch: loop.branch, loop })); + if (loops.length === 0) return guards; + return [...guards, ...loops].sort((a, b) => at(a.branch) - at(b.branch) || a.branch.localeCompare(b.branch)); +} + +/** A `line:column` branch as one number, for ordering constructs by where they start. */ +function at(branch: string): number { + const [line, column] = branch.split(':'); + return (Number(line) || 0) * 10000 + (Number(column) || 0); +} + +function sameScope(open: Open, scope: Scope): boolean { + if (open.branch !== scope.branch) return false; + if (scope.kind === 'loop') return !open.fork; + return !!open.fork && open.armKey === armKey(scope.guard); +} + +/** Open a construct: a bracketed loop, or a fork with the arm this site is in. */ +function openScope(into: WireBlock, scope: Scope): Open { + if (scope.kind === 'loop') { + const block: WireItem = { kind: 'block', block: 'loop', by: scope.loop.text, loop: scope.loop.kind, body: [] }; + into.push(block); + return { branch: scope.branch, body: (block as Extract).body }; + } + const g = scope.guard; + // The decision as a reader says it, not as the guard stores it: a + // disjunction keeps the parentheses that stop `a || b` from reading as two + // ways of arriving (`guardLabel` is the one place that decides). + const fork: Extract = { kind: 'fork', on: guardLabel([{ ...g, negated: false }]), form: formOf(g), arms: [] }; + // An early exit is a fork whose OTHER arm left before this site could run: + // `if (!product) throw` — the throw is written first, so it is the first arm, + // and it is empty because nothing in the picture happens there. + if (g.form === 'guard' && g.negated) { + fork.arms.push({ when: guardLabel([{ ...g, negated: false }]), ends: g.exit ?? 'return', body: [] }); + } + into.push(fork); + return { branch: scope.branch, fork, armKey: armKey(g), body: armFor(fork, g).body }; +} + /** What one recorded site draws as. */ function itemFor(input: ProgramInput, site: ProgramSite, path: readonly string[], state: Reading): WireItem | null { if (site.into) { diff --git a/src/ui-server/api/steps.ts b/src/ui-server/api/steps.ts index f9868ae..a4522a5 100644 --- a/src/ui-server/api/steps.ts +++ b/src/ui-server/api/steps.ts @@ -41,7 +41,7 @@ import type CodeGraph from '../../index'; import type { Edge, Language, Node, UnresolvedReference } from '../../types'; import { badRequest, intParam, notFound } from './respond'; import { createSiteReader } from './when'; -import type { BranchGuard, SiteTrigger } from '../../graph/branch-guards'; +import type { BranchGuard, SiteLoop, SiteTrigger } from '../../graph/branch-guards'; import { buildProgram, type ProgramSite, type WireProgram } from './program'; import { classifyEffect, implicitResponseStatus, responseStatus, type Effect } from './effects'; import { guardLabel } from '../../graph/branch-guards'; @@ -383,6 +383,8 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS const calls = createSiteReader(cg, projectRoot, MAX_CALL_SITES); /** The conditions a site runs under, structured — one read, joined where a string is wanted. */ const guardsAt = (caller: Node, site: { line?: number; column?: number }) => reader.guards(caller, site); + /** The loops a site is written inside — a run of calls that happens once per item. */ + const loopsAt = (caller: Node, site: { line?: number; column?: number }) => reader.loops(caller, site); const argsAt = (caller: Node, site: { line?: number; column?: number }) => reader.args(caller, site); const withArgs = async (site: WireStepSite, caller: Node, at: { line?: number; column?: number }): Promise => { const args = await argsAt(caller, at); @@ -553,7 +555,8 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS hop: HopSite, guards: readonly BranchGuard[], what: { step?: string; link?: string; into?: string }, - trigger: WireStepTrigger | null = null + trigger: WireStepTrigger | null = null, + loops: readonly SiteLoop[] = [] ): void => { let sites = programs.get(fn.id); if (!sites) { @@ -567,6 +570,7 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS at: { line: hop.line, column: hop.column, end: hop.end }, ...(hop.within ? { within: hop.within } : {}), guards: [...guards], + ...(loops.length > 0 ? { loops: [...loops] } : {}), ...(trigger ? { trigger } : {}), }); }; @@ -754,7 +758,7 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS if (!target.first) target.first = hop; const fired = trigger ?? (await triggerAt(fold.node, at)); const id = link(step, target, 'effect', fold.chain, [...fold.whens, when], wireSite, null, fired, hop.within); - record(fold.node, local, guards, { step: target.id, link: id }, fired); + record(fold.node, local, guards, { step: target.id, link: id }, fired, await loopsAt(fold.node, at)); return true; }; @@ -1145,7 +1149,7 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS const hop = fold.first ?? local; if (!to.first) to.first = hop; const id = link(step, to, a.linkKind, fold.chain, [...fold.whens, when], site, a.e, a.trigger, hop.within); - record(fold.node, local, guards, { step: to.id, link: id }, a.trigger); + record(fold.node, local, guards, { step: to.id, link: id }, a.trigger, await loopsAt(fold.node, at)); if (to.root !== null && !explored.has(to.id)) { explored.add(to.id); queue.push(to); @@ -1183,7 +1187,7 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS const local = await hopAt(fold.node, at, target.name); const hop = fold.first ?? local; const id = link(step, known, 'calls', fold.chain, [...fold.whens, guardLabel(guards)], await withArgs(a.site, fold.node, at), e, a.trigger, hop.within); - record(fold.node, local, guards, { step: known.id, link: id }, a.trigger); + record(fold.node, local, guards, { step: known.id, link: id }, a.trigger, await loopsAt(fold.node, at)); } continue; } @@ -1210,7 +1214,7 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS const first = fold.first ?? local; // The helper is drawn where it is CALLED: its own records are its // body, and this is the site the rail nests them under. - record(fold.node, local, guards, { into: target.id }, a.trigger); + record(fold.node, local, guards, { into: target.id }, a.trigger, await loopsAt(fold.node, at)); next.push({ node: target, chain: [...fold.chain, target], whens: [...fold.whens, guardLabel(guards)], first }); } } diff --git a/src/ui-server/api/when.ts b/src/ui-server/api/when.ts index fe3b3bf..673edab 100644 --- a/src/ui-server/api/when.ts +++ b/src/ui-server/api/when.ts @@ -19,6 +19,7 @@ import { decoratorsForFile, guardLabel, guardsForFile, + loopsForFile, memberTypesForFile, siteKey, supportsBranchGuards, @@ -26,6 +27,7 @@ import { type BranchGuard, type CallSiteText, type DefinitionDecorators, + type SiteLoop, type SiteTrigger, } from '../../graph/branch-guards'; import { resolveProjectFile } from '../security'; @@ -106,6 +108,8 @@ export interface SiteReader { * What {@link SiteReader.when} joins; empty when unconditional or unreadable. */ guards(caller: { filePath: string; language: Language }, site: { line?: number; column?: number }): Promise; + /** The loops the site is written inside, outermost first — a run of calls that happens once per item. */ + loops(caller: { filePath: string; language: Language }, site: { line?: number; column?: number }): Promise; /** What the site passes, abbreviated (`'userEmail', values.email`); null when unreadable. '' for an empty list. */ args(caller: { filePath: string; language: Language }, site: { line?: number; column?: number }): Promise; /** What fires the site — the JSX prop, `on*` option or runs-later call it is written under; null when nothing binds it. */ @@ -175,6 +179,15 @@ export function createSiteReader(cg: CodeGraph, projectRoot: string, maxSites = async when(caller, site) { return guardLabel(await guards(caller, site)); }, + async loops(caller, site) { + // Not counted against the budget: the tree is parsed for the site's + // guards anyway, and this is a second climb up the same nodes. + if (!site.line || !supportsBranchGuards(caller.language)) return []; + const file = resolve(caller); + if (!file) return []; + const key = { line: site.line, column: typeof site.column === 'number' ? site.column : null }; + return (await loopsForFile(file.abs, file.language, [key])).get(siteKey(key)) ?? []; + }, async args(caller, site) { if (!site.line || sites >= maxSites || !supportsBranchGuards(caller.language)) return null; const file = resolve(caller); diff --git a/ui/src/lib/program-model.ts b/ui/src/lib/program-model.ts index 1f3679c..6600329 100644 --- a/ui/src/lib/program-model.ts +++ b/ui/src/lib/program-model.ts @@ -174,7 +174,8 @@ export function groupLabel(item: Extract): string { case 'inline': return item.via ? `via ${item.via.name}` : 'via a helper'; case 'loop': - return item.by ? `for each ${item.by}` : 'for each'; + if (!item.by) return item.loop === 'while' ? 'again and again' : 'for each item'; + return item.loop === 'while' ? `again while ${item.by}` : `for each ${item.by}`; case 'later': return item.by ? `later · ${item.by}` : 'later'; default: diff --git a/ui/src/lib/wire.ts b/ui/src/lib/wire.ts index f6f03ca..88e5e3d 100644 --- a/ui/src/lib/wire.ts +++ b/ui/src/lib/wire.ts @@ -838,7 +838,17 @@ export type WireItem = * after this function returns (`later`), or calls started together * (`together`). */ - | { kind: 'block'; block: 'inline' | 'loop' | 'later' | 'together'; by?: string; via?: WireNodeRef; within?: string; body: WireBlock; again?: true } + | { + kind: 'block'; + block: 'inline' | 'loop' | 'later' | 'together'; + by?: string; + /** For a loop: whether it runs once per item or while a condition holds. */ + loop?: 'each' | 'while'; + via?: WireNodeRef; + within?: string; + body: WireBlock; + again?: true; + } /** Where the reading stopped: a helper that calls itself, or a cap the walk hit. */ | { kind: 'cut'; why: 'folded' | 'depth' };