This exercise will help you apply the concepts from the lessons through a simple domain use case. It will take you from metamodeling to interpretation and compilation. It’s also excellent preparation for your graded lab sessions on the Robots project.

In this lab, you will model a Finite State Machine (FSM). An FSM is a structure used to describe the behavior of a system based on states and transitions between those states. Each machine contains a set of states, one initial state, and transitions triggered by events.

By the end of this lab, you will have:

Modeling a Finite State Machine (FSM) with Ecore

In this lab, you will model a Finite State Machine (FSM) using an Ecore metamodel.

Prerequisites

The modeling is made with Eclipse DSL 2025-09 with the added Ecore tools 4.0.

[!WARNING] You have to test the Eclipse version before the first lab session or there might be problem with the last Sirius version when using Xtext2Langium in the next part.

Ecore Metamodeling

Ecore is the core modeling language of the Eclipse Modeling Framework (EMF). It is used to describe the structure of models, in other words, to define metamodels. A metamodel specifies what concepts exist in a domain, what properties they have, and how they relate to each other.

Key Concepts

EPackage: A container that groups related model elements (similar to a namespace or a Java package). It has a name, a namespace prefix, and a URI that uniquely identifies it.

EClass: Represents a concept or type in the model (like a class in object-oriented programming). Each EClass can have:

EAttribute: Defines a simple property of a class. For example: FSM.name : EString means that each FSM has a string attribute called name.

EReference: Describes a relationship between classes. A reference can be:

Opposite references: Two references can be declared as opposites to ensure consistency between both ends of a relation (e.g., Transition.src ↔ State.outgoing).

Multiplicity: Indicates how many elements can be linked (e.g., [0..*] for many, [1] for exactly one).

To start

Go to File > New > Ecore modeling project, name it fsm and open the model/fsm.ecore file. You can edit it with Right click on the package fsm (generated when creating a Ecore project) > New Child > EClass | ....

You can do the same on an EClass: New Child > EAttribute | EReference | ... .

When selecting (double click on it) a node, you can edit its properties in a dedicated window.

FSM Metamodeling

Model a Finite State Machine (FSM) using an Ecore metamodel. To help you, here is a class diagram illustrating what is expected:

FSM class diagram

Known issue

If you have any problem to create a ecore project in Eclipse, you should go to:

HelpInstall New Software

In the window type:

Work with: 2025-03 - https://download.eclipse.org/releases/2025-03

Check the box Modeling, then click on NextNext → Accept the conditions using the radio button → Finish

Restart Eclipse and you should have a new entry in the menu:

FileNewOther, type EcoreEcore Modeling Project

XText

In this exercise, you will design an Xtext grammar that defines a textual syntax for the FSM metamodel you created earlier in Ecore. Your goal is to allow users to describe finite state machines in a simple and readable DSL.

Objectives

Context

You already have an Ecore metamodel describing:

A machine (FSM) containing:

A state (State) that can have incoming and outgoing transitions. A transition (Transition) linking two states (src, tgt), with optional trigger and action.

Project

Create a new Xtext project: FileNewProjectXtext Project.

Define the grammar

Base it on the metamodel from the previous exercise. Make sure each FSM can contain states and transitions, and that these can refer to each other by name.

Use references ([Type|EString]) for links such as initialState, src, and tgt.

Your grammar should allow users to write files like this:

FSM MyFsm {
  initialState "S1"
  ownedStates {
    State "S1" { outgoing("t1") },
    State "S2" { incoming("t1") }
  }
  ownedTransitions {
    Transition "t1" src "S1" tgt "S2" trigger "go" action "doSomething"
  }
}

Check your grammar

Validate the grammar (no errors or warnings). Then, launch the Eclipse runtime by right-clicking on your xtext project, Run asEclipse Application. In the new Eclipes runtime, aka modeling workbench, create a new project: FileNewProjectProject. You can name it test-fsm. In this project, create new .fsm file: FileNewFile, and name it test.fsm: Mind to type the extension, which will let your Eclipse recognize the file as a file of your fsm language.

Use content assist (Ctrl+Space) to verify that names and references are correctly resolved and check if the IDE reports any syntaxique errors, by writing wrong token such as Sate instead of State.

You can also open your .fsm file with the FSM Model Editor by Right Click on a .fsmOpen withFsm Model Editor. You will see your program as EMF models.

