microsoft/typespec

Public

mirrored from https://github.com/microsoft/typespecAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
e966efd30e552f4e84d5ae7224515199738ddd8e

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

packages/compiler/server/serverlib.ts

789lines · modecode

1import { TextDocument } from "vscode-languageserver-textdocument";
2import {
3 CompletionItemKind,
4 CompletionList,
5 CompletionParams,
6 DefinitionParams,
7 Diagnostic as VSDiagnostic,
8 DiagnosticSeverity,
9 DidChangeWatchedFilesParams,
10 InitializedParams,
11 InitializeParams,
12 InitializeResult,
13 Location,
14 PrepareRenameParams,
15 PublishDiagnosticsParams,
16 Range,
17 ReferenceParams,
18 RenameParams,
19 ServerCapabilities,
20 TextDocumentChangeEvent,
21 TextDocumentIdentifier,
22 TextDocumentSyncKind,
23 TextEdit,
24 WorkspaceEdit,
25 WorkspaceFolder,
26 WorkspaceFoldersChangeEvent,
27} from "vscode-languageserver/node.js";
28import { loadCadlConfigForPath } from "../config/config-loader.js";
29import {
30 compilerAssert,
31 createSourceFile,
32 formatDiagnostic,
33 getSourceLocation,
34} from "../core/diagnostics.js";
35import { CompilerOptions } from "../core/options.js";
36import { getNodeAtPosition, visitChildren } from "../core/parser.js";
37import {
38 ensureTrailingDirectorySeparator,
39 getDirectoryPath,
40 joinPaths,
41 resolvePath,
42} from "../core/path-utils.js";
43import { createProgram, Program } from "../core/program.js";
44import {
45 CadlScriptNode,
46 CompilerHost,
47 Diagnostic as CadlDiagnostic,
48 DiagnosticTarget,
49 IdentifierNode,
50 SourceFile,
51 SymbolFlags,
52 SyntaxKind,
53 Type,
54} from "../core/types.js";
55import { doIO, loadFile } from "../core/util.js";
56import { getDoc, isIntrinsic } from "../lib/decorators.js";
57
58export interface ServerHost {
59 compilerHost: CompilerHost;
60 getDocumentByURL(url: string): TextDocument | undefined;
61 sendDiagnostics(params: PublishDiagnosticsParams): void;
62 log(message: string): void;
63}
64
65export interface Server {
66 readonly pendingMessages: readonly string[];
67 readonly workspaceFolders: readonly ServerWorkspaceFolder[];
68 initialize(params: InitializeParams): InitializeResult;
69 initialized(params: InitializedParams): void;
70 workspaceFoldersChanged(e: WorkspaceFoldersChangeEvent): void;
71 watchedFilesChanged(params: DidChangeWatchedFilesParams): void;
72 gotoDefinition(params: DefinitionParams): Promise<Location[]>;
73 complete(params: CompletionParams): Promise<CompletionList>;
74 findReferences(params: ReferenceParams): Promise<Location[]>;
75 prepareRename(params: PrepareRenameParams): Promise<Range | undefined>;
76 rename(params: RenameParams): Promise<WorkspaceEdit>;
77 checkChange(change: TextDocumentChangeEvent<TextDocument>): Promise<void>;
78 documentClosed(change: TextDocumentChangeEvent<TextDocument>): void;
79 log(message: string, details?: any): void;
80}
81
82export interface ServerSourceFile extends SourceFile {
83 // Keep track of the open doucment (if any) associated with a source file.
84 readonly document?: TextDocument;
85}
86
87export interface ServerWorkspaceFolder extends WorkspaceFolder {
88 // Remember path to URL conversion for workspace folders. This path must
89 // be resolved and normalized as other paths and have a trailing separator
90 // character so that we can test if a path is within a workspace using
91 // startsWith.
92 path: string;
93}
94
95interface CachedFile {
96 type: "file";
97 file: SourceFile;
98
99 // Cache additional data beyond the raw text of the source file. Currently
100 // used only for JSON.parse result of package.json.
101 data?: any;
102}
103
104interface CachedError {
105 type: "error";
106 error: unknown;
107 data?: any;
108}
109
110interface KeywordArea {
111 root?: boolean;
112 namespace?: boolean;
113 model?: boolean;
114 identifier?: boolean;
115}
116
117const serverOptions: CompilerOptions = {
118 noEmit: true,
119 designTimeBuild: true,
120};
121
122const keywords = [
123 // Root only
124 ["import", { root: true }],
125
126 // Root and namespace
127 ["using", { root: true, namespace: true }],
128 ["model", { root: true, namespace: true }],
129 ["namespace", { root: true, namespace: true }],
130 ["interface", { root: true, namespace: true }],
131 ["union", { root: true, namespace: true }],
132 ["enum", { root: true, namespace: true }],
133 ["alias", { root: true, namespace: true }],
134 ["op", { root: true, namespace: true }],
135
136 // On model `model Foo <keyword> ...`
137 ["extends", { model: true }],
138 ["is", { model: true }],
139
140 // On identifier`
141 ["true", { identifier: true }],
142 ["false", { identifier: true }],
143] as const;
144
145export function createServer(host: ServerHost): Server {
146 // Remember original URL when we convert it to a local path so that we can
147 // get it back. We can't convert it back because things like URL-encoding
148 // could give us back an equivalent but non-identical URL but the original
149 // URL is used as a key into the opened documents and so we must reproduce
150 // it exactly.
151 const pathToURLMap: Map<string, string> = new Map();
152
153 // Cache all file I/O. Only open documents are sent over the LSP pipe. When
154 // the compiler reads a file that isn't open, we use this cache to avoid
155 // hitting the disk. Entries are invalidated when LSP client notifies us of
156 // a file change.
157 const fileSystemCache = new Map<string, CachedFile | CachedError>();
158
159 const compilerHost: CompilerHost = {
160 ...host.compilerHost,
161 readFile,
162 stat,
163 };
164
165 let workspaceFolders: ServerWorkspaceFolder[] = [];
166 let isInitialized = false;
167 let pendingMessages: string[] = [];
168
169 return {
170 get pendingMessages() {
171 return pendingMessages;
172 },
173 get workspaceFolders() {
174 return workspaceFolders;
175 },
176 initialize,
177 initialized,
178 workspaceFoldersChanged,
179 watchedFilesChanged,
180 gotoDefinition,
181 documentClosed,
182 complete,
183 findReferences,
184 prepareRename,
185 rename,
186 checkChange,
187 log,
188 };
189
190 function initialize(params: InitializeParams): InitializeResult {
191 const capabilities: ServerCapabilities = {
192 textDocumentSync: TextDocumentSyncKind.Incremental,
193 definitionProvider: true,
194 completionProvider: {
195 resolveProvider: false,
196 triggerCharacters: [".", "@"],
197 allCommitCharacters: [".", ",", ";", "("],
198 },
199 referencesProvider: true,
200 renameProvider: {
201 prepareProvider: true,
202 },
203 };
204
205 if (params.capabilities.workspace?.workspaceFolders) {
206 workspaceFolders =
207 params.workspaceFolders?.map((w) => ({
208 ...w,
209 path: ensureTrailingDirectorySeparator(resolvePath(compilerHost.fileURLToPath(w.uri))),
210 })) ?? [];
211 capabilities.workspace = {
212 workspaceFolders: {
213 supported: true,
214 changeNotifications: true,
215 },
216 };
217 } else if (params.rootUri) {
218 workspaceFolders = [
219 {
220 name: "<root>",
221 uri: params.rootUri,
222 path: ensureTrailingDirectorySeparator(
223 resolvePath(compilerHost.fileURLToPath(params.rootUri))
224 ),
225 },
226 ];
227 } else if (params.rootPath) {
228 workspaceFolders = [
229 {
230 name: "<root>",
231 uri: compilerHost.pathToFileURL(params.rootPath),
232 path: ensureTrailingDirectorySeparator(resolvePath(params.rootPath)),
233 },
234 ];
235 }
236
237 log("Workspace Folders", workspaceFolders);
238 return { capabilities };
239 }
240
241 function initialized(params: InitializedParams): void {
242 isInitialized = true;
243 log("Initialization complete.");
244 }
245
246 function workspaceFoldersChanged(e: WorkspaceFoldersChangeEvent) {
247 log("Workspace Folders Changed", e);
248 const map = new Map(workspaceFolders.map((f) => [f.uri, f]));
249 for (const folder of e.removed) {
250 map.delete(folder.uri);
251 }
252 for (const folder of e.added) {
253 map.set(folder.uri, { ...folder, path: compilerHost.fileURLToPath(folder.uri) });
254 }
255 workspaceFolders = Array.from(map.values());
256 log("Workspace Folders", workspaceFolders);
257 }
258
259 function watchedFilesChanged(params: DidChangeWatchedFilesParams) {
260 // remove stale file system cache entries on file change notification
261 for (const each of params.changes) {
262 if (each.uri.startsWith("file:")) {
263 const path = compilerHost.fileURLToPath(each.uri);
264 fileSystemCache.delete(path);
265 }
266 }
267 }
268
269 type CompileCallback<T> = (
270 program: Program,
271 document: TextDocument,
272 script: CadlScriptNode
273 ) => (T | undefined) | Promise<T | undefined>;
274
275 async function compile(
276 document: TextDocument | TextDocumentIdentifier
277 ): Promise<Program | undefined>;
278
279 async function compile<T>(
280 document: TextDocument | TextDocumentIdentifier,
281 callback: CompileCallback<T>
282 ): Promise<T | undefined>;
283
284 async function compile<T>(
285 document: TextDocument | TextDocumentIdentifier,
286 callback?: CompileCallback<T>
287 ): Promise<T | Program | undefined> {
288 const path = getPath(document);
289 const mainFile = await getMainFileForDocument(path);
290 const config = await loadCadlConfigForPath(compilerHost, mainFile);
291
292 const options = {
293 ...serverOptions,
294 emitters: config.emitters ? Object.keys(config.emitters) : [],
295 };
296
297 if (!upToDate(document)) {
298 return undefined;
299 }
300
301 let program: Program;
302 try {
303 program = await createProgram(compilerHost, mainFile, options);
304 if (!upToDate(document)) {
305 return undefined;
306 }
307
308 if (mainFile !== path && !program.sourceFiles.has(path)) {
309 // If the file that changed wasn't imported by anything from the main
310 // file, retry using the file itself as the main file.
311 program = await createProgram(compilerHost, path, options);
312 }
313
314 if (!upToDate(document)) {
315 return undefined;
316 }
317
318 if (callback) {
319 const doc = "version" in document ? document : host.getDocumentByURL(document.uri);
320 compilerAssert(doc, "Failed to get document.");
321 const path = getPath(doc);
322 const script = program.sourceFiles.get(path);
323 compilerAssert(script, "Failed to get script.");
324 return await callback(program, doc, script);
325 }
326
327 return program;
328 } catch (err: any) {
329 host.sendDiagnostics({
330 uri: document.uri,
331 diagnostics: [
332 {
333 severity: DiagnosticSeverity.Error,
334 range: Range.create(0, 0, 0, 0),
335 message:
336 `Internal compiler error!\nFile issue at https://github.com/microsoft/cadl\n\n` +
337 err.stack,
338 },
339 ],
340 });
341
342 return undefined;
343 }
344 }
345
346 async function checkChange(change: TextDocumentChangeEvent<TextDocument>) {
347 const program = await compile(change.document);
348 if (!program) {
349 return;
350 }
351
352 // Group diagnostics by file.
353 //
354 // Initialize diagnostics for all source files in program to empty array
355 // as we must send an empty array when a file has no diagnostics or else
356 // stale diagnostics from a previous run will stick around in the IDE.
357 //
358 const diagnosticMap: Map<TextDocument, VSDiagnostic[]> = new Map();
359 diagnosticMap.set(change.document, []);
360 for (const each of program.sourceFiles.values()) {
361 const document = (each.file as ServerSourceFile)?.document;
362 if (document) {
363 diagnosticMap.set(document, []);
364 }
365 }
366
367 for (const each of program.diagnostics) {
368 let document: TextDocument | undefined;
369
370 const location = getSourceLocation(each.target);
371 if (location?.file) {
372 document = (location.file as ServerSourceFile).document;
373 } else {
374 // https://github.com/Microsoft/language-server-protocol/issues/256
375 //
376 // LSP does not currently allow sending a diagnostic with no location so
377 // we report diagnostics with no location on the document that changed to
378 // trigger.
379 document = change.document;
380 }
381
382 if (!document || !upToDate(document)) {
383 continue;
384 }
385
386 const start = document.positionAt(location?.pos ?? 0);
387 const end = document.positionAt(location?.end ?? 0);
388 const range = Range.create(start, end);
389 const severity = convertSeverity(each.severity);
390 const diagnostic = VSDiagnostic.create(range, each.message, severity, each.code, "Cadl");
391 const diagnostics = diagnosticMap.get(document);
392 compilerAssert(
393 diagnostics,
394 "Diagnostic reported against a source file that was not added to the program."
395 );
396 diagnostics.push(diagnostic);
397 }
398
399 for (const [document, diagnostics] of diagnosticMap) {
400 sendDiagnostics(document, diagnostics);
401 }
402 }
403
404 async function gotoDefinition(params: DefinitionParams): Promise<Location[]> {
405 const sym = await compile(params.textDocument, (program, document, file) => {
406 const id = getNodeAtPosition(file, document.offsetAt(params.position));
407 return id?.kind == SyntaxKind.Identifier ? program.checker?.resolveIdentifier(id) : undefined;
408 });
409 return getLocations(sym?.declarations);
410 }
411
412 async function complete(params: CompletionParams): Promise<CompletionList> {
413 const completions: CompletionList = {
414 isIncomplete: false,
415 items: [],
416 };
417
418 await compile(params.textDocument, (program, document, file) => {
419 const node = getNodeAtPosition(file, document.offsetAt(params.position));
420 if (node === undefined) {
421 addKeywordCompletion("root", completions);
422 } else {
423 switch (node.kind) {
424 case SyntaxKind.NamespaceStatement:
425 addKeywordCompletion("namespace", completions);
426 break;
427 case SyntaxKind.Identifier:
428 addIdentifierCompletion(program, node, completions);
429 break;
430 }
431 }
432 });
433
434 return completions;
435 }
436
437 async function findReferences(params: ReferenceParams): Promise<Location[]> {
438 const identifiers = await compile(params.textDocument, (program, document, file) =>
439 findReferenceIdentifiers(program, file, document.offsetAt(params.position))
440 );
441 return getLocations(identifiers);
442 }
443
444 async function prepareRename(params: PrepareRenameParams): Promise<Range | undefined> {
445 return await compile(params.textDocument, (_, document, file) => {
446 const id = getNodeAtPosition(file, document.offsetAt(params.position));
447 return id?.kind === SyntaxKind.Identifier ? getLocation(id)?.range : undefined;
448 });
449 }
450
451 async function rename(params: RenameParams): Promise<WorkspaceEdit> {
452 const changes: Record<string, TextEdit[]> = {};
453 await compile(params.textDocument, (program, document, file) => {
454 const identifiers = findReferenceIdentifiers(
455 program,
456 file,
457 document.offsetAt(params.position)
458 );
459 for (const id of identifiers) {
460 const location = getLocation(id);
461 if (!location) {
462 continue;
463 }
464 const change = TextEdit.replace(location.range, params.newName);
465 if (location.uri in changes) {
466 changes[location.uri].push(change);
467 } else {
468 changes[location.uri] = [change];
469 }
470 }
471 });
472 return { changes };
473 }
474
475 function findReferenceIdentifiers(
476 program: Program,
477 file: CadlScriptNode,
478 pos: number
479 ): IdentifierNode[] {
480 const id = getNodeAtPosition(file, pos);
481 if (id?.kind !== SyntaxKind.Identifier) {
482 return [];
483 }
484
485 const sym = program.checker?.resolveIdentifier(id);
486 if (!sym) {
487 return [id];
488 }
489
490 const references: IdentifierNode[] = [];
491 for (const script of program.sourceFiles.values() ?? []) {
492 visitChildren(script, function visit(node) {
493 if (
494 node.kind === SyntaxKind.Identifier &&
495 program.checker?.resolveIdentifier(node) === sym
496 ) {
497 references.push(node);
498 }
499 visitChildren(node, visit);
500 });
501 }
502
503 return references;
504 }
505
506 function addKeywordCompletion(area: keyof KeywordArea, completions: CompletionList) {
507 const filteredKeywords = keywords.filter(([_, x]) => area in x);
508 for (const [keyword] of filteredKeywords) {
509 completions.items.push({
510 label: keyword,
511 kind: CompletionItemKind.Keyword,
512 });
513 }
514 }
515
516 /**
517 * Add completion options for an identifier.
518 */
519 function addIdentifierCompletion(
520 program: Program,
521 node: IdentifierNode,
522 completions: CompletionList
523 ) {
524 const result = program.checker!.resolveCompletions(node);
525 if (result.size === 0) {
526 return;
527 }
528 for (const [key, { sym, label }] of result) {
529 let documentation: string | undefined;
530 let kind: CompletionItemKind;
531 if (sym.flags & (SymbolFlags.Function | SymbolFlags.Decorator)) {
532 kind = CompletionItemKind.Function;
533 } else if (
534 sym.flags & SymbolFlags.Namespace &&
535 sym.declarations[0].kind !== SyntaxKind.NamespaceStatement
536 ) {
537 kind = CompletionItemKind.Module;
538 } else {
539 const type = program.checker!.getTypeForNode(sym.declarations[0]);
540 documentation = getDoc(program, type);
541 kind = getCompletionItemKind(program, type);
542 }
543 completions.items.push({
544 label: label ?? key,
545 documentation,
546 kind,
547 insertText: key,
548 });
549 }
550
551 if (node.parent?.kind === SyntaxKind.TypeReference) {
552 addKeywordCompletion("identifier", completions);
553 }
554 }
555
556 function getCompletionItemKind(program: Program, target: Type): CompletionItemKind {
557 switch (target.node?.kind) {
558 case SyntaxKind.EnumStatement:
559 case SyntaxKind.UnionStatement:
560 return CompletionItemKind.Enum;
561 case SyntaxKind.AliasStatement:
562 return CompletionItemKind.Variable;
563 case SyntaxKind.ModelStatement:
564 return isIntrinsic(program, target) ? CompletionItemKind.Keyword : CompletionItemKind.Class;
565 case SyntaxKind.NamespaceStatement:
566 return CompletionItemKind.Module;
567 default:
568 return CompletionItemKind.Struct;
569 }
570 }
571
572 function documentClosed(change: TextDocumentChangeEvent<TextDocument>) {
573 // clear diagnostics on file close
574 sendDiagnostics(change.document, []);
575 }
576
577 function getLocations(targets: DiagnosticTarget[] | undefined): Location[] {
578 return targets?.map(getLocation).filter((x): x is Location => !!x) ?? [];
579 }
580
581 function getLocation(target: DiagnosticTarget): Location | undefined {
582 const location = getSourceLocation(target);
583 if (location.isSynthetic) {
584 return undefined;
585 }
586
587 const start = location.file.getLineAndCharacterOfPosition(location.pos);
588 const end = location.file.getLineAndCharacterOfPosition(location.end);
589 return {
590 uri: getURL(location.file.path),
591 range: Range.create(start, end),
592 };
593 }
594
595 function convertSeverity(severity: "warning" | "error"): DiagnosticSeverity {
596 switch (severity) {
597 case "warning":
598 return DiagnosticSeverity.Warning;
599 case "error":
600 return DiagnosticSeverity.Error;
601 }
602 }
603
604 function log(message: string, details: any = undefined) {
605 message = `[${new Date().toLocaleTimeString()}] ${message}`;
606 if (details) {
607 message += ": " + JSON.stringify(details, undefined, 2);
608 }
609
610 if (!isInitialized) {
611 pendingMessages.push(message);
612 return;
613 }
614
615 for (const pending of pendingMessages) {
616 host.log(pending);
617 }
618
619 pendingMessages = [];
620 host.log(message);
621 }
622
623 function sendDiagnostics(document: TextDocument, diagnostics: VSDiagnostic[]) {
624 host.sendDiagnostics({
625 uri: document.uri,
626 version: document.version,
627 diagnostics,
628 });
629 }
630
631 /**
632 * Determine if the given document is the latest version.
633 *
634 * A document can become out-of-date if a change comes in during an async
635 * operation.
636 */
637 function upToDate(document: TextDocument | TextDocumentIdentifier) {
638 if (!("version" in document)) {
639 return true;
640 }
641 return document.version === host.getDocumentByURL(document.uri)?.version;
642 }
643
644 /**
645 * Infer the appropriate entry point (a.k.a. "main file") for analyzing a
646 * change to the file at the given path. This is necessary because different
647 * results can be obtained from compiling the same file with different entry
648 * points.
649 *
650 * Walk directory structure upwards looking for package.json with cadlMain or
651 * main.cadl file. Stop search when reaching a workspace root. If a root is
652 * reached without finding an entry point, use the given path as its own
653 * entry point.
654 *
655 * Untitled documents are always treated as their own entry points as they
656 * do not exist in a directory that could pull them in via another entry
657 * point.
658 */
659 async function getMainFileForDocument(path: string) {
660 if (path.startsWith("untitled:")) {
661 return path;
662 }
663
664 let dir = getDirectoryPath(path);
665 const options = { allowFileNotFound: true };
666
667 while (inWorkspace(dir)) {
668 let mainFile = "main.cadl";
669 let pkg: any;
670 const pkgPath = joinPaths(dir, "package.json");
671 const cached = fileSystemCache.get(pkgPath)?.data;
672
673 if (cached) {
674 pkg = cached;
675 } else {
676 [pkg] = await loadFile(
677 compilerHost,
678 pkgPath,
679 JSON.parse,
680 logMainFileSearchDiagnostic,
681 options
682 );
683 fileSystemCache.get(pkgPath)!.data = pkg ?? {};
684 }
685
686 if (typeof pkg?.cadlMain === "string") {
687 mainFile = pkg.cadlMain;
688 }
689
690 const candidate = joinPaths(dir, mainFile);
691 const stat = await doIO(
692 () => compilerHost.stat(candidate),
693 candidate,
694 logMainFileSearchDiagnostic,
695 options
696 );
697
698 if (stat?.isFile()) {
699 return candidate;
700 }
701
702 dir = getDirectoryPath(dir);
703 }
704
705 return path;
706
707 function logMainFileSearchDiagnostic(diagnostic: CadlDiagnostic) {
708 log(
709 `Unexpected diagnostic while looking for main file of ${path}`,
710 formatDiagnostic(diagnostic)
711 );
712 }
713 }
714
715 function inWorkspace(path: string) {
716 return workspaceFolders.some((f) => path.startsWith(f.path));
717 }
718
719 function getPath(document: TextDocument | TextDocumentIdentifier) {
720 if (isUntitled(document.uri)) {
721 return document.uri;
722 }
723 const path = resolvePath(compilerHost.fileURLToPath(document.uri));
724 pathToURLMap.set(path, document.uri);
725 return path;
726 }
727
728 function getURL(path: string) {
729 if (isUntitled(path)) {
730 return path;
731 }
732 return pathToURLMap.get(path) ?? compilerHost.pathToFileURL(path);
733 }
734
735 function isUntitled(pathOrUrl: string) {
736 return pathOrUrl.startsWith("untitled:");
737 }
738
739 function getDocument(path: string) {
740 const url = getURL(path);
741 return url ? host.getDocumentByURL(url) : undefined;
742 }
743
744 async function readFile(path: string): Promise<ServerSourceFile> {
745 // Try open files sent from client over LSP
746 const document = getDocument(path);
747 if (document) {
748 return {
749 document,
750 ...createSourceFile(document.getText(), path),
751 };
752 }
753
754 // Try file system cache
755 const cached = fileSystemCache.get(path);
756 if (cached) {
757 if (cached.type === "error") {
758 throw cached.error;
759 }
760 return cached.file;
761 }
762
763 // Hit the disk and cache
764 try {
765 const file = await host.compilerHost.readFile(path);
766 fileSystemCache.set(path, { type: "file", file });
767 return file;
768 } catch (error) {
769 fileSystemCache.set(path, { type: "error", error });
770 throw error;
771 }
772 }
773
774 async function stat(path: string): Promise<{ isDirectory(): boolean; isFile(): boolean }> {
775 // if we have an open document for the path or a cache entry, then we know
776 // it's a file and not a directory and needn't hit the disk.
777 if (getDocument(path) || fileSystemCache.get(path)?.type === "file") {
778 return {
779 isFile() {
780 return true;
781 },
782 isDirectory() {
783 return false;
784 },
785 };
786 }
787 return await host.compilerHost.stat(path);
788 }
789}
790