giovanni.ciatto@unibo.itCompiled on: 2026-10-15 — printable version
Main references: Völter et al., DSL Engineering, 2013, Fowler, Domain-Specific Languages, 2010
(Abstract) Language $\approx$ (abstract) syntax + semantics
Model $\approx$ an abstract description of the possible entities involved in a domain, and their relations
In which language is the model expressed?
In which language is the meta-model expressed?
OMG’s four-layer architecture (cf. MOF specification):
Each layer is described by the language of the layer above
Between adjacent layers there are two different relations, which are easy to confuse:
Customer (M1) is an instance of the meta-class Class (M2)john (M0) is an instance of the class Customer (M1)Checking conformance is a mechanical activity
The top layer is reflexive: MOF conforms to MOF
EPackage: a namespace of meta-classes, identified by an URIEClass: a concept of the domain, possibly with super-typesEAttribute: a property whose value is a datum (string, int, enum)EReference: a property whose value is another model element
lowerBound / upperBound: multiplicities (0..1, 1, *)EEnum: a finite set of literalsA meta-model defines the abstract syntax of a language
The concrete syntax is the notation users actually read / write
Meta-model (abstract syntax):
YAML:
title: Domain-Specific Languages
year: 2010
authors:
- name: Martin Fowler
- name: Rebecca Parsons
XML:
<book title="Domain-Specific Languages" year="2010">
<author name="Martin Fowler"/>
<author name="Rebecca Parsons"/>
</book>
Structural meta-models only capture types and multiplicities
Many rules of a domain are not structural, e.g.:
These are called static semantics (or well-formedness rules)
context Book
inv uniqueAuthorNames: self.authors->isUnique(a | a.name)
inv nonEmptyTitle: self.title.size() > 0
Takeaway: meta-model + well-formedness rules = what it means for a model to be correct
Meta-models are the very first thing you should try to identify whenever approaching a new technology
If you grasp the meta-model, you grasp the essence of the technology
E.g. after you learned the basics of OOP (classes, methods, objects, etc.) you may easily learn any other OOP language
When you model a domain (e.g. with DDD) you are always exploiting some meta-model
Several slightly similar names may create confusion
Please read Martin Fowler’s article on Model Driven Architecture to clarify
Despite the name, the key ideas can be summarised as follows:
software engineering workflow should start by modelling the domain at hand carefully
the production of a runnable implementation should be automated as much as possible
Assumption: the model is expressed by means of some formal language
Formal language $\approx$ interpretable by a machine
Formality is a prerequisite for automation
Is UML adequate? Is it the only choice? Any alternative?
Domain-specific Languages (DSL) are programming / description / specification languages targeting one particular class of problems
As opposed to general-purpose languages (GPL) which are targetting as many classes of problems as possible
DSL may act as custom meta-models for a given domain
Examples of DSLs you may already know:
digraph G {
size ="4,4";
main [shape=box];
main -> parse [weight=8];
parse -> execute;
main -> init [style=dotted];
main -> cleanup;
execute -> { make_string; printf}
init -> make_string;
edge [color=red];
main -> printf [style=bold,label="100 times"];
make_string [label="make a string"];
node [shape=box,style=filled,color=".7 .3 1.0"];
execute -> compare;
}
DOT is a language that can describe graphs, for the sake of their visualisation
The most common implementation is the Graphviz toolkit
interface Customer { + CustomerID getID() + String getName() + void **setName**(name: String) + String getEmail() + void **setEmail**(email: String) }note left: Entity
interface CustomerID { + Object getValue() } note right: Value Object
interface TaxCode { + String getValue() } note left: Value Object
interface VatNumber { + long getValue() } note right: Value Object
VatNumber -d-|> CustomerID TaxCode -d-|> CustomerID
Customer *-r- CustomerID
interface Customer {
+ CustomerID getID()
+ String getName()
+ void **setName**(name: String)
+ String getEmail()
+ void **setEmail**(email: String)
}
note left: Entity
interface CustomerID {
+ Object getValue()
}
note right: Value Object
interface TaxCode {
+ String getValue()
}
note left: Value Object
interface VatNumber {
+ long getValue()
}
note right: Value Object
VatNumber -d-|> CustomerID
TaxCode -d-|> CustomerID
Customer *-r- CustomerID
Scenario: Verify withdraw at the ATM works correctly
Given John has 500$ on his account
When John asks to withdraw 200$
And John inserts the correct PIN
Then 200$ are dispensed by the ATM
And John has 300$ on his account
Gherkin is a language that can describe behavioural tests for software systems
Syntax is very flexible and it seems like natural language
Stakeholders and engineers will agree on a set of behavioural specifications for the system
The most common implementation is Cucumber
DFF : process(RST, CLK) is
begin
if RST = '1' then
Q <= '0';
elsif rising_edge(CLK) then
Q <= D;
end if;
end process DFF;
VHDL is a language that can describe hardware circuits
Seems like an ordinary programming language, but:
Technologies exist to automatically translate VHDL into hardware circuits
… or to simulate the behaviour of the circuit (either in software or in FPGA)
Details here: https://tomassetti.me/financial-accounting-dsl
Goal: use a DSL to describe taxes, pension contributions, and general financial calculations
pension contribution InpsTerziario paid by owner {
considered_salary = (taxable of IRES for employer - amount of IRES for employer - amount of IRAP for employer) by ownership share
rate = brackets [to 46,123] -> 22.74%,
[to 76,872] -> 23.74%,
[above] -> 0%
amount = (rate for considered_salary) with minimum 3,535.61
}
pension contribution InpsGLA paid by employer 2/3 and employee 1/3 {
considered_salary = gross_compensation of employee
rate = brackets [to 100,323] -> 27.72%,
[above] -> 0%
amount = rate for considered_salary
}
To communicate with domain experts in their own language
To let domain experts write the specifications (i.e. the model) of the system they want
To focus on the domain rather than on the implementation
To hide the implementation details from the domain experts
DSLs are not a replacement for GPLs
Yet the difference is fuzzy, so let’s try to clarify (cf. Völter et al., DSL Engineering, 2013):
| GPL | DSL | |
|---|---|---|
| Domain | any | clear boundary |
| Syntactical constructs | many and composable | few and static |
| Expressiveness | Turing-complete | possibly, less than Turing-complete |
| Customisability | maximised | minimised / confined / absent |
| Defined by | companies or committees | teams of domain experts |
| User base | large, anonymous, widespread | small, accessible, local |
| Evolution | slow, well-structured | fast-paced |
| Deprecation | very slow | feasible, often abrupt |
Most often, the focus is on the syntax of the DSL
Yet, the semantics of the DSL is equally important
Intuitively, semantics is given to languages by writing the machinery supporting their execution
The role of DSL engineers mostly focuses on steps 1 & 2 (besides defining the syntax)
Two main approaches:
Translation: translates a DSL script into a language for which an execution engine on a given target platform exists
Interpretation: the execution engine is able to parse and execute the DSL script directly
In both cases, there are technical prerequisites:
So far we discussed the so-called external DSLs
As opposed to internal (a.k.a. embedded) DSLs
Internal DSLs are an old idea (e.g. in Lisp, Smalltalk, Ruby), now widespread thanks to flexible mainstream GPLs
Examples of internal DSL you may already know:
More on this topic in the lecture on internal DSLs in Kotlin
build.gradle.ktsplugins {
`java-library`
}
dependencies {
implementation("com.google.guava:guava:33.7.2-jre")
testImplementation("org.junit.jupiter:junit-jupiter:6.1.3")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
configurations {
implementation {
resolutionStrategy.failOnVersionConflict()
}
}
sourceSets {
main {
java.srcDir("src/core/java")
}
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
tasks {
test {
testLogging.showExceptions = true
useJUnitPlatform()
}
}
Internal DSL may ease adoption of the DSL itself
Internal DSLs simplify the DSL engineering process
The integration between the GPL and the DSL is tight
Be it internal/external or transpiled/interpreted, the DSL needs an execution engine
This is no different from any other library supporting some given domain
Except that hacks can be exploited to ease the adoption of the target DSL syntax
Large Language Models (LLMs) generate code from natural language prompts
Because the two kinds of “code generators” have different guarantees:
| DSL + code generator | LLM prompted in natural language | |
|---|---|---|
| Input | formal model, conforming to a meta-model | informal text, possibly ambiguous |
| Same input, same output? | yes (deterministic program) | not guaranteed (sampling) |
| Input checked before generation? | yes (parser + validator) | no |
| Correctness argument | test the generator once, trust it for all models | review / test each output |
| When the spec changes | edit the model, re-generate | re-prompt, re-review the new code |
| Cost of building | high (language engineering) | low (prompt engineering) |
Non-determinism is measurable: same prompt, different programs, even at temperature 0
Martin Fowler (2025) frames LLMs as moving programming up the abstraction levels and, at the same time, sideways into non-determinism
The two approaches can be combined: let the LLM write the model, not the code
Benefits:
Caveat: LLMs know little about your DSL, as it is absent from their training data
Many mechanisms of LLM-based systems are meta-modelling in disguise:
Same concepts as in this lecture:
Takeaway: the ability to design a precise vocabulary for a domain did not lose value
Likely worth it when:
Likely not worth it when:
LLMs also lower the cost of building DSL tooling (grammars, validators, generators are well-known patterns)
| Tool | Approach | Platform | Docs |
|---|---|---|---|
| Eclipse Xtext | parser-based: textual grammar $\rightarrow$ parser, meta-model (EMF), IDE support | JVM (Java / Xtend) | eclipse.dev/Xtext |
| JetBrains MPS | projectional: users edit the syntax tree directly, no parser | JVM, own IDE | jetbrains.com/mps |
| Eclipse Langium | parser-based, inspired by Xtext, LSP-first | TypeScript / Node.js | langium.org |
| ANTLR | parser generation only (no meta-model, no IDE support) | Java, JS, Python, C#, C++, Go, … | antlr.org |
Language Server Protocol (LSP): relevant for any of the above, see next slide
Maturity note: Xtext is mature and widely used, yet its own release notes for 2.44 (Aug. 2026) state that its future maintenance is at risk due to declining contributions (cf. discussion)
de-facto standard protocol among IDEs
providing various IDE-like capabilities as-a-service
making it easier to support multiple IDEs for the same language
must-have feature for any MDD tool we may consider for our DSL
A framework for MDD and, in particular, external DSL
Xtext provides a language for defining languages…
… which is also a meta-modelling language
Meta-modelling and DSL definition are done simultaneously
It automatically generates the full language infrastructure, including
Exercises and examples about MDD will be based on Xtext
Users may want to schedule custom tasks on a machine
Task $\equiv$ running any command available on the OS
bash, cmd, powershell, etc.) of choiceScheduling implies defining when the task should be executed
Notice that tasks may be inter-dependent (e.g. because of before/after relations)
Sheduler DSL (pt. 1)Sheduler $\equiv$ Shell + Scheduler
¯\(ツ)/¯
We shall use Xtext’s meta-modelling language to define the domain of task scheduling
Simultaneously, we will define the syntax of the DSL
We will then add scoping and validation rules to the DSL, via the Xtext framework
The next step is designing and implementing the execution engine for the DSL
ScheduledExecutorServices for this purposeFinally, we will create a code generator creating Java code from the DSL
Sheduler DSL (pt. 2)Code: https://github.com/unibo-spe/sheduler-lang
Clone with Git the exercises branch
The cloned repository is an Eclipse project
In Eclipse, import the repository root directory as a Gradle project
You may also use IntelliJ; in that case just import the repository root directory as a Gradle project
The exercises branch contains placeholders for each exercise:
TODO Ex N.M comments break exercise N into small steps M (most IDEs list them in a TODO view)UnsupportedOperationException("TODO Ex N.M: ...")Each exercise has tests in it.unibo.spe.mdd.sheduler/src/test/java, marked as @Disabled("TODO Ex N.M ...")
./gradlew buildThe execution engine (runtime/ShedulerRuntime.java, runtime/ShedulerTask.java) is given, except for Exercise 5
src/main/resources/.../generator/*.java.template, for the code generatorRuntimeTemplatesSyncTest checks that the copies are identical: if you edit the classes, copy them overSolutions:
master branch of the repositoryThree Gradle sub-projects:
it.unibo.spe.mdd.sheduler: the language (grammar, validation, scoping, generator) $\leftarrow$ you work hereit.unibo.spe.mdd.sheduler.ide: LSP server, fully generatedit.unibo.spe.mdd.sheduler.web: web playground, fully generatedTwo files drive everything:
Sheduler.xtext: the grammar, which is also the meta-modelGenerateSheduler.mwe2: configuration of what Xtext should generate from the grammarGradle tasks you will use:
generateXtextLanguage: re-generates the infrastructure after editing the grammar (runs automatically before compilation)jettyRun: starts the web playground on http://localhost:8080shadowJar: packs the command-line compiler and the LSP server as runnable Jarsrun / runInterpreter (with --args=/abs/path/file.shed): generate code from / interpret a .shed fileDetails in the vertical slides below (press down), you only need them while setting up
sheduler-lang/
├── build.gradle
├── gradle/
├── gradle.properties
├── gradlew
├── gradlew.bat
├── it.unibo.spe.mdd.sheduler/
│ ├── build.gradle
│ └── src/
│ ├── main/
│ │ └── java/
│ │ └── it/unibo/spe/mdd/sheduler/
│ │ ├── GenerateSheduler.mwe2
│ │ ├── Sheduler.xtext
│ │ ├── TimeUtils.java
│ │ ├── generator/Main.java
│ │ ├── generator/ShedulerGenerator.java
│ │ ├── interpreter/ShedulerInterpreter.java
│ │ ├── runtime/ShedulerRuntime.java
│ │ ├── runtime/ShedulerTask.java
│ │ ├── scoping/ShedulerScopeProvider.java
│ │ └── validation/ShedulerValidator.java
│ ├── main/resources/ # templates for the code generator
│ └── test/
├── it.unibo.spe.mdd.sheduler.ide/
│ └── build.gradle
├── it.unibo.spe.mdd.sheduler.web/
│ └── build.gradle
└── settings.gradle
sheduler project is where the domain is modelled, and the language is defined
Sheduler.xtext: this is where modelling and language definition occursGenerateSheduler.mwe2: this is where the automated generation of scoping, validation, generation, testing facilities is configuredide project is where the generic IDE support via LSP is defined
sheduler projectweb project is where the web-based playground for our language is defined
ide projectgenerateXtextLanguage generates the language infrastructure
shadowJar generates runnable Jars
ide sub-project: the LSP server (*-ls.jar)sheduler sub-project: the command-line compiler (*-compiler.jar)jettyRun starts the Web-playground for the Sheduler language
run --args=/abs/path/file.shed runs the code generator on a file, and runInterpreter --args=... interprets it
ordinary Gradle tasks for compilation, testing, etc. are as usual
GenerateSheduler.mwe2 fileThis is automatically generated by Eclipse when setting up an Xtext project
Pretty self-explanatory:
module it.unibo.spe.mdd.sheduler.GenerateSheduler
import org.eclipse.xtext.xtext.generator.*
import org.eclipse.xtext.xtext.generator.model.project.*
var rootPath = ".."
Workflow {
component = XtextGenerator {
configuration = {
project = StandardProjectConfig {
baseName = "it.unibo.spe.mdd.sheduler"
rootPath = rootPath
runtimeTest = { enabled = true }
web = { enabled = true }
mavenLayout = true
}
code = {
encoding = "UTF-8"
lineDelimiter = "\r\n"
fileHeader = "/*\n * generated by Xtext \${version}\n */"
preferXtendStubs = false
}
}
language = StandardLanguage {
name = "it.unibo.spe.mdd.sheduler.Sheduler"
fileExtensions = "shed"
serializer = { generateStub = false }
validator = { generateDeprecationValidation = true }
generator = {
generateXtendStub = false
generateJavaMain = true
}
junitSupport = {
junitVersion = "5"
generateXtendStub = false
generateStub = true
}
}
}
}
GenerateSheduler.mwe2 file: what to noticeMWE2 (Modeling Workflow Engine 2) is yet another DSL, describing a workflow of generators
Workflow, XtextGenerator, StandardLanguage are Java classes, configured declarativelyStandardLanguage enables the default components: parser, validator, scoping, generator, serializer, formatter, …
fileExtensions = "shed" is what associates .shed files with our language
web = { enabled = true } is what produces the web sub-project (and the playground)
generateXtendStub = false / preferXtendStubs = false: stubs are generated in Java rather than in Xtend
generateJavaMain = true produces the Main class of the command-line compiler (see Exercise 4)
grammar it.unibo.spe.mdd.sheduler.Sheduler with org.eclipse.xtext.common.Terminals
generate sheduler "http://www.unibo.it/spe/mdd/sheduler/Sheduler"
TaskPoolSet: pools+=TaskPool+ ;
TaskPool: 'pool' name=ID? '{' tasks+=Task+ '}' ;
Task:
'schedule' ('task' name=ID)? '{'
'command' command=STRING
('entry' 'point' entrypoint=STRING)?
(
'in' relative=RelativeTime |
'at' absolute=AbsoluteTime |
'before' before=[Task] | // square brackets denote references to other model elements
'after' after=[Task]
)
('repeat' 'every' period=RelativeTime)?
'}'
;
AbsoluteTime: date=Date time=ClockTime ;
Date: year=INT '/' month=INT '/' day=INT;
ClockTime: hour=INT ':' minute=INT (':' second=INT (':' millisecond=INT (':' nanosecond=INT)?)?)? ;
RelativeTime: timeSpans += TimeSpan (('and' | ',' | '+') timeSpans += TimeSpan)* ;
TimeSpan: duration=INT unit=(TimeUnit|LongTimeUnit);
enum TimeUnit: NANOSECONDS = 'ns' | MILLISECONDS = 'ms' | SECONDS = 's' | MINUTES = 'm' | HOURS = 'h' | DAYS = 'd' | WEEKS = 'w' | YEARS = 'y' ;
enum LongTimeUnit returns TimeUnit:
NANOSECONDS = 'nanoseconds' |
MILLISECONDS = 'milliseconds' |
SECONDS = 'seconds' |
MINUTES = 'minutes' |
HOURS = 'hours' |
DAYS = 'days' |
WEEKS = 'weeks' |
YEARS = 'years' ;
Xtext infers an Ecore meta-model from the grammar, following a few rules:
| Grammar construct | Example from Sheduler.xtext |
Inferred meta-model element |
|---|---|---|
generate |
generate sheduler "http://..." |
an EPackage with that URI |
| parser rule | Task: ... ; |
an EClass named Task |
feature= + terminal |
name=ID, command=STRING |
an EAttribute (EString, EInt, …) |
feature= + rule |
relative=RelativeTime |
a containment EReference |
feature=[Type] |
after=[Task] |
a cross-EReference (resolved by linking, see scoping) |
+= |
tasks+=Task+ |
a many-valued feature (upperBound = *) |
? on a feature |
('task' name=ID)? |
an optional feature (lowerBound = 0) |
enum rule |
enum TimeUnit: ... |
an EEnum |
| keywords | 'schedule', '{', 'in' |
nothing: they belong to the concrete syntax only |
'in' ... | 'at' ...) become optional features: “exactly one is set” is guaranteed by the parser, not by the meta-model.shed file is a tree of objects…before / after), turning it into a graphsrc-gen/):
EClass (with getters / setters), plus its implementation classShedulerFactory to create instancesShedulerPackage, describing the meta-model reflectively
ShedulerPackage.Literals.TASK__NAME is the EAttribute object for Task.name, used in validation| Layer | What it is, for Sheduler | Where it lives |
|---|---|---|
| M3 | Ecore: EClass, EAttribute, EReference, … |
EMF library |
| M2 | the Sheduler meta-model: Task, TaskPool, TimeSpan, … |
inferred from Sheduler.xtext |
| M1 | a Sheduler model, e.g. the task greetOnce in myPool |
a .shed file |
| M0 | the actual execution: a ShedulerTask object, an OS process running echo hello |
the JVM / OS, at run time |
Each row conforms to the row above it:
Sheduler.xtext is written by you (DSL engineer).shed files are written by users (domain experts)The same M3 is shared by all EMF-based languages
Run task jettyRun to start the web playground
Copy-paste the following example program:
pool {
schedule task greetWorldFrequently {
command "echo hello"
entry point "/bin/sh -c"
in 5 minutes
repeat every 1 hours
}
}
What you should see (press Ctrl+Space to see the auto-completion menu)

