Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 86 additions & 2 deletions js/src/sails-idl-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { GearApi, HexString, UserMessageSent } from '@gear-js/api';
import { u8aToHex, u8aToU8a } from '@polkadot/util';

import type {
Type,
TypeDecl,
IIdlDoc,
IServiceExpo,
Expand Down Expand Up @@ -98,6 +99,46 @@ const _getArgsForTxBuilder = (args: any[], params: ISailsFuncArg[]) => {
return args.slice(0, params.length);
};

/**
* Collect every `Type` in scope for a service per the self-sufficient service IDL
* contract: depth-first across the `extends` chain (with cycle detection), then the
* service's own `types` last so locals shadow base definitions on name collision.
*
* Throws on a cyclic `extends` graph (`A → B → A`), reporting the chain.
* Used by both `SailsService` (to seed its `TypeResolver`) and `SailsProgram`
* (to build `resolveInService`'s lazy index).
*/
const _collectServiceScopeTypes = (
service: IServiceUnit,
resolveServiceUnit?: (ident: IServiceIdent) => IServiceUnit | undefined,
): Type[] => {
const out: Type[] = [];
const walk = (unit: IServiceUnit, visited: Set<string>) => {
if (visited.has(unit.name)) {
throw new Error(
`Cyclic service-extends chain detected at "${unit.name}" — chain: ` +
`${[...visited, unit.name].join(' → ')}`,
);
}
const nextVisited = new Set(visited);
nextVisited.add(unit.name);

if (resolveServiceUnit && unit.extends?.length) {
for (const ident of unit.extends as IServiceIdent[]) {
const baseUnit = resolveServiceUnit(ident);
if (!baseUnit) {
throw new Error(`Service definition for "${ident.name}" not found in IDL`);
}
walk(baseUnit, nextVisited);
}
}

for (const t of unit.types ?? []) out.push(t);
};
walk(service, new Set());
return out;
};

const _assertMatchingHeader = (
payload: Uint8Array | HexString,
expected: SailsMessageHeader,
Expand Down Expand Up @@ -129,6 +170,10 @@ export class SailsProgram {
private _api?: GearApi;
private _programId?: HexString;
private _services: Map<bigint, IServiceUnit>;
// Lazy index for resolveInService: per-service `Map<typeName, Type>`, pre-merged
// with the service's transitive extends chain. Populated on first call; not
// invalidated because `_doc` is immutable after parse.
private _serviceTypeIndex?: Map<string, Map<string, Type>>;
private _resolveServiceUnit = (ident: IServiceIdent): IServiceUnit | undefined => {
if (!ident.interface_id) {
throw new Error(`Service "${ident.name}" is missing interface_id in IDL`);
Expand All @@ -144,7 +189,7 @@ export class SailsProgram {
constructor(doc: IIdlDoc) {
this._doc = doc;
if (this._doc.program) {
this._typeResolver = new TypeResolver(this._doc.program.types);
this._typeResolver = new TypeResolver(this._doc.program.types ?? []);
}
this._services = this._initServices();
}
Expand Down Expand Up @@ -223,6 +268,44 @@ export class SailsProgram {
return services;
}

/**
* Resolve a `TypeDecl` to its user-type definition in the scope of a named service.
*
* Per the self-sufficient service IDL contract (see `docs/idl-v2-spec.md`), scope is
* the service's own `types` plus the types of every service it extends, transitively.
* Service-local definitions shadow base-service definitions on name collision.
* Program-level `types` are NOT in scope — they belong to program/ctor declarations.
*
* Returns `undefined` when the service doesn't exist, the `TypeDecl` isn't a named
* user type, or the name is not declared in the service's transitive scope.
*
* Backed by a lazy `Map`-based index — O(1) per call after the first invocation.
* Safe to call in tight loops while walking large IDL trees.
*/
resolveInService(serviceName: string, typeDecl: TypeDecl): Type | undefined {
if (typeof typeDecl === 'string' || typeDecl.kind !== 'named') return undefined;
if (!this._serviceTypeIndex) this._buildTypeIndex();
return this._serviceTypeIndex!.get(serviceName)?.get(typeDecl.name);
}
Comment thread
ukint-vs marked this conversation as resolved.

private _buildTypeIndex(): void {
const serviceIndex = new Map<string, Map<string, Type>>();
const unitByName = new Map<string, IServiceUnit>();
for (const unit of this._doc.services ?? []) unitByName.set(unit.name, unit);

const lookupBase = (ident: IServiceIdent): IServiceUnit | undefined => unitByName.get(ident.name);

for (const unit of this._doc.services ?? []) {
const merged = new Map<string, Type>();
// Locals come last so they win on Map.set last-write-wins.
for (const t of _collectServiceScopeTypes(unit, lookupBase)) {
merged.set(t.name, t);
}
serviceIndex.set(unit.name, merged);
}
this._serviceTypeIndex = serviceIndex;
}

/** #### Constructor functions with arguments from the parsed IDL */
get ctors(): Record<string, ISailsCtorFuncParams> | null {
if (!this._doc.program) {
Expand Down Expand Up @@ -349,7 +432,8 @@ export class SailsService implements ISailsService {
this._api = api;
this._programId = programId;
this._resolveServiceUnit = resolveServiceUnit;
this._typeResolver = new TypeResolver(service.types);
// Self-contained scope: bases first so locals shadow on collision.
this._typeResolver = new TypeResolver(_collectServiceScopeTypes(service, resolveServiceUnit));
this._routeIdx = routeIdx;

this.events = this._getEvents(service);
Expand Down
169 changes: 162 additions & 7 deletions js/src/type-resolver-idl-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,177 @@ export class TypeResolver {

const scaleTypes: Record<string, any> = {};
const userTypes: Record<string, Type> = {};
// Iteration order is last-write-wins: when two entries share a name, the later one
// overrides the earlier. Callers that want a "shadowing" merge pass already-merged
// arrays in the desired override order (e.g. base-service types first, locals last).
for (const type of types) {
userTypes[type.name] = type;
}
this._userTypes = userTypes;
for (const type of Object.values(userTypes)) {
if (!type.type_params?.length) {
// register non-generic by name
scaleTypes[type.name] = this.getTypeDef(type);
}
}
this._userTypes = userTypes;
this.registry.setKnownTypes({ types: scaleTypes });
this.registry.register(scaleTypes);
}

/**
* Resolve a named user type to its `Type` definition.
*
* Two call shapes:
* - `resolveNamed(typeDecl)` — pass a `TypeDecl`; returns the user type for a
* `{ kind: 'named', name, generics? }` decl, or `undefined` for primitives, slices,
* arrays, tuples, type parameters, and unknown names.
* - `resolveNamed(name, generics?)` — pass the user type's name directly, with an
* optional concrete generics list.
*
* When concrete generics are provided (either via the overload or via `typeDecl.generics`),
* the returned `Type` is a **concrete** substituted copy: every `{ kind: 'generic', name }`
* leaf is replaced according to the user type's declared `type_params`, and `type_params`
* is omitted from the result. When no generics are provided, the raw user `Type` is
* returned as-is (shared with the resolver's internal state — do not mutate).
*/
resolveNamed(type: TypeDecl): Type | undefined;
resolveNamed(name: string, generics?: TypeDecl[]): Type | undefined;
resolveNamed(typeOrName: TypeDecl | string, generics?: TypeDecl[]): Type | undefined {
let name: string;
let concrete: TypeDecl[] | undefined;

if (typeof typeOrName === 'string') {
// String is ambiguous: it's either a primitive (`'u32'`) or a user-type name.
// Primitives aren't in `_userTypes`, so lookup naturally returns `undefined` for them.
name = typeOrName;
concrete = generics;
} else if (typeOrName.kind === 'named') {
name = typeOrName.name;
concrete = generics ?? typeOrName.generics;
} else {
return undefined;
}

const userType = this._userTypes[name];
if (!userType) return undefined;
if (!concrete?.length) return userType;

const substitutions = this._genericsSubstitutions(userType, concrete);
return this._resolveTypeGenerics(userType, substitutions);
}
Comment thread
vobradovich marked this conversation as resolved.

/**
* Recursively resolve type parameters through a `TypeDecl` tree.
*
* Pure: does not mutate inputs. Idempotent: passing an already-resolved tree yields an
* equivalent tree. Only `{ kind: 'generic', name: 'T' }` leaves whose `name` appears in
* `substitutions` are replaced; wrapper shapes (`Option<T>`, `Vec<T>`, `Result<T, E>`, custom
* generics, and any `named` decl) are preserved and their inner types resolved in place.
*
* Recurses through replacement chains (`{ T: U, U: u32 }` resolves `T` to `u32`).
* Cyclic maps (`{ T: { kind: 'generic', name: 'T' } }` or `{ T: U, U: T }`) are detected at
* runtime and throw rather than stack-overflowing.
*/
resolveGenerics(type: TypeDecl, substitutions: Record<string, TypeDecl> = {}): TypeDecl {
return this._resolveGenerics(type, substitutions, new Set());
}

private _resolveGenerics(
type: TypeDecl,
substitutions: Record<string, TypeDecl>,
visited: Set<string>,
): TypeDecl {
if (typeof type === 'string') return type;
if (type.kind === 'slice') {
const item = this._resolveGenerics(type.item, substitutions, visited);
return item === type.item ? type : { kind: 'slice', item };
}
if (type.kind === 'array') {
const item = this._resolveGenerics(type.item, substitutions, visited);
return item === type.item ? type : { kind: 'array', item, len: type.len };
}
if (type.kind === 'tuple') {
const next = type.types.map((t) => this._resolveGenerics(t, substitutions, visited));
return next.every((t, i) => t === type.types[i]) ? type : { kind: 'tuple', types: next };
}
if (type.kind === 'generic') {
// Explicit type-parameter leaf. Track visited names so a cyclic map
// (`{ T: T }`, `{ T: U, U: T }`) throws instead of stack-overflowing.
const replacement = substitutions[type.name];
if (replacement === undefined) return type;
if (visited.has(type.name)) {
throw new Error(
`Cyclic substitution detected while resolving type parameter "${type.name}" — ` +
`substitution chain: ${[...visited, type.name].join(' → ')}`,
);
}
const nextVisited = new Set(visited);
nextVisited.add(type.name);
return this._resolveGenerics(replacement, substitutions, nextVisited);
}
if (type.kind === 'named') {
if (!type.generics?.length) return type;
const next = type.generics.map((g) => this._resolveGenerics(g, substitutions, visited));
return next.every((g, i) => g === type.generics![i])
? type
: { kind: 'named', name: type.name, generics: next };
}
throw new Error('Unknown TypeDecl kind :: ' + JSON.stringify(type));
}

// Build a substitution map by zipping a user type's declared `type_params` with a concrete
// generics list. Internal: callers should use `resolveNamed(name, generics)` instead of
// building substitution maps by hand.
private _genericsSubstitutions(userType: Type, generics: TypeDecl[] = []): Record<string, TypeDecl> {
const map: Record<string, TypeDecl> = {};
const params = userType.type_params ?? [];
const len = Math.min(params.length, generics.length);
for (let i = 0; i < len; i++) {
map[params[i].name] = generics[i];
}
return map;
}

// Return a `Type` with every `generic` leaf substituted and `type_params` stripped. The result
// is a shallow copy — fields/variants/targets are recursively resolved via `resolveGenerics`,
// which preserves unchanged subtrees by reference.
private _resolveTypeGenerics(type: Type, substitutions: Record<string, TypeDecl>): Type {
if (type.kind === 'struct') {
return {
kind: 'struct',
name: type.name,
docs: type.docs,
annotations: type.annotations,
fields: type.fields.map((f) => ({
...f,
type: this.resolveGenerics(f.type, substitutions),
})),
};
}
if (type.kind === 'enum') {
return {
kind: 'enum',
name: type.name,
docs: type.docs,
annotations: type.annotations,
variants: type.variants.map((v) => ({
...v,
fields: v.fields.map((f) => ({
...f,
type: this.resolveGenerics(f.type, substitutions),
})),
})),
};
}
// alias
return {
kind: 'alias',
name: type.name,
docs: type.docs,
annotations: type.annotations,
target: this.resolveGenerics(type.target, substitutions),
};
}

/**
* Convert a `TypeDecl` into a concrete string name, resolving generic parameters.
*
Expand Down Expand Up @@ -131,11 +290,7 @@ export class TypeResolver {
.map((t: TypeDecl) => this.getTypeDeclString(t, generics, 'canonical'))
.join('')}`;
if (!this.registry.hasType(canonicalName)) {
// type param to generic map, i.e, { "T": "String", "U": { "kind": "named", "name": "Option", "generics": ["u32"]} }
const generics_map: Record<string, TypeDecl> = {};
for (let i = 0; i < userType.type_params.length; i++) {
generics_map[userType.type_params[i].name] = type.generics[i];
}
const generics_map = this._genericsSubstitutions(userType, type.generics);
const typeDef = this.getTypeDef(userType, generics_map);
/// When a user type with generics is resolved, the resolver constructs two names:
// - genericName: readable, type-like syntax (example MyType<Option<u32>>).
Expand Down
Loading