Skip to content

Commit 96bfa0e

Browse files
authored
Recipe: File-based symbol import/export (#239)
1 parent 061b8f5 commit 96bfa0e

File tree

2 files changed

+296
-1
lines changed

2 files changed

+296
-1
lines changed

hugo/content/docs/recipes/scoping/_index.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,11 @@ In general, the way we resolve references is split into three phases of the docu
2323
- [Scope computation](/docs/reference/document-lifecycle#computing-scopes) determines which elements are reachable from a given position in your document.
2424
- Finally, the [linking phase](/docs/reference/document-lifecycle#linking) eagerly links each reference within a document to its target using your language's scoping rules.
2525

26-
In this guide, we'll look at different scoping kinds and styles and see how we can achieve them using Langium:
26+
In this recipe, we'll look at different scoping kinds and styles and see how we can achieve them using Langium:
2727

2828
1. [Qualified Name Scoping](/docs/recipes/scoping/qualified-name)
2929
2. [Class Member Scoping](/docs/recipes/scoping/class-member)
30+
3. [File-based scoping](/docs/recipes/scoping/file-based)
3031

3132
Note that these are just example implementations for commonly used scoping methods.
3233
The scoping API of Langium is designed to be flexible and extensible for any kind of use case.
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
---
2+
title: "File-based scoping"
3+
weight: 300
4+
---
5+
6+
## Goal
7+
8+
By default, Langium will always expose all top-level AST elements to the global scope. That means they are visible to all other documents in your workspace. However, a lot of languages are better served with a JavaScript-like `import`/`export` mechanism:
9+
10+
* Using `export` makes a symbol from the current file available for referencing from another file.
11+
* Using `import` allows to reference symbols for a different file.
12+
13+
To make things easier I will modify the "Hello World" example from the [learning section](/docs/learn/workflow).
14+
15+
## Step 1: Change the grammar
16+
17+
First off, we are changing the grammar to support the `export` and the `import` statements. Let's take a look at the modified grammar:
18+
19+
```langium
20+
grammar HelloWorld
21+
22+
entry Model:
23+
(
24+
fileImports+=FileImport //NEW: imports per file
25+
| persons+=Person
26+
| greetings+=Greeting
27+
)*;
28+
29+
FileImport: //NEW: imports of the same file are gathered in a list
30+
'import' '{'
31+
personImports+=PersonImport (',' personImports+=PersonImport)*
32+
'}' 'from' file=STRING;
33+
34+
PersonImport:
35+
person=[Person:ID] ('as' name=ID)?;
36+
37+
Person:
38+
published?='export'? 'person' name=ID; //NEW: export keyword
39+
40+
type Greetable = PersonImport | Person
41+
42+
Greeting:
43+
'Hello' person=[Greetable:ID] '!';
44+
45+
hidden terminal WS: /\s+/;
46+
terminal ID: /[_a-zA-Z][\w_]*/;
47+
terminal STRING: /"(\\.|[^"\\])*"|'(\\.|[^'\\])*'/;
48+
49+
hidden terminal ML_COMMENT: /\/\*[\s\S]*?\*\//;
50+
hidden terminal SL_COMMENT: /\/\/[^\n\r]*/;
51+
```
52+
53+
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:
54+
55+
```bash
56+
npm run langium:generate
57+
```
58+
59+
## Step 2: Exporting persons to the global scope
60+
61+
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:
62+
63+
```typescript
64+
export class HelloWorldScopeComputation extends DefaultScopeComputation {
65+
override async computeExports(document: LangiumDocument<AstNode>): Promise<AstNodeDescription[]> {
66+
const model = document.parseResult.value as Model;
67+
return model.persons
68+
.filter(p => p.published)
69+
.map(p => this.descriptions.createDescription(p, p.name));
70+
}
71+
}
72+
```
73+
74+
After that, you need to register the `HelloWorldScopeComputation` in the `HelloWorldModule`:
75+
76+
```typescript
77+
export const HelloWorldModule: Module<HelloWorldServices, PartialLangiumServices & HelloWorldAddedServices> = {
78+
//...
79+
references: {
80+
ScopeComputation: (services) => new HelloWorldScopeComputation(services)
81+
}
82+
};
83+
```
84+
85+
Having done this, will make all persons that are marked with the `export` keyword available to the other files through the index manager.
86+
87+
## Step 3: Importing from specific files
88+
89+
The final step is to adjust the cross-reference resolution by overriding the `DefaultScopeProvider.getScope(…)` function:
90+
91+
```typescript
92+
export class HelloWorldScopeProvider extends DefaultScopeProvider {
93+
override getScope(context: ReferenceInfo): Scope {
94+
switch(context.container.$type as keyof HelloWorldAstType) {
95+
case 'PersonImport':
96+
if(context.property === 'person') {
97+
return this.getExportedPersonsFromGlobalScope(context);
98+
}
99+
break;
100+
case 'Greeting':
101+
if(context.property === 'person') {
102+
return this.getImportedPersonsFromCurrentFile(context);
103+
}
104+
break;
105+
}
106+
return EMPTY_SCOPE;
107+
}
108+
//...
109+
}
110+
```
111+
112+
Do not forget to add the new service to the `HelloWorldModule`:
113+
114+
```typescript
115+
export const HelloWorldModule: Module<HelloWorldServices, PartialLangiumServices & HelloWorldAddedServices> = {
116+
//...
117+
references: {
118+
ScopeComputation: (services) => new HelloWorldScopeComputation(services),
119+
ScopeProvider: (services) => new HelloWorldScopeProvider(services) //NEW!
120+
}
121+
};
122+
```
123+
124+
You noticed the two missing functions? Here is what they have to do.
125+
126+
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.
127+
128+
```typescript
129+
private getExportedPersonsFromGlobalScope(context: ReferenceInfo): Scope {
130+
//get document for current reference
131+
const document = AstUtils.getDocument(context.container);
132+
//get model of document
133+
const model = document.parseResult.value as Model;
134+
//get URI of current document
135+
const currentUri = document.uri;
136+
//get folder of current document
137+
const currentDir = dirname(currentUri.path);
138+
const uris = new Set<string>();
139+
//for all file imports of the current file
140+
for (const fileImport of model.fileImports) {
141+
//resolve the file name relatively to the current file
142+
const filePath = join(currentDir, fileImport.file);
143+
//create back an URI
144+
const uri = currentUri.with({ path: filePath });
145+
//add the URI to URI list
146+
uris.add(uri.toString());
147+
}
148+
//get all possible persons from these files
149+
const astNodeDescriptions = this.indexManager.allElements(Person, uris).toArray();
150+
//convert them to descriptions inside of a scope
151+
return this.createScope(astNodeDescriptions);
152+
}
153+
```
154+
155+
The second function (`getImportedPersonsFromCurrentFile(context)`) will take a look at the current file and return all persons that are imported from other files.
156+
157+
```typescript
158+
private getImportedPersonsFromCurrentFile(context: ReferenceInfo) {
159+
//get current document of reference
160+
const document = AstUtils.getDocument(context.container);
161+
//get current model
162+
const model = document.parseResult.value as Model;
163+
//go through all imports
164+
const descriptions = model.fileImports.flatMap(fi => fi.personImports.map(pi => {
165+
//if the import is name, return the import
166+
if (pi.name) {
167+
return this.descriptions.createDescription(pi, pi.name);
168+
}
169+
//if import references to a person, return that person
170+
if (pi.person.ref) {
171+
return this.descriptions.createDescription(pi.person.ref, pi.person.ref.name);
172+
}
173+
//otherwise return nothing
174+
return undefined;
175+
}).filter(d => d != undefined)).map(d => d!);
176+
return this.createScope(descriptions);
177+
}
178+
```
179+
180+
## Result
181+
182+
Now, let's test the editor by `npm run build` and starting the extension.
183+
Try using these two files. The first file contains the Simpsons family.
184+
185+
```plain
186+
export person Homer
187+
export person Marge
188+
person Bart
189+
person Lisa
190+
export person Maggy
191+
```
192+
193+
The second file tries to import and greet them.
194+
195+
```plain
196+
import {
197+
Marge,
198+
Homer,
199+
Lisa, //reference error, because not exported
200+
Maggy as Baby
201+
} from "persons.hello"
202+
203+
Hello Lisa! //reference error, because no valid import
204+
Hello Maggy! //reference error, because name was overwritten with 'Baby'
205+
Hello Homer!
206+
Hello Marge!
207+
Hello Baby!
208+
```
209+
210+
<details>
211+
<summary>Full Implementation</summary>
212+
213+
```ts
214+
import { AstNode, AstNodeDescription, AstUtils, DefaultScopeComputation, DefaultScopeProvider, EMPTY_SCOPE, LangiumDocument, ReferenceInfo, Scope } from "langium";
215+
import { CancellationToken } from "vscode-languageclient";
216+
import { HelloWorldAstType, Model, Person } from "./generated/ast.js";
217+
import { dirname, join } from "node:path";
218+
219+
export class HelloWorldScopeComputation extends DefaultScopeComputation {
220+
override async computeExports(document: LangiumDocument<AstNode>): Promise<AstNodeDescription[]> {
221+
const model = document.parseResult.value as Model;
222+
return model.persons
223+
.filter(p => p.published)
224+
.map(p => this.descriptions.createDescription(p, p.name))
225+
;
226+
}
227+
}
228+
229+
export class HelloWorldScopeProvider extends DefaultScopeProvider {
230+
override getScope(context: ReferenceInfo): Scope {
231+
switch(context.container.$type as keyof HelloWorldAstType) {
232+
case 'PersonImport':
233+
if(context.property === 'person') {
234+
return this.getExportedPersonsFromGlobalScope(context);
235+
}
236+
break;
237+
case 'Greeting':
238+
if(context.property === 'person') {
239+
return this.getImportedPersonsFromCurrentFile(context);
240+
}
241+
break;
242+
}
243+
return EMPTY_SCOPE;
244+
}
245+
246+
protected getExportedPersonsFromGlobalScope(context: ReferenceInfo): Scope {
247+
//get document for current reference
248+
const document = AstUtils.getDocument(context.container);
249+
//get model of document
250+
const model = document.parseResult.value as Model;
251+
//get URI of current document
252+
const currentUri = document.uri;
253+
//get folder of current document
254+
const currentDir = dirname(currentUri.path);
255+
const uris = new Set<string>();
256+
//for all file imports of the current file
257+
for (const fileImport of model.fileImports) {
258+
//resolve the file name relatively to the current file
259+
const filePath = join(currentDir, fileImport.file);
260+
//create back an URI
261+
const uri = currentUri.with({ path: filePath });
262+
//add the URI to URI list
263+
uris.add(uri.toString());
264+
}
265+
//get all possible persons from these files
266+
const astNodeDescriptions = this.indexManager.allElements(Person, uris).toArray();
267+
//convert them to descriptions inside of a scope
268+
return this.createScope(astNodeDescriptions);
269+
}
270+
271+
private getImportedPersonsFromCurrentFile(context: ReferenceInfo) {
272+
//get current document of reference
273+
const document = AstUtils.getDocument(context.container);
274+
//get current model
275+
const model = document.parseResult.value as Model;
276+
//go through all imports
277+
const descriptions = model.fileImports.flatMap(fi => fi.personImports.map(pi => {
278+
//if the import is name, return the import
279+
if (pi.name) {
280+
return this.descriptions.createDescription(pi, pi.name);
281+
}
282+
//if import references to a person, return that person
283+
if (pi.person.ref) {
284+
return this.descriptions.createDescription(pi.person.ref, pi.person.ref.name);
285+
}
286+
//otherwise return nothing
287+
return undefined;
288+
}).filter(d => d != undefined)).map(d => d!);
289+
return this.createScope(descriptions);
290+
}
291+
}
292+
```
293+
294+
</details>

0 commit comments

Comments
 (0)