Generate an Xtext grammar from ecore

Eclipse allows you to create a Xtext project based on a ecore:

FileNewProject → Type xtext → Xtext Project From Existing Ecore Models

You can check and compare the generated grammar with yours.

Langium

If not done already, you will need to install a node environment as well as Visual Studio Code, and then run the command:

npm i -g yo@5.1.0 generator-langium@3.3.0`

to install the Langium project generator. Then, run:

yo langium

to generate the project. This will offer to create a few different things; you have to say yes to all of them BUT CLI, pick a language name, extension name, and a file extension (e.g. .fsm). You can also install the Langium extension from the VSCode marketplace, to have syntax highlighting and validation in your grammar.

We use a particular version of yo and generator-langium in these labs due to the rapid change in the version of Langium. Make sure that you use these versions.

Xtext to Langium

Fortunately, it is possible to convert an Xtext grammar into a Langium grammar thanks to this project.

To convert a grammar, go to the Eclipse menu Help -> Install new software -> in the site field, paste the URL https://typefox.github.io/xtext2langium/download/updates/v0.4.0/ and install the package. Afterwards, go into your Xtext project’s META-INF/MANIFEST.MF, switch to the Dependencies tab, and in the Required Plug-ins section add Xtext2langium as a dependency. Don’t forget to save your manifest file. Then you can go to the MWE2 file (named something like GenerateMyDsl.mwe2) in your project, and add to language, the following fragment:

language {
    ...
    fragment = io.typefox.xtext2langium.Xtext2LangiumFragment {
        outputPath = './langium'
    }
}

Right-click the MWE2 file and run it. You should see a langium folder appear in your project, with corresponding .langium grammar files which you can put into your src/language/ folder of the Langium project in VSCode.

Make sure the grammars names match up between your projects, otherwise you will have to manually refactor the conflicts.

Langium Build

Once copied and adjusted, run in the Langium project, :

npm install
npm run langium:generate
npm run build

Hit F5 in VS Code to run the extension. A new VSCode will popup: the modeling workbench. In this new VSCode, create a file with the correct extension and test your grammar.

File example:

FSM DoorSystem {
    initialState Closed

    ownedStates {
        State Closed {
            outgoing(open)
            incoming(close)
        },
        State Open {
            outgoing(close)
            incoming(open)
        }
    }

    ownedTransitions {
        Transition open {
            trigger "open"
            action "open door"
            src Closed
            tgt Open
        },
        Transition close {
            trigger "close"
            action "close door"
            src Open
            tgt Closed
        }
    }
}

Interpretation with Langium

In this part, you will develop an interpreter for your FSM language. The interpreter allows you to simulate the behavior of an FSM by evolving a runtime state.

Goal

The interpreter algorithm should:

The execution should produce trace messages in the console (console.log('...')) to show the evolution of the system.

Add a file interpreter.ts in language:

import { Reference } from 'langium';
import type { FSM, State, Transition } from './generated/ast.js';

export class FsmInterpreter {
    public run(fsm: FSM): void {
		const ctx = new FsmContext(fsm.initialState.ref);
        /* FSM interpreter algorithm goes there */
	}
}

export class FsmContext {
    currentState: State;

    constructor(initialState: State) {
        this.currentState = initialState;
    }
}

In index.ts, add export * from './interpreter.js'

Runner

Now, we want a VSCode action, a kind of plugin that execute a command to launch the interpretation.

In extension, a new folder runner, add a new file runner.ts with the following code:

import { FSM, createFsmServices, FsmInterpreter } from 'fsm-language'; 
import { NodeFileSystem } from 'langium/node';
import * as vscode from 'vscode';

export async function runFsmFile(textDocument: vscode.TextDocument) {
    const services = createFsmServices({ ...NodeFileSystem });
    const document = services.shared.workspace.LangiumDocumentFactory.fromString(textDocument.getText(), textDocument.uri);
    await services.shared.workspace.DocumentBuilder.build([document], { validation: true });
    const root = document.parseResult.value;
    const fsm = root as FSM;

    if (!fsm) {
        vscode.window.showErrorMessage(`Parsing failed: ${document.diagnostics}`);
        process.exit(1);
    }

    vscode.window.showInformationMessage('FSM loaded successfully');

    const interpreter = new FsmInterpreter(fsm);
    interpreter.run(fsm);
}

Then, add in main.ts from extension:

export async function activate(context: vscode.ExtensionContext): Promise<void> {
    client = await startLanguageClient(context);
    context.subscriptions.push(
        vscode.commands.registerCommand('fsm.runFsm', () => {
            const editor = vscode.window.activeTextEditor;
            if (editor) {
                runFsmFile(editor.document);
            }
        })
    );
}

And add in package.json:

"contributes": {
    /* ... */
        "commands": [
            {
              "command": "fsm.runFsm",
              "title": "Run FSM"
            },
        ]
    },
    /* ... */
    "activationEvents": [
        "onLanguage:fsm",
        "onCommand:fsm.runFsm",
    ],
    /* ... */

Build and Run

To test, open a terminal, go to extension folder and run npm run build. In VSCode, press F5 to launch a new VSCode with our extensions.

In this new VSCode, :

Compilation with Langium

In this part, you will implement a compiler for your FSM language. The compiler will translate a textual FSM model into a JavaScript implementation that can be executed independently. Unlike the interpreter (which executes the model directly), the compiler generates source code following the State design pattern.

Goal

Your compiler should:

This process illustrates the Visitor pattern, as your compiler must traverse the model and produce corresponding code elements.

With all the knowledge accumulated, you should be able to implement this compiler by yourself, in particular, based on what we did for the intrepreter.

Do not forget to:

Solutions

Intepreter

import { Reference } from 'langium';
import type { FSM, State, Transition } from './generated/ast.js';

export class FsmInterpreter {
    private random: () => number;

    constructor() {
        this.random = () => Math.random();
    }

    // Main algoritm of the interpreter
    public run(fsm: FSM): void {
        // Potentially, the initially is not provided, therefore we return
        if (!fsm.initialState?.ref) {
            console.log(`FSM '${fsm.name}' has no initial state.`);
            return;
        }

        // Initialization of the runtime state of the interpreter
        const ctx = new FsmContext(fsm.initialState.ref);
        console.log(`Starting FSM: ${fsm.name}`);
        console.log(`Initial state: ${ctx.currentState.name}`);

        // Main loop - until we reach a terminal state or 10 transitions have been taken
        let count = 10;
        while (count-- > 0) {
            // Retrieve all the outgoing transitions from the current state
            const transitions = this.getOutgoingTransitions(ctx.currentState);

            // if none, i.e. transitions[] is empty, we reach a terminal state, therefore we break
            if (transitions.length === 0) {
                console.log(`Reached terminal state: ${ctx.currentState.name}`);
                break;
            }

            // We pick a random transition, and interpret it
            const pickedTransition = this.pickRandomTransition(transitions);
            this.interpretTransition(pickedTransition, ctx);
        }
    }

    // Interpretating a transition means to print trigger and action, and update the current state
    private interpretTransition(t: Transition, ctx: FsmContext): void {
        console.log(`--- Executing transition: ${t.name}`);
        if (t.trigger) console.log(`Trigger: ${t.trigger}`);
        if (t.action) console.log(`Action: ${t.action}`);

        if (t.tgt?.ref) {
            ctx.currentState = t.tgt.ref;
            console.log(`Now in state: ${ctx.currentState.name}`);
        } else {
            console.log(`Transition '${t.name}' has no target; stopping.`);
        }
    }

    private pickRandomTransition(transitions: Transition[]): Transition {
        const i = Math.floor(this.random() * transitions.length);
        return transitions[i];
    }

    private getOutgoingTransitions(state: State): Transition[] {
        // in Langium, cross references are usually stored as { ref: <object> }
        return (state.outgoing ?? []).map(ref => ref.ref).filter((t): t is Transition => !!t);
    }
}

export class FsmContext {
    currentState: State;

    constructor(initialState: State) {
        this.currentState = initialState;
    }
}

Compiler

import fs from 'node:fs';
import path from 'node:path';
import { CompositeGeneratorNode, NL, toString } from 'langium/generate';
import type { FSM, State, Transition } from './generated/ast.js';

/**
 * A visitor-like compiler for FSM models, generating JavaScript code
 * that mirrors the Java generator implemented in Xtend.
 */
export class FsmCompiler {

    constructor(private readonly destination: string) {}

    public compile(fsm: FSM): void {
        const pkg = 'fsm.generated';
        const outDir = path.join(this.destination, pkg.replace(/\./g, '/'));
        fs.mkdirSync(outDir, { recursive: true });

        this.generateStateInterface(pkg, outDir);
        this.generateContext(pkg, outDir);
        this.generateStates(pkg, fsm, outDir);
        this.generateMain(pkg, fsm, outDir);

        const absoluteOutDir = path.resolve(outDir);
        console.log(`✅ FSM '${fsm.name}' compiled to ${absoluteOutDir}`);
    }

    private generateStateInterface(pkg: string, outDir: string): void {
        const node = new CompositeGeneratorNode();
        node.append(`// Auto-generated FSM State interface`, NL);
        node.append(`import { FsmContext } from './FsmContext';`, NL);
        node.append(`export interface State {`, NL);
        node.indent(s => {
            s.append(`onEnter(ctx: FsmContext): State | undefined;`, NL);
        });
        node.append(`}`, NL);
        this.writeFile(outDir, 'State.ts', toString(node));
    }

    private generateContext(pkg: string, outDir: string): void {
        const node = new CompositeGeneratorNode();
        node.append(`// Auto-generated FSM Context`, NL);
        node.append(`import type { State } from './State';`, NL, NL);
        node.append(`export class FsmContext {`, NL);
        node.indent(s => {
            s.append(`private current: State | undefined;`, NL, NL);
            s.append(`setState(s: State) { this.current = s; }`, NL);
            s.append(`getState(): State | undefined { return this.current; }`, NL);
            s.append(`start() {`, NL);
            s.indent(b => {
                b.append('if (!this.current) {', NL);
                b.indent(c => { 
                    b.append(`console.error('No initial state defined.');`, NL);
                    b.append(`return;`, NL);
                })
                b.append('}', NL, NL);

                b.append(`let count = 10;`, NL)
                b.append(`while(count-- > 0) {`, NL);
                b.indent(c => {
                    c.append(`const nextState = this.current.onEnter(this);`, NL);
                    c.append(`if (!nextState) { return; }`, NL);
                    c.append(`this.setState(nextState);`, NL)
                    c.append(`console.log('nb transitions remaining:', count);`, NL);
                });
                b.append('}', NL);
            });
            s.append('}', NL);
        });
        node.append(`}`, NL);
        this.writeFile(outDir, 'FsmContext.ts', toString(node));
    }

    private generateStates(pkg: string, fsm: FSM, outDir: string): void {
        for (const s of fsm.ownedStates) {
            this.generateStateClass(pkg, fsm, s, outDir);
        }
    }

    private generateMain(pkg: string, fsm: FSM, outDir: string): void {
        const node = new CompositeGeneratorNode();
        node.append(`// Auto-generated entry point`, NL);
        node.append(`import { FsmContext } from './FsmContext';`, NL);
        for (const s of fsm.ownedStates) {
            node.append(`import { ${s.name} } from './${s.name}';`, NL);
        }
        node.append(NL);
        node.append(`export class ${fsm.name}Main {`, NL);
        node.indent(s => {
            s.append(`static main() {`, NL);
            s.indent(b => {
                b.append(`const ctx = new FsmContext();`, NL);
                const init = fsm.initialState?.ref?.name ?? fsm.ownedStates?.[0]?.name ?? 'undefined';
                if (init !== 'undefined') {
                    b.append(`ctx.setState(new ${init}());`, NL);
                    b.append(`ctx.start();`, NL);
                } else {
                    b.append(`console.error('No initial state defined.');`, NL);
                }
            });
            s.append(`}`, NL);
        });
        node.append(`}`, NL);
        node.append(`${fsm.name}Main.main()`, NL);
        this.writeFile(outDir, `${fsm.name}Main.ts`, toString(node));
    }

    private generateStateClass(pkg: string, fsm: FSM, s: State, outDir: string): void {
        const node = new CompositeGeneratorNode();
        node.append(`// Auto-generated class for state ${s.name}`, NL);
        node.append(`import { randomInt } from 'crypto';`, NL);
        node.append(`import { State } from './State';`, NL); 
        node.append(`import { FsmContext } from './FsmContext';`, NL);
        const outgoing = (s.outgoing ?? []).map(ref => ref.ref).filter((t): t is Transition => !!t);
        outgoing.forEach((t, i) => {
            const tgt = t.tgt?.ref?.name ?? 'undefined';
            if (tgt) {
                node.append(`import { ${tgt} } from './${tgt}';`, NL);
            }
        });
        node.append(NL); 

        node.append(`export class ${s.name} implements State {`, NL);
        node.indent(b => {
            b.append(`onEnter(ctx: FsmContext): State | undefined {`, NL);
            b.indent(body => {
                if (outgoing.length === 0) {
                    body.append(`console.log("Reached terminal state: ${s.name}");`, NL);
                } else {
                    body.append(`console.log("Entering state: ${s.name}");`, NL);
                    body.append(`const choice = randomInt(0, ${outgoing.length});`, NL);
                    body.append(`switch (choice) {`, NL);

                    outgoing.forEach((t, i) => {
                        const tgt = t.tgt?.ref?.name ?? 'undefined';
                        const trigger = t.trigger ?? '';
                        const action = t.action ?? '';
                        body.indent(sw => {
                            sw.append(`case ${i}:`, NL);
                            sw.indent(caseBody => {
                                caseBody.append(`console.log("Transition: ${t.name} trigger: ${trigger} action: ${action}");`, NL);
                                caseBody.append(`return new ${tgt}();`, NL);
                            });
                        });
                    });
                    body.indent(sw => {
                        sw.append('default:', NL);
                        sw.indent(defaultCase => defaultCase.append('return undefined;', NL));
                    });
                    body.append(`}`, NL);
                }
            });
            b.append(`}`, NL);
        });
        node.append(`}`, NL);

        this.writeFile(outDir, `${s.name}.ts`, toString(node));
    }

    private writeFile(outDir: string, fileName: string, content: string): void {
        const filePath = path.join(outDir, fileName);
        fs.writeFileSync(filePath, content);
    }
}

