Give a list one struct type so every item keeps its shape (#254)
game-maker / gate (push) Successful in 3m28s
game-maker / suite (push) Successful in 10m38s

A list of structs used to let every item carry its own fields. Adding
an item meant typing the same fields again, and nothing kept the items
in step with the struct asset from #253. A list can now name a struct
type for its items, and the asset keeps them in shape.

- A list variable and a list field get the row Items with Mixed, every
  struct asset and Make struct type. Make struct type collects every
  field any item has, so no item loses a field.
- A typed item carries its type under the key `$struct`
  (ITEM_TYPE_KEY). listItems does not count it as a field.
- A list that holds anything but structs refuses a type, also when
  the offending item sits in an override. The console names the list
  and the count, and the list stays as it was.
- In a typed list `+ Item` adds an item with the defaults, without the
  kind menu. Field names are locked, values stay editable.
- The rows of a typed item take their kind from the struct asset
  (declaredKind in InlineList.ts). An asset field shows the asset
  picker instead of a text field, as reported in the issue.
- A struct item in a mixed list gets its own Type row. Switching a
  list back to Mixed keeps the type on each item.
- syncStructTypes brings marked items into shape and carries renames
  into them, also inside overrides and nested lists. One undo step.
- The delete dialog of a struct asset also counts typed lists.
- The game never sees the key. World and sharedState strip it with
  withoutItemTypes and bagWithoutItemTypes, so a struct stays a
  plain object at runtime.
- Add item and Set item get the setting Type. With a type they only
  accept a struct with exactly its field names (fitsType in
  SetItem.ts). Anything else is left out and logged.
- The item indent from #252 is back to its old width, the narrow one
  squeezed the value fields.

Not done: writing a variable does not check the type, and the List
node offers no type.

Found on the way: a test helper in list-of-structs.test.ts called
nodes without params. It now passes the same arguments as the
interpreter.

Tests:
- ./check.sh: 183 files, 2679 tests, CHECK EXIT 0
- e2e GM_STEPS=1,21,22,32,54,55: ALL PASSED, 150 checks
- e2e GM_STEPS=1-6,21,22,32,54,55 before the follow-up: 404 checks
- New: tests/core/typed-lists.test.ts, e2e module 55 with 13 checks
- Counter-checks bite in unit and browser, originals restored by cmp

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-28 07:23:00 +02:00
1 parent 61bbb96a96
commit b0d0868f55
25 files changed
+1446 -86

No files matched your search

+145 -1
View File
@@ -7,17 +7,29 @@ import type { GraphNode } from '../graph/Graph';
import { STRUCT_TYPE_PARAM } from '../graph/nested';
import type { StructAsset } from '../model/Asset';
import { nextAssetName } from './naming';
import { isStruct, type PropertyValue } from '../model/Properties';
import {
isList,
isStruct,
itemTypeOf,
type ListValue,
type PropertyValue,
type StructValue,
tagOf,
} from '../model/Properties';
import type { Project } from '../model/Project';
import {
containsType,
type FieldRename,
isTypedNode,
listDeclAt,
setListItemType,
setStructType,
structAssetOf,
structAt,
syncStructTypes,
variableValues,
} from '../model/structTypes';
import { structItemFields } from '../properties/listItems';
import {
coerceValue,
type PropertyDef,
@@ -127,6 +139,7 @@ function variableOfField(field: StructFieldDef, value: PropertyValue | undefined
);
if (value !== undefined) def.default = coerceValue(def, value);
if (def.kind === 'struct' && field.kind === 'struct' && field.structType) def.structType = field.structType;
if (def.kind === 'list' && field.kind === 'list' && field.itemType) def.itemType = field.itemType;
return def;
}
@@ -176,3 +189,134 @@ export class MakeStructTypeCommand implements Command {
if (this.asset) project.assets = removeById(project.assets, this.asset.id);
}
}
/** A list variable, or a list field inside a struct variable – `path` leads to the field (#254). */
export interface ListTarget {
ownerId: string;
key: string;
path: readonly string[];
}
function requireListDecl(project: Project, target: ListTarget) {
const owner = requireVariableOwner(project, target.ownerId);
const def = owner.variables.find((v) => v.key === target.key);
const decl = def && listDeclAt(def, target.path);
if (!def || !decl) throw new CommandError(`"${[target.key, ...target.path].join('.')}" is no list`);
return { owner, def, decl };
}
/** Every list the target holds anywhere – its default, the owner's value, each override. */
function listsOf(project: Project, target: ListTarget): ListValue[] {
const { owner, def } = requireListDecl(project, target);
return variableValues(project, owner, def).flatMap((value) => {
const list = valueAt(value, target.path);
return isList(list) ? [list] : [];
});
}
const isStructItem = (item: PropertyValue): boolean => isStruct(item) && tagOf(item) === undefined;
/**
* A list holding anything but structs cannot take a type: which of its items would become what is
* not for the editor to guess (#254). The refusal says so, in the console like any refused edit.
*/
function requireStructItems(project: Project, target: ListTarget): ListValue[] {
const lists = listsOf(project, target);
const others = lists.reduce((n, list) => n + list.filter((item) => !isStructItem(item)).length, 0);
if (others > 0) {
const name = [target.key, ...target.path].join('.');
const what = others === 1 ? '1 item that is' : `${others} items that are`;
throw new CommandError(`${name} holds ${what} no struct – only a list of structs can take a type`);
}
return lists;
}
/**
* Makes every item of a list one Struct asset, or with `''` a mixed list again. Items keep their
* values where the type has the field; a mixed list whose items keep their marks loses nothing.
*/
export class SetListItemTypeCommand implements Command {
readonly label: string;
private rec = new Recorder();
constructor(
private readonly target: ListTarget,
private readonly typeId: string,
) {
this.label = typeId ? 'Set item type' : 'Mixed items';
}
execute(project: Project): void {
if (this.typeId && !structAssetOf(project, this.typeId)) throw new CommandError('That struct is gone');
const { def } = requireListDecl(project, this.target);
if (this.typeId) requireStructItems(project, this.target);
this.rec = new Recorder();
setListItemType(def, this.target.path, this.typeId, this.rec);
syncStructTypes(project, this.rec);
}
undo(): void {
this.rec.revert();
}
}
/**
* Turns the shape of a list's items into a Struct asset and types the list with it. Every field any
* item has is in it, at the value the first item holding it has – so no item loses a field.
*/
export class MakeListItemTypeCommand implements Command {
readonly label = 'Make struct type';
private asset?: StructAsset;
private rec = new Recorder();
constructor(private readonly target: ListTarget) {}
get assetId(): string {
return this.asset?.id ?? '';
}
execute(project: Project): void {
const { def, decl } = requireListDecl(project, this.target);
if (decl.itemType && structAssetOf(project, decl.itemType))
throw new CommandError('This list has a type already');
const lists = requireStructItems(project, this.target);
if (!this.asset) {
const items = lists.flat().filter(isStruct);
const values: Record<string, PropertyValue> = {};
const fields: StructFieldDef[] = [];
for (const item of items)
for (const field of itemFieldDefs(item))
if (!(field.key in values)) {
values[field.key] = item[field.key]!;
fields.push(field);
}
const variables = fields.map((f) => variableOfField(f, values[f.key]));
const props = Object.fromEntries(variables.map((v) => [v.key, v.default]));
const name = nextAssetName(project, this.target.path.at(-1) ?? this.target.key);
this.asset = { id: newId(), type: 'struct', name, props, variables };
}
project.assets.push(this.asset);
this.rec = new Recorder();
setListItemType(def, this.target.path, this.asset.id, this.rec);
syncStructTypes(project, this.rec);
}
undo(project: Project): void {
this.rec.revert();
if (this.asset) project.assets = removeById(project.assets, this.asset.id);
}
}
/** The fields a struct item has, as a declaration: a struct in it declares its own again. */
function itemFieldDefs(item: StructValue): StructFieldDef[] {
return structItemFields(item).map(({ key, kind }): StructFieldDef => {
const value = item[key];
if (kind === 'struct' && isStruct(value)) {
const typeId = itemTypeOf(value);
return { key, kind, fields: itemFieldDefs(value), ...(typeId ? { structType: typeId } : {}) };
}
if (kind === 'struct') return { key, kind, fields: [] };
if (kind === 'list') return { key, kind };
return { key, kind };
});
}
+15 -3
View File
@@ -37,13 +37,25 @@ export interface StateMachine {
/**
* What a node may look up in the project around its graph: the behaviors it can run, the state
* machines it can read and the curves a `Lerp` eases along (#247). One handle, so no caller can
* resolve a node with half of it.
* machines it can read, the curves a `Lerp` eases along (#247) and the struct types `Add item`
* checks (#254). One handle, so no caller can resolve a node with half of it.
*/
export interface GraphLookup {
behavior(assetId: string): BehaviorProgram | null;
machine(assetId: string): StateMachine | null;
curve(assetId: string): readonly VectorPoint[] | null;
/** A Struct asset as a run knows it: a name and field names, nothing else travels (#254). */
structType(assetId: string): StructShape | null;
}
export const NO_LOOKUP: GraphLookup = { behavior: () => null, machine: () => null, curve: () => null };
export interface StructShape {
name: string;
fields: readonly string[];
}
export const NO_LOOKUP: GraphLookup = {
behavior: () => null,
machine: () => null,
curve: () => null,
structType: () => null,
};
+13 -6
View File
@@ -1,20 +1,27 @@
import type { PropertyValue } from '../../../model/Properties';
import { type PropertyValue, textOf } from '../../../model/Properties';
import { itemKindOf } from '../../../properties/listItems';
import { STRUCT_TYPE_SETTING } from '../../nested';
import type { NodeDef } from '../../NodeRegistry';
import { asList, asNumber, asValueItem, type ValueList } from '../../values';
import { asList, asNumber, asString, asValueItem, type ValueList } from '../../values';
import { any, int, list } from '../shared';
import { fitToSlot } from './SetItem';
import { fitsType, fitToSlot } from './SetItem';
export const AddItem: NodeDef = {
type: 'AddItem',
label: 'Add item',
category: 'data',
description:
'The same list with one more item, at the end or before index. A struct or list goes in as one item. The list that came in is left alone.',
'The same list with one more item, at the end or before index. A struct or list goes in as one item. The list that came in is left alone. With a type, only a struct with exactly its fields goes in – anything else is reported and the list stays as it was.',
inputs: [list('list'), any('value'), int('index', -1)],
outputs: [list('result')],
run: (_ctx, inputs) => {
run: (ctx, inputs, params, _def, _entry, lookup) => {
const items = asList(inputs.list);
const typeId = textOf(params[STRUCT_TYPE_SETTING.key] ?? '');
const type = typeId ? lookup.structType(typeId) : null;
if (type && !fitsType(inputs.value, type)) {
ctx.log(`Add item: ${asString(inputs.value ?? null) || 'nothing'} is no ${type.name} – not added`);
return { outputs: { result: items } };
}
const i = inputs.index === undefined ? -1 : Math.floor(asNumber(inputs.index));
// No index, −1 or anything outside the list appends – an index can only ever miss towards the end.
const at = i >= 0 && i <= items.length ? i : items.length;
@@ -22,7 +29,7 @@ export const AddItem: NodeDef = {
next.splice(at, 0, fitted(items, asValueItem(inputs.value)));
return { outputs: { result: next } };
},
params: [],
params: [STRUCT_TYPE_SETTING],
};
/**
+25 -5
View File
@@ -1,29 +1,49 @@
import type { PropertyValue } from '../../../model/Properties';
import { isStruct, type PropertyValue, tagOf, textOf } from '../../../model/Properties';
import { itemKindOf, itemLike, makeItem } from '../../../properties/listItems';
import type { StructShape } from '../../lookup';
import { STRUCT_TYPE_SETTING } from '../../nested';
import type { NodeDef } from '../../NodeRegistry';
import { asList, asNumber, asValueItem, isEntity, type ValueList } from '../../values';
import { asList, asNumber, asString, asValueItem, isEntity, type Value, type ValueList } from '../../values';
import { any, int, list } from '../shared';
export const SetItem: NodeDef = {
type: 'SetItem',
label: 'Set item',
category: 'data',
description: 'The same list with one item replaced. The list that came in is left alone.',
description:
'The same list with one item replaced. The list that came in is left alone. With a type, only a struct with exactly its fields goes in – anything else is reported and the list stays as it was.',
inputs: [list('list'), int('index'), any('value')],
outputs: [list('result')],
run: (_ctx, inputs) => {
run: (ctx, inputs, params, _def, _entry, lookup) => {
const items = asList(inputs.list);
const i = Math.floor(asNumber(inputs.index));
if (i < 0 || i >= items.length) return { outputs: { result: items } };
// Checked like `Add item` (#254): by the field names, the only thing a run knows a struct by.
const typeId = textOf(params[STRUCT_TYPE_SETTING.key] ?? '');
const type = typeId ? lookup.structType(typeId) : null;
if (type && !fitsType(inputs.value, type)) {
ctx.log(`Set item: ${asString(inputs.value ?? null) || 'nothing'} is no ${type.name} – not set`);
return { outputs: { result: items } };
}
const next = [...items];
// The slot keeps its kind: writing a number into a color item leaves a color behind, the same
// way writing into a number variable leaves a number (see `asTypeOf`).
next[i] = fitToSlot(items[i], asValueItem(inputs.value));
return { outputs: { result: next } };
},
params: [],
params: [STRUCT_TYPE_SETTING],
};
/**
* A run knows a struct only by its fields (#254): one fits a type when it has exactly the type's
* field names – none missing, none more.
*/
export function fitsType(value: Value | undefined, type: StructShape): boolean {
if (isEntity(value) || !isStruct(value) || tagOf(value) !== undefined) return false;
const keys = Object.keys(value);
return keys.length === type.fields.length && type.fields.every((field) => field in value);
}
/** `value` as the kind `slot` holds, the way `Set item` and `Add item` write into a list. */
export function fitToSlot(slot: ValueList[number] | undefined, value: ValueList[number]): ValueList[number] {
// An object has no kind to keep and none to take on, so it is written as it is (#157).
+2 -2
View File
@@ -1,4 +1,4 @@
import type { PropertyBag } from '../model/Properties';
import { type PropertyBag, withoutItemTypes } from '../model/Properties';
import type { Project } from '../model/Project';
import { isPerScene, isShared, sharedScopeOf } from '../model/Scope';
import type { NodeContext, SharedState } from './NodeContext';
@@ -14,7 +14,7 @@ export function declaredVariables(project: Project): Record<string, PropertyBag>
for (const asset of project.assets) {
if (asset.type !== 'variables') continue;
const bag: PropertyBag = {};
for (const def of asset.variables) bag[def.key] = asset.props[def.key] ?? def.default;
for (const def of asset.variables) bag[def.key] = withoutItemTypes(asset.props[def.key] ?? def.default);
declared[asset.id] = bag;
}
return declared;
+45 -1
View File
@@ -32,12 +32,55 @@ interface TaggedFields {
const TAG = '$';
/**
* The Struct asset a struct item follows (#254), kept in the item because a list declares at most
* one type for all of them – and a mixed list none. Model only: a run never sees it
* (`withoutItemTypes`), and no field can be called that, as no field name starts with `$`.
*/
export const ITEM_TYPE_KEY = '$struct';
/** The kind `value` wears as a tag, if it is a tagged item. */
export function tagOf(value: unknown): string | undefined {
if (!isStruct(value)) return undefined;
const keys = Object.keys(value);
const key = keys.length === 1 ? keys[0] : undefined;
return key?.startsWith(TAG) ? key.slice(TAG.length) : undefined;
return key?.startsWith(TAG) && key !== ITEM_TYPE_KEY ? key.slice(TAG.length) : undefined;
}
/** The Struct asset id a struct item is marked with, if any. */
export function itemTypeOf(value: PropertyValue | undefined): string | undefined {
if (!isStruct(value)) return undefined;
const id = value[ITEM_TYPE_KEY];
return typeof id === 'string' && id !== '' ? id : undefined;
}
/** The struct with its fields as they are, marked as `typeId` – or unmarked for `''`. */
export function withItemType(value: StructValue, typeId: string): StructValue {
const { [ITEM_TYPE_KEY]: _dropped, ...fields } = value;
return typeId ? { ...fields, [ITEM_TYPE_KEY]: typeId } : fields;
}
/** The value as a run holds it: every item-type mark taken out, at any depth. */
export function withoutItemTypes<T extends PropertyValue | undefined>(value: T): T {
if (isList(value)) return (value.some(holdsItemType) ? value.map(withoutItemTypes) : value) as T;
if (!isStruct(value) || !holdsItemType(value)) return value;
const out: Record<string, PropertyValue> = {};
for (const [key, item] of Object.entries(value))
if (key !== ITEM_TYPE_KEY) out[key] = withoutItemTypes(item);
return out as T;
}
export function bagWithoutItemTypes(bag: PropertyBag): PropertyBag {
if (!Object.values(bag).some(holdsItemType)) return bag;
const out: PropertyBag = {};
for (const [key, value] of Object.entries(bag)) out[key] = withoutItemTypes(value);
return out;
}
function holdsItemType(value: PropertyValue | undefined): boolean {
if (isList(value)) return value.some(holdsItemType);
if (!isStruct(value)) return false;
return ITEM_TYPE_KEY in value || Object.values(value).some(holdsItemType);
}
export function tagged(kind: TaggedKind, value: number | string): TaggedItem {
@@ -75,6 +118,7 @@ export function textOf(value: PropertyValue | undefined): string {
if (tagOf(value) !== undefined) return textOf(untag(value));
if (isStruct(value))
return Object.entries(value)
.filter(([field]) => field !== ITEM_TYPE_KEY)
.map(([field, item]) => `${field}: ${textOf(item)}`)
.join(', ');
return String(value);
+7 -1
View File
@@ -1,6 +1,8 @@
import type { GraphLookup } from '../graph/lookup';
import { startStateOf, statesOf, transitionsOf } from '../graph/stateMachine';
import { resolveAssetProps } from '../properties/resolve';
import { bagWithoutItemTypes } from './Properties';
import { typeFields } from './structTypes';
import { findAssetOf, type Project } from './Project';
/**
@@ -15,7 +17,7 @@ export function graphLookup(project: Project): GraphLookup {
return {
name: asset.name,
graph: asset.graph,
defaults: resolveAssetProps(project, asset),
defaults: bagWithoutItemTypes(resolveAssetProps(project, asset)),
kept: asset.keptVariables ?? [],
};
},
@@ -30,5 +32,9 @@ export function graphLookup(project: Project): GraphLookup {
};
},
curve: (assetId) => findAssetOf(project, assetId, 'curve')?.points ?? null,
structType: (assetId) => {
const asset = findAssetOf(project, assetId, 'struct');
return asset ? { name: asset.name, fields: typeFields(asset).map((f) => f.key) } : null;
},
};
}
+208 -10
View File
@@ -3,6 +3,9 @@
* of its fields, so everything reading `fields` works as it did; `syncStructTypes` brings every such
* copy – and every value and `Pack`/`Expand` port hanging on it – back in line after the asset
* changed. A type that is gone leaves the copy as it was: the place keeps its last fields as its own.
*
* Items declare nothing, so a struct item carries its type as a mark (#254): every item of a typed
* list, and any struct item given one in a mixed list. The same sync keeps marked items in shape.
*/
import type { DeclaredPort, Graph, GraphNode } from '../graph/Graph';
import { fixedPortKeys, isValidPortName, STRUCT_TYPE_PARAM } from '../graph/nested';
@@ -16,11 +19,22 @@ import {
type StructFieldDef,
structOf,
} from '../properties/PropertySchema';
import { valueAt, withValueAt } from '../properties/valuePath';
import type { Recorder } from '../util/recorder';
import { valueAt, type ValuePath, withValueAt } from '../properties/valuePath';
import { Recorder } from '../util/recorder';
import { isGraphAsset, isVariableOwner, type StructAsset, type VariableOwnerAsset } from './Asset';
import { overrideSites } from './instance';
import { type FieldValue, isStruct, type PropertyValue, type StructValue, tagOf, textOf } from './Properties';
import {
type FieldValue,
isList,
isStruct,
ITEM_TYPE_KEY,
itemTypeOf,
type PropertyValue,
type StructValue,
tagOf,
textOf,
withItemType,
} from './Properties';
import { findAssetOf, type Project } from './Project';
export const PACK_STRUCT = 'PackStruct';
@@ -50,7 +64,8 @@ export function fieldOfVariable(def: PropertyDef): StructFieldDef | undefined {
fields: def.fields,
...(def.structType ? { structType: def.structType } : {}),
};
if (def.kind === 'list') return { key: def.key, kind: 'list' };
if (def.kind === 'list')
return { key: def.key, kind: 'list', ...(def.itemType ? { itemType: def.itemType } : {}) };
return isKindWith('field', def.kind) ? { key: def.key, kind: def.kind } : undefined;
}
@@ -144,11 +159,12 @@ interface Holder {
write(value: PropertyValue): void;
}
function holdersOf(project: Project, site: TypedSite, rec: Recorder): Holder[] {
const { owner, def } = site;
const holders: Holder[] = [
{ read: () => def.default, write: (value) => rec.set(def, 'default', value as StructValue) },
];
function holdersOf(
project: Project,
{ owner, def }: { owner: VariableOwnerAsset; def: PropertyDef },
rec: Recorder,
): Holder[] {
const holders: Holder[] = [{ read: () => def.default, write: (value) => rec.set(def, 'default', value) }];
if (owner.props[def.key] !== undefined)
holders.push({
read: () => owner.props[def.key],
@@ -183,10 +199,97 @@ export function shapedStruct(
} else if (own !== undefined) shaped[field.key] = coerceFieldValue(own, field);
else
shaped[field.key] = fallback !== undefined ? coerceFieldValue(fallback, field) : emptyFieldValue(field);
if (field.kind === 'list' && field.itemType)
shaped[field.key] = markedItems(shaped[field.key], field.itemType);
}
return shaped;
}
/** A new item of the type (#254): the type's defaults, or what `value` has of its fields, marked. */
export function typedItem(project: Project, type: StructAsset, value?: PropertyValue): StructValue {
return withItemType(shapedStruct(project, typeFields(type), value, typeDefault(type)), type.id);
}
/** The struct items of a typed list marked with its type; anything else is left as it is. */
function markedItems(value: PropertyValue | undefined, typeId: string): PropertyValue {
if (!isList(value)) return [];
return value.map((item) =>
isStruct(item) && tagOf(item) === undefined && itemTypeOf(item) !== typeId
? withItemType(item, typeId)
: item,
);
}
/** A declared value with the items of every typed list in it marked – the declaration says which. */
function declaredMarks(
def: PropertyDef | StructFieldDef,
value: PropertyValue | undefined,
): PropertyValue | undefined {
if (def.kind === 'list') return def.itemType ? markedItems(value, def.itemType) : value;
if (def.kind !== 'struct' || !isStruct(value)) return value;
const out: Record<string, PropertyValue> = { ...value };
for (const field of def.fields) {
const next = declaredMarks(field, value[field.key]);
if (next !== undefined) out[field.key] = next;
}
return out;
}
/**
* Every marked struct item inside `value` brought into the shape of its type, at any depth – what
* a typed struct variable gets from `shapedStruct`, for the items no declaration holds. A mark
* naming a type that is gone leaves the item as it is.
*/
function shapedItems(project: Project, value: PropertyValue, depth = 0): PropertyValue {
if (depth > MAX_DEPTH) return value;
if (isList(value)) return value.map((item) => shapedItems(project, item, depth + 1));
if (!isStruct(value) || tagOf(value) !== undefined) return value;
const type = structAssetOf(project, itemTypeOf(value));
const shaped = type ? typedItem(project, type, value) : value;
const out: Record<string, PropertyValue> = {};
for (const [key, item] of Object.entries(shaped))
out[key] = key === ITEM_TYPE_KEY ? item : shapedItems(project, item, depth + 1);
return out;
}
/** A rename reaching the marked items: each one of a type holding the renamed field, at any depth. */
function renamedItems(project: Project, value: PropertyValue, rename: FieldRename, depth = 0): PropertyValue {
if (depth > MAX_DEPTH) return value;
if (isList(value)) return value.map((item) => renamedItems(project, item, rename, depth + 1));
if (!isStruct(value) || tagOf(value) !== undefined) return value;
let next: PropertyValue = value;
const typeId = itemTypeOf(value);
if (typeId)
for (const inside of typePaths(project, typeId, rename.structType)) {
const path = [...inside, ...rename.path];
const at = valueAt(next, path);
if (isStruct(at) && rename.from in at)
next = withValueAt(next, path, renamedKey(at, rename.from, rename.to)) ?? next;
}
const out: Record<string, PropertyValue> = {};
for (const [key, item] of Object.entries(next as StructValue))
out[key] = key === ITEM_TYPE_KEY ? item : renamedItems(project, item, rename, depth + 1);
return out;
}
/** Every variable of every asset declaring some, with where its value is kept. */
function everyVariable(project: Project): { owner: VariableOwnerAsset; def: PropertyDef }[] {
return project.assets.flatMap((owner) =>
isVariableOwner(owner) ? owner.variables.map((def) => ({ owner, def })) : [],
);
}
/** Every value kept for a variable – what an edit of its declaration has to fit. */
export function variableValues(
project: Project,
owner: VariableOwnerAsset,
def: PropertyDef,
): PropertyValue[] {
return holdersOf(project, { owner, def }, new Recorder())
.map((holder) => holder.read())
.filter((value) => value !== undefined);
}
/** The declaration with the fields of the struct at `path` replaced – and nothing else touched. */
function withFieldsAt(
fields: readonly StructFieldDef[],
@@ -275,6 +378,11 @@ function applyRename(project: Project, rec: Recorder, rename: FieldRename): void
holder.write(withValueAt(value, path, renamedKey(at, from, to)) ?? value!);
}
}
for (const variable of everyVariable(project))
for (const holder of holdersOf(project, variable, rec)) {
const value = holder.read();
if (value !== undefined) holder.write(renamedItems(project, value, rename));
}
if (rename.path.length > 0) return;
for (const { graph, node } of typedNodes(project)) {
if (nodeStructType(node) !== rename.structType) continue;
@@ -357,6 +465,11 @@ export function syncStructTypes(project: Project, rec: Recorder, rename?: FieldR
holder.write(withValueAt(value, site.path, next) ?? next);
}
}
for (const variable of everyVariable(project))
for (const holder of holdersOf(project, variable, rec)) {
const value = declaredMarks(variable.def, holder.read());
if (value !== undefined) holder.write(shapedItems(project, value));
}
for (const { graph, node } of typedNodes(project)) {
const type = structAssetOf(project, nodeStructType(node));
if (type) syncNodePorts(graph, node, typeFields(type), rec);
@@ -366,9 +479,94 @@ export function syncStructTypes(project: Project, rec: Recorder, rename?: FieldR
}
/** How many places name the type – what deleting it would cut loose. */
export function structTypeUsages(project: Project, typeId: string): { places: number; nodes: number } {
export function structTypeUsages(
project: Project,
typeId: string,
): { places: number; lists: number; nodes: number } {
const lists = everyVariable(project).flatMap(({ def }) => typedListsIn(def));
return {
places: typedSites(project).filter((site) => site.structType === typeId).length,
lists: lists.filter((id) => id === typeId).length,
nodes: typedNodes(project).filter(({ node }) => nodeStructType(node) === typeId).length,
};
}
/** The item types of the typed lists a declaration holds; a typed struct's are its type's. */
function typedListsIn(def: PropertyDef | StructFieldDef): string[] {
if (def.kind === 'list') return def.itemType ? [def.itemType] : [];
if (def.kind !== 'struct' || def.structType) return [];
return def.fields.flatMap(typedListsIn);
}
/**
* The declaration of the list at `path` inside a variable: the variable itself for none, else the
* list field the struct keys lead to. Undefined where there is no declared list.
*/
export function listDeclAt(def: PropertyDef, path: readonly string[]): { itemType?: string } | undefined {
if (path.length === 0) return def.kind === 'list' ? def : undefined;
if (def.kind !== 'struct') return undefined;
const at = structAt(def, path.slice(0, -1));
const field = at?.fields.find((f) => f.key === path.at(-1));
return field?.kind === 'list' ? field : undefined;
}
/**
* The item type of the list at `path` inside a variable's value, wherever the path runs: through
* declared struct fields, list items and marked struct items alike. A list inside a typed item has
* the item type its type declares for that field.
*/
export function listItemTypeAt(
project: Project,
def: PropertyDef,
value: PropertyValue | undefined,
path: ValuePath,
): string | undefined {
let decl: { fields?: readonly StructFieldDef[]; itemType?: string } =
def.kind === 'struct' ? { fields: def.fields } : def.kind === 'list' ? { itemType: def.itemType } : {};
let at = value;
for (const step of path) {
at = valueAt(at, [step]);
const field = typeof step === 'string' ? decl.fields?.find((f) => f.key === step) : undefined;
if (field?.kind === 'list') decl = { itemType: field.itemType };
else if (field?.kind === 'struct') decl = { fields: field.fields };
else {
const type = structAssetOf(
project,
(typeof step === 'number' ? decl.itemType : undefined) ?? itemTypeOf(at),
);
decl = type ? { fields: typeFields(type) } : {};
}
}
return decl.itemType;
}
/** Names the item type of the list at `path` (see `listDeclAt`); `''` makes it mixed again. */
export function setListItemType(
def: PropertyDef,
path: readonly string[],
typeId: string,
rec: Recorder,
): void {
if (path.length === 0) {
if (def.kind !== 'list') return;
if (typeId) rec.set(def, 'itemType', typeId);
else rec.delete(def, 'itemType');
return;
}
if (def.kind !== 'struct') return;
const last = path.at(-1);
const retyped = (fields: readonly StructFieldDef[]): StructFieldDef[] =>
fields.map((f) => {
if (f.key !== last || f.kind !== 'list') return f;
const { itemType: _dropped, ...rest } = f;
return typeId ? { ...rest, itemType: typeId } : rest;
});
const parent = path.slice(0, -1);
rec.set(
def,
'fields',
parent.length === 0
? retyped(def.fields)
: withFieldsAt(def.fields, parent, (f) => ({ ...f, fields: retyped(f.fields) })),
);
}
+8 -3
View File
@@ -163,9 +163,10 @@ export type PropertyDef =
| FlatDef
/**
* A list declares nothing about its items: each one carries its own kind (see `listItems.ts`),
* and the graph types a list as a list, so one `list` port fits them all.
* and the graph types a list as a list, so one `list` port fits them all. An `itemType` makes
* every item a struct of that Struct asset (#254), each marked with it (`ITEM_TYPE_KEY`).
*/
| { key: string; label: string; kind: 'list'; default: ListValue }
| { key: string; label: string; kind: 'list'; default: ListValue; itemType?: string }
/**
* `fields` is the declaration; the value carries exactly those keys, which is what lets a struct
* keep its shape while single fields are written (`mergeStruct`). With a `structType` the fields
@@ -196,10 +197,14 @@ export interface PrimitiveFieldDef {
kind: FieldKind;
}
/** A list held by a struct field. Its items declare nothing either, and may nest like any list. */
/**
* A list held by a struct field. Its items declare nothing either, and may nest like any list –
* unless `itemType` names the Struct asset every item is (#254), as on a list variable.
*/
export interface ListFieldDef {
key: string;
kind: 'list';
itemType?: string;
}
/** A struct held by a struct field declares its fields like a struct variable does (#239). */
+4 -1
View File
@@ -7,6 +7,7 @@
* through the same coercion again, so nothing deeper is dropped.
*/
import {
ITEM_TYPE_KEY,
isList,
isStruct,
type ListValue,
@@ -65,5 +66,7 @@ export function structItemOf(value: PropertyValue | undefined): StructValue {
/** The shape a struct item has right now: its keys in order, each with the kind of its value. */
export function structItemFields(item: PropertyValue | undefined): { key: string; kind: NestedKind }[] {
if (!isStruct(item) || tagOf(item) !== undefined) return [];
return Object.entries(item).map(([key, value]) => ({ key, kind: itemKindOf(value) }));
return Object.entries(item)
.filter(([key]) => key !== ITEM_TYPE_KEY)
.map(([key, value]) => ({ key, kind: itemKindOf(value) }));
}
+11 -1
View File
@@ -393,6 +393,7 @@ function normalizeVariables(raw: unknown): PropertyDef[] {
const def = variableDef(key, kind, undefined, fields);
def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue);
if (def.kind === 'struct') readStructType(def, v.structType);
if (def.kind === 'list') readItemType(def, v.itemType);
defs.push(def);
}
return defs;
@@ -416,7 +417,11 @@ function normalizeStructFields(raw: unknown): StructFieldDef[] {
function readField(key: string, f: Record<string, unknown>): StructFieldDef | null {
const isFieldKind = (value: unknown) => (FIELD_KINDS as readonly string[]).includes(String(value));
if (f.kind === 'list') return { key, kind: 'list' };
if (f.kind === 'list') {
const field: StructFieldDef = { key, kind: 'list' };
readItemType(field, f.itemType);
return field;
}
if (f.kind === 'struct') {
const field: StructFieldDef = { key, kind: 'struct', fields: normalizeStructFields(f.fields) };
readStructType(field, f.structType);
@@ -430,6 +435,11 @@ function readStructType(def: { structType?: string }, raw: unknown): void {
if (typeof raw === 'string' && raw !== '') def.structType = raw;
}
/** The same for a typed list (#254): gone, it is a mixed list whose items keep their marks. */
function readItemType(def: { itemType?: string }, raw: unknown): void {
if (typeof raw === 'string' && raw !== '') def.itemType = raw;
}
function normalizePorts(raw: unknown, fixed: readonly string[], writable: boolean): DeclaredPort[] {
const ports: DeclaredPort[] = [];
const seen = new Set(fixed);