RFC: Fusion strict object api: arguments and typing

Moin,

fusion inconveniences i found for myself:

I do really like Fusion, but sometimes it’s hard to see (from the prototype source code) what part of a fusion prototype is API or should not be touched. As it just comes down to some comments in the code. (I know the pattern of computed properties / nested component props make this clearer but hear me out …)

Often there are things like nodePath = 'to-be-set-by-user' f.x. in the ContentCollection.fusion.
But to me, this doesn’t feel right. I know one could set it to an empty string or null, but I don’t want to set a value for this, since it should be required and user defined.

When creating a new object and passing properties it feels like subclassing an object every time, and like shooting blind and hoping to define the right propertys, so everything works (a bit overdramatized - but it’s hard for beginners that don’t know the API for some prototypes that well).

Having no way to enforce certain properties will make it hard when fx. spelling ‘renderer’ wrong in the object creation. A new path will be created what you dindt want, and youre getting a at first glance wierd exception like ‘No fusion object was found in path x’. Why would this fail only at such late stage and not before. Issues like that should be caught earlier IMO and so will be easier to fix. ‘Required argument ‘renderer’ is missing for prototype Neos.Fusion:Renderer’ reads better, I think.

Also, Fusion has currently no build in type hinting implementing which would help with a stricter API for objects. (related idea came up on the sprint Neos and Flow Future Notes / Discussion - CodiMD)

idea

I’d like to have a way to interact / instantiate fusion objects, without overriding wrong things and while passing all required arguments with a type check.

A type check functionality only for the arguments would be easier to implement than the posibility to type check every path, that could maybe suffice already as PHP does it similarly.

The following ideas assume that the parser can tell the difference between inside a prototype() declaration, and a normal path declaration/ Object instantiation. That is possible already.

the prototype declaration could look like:

‘this.args’ would be pointing to all the arguments.

prototype(Vendor:Content) < prototype(Neos.Fusion:Component) {
    
    @args {
        name[string]
        node[?Neos\ContentRepository\Domain\Model\NodeInterface] = null,
        untyped = 123
    }

    name = ${this.args.name}
    node = ${this.args.node ? this.args.node : node}
    renderer = afx`
        <h1>hi {props.name}</h1>
        {q(props.node).property('stuff')}
    `
}

with syntax sugar

the prototype declaration could look like:
The idea here is that everything in () is parsed into a path like __args

prototype(Vendor:Content) < prototype(Neos.Fusion:Component) (
    name[string],
    node[?Neos\ContentRepository\Domain\Model\NodeInterface] = null,
    untyped = 123
) {
    name = ${this.args.name}
    node = ${this.args.node ? this.args.node : node}
    renderer = afx`
        <h1>hi {props.name}</h1>
        {q(props.node).property('stuff')}
    `
}

instantiation could look like:

value = Vendor:Content {
    @args {
        name = 'Test'
        node = ${node}
    }
}

with syntax sugar

instantiation could look like:
value = Vendor:Content(name='Test', node=${node})

the fusion AST could look like:

# simplified
'Vendor:Content' => [
    '__args' => [
        'name' => [
            '__typed_match_one' => [
                'scalar' => 'string',
            ]
        ],
        'node' => [
            'value' => null
            '__typed_match_one' => [
                'constant' => null,
                'interface' => 'Neos\ContentRepository\Domain\Model\NodeInterface'
            ]
        ],
        'untyped' => [
            'value' => 123
        ]
    ],
    'name' => '${this.args.name}'
    # further stuff
]

Further notes:

about required arguments

An argument would be required if it wasn’t defined in the prototype. I think the runtime configuration will merge the passed configuration on instantiation with the prototype source code. The runtime would only know if the argument is set, but not if it was set explicitly by the user or has the default value. If there is no value set for an argument, that means it wasn’t set and an exception will be thrown.

Lazy type checking and checking for required args or eagerly?

