Skip to content

Commit 68c0fa3

Browse files
authored
add support for languages with specific file names (#1873)
* add support for languages with specific file names * add comments, fix typo and reverts message changes
1 parent 89c0a9d commit 68c0fa3

File tree

10 files changed

+81
-20
lines changed

10 files changed

+81
-20
lines changed

packages/langium-cli/langium-config-schema.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@
5656
}
5757
]
5858
},
59+
"fileNames": {
60+
"description": "The file names used by the DSL",
61+
"type": "array",
62+
"items": [
63+
{
64+
"type": "string"
65+
}
66+
]
67+
},
5968
"caseInsensitive": {
6069
"description": "Enable case-insensitive keywords parsing",
6170
"type": "boolean"

packages/langium-cli/src/generator/module-generator.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export function generateModule(grammars: Grammar[], config: LangiumConfig, gramm
4848
export const ${ grammar.name }LanguageMetaData = {
4949
languageId: '${config.id}',
5050
fileExtensions: [${config.fileExtensions && joinToNode(config.fileExtensions, e => appendQuotesAndDot(e), { separator: ', ' })}],
51+
${config.fileNames ? `fileNames: [${config.fileNames.map(name => `'${name}'`).join(', ')}],` : undefined}
5152
caseInsensitive: ${Boolean(config.caseInsensitive)},
5253
mode: '${modeValue}'
5354
} as const satisfies LanguageMetaData;

packages/langium-cli/src/package-types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export interface LangiumLanguageConfig {
3939
grammar: string
4040
/** File extensions with leading `.` */
4141
fileExtensions?: string[]
42+
/** File names */
43+
fileNames?: string[]
4244
/** Enable case-insensitive keywords parsing */
4345
caseInsensitive?: boolean
4446
/** Enable generating a TextMate syntax highlighting file */

packages/langium-cli/src/parser-validation.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ function languageConfigToMetaData(config: LangiumLanguageConfig): LanguageMetaDa
5959
return {
6060
languageId: config.id,
6161
fileExtensions: config.fileExtensions ?? [],
62+
fileNames: config.fileNames,
6263
caseInsensitive: Boolean(config.caseInsensitive),
6364
mode: 'development'
6465
};

packages/langium-vscode/src/language-server/grammar-workspace-manager.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import type { Ignore } from 'ignore';
88
import ignore from 'ignore';
9-
import type { ConfigurationProvider, FileSystemNode, WorkspaceFolder } from 'langium';
9+
import type { ConfigurationProvider, FileSelector, FileSystemNode, WorkspaceFolder } from 'langium';
1010
import { Cancellation, DefaultWorkspaceManager, URI, UriUtils } from 'langium';
1111
import type { LangiumSharedServices } from 'langium/lsp';
1212
import * as path from 'path';
@@ -52,14 +52,15 @@ export class LangiumGrammarWorkspaceManager extends DefaultWorkspaceManager {
5252
return super.initializeWorkspace(folders, cancelToken);
5353
}
5454

55-
protected override includeEntry(workspaceFolder: WorkspaceFolder, entry: FileSystemNode, fileExtensions: string[]): boolean {
55+
protected override includeEntry(workspaceFolder: WorkspaceFolder, entry: FileSystemNode, selector: FileSelector): boolean {
5656
if (this.matcher) {
5757
// create path relative to workspace folder root: /user/foo/workspace/entry.txt -> entry.txt
5858
const relPath = path.relative(URI.parse(workspaceFolder.uri).path, entry.uri.path);
5959
const ignored = this.matcher.ignores(relPath);
60-
return !ignored && (entry.isDirectory || (entry.isFile && fileExtensions.includes(UriUtils.extname(entry.uri))));
60+
return !ignored && (entry.isDirectory || (entry.isFile && (selector.fileExtensions.includes(UriUtils.extname(entry.uri)) ||
61+
selector.fileNames.includes(UriUtils.basename(entry.uri)))));
6162
}
62-
return super.includeEntry(workspaceFolder, entry, fileExtensions);
63+
return super.includeEntry(workspaceFolder, entry, selector);
6364
}
6465

6566
}

packages/langium/src/languages/language-meta-data.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
export interface LanguageMetaData {
1111
languageId: string;
1212
fileExtensions: readonly string[];
13+
fileNames?: readonly string[];
1314
caseInsensitive: boolean;
1415
/**
1516
* Mode used to optimize code for development or production environments.

packages/langium/src/lsp/document-update-handler.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* terms of the MIT License, which is available in the project root.
55
******************************************************************************/
66

7-
import type { TextDocumentWillSaveEvent, DidChangeWatchedFilesParams, DidChangeWatchedFilesRegistrationOptions, TextDocumentChangeEvent, TextEdit } from 'vscode-languageserver';
7+
import type { TextDocumentWillSaveEvent, DidChangeWatchedFilesParams, DidChangeWatchedFilesRegistrationOptions, TextDocumentChangeEvent, TextEdit, FileSystemWatcher } from 'vscode-languageserver';
88
import { DidChangeWatchedFilesNotification, FileChangeType } from 'vscode-languageserver';
99
import { stream } from '../utils/stream.js';
1010
import { URI } from '../utils/uri-utils.js';
@@ -92,20 +92,35 @@ export class DefaultDocumentUpdateHandler implements DocumentUpdateHandler {
9292
}
9393

9494
protected registerFileWatcher(services: LangiumSharedServices): void {
95+
const watchers: FileSystemWatcher[] = [];
96+
// extensions
9597
const fileExtensions = stream(services.ServiceRegistry.all)
9698
.flatMap(language => language.LanguageMetaData.fileExtensions)
9799
.map(ext => ext.startsWith('.') ? ext.substring(1) : ext)
98100
.distinct()
99101
.toArray();
100102
if (fileExtensions.length > 0) {
103+
watchers.push({
104+
globPattern: fileExtensions.length === 1
105+
? `**/*.${fileExtensions[0]}`
106+
: `**/*.{${fileExtensions.join(',')}}`
107+
});
108+
}
109+
// filenames
110+
const fileNames = stream(services.ServiceRegistry.all)
111+
.flatMap(language => language.LanguageMetaData.fileNames ?? [])
112+
.distinct()
113+
.toArray();
114+
if (fileNames.length > 0) {
115+
watchers.push({
116+
globPattern: fileNames.length === 1
117+
? `**/${fileNames[0]}`
118+
: `**/{${fileNames.join(',')}}`
119+
});
120+
}
121+
if (watchers.length > 0) {
101122
const connection = services.lsp.Connection;
102-
const options: DidChangeWatchedFilesRegistrationOptions = {
103-
watchers: [{
104-
globPattern: fileExtensions.length === 1
105-
? `**/*.${fileExtensions[0]}`
106-
: `**/*.{${fileExtensions.join(',')}}`
107-
}]
108-
};
123+
const options: DidChangeWatchedFilesRegistrationOptions = { watchers };
109124
connection?.client.register(DidChangeWatchedFilesNotification.type, options);
110125
}
111126
}

packages/langium/src/service-registry.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export class DefaultServiceRegistry implements ServiceRegistry {
4444
protected singleton?: LangiumCoreServices;
4545
protected readonly languageIdMap = new Map<string, LangiumCoreServices>();
4646
protected readonly fileExtensionMap = new Map<string, LangiumCoreServices>();
47+
protected readonly fileNameMap = new Map<string, LangiumCoreServices>();
4748

4849
/**
4950
* @deprecated Use the new `fileExtensionMap` (or `languageIdMap`) property instead.
@@ -66,6 +67,14 @@ export class DefaultServiceRegistry implements ServiceRegistry {
6667
}
6768
this.fileExtensionMap.set(ext, language);
6869
}
70+
if (data.fileNames) {
71+
for (const name of data.fileNames) {
72+
if (this.fileNameMap.has(name)) {
73+
console.warn(`The file name ${name} is used by multiple languages. It is now assigned to '${data.languageId}'.`);
74+
}
75+
this.fileNameMap.set(name, language);
76+
}
77+
}
6978
this.languageIdMap.set(data.languageId, language);
7079
if (this.languageIdMap.size === 1) {
7180
this.singleton = language;
@@ -89,7 +98,9 @@ export class DefaultServiceRegistry implements ServiceRegistry {
8998
}
9099
}
91100
const ext = UriUtils.extname(uri);
92-
const services = this.fileExtensionMap.get(ext);
101+
const name = UriUtils.basename(uri);
102+
const services = this.fileNameMap.get(name) ?? this.fileExtensionMap.get(ext);
103+
93104
if (!services) {
94105
if (languageId) {
95106
throw new Error(`The service registry contains no services for the extension '${ext}' for language '${languageId}'.`);

packages/langium/src/workspace/workspace-manager.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ export interface WorkspaceManager {
6666
initializeWorkspace(folders: WorkspaceFolder[], cancelToken?: CancellationToken): Promise<void>;
6767

6868
}
69+
/**
70+
* The FileSelector provides file names and extensions used by this extension.
71+
*/
72+
export interface FileSelector {
73+
/** Allowed file extensions (e.g., ["ts", "js"]). */
74+
fileExtensions: string[];
75+
/** Allowed file names (e.g., ["config", "settings"]). */
76+
fileNames: string[];
77+
}
6978

7079
export class DefaultWorkspaceManager implements WorkspaceManager {
7180

@@ -119,6 +128,7 @@ export class DefaultWorkspaceManager implements WorkspaceManager {
119128
*/
120129
protected async performStartup(folders: WorkspaceFolder[]): Promise<LangiumDocument[]> {
121130
const fileExtensions = this.serviceRegistry.all.flatMap(e => e.LanguageMetaData.fileExtensions);
131+
const fileNames = this.serviceRegistry.all.flatMap(e => e.LanguageMetaData.fileNames ?? []);
122132
const documents: LangiumDocument[] = [];
123133
const collector = (document: LangiumDocument) => {
124134
documents.push(document);
@@ -132,7 +142,7 @@ export class DefaultWorkspaceManager implements WorkspaceManager {
132142
await this.loadAdditionalDocuments(folders, collector);
133143
await Promise.all(
134144
folders.map(wf => [wf, this.getRootFolder(wf)] as [WorkspaceFolder, URI])
135-
.map(async entry => this.traverseFolder(...entry, fileExtensions, collector))
145+
.map(async entry => this.traverseFolder(...entry, {fileExtensions, fileNames}, collector))
136146
);
137147
this._ready.resolve();
138148
return documents;
@@ -160,12 +170,12 @@ export class DefaultWorkspaceManager implements WorkspaceManager {
160170
* Traverse the file system folder identified by the given URI and its subfolders. All
161171
* contained files that match the file extensions are added to the collector.
162172
*/
163-
protected async traverseFolder(workspaceFolder: WorkspaceFolder, folderPath: URI, fileExtensions: string[], collector: (document: LangiumDocument) => void): Promise<void> {
173+
protected async traverseFolder(workspaceFolder: WorkspaceFolder, folderPath: URI, selector: FileSelector, collector: (document: LangiumDocument) => void): Promise<void> {
164174
const content = await this.fileSystemProvider.readDirectory(folderPath);
165175
await Promise.all(content.map(async entry => {
166-
if (this.includeEntry(workspaceFolder, entry, fileExtensions)) {
176+
if (this.includeEntry(workspaceFolder, entry, selector)) {
167177
if (entry.isDirectory) {
168-
await this.traverseFolder(workspaceFolder, entry.uri, fileExtensions, collector);
178+
await this.traverseFolder(workspaceFolder, entry.uri, selector, collector);
169179
} else if (entry.isFile) {
170180
const document = await this.langiumDocuments.getOrCreateDocument(entry.uri);
171181
collector(document);
@@ -177,16 +187,16 @@ export class DefaultWorkspaceManager implements WorkspaceManager {
177187
/**
178188
* Determine whether the given folder entry shall be included while indexing the workspace.
179189
*/
180-
protected includeEntry(_workspaceFolder: WorkspaceFolder, entry: FileSystemNode, fileExtensions: string[]): boolean {
190+
protected includeEntry(_workspaceFolder: WorkspaceFolder, entry: FileSystemNode, selector: FileSelector): boolean {
181191
const name = UriUtils.basename(entry.uri);
182192
if (name.startsWith('.')) {
183193
return false;
184194
}
185195
if (entry.isDirectory) {
186196
return name !== 'node_modules' && name !== 'out';
187197
} else if (entry.isFile) {
188-
const extname = UriUtils.extname(entry.uri);
189-
return fileExtensions.includes(extname);
198+
return selector.fileExtensions.includes(UriUtils.extname(entry.uri)) ||
199+
selector.fileNames.includes(UriUtils.basename(entry.uri));
190200
}
191201
return false;
192202
}

packages/langium/test/service-registry.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@ describe('DefaultServiceRegistry', () => {
4141
expect(registry.getServices(URI.parse('file:/test.y'))).toBe(language2);
4242
expect(registry.all).toHaveLength(2);
4343
});
44+
test('a file name has a higher priority than an extension', () => {
45+
const language1: LangiumCoreServices = { LanguageMetaData: { fileExtensions: [], fileNames: ['test.x'], languageId: 'foo' } } as any;
46+
const language2: LangiumCoreServices = { LanguageMetaData: { fileExtensions: ['.x'], languageId: 'bar' } } as any;
47+
const registry = new DefaultServiceRegistry(createSharedCoreServices());
48+
registry.register(language1);
49+
registry.register(language2);
50+
expect(registry.getServices(URI.parse('file:/test.x'))).toBe(language1);
51+
expect(registry.getServices(URI.parse('file:/other.x'))).toBe(language2);
52+
expect(registry.all).toHaveLength(2);
53+
});
4454

4555
function createSharedCoreServices(id?: string): LangiumSharedCoreServices {
4656
const textDocumentsModule: Module<LangiumSharedCoreServices, PartialLangiumSharedCoreServices> = {

0 commit comments

Comments
 (0)