Add test(s) that validate Pyright can talk over LSP (#7172)

* Everything building, but not running

* More tests passing

* Fix test to open a file

* Remove unused functions and consolidate others

* Remove unused custom lsp messages

* Add comments
This commit is contained in:
Rich Chiodo 2024-01-31 16:38:37 -08:00 committed by GitHub
parent 7585378936
commit e01b0fe205
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 4749 additions and 28 deletions

5
.vscode/launch.json vendored
View File

@ -84,6 +84,9 @@
"args": [
"${fileBasenameNoExtension}",
"--runInBand",
"--detectOpenHandles",
"--forceExit",
"--testTimeout=180000"
],
"cwd": "${workspaceRoot}/packages/pyright-internal",
"console": "integratedTerminal",
@ -94,7 +97,7 @@
"name": "Pyright jest selected test",
"type": "node",
"request": "launch",
"args": ["${fileBasenameNoExtension}", "--runInBand", "-t", "${selectedText}"],
"args": ["${fileBasenameNoExtension}", "--runInBand", "-t", "${selectedText}", "--forceExit", "--testTimeout=180000"],
"cwd": "${workspaceRoot}/packages/pyright-internal",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",

34
.vscode/tasks.json vendored
View File

@ -40,6 +40,40 @@
}
}
}
},
{
"label": "Watch test server",
"type": "npm",
"script": "watch:testserver",
"isBackground": true,
// From vscode-tsl-problem-matcher.
"problemMatcher": {
"owner": "custom",
"fileLocation": "absolute",
"pattern": [
{
"regexp": "\\[tsl\\] (ERROR|WARNING) in (.*)?\\((\\d+),(\\d+)\\)",
"severity": 1,
"file": 2,
"line": 3,
"column": 4
},
{
"regexp": "\\s*TS(\\d+):\\s*(.*)$",
"code": 1,
"message": 2
}
],
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "PublicPath: .*?"
},
"endsPattern": {
"regexp": "webpack compiled in .*? ms"
}
}
}
}
]
}

View File

@ -10,6 +10,7 @@
"build:extension:dev": "cd packages/vscode-pyright && npm run webpack",
"build:cli:dev": "cd packages/pyright && npm run webpack",
"watch:extension": "cd packages/vscode-pyright && npm run webpack-dev",
"watch:testserver": "cd packages/pyright-internal && npm run webpack:testserver:watch",
"check": "npm run check:syncpack && npm run check:eslint && npm run check:prettier",
"check:syncpack": "syncpack list-mismatches",
"fix:syncpack": "syncpack fix-mismatches --indent \" \" && npm run install:all",

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,10 @@
"scripts": {
"build": "tsc",
"clean": "shx rm -rf ./dist ./out",
"test": "jest --forceExit",
"webpack:testserver": "webpack --config ./src/tests/lsp/webpack.testserver.config.js --mode=development",
"webpack:testserver:watch": "npm run clean && webpack --config ./src/tests/lsp/webpack.testserver.config.js --mode development --watch --progress",
"test": "npm run webpack:testserver && jest --forceExit",
"test:norebuild": "jest --forceExit",
"test:coverage": "jest --forceExit --reporters=jest-junit --reporters=default --coverage --coverageReporters=cobertura --coverageReporters=html --coverageReporters=json",
"test:imports": "jest importResolver.test --forceExit --runInBand"
},
@ -39,11 +42,16 @@
"@types/lodash": "^4.14.202",
"@types/node": "^17.0.45",
"@types/tmp": "^0.2.3",
"copy-webpack-plugin": "^11.0.0",
"esbuild-loader": "^3.0.1",
"jest": "^29.6.1",
"jest-junit": "^16.0.0",
"shx": "^0.3.4",
"ts-jest": "^29.1.1",
"ts-loader": "^9.4.4",
"typescript": "~5.2",
"webpack": "^5.88.1",
"webpack-cli": "^5.1.4",
"word-wrap": "1.2.4"
}
}

View File