Validation rules are defined in the ShedulerValidator class
it.unibo.spe.mdd.sheduler.validationsheduler-lang/it.unibo.spe.mdd.shedulerStub class is generated by Xtext when running the generateXtextLanguage task
GenerateSheduler.mwe2 fileContent of the stub and validation rule example:
package it.unibo.spe.mdd.sheduler.validation;
import it.unibo.spe.mdd.sheduler.sheduler.*;
import org.eclipse.xtext.validation.Check;
import org.eclipse.xtext.validation.CheckType;
public class ShedulerValidator extends AbstractShedulerValidator {
@Check(CheckType.FAST)
public void ensureDateIsValid(Date date) {
if (date.getYear() < 0) {
error("Year must be positive", date, ShedulerPackage.Literals.DATE__YEAR, 0);
}
if (date.getMonth() < 1 || date.getMonth() > 12) {
error("Month must be between 1 and 12", date, ShedulerPackage.Literals.DATE__MONTH, 0);
}
if (date.getDay() < 1 || date.getDay() > 31) {
error("Day must be between 1 and 31", date, ShedulerPackage.Literals.DATE__DAY, 0);
}
}
}
@Check annotation
@Check method on every object of the matching typeensureDateIsValid(Date) is called once per Date in the fileCheckType argument of @Check is optional, and defaults to CheckType.NORMAL
FAST checks run whenever a file is modifiedNORMAL checks run when saving the fileEXPENSIVE checks run when explicitly validating the file via the menu optionerror(message, object, feature, ...) reports a problem on a specific feature of a specific object
ShedulerPackage.Literals.DATE__MONTH is the meta-model element for Date.month (M2 used at run time!)error(...) may be replaced by warning(...) (or info(...)) for minor issues
Write custom validation rules covering the following constraints:
Warning if attempting to represent some RelativeTime as java.time.Duration object would result in an overflow
TimeUtilsWarning if attempting to represent some AbsoluteTime as java.time.LocalDateTime object would result in an overflow
TimeUtilsWarning if some AbsoluteTime is in the past or in the present (only future admitted)
TimeUtilsLocalDateTime.now()LocalDateTime.isBefore()Error if some ClockTime is invalid
Error if some TimeSpan is invalid
Error if any two tasks from the same pool have the same name
Error if any two pools have the same name
No periodicity should be allowed for tasks that are scheduled before/after some other task
Solution in the vertical slides below (press down), or press right to skip it
@Check
public void checkRelativeTimeIsRepresentable(RelativeTime relativeTime) {
try {
TimeUtils.toDuration(relativeTime).toMillis(); // the runtime schedules in milliseconds
} catch (ArithmeticException e) {
warning("Relative time is not representable on the JVM", relativeTime, ShedulerPackage.Literals.RELATIVE_TIME__TIME_SPANS);
}
}
@Check
public void checkAbsoluteTimeIsRepresentable(AbsoluteTime absoluteTime) {
try {
Duration.between(LocalDateTime.now(), TimeUtils.toLocalDateTime(absoluteTime)).toMillis();
} catch (ArithmeticException | DateTimeException e) {
warning("Absolute time is not representable on the JVM", absoluteTime, ShedulerPackage.Literals.ABSOLUTE_TIME__DATE);
}
}
@Check
public void checkAbsoluteTimeIsInTheFuture(AbsoluteTime absoluteTime) {
LocalDateTime dateTime;
try {
dateTime = TimeUtils.toLocalDateTime(absoluteTime);
} catch (DateTimeException e) {
return; // already reported by checkAbsoluteTimeIsRepresentable
}
if (!dateTime.isAfter(LocalDateTime.now())) {
warning("Absolute time should be in the future", absoluteTime, ShedulerPackage.Literals.ABSOLUTE_TIME__TIME);
}
}
Rules 1–3 reuse TimeUtils, i.e. the same conversions used later by the generator and the interpreter
toMillis(), which overflows much earlier than Duration itself (in 2147483647 years): the check must do the sameCatch the right exception:
Duration / toMillis() overflows throw ArithmeticException2030/13/45) throw DateTimeException, which is not an ArithmeticException@Check method does not produce a nice marker: the check is simply brokenRule 3 returns early on that exception, on purpose: the same problem should be reported once
Rule 3 depends on LocalDateTime.now(): the same file may be valid today and invalid tomorrow
!isAfter(now) (rather than isBefore(now)) also covers the “present” case required by the rule
@Check(CheckType.FAST)
public void ensureClockTimeIsValid(ClockTime clockTime) {
if (clockTime.getHour() > 23) {
error("Hour must be between 0 and 23", clockTime, ShedulerPackage.Literals.CLOCK_TIME__HOUR);
}
if (clockTime.getMinute() > 59) {
error("Minute must be between 0 and 59", clockTime, ShedulerPackage.Literals.CLOCK_TIME__MINUTE);
}
if (clockTime.getSecond() > 59) {
error("Second must be between 0 and 59", clockTime, ShedulerPackage.Literals.CLOCK_TIME__SECOND);
}
if (clockTime.getMillisecond() > 999) {
error("Millisecond must be between 0 and 999", clockTime, ShedulerPackage.Literals.CLOCK_TIME__MILLISECOND);
}
if (clockTime.getNanosecond() > 999) {
error("Nanosecond must be between 0 and 999", clockTime, ShedulerPackage.Literals.CLOCK_TIME__NANOSECOND);
}
}
@Check(CheckType.FAST)
public void ensureTimeSpanIsValid(TimeSpan timeSpan) {
if (timeSpan.getDuration() <= 0) {
error("Duration must be strictly positive", timeSpan, ShedulerPackage.Literals.TIME_SPAN__DURATION);
}
}
INT terminal (from org.eclipse.xtext.common.Terminals) only matches digits: negative numbers cannot be parsed, so lower bounds of 0 need no check
in 0 minutes), hence <= 0repeat every 48 hours is perfectly fine, no need for hours < 24ClockTime (e.g. seconds in 12:13) are simply 0: EMF gives int features a default valuerepeat every period must be at least 1 ms, as scheduleAtFixedRate rejects a period of 0 ms (e.g. repeat every 500 ns)@Check(CheckType.FAST)
public void ensureTaskNamesAreUniqueWithinPool(TaskPool pool) {
Set<String> names = new HashSet<>();
for (Task task : pool.getTasks()) {
if (task.getName() != null && !names.add(task.getName())) {
error("Repeated task ID: " + task.getName(), task, ShedulerPackage.Literals.TASK__NAME);
}
}
}
@Check(CheckType.FAST)
public void ensurePoolNamesAreUniqueWithinPool(TaskPoolSet pools) {
Set<String> names = new HashSet<>();
for (TaskPool pool : pools.getPools()) {
if (pool.getName() != null && !names.add(pool.getName())) {
error("Repeated pool ID: " + pool.getName(), pool, ShedulerPackage.Literals.TASK_POOL__NAME);
}
}
}
@Check(CheckType.FAST)
public void ensureDependentTasksAreNotPeriodic(Task task) {
if ((task.getBefore() != null || task.getAfter() != null) && task.getPeriod() != null) {
error("Tasks scheduled before/after another task cannot be periodic", task, ShedulerPackage.Literals.TASK__PERIOD);
}
}
Rules 6–7 are about a collection of elements, so the check is attached to the container (TaskPool, TaskPoolSet)
Anonymous tasks and pools (name == null) are skipped: they cannot clash
Set.add returns false if the element was already there: no need for a separate contains
Xtext also provides a generic uniqueness check, NamesAreUniqueValidator, to be enabled in the .mwe2 file
Rule 8 could also have been enforced by the grammar (allowing repeat every only after in / at)
Why rule 8 at all? A task scheduled after another one inherits its timing from it (see Exercise 5)
Scoping rules are defined in the ShedulerScopeProvider class
it.unibo.spe.mdd.sheduler.scopingsheduler-lang/it.unibo.spe.mdd.shedulerStub class is generated by Xtext when running the generateXtextLanguage task
Content of the stub and scoping rule example:
public class ShedulerScopeProvider extends AbstractShedulerScopeProvider {
@Override
public IScope getScope(EObject context, EReference reference) {
return super.getScope(context, reference);
}
}
Remarks
IScope object, for each EObject containing some EReferenceTasks’ before and after propertiesEObjects, which are the possible values for the EReference
EObjects to include in the scopeWrite a custom scoping policy for the before and after properties of Tasks, such that:
Tasks from the same TaskPool can be referenced by some TaskTasks from some TaskPool cannot be referenced by Task from other TaskPoolsTasks (i.e. those without a name) cannot be referenced at allDocumentation here: https://eclipse.dev/Xtext/documentation/303_runtime_concepts.html#scoping
Solution in the vertical slides below (press down), or press right to skip it
public class ShedulerScopeProvider extends AbstractShedulerScopeProvider {
@Override
public IScope getScope(EObject context, EReference reference) {
if (context instanceof Task) {
if (Set.of(ShedulerPackage.Literals.TASK__AFTER, ShedulerPackage.Literals.TASK__BEFORE).contains(reference)) {
TaskPool pool = EcoreUtil2.getContainerOfType(context, TaskPool.class);
return Scopes.scopeFor(
pool.getTasks().stream()
.filter(it -> !context.equals(it)) // a task cannot refer to itself
.filter(it -> it.getName() != null) // anonymous tasks cannot be referenced
.toList()
);
}
}
return super.getScope(context, reference); // default behaviour for any other reference
}
}
What to notice:
ShedulerPackage.Literals.TASK__AFTER / TASK__BEFORE)EcoreUtil2.getContainerOfType walks the containment tree upwards, until it finds a TaskPoolScopes.scopeFor builds a scope where each element is visible by its name attributeafter foo resolves only if foo is in the scope, otherwise “Couldn’t resolve reference to Task ‘foo’”after proposes only the tasks in the scopeTwo approaches:
Both approaches require the definition of an execution engine
Is there functionality in the JDK which supports the scheduling of tasks in the future?
ScheduledExecutorServices !Same question for the execution of custom commands via some shell?
ProcessBuilders!Design insights:
ShedulerTask encapsulating:
ProcessBuildersShedulerRuntime
ScheduledExecutorService API…ShedulerTasks for executionShedulerRuntime and ShedulerTask classesGiven in package it.unibo.spe.mdd.sheduler.runtime (ShedulerTask shown before Exercise 5):
public class ShedulerRuntime {
private final ScheduledExecutorService delegate;
public ShedulerRuntime(ScheduledExecutorService delegate) {
this.delegate = Objects.requireNonNull(delegate);
}
public void schedule(ShedulerTask task) {
if (task.isPeriodic()) {
delegate.scheduleAtFixedRate(
task.asRunnable(),
task.getDelay().toMillis(),
task.getPeriod().toMillis(),
TimeUnit.MILLISECONDS
);
} else {
delegate.schedule(
task.asRunnable(),
task.getDelay().toMillis(),
TimeUnit.MILLISECONDS
);
}
}
}
public class ShedulerTask {
private ShedulerTask(String name, String command, String entrypoint, Duration delay) { /*...*/ }
public String getName() { /*...*/ }
public String getCommand() { /*...*/ }
public String getEntrypoint() { /*...*/ }
public Duration getPeriod() { /*...*/ }
public boolean isPeriodic() { /*...*/ }
public ShedulerTask setPeriod(Duration period) { /*...*/ }
public Duration getDelay() { /*...*/ }
public Process executeAsync() throws IOException {
List<String> cmd = new ArrayList<>(List.of(entrypoint.trim().split("\\s+")));
cmd.add(command); // e.g. ["/bin/sh", "-c", "echo hello"]
return new ProcessBuilder(cmd).inheritIO().start();
}
public Runnable asRunnable() {
return () -> {
try {
executeAsync();
} catch (IOException e) {
e.printStackTrace();
}
};
}
public static ShedulerTask in(String name, String command, String entrypoint, Duration delay) { /*...*/ }
public static ShedulerTask in(String command, String entrypoint, Duration delay) { /*...*/ }
public static ShedulerTask at(String name, String command, String entrypoint, LocalDateTime dateTime) { /*...*/ }
public static ShedulerTask at(String command, String entrypoint, LocalDateTime dateTime) { /*...*/ }
}
The engine is a thin layer over the JDK: it knows nothing about Xtext, EMF, or the grammar
in, at, period), not the one of the platform (ScheduledExecutorService)ShedulerTask has a private constructor and static factories named after the DSL keywords (in, at)
at(...) turns an absolute time into a delay, computed when the task is creatednull entry point (it is optional in the DSL) is replaced by ShedulerTask.DEFAULT_ENTRYPOINT, i.e. /bin/sh -cscheduleAtFixedRate vs. scheduleWithFixedDelay: “repeat every 1 hours” means a fixed rate
ProcessBuilder does not use a shell: the entry point (e.g. /bin/sh -c) must be split into separate arguments
/bin/sh -c”executeAsync does not wait for the process to terminate
pool myPool {
schedule task greetFrequently {
command "echo hello"
entry point "/bin/sh -c"
in 5 minutes
repeat every 1 hours
}
schedule task greetOnce {
command "echo hello"
entry point "/bin/bash -c"
at 2030/10/11 12:13
}
}
pool otherPool {
schedule task shutdownAfter1Day {
command "sudo shutdown now"
entry point "/bin/zsh -c"
in 1 days
}
}
public static void main(String[] args) {
ShedulerRuntime runtime = new ShedulerRuntime(
Executors.newScheduledThreadPool(Runtime.getRuntime().availableProcessors()));
pool_myPool(runtime);
pool_otherPool(runtime);
}
private static void pool_myPool(ShedulerRuntime runtime) {
ShedulerTask task0 = ShedulerTask.in("greetFrequently", "echo hello", "/bin/sh -c", Duration.parse("PT5M"));
task0.setPeriod(Duration.parse("PT1H"));
ShedulerTask task1 = ShedulerTask.at("greetOnce", "echo hello", "/bin/bash -c", LocalDateTime.parse("2030-10-11T12:13"));
runtime.schedule(task0);
runtime.schedule(task1);
}
private static void pool_otherPool(ShedulerRuntime runtime) {
ShedulerTask task0 = ShedulerTask.in("shutdownAfter1Day", "sudo shutdown now", "/bin/zsh -c", Duration.parse("PT24H"));
runtime.schedule(task0);
}
pool, one variable per task, one statement per clausein 1 days $\rightarrow$ PT24H), parsed back at run timetask0, task1), as DSL names are optional and may clash with Java keywordsSheduler DSLThe code generator should output code having a structure similar to the one shown in the previous slide
ShedulerSystem_<file name> class, with a main, plus one method per poolThe ShedulerRuntime and ShedulerTask classes should be generated as they are
ShedulerRuntime.java.template and ShedulerTask.java.template (in src/main/resources)AbstractShedulerGenerator provides javaTemplateFile(name, replacements), which loads a template and fills its __PLACEHOLDERS__The key part is generating the main class, where components are assembled together:
TODO Ex 3.* comments in ShedulerGenerator.java (skeleton below, comments omitted)public class ShedulerGenerator extends AbstractShedulerGenerator {
@Override
public void doGenerate(Resource resource, IFileSystemAccess2 fsa, IGeneratorContext context) {
File inputFile = new File(resource.getURI().toFileString());
TaskPoolSet taskPools = (TaskPoolSet) resource.getContents().get(0);
String inputFileName = inputFile.getName().split("\\.")[0];
fsa.generateFile("ShedulerRuntime.java", "// TODO Ex 3.2");
fsa.generateFile("ShedulerTask.java", "// TODO Ex 3.3");
fsa.generateFile("ShedulerSystem_" + inputFileName + ".java", "// TODO Ex 3.4");
}
private String generateTaskPoolMethodName(TaskPool taskPool, int index) { /* TODO Ex 3.7 */ }
private String generateTaskPoolsDefinitions(TaskPoolSet taskPools) throws IOException { /* TODO Ex 3.8 */ }
private String generateTaskPoolsCalls(TaskPoolSet taskPools) { /* TODO Ex 3.9 */ }
private String generateTaskPool(TaskPool pool) { /* TODO Ex 3.10 */ }
private String generateTask(int i, Task task) { /* TODO Ex 3.12 */ }
static String javaString(String s) { /* TODO Ex 3.16 */ }
}
Solution in the vertical slides below (press down), or press right to skip it
@Override
public void doGenerate(Resource resource, IFileSystemAccess2 fsa, IGeneratorContext context) {
File inputFile = new File(resource.getURI().toFileString());
TaskPoolSet taskPools = (TaskPoolSet) resource.getContents().get(0);
String inputFileName = inputFile.getName().split("\\.")[0].replaceAll("\\W", "_"); // e.g. my-tasks.shed
try {
fsa.generateFile("ShedulerRuntime.java", javaTemplateFile("ShedulerRuntime"));
fsa.generateFile("ShedulerTask.java", javaTemplateFile("ShedulerTask"));
fsa.generateFile("ShedulerSystem_" + inputFileName + ".java",
javaTemplateFile("ShedulerSystem", Map.of(
"INPUT", inputFileName,
"TASK_POOLS_CALLS", generateTaskPoolsCalls(taskPools),
"TASK_POOLS_DEFS", generateTaskPoolsDefinitions(taskPools)
))
);
} catch (IOException e) {
throw new Error("Buggy generator: missing template.", e);
}
}
ShedulerSystem.java.template (a resource file):
package it.unibo.spe.mdd.sheduler;
import it.unibo.spe.mdd.sheduler.runtime.ShedulerRuntime;
import it.unibo.spe.mdd.sheduler.runtime.ShedulerTask;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.concurrent.Executors;
public class ShedulerSystem___INPUT__ {
public static void main(String[] args) {
ShedulerRuntime runtime = new ShedulerRuntime(...);
__TASK_POOLS_CALLS__
}
__TASK_POOLS_DEFS__
}
javaTemplateFile(name, map) loads a template and replaces each __KEY__ with the corresponding valueShedulerRuntime and ShedulerTask are copied verbatim: no placeholderstaskPool.java.template (not shown) is the skeleton of a pool method: __ID__ (name) and __TASKS__ (body)private String generateTaskPool(TaskPool pool) {
StringBuilder sb = new StringBuilder();
List<Task> tasks = pool.getTasks();
for (int i = 0; i < tasks.size(); i++) { // 1. declare all tasks
sb.append(generateTask(i, tasks.get(i))).append("\n");
}
for (int i = 0; i < tasks.size(); i++) { // 2. schedule them
sb.append("runtime.schedule(task" + i + ");\n");
}
return sb.toString();
}
private String generateTask(int i, Task task) {
String args = javaString(task.getName()) + ", " + javaString(task.getCommand()) + ", " + javaString(task.getEntrypoint());
String factoryCall;
if (task.getRelative() != null) {
factoryCall = "ShedulerTask.in(" + args + ", Duration.parse(\"" + TimeUtils.toDuration(task.getRelative()) + "\"))";
} else if (task.getAbsolute() != null) {
factoryCall = "ShedulerTask.at(" + args + ", LocalDateTime.parse(\"" + TimeUtils.toLocalDateTime(task.getAbsolute()) + "\"))";
} else {
throw new UnsupportedOperationException("before/after: see Exercise 5");
}
String result = "ShedulerTask task" + i + " = " + factoryCall + ";";
if (task.getPeriod() != null) {
result += "\ntask" + i + ".setPeriod(Duration.parse(\"" + TimeUtils.toDuration(task.getPeriod()) + "\"));";
}
return result;
}
private static String javaString(String value) { // turns a value into a Java string literal
if (value == null) return "null";
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t") + "\"";
}
A code generator is a model-to-text (M2T) transformation: walk the model, emit text
The generated code is code: it must compile, for every valid model
command "echo \"hi\"" would break a naive "\"" + command + "\"": hence javaStringnull, not the string "null"The generator assumes a valid model, and does not re-check it
Static parts (ShedulerRuntime, ShedulerTask) are copied into the output
RuntimeTemplatesSyncTest) checks that they do not drift apartPlaceholders + StringBuilder are the simplest option, not the only one:
Generated files are not meant to be edited by hand: the next generation overwrites them
Sheduler DSLNo code generation, just a main (interpreter/ShedulerInterpreter.java, run via ./gradlew runInterpreter --args=...):
parse and validate the DSL, convert each Task (model) into a ShedulerTask (engine), schedule it via ShedulerRuntime
runtime)Parsing, validation, and scheduling are given: the key part is writing toShedulerTask (see TODO Ex 4.*):
public class ShedulerInterpreter {
public static void main(String[] args) {
// checks args, then:
Injector injector = new ShedulerStandaloneSetup().createInjectorAndDoEMFRegistration();
injector.getInstance(ShedulerInterpreter.class).runFile(args[0]);
}
@Inject private Provider<ResourceSet> resourceSetProvider;
@Inject private IResourceValidator validator;
protected void runFile(String string) {
// 1. parse: load the file as an EMF resource, whose root is the TaskPoolSet
ResourceSet set = resourceSetProvider.get();
Resource resource = set.getResource(URI.createFileURI(string), true);
// 2. validate: warnings are printed, errors stop us
List<Issue> issues = validator.validate(resource, CheckMode.ALL, CancelIndicator.NullImpl);
issues.forEach(System.err::println);
if (issues.stream().anyMatch(i -> i.getSeverity() == Severity.ERROR)) {
return;
}
// 3. execute: turn each Task (model) into a ShedulerTask (runtime), then schedule it
TaskPoolSet taskPools = (TaskPoolSet) resource.getContents().get(0);
ShedulerRuntime runtime = new ShedulerRuntime(Executors.newScheduledThreadPool(Runtime.getRuntime().availableProcessors()));
for (TaskPool pool : taskPools.getPools()) {
for (Task task : pool.getTasks()) {
runtime.schedule(toShedulerTask(task));
}
}
}
static ShedulerTask toShedulerTask(Task task) {
throw new UnsupportedOperationException("TODO Ex 4.1: convert a Task (model) into a ShedulerTask (runtime)");
}
}
ShedulerStandaloneSetup bootstraps the language outside Eclipse / LSP
EPackage) and the .shed extension in EMF’s global registriesset.getResource(...) knows how to parse a .shed fileInjector, configured by ShedulerRuntimeModule@Inject fields are filled by Guice: Xtext is built around dependency injection
The front-end is identical to the one of the generator (Main, generated because of generateJavaMain = true)
Solution in the vertical slides below (press down), or press right to skip it
static ShedulerTask toShedulerTask(Task task) {
ShedulerTask result;
if (task.getRelative() != null) {
result = ShedulerTask.in(task.getName(), task.getCommand(), task.getEntrypoint(), TimeUtils.toDuration(task.getRelative()));
} else if (task.getAbsolute() != null) {
result = ShedulerTask.at(task.getName(), task.getCommand(), task.getEntrypoint(), TimeUtils.toLocalDateTime(task.getAbsolute()));
} else {
throw new UnsupportedOperationException("before/after: see Exercise 5");
}
if (task.getPeriod() != null) {
result.setPeriod(TimeUtils.toDuration(task.getPeriod()));
}
return result;
}
What to notice:
generateTask in the generator: same case analysis, but here we call the factories instead of printing calls to them
ShedulerTasksnull: ShedulerTask falls back to a default one (/bin/sh -c)runFile returns: the executor’s threads are non-daemon, and tasks are pendingAdd support for task dependencies (before/after) to the execution engine
TODO Ex 5.1–5.9 in package runtime).java.template counterpartsUpdate the code generator accordingly
Update the interpreter accordingly
Solution in the vertical slides below (press down), or press right to skip it
The grammar says how to write dependencies, not what they mean: we have to decide
A after B: every time B runs, A runs right after B has terminatedA before B: every time B runs, A runs (and terminates) right before BB is the anchor of A: A has no timing of its own (hence rule 8 of Exercise 1)Consequences on the engine:
Process.waitFor())Consequences on the validator: dependencies must not form cycles
A after B and B after A: neither task would ever runMapping from the DSL to the engine:
| DSL | Engine |
|---|---|
schedule task A { ... after B } |
B.addSuccessor(A) |
schedule task A { ... before B } |
B.addPredecessor(A) |
A has no in / at |
A = ShedulerTask.dependent(...), not passed to runtime.schedule |
public class ShedulerTask {
private final Duration delay; // null iff dependent
private final List<ShedulerTask> predecessors = new ArrayList<>(); // run right BEFORE this one
private final List<ShedulerTask> successors = new ArrayList<>(); // run right AFTER this one
// ... other fields, constructor, in(...), at(...) as before
public static ShedulerTask dependent(String name, String command, String entrypoint) {
return new ShedulerTask(name, command, entrypoint, null);
}
public boolean isDependent() { return delay == null; }
public ShedulerTask addPredecessor(ShedulerTask task) {
predecessors.add(Objects.requireNonNull(task));
return this;
}
public ShedulerTask addSuccessor(ShedulerTask task) {
successors.add(Objects.requireNonNull(task));
return this;
}
void runChain() throws IOException, InterruptedException {
for (ShedulerTask p : predecessors) p.runChain();
executeAsync().waitFor();
for (ShedulerTask s : successors) s.runChain();
}
public Runnable asRunnable() {
return () -> {
try { runChain(); }
catch (IOException e) { e.printStackTrace(); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
};
}
}
public class ShedulerRuntime {
// ... as before
public void schedule(ShedulerTask task) {
if (task.isDependent()) {
throw new IllegalArgumentException("Task " + task.getName()
+ " is dependent: it is triggered by its anchor task");
}
// ... as before
}
}
runChain is recursive: dependents of dependents work out of the boxwaitFor makes the order real: “after” means after terminationnull): “dependent” is not an extra flag, it is the absence of timing@Check
public void ensureNoDependencyCycles(Task task) {
Set<Task> visited = new HashSet<>();
for (Task current = task; current != null; current = anchorOf(current)) {
if (!visited.add(current)) {
if (current == task) {
error("Cyclic dependency: task never reaches a timed task", task,
task.getBefore() != null ? ShedulerPackage.Literals.TASK__BEFORE : ShedulerPackage.Literals.TASK__AFTER);
}
return;
}
}
}
private static Task anchorOf(Task task) {
return task.getBefore() != null ? task.getBefore() : task.getAfter();
}
What to notice:
before and after are alternatives, so each task has at most one anchor
anchorOf == null), or loopscurrent == task)
after foo with no foo) are already reported by linking, and they simply end the chainInterpreter: from one pass to three passes, per pool
for (TaskPool pool : taskPools.getPools()) {
Map<Task, ShedulerTask> tasks = new LinkedHashMap<>();
for (Task task : pool.getTasks()) { // 1. create
tasks.put(task, toShedulerTask(task));
}
for (Map.Entry<Task, ShedulerTask> entry : tasks.entrySet()) {
Task task = entry.getKey(); // 2. wire
if (task.getAfter() != null) {
tasks.get(task.getAfter()).addSuccessor(entry.getValue());
} else if (task.getBefore() != null) {
tasks.get(task.getBefore()).addPredecessor(entry.getValue());
}
}
for (ShedulerTask t : tasks.values()) { // 3. schedule
if (!t.isDependent()) {
runtime.schedule(t);
}
}
}
where toShedulerTask now returns ShedulerTask.dependent(...) instead of throwing
Generator: same three passes, emitted as code
for (int i = 0; i < tasks.size(); i++) { // 2. wire
Task task = tasks.get(i);
if (task.getAfter() != null) {
sb.append("task" + tasks.indexOf(task.getAfter())
+ ".addSuccessor(task" + i + ");\n");
} else if (task.getBefore() != null) {
sb.append("task" + tasks.indexOf(task.getBefore())
+ ".addPredecessor(task" + i + ");\n");
}
}
Generated code, for notify scheduled after backup:
ShedulerTask task0 = ShedulerTask.in("backup", "tar czf b.tgz docs", null, Duration.parse("PT1H"));
ShedulerTask task1 = ShedulerTask.dependent("notify", "echo done", null);
task0.addSuccessor(task1);
runtime.schedule(task0);
The syntax of dependencies existed since the beginning: the semantics required decisions and a refactoring
Separate passes are needed because references may point forward (A after B, with B declared later)
tasks.indexOf(task.getAfter()) works because, thanks to our scoping, anchors are always in the same pool
waitFor blocks a thread of the executor until the process terminates
Executors.newScheduledThreadPool(1), a long task would delay all the othersExecutors.newScheduledThreadPool(Runtime.getRuntime().availableProcessors())Interpreter and generator share the same engine: the refactoring is done once
Compiled on: 2026-10-15 — printable version