In 5 Minuten zur DSL mit transitiven Importen in Xtext

Xtext ermöglicht das Referenzieren von Elementen in DSLs auf mehrere Arten. Eine Möglichkeit sieht den Import von Elementen über Namensräume vor. Dies geschieht über die Verwendung des ImportedNamespaceAwareLocalScopeProvider und erlaubt den "Import" einzelner oder, unter Einsatz von Wildcards (.*), aller Elemente eines Namensraumes.

Es kann aber Sprachen geben, in denen dieses Verhalten nicht gewünscht ist. In diesen Sprachen importiert der Nutzer explizit eine oder mehrere Ressource-Dateien, um auf deren Inhalte zugreifen zu können.

Eine einfache DSL mit Import-Verhalten dank Xtext

Eine DSL mit einem solchen Import-Verhalten lässt sich mit https://www.itemis.com/en/xtext/ recht einfach erstellen, indem man eine Parser-Regel mit dem speziellen Attributnamen XtextimportURI in die DSL einbaut. Das folgende Beispiel stellt eine einfache DSL dar, die es erlaubt, in beliebigen Ressourcen Namen zu definieren und diese in Grußbotschaften zu verwenden.

grammar org.xtext.example.mydsl.MyDsl with org.eclipse.xtext.common.Terminals
generate myDsl "http://www.xtext.org/example/mydsl/MyDsl"
Model:
	includes+=Include*
	names+=Name*
	greetings+=Greeting*;
Include:
	'import' importURI=STRING
	;
Name:
	'def' name=ID
	;
Greeting:
	'Hallo' name=[Name] '!'
	;

Wir möchten Kollegen aus unserer Firma Grußbotschaften schicken. Da die Firma aber groß ist und aus vielen Kollegen besteht, die in unterschiedlichen Bereichen arbeiten, möchten wir für jeden Firmenbereich eine eigene Datei erstellen, die die Namen der jeweiligen Kollegen enthält. Dies erhöht die Übersicht und Wartbarkeit.

Nur durch einen expliziten Import einer Ressource wollen wir die enthaltenen Namensdefinitionen in den Scope aufnehmen. Dabei soll dies möglichst schnell und ressourcenschonend erfolgen.

Der Ansatz ist hierbei, die Verwendung des Index, die das unnötige und (bei großen Modellen) zeitaufwendige Laden von Ressourcen überflüssig macht. Als ersten Schritt müssen wir die Informationen bzgl. der importierten Ressourcen in den Index schreiben. Dazu implementieren wir eine Klasse MyDslResourceDescriptionStrategy, die von DefaultResourceDescriptionStrategy ableitet. Die Strings mit den URIs, der in der Parser-Regel Model importierten Ressourcen, werden in einen durch Kommas getrennten String zusammengeführt und unter dem Schlüssel includes in der userData Map der Objektbeschreibung im Index gespeichert.

package org.xtext.example.mydsl

import com.google.inject.Inject
import java.util.HashMap
import org.eclipse.xtext.naming.QualifiedName
import org.eclipse.xtext.resource.EObjectDescription
import org.eclipse.xtext.resource.IEObjectDescription
import org.eclipse.xtext.resource.impl.DefaultResourceDescriptionStrategy
import org.eclipse.xtext.scoping.impl.ImportUriResolver
import org.eclipse.xtext.util.IAcceptor
import org.xtext.example.mydsl.myDsl.Model
import org.eclipse.emf.ecore.EObject

class MyDslResourceDescriptionStrategy extends DefaultResourceDescriptionStrategy {
	public static final String INCLUDES = "includes"
	@Inject
	ImportUriResolver uriResolver

	override createEObjectDescriptions(EObject eObject, IAcceptor acceptor) {
		if(eObject instanceof Model) {
			this.createEObjectDescriptionForModel(eObject, acceptor)
			return true
		}
		else {
			super.createEObjectDescriptions(eObject, acceptor)
		}
	}

	def void createEObjectDescriptionForModel(Model model, IAcceptor acceptor) {
		val uris = newArrayList()
		model.includes.forEach[uris.add(uriResolver.apply(it))]
		val userData = new HashMap<string,string>
		userData.put(INCLUDES, uris.join(","))
		acceptor.accept(EObjectDescription.create(QualifiedName.create(model.eResource.URI.toString), model, userData))
	}
}