@ -28,6 +28,7 @@ import { ConsoleWithLogLevel, LogLevel, convertLogLevel } from './common/console
import { isDebugMode, isString } from './common/core';
import { expandPathVariables } from './common/envVarUtils';
import { FileBasedCancellationProvider } from './common/fileBasedCancellationUtils';
import { FileSystem } from './common/fileSystem';
import { FullAccessHost } from './common/fullAccessHost';
import { Host } from './common/host';
import { ProgressReporter } from './common/progressReporter';
@ -46,13 +47,13 @@ const maxAnalysisTimeInForeground = { openFilesTimeInMs: 50, noOpenFilesTimeInMs
export class PyrightServer extends LanguageServerBase {
private _controller: CommandController;
constructor(connection: Connection) {
constructor(connection: Connection, realFileSystem?: FileSystem) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const version = require('../package.json').version || '';
const console = new ConsoleWithLogLevel(connection.console);
const fileWatcherProvider = new WorkspaceFileWatcherProvider();
const fileSystem = createFromRealFileSystem(console, fileWatcherProvider);
const fileSystem = realFileSystem ?? createFromRealFileSystem(console, fileWatcherProvider);
const pyrightFs = new PyrightFileSystem(fileSystem);
const tempFile = new RealTempFile(pyrightFs.isCaseSensitive);
const cacheManager = new CacheManager();

View File

@ -0,0 +1,193 @@
/*
* languageServer.test.ts
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT license.
*
* Tests to verify Pyright works as the backend for a language server.
*/
import assert from 'assert';
import {
CancellationToken,
CompletionRequest,
ConfigurationItem,
InitializedNotification,
InitializeRequest,
MarkupContent,
} from 'vscode-languageserver';
import { convertOffsetToPosition } from '../common/positionUtils';
import { PythonVersion } from '../common/pythonVersion';
import { isArray } from '../common/core';
import { normalizeSlashes } from '../common/pathUtils';
import {
cleanupAfterAll,
DEFAULT_WORKSPACE_ROOT,
getParseResults,
hover,
initializeLanguageServer,
openFile,
PyrightServerInfo,
runPyrightServer,
waitForDiagnostics,
} from './lsp/languageServerTestUtils';
describe(`Basic language server tests`, () => {
let serverInfo: PyrightServerInfo | undefined;
async function runLanguageServer(
projectRoots: string[] | string,
code: string,
callInitialize = true,
extraSettings?: { item: ConfigurationItem; value: any }[],
pythonVersion: PythonVersion = PythonVersion.V3_10,
supportsBackgroundThread?: boolean
) {
const result = await runPyrightServer(
projectRoots,
code,
callInitialize,
extraSettings,
pythonVersion,
supportsBackgroundThread
);
serverInfo = result;
return result;
}
afterEach(async () => {
if (serverInfo) {
await serverInfo.dispose();
serverInfo = undefined;
}
});
afterAll(async () => {
await cleanupAfterAll();
});
test('Basic Initialize', async () => {
const code = `
// @filename: test.py
//// # empty file
`;
const serverInfo = await runLanguageServer(DEFAULT_WORKSPACE_ROOT, code, /* callInitialize */ false);
const initializeResult = await initializeLanguageServer(serverInfo);
assert(initializeResult);
assert(initializeResult.capabilities.completionProvider?.resolveProvider);
});
test('Initialize without workspace folder support', async () => {
const code = `
// @filename: test.py
//// import [|/*marker*/os|]
`;
const info = await runLanguageServer(DEFAULT_WORKSPACE_ROOT, code, /* callInitialize */ false);
// This will test clients with no folder and configuration support.
const params = info.getInitializeParams();
params.capabilities.workspace!.workspaceFolders = false;
params.capabilities.workspace!.configuration = false;
// Perform LSP Initialize/Initialized handshake.
const result = await info.connection.sendRequest(InitializeRequest.type, params, CancellationToken.None);
assert(result);
await info.connection.sendNotification(InitializedNotification.type, {});
// Do simple hover request to verify our server works with a client that doesn't support
// workspace folder/configuration capabilities.
openFile(info, 'marker');
const hoverResult = await hover(info, 'marker');
assert(hoverResult);
assert(MarkupContent.is(hoverResult.contents));
assert.strictEqual(hoverResult.contents.value, '```python\n(module) os\n```');
});
test('Hover', async () => {
const code = `
// @filename: test.py
//// import [|/*marker*/os|]
`;
const info = await runLanguageServer(DEFAULT_WORKSPACE_ROOT, code, /* callInitialize */ true);
// Do simple hover request
openFile(info, 'marker');
const hoverResult = await hover(info, 'marker');
assert(hoverResult);
assert(MarkupContent.is(hoverResult.contents));
assert.strictEqual(hoverResult.contents.value, '```python\n(module) os\n```');
});
test('Completions', async () => {
const code = `
// @filename: test.py
//// import os
//// os.[|/*marker*/|]
`;
const info = await runLanguageServer(DEFAULT_WORKSPACE_ROOT, code, /* callInitialize */ true);
// Do simple completion request
openFile(info, 'marker');
const marker = info.testData.markerPositions.get('marker')!;
const fileUri = marker.fileUri;
const text = info.testData.files.find((d) => d.fileName === marker.fileName)!.content;
const parseResult = getParseResults(text);
const completionResult = await info.connection.sendRequest(
CompletionRequest.type,
{
textDocument: { uri: fileUri.toString() },
position: convertOffsetToPosition(marker.position, parseResult.tokenizerOutput.lines),
},
CancellationToken.None
);
assert(completionResult);
assert(!isArray(completionResult));
const completionItem = completionResult.items.find((i) => i.label === 'path')!;
assert(completionItem);
});
test('background thread diagnostics', async () => {
const code = `
// @filename: root/test.py
//// from math import cos, sin
//// import sys
//// [|/*marker*/|]
`;
const settings = [
{
item: {
scopeUri: `file://${normalizeSlashes(DEFAULT_WORKSPACE_ROOT, '/')}`,
section: 'python.analysis',
},
value: {
typeCheckingMode: 'strict',
diagnosticMode: 'workspace',
},
},
];
const info = await runLanguageServer(
DEFAULT_WORKSPACE_ROOT,
code,
/* callInitialize */ true,
settings,
undefined,
/* supportsBackgroundThread */ true
);
// get the file containing the marker that also contains our task list comments
await openFile(info, 'marker');
// Wait for the diagnostics to publish
const diagnostics = await waitForDiagnostics(info);
assert.equal(diagnostics[0]!.diagnostics.length, 6);
// Make sure the error has a special rule
assert.equal(diagnostics[0].diagnostics[1].code, 'reportUnusedImport');
assert.equal(diagnostics[0].diagnostics[3].code, 'reportUnusedImport');
assert.equal(diagnostics[0].diagnostics[5].code, 'reportUnusedImport');
});
});

View File

@ -0,0 +1,170 @@
/*
* customLsp.ts
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT license.
*
* Custom messages and notifications on top of the LSP used for testing.
*/
import {
CancellationToken,
DidChangeConfigurationParams,
DidChangeNotebookDocumentParams,
Disposable,
NotificationHandler,
RequestHandler,
} from 'vscode-languageserver-protocol';
import { Uri } from '../../common/uri/uri';
export interface RequestSender {
sendRequest<R>(method: string, params: any, token?: CancellationToken): Promise<R>;
}
export interface NotificationSender {
sendNotification: (method: string, params?: any) => void;
}
export interface RequestReceiver {
onRequest<P, R, E>(method: string, handler: RequestHandler<P, R, E>): Disposable;
}
export interface NotificationReceiver {
onNotification<P>(method: string, handler: NotificationHandler<P>): Disposable;
}
export interface WorkspaceInfo {
rootUri: Uri;
kinds: string[];
pythonPath: Uri | undefined;
pythonPathKind: string;
}
// Type-safe LSP wrappers for our custom calls.
export namespace CustomLSP {
export enum TestSignalKinds {
Initialization = 'initialization',
DidOpenDocument = 'didopendocument',
DidChangeDocument = 'didchangedocument',
}
export interface TestSignal {
uri: string;
kind: TestSignalKinds;
}
export enum Requests {
GetDiagnostics = 'test/getDiagnostics',
}
export enum Notifications {
SetStatusBarMessage = 'python/setStatusBarMessage',
BeginProgress = 'python/beginProgress',
ReportProgress = 'python/reportProgress',
EndProgress = 'python/endProgress',
WorkspaceTrusted = 'python/workspaceTrusted',
TestSignal = 'test/signal',
// Due to some restrictions on vscode-languageserver-node package,
// we can't mix use types from the package in 2 different extensions.
// Basically due to how lsp package utilizes singleton objects internally,
// if we use a client created from python core extension, which uses LSP library
// they imported, with LSP types from LSP library we imported, LSP will throw
// an exception saying internal singleton objects are not same.
//
// To workaround it, we won't use some of LSP types directly but create our own
// and use them with the client.
DidChangeConfiguration = 'workspace/didChangeConfiguration',
DidChangeNotebookDocument = 'notebookDocument/didChange',
CacheDirCreate = 'python/cacheDirCreate',
CacheFileWrite = 'python/cacheFileWrite',
// Starting/stopping the server are all notifications so they pass
// through without any interference.
TestStartServer = 'test/startServer',
TestStartServerResponse = 'test/startServerResponse',
}
interface Params {
[Requests.GetDiagnostics]: { uri: string };
[Notifications.CacheDirCreate]: { uri: string };
[Notifications.CacheFileWrite]: { uri: string; contents: string; overwrite: boolean };
[Notifications.SetStatusBarMessage]: string;
[Notifications.BeginProgress]: undefined;
[Notifications.ReportProgress]: string;
[Notifications.EndProgress]: undefined;
[Notifications.WorkspaceTrusted]: { isTrusted: boolean };
[Notifications.TestSignal]: TestSignal;
[Notifications.DidChangeConfiguration]: DidChangeConfigurationParams;
[Notifications.DidChangeNotebookDocument]: DidChangeNotebookDocumentParams;
[Notifications.TestStartServer]: TestServerStartOptions;
[Notifications.TestStartServerResponse]: { testName: string };
}
interface Response {
[Requests.GetDiagnostics]: { diagnostics: string };
}
// Interface for returning config options as we cannot return a
// class instance from the server.
export interface IFileSpec {
wildcardRoot: Uri;
regExp: string;
hasDirectoryWildcard: boolean;
}
export interface IConfigOptions {
projectRoot: Uri;
pythonPath?: Uri;
typeshedPath?: Uri;
include: IFileSpec[];
exclude: IFileSpec[];
ignore: IFileSpec[];
strict: IFileSpec[];
}
/**
* Data passed to the server worker thread in order to setup
* a test server.
*/
export interface TestServerStartOptions {
testName: string; // Helpful for debugging
pid: string; // Helpful for debugging
logFile: Uri; // Helpful for debugging
code: string; // Fourslash data.
projectRoots: Uri[];
pythonVersion: number;
backgroundAnalysis?: boolean;
}
export function sendRequest<P extends Params, R extends Response, M extends Requests & keyof P & keyof R & string>(
connection: RequestSender,
method: M,
params: P[M],
token?: CancellationToken
): Promise<R[M]> {
return connection.sendRequest(method, params, token);
}
export function sendNotification<P extends Params, M extends Notifications & keyof P & string>(
connection: NotificationSender,
method: M,
params: P[M]
): void {
connection.sendNotification(method, params);
}
export function onRequest<P extends Params, R extends Response, M extends Requests & keyof P & keyof R & string, E>(
connection: RequestReceiver,
method: M,
handler: RequestHandler<P[M], R[M], E>
): Disposable {
return connection.onRequest(method, handler);
}
export function onNotification<P extends Params, M extends Notifications & keyof P & string>(
connection: NotificationReceiver,
method: M,
handler: NotificationHandler<P[M]>
): Disposable {
return connection.onNotification(method, handler);
}
}

View File

@ -0,0 +1,385 @@
/*
* languageServer.ts
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT license.
*
* Test language server wrapper that lets us run the language server during a test.
*/
import {
CancellationToken,
Connection,
Disposable,
Message,
MessageReader,
MessageWriter,
PortMessageReader,
PortMessageWriter,
ShutdownRequest,
createConnection,
} from 'vscode-languageserver/node';
import { MessagePort, getEnvironmentData, parentPort, setEnvironmentData } from 'worker_threads';
import { Deferred, createDeferred } from '../../common/deferred';
import { FileSystemEntries, resolvePaths } from '../../common/pathUtils';
import { ServiceProvider } from '../../common/serviceProvider';
import { Uri } from '../../common/uri/uri';
import { parseTestData } from '../harness/fourslash/fourSlashParser';
import * as PyrightTestHost from '../harness/testHost';
import { clearCache } from '../harness/vfs/factory';
import { BackgroundAnalysis, BackgroundAnalysisRunner } from '../../backgroundAnalysis';
import { BackgroundAnalysisBase } from '../../backgroundAnalysisBase';
import { serialize } from '../../backgroundThreadBase';
import { FileSystem } from '../../common/fileSystem';
import { ServiceKeys } from '../../common/serviceProviderExtensions';
import { ServerSettings } from '../../languageServerBase';
import { PyrightFileSystem } from '../../pyrightFileSystem';
import { PyrightServer } from '../../server';
import { InitStatus, Workspace } from '../../workspaceFactory';
import { CustomLSP } from './customLsp';
import {
DEFAULT_WORKSPACE_ROOT,
TestHost,
TestHostOptions,
createFileSystem,
getFileLikePath,
logToDisk,
sleep,
} from './languageServerTestUtils';
const WORKER_STARTED = 'WORKER_STARTED';
const WORKER_BACKGROUND_DATA = 'WORKER_BACKGROUND_DATA';
function getCommonRoot(files: Uri[]) {
let root = files[0]?.getPath() || DEFAULT_WORKSPACE_ROOT;
for (let i = 1; i < files.length; i++) {
const file = files[i];
while (root.length > 0 && !file.pathStartsWith(root)) {
root = root.slice(0, root.lastIndexOf('/'));
}
}
return root;
}
class TestPyrightHost implements PyrightTestHost.TestHost {
constructor(private _host: PyrightTestHost.TestHost) {}
useCaseSensitiveFileNames(): boolean {
return this._host.useCaseSensitiveFileNames();
}
getAccessibleFileSystemEntries(dirname: string): FileSystemEntries {
return this._host.getAccessibleFileSystemEntries(dirname);
}
directoryExists(path: string): boolean {
return this._host.directoryExists(path);
}
fileExists(fileName: string): boolean {
return this._host.fileExists(fileName);
}
getFileSize(path: string): number {
return this._host.getFileSize(path);
}
readFile(path: string): string | undefined {
return this._host.readFile(path);
}
getWorkspaceRoot(): string {
// The default workspace root is wrong. It should be based on where the bundle is running.
// That's where the typeshed fallback and other bundled files are located.
return resolvePaths(__dirname);
}
writeFile(path: string, contents: string): void {
this._host.writeFile(path, contents);
}
listFiles(
path: string,
filter?: RegExp | undefined,
options?: { recursive?: boolean | undefined } | undefined
): string[] {
return this._host.listFiles(path, filter, options);
}
log(text: string): void {
this._host.log(text);
}
}
function createTestHost(testServerData: CustomLSP.TestServerStartOptions) {
const scriptOutput = '';
const runScript = async (
pythonPath: Uri | undefined,
scriptPath: Uri,
args: string[],
cwd: Uri,
token: CancellationToken
) => {
return { stdout: scriptOutput, stderr: '', exitCode: 0 };
};
const options = new TestHostOptions({ version: testServerData.pythonVersion, runScript });
const projectRootPaths = testServerData.projectRoots.map((p) => getFileLikePath(p));
const testData = parseTestData(
testServerData.projectRoots.length === 1 ? projectRootPaths[0] : DEFAULT_WORKSPACE_ROOT,
testServerData.code,
'noname.py'
);
const commonRoot = getCommonRoot(testServerData.projectRoots);
// Make sure global variables from previous tests are cleared.
clearCache();
// create a test file system using the test data.
const fs = createFileSystem(commonRoot, testData, new TestPyrightHost(PyrightTestHost.HOST));
return new TestHost(fs, fs, testData, projectRootPaths, options);
}
class TestServer extends PyrightServer {
constructor(
connection: Connection,
fs: FileSystem,
private readonly _supportsBackgroundAnalysis: boolean | undefined
) {
super(connection, fs);
}
test_onDidChangeWatchedFiles(params: any) {
this.onDidChangeWatchedFiles(params);
}
override async updateSettingsForWorkspace(
workspace: Workspace,
status: InitStatus | undefined,
serverSettings?: ServerSettings | undefined
): Promise<void> {
const result = await super.updateSettingsForWorkspace(workspace, status, serverSettings);
// LSP notification only allows synchronous callback. because of that, the one that sent the notification can't know
// when the work caused by the notification actually ended. To workaround that issue, we will send custom lsp to indicate
// something has been done.
CustomLSP.sendNotification(this.connection, CustomLSP.Notifications.TestSignal, {
uri: workspace.rootUri.toString(),
kind: CustomLSP.TestSignalKinds.Initialization,
});
return result;
}
override createBackgroundAnalysis(serviceId: string): BackgroundAnalysisBase | undefined {
if (this._supportsBackgroundAnalysis) {
return new BackgroundAnalysis(this.serverOptions.serviceProvider);
}
return undefined;
}
}
async function runServer(
testServerData: CustomLSP.TestServerStartOptions,
reader: MessageReader,
writer: MessageWriter,
connectionFactory: (reader: MessageReader, writer: MessageWriter) => Connection
): Promise<{ disposables: Disposable[]; connection: Connection }> {
// Create connection back to the client first.
const connection = connectionFactory(reader, writer);
// Fixup the input data.
testServerData = {
...testServerData,
projectRoots: testServerData.projectRoots.map((p) => Uri.fromJsonObj(p)),
logFile: Uri.fromJsonObj(testServerData.logFile),
};
try {
// Create a host so we can control the file system for the PyrightServer.
const disposables: Disposable[] = [];
const host = createTestHost(testServerData);
const server = new TestServer(connection, host.fs, testServerData.backgroundAnalysis);
// Listen for the test messages from the client. These messages
// are how the test code queries the state of the server.
disposables.push(
CustomLSP.onRequest(connection, CustomLSP.Requests.GetDiagnostics, async (params, token) => {
const filePath = Uri.parse(params.uri, true);
const workspace = await server.getWorkspaceForFile(filePath);
workspace.service.test_program.analyze(undefined, token);
const file = workspace.service.test_program.getBoundSourceFile(filePath);
const diagnostics = file?.getDiagnostics(workspace.service.test_program.configOptions) || [];
return { diagnostics: serialize(diagnostics) };
})
);
// Dispose the server and connection when terminating the server.
disposables.push(server);
disposables.push(connection);
return { disposables, connection };
} catch (err) {
console.error(err);
return { disposables: [], connection };
}
}
class ListeningPortMessageWriter extends PortMessageWriter {
private _callbacks: ((msg: Message) => Promise<void>)[] = [];
constructor(port: MessagePort) {
super(port);
}
override async write(msg: Message): Promise<void> {
await Promise.all(this._callbacks.map((c) => c(msg)));
return super.write(msg);
}
onPostMessage(callback: (msg: Message) => Promise<void>) {
this._callbacks.push(callback);
}
}
/**
* Object that exists in the worker thread that starts and stops (and cleans up after) the main server.
*/
class ServerStateManager {
private _instances: { disposables: Disposable[]; connection: Connection }[] = [];
private _pendingDispose: Deferred<void> | undefined;
private _reader = new PortMessageReader(parentPort!);
private _writer = new ListeningPortMessageWriter(parentPort!);
private _currentOptions: CustomLSP.TestServerStartOptions | undefined;
private _shutdownId: string | number | null = null;
constructor(private readonly _connectionFactory: (reader: MessageReader, writer: MessageWriter) => Connection) {
// Listen for shutdown response.
this._writer.onPostMessage(async (msg: Message) => {
if (Message.isResponse(msg) && msg.id === this._shutdownId) {
await this._handleShutdown();
}
});
}
run() {
parentPort?.on('message', (message) => this._handleMessage(message));
}
private _handleMessage(message: any) {
try {
// Debug output to help diagnose sync issues.
if (message && message.method === CustomLSP.Notifications.TestStartServer) {
this._handleStart(message.params);
} else if (Message.isRequest(message) && message.method === ShutdownRequest.method) {
this._shutdownId = message.id;
}
} catch (err) {
console.error(err);
}
}
private async _handleStart(options: CustomLSP.TestServerStartOptions) {
logToDisk(`Starting server for ${options.testName}`, options.logFile);
// Every time we start the server, remove all message handlers from our PortMessageReader.
// This prevents the old servers from responding to messages for new ones.
this._reader.dispose();
// Wait for the previous server to finish. This should be okay because the test
// client waits for the response message before sending anything else. Otherwise
// we'd receive the initialize message for the server and drop it before the server
// actually started.
if (this._pendingDispose) {
logToDisk(
`Waiting for previous server ${this._currentOptions?.testName} to finish for ${options.testName}`,
options.logFile
);
await this._pendingDispose.promise;
this._pendingDispose = undefined;
}
this._currentOptions = options;
// Set the worker data for the current test. Any background threads
// started after this point will pick up this value.
setEnvironmentData(WORKER_BACKGROUND_DATA, options);
// Create an instance of the server.
const { disposables, connection } = await runServer(
options,
this._reader,
this._writer,
this._connectionFactory
);
this._instances.push({ disposables, connection });
// Enable this to help diagnose sync issues.
logToDisk(`Started server for ${options.testName}`, options.logFile);
// Respond back.
parentPort?.postMessage({
jsonrpc: '2.0',
method: CustomLSP.Notifications.TestStartServerResponse,
params: options,
});
}
private async _handleShutdown() {
if (this._currentOptions) {
logToDisk(`Stopping ${this._currentOptions?.testName}`, this._currentOptions.logFile);
}
this._shutdownId = null;
const instance = this._instances.pop();
if (instance) {
this._pendingDispose = createDeferred<void>();
// Wait for our connection to finish first. Give it 10 tries.
// This is a bit of a hack but there are no good ways to cancel all running requests
// on shutdown.
let count = 0;
while (count < 10 && (instance.connection as any).console?._rawConnection?.hasPendingResponse()) {
await sleep(10);
count += 1;
}
this._pendingDispose.resolve();
instance.disposables.forEach((d) => {
d.dispose();
});
this._pendingDispose = undefined;
if (this._currentOptions) {
logToDisk(`Stopped ${this._currentOptions?.testName}`, this._currentOptions.logFile);
}
} else {
if (this._currentOptions) {
logToDisk(`Failed to stop ${this._currentOptions?.testName}`, this._currentOptions.logFile);
}
}
if (global.gc) {
global.gc();
}
}
}
async function runTestBackgroundThread() {
let options = getEnvironmentData(WORKER_BACKGROUND_DATA) as CustomLSP.TestServerStartOptions;
// Normalize the options.
options = {
...options,
projectRoots: options.projectRoots.map((p) => Uri.fromJsonObj(p)),
logFile: Uri.fromJsonObj(options.logFile),
};
try {
// Create a host on the background thread too so that it uses
// the host's file system. Has to be sync so that we don't
// drop any messages sent to the background thread.
const host = createTestHost(options);
const fs = new PyrightFileSystem(host.fs);
const serviceProvider = new ServiceProvider();
serviceProvider.add(ServiceKeys.fs, fs);
// run default background runner
const runner = new BackgroundAnalysisRunner(serviceProvider);
runner.start();
} catch (e) {
console.error(`BackgroundThread crashed with ${e}`);
}
}
export function run() {
// Start the background thread if this is not the first worker.
if (getEnvironmentData(WORKER_STARTED) === 'true') {
runTestBackgroundThread();
} else {
setEnvironmentData(WORKER_STARTED, 'true');
// Start the server state manager.
const stateManager = new ServerStateManager((reader, writer) => createConnection(reader, writer, {}));
stateManager.run();
}
}

View File

@ -0,0 +1,973 @@
/*
* languageServerTestUtils.ts
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT license.
*
* Utilities for running tests against the LSP server.
*/
import assert from 'assert';
import * as fs from 'fs-extra';
import { isMainThread, threadId, Worker } from 'node:worker_threads';
import path from 'path';
import {
ApplyWorkspaceEditParams,
ApplyWorkspaceEditRequest,
CancellationToken,
ConfigurationItem,
ConfigurationRequest,
DidChangeWorkspaceFoldersNotification,
DidOpenTextDocumentNotification,
Disposable,
InitializedNotification,
InitializeParams,
InitializeRequest,
InlayHintRefreshRequest,
LogMessageNotification,
LogMessageParams,
PublishDiagnosticsNotification,
PublishDiagnosticsParams,
Registration,
RegistrationRequest,
SemanticTokensRefreshRequest,
ShutdownRequest,
TelemetryEventNotification,
UnregistrationRequest,
} from 'vscode-languageserver-protocol';
import {
Connection,
createConnection,
Emitter,
Event,
HoverRequest,
NotificationHandler,
PortMessageReader,
PortMessageWriter,
ProgressToken,
ProgressType,
ProtocolNotificationType,
WorkDoneProgress,
WorkDoneProgressCancelNotification,
WorkDoneProgressCreateRequest,
} from 'vscode-languageserver/node';
import { PythonPathResult } from '../../analyzer/pythonPathUtils';
import { IPythonMode } from '../../analyzer/sourceFile';
import { PythonPlatform } from '../../common/configOptions';
import { createDeferred, Deferred } from '../../common/deferred';
import { DiagnosticSink } from '../../common/diagnosticSink';
import { FileSystem } from '../../common/fileSystem';
import { LimitedAccessHost } from '../../common/fullAccessHost';
import { HostKind, ScriptOutput } from '../../common/host';
import { combinePaths, resolvePaths } from '../../common/pathUtils';
import { convertOffsetToPosition } from '../../common/positionUtils';
import { PythonVersion } from '../../common/pythonVersion';
import { FileUri } from '../../common/uri/fileUri';
import { Uri } from '../../common/uri/uri';
import { ParseOptions, Parser } from '../../parser/parser';
import { parseTestData } from '../harness/fourslash/fourSlashParser';
import { FourSlashData } from '../harness/fourslash/fourSlashTypes';
import { createVfsInfoFromFourSlashData, getMarkerByName } from '../harness/fourslash/testStateUtils';
import * as host from '../harness/testHost';
import { createFromFileSystem, distlibFolder, libFolder } from '../harness/vfs/factory';
import * as vfs from '../harness/vfs/filesystem';
import { CustomLSP } from './customLsp';
// bundled root on test virtual file system.
const bundledStubsFolder = combinePaths(vfs.MODULE_PATH, 'bundled', 'stubs');
// bundled file path on real file system.
const bundledStubsFolderPath = resolvePaths(__dirname, '../../bundled/stubs');
const bundledStubsFolderPathTestServer = resolvePaths(__dirname, '../bundled/stubs');
// project root on test virtual file system.
export const DEFAULT_WORKSPACE_ROOT = combinePaths('/', 'src');
export const ERROR_SCRIPT_OUTPUT = 'Error: script failed to run';
export const STALL_SCRIPT_OUTPUT = 'Timeout: script never finished running';
export interface PyrightServerInfo {
disposables: Disposable[];
registrations: Registration[];
logs: LogMessageParams[];
connection: Connection;
signals: Map<CustomLSP.TestSignalKinds, Deferred<boolean>>;
testName: string; // Used for debugging
testData: FourSlashData;
projectRoots: Uri[];
progressReporters: string[];
progressReporterStatus: Map<string, number>;
progressParts: Map<string, TestProgressPart>;
telemetry: any[];
diagnostics: PublishDiagnosticsParams[];
diagnosticsEvent: Event<PublishDiagnosticsParams>;
workspaceEdits: ApplyWorkspaceEditParams[];
workspaceEditsEvent: Event<ApplyWorkspaceEditParams>;
getInitializeParams(): InitializeParams;
dispose(): Promise<void>;
convertPathToUri(path: string): Uri;
}
export class TestHostOptions {
version: PythonVersion;
platform: PythonPlatform;
// Search path on virtual file system.
searchPaths: Uri[];
// Run script function
runScript: (
pythonPath: Uri | undefined,
scriptPath: Uri,
args: string[],
cwd: Uri,
token: CancellationToken
) => Promise<ScriptOutput>;
constructor({
version = PythonVersion.V3_10,
platform = PythonPlatform.Linux,
searchPaths = [libFolder, distlibFolder],
runScript = async (
pythonPath: Uri | undefined,
scriptPath: Uri,
args: string[],
cwd: Uri,
token: CancellationToken
) => {
return { stdout: '', stderr: '' };
},
} = {}) {
this.version = version;
this.platform = platform;
this.searchPaths = searchPaths;
this.runScript = runScript;
}
}
// Enable this to log to disk for debugging sync issues.
export const logToDisk = (m: string, f: Uri) => {}; // logToDiskImpl
export function logToDiskImpl(message: string, fileName: Uri) {
const thread = isMainThread ? 'main' : threadId.toString();
fs.writeFileSync(fileName.getFilePath(), `${Date.now()} : ${thread} : ${message}\n`, {
flag: 'a+',
});
}
// Global server worker.
let serverWorker: Worker | undefined;
let serverWorkerFile: string | undefined;
let lastServerFinished: { name: string; finished: boolean } = { name: '', finished: true };
function removeAllListeners(worker: Worker) {
// Only remove the 'message', 'error' and 'close' events
worker.rawListeners('message').forEach((listener) => worker.removeListener('message', listener as any));
worker.rawListeners('error').forEach((listener) => worker.removeListener('error', listener as any));
worker.rawListeners('close').forEach((listener) => worker.removeListener('close', listener as any));
}
function createServerWorker(file: string, testServerData: CustomLSP.TestServerStartOptions) {
// Do not terminate the worker if it's the same file. Reuse it.
// This makes tests run a lot faster because creating a worker is the same
// as starting a new process.
if (!serverWorker || serverWorkerFile !== file) {
serverWorker?.terminate();
serverWorkerFile = file;
serverWorker = new Worker(file);
logToDisk(`Created new server worker for ${file}`, testServerData.logFile);
}
// Every time we 'create' the worker, refresh its message handlers. This
// is essentially the same thing as creating a new worker.
removeAllListeners(serverWorker);
logToDisk(
`Removed all worker listeners. Test ${testServerData.testName} is starting.\n Last test was ${lastServerFinished.name} and finished: ${lastServerFinished.finished}`,
testServerData.logFile
);
serverWorker.on('error', (e) => {
logToDisk(`Worker error: ${e}`, testServerData.logFile);
});
serverWorker.on('exit', (code) => {
logToDisk(`Worker exit: ${code}`, testServerData.logFile);
});
return serverWorker;
}
export async function cleanupAfterAll() {
if (serverWorker) {
await serverWorker.terminate();
serverWorker = undefined;
}
}
export function getFileLikePath(uri: Uri): string {
return FileUri.isFileUri(uri) ? uri.getFilePath() : uri.toString();
}
export function createFileSystem(projectRoot: string, testData: FourSlashData, optionalHost?: host.TestHost) {
const mountedPaths = new Map<string, string>();
if (fs.existsSync(bundledStubsFolderPath)) {
mountedPaths.set(bundledStubsFolder, bundledStubsFolderPath);
} else if (fs.existsSync(bundledStubsFolderPathTestServer)) {
mountedPaths.set(bundledStubsFolder, bundledStubsFolderPathTestServer);
}
const vfsInfo = createVfsInfoFromFourSlashData(projectRoot, testData);
return createFromFileSystem(
optionalHost ?? host.HOST,
vfsInfo.ignoreCase,
{ cwd: vfsInfo.projectRoot, files: vfsInfo.files, meta: testData.globalOptions },
mountedPaths
);
}
const settingsMap = new Map<PyrightServerInfo, { item: ConfigurationItem; value: any }[]>();
export function updateSettingsMap(info: PyrightServerInfo, settings: { item: ConfigurationItem; value: any }[]) {
// Normalize the URIs for all of the settings.
settings.forEach((s) => {
if (s.item.scopeUri) {
s.item.scopeUri = Uri.parse(s.item.scopeUri, true).toString();
}
});
const current = settingsMap.get(info) || [];
settingsMap.set(info, [...settings, ...current]);
}
export function getParseResults(fileContents: string, isStubFile = false, ipythonMode: IPythonMode = 0) {
const diagSink = new DiagnosticSink();
const parseOptions = new ParseOptions();
parseOptions.ipythonMode = ipythonMode;
parseOptions.isStubFile = isStubFile;
parseOptions.pythonVersion = PythonVersion.V3_10;
parseOptions.skipFunctionAndClassBody = false;
// Parse the token stream, building the abstract syntax tree.
const parser = new Parser();
return parser.parseSourceFile(fileContents, parseOptions, diagSink);
}
function createServerConnection(testServerData: CustomLSP.TestServerStartOptions, disposables: Disposable[]) {
// Start a worker with the server running in it.
const serverPath = path.join(__dirname, '..', '..', '..', 'out', 'testServer.bundle.js');
assert(
fs.existsSync(serverPath),
`Server bundle does not exist: ${serverPath}. Make sure you ran the build script for test bundle (npm run webpack:testserver).`
);
const serverWorker = createServerWorker(serverPath, testServerData);
const options = {};
const connection = createConnection(
new PortMessageReader(serverWorker),
new PortMessageWriter(serverWorker),
options
);
disposables.push(connection);
return connection;
}
export async function waitForDiagnostics(info: PyrightServerInfo, timeout = 10000) {
const deferred = createDeferred<void>();
const disposable = info.diagnosticsEvent((params) => {
if (params.diagnostics.length > 0) {
deferred.resolve();
}
});
const timer = setTimeout(() => deferred.reject('Timed out waiting for diagnostics'), timeout);
try {
await deferred.promise;
} finally {
clearTimeout(timer);
disposable.dispose();
}
return info.diagnostics;
}
interface ProgressPart {}
interface ProgressContext {
onProgress<P>(type: ProgressType<P>, token: string | number, handler: NotificationHandler<P>): Disposable;
sendNotification<P, RO>(type: ProtocolNotificationType<P, RO>, params?: P): void;
}
class TestProgressPart implements ProgressPart {
constructor(
private readonly _context: ProgressContext,
private readonly _token: ProgressToken,
info: PyrightServerInfo,
done: () => void
) {
info.disposables.push(
info.connection.onProgress(WorkDoneProgress.type, _token, (params) => {
switch (params.kind) {
case 'begin':
info.progressReporterStatus.set(_token.toString(), 0);
break;
case 'report':
info.progressReporterStatus.set(_token.toString(), params.percentage ?? 0);
break;
case 'end':
done();
break;
}
})
);
info.progressReporters.push(this._token.toString());
info.progressParts.set(this._token.toString(), this);
}
sendCancel() {
this._context.sendNotification(WorkDoneProgressCancelNotification.type, { token: this._token });
}
}
export async function runPyrightServer(
projectRoots: string[] | string,
code: string,
callInitialize = true,
extraSettings?: { item: ConfigurationItem; value: any }[],
pythonVersion: PythonVersion = PythonVersion.V3_10,
backgroundAnalysis?: boolean
): Promise<PyrightServerInfo> {
// Normalize the URIs for all of the settings.
extraSettings?.forEach((s) => {
if (s.item.scopeUri) {
s.item.scopeUri = Uri.parse(s.item.scopeUri, true).toString();
}
});
// Setup the test data we need to send for Test server startup.
const projectRootsArray = Array.isArray(projectRoots) ? projectRoots : [projectRoots];
const testServerData: CustomLSP.TestServerStartOptions = {
testName: expect.getState().currentTestName ?? 'NoName',
code,
projectRoots: projectRootsArray.map((p) => (p.includes(':') ? Uri.parse(p, true) : Uri.file(p))),
pythonVersion,
backgroundAnalysis,
logFile: Uri.file(path.join(__dirname, `log${process.pid}.txt`)),
pid: process.pid.toString(),
};
logToDisk(`Starting test ${testServerData.testName}`, testServerData.logFile);
lastServerFinished = { name: testServerData.testName, finished: false };
// Parse the test data on this side as well. This allows the use of markers and such.
const testData = parseTestData(
testServerData.projectRoots.length === 1
? getFileLikePath(testServerData.projectRoots[0])
: DEFAULT_WORKSPACE_ROOT,
testServerData.code,
'noname.py'
);
// Start listening to the 'client' side of the connection.
const disposables: Disposable[] = [];
const connection = createServerConnection(testServerData, disposables);
const serverStarted = createDeferred<string>();
const diagnosticsEmitter = new Emitter<PublishDiagnosticsParams>();
const workspaceEditsEmitter = new Emitter<ApplyWorkspaceEditParams>();
// Setup the server info.
const info: PyrightServerInfo = {
disposables,
registrations: [],
connection,
logs: [],
progressReporters: [],
progressReporterStatus: new Map<string, number>(),
progressParts: new Map<string, TestProgressPart>(),
signals: new Map(Object.values(CustomLSP.TestSignalKinds).map((v) => [v, createDeferred<boolean>()])),
testData,
testName: testServerData.testName,
telemetry: [],
projectRoots: testServerData.projectRoots,
diagnostics: [],
diagnosticsEvent: diagnosticsEmitter.event,
workspaceEdits: [],
workspaceEditsEvent: workspaceEditsEmitter.event,
getInitializeParams: () => getInitializeParams(testServerData.projectRoots),
convertPathToUri: (path: string) => Uri.file(path),
dispose: async () => {
// Send shutdown. This should disconnect the dispatcher and kill the server.
await connection.sendRequest(ShutdownRequest.type, undefined);
// Now we can dispose the connection.
disposables.forEach((d) => d.dispose());
logToDisk(`Finished test ${testServerData.testName}`, testServerData.logFile);
},
};
info.disposables.push(
info.connection.onNotification(CustomLSP.Notifications.TestStartServerResponse, (p) => {
serverStarted.resolve(p.testName);
}),
info.connection.onRequest(RegistrationRequest.type, (p) => {
info.registrations.push(...p.registrations);
}),
info.connection.onNotification(CustomLSP.Notifications.TestSignal, (p: CustomLSP.TestSignal) => {
info.signals.get(p.kind)!.resolve(true);
}),
info.connection.onNotification(LogMessageNotification.type, (p) => {
info.logs.push(p);
}),
info.connection.onRequest(SemanticTokensRefreshRequest.type, () => {
// empty. Sliently ignore for now.
}),
info.connection.onRequest(InlayHintRefreshRequest.type, () => {
// empty. Sliently ignore for now.
}),
info.connection.onRequest(ApplyWorkspaceEditRequest.type, (p) => {
info.workspaceEdits.push(p);
workspaceEditsEmitter.fire(p);
return { applied: true };
}),
info.connection.onRequest(UnregistrationRequest.type, (p) => {
const unregisterIds = p.unregisterations.map((u) => u.id);
info.registrations = info.registrations.filter((r) => !unregisterIds.includes(r.id));
}),
info.connection.onRequest(WorkDoneProgressCreateRequest.type, (p) => {
// Save the progress reporter so we can send progress updates.
info.progressReporters.push(p.token.toString());
info.disposables.push(
info.connection.onProgress(WorkDoneProgress.type, p.token, (params) => {
switch (params.kind) {
case 'begin':
info.progressReporterStatus.set(p.token.toString(), 0);
break;
case 'report':
info.progressReporterStatus.set(p.token.toString(), params.percentage ?? 0);
break;
case 'end':
break;
}
})
);
}),
info.connection.onNotification(PublishDiagnosticsNotification.type, (p) => {
info.diagnostics.push(p);
diagnosticsEmitter.fire(p);
}),
info.connection.onNotification(TelemetryEventNotification.type, (p) => {
info.telemetry.push(p);
})
);
info.disposables.push(
info.connection.onRequest(ConfigurationRequest.type, (p) => {
const result = [];
const mappedSettings = settingsMap.get(info) || [];
for (const item of p.items) {
const setting = mappedSettings.find(
(s) =>
(s.item.scopeUri === item.scopeUri || s.item.scopeUri === undefined) &&
s.item.section === item.section
);
result.push(setting?.value);
}
return result;
})
);
// Merge the extra settings.
const settings: { item: ConfigurationItem; value: any }[] = [];
if (extraSettings) {
for (const extra of extraSettings) {
const existing = settings.find(
(s) => s.item.section === extra.item.section && s.item.scopeUri === extra.item.scopeUri
);
if (existing) {
existing.value = { ...existing.value, ...extra.value };
} else {
settings.push(extra);
}
}
}
settingsMap.set(info, settings);
// Wait for the server to be started.
connection.listen();
logToDisk(`Sending start notification for ${testServerData.testName}`, testServerData.logFile);
CustomLSP.sendNotification(connection, CustomLSP.Notifications.TestStartServer, testServerData);
const serverTestName = await serverStarted.promise;
assert.equal(serverTestName, testServerData.testName, 'Server started for wrong test');
logToDisk(`Started test ${testServerData.testName}`, testServerData.logFile);
// Initialize the server if requested.
if (callInitialize) {
await initializeLanguageServer(info);
logToDisk(`Initialized test ${testServerData.testName}`, testServerData.logFile);
}
if (lastServerFinished.name === testServerData.testName) {
lastServerFinished.finished = true;
} else {
logToDisk(`Last server finished was incorrectly updated to ${lastServerFinished.name}`, testServerData.logFile);
}
return info;
}
export async function initializeLanguageServer(info: PyrightServerInfo) {
const params = info.getInitializeParams();
// Send the initialize request.
const result = await info.connection.sendRequest(InitializeRequest.type, params, CancellationToken.None);
info.connection.sendNotification(InitializedNotification.type, {});
if (params.workspaceFolders?.length) {
info.connection.sendNotification(DidChangeWorkspaceFoldersNotification.type, {
event: {
added: params.workspaceFolders!,
removed: [],
},
});
// Wait until workspace initialization is done.
// This is required since some tests check status of server directly. In such case, even if the client sent notification,
// server might not have processed it and still in the event queue.
// This check makes sure server at least processed initialization before test checking server status directly.
// If test only uses response from client.sendRequest, then this won't be needed.
await info.signals.get(CustomLSP.TestSignalKinds.Initialization)!.promise;
}
return result;
}
export async function sleep(timeout: number): Promise<number> {
return new Promise<number>((resolve) => {
setTimeout(() => resolve(timeout), timeout);
});
}
export function openFile(info: PyrightServerInfo, markerName: string, text?: string) {
const marker = getMarkerByName(info.testData, markerName);
const uri = marker.fileUri.toString();
text = text ?? info.testData.files.find((f) => f.fileName === marker.fileName)!.content;
info.connection.sendNotification(DidOpenTextDocumentNotification.type, {
textDocument: { uri, languageId: 'python', version: 1, text },
});
}
export async function hover(info: PyrightServerInfo, markerName: string) {
const marker = info.testData.markerPositions.get('marker')!;
const fileUri = marker.fileUri;
const text = info.testData.files.find((d) => d.fileName === marker.fileName)!.content;
const parseResult = getParseResults(text);
const hoverResult = await info.connection.sendRequest(
HoverRequest.type,
{
textDocument: { uri: fileUri.toString() },
position: convertOffsetToPosition(marker.position, parseResult.tokenizerOutput.lines),
},
CancellationToken.None
);
return hoverResult;
}
export function getInitializeParams(projectRoots: Uri[]) {
// cloned vscode "1.71.0-insider"'s initialize params.
const workspaceFolders = projectRoots
? projectRoots.map((root, i) => ({ name: root.fileName, uri: projectRoots[i].toString() }))
: [];
const params: InitializeParams = {
processId: process.pid,
clientInfo: {
name: `Pylance Unit Test ${expect.getState().currentTestName}`,
version: '1.71.0-insider',
},
locale: 'en-us',
rootPath: null,
rootUri: null,
capabilities: {
workspace: {
applyEdit: true,
workspaceEdit: {
documentChanges: true,
resourceOperations: ['create', 'rename', 'delete'],
failureHandling: 'textOnlyTransactional',
normalizesLineEndings: true,
changeAnnotationSupport: {
groupsOnLabel: true,
},
},
configuration: true,
didChangeWatchedFiles: {
dynamicRegistration: true,
relativePatternSupport: true,
},
symbol: {
dynamicRegistration: true,
symbolKind: {
valueSet: [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25,
26,
],
},
tagSupport: {
valueSet: [1],
},
resolveSupport: {
properties: ['location.range'],
},
},
codeLens: {
refreshSupport: true,
},
executeCommand: {
dynamicRegistration: true,
},
didChangeConfiguration: {
dynamicRegistration: true,
},
workspaceFolders: true,
semanticTokens: {
refreshSupport: true,
},
fileOperations: {
dynamicRegistration: true,
didCreate: true,
didRename: true,
didDelete: true,
willCreate: true,
willRename: true,
willDelete: true,
},
inlineValue: {
refreshSupport: true,
},
inlayHint: {
refreshSupport: true,
},
diagnostics: {
refreshSupport: true,
},
},
textDocument: {
publishDiagnostics: {
relatedInformation: true,
versionSupport: false,
tagSupport: {
valueSet: [1, 2],
},
codeDescriptionSupport: true,
dataSupport: true,
},
synchronization: {
dynamicRegistration: true,
willSave: true,
willSaveWaitUntil: true,
didSave: true,
},
completion: {
dynamicRegistration: true,
contextSupport: true,
completionItem: {
snippetSupport: true,
commitCharactersSupport: true,
documentationFormat: ['markdown', 'plaintext'],
deprecatedSupport: true,
preselectSupport: true,
tagSupport: {
valueSet: [1],
},
insertReplaceSupport: true,
resolveSupport: {
properties: ['documentation', 'detail', 'additionalTextEdits'],
},
insertTextModeSupport: {
valueSet: [1, 2],
},
labelDetailsSupport: true,
},
insertTextMode: 2,
completionItemKind: {
valueSet: [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25,
],
},
completionList: {
itemDefaults: ['commitCharacters', 'editRange', 'insertTextFormat', 'insertTextMode'],
},
},
hover: {
dynamicRegistration: true,
contentFormat: ['markdown', 'plaintext'],
},
signatureHelp: {
dynamicRegistration: true,
signatureInformation: {
documentationFormat: ['markdown', 'plaintext'],
parameterInformation: {
labelOffsetSupport: true,
},
activeParameterSupport: true,
},
contextSupport: true,
},
definition: {
dynamicRegistration: true,
linkSupport: true,
},
references: {
dynamicRegistration: true,
},
documentHighlight: {
dynamicRegistration: true,
},
documentSymbol: {
dynamicRegistration: true,
symbolKind: {
valueSet: [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25,
26,
],
},
hierarchicalDocumentSymbolSupport: true,
tagSupport: {
valueSet: [1],
},
labelSupport: true,
},
codeAction: {
dynamicRegistration: true,
isPreferredSupport: true,
disabledSupport: true,
dataSupport: true,
resolveSupport: {
properties: ['edit'],
},
codeActionLiteralSupport: {
codeActionKind: {
valueSet: [
'',
'quickfix',
'refactor',
'refactor.extract',
'refactor.inline',
'refactor.rewrite',
'source',
'source.organizeImports',
],
},
},
honorsChangeAnnotations: false,
},
codeLens: {
dynamicRegistration: true,
},
formatting: {
dynamicRegistration: true,
},
rangeFormatting: {
dynamicRegistration: true,
},
onTypeFormatting: {
dynamicRegistration: true,
},
rename: {
dynamicRegistration: true,
prepareSupport: true,
prepareSupportDefaultBehavior: 1,
honorsChangeAnnotations: true,
},
documentLink: {
dynamicRegistration: true,
tooltipSupport: true,
},
typeDefinition: {
dynamicRegistration: true,
linkSupport: true,
},
implementation: {
dynamicRegistration: true,
linkSupport: true,
},
colorProvider: {
dynamicRegistration: true,
},
foldingRange: {
dynamicRegistration: true,
rangeLimit: 5000,
lineFoldingOnly: true,
foldingRangeKind: {
valueSet: ['comment', 'imports', 'region'],
},
foldingRange: {
collapsedText: false,
},
},
declaration: {
dynamicRegistration: true,
linkSupport: true,
},
selectionRange: {
dynamicRegistration: true,
},
callHierarchy: {
dynamicRegistration: true,
},
semanticTokens: {
dynamicRegistration: true,
tokenTypes: [
'namespace',
'type',
'class',
'enum',
'interface',
'struct',
'typeParameter',
'parameter',
'variable',
'property',
'enumMember',
'event',
'function',
'method',
'macro',
'keyword',
'modifier',
'comment',
'string',
'number',
'regexp',
'operator',
'decorator',
],
tokenModifiers: [
'declaration',
'definition',
'readonly',
'static',
'deprecated',
'abstract',
'async',
'modification',
'documentation',
'defaultLibrary',
],
formats: ['relative'],
requests: {
range: true,
full: {
delta: true,
},
},
multilineTokenSupport: false,
overlappingTokenSupport: false,
serverCancelSupport: true,
augmentsSyntaxTokens: true,
},
linkedEditingRange: {
dynamicRegistration: true,
},
typeHierarchy: {
dynamicRegistration: true,
},
inlineValue: {
dynamicRegistration: true,
},
inlayHint: {
dynamicRegistration: true,
resolveSupport: {
properties: ['tooltip', 'textEdits', 'label.tooltip', 'label.location', 'label.command'],
},
},
diagnostic: {
dynamicRegistration: true,
relatedDocumentSupport: false,
},
},
window: {
showMessage: {
messageActionItem: {
additionalPropertiesSupport: true,
},
},
showDocument: {
support: true,
},
workDoneProgress: true,
},
general: {
staleRequestSupport: {
cancel: true,
retryOnContentModified: [
'textDocument/semanticTokens/full',
'textDocument/semanticTokens/range',
'textDocument/semanticTokens/full/delta',
],
},
regularExpressions: {
engine: 'ECMAScript',
version: 'ES2020',
},
markdown: {
parser: 'marked',
version: '1.1.0',
},
positionEncodings: ['utf-16'],
},
notebookDocument: {
synchronization: {
dynamicRegistration: true,
executionSummarySupport: true,
},
},
},
initializationOptions: {
autoFormatStrings: true,
},
workspaceFolders,
};
return params;
}
export class TestHost extends LimitedAccessHost {
private readonly _options: TestHostOptions;
constructor(
readonly fs: FileSystem,
readonly testFs: vfs.TestFileSystem,
readonly testData: FourSlashData,
readonly projectRoots: string[],
options?: TestHostOptions
) {
super();
this._options = options ?? new TestHostOptions();
}
override get kind(): HostKind {
return HostKind.FullAccess;
}
override getPythonVersion(pythonPath?: Uri, logInfo?: string[]): PythonVersion | undefined {
return this._options.version;
}
override getPythonPlatform(logInfo?: string[]): PythonPlatform | undefined {
return this._options.platform;
}
override getPythonSearchPaths(pythonPath?: Uri, logInfo?: string[]): PythonPathResult {
return {
paths: this._options.searchPaths,
prefix: Uri.empty(),
};
}
override runScript(
pythonPath: Uri | undefined,
scriptPath: Uri,
args: string[],
cwd: Uri,
token: CancellationToken
): Promise<ScriptOutput> {
return this._options.runScript(pythonPath, scriptPath, args, cwd, token);
}
}

View File

@ -0,0 +1,11 @@
/*
* main.ts
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT license.
*
* Provides the main entrypoint to the test server when running in Node.
*/
import { run } from './languageServer';
run();

View File

@ -0,0 +1,65 @@
/**
* webpack.config-cli.js
* Copyright: Microsoft 2018
*/
const path = require('path');
const CopyPlugin = require('copy-webpack-plugin');
const { tsconfigResolveAliases } = require('../../../../../build/lib/webpack');
const outPath = path.resolve(__dirname, '..', '..', '..', 'out');
const typeshedFallback = path.resolve(__dirname, '..', '..', '..', 'typeshed-fallback');
/**@type {(env: any, argv: { mode: 'production' | 'development' | 'none' }) => import('webpack').Configuration}*/
module.exports = (_, { mode }) => {
return {
context: __dirname,
entry: {
testServer: './main.ts',
},
target: 'node',
output: {
filename: '[name].bundle.js',
path: outPath,
libraryTarget: 'commonjs2',
devtoolModuleFilenameTemplate: '[absolute-resource-path]',
},
devtool: 'source-map',
stats: {
all: false,
errors: true,
warnings: true,
publicPath: true,
timings: true,
},
resolve: {
extensions: ['.ts', '.js'],
alias: tsconfigResolveAliases('tsconfig.json'),
},
externals: {
fsevents: 'commonjs2 fsevents',
},
module: {
rules: [
{
test: /\.ts$/,
loader: 'ts-loader',
options: {
configFile: 'tsconfig.json',
},
},
{
// Transform pre-compiled JS files to use syntax available in Node 12+.
// esbuild is fast, so let it run on all JS files rather than matching
// only known-bad libs.
test: /\.js$/,
loader: 'esbuild-loader',
options: {
target: 'node12',
},
},
],
},
plugins: [new CopyPlugin({ patterns: [{ from: typeshedFallback, to: 'typeshed-fallback' }] })],
};
};

View File

@ -1,7 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./out"
"outDir": "./out",
"paths": {
}
},
"include": [
"src/**/*",