MPS 2023.3 Help

Scopes

We are going to look at two ways to define scopes for custom language elements - the inherited (hierarchical) and the referential approaches. We chose the Calculator tutorial language as a testbed for our experiments. You can find the calculator-tutorial project included in the set of sample projects that comes with the MPS distribution.

Two ways

All references need to know the set of allowed targets. This enables MPS to populate the completion menu whenever the user is about to supply a value for the reference. Existing references can be validated against that set and marked as invalid, if they refer to elements out of the scope. By default, when no scoping is defined for a reference, all targets in the current model as well as in the imported models, are in scope and thus available for the reference.

MPS offers two ways to define scopes:

  • Inherited scopes

  • Reference scopes

Reference scope offers lower ceremony, while Inherited scopes allow the scope to be built gradually following the hierarchy of nodes in the model.

Inherited scopes

We will describe the new hierarchical (inherited) mechanism of scope resolution first. This mechanism delegates scope resolution to the ancestors, who implement ScopeProvider.

  1. MPS starts looking for the closest ancestor to the reference node that implements ScopeProvider and who can provide scope for the current kind.

  2. If the ScopeProvider returns null, MPS continues searching for more distant ancestors.

  3. Each ScopeProvider can 

    • build and return a Scope implementation (more on these later)

    • delegate to the parent scope

    • add its own elements to the parent scope

    • hide elements from parent scope (more on how to work with scopes will be discussed later)

Call to obtain the parent scope must be made explicitly from within a ScopeProvider.

Our InputFieldReference thus searches for InputField nodes and relies on its ancestors to build a list of those.

Sc1.png
Sc2.png

Once we have specified that the scope for InputFieldReference when searching for an InputField is inherited, we must indicate that Calculator is a ScopeProvider. This ensures that Calculator will have say in building the scope for all InputFieldReferences that are placed as its descendants.

Now, go to structure of Calculator and make sure in implements the ScopeProvider concept interface concept.

Sc3.png

The Calculator in our case should return a list of all its InputFields whenever queried for scope of InputField. So in the Behavior aspect of Calculator we override (Control + O) the getScope() method:

Co1111.png

If Scope remains unresolved, we need to import the model (Control + R) that contains it (jetbrains.mps.scope):

Sc5.png

The getScope() method takes two parameters:

  • kind - the concept of the possible targets for the reference

  • child - the child node of the current (this) ScopeProvider, from which the request came, so the actual reference is among descendants of the child node

We also need BaseLanguage since we need to encode some functionality. The jetbrains.mps.lang. smodel language needs to be imported in order to query nodes. These languages should have been imported for you automatically. If not, you can import them using the Control + L shortcut.

Now we can complete the scope definition code, which, in essence, returns all input fields from within the calculator (for parent scope to be available, import the jetbrains.mps.lang.Scopes language):

Sc8.png

A quick tip: Notice the use of SimpleRoleScope class. It is one of several helper classes that can help you build your own custom scopes. Check them out by Navigating to SimpleRoleScope (Control/Cmd + N) and opening up the containing package structureAlt+F1.

Scope helper implementations

MPS comes with several helper Scope implementations that cover many possible scenarios and you can use them to ease the task of defining a scope:

  • ListScope - represents the nodes passed into its constructor

  • DelegatingScope - delegates to a Scope instance passed into its constructor, typically to be extended by scopes that need to add functionality around an existing scope, e.g. LazyScope

  • CompositeScope - delegates to a group of (wrapped) Scope instances

  • FilteringScope - delegates to a single Scope instance, filtering its nodes with a predicate (the isExcluded method)

  • FilteringByNameScope - delegates to a single Scope instance, filtering its nodes by a name blacklist, which it gets as a constructor parameter

  • EmptyScope - scope with no nodes

  • SimpleRoleScope - a scope providing all child nodes of a node, which match a given role

  • ModelsScope - a scope containing all nodes of a given concept contained in the supplied set of models

  • ModelPlusImportedScope - like ModelsScope, but includes all models imported by the given model

For example, the getScope() method could be rewritten using ListScope this way:

Scopex1001.png

VariableReference

A slightly more advanced example can be found in BaseLanguage. VariableReference uses inherited scope for its variableDeclaration reference.

scp1.png

Concepts such as ForStatement, LocalVariableDeclaration, BaseMethodDeclaration, Classifier as well as some others add variable declarations to the scope and thus implement ScopeProvider.

scp2.png

For example, ForStatement uses the Scopes.forVariables helper function to build a scope that enriches the parent scope with all variables declared in the for loop, potentially hiding variables of the same name in the parent scope. The come from expression detects whether the reference that we're currently resolving the scope for lies in the given part of the sub-tree.

Using reference scope

Scopes can alternatively be implemented in a faster but less scalable way - using the reference scope:

rsc1.png

Instead of delegating to the ancestors of type ScopeProvider to do the resolution, you can insert the scope resolution code right into the constraint definition.

rsc2.png

Instead of the code that originally was inside the Calculator's getScope() method, it is now InputFieldReference itself that defines the scope. The function for reference scope is supposed to return a Scope instance, just like the ScopeProvider.getScope() method. Scope is essentially a list of potential reference targets together with logic to resolve these targets with textual values.

To remind you, there are several predefined Scope implementations and related helper factory methods ready for you to use:

  • SimpleRoleScope - simply adds all nodes connected to the supplied node and being in the specified role.

  • ListScope - builds a scope implementation arround a list (sequence) of nodes provided in the constructor.

  • ModelPlusImportedScope- provides reference targets from imported models. Allows the user add targets to scope by ctrl + R / cmd + R (import containing model).

  • FilteringScope - allow you to exclude some elements from another scope. Subclasses of FilteringScope with override the isExcluded() method.

  • DelegatingScope - delegates to another scope. Meant to be overridden to customize the behavior of the original scope.

You may also look around yourself in the scope model:

scp3.png

Caching language constructs

Several expressions are available for reference scope functions that calculate a scope once and then cache it under a provided key. When a model asks for such a cached scope, it is only calculated once and cached for subsequent requests.

  • for model [factory, key] - the "factory" function calculates a scope for a provided model, the scope is then cached under "key".

  • visible roots [ concept declaration ] - creates a scope for all roots of the given concept visible from the current model and caches it

  • visible nodes [ concept declaration ] - creates a scope for all nodes of the given concept visible from the current model and caches it

Last modified: 07 March 2024