Building On What You Already Wrote
What a spec actually is, how extends resolves, and the difference between super and self.
Psy has exactly one primitive. A spec.
(I did think about adding more. I kept not needing to, and every time I nearly did, the thing I actually wanted turned out to be inheritance wearing a hat.)
spec Defaults:
retries: 3
strict: true
spec Production extends Defaults:
retries: 5psy inspect ProductionProduction (defaults.psy)
abstract: false
precedence: defaults.psy#Production > defaults.psy#Defaults
retries = 5
declared in Production at defaults.psy
strict = true
declared in Defaults at defaults.psyThat is the whole idea. The rest of this chapter is the parts that are not obvious.
A spec is not a class
This trips people up for about ten minutes, so let me get it out of the way.
A spec is never instantiated. There is no new, no constructor, no instance.
A spec is the value structure. Resolving it produces data, and that data is what
the compiler hands to whatever consumes it.
So when you read spec Production extends Defaults, do not think "subclass".
Think "this document, plus these changes".
The shape of one
[export] [abstract] spec Name [extends A, B, C] [as keyword]:
<members>Names are PascalCase by convention, properties are camelCase, and the linter
will tell you when you drift. Neither is enforced by the parser — you can turn
both rules off — but the generated output of half the tooling assumes them, so
you may as well.
Specs never nest
spec Outer:
foo: 1
spec Inner: // error PSY2011
bar: 2Specs are always top-level. Always. If you want nested structure, that is what objects are for, and they nest as deep as you like.
I made this rule early and have never regretted it. A nested spec would need a name that is only meaningful inside its parent, which means it needs a scoping rule, which means references need a scoping rule, which means... no.
Order does not matter
spec A extends B:
foo: 1
spec B:
bar: 2That works. The compiler binds every top-level declaration before it resolves any of them, so a spec may extend one that is declared eighty lines further down. Put your declarations in whatever order reads best.
Say a property once
spec Thing:
retries: 3
retries: 5 // error PSY3019Declaring the same property twice in one spec is an error. If you are trying to build a value out of several pieces, you want a block with composition operations, which is the next chapter.
How a property is actually resolved
Given a spec S and a property p:
- If
Sdeclaresp, evaluate that declaration inS's context. - Otherwise find the first spec in
S's precedence order that declaresp, and evaluate that declaration inS's context. - If nothing declares it,
Ssimply has nop.
Step 2 is doing more work than it looks like. Note what it does not say: it does not say "take the parent's resolved value". It says take the parent's definition and re-run it here.
That distinction is invisible until you meet self, at the bottom of this page,
and then it is the whole ballgame.
Extending several things
spec Child extends A, B, C:
plain: 1Precedence is left to right, strongest first:
A > B > CSo:
spec A:
foo: fromA
only: onlyA
spec B:
foo: fromB
bar: fromB
spec C:
foo: fromC
baz: fromC
spec Child extends A, B, C:
plain: 1Child.foo = "fromA"
Child.bar = "fromB"
Child.baz = "fromC"
Child.only = "onlyA"
Child.plain = 1Leftmost wins, and the ones to its right fill in what it did not mention.
The diamond
When two parents share an ancestor, the ancestor should appear once, and it should appear after everything that inherits it. That is what a linearization is for.
spec Root:
root: 1
spec Left extends Root:
left: 1
spec Right extends Root:
right: 1
spec Child extends Left, Right:
child: 1Child > Left > Right > RootPsy computes that with a C3-style merge, which is the same algorithm Python uses and for the same reasons. When a hierarchy admits no consistent C3 order at all, Psy falls back to plain left-to-right depth-first rather than failing.
That fallback is a deliberate choice and I will defend it: a config language should not refuse to compile because your inheritance graph is unusual. The result is always total, always deterministic, and never depends on iteration order. It might just not be the order you would have picked.
psy inspect <Spec> prints the whole linearization as the precedence: line, so
you never have to work it out on paper.
What you can and cannot extend
Only specs. Extending a constant, or an imported thing that is not a spec, is
PSY3011. Extending a name that does not exist at all is PSY3002. And a spec
may not appear in its own inheritance chain — PSY3006, no exceptions, no
"but it would be useful if".
super — reaching what you inherited
spec Child extends A, B, C:
foo: supersuper resolves to the first matching inherited value, in precedence order:
A.foo, else B.foo, else C.foo.
super always means "this same property, in my parents". foo: super looks for
foo. It cannot reach a differently-named property, and there is no
super.somethingElse syntax. One job.
There is a second form — + super, which composes all the inherited values
rather than taking the first — and it is the one you will use more often. It
belongs with the other composition operators, so it is next
chapter.
super binds lexically
Here is the rule that will save you a debugging session eventually.
super is relative to the spec whose source is being evaluated, not to the
spec the property is being resolved for.
spec Grand:
prompt:
grand
spec Parent extends Grand:
prompt:
+ super
parent
spec Child extends Parent:
other: 1Child.prompt = "grand\n\nparent"Rendered, that is:
grand
parentWhen Parent's definition gets re-evaluated on behalf of Child, its super
still means Grand — because that is what super meant where it was written.
It does not suddenly mean Parent just because Child is asking.
Lexical. Like a closure. That is the mental model.
When super has nothing to reach
superin a spec with no parents at all isPSY3012.superwhere no parent declares that property isPSY3013.- A
superchain that loops back on itself isPSY3007.
self — asking who is being resolved
And now the opposite.
self.name
self.keyword
self.abstract| Field | Value |
|---|---|
self.name | the spec's name, as a string |
self.keyword | the framework keyword it was declared with, or null |
self.abstract | true for abstract specs |
Any other field is PSY3017, as is reaching for self outside a spec.
self is late bound. It always names the spec a property is being resolved
for, never the spec that wrote the definition:
spec Parent:
label:
I am ${self.name}.
spec Child extends Parent:
other: 1Parent.label = "I am Parent."
Child.label = "I am Child."As data:
{
"Parent": { "label": "I am Parent." },
"Child": { "label": "I am Child.", "other": 1 }
}One definition. Two answers. That only works because resolution re-evaluates definitions rather than caching parent values — which is the thing I flagged as doing more work than it looked like, about a page ago.
Why you would want this
export abstract spec Described as described:
abstract summary: string
label:
Definition ${self.name} declared as ${self.keyword}.
export described Overview:
summary:
Everything, briefly.psy inspect Overview.labelOverview.label (described.psy)
= "Definition Overview declared as described."
declared in Described at described.psy
provenance:
block string in Described.label (described.psy:5:9)
interpolation self.name in Described.label (described.psy:6:20)
interpolation self.keyword in Described.label (described.psy:6:44)Written once, on the base, correct for every descendant forever — and the provenance names the two interpolations that produced it.
This is how a framework attaches boilerplate that has to mention the thing it is attached to. Without it, every declaration repeats its own name back at itself and one of them is eventually wrong.
The two of them together
| Binds | Means | |
|---|---|---|
super | lexically | the parents of whoever wrote this line |
self | dynamically | whoever is being resolved right now |
A parent can write one definition that reads correctly for every descendant
(self) while still reaching its own ancestors (super). Those pull in opposite
directions on purpose.