Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ The SQL and Mongo family interpreters are the first consumers. They define their

The kit consumes `ExpressionAst` directly. No intermediate argument representation is introduced, and no combinator reparses flattened source text except `json()`, the deliberate quoted-JSON-object exception.

Attributes are a PSL authoring concern, so the kit is in `psl-parser` rather than framework core. Field, model, and block attributes are all constructed through it. A block descriptor declares which attributes its block accepts, and the generic block reconstruction interprets them at parse time.
Attributes are a PSL authoring concern, so the kit is in `psl-parser` rather than framework core. Field, model, and block attributes are all constructed through it. A block descriptor declares which attributes its block accepts, and symbol-table construction interprets them after collecting all declarations.

---

Expand Down Expand Up @@ -85,6 +85,7 @@ A combinator declares what it reads. The contexts nest by what the site being pa
interface AttributeCtx {
readonly sourceId: string;
readonly sourceFile: SourceFile;
readonly symbols: SymbolTable;
}

interface ModelAttributeCtx extends AttributeCtx {
Expand All @@ -97,7 +98,7 @@ interface FieldAttributeCtx extends ModelAttributeCtx {
}
```

A block has no model, so a block attribute is parsed with only the source context. A combinator is usable at any level that carries the facts it declares, and rejected where those facts do not exist.
A block has no model, so a block attribute is parsed without a model context. A combinator is usable at any level that carries the facts it declares, and rejected where those facts do not exist. Checked references derive their lexical scope from the expression's syntax ancestry; the parse context carries no owner or scope field.

A spec fixes the attribute level and name, declares its arguments, and may refine the parsed result:

Expand Down Expand Up @@ -136,6 +137,7 @@ Positionals are fixed slots with an output key. Variadic positionals are not sup
- `numLiteral()` parses any number literal and keeps its source text, for consumers that must not round it through a JavaScript number.
- `int({ min, max })` parses an integer with optional inclusive bounds.
- `bool()` parses a boolean literal.
- `identifier()` accepts any bare identifier and returns its name as a string.
- `identifier(name)` matches one exact bare identifier and preserves its literal type.

There is no enum-specific combinator. A fixed vocabulary is a `oneOf` over pinned matchers, making the source spelling explicit:
Expand All @@ -157,9 +159,20 @@ These leaves perform direct AST checks. They do not wrap arktype schemas.

`fieldRef()` parses a field-name identifier and validates it against the declaring model, so it is available to model and field attributes alike. `referencedFieldRef()` validates against the relation target, which only a field can resolve; cross-space references may defer the existence check when no referenced model is locally available. Both return the authored field name as a string.

`entityRef()` parses an unresolved model-name string. Existence and family semantics remain downstream concerns.
`entityRef(expected)` checks that the referenced declaration exists and has the expected kind: `{ kind: 'model' }`, `{ kind: 'compositeType' }`, `{ kind: 'namedType' }`, or `{ kind: 'block', keyword }`. It returns the selected declaration plus its lexical namespace (undefined at top level). Resolution prefers the containing namespace's declaration, then top level, never a sibling namespace; forward references are allowed, and missing or wrong-kind targets produce source-anchored expression diagnostics.

The current kit does not return declaration-bearing entity coordinates, provide a document-path scope, or include a codec reference combinator. Those would be separate additions if a future consumer requires them.
```ts
const baseSpec = modelAttribute('base', {
documentation: 'Declares the base model.',
positional: [
{ key: 'base', type: entityRef({ kind: 'model' }), documentation: 'The model to inherit from.' },
],
});
```

`oneOf(entityRef(expected), identifier())` prefers a checked identity and otherwise returns an unchecked name, without leaking failed-alternative diagnostics.

The current kit does not provide a document-path scope or include a codec reference combinator. Those would be separate additions if a future consumer requires them.

### Native collections

Expand Down Expand Up @@ -244,7 +257,7 @@ const indexFieldElement = oneOf(
fieldRef(),
funcCall('wildcard', {
documentation: 'Indexes document fields using a wildcard index.',
positional: [{ key: 'scope', type: optional(entityRef()), documentation: 'The field path to index recursively. Omit for all document fields.' }],
positional: [{ key: 'scope', type: optional(identifier()), documentation: 'The field path to index recursively. Omit for all document fields.' }],
}),
...fieldNames.map((name) => funcCall(name, sortSig)),
);
Expand Down Expand Up @@ -320,7 +333,6 @@ The current implementation is sufficient for interpreter consumption but not yet
## Follow-up work

- Add central spec discovery and traversable combinator metadata for language-tooling consumers.
- Decide whether reference combinators should expose declaration-bearing results while preserving the interpreter's string-oriented lowering needs.
- Revisit signature-derived `TypedFuncCall` output types if downstream code needs statically discriminated call unions.
- Decide whether literal-to-field-type compatibility should remain in lowering or gain a dedicated field-context combinator.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { AttributeCtx } from '../types';
export const ATTRIBUTE_DIAGNOSTIC_CODE: PslDiagnosticCode = 'PSL_INVALID_ATTRIBUTE_SYNTAX';

export function leafDiagnostic(
ctx: AttributeCtx,
ctx: Pick<AttributeCtx, 'sources'>,
node: AstNode,
message: string,
code: PslDiagnostic['code'] = ATTRIBUTE_DIAGNOSTIC_CODE,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,53 @@
import { notOk, ok, type Result } from '@internal/utils/result';
import type { PslDiagnostic } from '../../diagnostic';
import type {
DeclarationFor,
EntitySelector,
ResolvedEntityReference,
} from '../../entity-reference';
import { resolveEntityReference } from '../../entity-reference';
import { IdentifierAst } from '../../syntax/ast/identifier';
import type { AttributeCtx, EntityRefArgType } from '../types';
import { leafDiagnostic } from './diagnostic';

// A bare model-name reference. Existence of a model with this name is resolved
// downstream (e.g. `resolvePolymorphism`), not here.
export function entityRef(): EntityRefArgType<AttributeCtx> {
export function entityRef<const S extends EntitySelector>(
expected: S,
): EntityRefArgType<DeclarationFor<S>, AttributeCtx> {
const label = `${expected.kind === 'block' ? expected.keyword : expected.kind} reference`;
return {
kind: 'entityRef',
label: 'model name',
parse: (arg, ctx): Result<string, readonly PslDiagnostic[]> => {
const identifier = IdentifierAst.cast(arg.syntax);
if (identifier === undefined) {
return notOk([leafDiagnostic(ctx, arg, 'Expected a model name')]);
}
const name = identifier.name();
label,
expected,
parse: (
arg,
ctx,
): Result<ResolvedEntityReference<DeclarationFor<S>>, readonly PslDiagnostic[]> => {
const name = IdentifierAst.cast(arg.syntax)?.name();
if (name === undefined) {
return notOk([leafDiagnostic(ctx, arg, 'Expected a model name')]);
return notOk([leafDiagnostic(ctx, arg, `Expected ${label}`)]);
}
const reference = resolveEntityReference(arg, name, ctx.symbols);
if (reference === undefined) {
return notOk([leafDiagnostic(ctx, arg, `Unknown ${label} "${name}"`)]);
}
if (!matchesSelector(reference, expected)) {
const actual = reference.declaration;
const kind = actual.kind === 'block' ? actual.keyword : actual.kind;
return notOk([leafDiagnostic(ctx, arg, `Expected ${label} "${name}", found ${kind}`)]);
}
return ok(name);
return ok(reference);
},
};
}

function matchesSelector<S extends EntitySelector>(
reference: ResolvedEntityReference,
expected: S,
): reference is ResolvedEntityReference<DeclarationFor<S>> {
const declaration = reference.declaration;
return (
declaration.kind === expected.kind &&
(expected.kind !== 'block' ||
(declaration.kind === 'block' && declaration.keyword === expected.keyword))
);
}
Original file line number Diff line number Diff line change
@@ -1,22 +1,33 @@
import { notOk, ok, type Result } from '@internal/utils/result';
import type { PslDiagnostic } from '../../diagnostic';
import { IdentifierAst } from '../../syntax/ast/identifier';
import type { AttributeCtx, IdentifierArgType } from '../types';
import type {
AttributeCtx,
FixedIdentifierArgType,
IdentifierArgType,
UnrestrictedIdentifierArgType,
} from '../types';
import { leafDiagnostic } from './diagnostic';

export function identifier(): UnrestrictedIdentifierArgType<AttributeCtx>;
export function identifier<const N extends string>(
name: N,
options: { readonly documentation: string },
): IdentifierArgType<N, AttributeCtx> {
): FixedIdentifierArgType<N, AttributeCtx>;
export function identifier(
name?: string,
options?: { readonly documentation: string },
): IdentifierArgType<string, AttributeCtx> {
const label = name ?? 'identifier';
return {
kind: 'identifier',
label: name,
label,
name,
documentation: options.documentation,
parse: (arg, ctx): Result<N, readonly PslDiagnostic[]> => {
const identifier = IdentifierAst.cast(arg.syntax);
if (identifier !== undefined && identifier.name() === name) return ok(name);
return notOk([leafDiagnostic(ctx, arg, `Expected ${name}`)]);
documentation: options?.documentation ?? '',
parse: (arg, ctx): Result<string, readonly PslDiagnostic[]> => {
const value = IdentifierAst.cast(arg.syntax)?.name();
if (value !== undefined && (name === undefined || value === name)) return ok(value);
return notOk([leafDiagnostic(ctx, arg, `Expected ${label}`)]);
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export function list<T, Ctx extends AttributeCtx>(
const unique = opts?.unique ?? false;
return {
kind: 'list',
label: opts?.label ?? `${of.label}[]`,
label: opts?.label ?? (of.label.includes(' | ') ? `(${of.label})[]` : `${of.label}[]`),
of,
allowEmpty,
unique,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,21 @@ import type { PslSpan } from '@internal/framework-components/psl-ast';
import type { Result } from '@internal/utils/result';
import type { Simplify, UnionToIntersection } from '@internal/utils/types';
import type { PslDiagnostic } from '../diagnostic';
import type {
EntityDeclaration,
EntitySelector,
ResolvedEntityReference,
} from '../entity-reference';
import type { PslSources } from '../source-file';
import type { FieldSymbol, ModelSymbol } from '../symbol-table';
import type { FieldSymbol, ModelSymbol, SymbolTable } from '../symbol-table';
import type { ExpressionAst } from '../syntax/ast/expressions';
import type { AstNode } from '../syntax/ast-helpers';

export type AttributeLevel = 'field' | 'model' | 'block';

export interface AttributeCtx {
readonly sources: PslSources;
readonly symbols: SymbolTable;
}

export interface ModelAttributeCtx extends AttributeCtx {
Expand Down Expand Up @@ -53,9 +59,12 @@ export interface BoolArgType<Ctx extends AttributeCtx = AttributeCtx>
readonly kind: 'bool';
}

export interface EntityRefArgType<Ctx extends AttributeCtx = AttributeCtx>
extends ArgTypeOutput<string, Ctx> {
export interface EntityRefArgType<
D extends EntityDeclaration = EntityDeclaration,
Ctx extends AttributeCtx = AttributeCtx,
> extends ArgTypeOutput<ResolvedEntityReference<D>, Ctx> {
readonly kind: 'entityRef';
readonly expected: EntitySelector;
}

export interface FieldRefArgType<Ctx extends ModelAttributeCtx = ModelAttributeCtx>
Expand Down Expand Up @@ -90,7 +99,7 @@ export interface FuncCallArgType<
readonly signature: Signature;
}

export interface IdentifierArgType<
export interface FixedIdentifierArgType<
Name extends string = string,
Ctx extends AttributeCtx = AttributeCtx,
> extends ArgTypeOutput<Name, Ctx> {
Expand All @@ -99,6 +108,17 @@ export interface IdentifierArgType<
readonly documentation: string;
}

export interface UnrestrictedIdentifierArgType<Ctx extends AttributeCtx = AttributeCtx>
extends ArgTypeOutput<string, Ctx> {
readonly kind: 'identifier';
readonly name: undefined;
}

export type IdentifierArgType<
Name extends string = string,
Ctx extends AttributeCtx = AttributeCtx,
> = FixedIdentifierArgType<Name, Ctx> | UnrestrictedIdentifierArgType<Ctx>;

export interface IntArgType<Ctx extends AttributeCtx = AttributeCtx>
extends ArgTypeOutput<number, Ctx> {
readonly kind: 'int';
Expand Down Expand Up @@ -227,7 +247,7 @@ export type ContextForRequirement<Req extends ArgTypeContext> = Req extends 'fie

export type InspectableArgType<Ctx extends AttributeCtx> =
| BoolArgType<Ctx>
| EntityRefArgType<Ctx>
| EntityRefArgType<EntityDeclaration, Ctx>
| FieldRefArgType<ModelAttributeCtx & Ctx>
| FuncCallArgType<string, Ctx>
| IdentifierArgType<string, Ctx>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { BlockAttributeSpecFactory } from './attribute-spec/spec-context';
import type { ParseDiagnostic } from './parse';
import { nodePslSpan } from './resolve';
import type { PslSources } from './source-file';
import type { BlockSymbol, SymbolTable } from './symbol-table';
import type { ModelAttributeAst } from './syntax/ast/attributes';
import type { GenericBlockDeclarationAst, KeyValuePairAst } from './syntax/ast/declarations';
import { ArrayLiteralAst, type ExpressionAst } from './syntax/ast/expressions';
Expand All @@ -33,8 +34,7 @@ export function reconstructExtensionBlock(
const blockName = node.name()?.name() ?? '';

const blockAttributes: PslExtensionBlockAttribute[] = [];
const attributes: Record<string, PslExtensionBlockParsedAttribute> = {};
const seenAttributeNames = new Set<string>();

for (const attribute of node.attributes()) {
const name = attribute.name()?.path().join('.') ?? '';
const args = Array.from(attribute.argList()?.args() ?? [], (arg) => {
Expand All @@ -47,22 +47,6 @@ export function reconstructExtensionBlock(
});
const span = nodePslSpan(attribute.syntax, sources);
blockAttributes.push({ name, args, span });
if (descriptor === undefined) continue;
const parsed = parseBlockAttribute(
attribute,
name,
span,
descriptor,
seenAttributeNames,
keyword,
blockName,
sources,
);
if (parsed.ok) {
attributes[name] = parsed.value;
} else {
diagnostics.push(...parsed.diagnostics);
}
}

const parameters: Record<string, PslExtensionBlockParamValue> = {};
Expand Down Expand Up @@ -97,11 +81,40 @@ export function reconstructExtensionBlock(
name: blockName,
parameters,
blockAttributes,
attributes,
attributes: {},
span: nodePslSpan(node.syntax, sources),
};
}

export function interpretBlockAttributes(
symbol: BlockSymbol,
descriptor: AuthoringPslBlockDescriptor,
sources: PslSources,
symbols: SymbolTable,
diagnostics: ParseDiagnostic[],
): void {
const seenNames = new Set<string>();
for (const attribute of symbol.node.attributes()) {
const name = attribute.name()?.path().join('.') ?? '';
const parsed = parseBlockAttribute(
attribute,
name,
nodePslSpan(attribute.syntax, sources),
descriptor,
seenNames,
symbol.keyword,
symbol.name,
sources,
symbols,
);
if (parsed.ok) {
Object.assign(symbol.block.attributes, { [name]: parsed.value });
} else {
diagnostics.push(...parsed.diagnostics);
}
}
}

function parseBlockAttribute(
attribute: ModelAttributeAst,
name: string,
Expand All @@ -111,6 +124,7 @@ function parseBlockAttribute(
keyword: string,
blockName: string,
sources: PslSources,
symbols: SymbolTable,
):
| { readonly ok: true; readonly value: PslExtensionBlockParsedAttribute }
| { readonly ok: false; readonly diagnostics: readonly ParseDiagnostic[] } {
Expand Down Expand Up @@ -148,7 +162,7 @@ function parseBlockAttribute(
BlockAttributeSpecFactory,
'framework core cannot name AttributeSpec, so block-attribute factories transit the descriptor erased as unknown; this is the single point that restores the factory type the descriptor surface documents'
>(declared[name]);
const result = interpretAttribute(attribute, factory(), { sources });
const result = interpretAttribute(attribute, factory(), { sources, symbols });
if (!result.ok) {
return {
ok: false,
Expand Down
Loading
Loading