Chapter 10. Scoping

Usually, the first aspect you need to customize in your DSL implementation in Xtext is the validator. Typically, the second aspect you need to customize is scoping , which is the main mechanism behind visibility and cross-reference resolution. As soon as the DSL needs some constructs for structuring the code, a custom scoping implementation is required. In particular, scoping and typing are often strictly connected and interdependent especially for object-oriented languages. For this reason, in this chapter, we will continue developing the SmallJava DSL introduced in the previous chapter. We will describe both local and global scoping and explain how to customize scoping using SmallJava as a case study.

This chapter will cover the following topics:

  • A detailed description of the Xtext mechanisms for local and global scoping
  • How to customize the local scoping in an object-oriented language
  • How to customize the global scoping with the concepts of packages and imports
  • How to provide a library and a project wizard for your DSL
  • How to customize the indexing of elements of your DSL

Cross-reference resolution in Xtext

Cross-reference resolution involves several mechanisms. In this section, we introduce the main concepts behind these mechanisms and describe how they interact. We will also write tests to get familiar with cross-reference resolution.

Containments and cross-references

Xtext relies on EMF for the in-memory representation of a parsed program, thus, it is necessary to understand how cross-references are represented in EMF.

In EMF, when a feature is not a datatype (string, integer, and so on), it is a reference, that is, an instance of EReference. A containment reference defines a stronger type of association. The association is stronger regarding the lifecycle. The referenced object is contained in the referring object, called the container. In particular, an object can have only one container. If you delete the container, all its contents are also automatically deleted. For non-containment references, the referenced object is stored somewhere else, for example, in another object of the same resource or even in a different resource. A cross-reference is implemented as a non-containment reference.

Note

In Ecore, the EReference class has a boolean property called containment that defines whether the reference is a containment reference or not.

Let's recall the SmallJava DSL rule for a class definition:

SJClass: 'class' name=ID ('extends' superclass=[SJClass])? '{'
      members += SJMember*
'}' ;

An SJMember member is contained in an SJClass class, that is, members is a multi-value containment reference. On the contrary, superclass is a single-value cross-reference.

Scoping deals with cross-references, that is, references with the boolean attribute containment set to false.

The index

The Xtext index stores information about all the objects in every resource. This mechanism is the base for cross-reference resolution. For technical reasons (mainly efficiency and memory overhead), the index stores the IEObjectDescription elements instead of the actual objects. An IEObjectDescription element is an abstract description of an EObject. This description contains the name of the object and the EMF URI of the object. The EMF URI is a path that includes the resource of the object and a unique identifier in the resource. The URI provides a means to locate and load the actual object when needed. The description also contains the EClass of the object, which is useful to filter descriptions by type.

The set of resources handled by the index depends on the context of execution. In the IDE, Xtext indexes all the resources in all the Xtext projects. The index is kept up-to-date in an incremental way using the incremental building mechanism of Eclipse, thus keeping the overhead minimal.

In a plain runtime context, where there is no workspace, the index is based on the EMF ResourceSet. We will see what this implies when we write JUnit tests and when we implement a standalone compiler.

In both contexts, the index is global. Visibility across resources is handled by using containers as shown later in the The index and the containers section.

Qualified names

The default implementation of the index uses a mechanism based on names. The computation of names is delegated to IQualifiedNameProvider. The default implementation of the name provider is based on the string attribute name. This is why we always defined elements that we want to refer to with a feature name in the grammar.

Note

The Xtext editor highlights the string attribute name in orange in the grammar.

Using only the simple name will soon raise problems due to duplicates, even in a simple program. Most Java-like languages use namespaces to allow for elements with the same name in different namespaces. Thus, for example, two different methods are allowed to have local variables with the same name, two different classes are allowed to have fields with the same name, two different packages are allowed to have classes with the same name, and so on. For this reason, the default implementation of the name provider computes a qualified name. It concatenates the name of an element with the qualified name of its container recursively; elements without a name are simply skipped. By default, all segments of a qualified name are separated by a dot, which is a common notation for expressing qualified names like in Java.

For example, consider the following SmallJava program (the type of declarations is not important here, thus, we use the class A that we assume as defined in the program):

class C {
  A f;
  A m(A p) {
    A v = null;
    return null;
  }
}

The containment relations are shown in the following tree figure:

Qualified names

The qualified names of the elements of the preceding SmallJava class are shown in the following table:

Object

Qualified Name

SJClass C

C

SJField f

C.f