In the runtime the required arguments and types can be checked on instantiation of the object or when this.args.x is called.
Implementing a lazy way would be harder, but what if ${this.args.something} is not set, because a user used the old way of passing ‘something’ (More detail shown in the compatibility example down below)

possibility for pure components?

fx. only the arguments go in and inside there is no access to the outer @context. This would be helpful for caching. But what about when fx. Neos.Fusion:Tag is modified not globally but only for specific paths. That would lead to unexpected results. So pure components must be evaluated always on global level

how would this affect afx?

return type declaration?

how to handle a real path ‘args’ as this will not be accessible via ‘this’

passing args to renderer?

prototype(Vendor:Content) < prototype(Neos.Fusion:Component) (
    foo[string]
) {
    args = ${this.args}
    renderer = afx`
        {props.args.foo}
    `
}

prototype(Vendor:Content) < prototype(Neos.Fusion:Component) (
    foo[string]
) {
    @apply.args = ${this.args}    
    renderer = afx`
        {props.foo}
    `
}

what about passing an argument that doesnt exist?

this could be catched when the fusion parser can tell the difference between if its inside a prototype declaration or an instantiation.

compatibility

the idea would be compatible with the current manner of instantiation of objects in example of Neos.Neos:Editable (syntax sugar way):

prototype(Neos.Neos:Editable) < prototype(Neos.Fusion:Component) (
    property[string],
    node[Neos\ContentRepository\Domain\Model\NodeInterface] = ${node},
    block[bool] = true 
) {
    # The name of the property which should be accessed
    node = ${this.args.node}

    # The name of the property which should be accessed
    property = ${this.args.property}

    # Decides if the editable tag should be a block element (`div`) or an inline element (`span`)
    block = ${this.args.block}

    renderer = afx`...`
}

the above should be usable as currently known via classic override:

but that would mean the required argument ‘property’ is not passed, if argument checks are eager.

editable = Neos.Neos:Editable {
    property = 'title'
}

or with specified arguments (syntax sugar way):

editable = Neos.Neos:Editable (
    property = 'title'
)
3 Likes

There are some problems with this idea, one would be, that quite simple fusion would sometimes get harder to write when using the argument style.

  • setting new array keys in a Neos.Fusion:DataStructure can only happen through the current way, as implementing a spread argument might be quite complex.

  • if and process are won’t work in the argument section of an object.

  • using just attributes.class (object/array as argument), won’t be possible one need to set a data structure explicit.

AFX

value = afx`
    <h1 class.0='a' class.1='b'>AFX</h1>
    <a href='#'>
        Abc <span @if.has={false}>de</span>
    </a>
`

Current override Fusion

value = Neos.Fusion:Array {
    item_1 = Neos.Fusion:Tag {
        tagName = 'h1'
        attributes.class.0 = 'a'
        attributes.class.1 = 'b'
        content = 'AFX'
    }
    item_2 = Neos.Fusion:Tag {
        tagName = 'a'
        attributes.href = '#'
        content = Neos.Fusion:Array {
            item_1 = 'Abc '
            item_2 = Neos.Fusion:Tag {
                tagName = 'span'
                @if.has = ${false}
                content = 'de'
            }
        }
    }
}

Argument ( + Override) Fusion

value = Neos.Fusion:Array {
    item_1 = Neos.Fusion:Tag (
        tagName = 'h1'
        content = 'AFX'
        attributes = Neos.Fusion:DataStructure {
            class = Neos.Fusion:DataStructure {
                0 = 'a'
                1 = 'b'
            }
        }
    )
    item_2 = Neos.Fusion:Tag (
        tagName = 'a'
        attributes = Neos.Fusion:DataStructure {
            href = '#'
        }
        content = Neos.Fusion:Array {
            item_1 = 'Abc '
            item_2 = Neos.Fusion:Tag (
                tagName = 'span'
                content = 'de'
                # make it possible to add @if here too?
            ) {
                # override mode:
                @if.has = ${false}
            }
        }
    )
}

