Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Recipe: File-based symbol import/export #239

Merged
merged 12 commits into from
Aug 22, 2024
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)
Lotes marked this conversation as resolved.
Show resolved Hide resolved
295 changes: 295 additions & 0 deletions hugo/content/docs/recipes/scoping/file-based.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
---
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
Lotes marked this conversation as resolved.
Show resolved Hide resolved

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:
Lotes marked this conversation as resolved.
Show resolved Hide resolved

```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
Lotes marked this conversation as resolved.
Show resolved Hide resolved

PersonImport:
person=[Person:ID] ('as' name=ID)?
;
Lotes marked this conversation as resolved.
Show resolved Hide resolved

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))
;
}
Lotes marked this conversation as resolved.
Show resolved Hide resolved
}
```

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
Lotes marked this conversation as resolved.
Show resolved Hide resolved

The final step is to adjust the cross-reference resolution through overriding the `DefaultScopeProvider.getScope(…)` function. Here is the implementation:
Lotes marked this conversation as resolved.
Show resolved Hide resolved

```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;
}
//...
}
```

Do not forget to add the new service to the `HelloWorldModule`:

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

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 by the file imports. Note that we are outputting all persons that are marked with the `export` keyword. The actual name resolution is done internally later by the linker.

```typescript
protected getExportedPersonsFromGlobalScope(context: ReferenceInfo): Scope {
Lotes marked this conversation as resolved.
Show resolved Hide resolved
//get document for current reference
const document = AstUtils.getDocument(context.container);
//get model of document
const model = document.parseResult.value as Model;
//get URI of current document
const currentUri = document.uri;
//get folder of current document
const currentDir = dirname(currentUri.path);
const uris = new Set<string>();
//for all file imports of the current file
for (const fileImport of model.fileImports) {
//resolve the file name relatively to the current file
const filePath = join(currentDir, fileImport.file);
//create back an URI
const uri = currentUri.with({ path: filePath });
//add the URI to URI list
uris.add(uri.toString());
}
//get all possible persons from these files
const astNodeDescriptions = this.indexManager.allElements(Person, uris).toArray();
//convert them to descriptions inside of a scope
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.

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

The second file tries to import and greet them.

```plain
import {
Marge,
Homer,
Lisa, //reference error, because not exported
Maggy as Baby
} from "persons.hello"

Hello Lisa! //reference error, because no valid import
Hello Maggy! //reference error, because name was overwritten with 'Baby'
Hello Homer!
Hello Marge!
Hello Baby!
```

<details>
<summary>Full Implementation</summary>

```ts
import { AstNode, AstNodeDescription, AstUtils, DefaultScopeComputation, DefaultScopeProvider, EMPTY_SCOPE, LangiumDocument, ReferenceInfo, Scope } from "langium";
import { CancellationToken } from "vscode-languageclient";
import { HelloWorldAstType, Model, Person } from "./generated/ast.js";
import { dirname, join } from "node:path";

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))
;
}
}

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;
}

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;
//get URI of current document
const currentUri = document.uri;
//get folder of current document
const currentDir = dirname(currentUri.path);
const uris = new Set<string>();
//for all file imports of the current file
for (const fileImport of model.fileImports) {
//resolve the file name relatively to the current file
const filePath = join(currentDir, fileImport.file);
//create back an URI
const uri = currentUri.with({ path: filePath });
//add the URI to URI list
uris.add(uri.toString());
}
//get all possible persons from these files
const astNodeDescriptions = this.indexManager.allElements(Person, uris).toArray();
//convert them to descriptions inside of a scope
return this.createScope(astNodeDescriptions);
}

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);
}
}
```

</details>
Loading