SJMethod m

C.m

SJParameter p

C.m.p

SJVariableDeclaration v

C.m.v

Note that SJMethodBody does not participate in the computation of the qualified name of the contained variable declaration, since it does not have a name feature.

Note

Qualified names are the mechanism also used by NamesAreUniqueValidator to decide when two elements are considered as duplicates.

Exported objects

To understand the mechanism behind scoping, it is useful to learn how to access the index. Accessing the index will also be useful for performing additional checks as shown later in this chapter. We show how to get all the object descriptions of the current resource.

The indexed object descriptions of a resource are stored in IResourceDescription, which is an abstract description of a resource. The index is implemented by IResourceDescriptions (plural form) and can be obtained through an injected ResourceDescriptionsProvider using the getResourceDescriptions(Resource) method.

Different resource descriptions are returned depending on the context they are retrieved. For example, there are resource descriptions of the files as they are on disk. However, while typing in the editor, a different resource description will be retrieved, which reflects the unsaved changes and that will shadow the resource description of the corresponding file saved on the disk. The ResourceSet records in which context it was created, that is why it is used as a parameter in the ResourceDescriptionsProvider.

Once we have the index, we get the IResourceDescription of a resource by specifying its URI. Once we have the IResourceDescription, we get the list of all the IEObjectDescription elements of the resource that are externally visible, that is, globally exported, using the method getExportedObjects. We can also filter exported elements by type:

We implement all the index-related operations in a separate class, SmallJavaIndex, in the package scoping:

class SmallJavaIndex {
  @Inject ResourceDescriptionsProvider rdp

  def getResourceDescription(EObject o) {
    val index = rdp.getResourceDescriptions(o.eResource)
    index.getResourceDescription(o.eResource.URI)
  }

  def getExportedEObjectDescriptions(EObject o) {
    o.getResourceDescription.getExportedObjects
  }

  def getExportedClassesEObjectDescriptions(EObject o) {
    o.getResourceDescription.
      getExportedObjectsByType(SmallJavaPackage.eINSTANCE.SJClass)
  }
}

We then write a learning test (see Chapter 7, Testing) listing the qualified names of the exported object descriptions:

@RunWith(XtextRunner)
@InjectWith(SmallJavaInjectorProvider)
class SmallJavaIndexTest {
  @Inject extension ParseHelper<SJProgram>
  @Inject extension SmallJavaIndex
  @Test def void testExportedEObjectDescriptions() {
    '''
    class C {
      A f;
      A m(A p) {
        A v = null;
        return null;
      }
    }
    class A {}
    '''.parse.assertExportedEObjectDescriptions
          ("C, C.f, C.m, C.m.p, C.m.v, A")
     // this will have to be adapted at the end of the chapter
  }
  def
 private assertExportedEObjectDescriptions(EObject o,
                    CharSequence expected) {
   expected.toString.assertEquals(
    o.getExportedEObjectDescriptions.map[qualifiedName].join(", ")
   )
  }
}

The linker and the scope provider

The actual cross-reference resolution, that is, the linking, is performed by LinkingService. Usually, you do not need to customize the linking service, since the default implementation relies on IScopeProvider, which is the component you would customize instead. The default linking service relies on the scope obtained for a specific context in the model, and chooses an object whose name matches the textual representation of the reference in the program.

Thus, a scope provides information about:

  • The objects that can be reached, that is, they are visible, in a specific part of your model, the context
  • The textual representation to refer to them

You can think of a scope as a symbol table (or a map), where the keys are the names and the values are instances of IEObjectDescription. The Java interface for scopes is IScope.

The overall process of cross-reference resolution, that is, the interaction between the linker and the scope provider, can be simplified as follows:

  1. The linker must resolve a cross-reference with text n in the program context c for the feature f of type t.
  2. The linker queries the scope provider: "give me the scope for the elements assignable to f in the program context c".
  3. The scope searches for an element whose key matches with n.
  4. If it finds it, it locates and loads the EObject pointed to by the IEObjectDescription and resolves the cross-reference.
  5. Otherwise, it issues an error of the shape "Couldn't resolve reference to...".

The IScopeProvider entry point is a single method:

IScope getScope(EObject context, EReference reference)

Since the program is stored in an EMF model, the context is an EObject and the reference is an EReference. Note that cross-references in an Xtext grammar specify the type of the referred elements. Thus, the scope provider must also take the types of objects for that specific reference into consideration when building the scope. The type information is retrieved from EReference that is passed to getScope.