Um unsere ResourceDescriptionStrategy nutzen zu können, müssen wir sie noch im MyDslRuntimeModule binden.

 

package org.xtext.example.mydsl

import org.eclipse.xtext.resource.IDefaultResourceDescriptionStrategy
import org.eclipse.xtext.scoping.IGlobalScopeProvider
import org.xtext.example.mydsl.scoping.MyDslGlobalScopeProvider

class MyDslRuntimeModule extends AbstractMyDslRuntimeModule {
def Class<? extends IDefaultResourceDescriptionStrategy> bindIDefaultResourceDescriptionStrategy() {
MyDslResourceDescriptionStrategy
}
}


Bisher haben wir nur Informationen gesammelt und im Index gespeichert. Um sie verwenden zu können, benötigen wir zusätzlich einen eigenen
IGlobalScopeProvider. Dazu implementieren wir eine Klasse MyDslGlobalScopeProvider, die von ImportUriGlobalScopeProvider ableitet, und überschreiben die Methode getImportedUris(Resource resource). Diese Methode liefert ein LinkedHashSet zurück, das letztendlich alle URIs enthält, die in der Ressource importiert werden sollen.

Das Auslesen der importierten Ressourcen aus dem Index wird von der Methode collectImportUris erledigt. Die Methode fragt den IResourceDescription.Manager nach der IResourceDescription der Ressource. Aus dieser wird für jedes Model-Element aus der userData Map der unter dem Schlüssel includes gespeicherte String mit den URIs der importierten Ressourcen ausgelesen, zerlegt und die einzelnen URIs in einem Set gespeichert.


package org.xtext.example.mydsl.scoping

import com.google.common.base.Splitter
import com.google.inject.Inject
import com.google.inject.Provider
import java.util.LinkedHashSet
import org.eclipse.emf.common.util.URI
import org.eclipse.emf.ecore.resource.Resource
import org.eclipse.xtext.EcoreUtil2
import org.eclipse.xtext.resource.IResourceDescription
import org.eclipse.xtext.scoping.impl.ImportUriGlobalScopeProvider
import org.eclipse.xtext.util.IResourceScopeCache
import org.xtext.example.mydsl.MyDslResourceDescriptionStrategy
import org.xtext.example.mydsl.myDsl.MyDslPackage

class MyDslGlobalScopeProvider extends ImportUriGlobalScopeProvider {
	private static final Splitter SPLITTER = Splitter.on(',');

	@Inject
	IResourceDescription.Manager descriptionManager;

	@Inject
	IResourceScopeCache cache;

	override protected getImportedUris(Resource resource) {
		return cache.get(MyDslGlobalScopeProvider.getSimpleName(), resource, new Provider<linkedhashset>() {
			override get() {
				val uniqueImportURIs = collectImportUris(resource, new LinkedHashSet(5))

				val uriIter = uniqueImportURIs.iterator()
				while(uriIter.hasNext()) {
					if (!EcoreUtil2.isValidUri(resource, uriIter.next()))
						uriIter.remove()
				}
				return uniqueImportURIs
			}

			def LinkedHashSet collectImportUris(Resource resource, LinkedHashSet uniqueImportURIs) {
				val resourceDescription = descriptionManager.getResourceDescription(resource)
				val models = resourceDescription.getExportedObjectsByType(MyDslPackage.Literals.MODEL)
				
				models.forEach[
					val userData = getUserData(MyDslResourceDescriptionStrategy.INCLUDES)
					if(userData !== null) {
						SPLITTER.split(userData).forEach[uri |
							var includedUri = URI.createURI(uri)
							includedUri = includedUri.resolve(resource.URI)
							uniqueImportURIs.add(includedUri)
						]
					}
				]
				return uniqueImportURIs
			}
		});
	}
}


Um unseren
MyDslGlobalScopeProvider nutzen zu können, müssen wir diesen wiederum im MyDslRuntimeModule binden.

package org.xtext.example.mydsl

import org.eclipse.xtext.resource.IDefaultResourceDescriptionStrategy
import org.eclipse.xtext.scoping.IGlobalScopeProvider
import org.xtext.example.mydsl.scoping.MyDslGlobalScopeProvider

class MyDslRuntimeModule extends AbstractMyDslRuntimeModule {
	def Class bindIDefaultResourceDescriptionStrategy() {
		MyDslResourceDescriptionStrategy
	}
	override Class bindIGlobalScopeProvider() {
		MyDslGlobalScopeProvider;
	}
}