I like every approach to bring type safety and static analysis to fusion. In my mind this always was about fusion pathes and not so much prototypes.

prototype(Vendor:Content) < prototype(Neos.Fusion:Component) {
    # return type of prototype
    @return = string
    
    # argument with type 
    foo = null
    foo.@type = string

    renderer = afx`
        {props.foo}
    `
}

This would already end up in the AST pathes __meta.return resp __meta.type that we could evaluate in the runtime or maybe statically. Types would be checked whenever a prototype or a fusion path is evaluated which fits well into how the runtime works today.

Of course we can and should add a syntax for one line declaration like foo ?string = null eventually but we could implement the analysis and type evaluation right away which would be nice and allow to work on this independent of changes to the parser.

Can you explain your reasoning for your specific approach? I may very well be that your approach is much easier to statically analyze.

One advantage your approach has is that type declarations cannot be overridden from outside. However we could also define that @return and @type are only valid in prototype declarations and are ignored by the parser otherwise or handled differently.

Note: while the runtime type checking is really important i consider static analysis way harder but even more useful. As it could at least say that the given ast is syntactically sound and all types match the expectations.

1 Like

I guess it just felt to me more natural to have some function with typed arguments - but that’s not really how Fusion was invented.


So totally not inspired by your typed Fusion code above, if made a simple package that already kind of works: mhsdesign/MhsDesign.FusionTypeHints (github.com)

Luckily, someone built a parser and compiler for PHP type checking from a string annotation (inspired by Facebook Flow types) GitHub - attitude/duck-types-php: If it walks like a duck and talks like a duck, treat it like a duck, even if it’s not a duck — a dynamic typing for PHP inspired by Flow types

Problems I found are:

1 Like

Nice … I agree that @type and @return are redundant.

I also see the issues you mentioned with the duckTypes package but it is a good start for now. We can always limit the scope and add tests if this turns out to be valuable.

Hardest question for me is wether this will allow to statically analyze a fusion ast.

1 Like

Can we even statically analyze eel? Sure its actual php code but the magic with the eel helpers from the yaml?

Hmm would it be possible to create from the fusion ast valid php and verify that?
I dont know could be also a totally stupid idea…

/*
root:string = "hello"
*/

function root(): string {
	return "hello";
}

/*
root1:string = Neos.Fusion:Value {
	value:string = "hi"
}
*/


class NeosFusionValue {
	public function render(string $value): string {
    	return $value;
    }
}

function root1(): string {
	return root1NeosFusionValue();
}

function root1NeosFusionValue(): string {
	$value = root1NeosFusionValuevalue();
    return (new NeosFusionValue)->render($value);
}

function root1NeosFusionValuevalue(): string {
	return "hi";
}

but eel would be really hard, as i dont think any php analyzer understands this transpiled eel code:

// ${this.somepath}
return function ($context) {return $context->getAndWrap('this')->getAndWrap('somepath');};

Also eel helpers have often __call magic, or for example flowquery has… that would make it even harder…

btw i made some tests for the lib i used: @mficzel Add 190 tests with phpunit. by mhsdesign · Pull Request #3 · attitude/duck-types-php · GitHub

still we would need to have a watcher in the parsing and compile process to stricly allow features and disallow unwanted or too complex features. would be too bad with this current possibility to accidently mess up our types api. maybe we can fork it and strip everything out what we dont need?

(or even provide at start only a rather primitive feature set)

Which features of the types would you consider dangerous? Only the custom types look a little strange to me.

as discussed with you we could simply rollout a rather primitive support for types which could include:

  • classes
  • nullable things ?
  • string, int, bool, null
  • (simple array lists like string[])

that way we dont need a library since its not complex to parse those.

later we could still extend support for union types, complex arrays like associative, or a choise of simple strings like 'red'|'green'|'blue'.

we should maybe consider parsing the annotations in the fusion parser to speed things up - but since its dev mode only…