Both scopes and the index deal with object descriptions. The crucial difference is that a scope also specifies the actual string with which you can refer to an object. The actual string does not have to be equal to the object description's qualified name in the index. Thus, the same object can be referred to with different strings in different program contexts.

To put it in another way, the index provides all the qualified names of the visible objects of a resource so that all these objects can be referred to using their qualified names. The scope provides further information, that is, in a given program context, some objects can be referred to even using simple names or using qualified names with less segments.

If in our DSL we can only use IDs to refer to objects, and objects are visible only by their fully qualified names, then we will not be able to refer to any object. Thus, being able to refer to an object by a simple name is essential.

Another important aspect of scopes is that they can be nested. Usually, a scope is part of a chain of scopes so that a scope can have an outer scope, also known as the parent scope. If a matching string cannot be found in a scope, it is recursively searched in the outer scope. If a matching string is found in a scope, the outer scope is not consulted. This strategy allows Xtext to implement typical situations in programming languages. For example, in a code block, you can refer to variables declared in the containing block. Similarly, declarations in a block usually shadow possible declarations with the same name in the outer context.

The default implementation of IScopeProvider reflects the nested nature of scopes using the containment relations of the EMF model. For a given program context, it builds a scope where all objects in the container of the context are visible by their simple name. The outer scope is obtained by recursively applying the same strategy on the container of the context.

Let's go back to our previous SmallJava example:

class C {
  A f;
  A m(A p) {
    A v = null;
    return null; // assume this is the context
  }
}

Let's assume that the context is the expression of the return statement; in that context, the SJMember elements will be visible by simple names and by qualified names:

f, m, C.f, C.m

In the same context, the SJSymbol elements will be visible by the following names:

p, v, m.p, m.v, C.m.p, C.m.v

This is because p and v are contained in m, which in turn is contained in C. Note that they are also visible by simpler qualified names, that is, qualified names with less segments, m.p and m.v, respectively.

In the SmallJava grammar, we can refer to members and symbols only by their simple name using an ID, not by a qualified name. Without a scope, we would not be able to refer to any of such elements, since they would be visible only by their qualified names.

A learning test that invokes the method getScope can help understand the default scope provider implementation (note how we specify EReference using SmallJavaPackage):

@RunWith(XtextRunner)
@InjectWith(SmallJavaInjectorProvider)
class SmallJavaScopeProviderTest {
  @Inject extension ParseHelper<SJProgram>
  @Inject extension SmallJavaModelUtil

  @Inject extension IScopeProvider
  @Test def void testScopeProvider() {
    '''
    class C {
      A f;
      A m(A p) {
        A v = null;
        return null; // return's expression is the context
      }
    }
    class A {}
    '''.parse.classes.head.
        methods.last.returnStatement.expression => [
          // THIS WILL FAIL when we customize scoping in the next sections
          assertScope
          (SmallJavaPackage.eINSTANCE.SJMemberSelection_Member,
            "f, m, C.f, C.m")
          assertScope
          (SmallJavaPackage.eINSTANCE.SJSymbolRef_Symbol,
            "p, v, m.p, m.v, C.m.p, C.m.v")
        ]
    }

  def private assertScope(EObject context,
                 EReference reference, CharSequence expected) {

    expected.toString.assertEquals(
      context.getScope(reference).
        allElements.map[name].join(", "))
  }
}

In the next diagram, we show the interaction between the linker and the scope provider for the resolution of the member in a member selection expression:

The linker and the scope provider

Summarizing, the default implementation of the scope provider fits most DSLs; in fact, for the Entities DSL, the cross-reference resolution worked out of the box.

Note

The scope provider is also used by the content assist to provide a list of all visible elements. Since the scope concerns a specific program context, the proposals provided by the content assist are actually sensible for that specific context.

Component interaction

Before getting into scope customization, we conclude this section by summarizing, in a simplified way, how all of the preceding mechanisms are executed internally by Xtext's builder:

  • Parsing: Xtext parses the program files and builds the corresponding EMF models; during this stage, cross-references are not resolved
  • Indexing: All the contents of the EMF models are processed; if an element can be given a qualified name, a corresponding object description is put in the index
  • Linking: The linking service performs cross-reference resolution using the scope provider

This workflow is fixed. Consequently, we cannot rely on resolved cross-references during indexing. We need to keep that into consideration if we modify the indexing process, as we will see later in the section, What to put in the index?

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.144.172.115