Skip to content

Commit

Permalink
Add file-based scoping
Browse files Browse the repository at this point in the history
  • Loading branch information
Lotes committed Jun 10, 2024
1 parent ad3bbc2 commit 5ca58be
Show file tree
Hide file tree
Showing 2 changed files with 194 additions and 0 deletions.
6 changes: 6 additions & 0 deletions hugo/content/docs/recipes/scoping/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,9 @@ In this guide, we'll look at different scoping kinds and styles and see how we c

Note that these are just example implementations for commonly used scoping methods.
The scoping API of Langium is designed to be flexible and extensible for any kind of use case.

## Other kinds of scoping

Also mind the following scoping kinds:

- [File-based scoping](/docs/recipes/scoping/file-based)
188 changes: 188 additions & 0 deletions hugo/content/docs/recipes/scoping/file-based.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
---
title: "File-based scoping"
weight: 300
---

## Goal

Our goal here is to mimic the TypeScript import/export mechanism. By that I mean:

* you can export certain symbols using an `export` keyword from your current file to make it available to the other files
* you can import certain symbols using the `import` keyword from a different file

To make things easier I will modify the "Hello World" example from the [learning section](/docs/learn/workflow).

## Step 1: Change the grammar

First thing, we are changing the grammar to support the `export` and the `import` keywords. Here is the modified grammar:

```langium
grammar HelloWorld
entry Model:
(
fileImports+=FileImport //NEW: imports per file
| persons+=Person
| greetings+=Greeting
)*;
FileImport:
'import' '{' personImports+=PersonImport (',' personImports+=PersonImport)* '}' 'from' file=STRING
; //NEW: imports of the same file are gathered in a list
PersonImport:
person=[Person:ID] ('as' name=ID)?
;
Person:
published?='export'? 'person' name=ID; //NEW: export keyword
type Greetable = PersonImport | Person
Greeting:
'Hello' person=[Greetable:ID] '!';
hidden terminal WS: /\s+/;
terminal ID: /[_a-zA-Z][\w_]*/;
terminal STRING: /"(\\.|[^"\\])*"|'(\\.|[^'\\])*'/;
hidden terminal ML_COMMENT: /\/\*[\s\S]*?\*\//;
hidden terminal SL_COMMENT: /\/\/[^\n\r]*/;
```

After changing the grammar you need to regenerate the abstract syntax tree (AST) and the language infrastructure. You can do that by running the following command:

```bash
npm run langium:generate
```

## Step 2: Exporting persons to the global scope

The index manager shall get all persons that are marked with the export keyword. In Langium this is done by overriding the `ScopeComputation.getExports(…)` function. Here is the implementation:

```typescript
export class HelloWorldScopeComputation extends DefaultScopeComputation {
override async computeExports(document: LangiumDocument<AstNode>, _cancelToken?: CancellationToken | undefined): Promise<AstNodeDescription[]> {
const model = document.parseResult.value as Model;
return model.persons
.filter(p => p.published)
.map(p => this.descriptions.createDescription(p, p.name))
;
}
}
```

After that, you need to register the `HelloWorldScopeComputation` in the `HelloWorldModule`:

```typescript
export const HelloWorldModule: Module<HelloWorldServices, PartialLangiumServices & HelloWorldAddedServices> = {
//...
references: {
ScopeComputation: (services) => new HelloWorldScopeComputation(services)
}
};
```

Having done this, will make all persons that are marked with the `export` keyword available to the other files through the index manager.

## Step 3: Bending the cross-reference resolution

The final step is to adjust the cross-reference resolution through overriding the `DefaultScopeProvider.getScope(…)` function. Here is the implementation:

```typescript
export class HelloWorldScopeProvider extends DefaultScopeProvider {
override getScope(context: ReferenceInfo): Scope {
switch(context.container.$type as keyof HelloWorldAstType) {
case 'PersonImport':
if(context.property === 'person') {
return this.getExportedPersonsFromGlobalScope(context);
}
break;
case 'Greeting':
if(context.property === 'person') {
return this.getImportedPersonsFromCurrentFile(context);
}
break;
}
return EMPTY_SCOPE;
}
//...
}
```

You noticed the two missing functions? Here is what they have to do.

The first function (`getExportedPersonsFromGlobalScope(context)`) will take a look at the global scope and return all exported persons respecting the files that were touched. Not that we are outputting all persons that are marked with the `export` keyword. The actual name resolution is done later by the linker.

```typescript
protected getExportedPersonsFromGlobalScope(context: ReferenceInfo): Scope {
//get document for current reference
const document = AstUtils.getDocument(context.container);
//get model of document
const model = document.parseResult.value as Model;
//determine current directory
const currentDir = dirname(document.uri.fsPath);
//look at all imports of the current document
const astNodeDescriptions = model.fileImports.flatMap((fileImport) => {
const fileName = resolve(currentDir, fileImport.file);
const uri = URI.file(fileName);
return this.indexManager.allElements(Person, new Set<string>([uri.toString()])).toArray();
});
return this.createScope(astNodeDescriptions);
}
```

The second function (`getImportedPersonsFromCurrentFile(context)`) will take a look at the current file and return all persons that are imported from other files.

```typescript
private getImportedPersonsFromCurrentFile(context: ReferenceInfo) {
//get current document of reference
const document = AstUtils.getDocument(context.container);
//get current model
const model = document.parseResult.value as Model;
//go through all imports
const descriptions = model.fileImports.flatMap(fi => fi.personImports.map(pi => {
//if the import is name, return the import
if (pi.name) {
return this.descriptions.createDescription(pi, pi.name);
}
//if import references to a person, return that person
if (pi.person.ref) {
return this.descriptions.createDescription(pi.person.ref, pi.person.ref.name);
}
//otherwise return nothing
return undefined;
}).filter(d => d != undefined)).map(d => d!);
return this.createScope(descriptions);
}
```

## Result

Now, let's test the editor by `npm run build` and starting the extension.
Try using these two files. The first file contains the Simpsons family.

```
export person Homer
export person Marge
person Bart
person Lisa
export person Maggy
```

The second file tries to import and greet them.

```
import {
Marge,
Homer,
Lisa, //reference error
Maggy as Baby
} from "persons.hello"
Hello Lisa! //reference error
Hello Maggy! //reference error
Hello Homer!
Hello Marge!
Hello Baby!
```

0 comments on commit 5ca58be

Please sign in to comment.