Compile action

Now, we want a VSCode action, a kind of plugin that execute a command to launch the compilation.

In extension, a new folder compiler, add a new file compiler.ts with the following code:

import path from "path";
import { FSM, createFsmServices, FsmCompiler } from 'fsm-language';
import { NodeFileSystem } from 'langium/node';
import * as vscode from 'vscode';

export async function compileFsmFile(textDocument: vscode.TextDocument) {
    const services = createFsmServices(NodeFileSystem);
    const document = services.shared.workspace.LangiumDocumentFactory.fromString(
        textDocument.getText(),
        textDocument.uri
    );

    await services.shared.workspace.DocumentBuilder.build([document], { validation: true });

    const root = document.parseResult.value;
    const fsm = root as FSM;
    if (!fsm) {
        vscode.window.showErrorMessage(`Parsing failed: ${document.diagnostics}`);
        return;
    }

    const destinationFolder = path.dirname(textDocument.fileName);
    const compiler = new FsmCompiler(destinationFolder);
    compiler.compile(fsm);

    vscode.window.showInformationMessage(`FSM '${fsm.name}' compiled successfully.`);
}

Then, add in main.ts from extension:

export async function activate(context: vscode.ExtensionContext): Promise<void> {
    client = await startLanguageClient(context);
    context.subscriptions.push(
        vscode.commands.registerCommand('fsm.runFsm', () => {
            const editor = vscode.window.activeTextEditor;
            if (editor) {
                runFsmFile(editor.document);
            }
        })
    );
    context.subscriptions.push(
        vscode.commands.registerCommand('fsm.compileFsm', () => {
            const editor = vscode.window.activeTextEditor;
            if (editor) {
                compileFsmFile(editor.document);
            }
        })
    );
}

And add in package.json:

"contributes": {
    /* ... */
        "commands": [
            {
              "command": "fsm.runFsm",
              "title": "Run FSM"
            },
            {
                "command": "fsm.compileFsm",
                "title": "Compile FSM"
            }
        ]
    },
    /* ... */
    "activationEvents": [
        "onLanguage:fsm",
        "onCommand:fsm.runFsm",
        "onCommand:fsm.compileFsm"
    ],
    /* ... */

Build and Run

To test, open a terminal, go to extension folder and run npm run build. In VSCode, press F5 to launch a new VSCode with our extensions.

In this new VSCode, :

npx ts-node --compiler-options '{"module":"CommonJS"}' XXXMain.ts

If needed, install ts-node:

npm install -D ts-node typescript