Wir starten den Editor für unsere kleine Sprache und beginnen die Modell-Dateien zu erstellen. Dabei haben wir die Idee, die Ressourcen der unterschiedlichen Firmenbereiche nicht einzeln zu importieren, sondern eine Ressource zu erstellen, die alle Importe enthält, und diese dann zu importieren. Dazu erstellen wir folgende Ressourcen:

Ressourcen-Agile.png

 Ressourcen-Xtext.png

Ressource-Kollegen.png


Beim Erstellen der Ressource mit den Grußbotschaften stellen wir fest, dass die Namen nicht aufgelößt werden können.

Ressource-Greetings.png


Woran liegt das? Wir haben doch alle importierten Ressourcen in den Index geschrieben.

Das ist soweit richtig. Alle direkt importieren Ressourcen werden in den Index geschrieben. Die Importe in einer importierten Ressource jedoch werden ignoriert. Das von uns gewünschte Feature bezeichnet man als transitive Importe. Mit dem Import einer Ressource werden implizit alle von ihr importierten Ressourcen mit importiert.

Um in unserer Sprache transitive Importe zu ermöglichen, müssen wir unseren MyDslGlobalScopeProvider anpassen. Statt die URI einer importierten Ressource nur in dem Set zu speichern, rufen wir zusätzlich die Methode collectImportUris auf und übergeben die URI als Parameter, sodass deren importierte Ressourcen ebenfalls verarbeitet werden.

package org.xtext.example.mydsl.scoping

import com.google.common.base.Splitter
import com.google.inject.Inject
import com.google.inject.Provider
import java.util.LinkedHashSet
import org.eclipse.emf.common.util.URI
import org.eclipse.emf.ecore.resource.Resource
import org.eclipse.xtext.EcoreUtil2
import org.eclipse.xtext.resource.IResourceDescription
import org.eclipse.xtext.scoping.impl.ImportUriGlobalScopeProvider
import org.eclipse.xtext.util.IResourceScopeCache
import org.xtext.example.mydsl.MyDslResourceDescriptionStrategy
import org.xtext.example.mydsl.myDsl.MyDslPackage

class MyDslGlobalScopeProvider extends ImportUriGlobalScopeProvider {
	private static final Splitter SPLITTER = Splitter.on(',');

	@Inject
	IResourceDescription.Manager descriptionManager;

	@Inject
	IResourceScopeCache cache;

	override protected getImportedUris(Resource resource) {
		return cache.get(MyDslGlobalScopeProvider.getSimpleName(), resource, new Provider<linkedhashset>() {
			override get() {
				val uniqueImportURIs = collectImportUris(resource, new LinkedHashSet(5))

				val uriIter = uniqueImportURIs.iterator()
				while(uriIter.hasNext()) {
					if (!EcoreUtil2.isValidUri(resource, uriIter.next()))
						uriIter.remove()
				}
				return uniqueImportURIs
			}

			def LinkedHashSet collectImportUris(Resource resource, LinkedHashSet uniqueImportURIs) {
				val resourceDescription = descriptionManager.getResourceDescription(resource)
				val models = resourceDescription.getExportedObjectsByType(MyDslPackage.Literals.MODEL)
				
				models.forEach[
					val userData = getUserData(MyDslResourceDescriptionStrategy.INCLUDES)
					if(userData !== null) {
						SPLITTER.split(userData).forEach[uri |
							var includedUri = URI.createURI(uri)
							includedUri = includedUri.resolve(resource.URI)
							if(uniqueImportURIs.add(includedUri)) {
								collectImportUris(resource.getResourceSet().getResource(includedUri, true), uniqueImportURIs)
							}
						]
					}
				]
				
				return uniqueImportURIs
			}
		});
	}
}


Wenn wir nach dieser kleinen Anpassung unsere Ressource mit den Grußbotschaften erneut öffnen sehen wir, dass die Namen durch die transitiven Importe aufgelöst werden können.

Das Beispielprojekt kann hier heruntergeladen werden.

Über Christian Wehrheim

Christian arbeitet als Entwickler für die itemis AG in Frankfurt. Neben seiner Projekttätigkeit im Java Enterprise-Umfeld ist er immer auf der Suche nach neuen spannenden Themen aus der Welt der Software-Entwicklung, die seinen Horizont erweitern.