feat(steps): a run of calls that happens once per item says so
A body drawn once, with nothing to say it repeats, is a quiet lie about the order — so the reading now reads the loops a site is written inside, the same way it reads its conditions: one climb up the same ancestors, per language, `for` / `foreach` / `for … in` / `while` / `do` / `repeat`, with the header as written (`item of items`, `queue.length > 0`) and where the loop starts. Loops and forks nest in either direction, and neither reading knows about the other, so the block builder merges them by where each construct BEGINS: on one ancestor chain the outer one always starts first, which rebuilds the nesting from the positions alone. A `for` inside an `if` and an `if` inside a `for` come out the way the code has them. With it, the per-framework readings are pinned: an Express handler with its helper drawn inside the reply it builds, a FastAPI `raise HTTPException` ending the arm it is in, a Spring early `return` as the other arm of its `if` (with the comparison flipped, not wrapped), an ASP.NET handler's two outcomes, and a Nest controller read on through the service it delegates to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
This commit is contained in:
co-authored by
Claude Opus 5
parent
9acab0020f
commit
7b6704a70d
@@ -7,7 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
import { describe, it, expect, beforeAll } from 'vitest';
|
import { describe, it, expect, beforeAll } from 'vitest';
|
||||||
import { initGrammars } from '../src/extraction/grammars';
|
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';
|
import type { Language } from '../src/types';
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -334,3 +334,118 @@ public class OrderService : IOrderService {
|
|||||||
expect(Object.fromEntries(types)).toEqual({ _orderRepository: 'IRepository<Order>', Mailer: 'IEmailSender', orderRepository: 'IRepository<Order>', uriComposer: 'IUriComposer' });
|
expect(Object.fromEntries(types)).toEqual({ _orderRepository: 'IRepository<Order>', Mailer: 'IEmailSender', orderRepository: 'IRepository<Order>', uriComposer: 'IUriComposer' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('loops a site is written inside', () => {
|
||||||
|
/** The loop headers at the site, outermost first, as `<kind> <text>`. */
|
||||||
|
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<Item> 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<Item> 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<Item>) {
|
||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import * as path from 'path';
|
|||||||
import { CodeGraph } from '../src';
|
import { CodeGraph } from '../src';
|
||||||
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
|
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
|
||||||
import { buildSteps, projectKind } from '../src/ui-server/api/steps';
|
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';
|
import { routeRoots } from '../src/ui-server/api/route-roots';
|
||||||
|
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
@@ -279,6 +280,110 @@ const route = (name: string) => {
|
|||||||
};
|
};
|
||||||
const effect = (p: Awaited<ReturnType<typeof buildSteps>>, category: string) => p.steps.find((s) => s.kind === 'effect' && s.effect?.category === category);
|
const effect = (p: Awaited<ReturnType<typeof buildSteps>>, 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<ReturnType<typeof buildSteps>>): 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', () => {
|
describe('route roots', () => {
|
||||||
it('names the handler an API route runs, the route itself for an inline handler', () => {
|
it('names the handler an API route runs, the route itself for an inline handler', () => {
|
||||||
const roots = routeRoots(cg, cg.getNodesByKind('route'));
|
const roots = routeRoots(cg, cg.getNodesByKind('route'));
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
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';
|
import { buildProgram, type ProgramInput, type ProgramSite, type WireBlock, type WireItem } from '../src/ui-server/api/program';
|
||||||
|
|
||||||
/* ------------------------------------------------------------ material -- */
|
/* ------------------------------------------------------------ material -- */
|
||||||
@@ -27,6 +27,11 @@ function at(step: string, guards: BranchGuard[] = [], extra: Partial<ProgramSite
|
|||||||
return { step, link: `l:${step}`, at: { line, column: 0, end: { line, column: 40 } }, guards, ...extra };
|
return { step, link: `l:${step}`, at: { line, column: 0, end: { line, column: 40 } }, guards, ...extra };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A loop the site is written inside. */
|
||||||
|
function loop(text: string, kind: SiteLoop['kind'] = 'each', branch = `l:${text}`): SiteLoop {
|
||||||
|
return { text, kind, branch };
|
||||||
|
}
|
||||||
|
|
||||||
/** A site that folds into a helper. */
|
/** A site that folds into a helper. */
|
||||||
function into(fn: string, guards: BranchGuard[] = [], extra: Partial<ProgramSite> = {}): ProgramSite {
|
function into(fn: string, guards: BranchGuard[] = [], extra: Partial<ProgramSite> = {}): ProgramSite {
|
||||||
const line = nextLine++;
|
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']);
|
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', () => {
|
it('closes a fork when the code leaves it', () => {
|
||||||
const cond = g('ready');
|
const cond = g('ready');
|
||||||
const p = program({ root: [at('inside', [cond]), at('after')] });
|
const p = program({ root: [at('inside', [cond]), at('after')] });
|
||||||
|
|||||||
@@ -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<SiteLoop[]> {
|
||||||
|
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<string, 'each' | 'while'> = 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<Map<string, SiteLoop[]>> {
|
||||||
|
const out = new Map<string, SiteLoop[]>();
|
||||||
|
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).
|
* The walk. `line` is 1-based, `column` 0-based (null → first non-blank).
|
||||||
* Returns the guards outermost first — execution order, the way a reader
|
* Returns the guards outermost first — execution order, the way a reader
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
* string can never say.
|
* 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';
|
import type { WireNodeRef } from './wire';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -63,7 +63,17 @@ export type WireItem =
|
|||||||
* after this function returns (`later`), or calls started together
|
* after this function returns (`later`), or calls started together
|
||||||
* (`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. */
|
/** Where the reading stopped: a helper that calls itself, or a cap the walk hit. */
|
||||||
| { kind: 'cut'; why: 'folded' | 'depth' };
|
| { kind: 'cut'; why: 'folded' | 'depth' };
|
||||||
|
|
||||||
@@ -91,6 +101,8 @@ export interface ProgramSite {
|
|||||||
within?: string;
|
within?: string;
|
||||||
/** The conditions it runs under, outermost first. */
|
/** The conditions it runs under, outermost first. */
|
||||||
guards: BranchGuard[];
|
guards: BranchGuard[];
|
||||||
|
/** The loops it is written inside, outermost first. */
|
||||||
|
loops?: SiteLoop[];
|
||||||
/** What fires it, when something binds it — a callback runs LATER. */
|
/** What fires it, when something binds it — a callback runs LATER. */
|
||||||
trigger?: { kind: string; name: string; of?: string | null };
|
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 [];
|
if (sites.length === 0) return [];
|
||||||
|
|
||||||
const root: WireBlock = [];
|
const root: WireBlock = [];
|
||||||
/** The forks open at the site being placed, outermost first. */
|
/** The constructs open at the site being placed, outermost first. */
|
||||||
const stack: Array<{ branch: string; fork: Extract<WireItem, { kind: 'fork' }>; armKey: string; arm: WireArm; exit?: WireArmEnd }> = [];
|
const stack: Open[] = [];
|
||||||
const blockAt = (depth: number): WireBlock => (depth === 0 ? root : stack[depth - 1]!.arm.body);
|
const bodyAt = (depth: number): WireBlock => (depth === 0 ? root : stack[depth - 1]!.body);
|
||||||
|
|
||||||
for (const site of sites) {
|
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;
|
let keep = 0;
|
||||||
while (keep < stack.length && keep < guards.length && stack[keep]!.branch === guards[keep]!.branch && stack[keep]!.armKey === armKey(guards[keep]!)) {
|
while (keep < stack.length && keep < scopes.length && sameScope(stack[keep]!, scopes[keep]!)) 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 level after it may still be the SAME decision taken the other way —
|
// the fork and opens its other arm; anything deeper is closed either way.
|
||||||
// an `else`, another `case`, the code after an early exit. That keeps the
|
const open = stack[keep];
|
||||||
// fork and opens its other arm; anything deeper is closed either way.
|
const scope = scopes[keep];
|
||||||
if (keep < stack.length && keep < guards.length && stack[keep]!.branch === guards[keep]!.branch) {
|
if (open && scope && open.branch === scope.branch && open.fork && scope.kind === 'guard') {
|
||||||
stack.length = keep + 1;
|
stack.length = keep + 1;
|
||||||
const open = stack[keep]!;
|
open.armKey = armKey(scope.guard);
|
||||||
open.armKey = armKey(guards[keep]!);
|
open.body = armFor(open.fork, scope.guard).body;
|
||||||
open.arm = armFor(open.fork, guards[keep]!);
|
|
||||||
keep++;
|
keep++;
|
||||||
} else {
|
} else {
|
||||||
stack.length = keep;
|
stack.length = keep;
|
||||||
}
|
}
|
||||||
for (let i = keep; i < guards.length; i++) {
|
for (let i = keep; i < scopes.length; i++) stack.push(openScope(bodyAt(i), scopes[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<WireItem, { kind: 'fork' }> = { 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) });
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = itemFor(input, site, path, state);
|
const item = itemFor(input, site, path, state);
|
||||||
if (item !== null) {
|
if (item !== null) {
|
||||||
state.items++;
|
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;
|
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<WireItem, { kind: 'fork' }>;
|
||||||
|
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<WireItem, { kind: 'block' }>).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<WireItem, { kind: 'fork' }> = { 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. */
|
/** What one recorded site draws as. */
|
||||||
function itemFor(input: ProgramInput, site: ProgramSite, path: readonly string[], state: Reading): WireItem | null {
|
function itemFor(input: ProgramInput, site: ProgramSite, path: readonly string[], state: Reading): WireItem | null {
|
||||||
if (site.into) {
|
if (site.into) {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import type CodeGraph from '../../index';
|
|||||||
import type { Edge, Language, Node, UnresolvedReference } from '../../types';
|
import type { Edge, Language, Node, UnresolvedReference } from '../../types';
|
||||||
import { badRequest, intParam, notFound } from './respond';
|
import { badRequest, intParam, notFound } from './respond';
|
||||||
import { createSiteReader } from './when';
|
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 { buildProgram, type ProgramSite, type WireProgram } from './program';
|
||||||
import { classifyEffect, implicitResponseStatus, responseStatus, type Effect } from './effects';
|
import { classifyEffect, implicitResponseStatus, responseStatus, type Effect } from './effects';
|
||||||
import { guardLabel } from '../../graph/branch-guards';
|
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);
|
const calls = createSiteReader(cg, projectRoot, MAX_CALL_SITES);
|
||||||
/** The conditions a site runs under, structured — one read, joined where a string is wanted. */
|
/** 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);
|
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 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<WireStepSite> => {
|
const withArgs = async (site: WireStepSite, caller: Node, at: { line?: number; column?: number }): Promise<WireStepSite> => {
|
||||||
const args = await argsAt(caller, at);
|
const args = await argsAt(caller, at);
|
||||||
@@ -553,7 +555,8 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
|||||||
hop: HopSite,
|
hop: HopSite,
|
||||||
guards: readonly BranchGuard[],
|
guards: readonly BranchGuard[],
|
||||||
what: { step?: string; link?: string; into?: string },
|
what: { step?: string; link?: string; into?: string },
|
||||||
trigger: WireStepTrigger | null = null
|
trigger: WireStepTrigger | null = null,
|
||||||
|
loops: readonly SiteLoop[] = []
|
||||||
): void => {
|
): void => {
|
||||||
let sites = programs.get(fn.id);
|
let sites = programs.get(fn.id);
|
||||||
if (!sites) {
|
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 },
|
at: { line: hop.line, column: hop.column, end: hop.end },
|
||||||
...(hop.within ? { within: hop.within } : {}),
|
...(hop.within ? { within: hop.within } : {}),
|
||||||
guards: [...guards],
|
guards: [...guards],
|
||||||
|
...(loops.length > 0 ? { loops: [...loops] } : {}),
|
||||||
...(trigger ? { trigger } : {}),
|
...(trigger ? { trigger } : {}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -754,7 +758,7 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
|||||||
if (!target.first) target.first = hop;
|
if (!target.first) target.first = hop;
|
||||||
const fired = trigger ?? (await triggerAt(fold.node, at));
|
const fired = trigger ?? (await triggerAt(fold.node, at));
|
||||||
const id = link(step, target, 'effect', fold.chain, [...fold.whens, when], wireSite, null, fired, hop.within);
|
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;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1145,7 +1149,7 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
|||||||
const hop = fold.first ?? local;
|
const hop = fold.first ?? local;
|
||||||
if (!to.first) to.first = hop;
|
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);
|
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)) {
|
if (to.root !== null && !explored.has(to.id)) {
|
||||||
explored.add(to.id);
|
explored.add(to.id);
|
||||||
queue.push(to);
|
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 local = await hopAt(fold.node, at, target.name);
|
||||||
const hop = fold.first ?? local;
|
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);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1210,7 +1214,7 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
|||||||
const first = fold.first ?? local;
|
const first = fold.first ?? local;
|
||||||
// The helper is drawn where it is CALLED: its own records are its
|
// The helper is drawn where it is CALLED: its own records are its
|
||||||
// body, and this is the site the rail nests them under.
|
// 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 });
|
next.push({ node: target, chain: [...fold.chain, target], whens: [...fold.whens, guardLabel(guards)], first });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
decoratorsForFile,
|
decoratorsForFile,
|
||||||
guardLabel,
|
guardLabel,
|
||||||
guardsForFile,
|
guardsForFile,
|
||||||
|
loopsForFile,
|
||||||
memberTypesForFile,
|
memberTypesForFile,
|
||||||
siteKey,
|
siteKey,
|
||||||
supportsBranchGuards,
|
supportsBranchGuards,
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
type BranchGuard,
|
type BranchGuard,
|
||||||
type CallSiteText,
|
type CallSiteText,
|
||||||
type DefinitionDecorators,
|
type DefinitionDecorators,
|
||||||
|
type SiteLoop,
|
||||||
type SiteTrigger,
|
type SiteTrigger,
|
||||||
} from '../../graph/branch-guards';
|
} from '../../graph/branch-guards';
|
||||||
import { resolveProjectFile } from '../security';
|
import { resolveProjectFile } from '../security';
|
||||||
@@ -106,6 +108,8 @@ export interface SiteReader {
|
|||||||
* What {@link SiteReader.when} joins; empty when unconditional or unreadable.
|
* What {@link SiteReader.when} joins; empty when unconditional or unreadable.
|
||||||
*/
|
*/
|
||||||
guards(caller: { filePath: string; language: Language }, site: { line?: number; column?: number }): Promise<BranchGuard[]>;
|
guards(caller: { filePath: string; language: Language }, site: { line?: number; column?: number }): Promise<BranchGuard[]>;
|
||||||
|
/** 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<SiteLoop[]>;
|
||||||
/** What the site passes, abbreviated (`'userEmail', values.email`); null when unreadable. '' for an empty list. */
|
/** 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<string | null>;
|
args(caller: { filePath: string; language: Language }, site: { line?: number; column?: number }): Promise<string | null>;
|
||||||
/** What fires the site — the JSX prop, `on*` option or runs-later call it is written under; null when nothing binds it. */
|
/** 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) {
|
async when(caller, site) {
|
||||||
return guardLabel(await guards(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) {
|
async args(caller, site) {
|
||||||
if (!site.line || sites >= maxSites || !supportsBranchGuards(caller.language)) return null;
|
if (!site.line || sites >= maxSites || !supportsBranchGuards(caller.language)) return null;
|
||||||
const file = resolve(caller);
|
const file = resolve(caller);
|
||||||
|
|||||||
@@ -174,7 +174,8 @@ export function groupLabel(item: Extract<WireItem, { kind: 'block' }>): string {
|
|||||||
case 'inline':
|
case 'inline':
|
||||||
return item.via ? `via ${item.via.name}` : 'via a helper';
|
return item.via ? `via ${item.via.name}` : 'via a helper';
|
||||||
case 'loop':
|
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':
|
case 'later':
|
||||||
return item.by ? `later · ${item.by}` : 'later';
|
return item.by ? `later · ${item.by}` : 'later';
|
||||||
default:
|
default:
|
||||||
|
|||||||
+11
-1
@@ -838,7 +838,17 @@ export type WireItem =
|
|||||||
* after this function returns (`later`), or calls started together
|
* after this function returns (`later`), or calls started together
|
||||||
* (`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. */
|
/** Where the reading stopped: a helper that calls itself, or a cap the walk hit. */
|
||||||
| { kind: 'cut'; why: 'folded' | 'depth' };
|
| { kind: 'cut'; why: 'folded' | 'depth' };
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user