test(vipeye-internal): 删除剩余 18 个内联 .py 夹具套件,测试集转为 Viper-only
Validation / Typecheck (push) Has been cancelled
Validation / Style (push) Has been cancelled
Validation / Test macos-latest (push) Has been cancelled
Validation / Test ubuntu-latest (push) Has been cancelled
Validation / Test windows-latest (push) Has been cancelled
Validation / Build (push) Has been cancelled
Validation / Required (push) Has been cancelled

- 删除 completions/signatureHelp/diagnostics/service/languageServer/importResolver(+Support)/
  importStatementUtils/documentSymbolCollector/chainedSourceFiles/sourceMapperUtils/workspaceEditUtils/
  testState/classDeclaration/wildcardImportPackageMerge/privateImportUsage/typeServer 等 18 个套件
- 保留并加注 tests/fourslash/typings/fourslash.d.ts:其 declare namespace _ 仍被
  harness/fourslash/testState.ts 引用,删除会导致 ts-jest TS2694 整批套件无法运行

验证: 全量串行 53 套件通过、1 跳过,732 用例全绿(0 失败)
This commit is contained in:
TermiNexus committed 2026-09-18 23:05:17 +08:00
1 parent a955a5356f
commit 04d5a32502
19 files changed
+455 -10345

No files matched your search

@@ -1,491 +0,0 @@
/*
* chainedSourceFiles.test.ts
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT license.
*
* Unit tests for tokenizer ipython mode
*/
import assert from 'assert';
import { CancellationToken } from 'vscode-jsonrpc';
import { MarkupKind } from 'vscode-languageserver-types';
import { Program } from '../analyzer/program';
import { AnalyzerService } from '../analyzer/service';
import { IPythonMode } from '../analyzer/sourceFile';
import { ConfigOptions } from '../common/configOptions';
import { NullConsole } from '../common/console';
import { normalizeSlashes } from '../common/pathUtils';
import { convertOffsetsToRange, convertOffsetToPosition } from '../common/positionUtils';
import { ServiceProvider } from '../common/serviceProvider';
import { Uri } from '../common/uri/uri';
import { UriEx } from '../common/uri/uriUtils';
import { CompletionProvider } from '../languageService/completionProvider';
import { parseTestData } from './harness/fourslash/fourSlashParser';
import { TestAccessHost } from './harness/testAccessHost';
import * as host from './harness/testHost';
import { createFromFileSystem, distlibFolder, libFolder } from './harness/vfs/factory';
import * as vfs from './harness/vfs/filesystem';
test('check chained files', () => {
const code = `
// @filename: test1.py
//// def foo1(): pass
// @filename: test2.py
//// def foo2(): pass
// @filename: test3.py
//// def foo3(): pass
// @filename: test4.py
//// [|foo/*marker*/|]
`;
const basePath = UriEx.file(normalizeSlashes('/'));
const { data, service } = createServiceWithChainedSourceFiles(basePath, code);
const marker = data.markerPositions.get('marker')!;
const markerUri = marker.fileUri;
const parseResult = service.getParseResults(markerUri)!;
const result = new CompletionProvider(
service.test_program,
markerUri,
convertOffsetToPosition(marker.position, parseResult.tokenizerOutput.lines),
{
format: MarkupKind.Markdown,
lazyEdit: false,
snippet: false,
},
CancellationToken.None
).getCompletions();
assert(result?.items.some((i) => i.label === 'foo1'));
assert(result?.items.some((i) => i.label === 'foo2'));
assert(result?.items.some((i) => i.label === 'foo3'));
});
test('modify chained files', () => {
const code = `
// @filename: test1.py
//// def foo1(): pass
// @filename: test2.py
//// [|/*delete*/|]
//// def foo2(): pass
// @filename: test3.py
//// def foo3(): pass
// @filename: test4.py
//// [|foo/*marker*/|]
`;
const basePath = UriEx.file(normalizeSlashes('/'));
const { data, service } = createServiceWithChainedSourceFiles(basePath, code);
// Make sure files are all realized.
const marker = data.markerPositions.get('marker')!;
const markerUri = marker.fileUri;
const parseResult = service.getParseResults(markerUri)!;
// Close file in the middle of the chain
service.setFileClosed(data.markerPositions.get('delete')!.fileUri);
// Make sure we don't get suggestion from auto import but from chained files.
service.test_program.configOptions.autoImportCompletions = false;
const result = new CompletionProvider(
service.test_program,
markerUri,
convertOffsetToPosition(marker.position, parseResult.tokenizerOutput.lines),
{
format: MarkupKind.Markdown,
lazyEdit: false,
snippet: false,
},
CancellationToken.None
).getCompletions();
assert(result);
assert(!result.items.some((i) => i.label === 'foo1'));
assert(!result.items.some((i) => i.label === 'foo2'));
assert(result.items.some((i) => i.label === 'foo3'));
});
test('modify chained files', async () => {
const code = `
// @filename: test1.py
//// [|/*changed*/|]
//// def foo1(): pass
// @filename: test2.py
//// def foo2(): pass
// @filename: test3.py
//// def foo3(): pass
// @filename: test4.py
//// [|/*marker*/foo1()|]
`;
const basePath = UriEx.file(normalizeSlashes('/'));
const { data, service } = createServiceWithChainedSourceFiles(basePath, code);
const marker = data.markerPositions.get('marker')!;
const markerUri = marker.fileUri;
const range = data.ranges.find((r) => r.marker === marker)!;
const parseResults = service.getParseResults(markerUri)!;
analyze(service.test_program);
// Initially, there should be no error.
const initialDiags = await service.getDiagnosticsForRange(
markerUri,
convertOffsetsToRange(range.pos, range.end, parseResults.tokenizerOutput.lines),
CancellationToken.None
);
assert.strictEqual(initialDiags.length, 0);
// Change test1 content
service.updateOpenFileContents(data.markerPositions.get('changed')!.fileUri, 2, 'def foo5(): pass');
analyze(service.test_program);
const finalDiags = await service.getDiagnosticsForRange(
markerUri,
convertOffsetsToRange(range.pos, range.end, parseResults.tokenizerOutput.lines),
CancellationToken.None
);
assert.strictEqual(finalDiags.length, 1);
});
function generateChainedFiles(count: number, lastFile: string) {
let code = '';
for (let i = 0; i < count; i++) {
code += `
// @filename: test${i + 1}.py
//// def foo${i + 1}(): pass
`;
}
code += lastFile;
return code;
}
test('chained files with 1000s of files', async () => {
const lastFile = `
// @filename: testFinal.py
//// [|/*marker*/foo1()|]
`;
const code = generateChainedFiles(1000, lastFile);
const basePath = UriEx.file(normalizeSlashes('/'));
const { data, service } = createServiceWithChainedSourceFiles(basePath, code);
const marker = data.markerPositions.get('marker')!;
const markerUri = marker.fileUri;
const range = data.ranges.find((r) => r.marker === marker)!;
const parseResults = service.getParseResults(markerUri)!;
analyze(service.test_program);
// There should be no error as it should find the foo1 in the first chained file.
const initialDiags = await service.getDiagnosticsForRange(
markerUri,
convertOffsetsToRange(range.pos, range.end, parseResults.tokenizerOutput.lines),
CancellationToken.None
);
assert.strictEqual(initialDiags.length, 0);
});
test('imported by files', async () => {
const code = `
// @filename: test1.py
//// import [|/*marker*/os|]
// @filename: test2.py
//// os.path.join()
`;
const basePath = UriEx.file(normalizeSlashes('/'));
const { data, service } = createServiceWithChainedSourceFiles(basePath, code);
analyze(service.test_program);
const marker = data.markerPositions.get('marker')!;
const markerUri = marker.fileUri;
const range = data.ranges.find((r) => r.marker === marker)!;
const parseResults = service.getParseResults(markerUri)!;
const diagnostics = await service.getDiagnosticsForRange(
markerUri,
convertOffsetsToRange(range.pos, range.end, parseResults.tokenizerOutput.lines),
CancellationToken.None
);
assert.strictEqual(diagnostics.length, 0);
});
test('re ordering cells', async () => {
const code = `
// @filename: test1.py
//// import [|/*marker*/os|]
// @filename: test2.py
//// /*bottom*/os.path.join()
`;
const basePath = UriEx.file(normalizeSlashes('/'));
const { data, service } = createServiceWithChainedSourceFiles(basePath, code);
analyze(service.test_program);
const marker = data.markerPositions.get('marker')!;
const markerUri = marker.fileUri;
const range = data.ranges.find((r) => r.marker === marker)!;
const bottom = data.markerPositions.get('bottom')!;
const bottomUri = bottom.fileUri;
service.updateChainedUri(bottomUri, undefined);
service.updateChainedUri(markerUri, bottomUri);
analyze(service.test_program);
const parseResults = service.getParseResults(markerUri)!;
const diagnostics = await service.getDiagnosticsForRange(
markerUri,
convertOffsetsToRange(range.pos, range.end, parseResults.tokenizerOutput.lines),
CancellationToken.None
);
assert.strictEqual(diagnostics.length, 1);
});
test('later chained-file globals resolved in function body', async () => {
const code = `
// @filename: test1.py
//// def use_x():
//// [|/*marker*/return x + 1|]
// @filename: test2.py
//// x = 42
`;
const basePath = UriEx.file(normalizeSlashes('/'));
const { data, service } = createServiceWithChainedSourceFiles(basePath, code);
analyze(service.test_program);
const marker = data.markerPositions.get('marker')!;
const markerUri = marker.fileUri;
const range = data.ranges.find((r) => r.marker === marker)!;
const parseResults = service.getParseResults(markerUri)!;
const diagnostics = await service.getDiagnosticsForRange(
markerUri,
convertOffsetsToRange(range.pos, range.end, parseResults.tokenizerOutput.lines),
CancellationToken.None
);
assert.strictEqual(diagnostics.length, 0);
});
test('later chained-file globals resolved in lambda body', async () => {
const code = `
// @filename: test1.py
//// [|/*marker*/f = lambda: x * 2|]
// @filename: test2.py
//// x = 10
`;
const basePath = UriEx.file(normalizeSlashes('/'));
const { data, service } = createServiceWithChainedSourceFiles(basePath, code);
analyze(service.test_program);
const marker = data.markerPositions.get('marker')!;
const markerUri = marker.fileUri;
const range = data.ranges.find((r) => r.marker === marker)!;
const parseResults = service.getParseResults(markerUri)!;
const diagnostics = await service.getDiagnosticsForRange(
markerUri,
convertOffsetsToRange(range.pos, range.end, parseResults.tokenizerOutput.lines),
CancellationToken.None
);
assert.strictEqual(diagnostics.length, 0);
});
test('later chained-file globals resolved in class header', async () => {
const code = `
// @filename: test1.py
//// [|/*marker*/class MyClass(Base):|]
//// pass
// @filename: test2.py
//// class Base:
//// pass
`;
const basePath = UriEx.file(normalizeSlashes('/'));
const { data, service } = createServiceWithChainedSourceFiles(basePath, code);
analyze(service.test_program);
const marker = data.markerPositions.get('marker')!;
const markerUri = marker.fileUri;
const range = data.ranges.find((r) => r.marker === marker)!;
const parseResults = service.getParseResults(markerUri)!;
const diagnostics = await service.getDiagnosticsForRange(
markerUri,
convertOffsetsToRange(range.pos, range.end, parseResults.tokenizerOutput.lines),
CancellationToken.None
);
assert.strictEqual(diagnostics.length, 0);
});
test('later chained-file globals resolved in comprehension inside function', async () => {
const code = `
// @filename: test1.py
//// def build_list():
//// [|/*marker*/return [i * x for i in [1, 2, 3]]|]
// @filename: test2.py
//// x = 5
`;
const basePath = UriEx.file(normalizeSlashes('/'));
const { data, service } = createServiceWithChainedSourceFiles(basePath, code);
analyze(service.test_program);
const marker = data.markerPositions.get('marker')!;
const markerUri = marker.fileUri;
const range = data.ranges.find((r) => r.marker === marker)!;
const parseResults = service.getParseResults(markerUri)!;
const diagnostics = await service.getDiagnosticsForRange(
markerUri,
convertOffsetsToRange(range.pos, range.end, parseResults.tokenizerOutput.lines),
CancellationToken.None
);
assert.strictEqual(diagnostics.length, 0);
});
test('later chained-file globals resolved in nested function', async () => {
const code = `
// @filename: test1.py
//// def outer():
//// def inner():
//// [|/*marker*/return x|]
//// return inner
// @filename: test2.py
//// x = 99
`;
const basePath = UriEx.file(normalizeSlashes('/'));
const { data, service } = createServiceWithChainedSourceFiles(basePath, code);
analyze(service.test_program);
const marker = data.markerPositions.get('marker')!;
const markerUri = marker.fileUri;
const range = data.ranges.find((r) => r.marker === marker)!;
const parseResults = service.getParseResults(markerUri)!;
const diagnostics = await service.getDiagnosticsForRange(
markerUri,
convertOffsetsToRange(range.pos, range.end, parseResults.tokenizerOutput.lines),
CancellationToken.None
);
assert.strictEqual(diagnostics.length, 0);
});
test('later chained-file globals not visible without chaining', async () => {
// Verify that the same code produces errors without chained files
// (i.e. our test scenarios actually catch a real bug).
const code = `
// @filename: test1.py
//// def use_x():
//// [|/*marker*/return x + 1|]
`;
const basePath = UriEx.file(normalizeSlashes('/'));
const { data, service } = createServiceWithChainedSourceFiles(basePath, code);
analyze(service.test_program);
const marker = data.markerPositions.get('marker')!;
const markerUri = marker.fileUri;
const range = data.ranges.find((r) => r.marker === marker)!;
const parseResults = service.getParseResults(markerUri)!;
const diagnostics = await service.getDiagnosticsForRange(
markerUri,
convertOffsetsToRange(range.pos, range.end, parseResults.tokenizerOutput.lines),
CancellationToken.None
);
// Should have an error because x is not defined anywhere
assert(diagnostics.length > 0);
});
test('later chained-file globals with multiple files and mixed scopes', async () => {
const code = `
// @filename: test1.py
//// def use_both():
//// [|/*marker*/return x + y|]
// @filename: test2.py
//// x = 42
// @filename: test3.py
//// y = 100
`;
const basePath = UriEx.file(normalizeSlashes('/'));
const { data, service } = createServiceWithChainedSourceFiles(basePath, code);
analyze(service.test_program);
const marker = data.markerPositions.get('marker')!;
const markerUri = marker.fileUri;
const range = data.ranges.find((r) => r.marker === marker)!;
const parseResults = service.getParseResults(markerUri)!;
const diagnostics = await service.getDiagnosticsForRange(
markerUri,
convertOffsetsToRange(range.pos, range.end, parseResults.tokenizerOutput.lines),
CancellationToken.None
);
assert.strictEqual(diagnostics.length, 0);
});
function createServiceWithChainedSourceFiles(basePath: Uri, code: string) {
const fs = createFromFileSystem(host.HOST, /*ignoreCase*/ false, { cwd: basePath.getFilePath() });
const service = new AnalyzerService('test service', new ServiceProvider(), {
console: new NullConsole(),
hostFactory: () => new TestAccessHost(UriEx.file(vfs.MODULE_PATH), [libFolder, distlibFolder]),
importResolverFactory: AnalyzerService.createImportResolver,
configOptions: new ConfigOptions(basePath),
fileSystem: fs,
shouldRunAnalysis: () => true,
});
const data = parseTestData(basePath.getFilePath(), code, '');
let chainedFilePath: Uri | undefined;
for (const file of data.files) {
const uri = file.fileUri;
service.setFileOpened(uri, 1, file.content, IPythonMode.CellDocs, chainedFilePath);
chainedFilePath = uri;
}
return { data, service };
}
function analyze(program: Program) {
while (program.analyze()) {
// Process all queued items
}
}
@@ -1,198 +0,0 @@
/*
* classDeclaration.test.ts
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT license.
*
* Test class detail's declaration
*/
import assert from 'assert';
import { isClassDeclaration, isSpecialBuiltInClassDeclaration } from '../analyzer/declaration';
import { getEnclosingFunction } from '../analyzer/parseTreeUtils';
import { isProperty } from '../analyzer/typeUtils';
import { TypeCategory, isClassInstance } from '../analyzer/types';
import { TextRange } from '../common/textRange';
import { ParseNodeType } from '../parser/parseNodes';
import { Range } from './harness/fourslash/fourSlashTypes';
import { TestState, getNodeAtMarker, parseAndGetTestState } from './harness/fourslash/testState';
test('regular class', () => {
const code = `
// @filename: test.py
//// [|class /*marker*/A:
//// pass|]
`;
checkClassDetail(code);
});
test('Meta class', () => {
const code = `
// @filename: test.py
//// [|class /*range*/MyMeta(type):
//// def __new__(cls, name, bases, dct):
//// return super().__new__(cls, name, bases, dct)|]
////
//// class MyClass(metaclass=MyMeta):
//// pass
////
//// /*marker*/E = MyMeta()
`;
checkClassDetail(code, '__class_MyMeta');
});
test('special built in class', () => {
const code = `
// @filename: test.py
//// from typing import TypedDict
//// def foo(t: /*marker*/TypedDict): ...
`;
checkSpecialBuiltInClassDetail(code);
});
test('dynamic enum', () => {
const code = `
// @filename: test.py
//// from enum import Enum
//// /*marker*/E = Enum('E', { 'One': 1 })
`;
checkNoDeclarationInClassDetail(code);
});
test('dynamic named tuple', () => {
const code = `
// @filename: test.py
//// from typing import NamedTuple
//// /*marker*/N = NamedTuple("N", [('name', str)])
`;
checkNoDeclarationInClassDetail(code);
});
test('dynamic typed dict', () => {
const code = `
// @filename: test.py
//// from typing import TypedDict
//// /*marker*/T = TypedDict("T", { "one": str })
`;
checkNoDeclarationInClassDetail(code);
});
test('dynamic new type', () => {
const code = `
// @filename: test.py
//// from typing import NewType
//// /*marker*/I = NewType('I', int)
`;
checkNoDeclarationInClassDetail(code);
});
test('dynamic type', () => {
const code = `
// @filename: test.py
//// /*marker*/D = type('D', (object,), {})
`;
checkNoDeclarationInClassDetail(code);
});
test('property', () => {
const code = `
// @filename: test.py
//// class MyClass:
//// def __init__(self):
//// self._v = None
////
//// @property
//// def /*getter*/value(self):
//// return self._v
////
//// @value.setter
//// def /*setter*/value(self, value):
//// self._v = value
////
//// @value.deleter
//// def /*deleter*/value(self):
//// del self._v
`;
const state = parseAndGetTestState(code).state;
['getter', 'setter', 'deleter'].forEach((marker) => {
const node = getNodeAtMarker(state, marker);
assert(node.nodeType === ParseNodeType.Name);
const functionNode = getEnclosingFunction(node);
assert(functionNode?.nodeType === ParseNodeType.Function);
const result = state.program.evaluator!.getTypeOfFunction(functionNode);
assert(result?.decoratedType);
assert(isProperty(result.decoratedType));
assert(isClassInstance(result.decoratedType));
assert(result.decoratedType.shared.declaration);
assert(isClassDeclaration(result.decoratedType.shared.declaration));
assert(result.decoratedType.shared.declaration.moduleName === 'builtins');
assert(result.decoratedType.shared.declaration.node.d.name.d.value === 'property');
});
});
function checkSpecialBuiltInClassDetail(code: string) {
const state = parseAndGetTestState(code).state;
const node = getNodeAtMarker(state);
assert(node.nodeType === ParseNodeType.Name);
const type = state.program.evaluator!.getType(node);
assert(type?.category === TypeCategory.Class);
assert.strictEqual(node.d.value, type.priv.aliasName ?? type.shared.name);
assert(type.shared.declaration);
if (type.priv.aliasName) {
assert(isClassDeclaration(type.shared.declaration));
} else {
assert(isSpecialBuiltInClassDeclaration(type.shared.declaration));
}
}
function checkNoDeclarationInClassDetail(code: string) {
const state = parseAndGetTestState(code).state;
_checkClassDetail(state, undefined);
}
function checkClassDetail(code: string, name?: string) {
const state = parseAndGetTestState(code).state;
_checkClassDetail(state, state.getRangeByMarkerName('marker') ?? state.getRangeByMarkerName('range'), name);
}
function _checkClassDetail(state: TestState, range: Range | undefined, name?: string) {
const node = getNodeAtMarker(state);
assert(node.nodeType === ParseNodeType.Name);
const type = state.program.evaluator!.getType(node);
assert(type?.category === TypeCategory.Class);
assert.strictEqual(name ?? node.d.value, type.priv.aliasName ?? type.shared.name);
if (range) {
assert(type.shared.declaration);
assert(isClassDeclaration(type.shared.declaration));
assert.deepStrictEqual(
TextRange.create(type.shared.declaration.node.start, type.shared.declaration.node.length),
TextRange.fromBounds(range.pos, range.end)
);
} else {
// There should be no decl.
assert(!type.shared.declaration);
}
}
File diff suppressed because it is too large. Load diff
@@ -1,85 +0,0 @@
/*
* diagnostics.test.ts
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT license.
*
* Unit tests for diagnostics
*/
import assert from 'assert';
import { DiagnosticRule } from '../common/diagnosticRules';
import { Range } from '../common/textRange';
import { parseAndGetTestState } from './harness/fourslash/testState';
// A range that spans the whole document so range intersection never filters diagnostics out.
const wholeFileRange: Range = {
start: { line: 0, character: 0 },
end: { line: 1_000_000, character: 0 },
};
test('unused import', async () => {
const code = `
// @filename: test1.py
//// from test2 import [|/*marker*/foo|]
// @filename: test2.py
//// def foo(): pass
`;
const state = parseAndGetTestState(code).state;
state.verifyDiagnostics({
marker: { category: 'unused', message: '"foo" is not accessed' },
});
});
test('viper ignore unused import', async () => {
const code = `
// @filename: test1.py
//// from test2 import [|/*marker*/foo|] # viper: ignore
// @filename: test2.py
//// def foo(): pass
`;
const state = parseAndGetTestState(code).state;
state.verifyDiagnostics({
marker: { category: 'none', message: '' },
});
});
test('getDiagnosticsForRangeWithoutFileIgnore analyzes the file on demand when its cache is empty', () => {
// Regression for the foreground/background Program split: in the product the published
// diagnostics are produced by the background-analysis Program, so the foreground Program
// that serves code-action requests may never have checked the file, leaving its pre-ignore
// diagnostics cache empty. The getter must populate that cache on demand.
const code = `
// @filename: viperconfig.json
//// {
//// "ignore": ["**/test.py"]
//// }
// @filename: test.py
//// /*marker*/undefinedSymbol
`;
const state = parseAndGetTestState(code).state;
const marker = state.getMarkerByName('marker');
state.openFile(marker.fileName);
// Intentionally do NOT analyze: this mirrors a foreground Program that has not checked the
// file. The file is ignored, so its published diagnostics are empty regardless.
assert.strictEqual(state.program.getDiagnosticsForRange(marker.fileUri, wholeFileRange).length, 0);
// The pre-ignore getter should still surface the unknown-symbol diagnostic by analyzing the
// file on demand. Without the on-demand analysis this returns [] and the quick fixes vanish.
const preIgnoreDiagnostics = state.program.getDiagnosticsForRangeWithoutFileIgnore(marker.fileUri, wholeFileRange);
assert(
preIgnoreDiagnostics.some((diag) => diag.getRule() === DiagnosticRule.reportUndefinedVariable),
`Expected a reportUndefinedVariable diagnostic, got ${JSON.stringify(
preIgnoreDiagnostics.map((diag) => ({ rule: diag.getRule(), message: diag.message }))
)}`
);
});
@@ -1,399 +0,0 @@
/*
* documentSymbolCollector.test.ts
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT license.
*
* Tests documentSymbolCollector
*/
import { parseAndGetTestState } from './harness/fourslash/testState';
import { verifyReferencesAtPosition } from './testStateUtils';
test('folder reference', () => {
const code = `
// @filename: common/__init__.py
//// from [|io2|] import tools as tools
//// from [|io2|].tools import pathUtils as pathUtils
// @filename: io2/empty.py
//// # empty
// @filename: io2/tools/__init__.py
//// def combine(a, b):
//// pass
// @filename: io2/tools/pathUtils.py
//// def getFilename(path):
//// pass
// @filename: test1.py
//// from common import *
////
//// tools.combine(1, 1)
//// pathUtils.getFilename("c")
// @filename: test2.py
//// from .[|io2|] import tools as t
////
//// t.combine(1, 1)
// @filename: test3.py
//// from .[|io2|].tools import pathUtils as p
////
//// p.getFilename("c")
// @filename: test4.py
//// from common import tools, pathUtils
////
//// tools.combine(1, 1)
//// pathUtils.getFilename("c")
// @filename: test5.py
//// from [|io2|] import tools as tools
//// from [|io2|].tools import pathUtils as pathUtils
////
//// tools.combine(1, 1)
//// pathUtils.getFilename("c")
`;
const state = parseAndGetTestState(code).state;
const ranges = state.getRangesByText().get('io2')!;
for (const range of ranges) {
verifyReferencesAtPosition(state.program, state.configOptions, 'io2', range.fileName, range.pos, ranges);
}
});
test('__init__ wildcard import', () => {
const code = `
// @filename: common/__init__.py
//// from io2 import [|tools|] as [|tools|]
//// from io2.[|tools|] import pathUtils as pathUtils
// @filename: io2/empty.py
//// # empty
// @filename: io2/tools/__init__.py
//// def combine(a, b):
//// pass
// @filename: io2/tools/pathUtils.py
//// def getFilename(path):
//// pass
// @filename: test1.py
//// from common import *
////
//// [|tools|].combine(1, 1)
//// pathUtils.getFilename("c")
// @filename: test2.py
//// from .io2 import [|tools|] as t
////
//// t.combine(1, 1)
// @filename: test3.py
//// from .io2.[|tools|] import pathUtils as p
////
//// p.getFilename("c")
// @filename: test4.py
//// from common import [|tools|], pathUtils
////
//// [|tools|].combine(1, 1)
//// pathUtils.getFilename("c")
// @filename: test5.py
//// from io2 import [|tools|] as [|tools|]
//// from io2.[|tools|] import pathUtils as pathUtils
////
//// [|tools|].combine(1, 1)
//// pathUtils.getFilename("c")
`;
const state = parseAndGetTestState(code).state;
const ranges = state.getRangesByText().get('tools')!;
for (const range of ranges) {
verifyReferencesAtPosition(state.program, state.configOptions, 'tools', range.fileName, range.pos, ranges);
}
});
test('submodule wildcard import', () => {
const code = `
// @filename: common/__init__.py
//// from io2 import tools as tools
//// from io2.tools import [|pathUtils|] as [|pathUtils|]
// @filename: io2/empty.py
//// # empty
// @filename: io2/tools/__init__.py
//// def combine(a, b):
//// pass
// @filename: io2/tools/pathUtils.py
//// def getFilename(path):
//// pass
// @filename: test1.py
//// from common import *
////
//// tools.combine(1, 1)
//// [|pathUtils|].getFilename("c")
// @filename: test2.py
//// from .io2 import tools as t
////
//// t.combine(1, 1)
// @filename: test3.py
//// from .io2.tools import [|pathUtils|] as p
////
//// p.getFilename("c")
// @filename: test4.py
//// from common import tools, [|pathUtils|]
////
//// tools.combine(1, 1)
//// [|pathUtils|].getFilename("c")
// @filename: test5.py
//// from io2 import tools as tools
//// from io2.tools import [|pathUtils|] as [|pathUtils|]
////
//// tools.combine(1, 1)
//// [|pathUtils|].getFilename("c")
`;
const state = parseAndGetTestState(code).state;
const ranges = state.getRangesByText().get('pathUtils')!;
for (const range of ranges) {
verifyReferencesAtPosition(state.program, state.configOptions, 'pathUtils', range.fileName, range.pos, ranges);
}
});
test('use localName import alias', () => {
const code = `
// @filename: common/__init__.py
//// from io2 import tools as [|/*marker1*/tools|]
//// from io2.tools import pathUtils as pathUtils
// @filename: io2/empty.py
//// # empty
// @filename: io2/tools/__init__.py
//// def combine(a, b):
//// pass
// @filename: io2/tools/pathUtils.py
//// def getFilename(path):
//// pass
// @filename: test1.py
//// from common import *
////
//// [|/*marker2*/tools|].combine(1, 1)
//// pathUtils.getFilename("c")
// @filename: test2.py
//// from .io2 import tools as t
////
//// t.combine(1, 1)
// @filename: test3.py
//// from .io2.tools import pathUtils as p
////
//// p.getFilename("c")
// @filename: test4.py
//// from common import [|/*marker3*/tools|], pathUtils
////
//// [|/*marker4*/tools|].combine(1, 1)
//// pathUtils.getFilename("c")
// @filename: test5.py
//// from io2 import tools as [|/*marker5*/tools|]
//// from io2.tools import pathUtils as pathUtils
////
//// [|/*marker6*/tools|].combine(1, 1)
//// pathUtils.getFilename("c")
`;
const state = parseAndGetTestState(code).state;
const references = state
.getRangesByText()
.get('tools')!
.map((r) => ({ uri: r.fileUri, range: state.convertPositionRange(r) }));
state.verifyFindAllReferences({
marker1: { references },
marker2: { references },
marker3: { references },
marker4: { references },
marker5: { references },
marker6: { references },
});
});
test('use localName import module', () => {
const code = `
// @filename: common/__init__.py
//// from io2 import [|/*marker1*/tools|] as [|tools|]
//// from io2.[|/*marker2*/tools|] import pathUtils as pathUtils
// @filename: io2/empty.py
//// # empty
// @filename: io2/tools/__init__.py
//// def combine(a, b):
//// pass
// @filename: io2/tools/pathUtils.py
//// def getFilename(path):
//// pass
// @filename: test1.py
//// from common import *
////
//// [|tools|].combine(1, 1)
//// pathUtils.getFilename("c")
// @filename: test2.py
//// from .io2 import [|/*marker3*/tools|] as t
////
//// t.combine(1, 1)
// @filename: test3.py
//// from .io2.[|/*marker4*/tools|] import pathUtils as p
////
//// p.getFilename("c")
// @filename: test4.py
//// from common import [|tools|], pathUtils
////
//// [|tools|].combine(1, 1)
//// pathUtils.getFilename("c")
// @filename: test5.py
//// from io2 import [|/*marker5*/tools|] as [|tools|]
//// from io2.[|/*marker6*/tools|] import pathUtils as pathUtils
////
//// [|tools|].combine(1, 1)
//// pathUtils.getFilename("c")
`;
const state = parseAndGetTestState(code).state;
const references = state
.getRangesByText()
.get('tools')!
.map((r) => ({ uri: r.fileUri, range: state.convertPositionRange(r) }));
state.verifyFindAllReferences({
marker1: { references },
marker2: { references },
marker3: { references },
marker4: { references },
marker5: { references },
marker6: { references },
});
});
test('import dotted name', () => {
const code = `
// @filename: nest1/__init__.py
//// # empty
// @filename: nest1/nest2/__init__.py
//// # empty
// @filename: nest1/nest2/module.py
//// def foo():
//// pass
// @filename: test1.py
//// import [|nest1|].[|nest2|].[|module|]
////
//// [|nest1|].[|nest2|].[|module|]
// @filename: nest1/test2.py
//// import [|nest1|].[|nest2|].[|module|]
////
//// [|nest1|].[|nest2|].[|module|]
`;
const state = parseAndGetTestState(code).state;
function verify(name: string) {
const ranges = state.getRangesByText().get(name)!;
for (const range of ranges) {
verifyReferencesAtPosition(state.program, state.configOptions, name, range.fileName, range.pos, ranges);
}
}
verify('nest1');
verify('nest2');
verify('module');
});
test('import alias', () => {
const code = `
// @filename: nest/__init__.py
//// # empty
// @filename: nest/module2.py
//// # empty
// @filename: module1.py
//// # empty
// @filename: test1.py
//// import [|/*marker1*/module1|] as [|module1|]
// @filename: test2.py
//// import nest.[|/*marker2*/module2|] as [|module2|]
`;
const state = parseAndGetTestState(code).state;
const marker1 = state.getMarkerByName('marker1');
const ranges1 = state.getRangesByText().get('module1')!;
verifyReferencesAtPosition(
state.program,
state.configOptions,
'module1',
marker1.fileName,
marker1.position,
ranges1
);
const marker2 = state.getMarkerByName('marker2');
const ranges2 = state.getRangesByText().get('module2')!;
verifyReferencesAtPosition(
state.program,
state.configOptions,
'module2',
marker2.fileName,
marker2.position,
ranges2
);
});
test('string in __all__', () => {
const code = `
// @filename: test1.py
//// class [|/*marker1*/A|]:
//// pass
////
//// a: "[|A|]" = "A"
////
//// __all__ = [ "[|A|]" ]
`;
const state = parseAndGetTestState(code).state;
const marker1 = state.getMarkerByName('marker1');
const ranges1 = state.getRangesByText().get('A')!;
verifyReferencesAtPosition(state.program, state.configOptions, 'A', marker1.fileName, marker1.position, ranges1);
});
@@ -0,0 +1,455 @@
/*
* fourslash.ts
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT license.
*
* Python 时代的 *.fourslash.ts 夹具与 testState.test.ts 已随 Viper 化清理删除,
* 但本文件声明的 `declare namespace _` **仍被 harness 引用**:
* packages\vipeye-internal\src\tests\harness\fourslash\testState.ts
* 使用 `_.FourSlashVerificationMode` / `_.FourSlashCompletionItem` /
* `_.FourSlashCallHierarchyItem` 等类型,删除本文件会导致 ts-jest 类型检查报 TS2694
* (整批用例“Test suite failed to run”)。因此保留,勿删。
*
* 其余内容与原 pyright 一致:为编辑 *.fourslash.ts 提供类型信息,
* 运行期由 harness 注入 helper(见 ...\harness\fourslash\runner.ts@runCode),
* 因此 Fourslash 类型需与 TestState 保持同步。
*
* 保留的测试仍可参考 markup 用法:
* packages\vipeye-internal\src\tests\fourSlashParser.test.ts
*/
declare namespace _ {
type CompletionItemKind =
| 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;
type FourSlashVerificationMode = 'exact' | 'included' | 'excluded';
interface FourSlashCompletionItem {
label: string;
kind: CompletionItemKind | undefined;
insertionText?: string;
documentation?: string;
detail?: string;
textEdit?: TextEdit;
additionalTextEdits?: TextEdit[];
detailDescription?: string;
commitCharacters?: string[];
}
interface FourSlashCallHierarchyItem {
filePath: string;
name: string;
range: PositionRange;
}
interface TextRange {
start: number;
length: number;
}
interface LineAndColumn {
// Both line and column are zero-based
line: number;
column: number;
}
interface Marker {
fileName: string;
position: number;
data?: {};
}
interface Range {
fileName: string;
marker?: Marker;
pos: number;
end: number;
}
interface TextChange {
span: TextRange;
newText: string;
}
interface Command {
title: string;
command: string;
arguments?: any[];
}
interface Position {
// Both line and column are zero-based
line: number;
character: number;
}
interface PositionRange {
start: Position;
end: Position;
}
interface TextEdit {
range: PositionRange;
newText: string;
}
interface DocumentRange {
path: string;
range: PositionRange;
}
interface TextEditAction {
range: PositionRange;
replacementText: string;
}
interface FileEditAction extends TextEditAction {
filePath: string;
}
type DocumentHighlightKind = 1 | 2 | 3;
interface DocumentHighlight {
range: PositionRange;
kind?: DocumentHighlightKind;
}
interface AbbreviationInfo {
importFrom?: string;
importName: string;
}
interface TextDocumentIdentifier {
uri: string;
}
interface OptionalVersionedTextDocumentIdentifier extends TextDocumentIdentifier {
version: number | null;
}
interface AnnotatedTextEdit extends TextEdit {
annotationId: string;
}
interface TextDocumentEdit {
textDocument: OptionalVersionedTextDocumentIdentifier;
edits: (TextEdit | AnnotatedTextEdit)[];
}
interface FileOptions {
overwrite?: boolean;
ignoreIfExists?: boolean;
}
interface ResourceOperation {
kind: string;
annotationId?: string;
}
interface CreateFile extends ResourceOperation {
kind: 'create';
uri: string;
options?: FileOptions;
}
interface RenameFile extends ResourceOperation {
kind: 'rename';
oldUri: string;
newUri: string;
options?: FileOptions;
}
interface DeleteFileOptions {
recursive?: boolean;
ignoreIfNotExists?: boolean;
}
interface DeleteFile extends ResourceOperation {
kind: 'delete';
uri: string;
options?: DeleteFileOptions;
}
interface ChangeAnnotation {
label: string;
needsConfirmation?: boolean;
description?: string;
}
interface WorkspaceEdit {
changes?: {
[uri: string]: TextEdit[];
};
documentChanges?: (TextDocumentEdit | CreateFile | RenameFile | DeleteFile)[];
changeAnnotations?: {
[id: string]: ChangeAnnotation;
};
}
type MarkupKind = 'markdown' | 'plaintext';
type DefinitionFilter = 'all' | 'preferSource' | 'preferStubs';
interface Fourslash {
getMappedFilePath(path: string): string;
getDocumentHighlightKind(m?: Marker): DocumentHighlightKind | undefined;
getMarkerName(m: Marker): string;
getMarkerByName(markerName: string): Marker;
getMarkerNames(): string[];
getMarkers(): Marker[];
getRanges(): Range[];
getRangesInFile(fileName: string): Range[];
getRangesByText(): Map<string, Range[]>;
getFilteredRanges<T extends {}>(
predicate: (m: Marker | undefined, d: T | undefined, text: string) => boolean
): Range[];
getPositionRange(markerString: string): PositionRange;
getPosition(markerString: string): Position;
get BOF(): number;
get EOF(): number;
expandPositionRange(range: PositionRange, start: number, end: number): PositionRange;
convertPositionRange(range: Range): PositionRange;
convertPathToUri(path: string): string;
getDirectoryPath(path: string): string;
getPathSep(): string;
goToBOF(): void;
goToEOF(): void;
goToPosition(positionOrLineAndColumn: number | LineAndColumn): void;
goToMarker(nameOrMarker: string | Marker): void;
goToEachMarker(markers: readonly Marker[], action: (marker: Marker, index: number) => void): void;
goToEachRange(action: (range: Range) => void): void;
goToRangeStart({ fileName, pos }: Range): void;
select(startMarker: string, endMarker: string): void;
selectAllInFile(fileName: string): void;
selectRange(range: Range): void;
selectLine(index: number): void;
moveCaretRight(count: number): void;
openFile(indexOrName: number | string): void;
openFiles(indexOrNames: (number | string)[]): void;
verifyDiagnostics(map?: { [marker: string]: { category: string; message: string | undefined } }): void;
verifyCodeActions(
verifyMode: FourSlashVerificationMode,
map: {
[marker: string]: {
codeActions: { title: string; kind: string; command?: Command; edit?: WorkspaceEdit }[];
};
}
): Promise<any>;
verifyCommand(command: Command, files: { [filePath: string]: string }): Promise<any>;
verifyInvokeCodeAction(
map: {
[marker: string]: { title: string; files?: { [filePath: string]: string }; edits?: TextEdit[] };
},
verifyCodeActionCount?: boolean
): Promise<any>;
verifyHover(kind: string, map: { [marker: string]: string | null }): void;
verifyCompletion(
verifyMode: FourSlashVerificationMode,
docFormat: MarkupKind,
map: {
[marker: string]: {
completions: FourSlashCompletionItem[];
memberAccessInfo?: {
lastKnownModule?: string;
lastKnownMemberName?: string;
unknownMemberName?: string;
};
};
},
abbrMap?: { [abbr: string]: AbbreviationInfo }
): Promise<void>;
verifySignature(
docFormat: MarkupKind,
map: {
[marker: string]: {
noSig?: boolean;
signatures?: {
label: string;
parameters: string[];
documentation?: string;
}[];
activeParameters?: (number | undefined)[];
callHasParameters?: boolean;
};
}
): void;
verifyFindAllReferences(map: {
[marker: string]: {
references: DocumentRange[];
};
}): void;
verifyShowCallHierarchyGetIncomingCalls(map: {
[marker: string]: {
items: FourSlashCallHierarchyItem[];
};
}): void;
verifyShowCallHierarchyGetOutgoingCalls(map: {
[marker: string]: {
items: FourSlashCallHierarchyItem[];
};
}): void;
verifyHighlightReferences(map: {
[marker: string]: {
references: DocumentHighlight[];
};
}): void;
verifyFindDefinitions(
map: {
[marker: string]: {
definitions: DocumentRange[];
};
},
filter?: DefinitionFilter
): void;
verifyFindTypeDefinitions(map: {
[marker: string]: {
definitions: DocumentRange[];
};
}): void;
verifyRename(
map: {
[marker: string]: {
newName: string;
changes: FileEditAction[];
};
},
isUntitled?: boolean
): void;
verifyTypeVerifierResults(
packageName: string,
ignoreUnknownTypesFromImports: boolean,
verboseOutput: boolean,
expected: object
): void;
replace(start: number, length: number, text: string): void;
/* not tested yet
paste(text: string): void;
type(text: string): void;
deleteChar(count: number): void;
deleteLineRange(startIndex: number, endIndexInclusive: number): void;
deleteCharBehindMarker(count: number): void;
verifyCaretAtMarker(markerName: string): void;
verifyCurrentLineContent(text: string): void;
verifyCurrentFileContent(text: string): void;
verifyTextAtCaretIs(text: string): void;
verifyRangeIs(expectedText: string, includeWhiteSpace?: boolean): void;
setCancelled(numberOfCalls: number): void;
resetCancelled(): void; */
}
export interface InlayHint {
label: string;
position?: _.Position;
kind?: Consts.InlayHintKind;
tooltip?: string;
paddingLeft?: boolean;
paddingRight?: boolean;
textEdits?: TextEdit[];
}
}
declare var helper: _.Fourslash;
declare namespace Consts {
export namespace MarkupKind {
export const PlainText = 'plaintext';
export const Markdown = 'markdown';
}
export namespace CodeActionKind {
export const QuickFix = 'quickfix';
export const Refactor = 'refactor';
}
export enum Commands {
createTypeStub = 'viper.createtypestub',
restartServer = 'viper.restartserver',
orderImports = 'viper.organizeimports',
}
namespace DocumentHighlightKind {
/**
* A textual occurrence.
*/
const Text: 1;
/**
* Read-access of a symbol, like reading a variable.
*/
const Read: 2;
/**
* Write-access of a symbol, like writing to a variable.
*/
const Write: 3;
}
namespace CompletionItemKind {
const Text: 1;
const Method: 2;
const Function: 3;
const Constructor: 4;
const Field: 5;
const Variable: 6;
const Class: 7;
const Interface: 8;
const Module: 9;
const Property: 10;
const Unit: 11;
const Value: 12;
const Enum: 13;
const Keyword: 14;
const Snippet: 15;
const Color: 16;
const File: 17;
const Reference: 18;
const Folder: 19;
const EnumMember: 20;
const Constant: 21;
const Struct: 22;
const Event: 23;
const Operator: 24;
const TypeParameter: 25;
}
namespace InlayHintKind {
const Type = 1;
const Parameter = 2;
function is(value: number): value is InlayHintKind;
}
export type InlayHintKind = 1 | 2;
export const IndexValueDetail = 'Index value';
}
File diff suppressed because it is too large. Load diff
@@ -1,434 +0,0 @@
/*
* importResolverInfrastructure.test.ts
*
* Unit tests for the extracted ImportResolver infrastructure helpers.
*/
import assert from 'assert';
import { createImportResolverFileSystem } from '../analyzer/importResolverFileSystem';
import { ImportLogger } from '../analyzer/importLogger';
import { createDefaultTypeshedInfoProvider } from '../analyzer/typeshedInfoProvider';
import { typeshedFallback } from '../common/pathConsts';
import { normalizeSlashes } from '../common/pathUtils';
import { PythonVersion } from '../common/pythonVersion';
import { Uri } from '../common/uri/uri';
import { TestFileSystem } from './harness/vfs/filesystem';
function normalizedPath(uri: Uri): string {
return normalizeSlashes(uri.getFilePath(), '/');
}
describe('ImportResolverFileSystem', () => {
test('readdirEntriesSync caches per-directory and is cleared by invalidateCache', () => {
const fs = new TestFileSystem(/* ignoreCase */ false, { cwd: '/' });
fs.mkdirpSync('/dir');
fs.writeFileSync(Uri.file('/dir/a.py', fs), '');
const spy = jest.spyOn(fs, 'readdirEntriesSync');
const cache = createImportResolverFileSystem(fs);
cache.readdirEntriesSync(Uri.file('/dir', fs));
cache.readdirEntriesSync(Uri.file('/dir', fs));
assert.strictEqual(spy.mock.calls.length, 1);
cache.invalidateCache();
cache.readdirEntriesSync(Uri.file('/dir', fs));
assert.strictEqual(spy.mock.calls.length, 2);
spy.mockRestore();
});
test('fileExists/dirExists follow symlinks via realpath (parity with pre-refactor behavior)', () => {
const fs = new TestFileSystem(/* ignoreCase */ false, { cwd: '/' });
fs.mkdirpSync('/realDir');
fs.writeFileSync(Uri.file('/realFile.txt', fs), 'x');
fs.mkdirpSync('/links');
fs.symlinkSync('/realFile.txt', '/links/fileLink.txt');
fs.symlinkSync('/realDir', '/links/dirLink');
const cache = createImportResolverFileSystem(fs);
assert.strictEqual(cache.fileExists(Uri.file('/links/fileLink.txt', fs)), true);
assert.strictEqual(cache.fileExists(Uri.file('/links/dirLink', fs)), false);
assert.strictEqual(cache.dirExists(Uri.file('/links/dirLink', fs)), true);
assert.strictEqual(cache.dirExists(Uri.file('/links/fileLink.txt', fs)), false);
});
test('fileExists/dirExists return false for missing paths and broken symlinks', () => {
const fs = new TestFileSystem(/* ignoreCase */ false, { cwd: '/' });
fs.mkdirpSync('/links');
fs.symlinkSync('/doesNotExist', '/links/brokenLink');
const cache = createImportResolverFileSystem(fs);
assert.strictEqual(cache.fileExists(Uri.file('/missingFile', fs)), false);
assert.strictEqual(cache.dirExists(Uri.file('/missingDir', fs)), false);
assert.strictEqual(cache.fileExists(Uri.file('/links/brokenLink', fs)), false);
assert.strictEqual(cache.dirExists(Uri.file('/links/brokenLink', fs)), false);
});
test('dirExists caches existence checks for root', () => {
const fs = new TestFileSystem(/* ignoreCase */ false, { cwd: '/' });
const statSpy = jest.spyOn(fs, 'statSync');
const cache = createImportResolverFileSystem(fs);
const root = Uri.file('/', fs);
assert.strictEqual(cache.dirExists(root), true);
assert.strictEqual(cache.dirExists(root), true);
// For the root path we should consult the filesystem once and then cache the result.
assert.strictEqual(statSpy.mock.calls.length, 1);
statSpy.mockRestore();
});
test('getFilesInDirectory returns files and symlinks-to-files and caches results', () => {
const fs = new TestFileSystem(/* ignoreCase */ false, { cwd: '/' });
fs.mkdirpSync('/dir');
fs.writeFileSync(Uri.file('/dir/a.py', fs), '');
fs.writeFileSync(Uri.file('/realFile.txt', fs), 'x');
fs.symlinkSync('/realFile.txt', '/dir/fileLink.txt');
const readdirSpy = jest.spyOn(fs, 'readdirEntriesSync');
const cache = createImportResolverFileSystem(fs);
const dir = Uri.file('/dir', fs);
const files1 = cache.getFilesInDirectory(dir);
assert(files1.some((u) => normalizedPath(u) === '/dir/a.py'));
assert(files1.some((u) => normalizedPath(u) === '/dir/fileLink.txt'));
const callsAfterFirst = readdirSpy.mock.calls.length;
const files2 = cache.getFilesInDirectory(dir);
assert.deepStrictEqual(
files2.map((u) => normalizedPath(u)).sort(),
files1.map((u) => normalizedPath(u)).sort()
);
// Second call should be served from cache.
assert.strictEqual(readdirSpy.mock.calls.length, callsAfterFirst);
readdirSpy.mockRestore();
});
test('getFilesInDirectory is stale until invalidateCache', () => {
const fs = new TestFileSystem(/* ignoreCase */ false, { cwd: '/' });
fs.mkdirpSync('/dir');
fs.writeFileSync(Uri.file('/dir/a.py', fs), '');
const cache = createImportResolverFileSystem(fs);
const dir = Uri.file('/dir', fs);
const files1 = cache
.getFilesInDirectory(dir)
.map((u) => normalizedPath(u))
.sort();
assert.deepStrictEqual(files1, ['/dir/a.py']);
fs.writeFileSync(Uri.file('/dir/b.py', fs), '');
// Served from cache, so it won't include newly-added files.
const files2 = cache
.getFilesInDirectory(dir)
.map((u) => normalizedPath(u))
.sort();
assert.deepStrictEqual(files2, ['/dir/a.py']);
cache.invalidateCache();
const files3 = cache
.getFilesInDirectory(dir)
.map((u) => normalizedPath(u))
.sort();
assert.deepStrictEqual(files3, ['/dir/a.py', '/dir/b.py']);
});
test('getResolvableNamesInDirectory includes extensionless file names and -stubs suffix stripping', () => {
const fs = new TestFileSystem(/* ignoreCase */ false, { cwd: '/' });
fs.mkdirpSync('/dir');
fs.mkdirpSync('/dir/pkg-stubs');
fs.writeFileSync(Uri.file('/dir/foo.cpython-311-x86_64-linux-gnu.so', fs), '');
const cache = createImportResolverFileSystem(fs);
const names = cache.getResolvableNamesInDirectory(Uri.file('/dir', fs));
assert(names.has('pkg-stubs'));
assert(names.has('pkg'));
assert(names.has('foo'));
});
test('getResolvableNamesInDirectory strips extensions (including multi-dot) and follows symlinks for file-ness', () => {
const fs = new TestFileSystem(/* ignoreCase */ false, { cwd: '/' });
fs.mkdirpSync('/dir');
fs.writeFileSync(Uri.file('/dir/a.py', fs), '');
fs.writeFileSync(Uri.file('/dir/b.pyi', fs), '');
fs.writeFileSync(Uri.file('/dir/c.pyc', fs), '');
fs.writeFileSync(Uri.file('/dir/foo.cpython-311-x86_64-linux-gnu.so', fs), '');
fs.writeFileSync(Uri.file('/real.pyi', fs), 'x');
fs.symlinkSync('/real.pyi', '/dir/link.pyi');
const cache = createImportResolverFileSystem(fs);
const names = cache.getResolvableNamesInDirectory(Uri.file('/dir', fs));
assert(names.has('a'));
assert(names.has('b'));
assert(names.has('c'));
assert(names.has('foo'));
assert(names.has('link'));
});
test('readdirEntriesSync failures are swallowed and cached until invalidateCache', () => {
const fs = new TestFileSystem(/* ignoreCase */ false, { cwd: '/' });
fs.mkdirpSync('/boom');
const original = fs.readdirEntriesSync.bind(fs);
const readdirSpy = jest.spyOn(fs, 'readdirEntriesSync').mockImplementation((uri) => {
if (normalizedPath(uri) === '/boom') {
throw new Error('boom');
}
return original(uri);
});
const cache = createImportResolverFileSystem(fs);
assert.deepStrictEqual(cache.readdirEntriesSync(Uri.file('/boom', fs)), []);
assert.deepStrictEqual(cache.readdirEntriesSync(Uri.file('/boom', fs)), []);
// Only one underlying call, and then we serve the cached empty value.
assert.strictEqual(readdirSpy.mock.calls.length, 1);
cache.invalidateCache();
assert.deepStrictEqual(cache.readdirEntriesSync(Uri.file('/boom', fs)), []);
assert.strictEqual(readdirSpy.mock.calls.length, 2);
readdirSpy.mockRestore();
});
});
describe('TypeshedInfoProvider (default)', () => {
function createFsWithTypeshedLayout() {
const fs = new TestFileSystem(/* ignoreCase */ false, { cwd: '/' });
const typeshedRoot = `/${typeshedFallback}`;
fs.mkdirpSync(typeshedRoot);
fs.mkdirpSync(`${typeshedRoot}/stdlib`);
fs.mkdirpSync(`${typeshedRoot}/stubs`);
const cache = createImportResolverFileSystem(fs);
const provider = createDefaultTypeshedInfoProvider(cache);
return { fs, cache, provider, typeshedRoot };
}
test('getTypeshedRoot prefers custom path when present, otherwise falls back', () => {
const { fs, provider, typeshedRoot } = createFsWithTypeshedLayout();
fs.mkdirpSync('/customTypeshed');
const custom = Uri.file('/customTypeshed', fs);
const resolvedCustom = provider.getTypeshedRoot(custom);
assert(resolvedCustom);
assert.strictEqual(normalizedPath(resolvedCustom), '/customTypeshed');
const resolvedFallback = provider.getTypeshedRoot(undefined);
assert(resolvedFallback);
assert.strictEqual(normalizedPath(resolvedFallback), typeshedRoot);
});
test('getTypeshedRoot ignores a custom typeshed path that is not a directory', () => {
const { fs, provider, typeshedRoot } = createFsWithTypeshedLayout();
const customPath = Uri.file('/customTypeshed', fs);
fs.writeFileSync(customPath, 'not a directory');
const resolved = provider.getTypeshedRoot(customPath);
assert(resolved);
assert.strictEqual(normalizedPath(resolved), typeshedRoot);
});
test('getTypeshedSubdirectory returns undefined when the expected subdirectory does not exist', () => {
const fs = new TestFileSystem(/* ignoreCase */ false, { cwd: '/' });
const typeshedRoot = `/${typeshedFallback}`;
fs.mkdirpSync(typeshedRoot);
fs.mkdirpSync(`${typeshedRoot}/stdlib`);
// Intentionally omit `${typeshedRoot}/stubs`.
const cache = createImportResolverFileSystem(fs);
const provider = createDefaultTypeshedInfoProvider(cache);
assert.strictEqual(
provider.getTypeshedSubdirectory(/* isStdLib */ false, /* customTypeshedPath */ undefined),
undefined
);
});
test('getThirdPartyPackageMap builds expected map and is memoized (independent of filesystem cache)', () => {
const { fs, cache, provider, typeshedRoot } = createFsWithTypeshedLayout();
// Build a minimal stubs layout:
// stubs/a/foo/
// stubs/a/bar.pyi
// stubs/a/@python2/ (ignored)
// stubs/b/foo/
fs.mkdirpSync(`${typeshedRoot}/stubs/a/foo`);
fs.mkdirpSync(`${typeshedRoot}/stubs/a/@python2`);
fs.writeFileSync(Uri.file(`${typeshedRoot}/stubs/a/bar.pyi`, fs), '');
fs.writeFileSync(Uri.file(`${typeshedRoot}/stubs/a/readme.txt`, fs), '');
fs.mkdirpSync(`${typeshedRoot}/stubs/b/foo`);
fs.writeFileSync(Uri.file(`${typeshedRoot}/stubs/outerFile.txt`, fs), '');
const readdirSpy = jest.spyOn(fs, 'readdirEntriesSync');
const [packageMap, roots] = provider.getThirdPartyPackageMap(undefined);
assert.deepStrictEqual(
roots.map((u) => normalizedPath(u)).sort(),
[`${typeshedRoot}/stubs/a`, `${typeshedRoot}/stubs/b`].sort()
);
const fooPaths = packageMap.get('foo');
assert(fooPaths);
assert.deepStrictEqual(
fooPaths.map((u) => normalizedPath(u)).sort(),
[`${typeshedRoot}/stubs/a`, `${typeshedRoot}/stubs/b`].sort()
);
const barPaths = packageMap.get('bar');
assert(barPaths);
assert.deepStrictEqual(
barPaths.map((u) => normalizedPath(u)),
[`${typeshedRoot}/stubs/a`]
);
assert(!packageMap.has('@python2'));
assert(!packageMap.has('readme'));
const callsAfterFirst = readdirSpy.mock.calls.length;
// Clear the ImportResolverFileSystem cache to ensure TypeshedInfoProvider memoization is effective.
cache.invalidateCache();
const [packageMap2, roots2] = provider.getThirdPartyPackageMap(undefined);
assert.strictEqual(packageMap2, packageMap);
assert.strictEqual(roots2, roots);
// Should not have re-enumerated directories.
assert.strictEqual(readdirSpy.mock.calls.length, callsAfterFirst);
readdirSpy.mockRestore();
});
test('getStdLibModuleVersionInfo parses VERSIONS and is memoized (independent of filesystem cache)', () => {
const { fs, cache, provider, typeshedRoot } = createFsWithTypeshedLayout();
const versionsUri = Uri.file(`${typeshedRoot}/stdlib/VERSIONS`, fs);
fs.writeFileSync(
versionsUri,
[
'asyncio: 3.4-',
'distutils: 3.0-3.10 ; platforms=win32, !linux',
'bar: -3.8',
'email: 3.6+ # plus suffix is allowed',
'this is not valid',
': 3.7- # missing module name',
].join('\n'),
'utf8'
);
const statSpy = jest.spyOn(fs, 'statSync');
const readSpy = jest.spyOn(fs, 'readFileSync');
const versionInfo = provider.getStdLibModuleVersionInfo(undefined);
const asyncio = versionInfo.get('asyncio');
assert(asyncio);
assert(PythonVersion.isEqualTo(asyncio.min, PythonVersion.fromString('3.4')!));
assert.strictEqual(asyncio.max, undefined);
const distutils = versionInfo.get('distutils');
assert(distutils);
assert(PythonVersion.isEqualTo(distutils.min, PythonVersion.fromString('3.0')!));
assert(PythonVersion.isEqualTo(distutils.max!, PythonVersion.fromString('3.10')!));
assert.deepStrictEqual(distutils.supportedPlatforms, ['win32']);
assert.deepStrictEqual(distutils.unsupportedPlatforms, ['linux']);
const bar = versionInfo.get('bar');
assert(bar);
// Empty min version defaults to 3.0.
assert(PythonVersion.isEqualTo(bar.min, PythonVersion.fromString('3.0')!));
assert(PythonVersion.isEqualTo(bar.max!, PythonVersion.fromString('3.8')!));
const email = versionInfo.get('email');
assert(email);
assert(PythonVersion.isEqualTo(email.min, PythonVersion.fromString('3.6')!));
const statCallsAfterFirst = statSpy.mock.calls.length;
const readCallsAfterFirst = readSpy.mock.calls.length;
// Clear the ImportResolverFileSystem cache to ensure TypeshedInfoProvider memoization is effective.
cache.invalidateCache();
const versionInfo2 = provider.getStdLibModuleVersionInfo(undefined);
assert.strictEqual(versionInfo2, versionInfo);
assert.strictEqual(statSpy.mock.calls.length, statCallsAfterFirst);
assert.strictEqual(readSpy.mock.calls.length, readCallsAfterFirst);
statSpy.mockRestore();
readSpy.mockRestore();
});
test('getStdLibModuleVersionInfo memoizes an empty result when VERSIONS is missing', () => {
const { fs, provider, typeshedRoot } = createFsWithTypeshedLayout();
// No VERSIONS file.
const versionInfo1 = provider.getStdLibModuleVersionInfo(undefined);
assert.strictEqual(versionInfo1.size, 0);
// Create it later; memoization should keep returning the originally computed value.
fs.writeFileSync(Uri.file(`${typeshedRoot}/stdlib/VERSIONS`, fs), 'asyncio: 3.4-', 'utf8');
const versionInfo2 = provider.getStdLibModuleVersionInfo(undefined);
assert.strictEqual(versionInfo2, versionInfo1);
assert.strictEqual(versionInfo2.size, 0);
});
test('getStdLibModuleVersionInfo logs and skips reading when VERSIONS is too large', () => {
const { fs, provider, typeshedRoot } = createFsWithTypeshedLayout();
const versionsUri = Uri.file(`${typeshedRoot}/stdlib/VERSIONS`, fs);
fs.writeFileSync(versionsUri, 'a'.repeat(256 * 1024), 'utf8');
const importLogger = new ImportLogger();
const readSpy = jest.spyOn(fs, 'readFileSync');
const versionInfo = provider.getStdLibModuleVersionInfo(undefined, importLogger);
assert.strictEqual(versionInfo.size, 0);
assert.strictEqual(readSpy.mock.calls.length, 0);
assert(importLogger.getLogs().some((l) => l.includes('unexpectedly large')));
readSpy.mockRestore();
});
test('getStdLibModuleVersionInfo logs and returns empty when reading VERSIONS throws', () => {
const { fs, provider, typeshedRoot } = createFsWithTypeshedLayout();
const versionsUri = Uri.file(`${typeshedRoot}/stdlib/VERSIONS`, fs);
fs.writeFileSync(versionsUri, 'asyncio: 3.4-', 'utf8');
const importLogger = new ImportLogger();
const readSpy = jest.spyOn(fs, 'readFileSync').mockImplementation(() => {
throw new Error('read failed');
});
const versionInfo = provider.getStdLibModuleVersionInfo(undefined, importLogger);
assert.strictEqual(versionInfo.size, 0);
assert(importLogger.getLogs().some((l) => l.includes('Could not read typeshed stdlib VERSIONS file')));
readSpy.mockRestore();
});
});
@@ -1,805 +0,0 @@
/*
* importStatementUtils.test.ts
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT license.
*
* Unit tests for importStatementUtils module.
*/
import assert from 'assert';
import { isFunctionDeclaration } from '../analyzer/declaration';
import { ImportType } from '../analyzer/importResult';
import {
getRelativeModuleName,
getTextEditsForAutoImportInsertions,
getTextEditsForAutoImportSymbolAddition,
getTopLevelImports,
ImportNameInfo,
ImportNameWithModuleInfo,
} from '../analyzer/importStatementUtils';
import { findNodeByOffset } from '../analyzer/parseTreeUtils';
import { isArray } from '../common/core';
import { TextEditAction } from '../common/editAction';
import { convertOffsetToPosition } from '../common/positionUtils';
import { rangesAreEqual } from '../common/textRange';
import { applyTextEditsToString } from '../common/workspaceEditUtils';
import { NameNode } from '../parser/parseNodes';
import { Range } from './harness/fourslash/fourSlashTypes';
import { parseAndGetTestState, TestState } from './harness/fourslash/testState';
test('getTextEditsForAutoImportInsertion - import empty', () => {
const code = `
//// import os[|/*marker1*/{|"r":"!n!import sys"|}|]
`;
testInsertion(code, 'marker1', [], 'sys', ImportType.BuiltIn);
});
test('getTextEditsForAutoImportInsertion - import', () => {
const code = `
//// import os[|/*marker1*/{|"r":"!n!import sys"|}|]
`;
testInsertion(code, 'marker1', {}, 'sys', ImportType.BuiltIn);
});
test('getTextEditsForAutoImportInsertion - import alias', () => {
const code = `
//// import os[|/*marker1*/{|"r":"!n!import sys as s"|}|]
`;
testInsertion(code, 'marker1', { alias: 's' }, 'sys', ImportType.BuiltIn);
});
test('getTextEditsForAutoImportInsertion - multiple imports', () => {
const code = `
//// import os[|/*marker1*/{|"r":"!n!import sys"|}|]
`;
testInsertion(code, 'marker1', [{}, {}], 'sys', ImportType.BuiltIn);
});
test('getTextEditsForAutoImportInsertion - multiple imports alias', () => {
const code = `
//// import os[|/*marker1*/{|"r":"!n!import sys as s, sys as y"|}|]
`;
testInsertion(code, 'marker1', [{ alias: 's' }, { alias: 'y' }], 'sys', ImportType.BuiltIn);
});
test('getTextEditsForAutoImportInsertion - multiple imports alias duplicated', () => {
const code = `
//// import os[|/*marker1*/{|"r":"!n!import sys as s"|}|]
`;
testInsertion(code, 'marker1', [{ alias: 's' }, { alias: 's' }], 'sys', ImportType.BuiltIn);
});
test('getTextEditsForAutoImportInsertion - from import', () => {
const code = `
//// import os[|/*marker1*/{|"r":"!n!from sys import path"|}|]
`;
testInsertion(code, 'marker1', { name: 'path' }, 'sys', ImportType.BuiltIn);
});
test('getTextEditsForAutoImportInsertion - from import alias', () => {
const code = `
//// import os[|/*marker1*/{|"r":"!n!from sys import path as p"|}|]
`;
testInsertion(code, 'marker1', { name: 'path', alias: 'p' }, 'sys', ImportType.BuiltIn);
});
test('getTextEditsForAutoImportInsertion - multiple from imports', () => {
const code = `
//// import os[|/*marker1*/{|"r":"!n!from sys import meta_path, path"|}|]
`;
testInsertion(code, 'marker1', [{ name: 'path' }, { name: 'meta_path' }], 'sys', ImportType.BuiltIn);
});
test('getTextEditsForAutoImportInsertion - multiple from imports with alias', () => {
const code = `
//// import os[|/*marker1*/{|"r":"!n!from sys import meta_path as m, path as p"|}|]
`;
testInsertion(
code,
'marker1',
[
{ name: 'path', alias: 'p' },
{ name: 'meta_path', alias: 'm' },
],
'sys',
ImportType.BuiltIn
);
});
test('getTextEditsForAutoImportInsertion - multiple from imports with alias duplicated', () => {
const code = `
//// import os[|/*marker1*/{|"r":"!n!from sys import meta_path as m, path as p"|}|]
`;
testInsertion(
code,
'marker1',
[
{ name: 'path', alias: 'p' },
{ name: 'meta_path', alias: 'm' },
{ name: 'path', alias: 'p' },
],
'sys',
ImportType.BuiltIn
);
});
test('getTextEditsForAutoImportInsertion - multiple import statements', () => {
const code = `
//// import os[|/*marker1*/{|"r":"!n!import sys as s!n!from sys import path as p"|}|]
`;
testInsertion(code, 'marker1', [{ alias: 's' }, { name: 'path', alias: 'p' }], 'sys', ImportType.BuiltIn);
});
test('getTextEditsForAutoImportInsertion - different group', () => {
const code = `
//// import os[|/*marker1*/{|"r":"!n!!n!import sys as s!n!from sys import path as p"|}|]
`;
testInsertion(code, 'marker1', [{ alias: 's' }, { name: 'path', alias: 'p' }], 'sys', ImportType.Local);
});
test('getTextEditsForAutoImportInsertion - at the top', () => {
const code = `
//// [|/*marker1*/{|"r":"import sys as s!n!from sys import path as p!n!!n!!n!"|}|]import os
`;
testInsertion(code, 'marker1', [{ alias: 's' }, { name: 'path', alias: 'p' }], 'sys', ImportType.BuiltIn);
});
test('getTextEditsForAutoImportInsertion - at top of second group', () => {
const code = `
//// import os
////
//// [|/*marker1*/{|"r":"from test.a import testa!n!"|}|]from test.b import testb
`;
testInsertion(code, 'marker1', [{ name: 'testa' }], 'test.a', ImportType.Local);
});
test('getTextEditsForAutoImportInsertion - at the top after module doc string', () => {
const code = `
//// ''' module doc string '''
//// __author__ = "Software Authors Name"
//// __copyright__ = "Copyright (C) 2004 Author Name"
//// __license__ = "Public Domain"
//// __version__ = "1.0"
//// [|/*marker1*/{|"r":"import sys as s!n!from sys import path as p!n!!n!!n!"|}|]import os
`;
testInsertion(code, 'marker1', [{ alias: 's' }, { name: 'path', alias: 'p' }], 'sys', ImportType.BuiltIn);
});
test('getTextEditsForAutoImportInsertion - insert before different group adds blank line', () => {
const code = `
// @filename: mypkg/__init__.py
//// pass
// @filename: test.py
//// [|/*range1*/{|"r":"import sys!n!!n!"|}|]from mypkg import foo
//// foo()/*marker1*/
`;
testInsertion(code, 'marker1', {}, 'sys', ImportType.BuiltIn);
});
test('getTextEditsForAutoImportInsertion - insert before unresolved import adds blank line', () => {
const code = `
// @filename: test.py
//// [|/*range1*/{|"r":"import sys!n!!n!"|}|]import unknownpkg
//// unknownpkg./*marker1*/foo
`;
testInsertion(code, 'marker1', {}, 'sys', ImportType.BuiltIn);
});
test('getTextEditsForAutoImportInsertion - insert before same group does not add blank line (local)', () => {
const code = `
// @filename: aaa/__init__.py
//// pass
// @filename: mypkg/__init__.py
//// pass
// @filename: test.py
//// [|/*range1*/{|"r":"import aaa!n!"|}|]from mypkg import foo
//// foo()/*marker1*/
`;
testInsertion(code, 'marker1', {}, 'aaa', ImportType.Local);
});
test('getTextEditsForAutoImportInsertion - insert before same group does not add blank line (built-in)', () => {
const code = `
// @filename: test.py
//// [|/*range1*/{|"r":"import sys!n!"|}|]import zlib
//// zlib./*marker1*/crc32(b'')
`;
testInsertion(code, 'marker1', {}, 'sys', ImportType.BuiltIn);
});
test('getTextEditsForAutoImportInsertion - insert before local relative adds blank line', () => {
const code = `
// @filename: mypkg/__init__.py
//// pass
// @filename: mypkg/test.py
//// [|/*range1*/{|"r":"import sys!n!!n!"|}|]from . import foo
//// foo()/*marker1*/
`;
testInsertion(code, 'marker1', {}, 'sys', ImportType.BuiltIn);
});
test('getTextEditsForAutoImportInsertion - insert third-party before local relative adds blank line', () => {
const code = `
// @filename: mypkg/__init__.py
//// pass
// @filename: mypkg/foo.py
//// bar = 1
// @filename: mypkg/test.py
//// [|/*range1*/{|"r":"import numpy!n!!n!"|}|]from .foo import bar
//// bar/*marker1*/
`;
testInsertion(code, 'marker1', {}, 'numpy', ImportType.ThirdParty);
});
test('getTextEditsForAutoImportInsertions - new relative import grouped below absolute local import', () => {
const code = `
// @filename: aaa.py
//// A = 1
// @filename: test.py
//// from aaa import A[|/*marker1*/{|"r":"!n!!n!from .mmm import M"|}|]
`;
const module = { moduleName: 'mmm', importType: ImportType.Local, isLocalTypingsFile: false };
testInsertions(code, 'marker1', [{ module, name: 'M', nameForImportFrom: '.mmm' }]);
});
test('getTextEditsForAutoImportInsertions - new relative import sorts within existing relative group', () => {
const code = `
// @filename: pkg/__init__.py
//// pass
// @filename: pkg/nnn.py
//// N = 1
// @filename: pkg/test.py
//// [|{|"r":"from .mmm import M!n!"|}|]from .nnn import N
//// N/*marker1*/
`;
const module = { moduleName: 'mmm', importType: ImportType.Local, isLocalTypingsFile: false };
testInsertions(code, 'marker1', [{ module, name: 'M', nameForImportFrom: '.mmm' }]);
});
test('getTextEditsForAutoImportInsertions - absolute-local and relative-local batched at same anchor (applied)', () => {
// The auto-import code action inserts a single symbol at a time, so it never batches an
// absolute-local and a relative-local insert at the same anchor; that single-insert path is the
// one this PR targets and is covered by the integration tests in codeActionProvider.common.ts.
//
// This test pins down the *batched* same-anchor path (a single getTextEditsForAutoImportInsertions
// call producing two same-range edits) and asserts the final applied document text rather than
// each edit independently. Upgrading Local -> LocalRelative changes the merge key, so the two
// inserts stay as separate same-range edits instead of merging. When applyTextEditsToString
// composes two zero-width edits at the same offset, the later-applied edit lands on top, so the
// relative import renders above the absolute one here. This ordering is a pre-existing property of
// composing multiple same-anchor edits (the long-standing builtin/third-party/local batch below
// behaves the same way) and is not introduced by this PR.
const code = `
// @filename: aaa.py
//// A = 1
// @filename: pkg/__init__.py
//// pass
// @filename: pkg/mmm.py
//// M = 1
// @filename: pkg/test.py
//// import os/*marker1*/
`;
const moduleAbs = { moduleName: 'aaa', importType: ImportType.Local, isLocalTypingsFile: false };
const moduleRel = { moduleName: 'mmm', importType: ImportType.Local, isLocalTypingsFile: false };
const result = applyInsertions(code, 'marker1', [
{ module: moduleAbs, name: 'A' },
{ module: moduleRel, name: 'M', nameForImportFrom: '.mmm' },
]);
assert.strictEqual(result, 'import os\n\nfrom .mmm import M\n\nfrom aaa import A');
});
test('getTextEditsForAutoImportInsertions - absolute-local and relative-local batched at top of import-less file (applied)', () => {
// Same batched same-anchor path as above, but at a top-of-file (0,0) insertion in a file with no
// existing imports. The two same-range (0,0) edits do not merge (different import groups), and
// applyTextEditsToString again renders the later-applied (relative) edit above the absolute one.
// Input order does not affect the result: the edit list is import-group-sorted before applying.
const code = `
//// /*marker1*/x = 1
`;
const moduleAbs = { moduleName: 'aaa', importType: ImportType.Local, isLocalTypingsFile: false };
const moduleRel = { moduleName: 'mmm', importType: ImportType.Local, isLocalTypingsFile: false };
// Pass relative first to prove the ordering comes from the import-group sort, not input order.
const result = applyInsertions(code, 'marker1', [
{ module: moduleRel, name: 'M', nameForImportFrom: '.mmm' },
{ module: moduleAbs, name: 'A' },
]);
assert.strictEqual(result, 'from .mmm import M\n\n\nfrom aaa import A\n\n\nx = 1');
});
test('getTextEditsForAutoImportInsertions - mix of import and from import statements', () => {
const code = `
//// [|/*marker1*/{|"r":"import sys as s!n!from sys import path as p!n!!n!!n!"|}|]import os
`;
const module = { moduleName: 'sys', importType: ImportType.BuiltIn, isLocalTypingsFile: false };
testInsertions(code, 'marker1', [
{ module, alias: 's' },
{ module, name: 'path', alias: 'p' },
]);
});
test('getTextEditsForAutoImportInsertions - multiple modules with different group', () => {
const code = `
//// [|/*marker1*/|][|{|"r":"from sys import path as p!n!!n!!n!"|}|][|{|"r":"import numpy!n!!n!!n!"|}|][|{|"r":"from test import join!n!!n!!n!"|}|]import os
`;
const module1 = { moduleName: 'sys', importType: ImportType.BuiltIn, isLocalTypingsFile: false };
const module2 = { moduleName: 'numpy', importType: ImportType.ThirdParty, isLocalTypingsFile: false };
const module3 = { moduleName: 'test', importType: ImportType.Local, isLocalTypingsFile: false };
testInsertions(code, 'marker1', [
{ module: module1, name: 'path', alias: 'p' },
{ module: module2 },
{ module: module3, name: 'join' },
]);
});
test('getTextEditsForAutoImportInsertions - multiple modules with existing imports', () => {
const code = `
//// import os[|/*marker1*/|][|{|"r":"!n!from sys import path as p"|}|][|{|"r":"!n!!n!import numpy"|}|][|{|"r":"!n!!n!from test import join"|}|]
`;
const module1 = { moduleName: 'sys', importType: ImportType.BuiltIn, isLocalTypingsFile: false };
const module2 = { moduleName: 'numpy', importType: ImportType.ThirdParty, isLocalTypingsFile: false };
const module3 = { moduleName: 'test', importType: ImportType.Local, isLocalTypingsFile: false };
testInsertions(code, 'marker1', [
{ module: module1, name: 'path', alias: 'p' },
{ module: module2 },
{ module: module3, name: 'join' },
]);
});
test('getTextEditsForAutoImportInsertions - multiple modules with same group', () => {
const code = `
//// import os[|/*marker1*/|][|{|"r":"!n!!n!import module2!n!from module1 import path as p!n!from module3 import join"|}|]
`;
const module1 = { moduleName: 'module1', importType: ImportType.Local, isLocalTypingsFile: false };
const module2 = { moduleName: 'module2', importType: ImportType.Local, isLocalTypingsFile: false };
const module3 = { moduleName: 'module3', importType: ImportType.Local, isLocalTypingsFile: false };
testInsertions(code, 'marker1', [
{ module: module1, name: 'path', alias: 'p' },
{ module: module2 },
{ module: module3, name: 'join' },
]);
});
test('getTextEditsForAutoImportSymbolAddition', () => {
const code = `
//// from sys import [|/*marker1*/{|"r":"meta_path, "|}|]path
`;
testAddition(code, 'marker1', { name: 'meta_path' }, 'sys');
});
test('getTextEditsForAutoImportSymbolAddition - already exist', () => {
const code = `
//// from sys import path[|/*marker1*/|]
`;
testAddition(code, 'marker1', { name: 'path' }, 'sys');
});
test('getTextEditsForAutoImportSymbolAddition - with alias', () => {
const code = `
//// from sys import path[|/*marker1*/{|"r":", path as p"|}|]
`;
testAddition(code, 'marker1', { name: 'path', alias: 'p' }, 'sys');
});
test('getTextEditsForAutoImportSymbolAddition - multiple names', () => {
const code = `
//// from sys import [|/*marker1*/{|"r":"meta_path as m, "|}|]path[|{|"r":", zoom as z"|}|]
`;
testAddition(
code,
'marker1',
[
{ name: 'meta_path', alias: 'm' },
{ name: 'zoom', alias: 'z' },
],
'sys'
);
});
test('getTextEditsForAutoImportSymbolAddition - multiple names at some spot', () => {
const code = `
//// from sys import [|/*marker1*/{|"r":"meta_path as m, noon as n, "|}|]path
`;
testAddition(
code,
'marker1',
[
{ name: 'meta_path', alias: 'm' },
{ name: 'noon', alias: 'n' },
],
'sys'
);
});
test('getTextEditsForAutoImportSymbolAddition - wildcard', () => {
const code = `
//// from sys import *[|/*marker1*/|]
`;
testAddition(code, 'marker1', [{ name: 'path' }], 'sys');
});
test('getRelativeModuleName - same file', () => {
const code = `
// @filename: source.py
//// [|/*src*/|] [|/*dest*/|]
`;
testRelativeModuleName(code, '.source');
});
test('getRelativeModuleName - same file __init__', () => {
const code = `
// @filename: common/__init__.py
//// [|/*src*/|] [|/*dest*/|]
`;
testRelativeModuleName(code, '.');
});
test('getRelativeModuleName - same folder', () => {
const code = `
// @filename: source.py
//// [|/*src*/|]
// @filename: dest.py
//// [|/*dest*/|]
`;
testRelativeModuleName(code, '.dest');
});
test('getRelativeModuleName - different folder move down', () => {
const code = `
// @filename: common/source.py
//// [|/*src*/|]
// @filename: dest.py
//// [|/*dest*/|]
`;
testRelativeModuleName(code, '..dest');
});
test('getRelativeModuleName - different folder move up', () => {
const code = `
// @filename: source.py
//// [|/*src*/|]
// @filename: common/dest.py
//// [|/*dest*/|]
`;
testRelativeModuleName(code, '.common.dest');
});
test('getRelativeModuleName - folder move down __init__ parent folder', () => {
const code = `
// @filename: nest1/nest2/source.py
//// [|/*src*/|]
// @filename: nest1/__init__.py
//// [|/*dest*/|]
`;
testRelativeModuleName(code, '..');
});
test('getRelativeModuleName - folder move down __init__ parent folder ignore folder structure', () => {
const code = `
// @filename: nest1/nest2/source.py
//// [|/*src*/|]
// @filename: nest1/__init__.py
//// [|/*dest*/|]
`;
testRelativeModuleName(code, '...nest1', /*ignoreFolderStructure*/ true);
});
test('getRelativeModuleName - different folder move down __init__ sibling folder', () => {
const code = `
// @filename: nest1/nest2/source.py
//// [|/*src*/|]
// @filename: different/__init__.py
//// [|/*dest*/|]
`;
testRelativeModuleName(code, '...different');
});
test('getRelativeModuleName - different folder move up __init__', () => {
const code = `
// @filename: source.py
//// [|/*src*/|]
// @filename: common/__init__.py
//// [|/*dest*/|]
`;
testRelativeModuleName(code, '.common');
});
test('getRelativeModuleName - root __init__', () => {
const code = `
// @filename: source.py
//// [|/*src*/|]
// @filename: __init__.py
//// [|/*dest*/|]
`;
testRelativeModuleName(code, '.');
});
test('getRelativeModuleName over fake file', () => {
const code = `
// @filename: target.py
//// [|/*dest*/|]
`;
const state = parseAndGetTestState(code).state;
const dest = state.getMarkerByName('dest')!.fileUri;
assert.strictEqual(
getRelativeModuleName(
state.fs,
dest.getDirectory().combinePaths('source.py'),
dest,
state.configOptions,
/*ignoreFolderStructure*/ false,
/*sourceIsFile*/ true
),
'.target'
);
});
test('getRelativeModuleName - target in stub path', () => {
const code = `
// @filename: source.py
//// [|/*src*/|]
// @filename: typings/library/__init__.py
//// [|/*dest*/|]
`;
testRelativeModuleName(code, undefined);
});
test('getRelativeModuleName - target in typeshed path', () => {
const code = `
// @filename: viperconfig.json
//// {
//// "typeshedPath": "my_typeshed"
//// }
// @filename: source.py
//// [|/*src*/|]
// @filename: my_typeshed/library/__init__.py
//// [|/*dest*/|]
`;
testRelativeModuleName(code, undefined);
});
test('resolve alias of not needed file', () => {
const code = `
// @filename: viperconfig.json
//// {
//// "useLibraryCodeForTypes": true
//// }
// @filename: myLib/__init__.py
// @library: true
//// from myLib.foo import [|/*marker*/foo|]
// @filename: myLib/foo.py
// @library: true
//// def foo(): pass
`;
const state = parseAndGetTestState(code).state;
const marker = state.getMarkerByName('marker')!;
const evaluator = state.workspace.service.test_program.evaluator!;
state.openFile(marker.fileName);
const markerUri = marker.fileUri;
const parseResults = state.workspace.service.getParseResults(markerUri)!;
const nameNode = findNodeByOffset(parseResults.parserOutput.parseTree, marker.position) as NameNode;
const aliasDecls = evaluator.getDeclInfoForNameNode(nameNode)!.decls;
// Unroot the file. we can't explicitly close the file since it will unload the file from test program.
state.workspace.service.test_program.getSourceFileInfo(markerUri)!.isOpenByClient = false;
const unresolved = evaluator.resolveAliasDeclaration(aliasDecls[0], /*resolveLocalNames*/ false);
assert(!unresolved);
const resolved = evaluator.resolveAliasDeclaration(aliasDecls[0], /*resolveLocalNames*/ false, {
skipFileNeededCheck: true,
});
assert(resolved);
assert(isFunctionDeclaration(resolved));
});
function testRelativeModuleName(code: string, expected: string | undefined, ignoreFolderStructure = false) {
const state = parseAndGetTestState(code).state;
const src = state.getMarkerByName('src')!.fileUri;
const dest = state.getMarkerByName('dest')!.fileUri;
assert.strictEqual(
getRelativeModuleName(state.fs, src, dest, state.configOptions, ignoreFolderStructure),
expected
);
}
function testAddition(
code: string,
markerName: string,
importNameInfo: ImportNameInfo | ImportNameInfo[],
moduleName: string
) {
const state = parseAndGetTestState(code).state;
const marker = state.getMarkerByName(markerName)!;
const parseResults = state.program.getBoundSourceFile(marker!.fileUri)!.getParseResults()!;
const importStatement = getTopLevelImports(parseResults.parserOutput.parseTree).orderedImports.find(
(i) => i.moduleName === moduleName
)!;
const edits = getTextEditsForAutoImportSymbolAddition(importNameInfo, importStatement, parseResults);
const ranges = [...state.getRanges().filter((r) => !!r.marker?.data)];
assert.strictEqual(edits.length, ranges.length, `${markerName} expects ${ranges.length} but got ${edits.length}`);
testTextEdits(state, edits, ranges);
}
function testInsertions(
code: string,
markerName: string,
importNameInfo: ImportNameWithModuleInfo | ImportNameWithModuleInfo[]
) {
const state = parseAndGetTestState(code).state;
const marker = state.getMarkerByName(markerName)!;
const parseResults = state.program.getBoundSourceFile(marker!.fileUri)!.getParseResults()!;
const importStatements = getTopLevelImports(parseResults.parserOutput.parseTree);
const edits = getTextEditsForAutoImportInsertions(
importNameInfo,
importStatements,
parseResults,
convertOffsetToPosition(marker.position, parseResults.tokenizerOutput.lines)
);
const ranges = [...state.getRanges().filter((r) => !!r.marker?.data)];
assert.strictEqual(edits.length, ranges.length, `${markerName} expects ${ranges.length} but got ${edits.length}`);
testTextEdits(state, edits, ranges);
}
// Computes auto-import insertion edits the same way `testInsertions` does, but actually
// applies them to the document and returns the resulting text. `testTextEdits` only checks
// each edit's range/text independently (order-insensitive), so it cannot observe how multiple
// edits at the *same* anchor compose once applied. Use this when the asserted behavior depends
// on the final applied document text.
function applyInsertions(
code: string,
markerName: string,
importNameInfo: ImportNameWithModuleInfo | ImportNameWithModuleInfo[]
): string {
const state = parseAndGetTestState(code).state;
const marker = state.getMarkerByName(markerName)!;
const sourceFile = state.program.getBoundSourceFile(marker.fileUri)!;
const parseResults = sourceFile.getParseResults()!;
const importStatements = getTopLevelImports(parseResults.parserOutput.parseTree);
const edits = getTextEditsForAutoImportInsertions(
importNameInfo,
importStatements,
parseResults,
convertOffsetToPosition(marker.position, parseResults.tokenizerOutput.lines)
);
return applyTextEditsToString(edits, parseResults.tokenizerOutput.lines, parseResults.text);
}
function testInsertion(
code: string,
markerName: string,
importNameInfo: ImportNameInfo | ImportNameInfo[],
moduleName: string,
importType: ImportType
) {
importNameInfo = isArray(importNameInfo) ? importNameInfo : [importNameInfo];
if (importNameInfo.length === 0) {
importNameInfo.push({});
}
testInsertions(
code,
markerName,
importNameInfo.map((i) => {
return {
module: {
moduleName,
importType,
isLocalTypingsFile: false,
},
name: i.name,
alias: i.alias,
};
})
);
}
function testTextEdits(state: TestState, edits: TextEditAction[], ranges: Range[]) {
for (const edit of edits) {
assert(
ranges.some((r) => {
const data = r.marker!.data as { r: string };
const expectedText = data.r;
return (
rangesAreEqual(state.convertPositionRange(r), edit.range) &&
expectedText.replace(/!n!/g, '\n') === edit.replacementText
);
}),
`can't find '${edit.replacementText}'@'${edit.range.start.line},${edit.range.start.character}'`
);
}
}
@@ -1,450 +0,0 @@
/*
* languageServer.test.ts
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT license.
*
* Tests to verify Vipeye works as the backend for a language server.
*/
import assert from 'assert';
import {
CancellationToken,
CompletionItem,
CompletionRequest,
ConfigurationItem,
DidChangeWorkspaceFoldersNotification,
DidCloseTextDocumentNotification,
DocumentDiagnosticRequest,
InitializedNotification,
InitializeRequest,
MarkupContent,
} from 'vscode-languageserver';
import { convertOffsetToPosition } from '../common/positionUtils';
import { PythonVersion, pythonVersion3_10 } from '../common/pythonVersion';
import { isArray } from '../common/core';
import { normalizeSlashes } from '../common/pathUtils';
import { distlibFolder } from './harness/vfs/factory';
import {
cleanupAfterAll,
DEFAULT_WORKSPACE_ROOT,
getParseResults,
hover,
openFile,
ViperServerInfo,
runViperServer,
waitForDiagnostics,
} from './lsp/languageServerTestUtils';
describe(`Basic language server tests`, () => {
let serverInfo: ViperServerInfo | undefined;
async function runLanguageServer(
projectRoots: string[] | string,
code: string,
callInitialize = true,
extraSettings?: { item: ConfigurationItem; value: any }[],
pythonVersion: PythonVersion = pythonVersion3_10,
supportsBackgroundThread?: boolean,
supportsPullDiagnostics?: boolean
) {
const result = await runViperServer(
projectRoots,
code,
callInitialize,
extraSettings,
pythonVersion,
supportsBackgroundThread,
supportsPullDiagnostics
);
serverInfo = result;
return result;
}
afterEach(async () => {
if (serverInfo) {
await serverInfo.dispose();
serverInfo = undefined;
}
});
afterAll(async () => {
await cleanupAfterAll();
});
test.each([
{ name: 'capability disabled', capability: false, initFolders: 1, firstNotify: null, secondNotify: null },
{ name: '1 init, no notifications', capability: true, initFolders: 1, firstNotify: null, secondNotify: null },
{ name: '1 init, notify with 0', capability: true, initFolders: 1, firstNotify: 0, secondNotify: null },
{ name: '1 init, notify with 1', capability: true, initFolders: 1, firstNotify: 1, secondNotify: null },
{ name: '1 init, notify with 2', capability: true, initFolders: 1, firstNotify: 2, secondNotify: null },
{ name: '1 init, notify with 0 then 0', capability: true, initFolders: 1, firstNotify: 0, secondNotify: 0 },
{ name: '1 init, notify with 0 then 1', capability: true, initFolders: 1, firstNotify: 0, secondNotify: 1 },
{ name: '1 init, notify with 0 then 2', capability: true, initFolders: 1, firstNotify: 0, secondNotify: 2 },
{ name: '1 init, notify with 1 then 0', capability: true, initFolders: 1, firstNotify: 1, secondNotify: 0 },
{ name: '1 init, notify with 1 then 1', capability: true, initFolders: 1, firstNotify: 1, secondNotify: 1 },
{ name: '1 init, notify with 1 then 2', capability: true, initFolders: 1, firstNotify: 1, secondNotify: 2 },
{ name: '1 init, notify with 2 then 0', capability: true, initFolders: 1, firstNotify: 2, secondNotify: 0 },
{ name: '1 init, notify with 2 then 1', capability: true, initFolders: 1, firstNotify: 2, secondNotify: 1 },
{ name: '1 init, notify with 2 then 2', capability: true, initFolders: 1, firstNotify: 2, secondNotify: 2 },
{ name: '2 init, no notifications', capability: true, initFolders: 2, firstNotify: null, secondNotify: null },
{ name: '2 init, notify with 2', capability: true, initFolders: 2, firstNotify: 2, secondNotify: null },
{ name: '0 init, notify with 1', capability: true, initFolders: 0, firstNotify: 1, secondNotify: null },
{ name: '0 init, notify with 2', capability: true, initFolders: 0, firstNotify: 2, secondNotify: null },
])('workspace initialization: $name', async ({ capability, initFolders, firstNotify, secondNotify }) => {
const code = `
// @filename: test.py
//// import [|/*marker*/os|]
`;
const info = await runLanguageServer(DEFAULT_WORKSPACE_ROOT, code, false);
const params = info.getInitializeParams();
const folders = params.workspaceFolders!;
const folder2 = { name: 'workspace2', uri: 'file:///workspace2' };
params.capabilities.workspace!.workspaceFolders = capability;
if (initFolders === 0) {
params.workspaceFolders = [];
} else if (initFolders === 2) {
params.workspaceFolders = [...folders, folder2];
}
await info.connection.sendRequest(InitializeRequest.type, params, CancellationToken.None);
await info.connection.sendNotification(InitializedNotification.type, {});
const getFoldersForNotify = (count: number) => {
if (count === 0) return [];
if (count === 1) return folders;
return [...folders, folder2];
};
if (firstNotify !== null) {
await info.connection.sendNotification(DidChangeWorkspaceFoldersNotification.type, {
event: { added: getFoldersForNotify(firstNotify), removed: [] },
});
}
if (secondNotify !== null) {
await info.connection.sendNotification(DidChangeWorkspaceFoldersNotification.type, {
event: { added: getFoldersForNotify(secondNotify), removed: [] },
});
}
openFile(info, 'marker');
const result = await hover(info, 'marker');
assert(result && MarkupContent.is(result.contents));
assert.strictEqual(result.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: CompletionItem) => i.label === 'path')!;
assert(completionItem);
});
[false, true].forEach((supportsPullDiagnostics) => {
describe(`Diagnostics ${supportsPullDiagnostics ? 'pull' : 'push'}`, () => {
// Background analysis takes longer than 5 seconds sometimes, so we need to
// increase the timeout.
jest.setTimeout(200000);
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: 'viper.analysis',
},
value: {
typeCheckingMode: 'strict',
diagnosticMode: 'workspace',
},
},
];
const info = await runLanguageServer(
DEFAULT_WORKSPACE_ROOT,
code,
/* callInitialize */ true,
settings,
undefined,
/* supportsBackgroundThread */ true,
supportsPullDiagnostics
);
// 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);
const diagnostic = diagnostics.find((d) => d.uri.includes('root/test.py'));
assert(diagnostic);
assert.equal(diagnostic.diagnostics.length, 6);
// Make sure the error has a special rule
assert.equal(diagnostic.diagnostics[1].code, 'reportUnusedImport');
assert.equal(diagnostic.diagnostics[3].code, 'reportUnusedImport');
assert.equal(diagnostic.diagnostics[4].code, 'reportUnusedImport');
});
test('background thread diagnostics open mode', 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: 'viper.analysis',
},
value: {
typeCheckingMode: 'strict',
},
},
];
const info = await runLanguageServer(
DEFAULT_WORKSPACE_ROOT,
code,
/* callInitialize */ true,
settings,
undefined,
/* supportsBackgroundThread */ true,
supportsPullDiagnostics
);
// 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);
const diagnostic = diagnostics.find((d) => d.uri.includes('root/test.py'));
assert(diagnostic);
const unusedImports = diagnostic.diagnostics.filter((d) => d.code === 'reportUnusedImport');
assert.equal(unusedImports.length, 3);
});
test('Diagnostic severity overrides test', async () => {
const code = `
// @filename: test.py
//// def test([|/*marker*/x|]): ...
////
// @filename: pyproject.toml
////
`;
const settings = [
{
item: {
scopeUri: `file://${normalizeSlashes(DEFAULT_WORKSPACE_ROOT, '/')}`,
section: 'viper.analysis',
},
value: {
diagnosticSeverityOverrides: {
reportUnknownParameterType: 'warning',
},
},
},
];
const info = await runLanguageServer(
DEFAULT_WORKSPACE_ROOT,
code,
/* callInitialize */ true,
settings,
undefined,
/* supportsBackgroundThread */ true,
supportsPullDiagnostics
);
// 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);
const diagnostic = diagnostics.find((d) => d.uri.includes('test.py'));
assert(diagnostic);
// Make sure the error has a special rule
assert.ok(
diagnostic.diagnostics.some((d) => d.code === 'reportUnknownParameterType'),
`Expected diagnostic not found. Got ${JSON.stringify(diagnostic.diagnostics)}`
);
});
if (supportsPullDiagnostics) {
// Regression test: in open-files-only mode, diagnostics for a library/out-of-workspace
// file that was transiently opened (e.g. via go-to-definition) must clear once the client
// closes the file. A re-pull of the now-closed file must return an empty `full` report.
test('closed out-of-workspace file clears diagnostics on pull (open-files-only)', async () => {
const libraryPath = normalizeSlashes(`${distlibFolder.getFilePath()}/library.py`);
const code = `
// @filename: ${libraryPath}
//// x: int = [|/*lib*/"not an int"|]
`;
const settings = [
{
item: {
scopeUri: `file://${normalizeSlashes(DEFAULT_WORKSPACE_ROOT, '/')}`,
section: 'viper.analysis',
},
value: {
// default open-files-only mode
diagnosticMode: 'openFilesOnly',
},
},
];
const info = await runLanguageServer(
DEFAULT_WORKSPACE_ROOT,
code,
/* callInitialize */ true,
settings,
undefined,
/* supportsBackgroundThread */ true,
supportsPullDiagnostics
);
// Simulate go-to-definition opening the out-of-workspace library file.
await openFile(info, 'lib');
const libUri = info.testData.markerPositions.get('lib')!.fileUri.toString();
// 1) While the library file is open, a pull reports the type error.
const reportOpen: any = await info.connection.sendRequest(DocumentDiagnosticRequest.type, {
textDocument: { uri: libUri },
});
assert.strictEqual(reportOpen?.kind, 'full');
assert.ok(
(reportOpen?.items?.length ?? 0) > 0,
`expected library file to report errors while open, got ${JSON.stringify(reportOpen?.items)}`
);
// 2) Close the library file.
info.connection.sendNotification(DidCloseTextDocumentNotification.type, {
textDocument: { uri: libUri },
});
// 3) A re-pull of the now-closed out-of-workspace file must return an empty full report.
const reportClosed: any = await info.connection.sendRequest(DocumentDiagnosticRequest.type, {
textDocument: { uri: libUri },
});
assert.strictEqual(reportClosed?.kind, 'full');
assert.strictEqual(
reportClosed?.items?.length ?? 0,
0,
`expected no diagnostics for closed out-of-workspace library file, got ${JSON.stringify(
reportClosed?.items
)}`
);
});
// Regression guard: the open-state guard must only apply in
// open-files-only mode. In `workspace` mode (`checkOnlyOpenFiles === false`) a file
// that the client has closed is still analyzed, so a re-pull must continue to report
// its diagnostics. This proves the guard's `checkOnlyOpenFiles` condition is
// load-bearing and that the fix did not change workspace-mode behavior.
test('closed workspace file still reports diagnostics on pull (workspace mode)', async () => {
const code = `
// @filename: root/test.py
//// x: int = [|/*marker*/"not an int"|]
`;
const settings = [
{
item: {
scopeUri: `file://${normalizeSlashes(DEFAULT_WORKSPACE_ROOT, '/')}`,
section: 'viper.analysis',
},
value: {
diagnosticMode: 'workspace',
},
},
];
const info = await runLanguageServer(
DEFAULT_WORKSPACE_ROOT,
code,
/* callInitialize */ true,
settings,
undefined,
/* supportsBackgroundThread */ true,
supportsPullDiagnostics
);
await openFile(info, 'marker');
const fileUri = info.testData.markerPositions.get('marker')!.fileUri.toString();
// 1) While the file is open, a pull reports the type error.
const reportOpen: any = await info.connection.sendRequest(DocumentDiagnosticRequest.type, {
textDocument: { uri: fileUri },
});
assert.strictEqual(reportOpen?.kind, 'full');
assert.ok(
(reportOpen?.items?.length ?? 0) > 0,
`expected workspace file to report errors while open, got ${JSON.stringify(reportOpen?.items)}`
);
// 2) Close the file.
info.connection.sendNotification(DidCloseTextDocumentNotification.type, {
textDocument: { uri: fileUri },
});
// 3) In workspace mode the closed file is still analyzed, so a re-pull must still
// report the error (the open-state guard must NOT fire here).
const reportClosed: any = await info.connection.sendRequest(DocumentDiagnosticRequest.type, {
textDocument: { uri: fileUri },
});
assert.strictEqual(reportClosed?.kind, 'full');
assert.ok(
(reportClosed?.items?.length ?? 0) > 0,
`expected workspace-mode closed file to still report errors, got ${JSON.stringify(
reportClosed?.items
)}`
);
});
}
});
});
});
@@ -1,342 +0,0 @@
/*
* privateImportUsage.test.ts
*
* Tests for reportPrivateImportUsage when source packages are added to check paths.
*
* Bug: When a py.typed package is added to check paths (e.g., via command line),
* the reportPrivateImportUsage errors incorrectly disappear because the file is
* created with isInPyTypedPackage=false instead of detecting it properly.
*/
import assert from 'assert';
import { ImportResolver } from '../analyzer/importResolver';
import { Program } from '../analyzer/program';
import { ConfigOptions } from '../common/configOptions';
import { DiagnosticCategory } from '../common/diagnostic';
import { lib, sitePackages } from '../common/pathConsts';
import { combinePaths, getDirectoryPath, normalizeSlashes } from '../common/pathUtils';
import { createServiceProvider } from '../common/serviceProviderExtensions';
import { UriEx } from '../common/uri/uriUtils';
import { TestAccessHost } from './harness/testAccessHost';
import { TestFileSystem } from './harness/vfs/filesystem';
import { ViperFileSystem } from '../viperFileSystem';
const libraryRoot = combinePaths(normalizeSlashes('/'), lib, sitePackages);
function createTestFileSystem(files: { path: string; content: string }[]): TestFileSystem {
const fs = new TestFileSystem(/* ignoreCase */ false, { cwd: normalizeSlashes('/') });
for (const file of files) {
const path = normalizeSlashes(file.path);
const dir = getDirectoryPath(path);
fs.mkdirpSync(dir);
fs.writeFileSync(UriEx.file(path), file.content);
}
return fs;
}
function createServiceProviderFromFiles(files: { path: string; content: string }[]) {
const testFS = createTestFileSystem(files);
const fs = new ViperFileSystem(testFS);
return createServiceProvider(testFS, fs);
}
describe('reportPrivateImportUsage with tracked library files', () => {
test('error should persist when library with py.typed is also a tracked file', () => {
// Setup: Create three packages
// pkg_a: defines helper_func
// pkg_b: imports helper_func from pkg_a but doesn't re-export it (has py.typed)
// pkg_c: imports helper_func from pkg_b (should get error)
const files = [
// pkg_a in library (defines the original function)
{
path: combinePaths(libraryRoot, 'pkg_a', '__init__.py'),
content: '',
},
{
path: combinePaths(libraryRoot, 'pkg_a', 'py.typed'),
content: '',
},
{
path: combinePaths(libraryRoot, 'pkg_a', 'utils.py'),
content: 'def helper_func(): pass',
},
// pkg_b in library (re-imports without re-exporting)
{
path: combinePaths(libraryRoot, 'pkg_b', '__init__.py'),
content: '',
},
{
path: combinePaths(libraryRoot, 'pkg_b', 'py.typed'),
content: '',
},
{
path: combinePaths(libraryRoot, 'pkg_b', 'reexport.py'),
content: 'from pkg_a.utils import helper_func', // No __all__, not re-exported
},
// pkg_c - local source file that imports from pkg_b
{
path: normalizeSlashes('/src/pkg_c/__init__.py'),
content: '',
},
{
path: normalizeSlashes('/src/pkg_c/bad_import.py'),
content: 'from pkg_b.reexport import helper_func', // Should error
},
];
const sp = createServiceProviderFromFiles(files);
const configOptions = new ConfigOptions(UriEx.file('/'));
// Set up execution environment with reportPrivateImportUsage enabled
configOptions.diagnosticRuleSet.reportPrivateImportUsage = 'error';
const importResolver = new ImportResolver(
sp,
configOptions,
new TestAccessHost(sp.fs().getModulePath(), [UriEx.file(libraryRoot)])
);
const program = new Program(importResolver, configOptions, sp);
// Track only the consumer file (pkg_c)
const consumerUri = UriEx.file('/src/pkg_c/bad_import.py');
program.setTrackedFiles([consumerUri]);
// Analyze
while (program.analyze()) {
// Continue until complete
}
// Get diagnostics for the consumer file
const sourceFile = program.getSourceFile(consumerUri);
assert(sourceFile, 'Source file should exist');
const diagnostics = sourceFile.getDiagnostics(configOptions) || [];
const errors = diagnostics.filter((d) => d.category === DiagnosticCategory.Error);
// Should have 1 error about private import
assert.strictEqual(
errors.length,
1,
`Expected 1 error when only consumer is tracked, got ${errors.length}: ${errors
.map((e) => e.message)
.join(', ')}`
);
assert(
errors[0].message.includes('not exported') || errors[0].message.includes('helper_func'),
`Error message should mention private import: ${errors[0].message}`
);
program.dispose();
// Now create a new program and track BOTH the library file and the consumer
const program2 = new Program(importResolver, configOptions, sp);
const libraryFileUri = UriEx.file(combinePaths(libraryRoot, 'pkg_b', 'reexport.py'));
// Track both files - this is the bug scenario
// When the library file is tracked, it should still detect py.typed
program2.setTrackedFiles([consumerUri, libraryFileUri]);
// Analyze
while (program2.analyze()) {
// Continue until complete
}
// Get diagnostics for the consumer file again
const sourceFile2 = program2.getSourceFile(consumerUri);
assert(sourceFile2, 'Source file should exist in second program');
const diagnostics2 = sourceFile2.getDiagnostics(configOptions) || [];
const errors2 = diagnostics2.filter((d) => d.category === DiagnosticCategory.Error);
// BUG: Without the fix, this would be 0 errors instead of 1
// The error disappears because pkg_b/reexport.py is created with isInPyTypedPackage=false
assert.strictEqual(
errors2.length,
1,
`Expected 1 error when library is also tracked, got ${errors2.length}. ` +
`Errors: ${errors2.map((e) => e.message).join(', ')}. ` +
`This is the bug - error should persist even when library file is tracked.`
);
program2.dispose();
sp.dispose();
});
test('config file reportPrivateImportUsage override should apply when includeFileSpecsOverride is set', () => {
// This tests the scenario where viper is run with positional directory arguments
// (e.g., viper --project viperconfig.json dir1) and the config file has
// reportPrivateImportUsage: false. The override should still be respected.
const files = [
// pkg_a in library (defines the original function)
{
path: combinePaths(libraryRoot, 'pkg_a', '__init__.py'),
content: '',
},
{
path: combinePaths(libraryRoot, 'pkg_a', 'py.typed'),
content: '',
},
{
path: combinePaths(libraryRoot, 'pkg_a', 'utils.py'),
content: 'def helper_func(): pass',
},
// pkg_b in library (re-imports without re-exporting)
{
path: combinePaths(libraryRoot, 'pkg_b', '__init__.py'),
content: '',
},
{
path: combinePaths(libraryRoot, 'pkg_b', 'py.typed'),
content: '',
},
{
path: combinePaths(libraryRoot, 'pkg_b', 'reexport.py'),
content: 'from pkg_a.utils import helper_func', // No __all__, not re-exported
},
// Consumer package - local source file that imports from pkg_b
{
path: normalizeSlashes('/src/consumer/__init__.py'),
content: '',
},
{
path: normalizeSlashes('/src/consumer/bad_import.py'),
content: 'from pkg_b.reexport import helper_func', // Would normally error
},
];
const sp = createServiceProviderFromFiles(files);
// Test 1: With reportPrivateImportUsage = 'error' (default) -> should get error
{
const configOptions = new ConfigOptions(UriEx.file('/'));
configOptions.diagnosticRuleSet.reportPrivateImportUsage = 'error';
const importResolver = new ImportResolver(
sp,
configOptions,
new TestAccessHost(sp.fs().getModulePath(), [UriEx.file(libraryRoot)])
);
const program = new Program(importResolver, configOptions, sp);
const consumerUri = UriEx.file('/src/consumer/bad_import.py');
program.setTrackedFiles([consumerUri]);
while (program.analyze()) {
// Continue until complete
}
const sourceFile = program.getSourceFile(consumerUri);
assert(sourceFile, 'Source file should exist');
const diagnostics = sourceFile.getDiagnostics(configOptions) || [];
const errors = diagnostics.filter((d) => d.category === DiagnosticCategory.Error);
assert.strictEqual(
errors.length,
1,
`Expected 1 error with reportPrivateImportUsage=error, got ${errors.length}`
);
program.dispose();
}
// Test 2: With reportPrivateImportUsage = 'none' (simulating config override) -> should NOT get error
{
const configOptions = new ConfigOptions(UriEx.file('/'));
configOptions.diagnosticRuleSet.reportPrivateImportUsage = 'none';
const importResolver = new ImportResolver(
sp,
configOptions,
new TestAccessHost(sp.fs().getModulePath(), [UriEx.file(libraryRoot)])
);
const program = new Program(importResolver, configOptions, sp);
const consumerUri = UriEx.file('/src/consumer/bad_import.py');
program.setTrackedFiles([consumerUri]);
while (program.analyze()) {
// Continue until complete
}
const sourceFile = program.getSourceFile(consumerUri);
assert(sourceFile, 'Source file should exist');
const diagnostics = sourceFile.getDiagnostics(configOptions) || [];
const privateImportErrors = diagnostics.filter(
(d) =>
d.category === DiagnosticCategory.Error &&
(d.message.includes('not exported') || d.message.includes('helper_func'))
);
assert.strictEqual(
privateImportErrors.length,
0,
`Expected 0 private import errors with reportPrivateImportUsage=none, got ${
privateImportErrors.length
}: ${privateImportErrors.map((e) => e.message).join(', ')}`
);
program.dispose();
}
// Test 3: With reportPrivateImportUsage = 'none' AND positional arg include override
// This simulates: viper --project config.json consumer/
// where config.json has reportPrivateImportUsage: false
{
const configOptions = new ConfigOptions(UriEx.file('/'));
// Simulate the config file setting: first set standard mode defaults
configOptions.diagnosticRuleSet = ConfigOptions.getDiagnosticRuleSet('standard');
// Then apply the config override (reportPrivateImportUsage: false -> 'none')
configOptions.diagnosticRuleSet.reportPrivateImportUsage = 'none';
// Simulate includeFileSpecsOverride by changing the include
// (In the real flow, this is done by _applyCommandLineOverrides)
configOptions.include = [];
const importResolver = new ImportResolver(
sp,
configOptions,
new TestAccessHost(sp.fs().getModulePath(), [UriEx.file(libraryRoot)])
);
const program = new Program(importResolver, configOptions, sp);
const consumerUri = UriEx.file('/src/consumer/bad_import.py');
// Track only the consumer file (simulating positional arg)
program.setTrackedFiles([consumerUri]);
while (program.analyze()) {
// Continue until complete
}
const sourceFile = program.getSourceFile(consumerUri);
assert(sourceFile, 'Source file should exist');
const diagnostics = sourceFile.getDiagnostics(configOptions) || [];
const privateImportErrors = diagnostics.filter(
(d) =>
d.category === DiagnosticCategory.Error &&
(d.message.includes('not exported') || d.message.includes('helper_func'))
);
// The config says reportPrivateImportUsage: false, so there should be no errors
// even when include is overridden by positional args.
assert.strictEqual(
privateImportErrors.length,
0,
`Expected 0 private import errors with config override and include override, got ${
privateImportErrors.length
}: ${privateImportErrors.map((e) => e.message).join(', ')}. ` +
`This would indicate the bug where positional args cause config overrides to be ignored.`
);
program.dispose();
}
sp.dispose();
});
});
@@ -1,710 +0,0 @@
/*
* service.test.ts
*
* service tests.
*/
import assert from 'assert';
import { CancellationToken } from 'vscode-jsonrpc';
import { InvalidatedReason } from '../analyzer/backgroundAnalysisProgram';
import { SourceEnumerator } from '../analyzer/sourceEnumerator';
import { IPythonMode } from '../analyzer/sourceFile';
import { NullConsole } from '../common/console';
import { CommandLineOptions } from '../common/commandLineOptions';
import { combinePaths, getDirectoryPath, normalizeSlashes } from '../common/pathUtils';
import { Uri } from '../common/uri/uri';
import { getFileSpec, UriEx } from '../common/uri/uriUtils';
import { TestFileSystem } from './harness/vfs/filesystem';
import { parseTestData } from './harness/fourslash/fourSlashParser';
import { parseAndGetTestState, TestState } from './harness/fourslash/testState';
test('random library file changed', () => {
const state = parseAndGetTestState('', '/projectRoot').state;
assert.strictEqual(
state.workspace.service.test_shouldHandleLibraryFileWatchChanges(
Uri.file('/site-packages/test.py', state.serviceProvider),
[Uri.file('/site-packages', state.serviceProvider)]
),
true
);
});
test('random library file starting with . changed', () => {
const state = parseAndGetTestState('', '/projectRoot').state;
assert.strictEqual(
state.workspace.service.test_shouldHandleLibraryFileWatchChanges(
Uri.file('/site-packages/.test.py', state.serviceProvider),
[Uri.file('/site-packages', state.serviceProvider)]
),
false
);
});
test('source enumeration reports symlinked include roots', () => {
const fs = new TestFileSystem(/* ignoreCase */ false, { cwd: '/' });
fs.mkdirpSync('/realRoot/pkg');
fs.writeFileSync(Uri.file('/realRoot/pkg/module.py', fs), 'x = 1');
fs.symlinkSync('/realRoot', '/workspaceLink');
const enumerator = new SourceEnumerator(
[getFileSpec(Uri.file('/', fs), 'workspaceLink')],
[],
/* autoExcludeVenv */ false,
fs,
new NullConsole()
);
const result = enumerator.enumerate(/* timeLimitInMs */ 1000);
assert.strictEqual(result.isComplete, true);
assert.deepStrictEqual(
enumerator.getSymlinkedDirectoryRoots().map((uri) => uri.key),
[Uri.file('/workspaceLink', fs).key]
);
});
test('random library file changed, nested search paths', () => {
const state = parseAndGetTestState('', '/projectRoot').state;
assert.strictEqual(
state.workspace.service.test_shouldHandleLibraryFileWatchChanges(
Uri.file('/lib/.venv/site-packages/myFile.py', state.serviceProvider),
[Uri.file('/lib', state.serviceProvider), Uri.file('/lib/.venv/site-packages', state.serviceProvider)]
),
true
);
});
test('random library file changed, nested search paths, fs is not case sensitive', () => {
const code = `
// global options
// @ignoreCase: true
`;
const state = parseAndGetTestState(code, '/projectRoot').state;
assert.strictEqual(
state.workspace.service.test_shouldHandleLibraryFileWatchChanges(
Uri.file('/lib/.venv/site-packages/myFile.py', state.serviceProvider),
[Uri.file('/lib', state.serviceProvider), Uri.file('/LIB/.venv/site-packages', state.serviceProvider)]
),
true
);
});
test('random library file changed, nested search paths, fs is case sensitive', () => {
const code = `
// global options
// @ignoreCase: false
`;
const state = parseAndGetTestState(code, '/projectRoot').state;
assert.strictEqual(
state.workspace.service.test_shouldHandleLibraryFileWatchChanges(
Uri.file('/lib/.venv/site-packages/myFile.py', state.serviceProvider),
[Uri.file('/lib', state.serviceProvider), Uri.file('/LIB/.venv/site-packages', state.serviceProvider)]
),
false
);
});
test('random library file starting with . changed, fs is not case sensitive', () => {
const code = `
// global options
// @ignoreCase: true
`;
const state = parseAndGetTestState(code, '/projectRoot').state;
assert.strictEqual(
state.workspace.service.test_shouldHandleLibraryFileWatchChanges(
Uri.file('/lib/.test.py', state.serviceProvider),
[Uri.file('/LIB', state.serviceProvider), Uri.file('/lib/site-packages', state.serviceProvider)]
),
false
);
});
test('random library file starting with . changed, fs is case sensitive', () => {
const code = `
// global options
// @ignoreCase: false
`;
const state = parseAndGetTestState(code, '/projectRoot').state;
assert.strictEqual(
state.workspace.service.test_shouldHandleLibraryFileWatchChanges(
Uri.file('/lib/.test.py', state.serviceProvider),
[Uri.file('/LIB', state.serviceProvider), Uri.file('/lib/site-packages', state.serviceProvider)]
),
true
);
});
test('random library file under a folder starting with . changed', () => {
const state = parseAndGetTestState('', '/projectRoot').state;
assert.strictEqual(
state.workspace.service.test_shouldHandleLibraryFileWatchChanges(
Uri.file('/site-packages/.testFolder/test.py', state.serviceProvider),
[Uri.file('/site-packages', state.serviceProvider)]
),
false
);
});
test('basic file change', () => {
const code = `
// @filename: test.py
//// [|/*marker*/|]
`;
testSourceFileWatchChange(code);
});
test('non python file', () => {
const code = `
// @filename: test.pyc
//// [|/*marker*/|]
`;
testSourceFileWatchChange(code, /* expected */ false);
});
test('temp file', () => {
const code = `
// @filename: test.py.12345678901234567890123456789012.py
//// [|/*marker*/|]
`;
testSourceFileWatchChange(code, /* expected */ false);
});
test('excluded file', () => {
const code = `
// @filename: viperconfig.json
//// {
//// "exclude": ["**/excluded.py"]
//// }
// @filename: included.py
//// # empty
// @filename: excluded.py
//// [|/*marker*/|]
`;
testSourceFileWatchChange(code, /* expected */ false);
});
test('excluded but still part of program', () => {
const code = `
// @filename: viperconfig.json
//// {
//// "exclude": ["**/excluded.py"]
//// }
// @filename: included.py
//// from . import excluded
// @filename: excluded.py
//// [|/*marker*/|]
`;
const state = parseAndGetTestState(code, '/projectRoot').state;
const marker = state.getMarkerByName('marker');
while (state.workspace.service.test_program.analyze());
assert.strictEqual(
state.workspace.service.test_shouldHandleSourceFileWatchChanges(marker.fileUri, /* isFile */ true),
true
);
});
test('py.typed marker file', () => {
const code = `
// @filename: myPkg/__init__.py
//// # empty
// @filename: myPkg/py.typed
//// [|/*marker*/|]
`;
testSourceFileWatchChange(code, /* expected */ true);
});
test('py.typed marker file outside workspace semantics', () => {
const code = `
// @filename: random/py.typed
//// [|/*marker*/|]
`;
testSourceFileWatchChange(code, /* expected */ false);
});
test('py.typed marker file add causes invalidation', () => {
const code = `
// @filename: myPkg/__init__.py
//// # empty
`;
const state = parseAndGetTestState(code, '/projectRoot').state;
const pyTypedUri = Uri.file('/projectRoot/myPkg/py.typed', state.serviceProvider);
// Setup the file watcher for the project.
const cmdOptions = new CommandLineOptions(state.workspace.rootUri, false);
cmdOptions.languageServerSettings.watchForSourceChanges = true;
state.workspace.service.setOptions(cmdOptions);
let invalidatedReason: InvalidatedReason | undefined;
state.workspace.service.test_setOnInvalidatedCallback((reason) => {
invalidatedReason = reason;
});
state.testFS.writeFileSync(pyTypedUri, 'marker');
state.testFS.fireFileWatcherEvent(pyTypedUri.toString(), 'add');
assert.strictEqual(invalidatedReason, InvalidatedReason.SourceWatcherChanged);
});
test('py.typed marker file change causes invalidation', () => {
const code = `
// @filename: myPkg/__init__.py
//// # empty
// @filename: myPkg/py.typed
//// marker
`;
const state = parseAndGetTestState(code, '/projectRoot').state;
const pyTypedUri = Uri.file('/projectRoot/myPkg/py.typed', state.serviceProvider);
// Setup the file watcher for the project.
const cmdOptions = new CommandLineOptions(state.workspace.rootUri, false);
cmdOptions.languageServerSettings.watchForSourceChanges = true;
state.workspace.service.setOptions(cmdOptions);
let invalidatedReason: InvalidatedReason | undefined;
state.workspace.service.test_setOnInvalidatedCallback((reason) => {
invalidatedReason = reason;
});
state.testFS.writeFileSync(pyTypedUri, 'changed');
state.testFS.fireFileWatcherEvent(pyTypedUri.toString(), 'change');
assert.strictEqual(invalidatedReason, InvalidatedReason.SourceWatcherChanged);
});
test('py.typed marker file delete causes invalidation', () => {
const code = `
// @filename: myPkg/__init__.py
//// # empty
// @filename: myPkg/py.typed
//// marker
`;
const state = parseAndGetTestState(code, '/projectRoot').state;
const pyTypedUri = Uri.file('/projectRoot/myPkg/py.typed', state.serviceProvider);
// Setup the file watcher for the project.
const cmdOptions = new CommandLineOptions(state.workspace.rootUri, false);
cmdOptions.languageServerSettings.watchForSourceChanges = true;
state.workspace.service.setOptions(cmdOptions);
let invalidatedReason: InvalidatedReason | undefined;
state.workspace.service.test_setOnInvalidatedCallback((reason) => {
invalidatedReason = reason;
});
// The watcher reports only 'add' and 'change'. Deletions are inferred when the path can't be stat'ed.
state.testFS.unlinkSync(pyTypedUri);
state.testFS.fireFileWatcherEvent(pyTypedUri.toString(), 'change');
assert.strictEqual(invalidatedReason, InvalidatedReason.SourceWatcherChanged);
});
test('random folder changed', () => {
const code = `
// @filename: notUsed.py
//// # empty
`;
const state = parseAndGetTestState(code, '/projectRoot').state;
assert.strictEqual(
state.workspace.service.test_shouldHandleSourceFileWatchChanges(
Uri.file('/randomFolder', state.serviceProvider),
/* isFile */ false
),
false
);
});
test('excluded folder changed', () => {
const code = `
// @filename: viperconfig.json
//// {
//// "exclude": ["**/.*"]
//// }
// @filename: .excluded/notUsed.py
//// [|/*marker*/|]
`;
testSourceFileWatchChange(code, /* expected */ false, /* isFile */ false);
});
test('file under excluded folder changed', () => {
const code = `
// @filename: viperconfig.json
//// {
//// "exclude": ["**/.*"]
//// }
// @filename: included.py
//// # empty
// @filename: .excluded/notUsed.py
//// [|/*marker*/|]
`;
testSourceFileWatchChange(code, /* expected */ false);
});
test('folder under excluded folder changed', () => {
const code = `
// @filename: viperconfig.json
//// {
//// "exclude": ["**/.*"]
//// }
// @filename: .excluded/nested/notUsed.py
//// [|/*marker*/|]
`;
testSourceFileWatchChange(code, /* expected */ false, /* isFile */ false);
});
test('folder that contains no file has changed', () => {
const code = `
// @filename: viperconfig.json
//// {
//// "exclude": ["**/excluded.py"]
//// }
// @filename: included.py
//// # empty
// @filename: lib/excluded.py
//// [|/*marker*/|]
`;
testSourceFileWatchChange(code, /* expected */ false, /* isFile */ false);
});
test('folder that contains a file has changed', () => {
const code = `
// @filename: lib/included.py
//// [|/*marker*/|]
`;
testSourceFileWatchChange(code, /* expected */ true, /* isFile */ false);
});
test('folder that contains no file but whose parent has __init__ has changed', () => {
const code = `
// @filename: viperconfig.json
//// {
//// "exclude": ["**/excluded.py"]
//// }
// @filename: lib/__init__.py
//// # empty
// @filename: lib/nested/excluded.py
//// [|/*marker*/|]
`;
testSourceFileWatchChange(code, /* expected */ true, /* isFile */ false);
});
test('library file watching for extra path under workspace', () => {
const watchers = getRegisteredLibraryFileWatchers('/src', ['extraPath'], ['extraPath/**']);
assert(watchers.some((w) => w.paths.some((p) => p.equals(UriEx.file('/src/extraPath')))));
});
test('user file watching as extra path under workspace', () => {
// Sometimes, this trick is used to make sub-modules to top-level modules.
const watchers = getRegisteredLibraryFileWatchers('/src', ['extraPath']);
// This shouldn't be recognized as library file.
assert(!watchers.some((w) => w.paths.some((p) => p.equals(UriEx.file('/src/extraPath')))));
});
test('library file watching another workspace root using extra path', () => {
// The extra path for a different workspace root will be initially added as a relative path,
// but when it reaches the service layer, it will be normalized to an absolute path.
// That's why it is used as an absolute path here.
const watchers = getRegisteredLibraryFileWatchers('/root1', ['/root2']);
assert(watchers.some((w) => w.paths.some((p) => p.equals(UriEx.file('/root2')))));
});
test('program containsSourceFileIn', () => {
const code = `
// @ignoreCase: true
// @filename: myLib/__init__.py
//// # empty
`;
const state = parseAndGetTestState(code, '/projectRoot').state;
assert(state.workspace.service.test_program.containsSourceFileIn(state.activeFile.fileUri));
});
test('service runEditMode', () => {
const code = `
// @filename: open.py
//// /*open*/
// @filename: closed.py
//// /*closed*/
`;
const state = parseAndGetTestState(code, '/projectRoot').state;
const open = state.getMarkerByName('open');
const closed = state.getMarkerByName('closed');
const openUri = open.fileUri;
const closedUri = closed.fileUri;
const newFileUri = Uri.file(combinePaths(getDirectoryPath(open.fileName), 'interimFile.py'), state.serviceProvider);
state.testFS.writeFileSync(newFileUri, '# empty', 'utf8');
const options = {
ipythonMode: IPythonMode.None,
chainedFileUri: newFileUri,
};
// try run edit mode
verifyRunEditMode('# first');
// try run again to make sure things are cleared up correctly
verifyRunEditMode('# second');
function verifyRunEditMode(value: string) {
state.workspace.service.runEditMode((p) => {
p.addInterimFile(newFileUri);
p.setFileOpened(openUri, 0, value, options);
p.setFileOpened(closedUri, 0, value, options);
const interim = p.getSourceFileInfo(newFileUri);
assert(interim);
const openFile = p.getSourceFileInfo(openUri);
assert(openFile);
assert(openFile.isOpenByClient);
assert.strictEqual(value, openFile.contents);
const closedFile = p.getSourceFileInfo(closedUri);
assert(closedFile);
assert(closedFile.isOpenByClient);
assert.strictEqual(value, closedFile.contents);
}, CancellationToken.None);
const interim = state.workspace.service.test_program.getSourceFileInfo(newFileUri);
assert(!interim);
const openFile = state.workspace.service.test_program.getSourceFileInfo(openUri);
assert(openFile);
assert(openFile.isOpenByClient);
assert.strictEqual('', openFile.contents?.trim());
const closedFile = state.workspace.service.test_program.getSourceFileInfo(closedUri);
assert(closedFile);
assert(!closedFile.isOpenByClient);
const content = closedFile.contents ?? '';
assert.strictEqual('', content.trim());
}
});
test('setFileOpened does not change tracked state for existing source files', () => {
const state = parseAndGetTestState('', '/projectRoot').state;
const program = state.workspace.service.test_program;
const uri = UriEx.file('/projectRoot/interim.py');
program.addInterimFile(uri);
const sourceFileInfo = program.getSourceFileInfo(uri);
assert(sourceFileInfo);
assert.strictEqual(sourceFileInfo.isTracked, false);
// Opening an existing untracked file does not change its tracked state.
// Tracking is determined at creation time.
program.setFileOpened(uri, 1, 'value = 1', {
ipythonMode: IPythonMode.None,
chainedFileUri: undefined,
});
assert.strictEqual(sourceFileInfo.isOpenByClient, true);
assert.strictEqual(sourceFileInfo.isTracked, false);
});
test('setTrackedFiles does not untrack virtual open files', () => {
const state = parseAndGetTestState('', '/projectRoot').state;
const program = state.workspace.service.test_program;
const uri = Uri.parse('vscode-copilot-chat-code-block://conversation/block1.py', state.serviceProvider);
program.setFileOpened(uri, 1, 'value = 1', {
isVirtual: true,
ipythonMode: IPythonMode.None,
chainedFileUri: undefined,
});
program.setTrackedFiles([]);
const sourceFileInfo = program.getSourceFileInfo(uri);
assert(sourceFileInfo);
assert.strictEqual(sourceFileInfo.isOpenByClient, true);
assert.strictEqual(sourceFileInfo.isVirtual, true);
assert.strictEqual(sourceFileInfo.isTracked, true);
});
test('setTrackedFiles does not preserve tracked state for non-virtual open files', () => {
const state = parseAndGetTestState('', '/projectRoot').state;
const program = state.workspace.service.test_program;
const uri = UriEx.file('/projectRoot/interim.py');
state.testFS.mkdirpSync('/projectRoot');
state.testFS.writeFileSync(uri, 'value = 1');
program.addTrackedFile(uri);
program.setFileOpened(uri, 1, 'value = 1', {
ipythonMode: IPythonMode.None,
chainedFileUri: undefined,
});
program.setTrackedFiles([]);
const sourceFileInfo = program.getSourceFileInfo(uri);
assert(sourceFileInfo);
assert.strictEqual(sourceFileInfo.isOpenByClient, true);
assert.strictEqual(sourceFileInfo.isVirtual, false);
assert.strictEqual(sourceFileInfo.isTracked, false);
});
test('setFileClosed auto-untracks and removes virtual files from the source list', () => {
const state = parseAndGetTestState('', '/projectRoot').state;
const program = state.workspace.service.test_program;
const uri = Uri.parse('vscode-copilot-chat-code-block://conversation/block1.py', state.serviceProvider);
program.setFileOpened(uri, 1, 'value = 1', {
isVirtual: true,
ipythonMode: IPythonMode.None,
chainedFileUri: undefined,
});
const sourceFileInfo = program.getSourceFileInfo(uri);
assert(sourceFileInfo);
assert.strictEqual(sourceFileInfo.isTracked, true);
assert.strictEqual(sourceFileInfo.isVirtual, true);
program.setFileClosed(uri);
// Virtual files are auto-untracked on close and removed from source list
assert.strictEqual(program.getSourceFileInfo(uri), undefined);
});
test('untitled files are treated as virtual and survive setTrackedFiles', () => {
const state = parseAndGetTestState('', '/projectRoot').state;
const program = state.workspace.service.test_program;
const uri = Uri.parse('untitled:Untitled-1.py', state.serviceProvider);
program.setFileOpened(uri, 1, 'value = 1', {
isVirtual: uri.isUntitled(),
ipythonMode: IPythonMode.None,
chainedFileUri: undefined,
});
const sourceFileInfo = program.getSourceFileInfo(uri);
assert(sourceFileInfo);
assert.strictEqual(sourceFileInfo.isVirtual, true);
assert.strictEqual(sourceFileInfo.isTracked, true);
// Untitled files should survive tracked-file refresh since they
// are in-memory-only and don't participate in disk enumeration.
program.setTrackedFiles([]);
assert.strictEqual(sourceFileInfo.isTracked, true);
});
test('file changes cause semantic update', () => {
const code = `
// @filename: open.py
//// import closed
//// /*open*/
// @filename: closed.py
//// /*closed*/
`;
const state = parseAndGetTestState(code, '/projectRoot').state;
const open = state.getMarkerByName('open');
const closed = state.getMarkerByName('closed');
const openUri = open.fileUri;
const closedUri = closed.fileUri;
const openContents = state.testFS.readFileSync(openUri, 'utf-8');
const options = {
ipythonMode: IPythonMode.None,
chainedFileUri: undefined,
};
const cmdOptions = new CommandLineOptions(state.workspace.rootUri, false);
cmdOptions.languageServerSettings.watchForSourceChanges = true;
state.workspace.service.setOptions(cmdOptions);
// Changing the closed file should update the semantic version of the open file as it is
// imported by it.
const p = state.workspace.service.test_program;
p.setFileOpened(openUri, 0, openContents, options);
// Do a parse so that imports are processed but not a full analysis as that would load
// the closed file into memory.
p.getParseResults(openUri);
const openFile = p.getSourceFileInfo(openUri);
assert(openFile);
assert(openFile.isOpenByClient);
assert.strictEqual(openContents, openFile.contents);
assert.strictEqual(openFile.imports.length, 3);
const oldSemanticVersion = openFile.semanticVersion;
state.testFS.writeFileSync(closedUri, 'print("changed")');
state.testFS.fireFileWatcherEvent(closedUri.toString(), 'change');
assert.strictEqual(openFile.semanticVersion, oldSemanticVersion + 1);
});
function testSourceFileWatchChange(code: string, expected = true, isFile = true) {
const state = parseAndGetTestState(code, '/projectRoot').state;
const marker = state.getMarkerByName('marker');
const path = isFile ? marker.fileName : getDirectoryPath(marker.fileName);
assert.strictEqual(
state.workspace.service.test_shouldHandleSourceFileWatchChanges(Uri.file(path, state.serviceProvider), isFile),
expected
);
}
function getRegisteredLibraryFileWatchers(root: string, extraPaths: string[], excludes: string[] = []) {
root = normalizeSlashes(root);
const data = parseTestData(root, '', '');
const state = new TestState(root, data);
const options = new CommandLineOptions(state.workspace.rootUri, false);
options.languageServerSettings.watchForLibraryChanges = true;
options.configSettings.extraPaths = extraPaths;
options.configSettings.excludeFileSpecs = excludes;
state.workspace.service.setOptions(options);
return state.testFS.fileWatchers;
}
@@ -1,607 +0,0 @@
/*
* signatureHelp.test.ts
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT license.
*
* Unit tests for signature help.
*/
import assert from 'assert';
import { CancellationToken, MarkupKind, SignatureHelp } from 'vscode-languageserver';
import { SignatureHelpProvider } from '../languageService/signatureHelpProvider';
import { parseAndGetTestState, TestState } from './harness/fourslash/testState';
import { ViperDocStringService } from '../common/docStringService';
test('invalid position in format string segment', () => {
const code = `
// @filename: test.py
//// f'{"(".capit[|/*marker*/|]alize()}'
`;
checkSignatureHelp(code, false);
});
test('valid position in format string segment', () => {
const code = `
// @filename: test.py
//// f'{"(".capitalize([|/*marker*/|])}'
`;
checkSignatureHelp(code, true);
});
test('valid position in the second format string segment', () => {
const code = `
// @filename: test.py
//// f'{print("hello")} {"(".capitalize([|/*marker*/|])}'
`;
checkSignatureHelp(code, true);
});
test('invalid position in the second format string segment', () => {
const code = `
// @filename: test.py
//// f'{print("hello")} {"(".capitalize [|/*marker*/|] ()}'
`;
checkSignatureHelp(code, false);
});
test('nested call in format string segment', () => {
const code = `
// @filename: test.py
//// def foo():
//// pass
////
//// f'{"(".capitalize(foo([|/*marker*/|]))}'
`;
checkSignatureHelp(code, true);
});
test('signature help shows source default values for stub ellipsis defaults', () => {
const code = `
// @filename: viperconfig.json
//// {
//// "useLibraryCodeForTypes": true
//// }
// @filename: test.py
//// import mylib
////
//// mylib.f([|/*marker*/|]
// @filename: mylib.pyi
//// def f(a: int, b: str = ...) -> None: ...
// @filename: mylib.py
//// def f(a: int, b: int = 3) -> None:
//// ...
`;
const state = parseAndGetTestState(code).state;
const actual = getSignatureHelpForMarker(state, 'marker');
assert(actual, 'Expected signature help result');
assert.strictEqual(actual.signatures.length, 1, 'Expected one signature');
assert.strictEqual(actual.signatures[0].label, '(a: int, b: str = 3) -> None');
});
test('signature help substitutes multiple stub ellipsis defaults from source', () => {
const code = `
// @filename: viperconfig.json
//// {
//// "useLibraryCodeForTypes": true
//// }
// @filename: test.py
//// import mylib
////
//// mylib.f([|/*marker*/|]
// @filename: mylib.pyi
//// def f(a: int = ..., b: str = ...) -> None: ...
// @filename: mylib.py
//// def f(a: int = 3, b: str = "hello") -> None:
//// ...
`;
const state = parseAndGetTestState(code).state;
const actual = getSignatureHelpForMarker(state, 'marker');
assert(actual, 'Expected signature help result');
assert.strictEqual(actual.signatures.length, 1, 'Expected one signature');
assert.strictEqual(actual.signatures[0].label, '(a: int = 3, b: str = "hello") -> None');
});
test('signature help shows concrete default values when provided by stub', () => {
const code = `
// @filename: viperconfig.json
//// {
//// "useLibraryCodeForTypes": true
//// }
// @filename: test.py
//// import mylib
////
//// mylib.f([|/*marker*/|]
// @filename: mylib.pyi
//// def f(a: int = 3, b: str = "hello") -> None: ...
`;
const state = parseAndGetTestState(code).state;
const actual = getSignatureHelpForMarker(state, 'marker');
assert(actual, 'Expected signature help result');
assert.strictEqual(actual.signatures.length, 1, 'Expected one signature');
assert.strictEqual(actual.signatures[0].label, '(a: int = 3, b: str = "hello") -> None');
});
test('signature help substitutes stub ellipsis defaults for all overloads', () => {
const code = `
// @filename: viperconfig.json
//// {
//// "useLibraryCodeForTypes": true
//// }
// @filename: test.py
//// import mylib
////
//// mylib.f(1, [|/*marker*/|])
// @filename: mylib.pyi
//// from typing import overload
////
//// @overload
//// def f(a: int = ..., b: str = ...) -> None: ...
////
//// @overload
//// def f(a: str = ..., b: str = ...) -> None: ...
////
//// def f(*args, **kwargs) -> None: ...
// @filename: mylib.py
//// def f(a: int = 3, b: str = "hello") -> None:
//// ...
`;
const state = parseAndGetTestState(code).state;
const actual = getSignatureHelpForMarker(state, 'marker');
assert(actual, 'Expected signature help result');
// Python runtime has a single implementation, so source defaults apply to all overloads.
assert.strictEqual(actual.activeSignature, 0);
assert.strictEqual(actual.signatures.length, 2);
assert.strictEqual(actual.signatures[0].label, '(a: int = 3, b: str = "hello") -> None');
assert.strictEqual(actual.signatures[1].label, '(a: str = 3, b: str = "hello") -> None');
});
test('signature help does not substitute when stub default is not ellipsis', () => {
const code = `
// @filename: viperconfig.json
//// {
//// "useLibraryCodeForTypes": true
//// }
// @filename: test.py
//// import mylib
////
//// mylib.f([|/*marker*/|]
// @filename: mylib.pyi
//// def f(a: int, b: str = 1) -> None: ...
// @filename: mylib.py
//// def f(a: int, b: int = 3) -> None:
//// ...
`;
const state = parseAndGetTestState(code).state;
const actual = getSignatureHelpForMarker(state, 'marker');
assert(actual, 'Expected signature help result');
assert.strictEqual(actual.signatures.length, 1, 'Expected one signature');
assert.strictEqual(actual.signatures[0].label, '(a: int, b: str = 1) -> None');
});
test('signature help does not substitute when source implementation is missing', () => {
const code = `
// @filename: viperconfig.json
//// {
//// "useLibraryCodeForTypes": true
//// }
// @filename: test.py
//// import mylib
////
//// mylib.f([|/*marker*/|]
// @filename: mylib.pyi
//// def f(a: int, b: str = ...) -> None: ...
`;
const state = parseAndGetTestState(code).state;
const actual = getSignatureHelpForMarker(state, 'marker');
assert(actual, 'Expected signature help result');
assert.strictEqual(actual.signatures.length, 1, 'Expected one signature');
assert.strictEqual(actual.signatures[0].label, '(a: int, b: str = ...) -> None');
});
test('signature help does not substitute unsafe long defaults for stub ellipsis defaults', () => {
const longNumber = '9'.repeat(150);
const code = `
// @filename: viperconfig.json
//// {
//// "useLibraryCodeForTypes": true
//// }
// @filename: test.py
//// import mylib
////
//// mylib.f([|/*marker*/|]
// @filename: mylib.pyi
//// def f(a: int, b: str = ...) -> None: ...
// @filename: mylib.py
//// def f(a: int, b: int = ${longNumber}) -> None:
//// ...
`;
const state = parseAndGetTestState(code).state;
const actual = getSignatureHelpForMarker(state, 'marker');
assert(actual, 'Expected signature help result');
assert.strictEqual(actual.signatures.length, 1, 'Expected one signature');
assert.strictEqual(actual.signatures[0].label, '(a: int, b: str = ...) -> None');
});
test('signature help does not substitute unsafe multiline defaults for stub ellipsis defaults', () => {
const code = `
// @filename: viperconfig.json
//// {
//// "useLibraryCodeForTypes": true
//// }
// @filename: test.py
//// import mylib
////
//// mylib.f([|/*marker*/|]
// @filename: mylib.pyi
//// def f(a: int, b: str = ...) -> None: ...
// @filename: mylib.py
//// def f(a: int, b: int = """hello
//// world""") -> None:
//// ...
`;
const state = parseAndGetTestState(code).state;
const actual = getSignatureHelpForMarker(state, 'marker');
assert(actual, 'Expected signature help result');
assert.strictEqual(actual.signatures.length, 1, 'Expected one signature');
assert.strictEqual(actual.signatures[0].label, '(a: int, b: str = ...) -> None');
});
test('signature help overloads: mixed ellipsis and concrete defaults', () => {
const code = `
// @filename: viperconfig.json
//// {
//// "useLibraryCodeForTypes": true
//// }
// @filename: test.py
//// import mylib
////
//// mylib.f([|/*marker*/|])
// @filename: mylib.pyi
//// from typing import overload
////
//// @overload
//// def f(a: int, b: str = ...) -> None: ...
////
//// @overload
//// def f(a: int, b: str = "default") -> None: ...
////
//// def f(*args, **kwargs) -> None: ...
// @filename: mylib.py
//// def f(a: int, b: str = "hello") -> None:
//// ...
`;
const state = parseAndGetTestState(code).state;
const actual = getSignatureHelpForMarker(state, 'marker');
assert(actual, 'Expected signature help result');
assert.strictEqual(actual.signatures.length, 2);
// First overload: ellipsis substituted from source
assert.strictEqual(actual.signatures[0].label, '(a: int, b: str = "hello") -> None');
// Second overload: concrete default kept as-is (not ellipsis, no substitution)
assert.strictEqual(actual.signatures[1].label, '(a: int, b: str = "default") -> None');
});
test('signature help overloads: overload with no defaults stays unchanged', () => {
const code = `
// @filename: viperconfig.json
//// {
//// "useLibraryCodeForTypes": true
//// }
// @filename: test.py
//// import mylib
////
//// mylib.f([|/*marker*/|])
// @filename: mylib.pyi
//// from typing import overload
////
//// @overload
//// def f(a: int) -> None: ...
////
//// @overload
//// def f(a: int, b: str = ...) -> None: ...
////
//// def f(*args, **kwargs) -> None: ...
// @filename: mylib.py
//// def f(a: int, b: str = "hello") -> None:
//// ...
`;
const state = parseAndGetTestState(code).state;
const actual = getSignatureHelpForMarker(state, 'marker');
assert(actual, 'Expected signature help result');
assert.strictEqual(actual.signatures.length, 2);
// First overload: no default parameter, stays unchanged
assert.strictEqual(actual.signatures[0].label, '(a: int) -> None');
// Second overload: ellipsis substituted from source
assert.strictEqual(actual.signatures[1].label, '(a: int, b: str = "hello") -> None');
});
test('signature help overloads: param name not in source keeps ellipsis', () => {
const code = `
// @filename: viperconfig.json
//// {
//// "useLibraryCodeForTypes": true
//// }
// @filename: test.py
//// import mylib
////
//// mylib.f([|/*marker*/|])
// @filename: mylib.pyi
//// from typing import overload
////
//// @overload
//// def f(a: int, b: str = ...) -> None: ...
////
//// @overload
//// def f(a: int, x: str = ...) -> None: ...
////
//// def f(*args, **kwargs) -> None: ...
// @filename: mylib.py
//// def f(a: int, b: str = "hello") -> None:
//// ...
`;
const state = parseAndGetTestState(code).state;
const actual = getSignatureHelpForMarker(state, 'marker');
assert(actual, 'Expected signature help result');
assert.strictEqual(actual.signatures.length, 2);
// First overload: 'b' found in source, substituted
assert.strictEqual(actual.signatures[0].label, '(a: int, b: str = "hello") -> None');
// Second overload: 'x' not in source, keeps ellipsis
assert.strictEqual(actual.signatures[1].label, '(a: int, x: str = ...) -> None');
});
test('signature help overloads: source uses *args/**kwargs, no substitution', () => {
const code = `
// @filename: viperconfig.json
//// {
//// "useLibraryCodeForTypes": true
//// }
// @filename: test.py
//// import mylib
////
//// mylib.f([|/*marker*/|])
// @filename: mylib.pyi
//// from typing import overload
////
//// @overload
//// def f(a: int = ...) -> None: ...
////
//// @overload
//// def f(a: str = ...) -> None: ...
////
//// def f(*args, **kwargs) -> None: ...
// @filename: mylib.py
//// def f(*args, **kwargs) -> None:
//// ...
`;
const state = parseAndGetTestState(code).state;
const actual = getSignatureHelpForMarker(state, 'marker');
assert(actual, 'Expected signature help result');
assert.strictEqual(actual.signatures.length, 2);
// Source has no named params with defaults, so ellipsis stays
assert.strictEqual(actual.signatures[0].label, '(a: int = ...) -> None');
assert.strictEqual(actual.signatures[1].label, '(a: str = ...) -> None');
});
test('signature help overloads: different param counts, extra params keep ellipsis', () => {
const code = `
// @filename: viperconfig.json
//// {
//// "useLibraryCodeForTypes": true
//// }
// @filename: test.py
//// import mylib
////
//// mylib.f([|/*marker*/|])
// @filename: mylib.pyi
//// from typing import overload
////
//// @overload
//// def f(a: int = ..., b: str = ..., c: float = ...) -> None: ...
////
//// @overload
//// def f(a: int = ...) -> None: ...
////
//// def f(*args, **kwargs) -> None: ...
// @filename: mylib.py
//// def f(a: int = 1, b: str = "hi") -> None:
//// ...
`;
const state = parseAndGetTestState(code).state;
const actual = getSignatureHelpForMarker(state, 'marker');
assert(actual, 'Expected signature help result');
assert.strictEqual(actual.signatures.length, 2);
// First overload: a and b substituted, c not in source keeps ellipsis
assert.strictEqual(actual.signatures[0].label, '(a: int = 1, b: str = "hi", c: float = ...) -> None');
// Second overload: a substituted
assert.strictEqual(actual.signatures[1].label, '(a: int = 1) -> None');
});
test('within arguments in format string segment', () => {
const code = `
// @filename: test.py
//// def foo():
//// pass
////
//// f'{"(".capitalize(fo[|/*marker*/|]o())}'
`;
checkSignatureHelp(code, true);
});
// Wrong signature hint with an unpacked parameterized TypedDict as a variadic keyword parameter.
// When a function declares `**kwargs: Unpack[GenericTypedDict[int]]`, signature help expands the
// TypedDict entries but must substitute the type argument: the `t` entry should print as `int`,
// not the unspecialized TypeVar `T@TD`.
test('signature help substitutes type argument for unpacked generic TypedDict kwargs', () => {
const code = `
// @filename: test.py
//// from typing import Generic, TypedDict, TypeVar, Unpack
////
//// T = TypeVar('T')
////
//// class TD(TypedDict, Generic[T]):
//// t: T
////
//// def func(**kwargs: Unpack[TD[int]]) -> None:
//// pass
////
//// func([|/*marker*/|])
`;
const state = parseAndGetTestState(code).state;
const actual = getSignatureHelpForMarker(state, 'marker');
assert(actual, 'Expected signature help for unpacked generic TypedDict kwargs');
assert.strictEqual(actual.signatures.length, 1, 'Expected one signature');
assert.strictEqual(actual.signatures[0].label, '(*, t: int) -> None');
});
// Additional coverage for the unpacked generic TypedDict kwargs case.
// The fix specializes TypedDict entries against the (possibly generic) TypedDict instance at the
// param value type, the default-param value type (only exercised by non-required entries), and
// nested generic value types. These variants guard the non-required (default-param) branch,
// per-entry specialization with a concrete sibling entry, and nested generic value types.
test('signature help substitutes type argument for unpacked generic TypedDict kwargs variants', () => {
const code = `
// @filename: test.py
//// from typing import Generic, NotRequired, TypedDict, TypeVar, Unpack
////
//// T = TypeVar('T')
////
//// class TDNotRequired(TypedDict, Generic[T]):
//// t: NotRequired[T]
////
//// class TDMixed(TypedDict, Generic[T]):
//// a: T
//// b: str
////
//// class TDNested(TypedDict, Generic[T]):
//// t: list[T]
////
//// def func_notrequired(**kwargs: Unpack[TDNotRequired[int]]) -> None: ...
//// def func_mixed(**kwargs: Unpack[TDMixed[int]]) -> None: ...
//// def func_nested(**kwargs: Unpack[TDNested[int]]) -> None: ...
////
//// func_notrequired([|/*notRequired*/|])
//// func_mixed([|/*mixed*/|])
//// func_nested([|/*nested*/|])
`;
const state = parseAndGetTestState(code).state;
// Non-required entries exercise the default-param specialization branch: the type argument must
// still be substituted (`t: int = ...`), not left as the TypeVar `t: T@... = ...`.
const notRequired = getSignatureHelpForMarker(state, 'notRequired');
assert(notRequired, "Expected signature help at 'notRequired'");
assert.strictEqual(notRequired.signatures.length, 1);
assert.strictEqual(notRequired.signatures[0].label, '(*, t: int = ...) -> None');
// Mixed entries: the generic entry is substituted while the concrete sibling is unchanged.
const mixed = getSignatureHelpForMarker(state, 'mixed');
assert(mixed, "Expected signature help at 'mixed'");
assert.strictEqual(mixed.signatures.length, 1);
assert.strictEqual(mixed.signatures[0].label, '(*, a: int, b: str) -> None');
// Nested generic value types must specialize recursively (`list[T]` -> `list[int]`).
const nested = getSignatureHelpForMarker(state, 'nested');
assert(nested, "Expected signature help at 'nested'");
assert.strictEqual(nested.signatures.length, 1);
assert.strictEqual(nested.signatures[0].label, '(*, t: list[int]) -> None');
});
function checkSignatureHelp(code: string, expects: boolean) {
const state = parseAndGetTestState(code).state;
const actual = getSignatureHelpForMarker(state, 'marker');
assert.strictEqual(!!actual, expects);
}
function getSignatureHelpForMarker(state: TestState, markerName: string): SignatureHelp | undefined {
const marker = state.getMarkerByName(markerName);
const position = state.getPosition(markerName);
return new SignatureHelpProvider(
state.workspace.service.test_program,
marker.fileUri,
position,
MarkupKind.Markdown,
/*hasSignatureLabelOffsetCapability*/ true,
/*hasActiveParameterCapability*/ true,
/*context*/ undefined,
new ViperDocStringService(),
CancellationToken.None
).getSignatureHelp();
}
@@ -1,502 +0,0 @@
/*
* sourceFile.test.ts
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT license.
*
* Unit tests for viper sourceMapperUtils module.
*/
import assert from 'assert';
import { CancellationToken, CancellationTokenSource } from 'vscode-jsonrpc';
import { MarkupKind } from 'vscode-languageserver';
import { AnalyzerService } from '../analyzer/service';
import {
FunctionDeclaration,
VariableDeclaration,
isFunctionDeclaration,
isVariableDeclaration,
} from '../analyzer/declaration';
import { findNodeByOffset } from '../analyzer/parseTreeUtils';
import { buildImportTree as buildImportTreeImpl } from '../analyzer/sourceMapperUtils';
import { TypeCategory } from '../analyzer/types';
import { ConfigOptions } from '../common/configOptions';
import { NullConsole } from '../common/console';
import { combinePaths, getDirectoryPath, normalizeSlashes } from '../common/pathUtils';
import { convertOffsetToPosition } from '../common/positionUtils';
import { createServiceProvider } from '../common/serviceProviderExtensions';
import { TextRange } from '../common/textRange';
import { UriEx } from '../common/uri/uriUtils';
import { HoverProvider } from '../languageService/hoverProvider';
import { PartialStubService } from '../partialStubService';
import { NameNode, ParseNodeType } from '../parser/parseNodes';
import { ViperFileSystem } from '../viperFileSystem';
import { parseTestData } from './harness/fourslash/fourSlashParser';
import { TestAccessHost } from './harness/testAccessHost';
import { getNodeAtMarker, parseAndGetTestState } from './harness/fourslash/testState';
import { TestFileSystem } from './harness/vfs/filesystem';
function buildImportTree(
sourceFile: string,
targetFile: string,
importResolver: (f: string) => string[],
token: CancellationToken
): string[] {
return buildImportTreeImpl(
UriEx.file(sourceFile),
UriEx.file(targetFile),
(from) => {
const resolved = importResolver(from.getFilePath().slice(1));
return resolved.map((f) => UriEx.file(f));
},
token
).map((u) => u.getFilePath().slice(1));
}
describe('BuildImportTree', () => {
const tokenSource = new CancellationTokenSource();
test('Simple', () => {
const results = buildImportTree(
'A',
'C',
(f) => {
switch (f) {
case 'C':
return ['B'];
case 'B':
return ['A'];
default:
break;
}
return [];
},
tokenSource.token
);
assert.deepEqual(results, ['C', 'B']);
});
test('Recursion', () => {
const results = buildImportTree(
'A',
'E',
(f) => {
switch (f) {
case 'E':
return ['D'];
case 'D':
return ['C', 'B'];
case 'C':
return ['D'];
case 'B':
return ['A'];
default:
break;
}
return [];
},
tokenSource.token
);
assert.deepEqual(results, ['E', 'D', 'B']);
});
test('Multiple Paths', () => {
const results = buildImportTree(
'A',
'G',
(f) => {
switch (f) {
case 'G':
return ['F', 'H', 'I'];
case 'F':
return ['D', 'E'];
case 'D':
return ['C', 'B'];
case 'C':
return ['E'];
case 'B':
return ['A'];
default:
break;
}
return [];
},
tokenSource.token
);
assert.deepEqual(results, ['G', 'F', 'D', 'B']);
});
test('No paths', () => {
const results = buildImportTree(
'A',
'G',
(f) => {
switch (f) {
case 'G':
return ['F', 'H', 'I'];
case 'F':
return ['D', 'E'];
case 'D':
return ['C', 'B'];
case 'C':
return ['E'];
default:
break;
}
return [];
},
tokenSource.token
);
assert.deepEqual(results, ['G']);
});
function genArray(start: number, end: number): string[] {
return Array(end - start)
.fill(0)
.map(() => String.fromCharCode(start++));
}
test('Too deep', () => {
const results = buildImportTree(
'Z',
'A',
(f) => {
const start = f.charCodeAt(0);
const end = 'Y'.charCodeAt(0);
return genArray(start, end);
},
tokenSource.token
);
assert.deepEqual(results, ['A']);
});
test('Canceled', () => {
const canceled = new CancellationTokenSource();
canceled.cancel();
const results = buildImportTree(
'A',
'E',
(f) => {
switch (f) {
case 'E':
return ['D'];
case 'D':
return ['C', 'B'];
case 'C':
return ['D'];
case 'B':
return ['A'];
default:
break;
}
return [];
},
canceled.token
);
assert.deepEqual(results, ['E']);
});
});
test('find type alias decl', () => {
const code = `
// @filename: test.py
//// from typing import Mapping
//// [|/*decl*/M|] = Mapping
////
//// def foo(/*marker*/m: M): pass
`;
assertTypeAlias(code);
});
test('find type alias decl from inferred type', () => {
const code = `
// @filename: test.py
//// from typing import Mapping
//// [|/*decl*/M|] = Mapping
////
//// def foo(m: M):
//// return m
// @filename: test1.py
//// from test import foo
//// a = { "hello": 10 }
////
//// /*marker*/b = foo(a)
`;
assertTypeAlias(code);
});
function assertTypeAlias(code: string) {
const state = parseAndGetTestState(code).state;
const node = getNodeAtMarker(state, 'marker');
assert(node.nodeType === ParseNodeType.Name);
const type = state.program.evaluator!.getType(node);
assert(type?.category === TypeCategory.Class);
assert.strictEqual(type.shared.name, 'Mapping');
assert.strictEqual(type.props?.typeAliasInfo?.shared.name, 'M');
assert.strictEqual(type.props?.typeAliasInfo.shared.moduleName, 'test');
const marker = state.getMarkerByName('marker');
const markerUri = marker.fileUri;
const mapper = state.program.getSourceMapper(
markerUri,
CancellationToken.None,
/* mapCompiled */ false,
/* preferStubs */ true
);
const range = state.getRangeByMarkerName('decl')!;
const decls = mapper.findDeclarationsByType(markerUri, type, /* userTypeAlias */ true);
const decl = decls.find((d) => isVariableDeclaration(d) && d.typeAliasName && d.typeAliasName.d.value === 'M') as
| VariableDeclaration
| undefined;
assert(decl);
assert.deepEqual(TextRange.create(decl.node.start, decl.node.length), TextRange.fromBounds(range.pos, range.end));
}
test('find method declarations through alias factory assignment', () => {
const code = `
// @filename: viperconfig.json
//// {
//// "useLibraryCodeForTypes": true
//// }
// @filename: _collections_abc.py
// @library: true
//// class MutableMapping:
//// def update(self, value=None):
//// '''Do the thing.
////
//// Updates the internal state.'''
//// pass
// @filename: _typing.pyi
// @library: true
//// from typing import overload
//// class MutableMapping:
//// @overload
//// def update(self) -> None: ...
//// @overload
//// def update(self, value: int) -> None: ...
// @filename: _typing.py
// @library: true
//// from _collections_abc import MutableMapping as _MutableMapping
////
//// def _alias(value, _parameter_count):
//// return value
////
//// MutableMapping = _alias(_MutableMapping, 2)
// @filename: _builtins.pyi
// @library: true
//// from _typing import MutableMapping
//// class MyDict(MutableMapping):
//// pass
// @filename: test.py
//// from _builtins import MyDict
//// d = MyDict()
//// d./*marker*/update()
`;
const state = parseAndGetTestState(code).state;
const node = getNodeAtMarker(state, 'marker') as NameNode;
assert.strictEqual(node.nodeType, ParseNodeType.Name);
const decls = state.program.evaluator!.getDeclInfoForNameNode(node)?.decls;
assert(decls && decls.length > 0, 'Expected declarations for update');
const stubDecls = decls.filter((decl): decl is FunctionDeclaration => isFunctionDeclaration(decl));
assert(stubDecls.length > 0, 'Expected function declarations for update');
const marker = state.getMarkerByName('marker');
const mapper = state.program.getSourceMapper(
marker.fileUri,
CancellationToken.None,
/* mapCompiled */ false,
/* preferStubs */ false
);
const implDecls = stubDecls.flatMap((decl) => mapper.findFunctionDeclarations(decl));
assert.deepStrictEqual(getUniqueDeclPaths(implDecls), ['/lib/site-packages/_collections_abc.py']);
});
test('find method declarations through typeshed stdlib alias chain', () => {
const stdlibSourceRoot = '/python-stdlib';
const { data, service } = createAnalyzerServiceFromCode(getTypeshedStdlibAliasChainCode(), [stdlibSourceRoot]);
while (service.test_program.analyze()) {
// Continue until analysis completes.
}
const marker = data.markerPositions.get('marker');
assert(marker, 'Expected marker for update reference');
const sourceFile = service.test_program.getBoundSourceFile(marker.fileUri);
assert(sourceFile, 'Expected source file for marker file');
const parseResults = sourceFile.getParseResults();
assert(parseResults, 'Expected parse results for marker file');
const node = findNodeByOffset(parseResults.parserOutput.parseTree, marker.position) as NameNode;
assert.strictEqual(node.nodeType, ParseNodeType.Name);
const decls = service.test_program.evaluator!.getDeclInfoForNameNode(node)?.decls;
assert(decls && decls.length > 0, 'Expected declarations for update');
const stubDecls = decls.filter((decl): decl is FunctionDeclaration => isFunctionDeclaration(decl));
assert(stubDecls.length > 0, 'Expected function declarations for update');
const mapper = service.test_program.getSourceMapper(
marker.fileUri,
CancellationToken.None,
/* mapCompiled */ false,
/* preferStubs */ false
);
const implDecls = stubDecls.flatMap((decl) => mapper.findFunctionDeclarations(decl));
assert.deepStrictEqual(getUniqueDeclPaths(implDecls), ['/python-stdlib/_collections_abc.py']);
});
test('hover shows inherited stdlib docstring through configured venv', () => {
const stdlibSourceRoot = '/python-stdlib';
const venvPath = '/venvs';
const venvName = 'test';
const venvRoot = `${venvPath}/${venvName}`;
const venvSitePackages = `${venvRoot}/lib/site-packages`;
const { data, service } = createAnalyzerServiceFromCode(
getTypeshedStdlibAliasChainCode(),
[stdlibSourceRoot, venvSitePackages],
{ hostPrefix: venvRoot, venvPath, venv: venvName }
);
while (service.test_program.analyze()) {
// Continue until analysis completes.
}
const marker = data.markerPositions.get('marker');
assert(marker, 'Expected marker for hover target');
const sourceFile = service.test_program.getBoundSourceFile(marker.fileUri);
assert(sourceFile, 'Expected source file for hover target');
const parseResults = sourceFile.getParseResults();
assert(parseResults, 'Expected parse results for hover target');
const position = convertOffsetToPosition(marker.position, parseResults.tokenizerOutput.lines);
const hover = new HoverProvider(
service.test_program,
marker.fileUri,
position,
MarkupKind.Markdown,
CancellationToken.None
).getHover();
assert(hover, 'Expected hover result for update');
assert(!Array.isArray(hover.contents) && typeof hover.contents !== 'string', 'Expected structured hover contents');
const contents = hover.contents as { kind: MarkupKind; value: string };
assert.strictEqual(contents.kind, MarkupKind.Markdown);
const expectedHoverMarkdown =
'```python\n(method) def update(value: Unknown = None) -> Unknown\n```\n---\nDo the thing.\n\nUpdates the internal state.';
assert.strictEqual(contents.value, expectedHoverMarkdown);
});
function getUniqueDeclPaths(decls: FunctionDeclaration[]) {
return Array.from(new Set(decls.map((decl) => normalizeSlashes(decl.uri.getFilePath(), '/'))));
}
function getTypeshedStdlibAliasChainCode() {
return `
// @filename: /typeshed-fallback/stdlib/typing.pyi
//// class MutableMapping:
//// def update(self, value=None) -> None: ...
// @filename: /python-stdlib/_collections_abc.py
//// class MutableMapping:
//// def update(self, value=None):
//// '''Do the thing.
////
//// Updates the internal state.'''
//// pass
// @filename: /python-stdlib/typing.py
//// from _collections_abc import MutableMapping as _MutableMapping
////
//// def _alias(value, _parameter_count):
//// return value
////
//// MutableMapping = _alias(_MutableMapping, 2)
// @filename: /typeshed-fallback/stdlib/builtins.pyi
//// from typing import MutableMapping
//// class dict(MutableMapping):
//// pass
// @filename: /venvs/test/lib/site-packages/sentinel.py
//// x = 1
// @filename: /src/test.py
//// d = dict()
//// d./*marker*/update()
`;
}
function createAnalyzerServiceFromCode(code: string, pythonSearchPaths: string[], options?: CustomAnalyzerOptions) {
const data = parseTestData('/', code, '');
const testFS = new TestFileSystem(/* ignoreCase */ false, { cwd: normalizeSlashes('/') });
for (const file of data.files) {
const filePath = normalizeSlashes(file.fileName);
testFS.mkdirpSync(getDirectoryPath(filePath));
testFS.writeFileSync(UriEx.file(filePath), file.content, 'utf8');
}
const fs = new ViperFileSystem(testFS);
const serviceProvider = createServiceProvider(testFS, fs, new PartialStubService(fs));
const configOptions = new ConfigOptions(UriEx.file('/'));
configOptions.internalTestMode = true;
configOptions.useLibraryCodeForTypes = true;
if (options?.venvPath && options.venv) {
configOptions.venvPath = UriEx.file(options.venvPath);
configOptions.venv = options.venv;
configOptions.pythonPath = UriEx.file(combinePaths(options.venvPath, options.venv, 'python'));
}
const service = new AnalyzerService('test service', serviceProvider, {
console: new NullConsole(),
hostFactory: () =>
new TestAccessHost(
options?.hostPrefix ? UriEx.file(options.hostPrefix) : serviceProvider.fs().getModulePath(),
pythonSearchPaths.map((path) => UriEx.file(path)),
serviceProvider.fs()
),
importResolverFactory: AnalyzerService.createImportResolver,
configOptions,
fileSystem: fs,
libraryReanalysisTimeProvider: () => 0,
shouldRunAnalysis: () => true,
});
for (const file of data.files) {
service.setFileOpened(file.fileUri, 1, file.content);
}
return { data, service };
}
interface CustomAnalyzerOptions {
hostPrefix?: string;
venvPath?: string;
venv?: string;
}
@@ -1,618 +0,0 @@
/*
* testState.test.ts
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT license.
*
* Tests and show how to use TestState in unit test
*/
import assert from 'assert';
import { combinePaths, getFileName, normalizeSlashes } from '../common/pathUtils';
import { compareStringsCaseSensitive } from '../common/stringUtils';
import { Uri } from '../common/uri/uri';
import { Range } from './harness/fourslash/fourSlashTypes';
import { runFourSlashTestContent } from './harness/fourslash/runner';
import { parseAndGetTestState } from './harness/fourslash/testState';
import * as factory from './harness/vfs/factory';
test('Create', () => {
const code = `
// @filename: file1.py
////class A:
//// pass
`;
const { data, state } = parseAndGetTestState(code);
assert(state.activeFile === data.files[0]);
});
test('Multiple files', () => {
const code = `
// @filename: file1.py
////class A:
//// pass
// @filename: file2.py
////class B:
//// pass
// @filename: file3.py
////class C:
//// pass
`;
const state = parseAndGetTestState(code, factory.srcFolder).state;
assert.equal(state.cwd(), normalizeSlashes('/'));
assert(
state.fs.existsSync(
Uri.file(normalizeSlashes(combinePaths(factory.srcFolder, 'file1.py')), state.serviceProvider)
)
);
});
test('Configuration', () => {
const code = `
// @filename: viperconfig.json
//// {
//// "include": [
//// "src"
//// ],
////
//// "exclude": [
//// "**/node_modules",
//// "**/__pycache__",
//// "src/experimental",
//// "src/web/node_modules",
//// "src/typestubs"
//// ],
////
//// "ignore": [
//// "src/oldstuff"
//// ],
////
//// "typingsPath": "src/typestubs",
//// "venvPath": "/home/foo/.venvs",
////
//// "reportMissingImports": true,
//// "reportMissingTypeStubs": false,
////
//// "pythonVersion": "3.6",
//// "pythonPlatform": "Linux",
////
//// "executionEnvironments": [
//// {
//// "root": "src/web",
//// "pythonVersion": "3.5",
//// "pythonPlatform": "Windows",
//// "extraPaths": [
//// "src/service_libs"
//// ]
//// },
//// {
//// "root": "src/sdk",
//// "pythonVersion": "3.0",
//// "extraPaths": [
//// "src/backend"
//// ],
//// "venv": "venv_bar"
//// },
//// {
//// "root": "src/tests",
//// "extraPaths": [
//// "src/tests/e2e",
//// "src/sdk"
//// ]
//// },
//// {
//// "root": "src"
//// }
//// ]
//// }
// @filename: file1.py
////class A:
//// pass
`;
const state = parseAndGetTestState(code, factory.srcFolder).state;
assert.equal(state.cwd(), normalizeSlashes('/'));
assert(
state.fs.existsSync(
Uri.file(normalizeSlashes(combinePaths(factory.srcFolder, 'file1.py')), state.serviceProvider)
)
);
assert.equal(state.configOptions.diagnosticRuleSet.reportMissingImports, 'error');
assert.equal(state.configOptions.diagnosticRuleSet.reportMissingModuleSource, 'warning');
assert.equal(state.configOptions.stubPath?.getFilePath(), normalizeSlashes('/src/typestubs'));
});
test('stubPath configuration', () => {
const code = `
// @filename: viperconfig.json
//// {
//// "stubPath": "src/typestubs"
//// }
`;
const state = parseAndGetTestState(code).state;
assert.equal(state.configOptions.stubPath?.getFilePath(), normalizeSlashes('/src/typestubs'));
});
test('viperconfig.json exists in VFS', () => {
// Regression test: viperconfig.json should be written to the virtual file system,
// not just parsed for config options.
const code = `
// @filename: viperconfig.json
//// {
//// "stubPath": "my_stubs"
//// }
// @filename: test.py
//// x = 1
`;
const state = parseAndGetTestState(code, factory.srcFolder).state;
// The viperconfig.json file should exist in the VFS
const configUri = Uri.file(
normalizeSlashes(combinePaths(factory.srcFolder, 'viperconfig.json')),
state.serviceProvider
);
assert(state.fs.existsSync(configUri), `viperconfig.json should exist in VFS at ${configUri.toString()}`);
// The config should also be parsed correctly
assert.equal(state.configOptions.stubPath?.getFilePath(), normalizeSlashes('/my_stubs'));
});
test('Duplicated stubPath configuration', () => {
const code = `
// @filename: viperconfig.json
//// {
//// "typingsPath": "src/typestubs1",
//// "stubPath": "src/typestubs2"
//// }
`;
const state = parseAndGetTestState(code).state;
assert.equal(state.configOptions.stubPath?.getFilePath(), normalizeSlashes('/src/typestubs2'));
});
test('ProjectRoot', () => {
const code = `
// global options
// @projectRoot: /root
// @filename: /root/file1.py
////class A:
//// pass
`;
const state = parseAndGetTestState(code).state;
assert.equal(state.cwd(), normalizeSlashes('/root'));
assert(state.fs.existsSync(Uri.file(normalizeSlashes('/root/file1.py'), state.serviceProvider)));
assert.equal(state.configOptions.projectRoot.getFilePath(), normalizeSlashes('/root'));
});
test('CustomTypeshedFolder', () => {
// use differnt physical folder as typeshed folder. this is different than
// typeshed folder settings in config json file since that points to a path
// in virtual file system. not physical one. this decides which physical folder
// those virtual folder will mount to.
const code = `
// global options
// @typeshed: ${__dirname}
`;
// mount the folder this file is in as typeshed folder and check whether
// in typeshed folder in virtual file system, this file exists.
const state = parseAndGetTestState(code).state;
assert(state.fs.existsSync(factory.typeshedFolder.combinePaths(getFileName(__filename))));
});
test('IgnoreCase', () => {
const code = `
// global options
// @ignoreCase: true
// @filename: file1.py
////class A:
//// pass
`;
const state = parseAndGetTestState(code, factory.srcFolder).state;
assert(
state.fs.existsSync(
Uri.file(normalizeSlashes(combinePaths(factory.srcFolder, 'FILE1.py')), state.serviceProvider)
)
);
});
test('GoToMarker', () => {
const code = `
////class A:
//// /*marker1*/pass
`;
const { data, state } = parseAndGetTestState(code);
const marker = data.markerPositions.get('marker1');
state.goToMarker('marker1');
assert.equal(state.lastKnownMarker, 'marker1');
assert.equal(state.currentCaretPosition, marker!.position);
state.goToMarker(marker);
assert.equal(state.lastKnownMarker, 'marker1');
assert.equal(state.currentCaretPosition, marker!.position);
assert.equal(state.selectionEnd, -1);
});
test('GoToEachMarker', () => {
const code = `
// @filename: file1.py
////class A:
//// /*marker1*/pass
// @filename: file2.py
////class B:
//// /*marker2*/pass
`;
const { data, state } = parseAndGetTestState(code);
const marker1 = data.markerPositions.get('marker1');
const marker2 = data.markerPositions.get('marker2');
const results: number[] = [];
state.goToEachMarker([marker1!, marker2!], (m) => {
results.push(m.position);
});
assert.deepEqual(results, [marker1!.position, marker2!.position]);
assert.equal(state.activeFile.fileName, marker2!.fileName);
assert.equal(state.currentCaretPosition, marker2!.position);
assert.equal(state.selectionEnd, -1);
});
test('Markers', () => {
const code = `
// @filename: file1.py
////class A:
//// /*marker1*/pass
// @filename: file2.py
////class B:
//// /*marker2*/pass
`;
const { data, state } = parseAndGetTestState(code);
const marker1 = data.markerPositions.get('marker1');
assert.deepEqual(state.getMarkerName(marker1!), 'marker1');
assert.deepEqual(
state
.getMarkers()
.map((m) => state.getMarkerName(m))
.sort(compareStringsCaseSensitive),
state.getMarkerNames().sort(compareStringsCaseSensitive)
);
});
test('GoToPosition', () => {
const code = `
// @filename: file1.py
////class A:
//// /*marker1*/pass
`;
const { data, state } = parseAndGetTestState(code);
const marker1 = data.markerPositions.get('marker1');
state.goToPosition(marker1!.position);
assert.equal(state.currentCaretPosition, marker1!.position);
assert.equal(state.selectionEnd, -1);
});
test('select', () => {
const code = `
// @filename: file1.py
/////*start*/class A:
//// class B:
//// def Test(self):
//// pass
////
//// def Test2(self):
//// pass/*end*/
`;
const { data, state } = parseAndGetTestState(code);
state.select('start', 'end');
assert.equal(state.currentCaretPosition, data.markerPositions.get('start')!.position);
assert.equal(state.selectionEnd, data.markerPositions.get('end')!.position);
});
test('selectAllInFile', () => {
const code = `
// @filename: file1.py
/////*start*/class A:
//// class B:
//// def Test(self):
//// pass
////
//// def Test2(self):
//// pass/*end*/
`;
const { data, state } = parseAndGetTestState(code);
state.selectAllInFile(data.files[0].fileName);
assert.equal(state.currentCaretPosition, data.markerPositions.get('start')!.position);
assert.equal(state.selectionEnd, data.markerPositions.get('end')!.position);
});
test('selectRange', () => {
const code = `
// @filename: file1.py
/////class A:
//// class B:
//// [|def Test(self):
//// pass|]
////
//// def Test2(self):
//// pass
`;
const { data, state } = parseAndGetTestState(code);
const range = data.ranges[0];
state.selectRange(range);
assert.equal(state.activeFile.fileName, range.fileName);
assert.equal(state.currentCaretPosition, range.pos);
assert.equal(state.selectionEnd, range.end);
});
test('selectLine', () => {
const code = `
// @filename: file1.py
/////class A:
//// class B:
////[| def Test(self):|]
//// pass
////
//// def Test2(self):
//// pass
`;
const { data, state } = parseAndGetTestState(code);
const range = data.ranges[0];
state.selectLine(2);
assert.equal(state.currentCaretPosition, range.pos);
assert.equal(state.selectionEnd, range.end);
});
test('goToEachRange', () => {
const code = `
// @filename: file1.py
/////class A:
//// class B:
//// [|def Test(self):|]
//// pass
////
//// def Test2(self):
//// [|pass|]
`;
const { state } = parseAndGetTestState(code);
const results: Range[] = [];
state.goToEachRange((r) => {
assert.equal(state.activeFile.fileName, r.fileName);
results.push(r);
});
assert.deepEqual(results, [state.getRanges()[0], state.getRanges()[1]]);
});
test('getRangesInFile', () => {
const code = `
// @filename: file1.py
/////class A:
//// class B:
//// [|def Test(self):|]
//// pass
// @filename: file2.py
//// def Test2(self):
//// [|pass|]
`;
const { data, state } = parseAndGetTestState(code);
assert.deepEqual(
state.getRangesInFile(data.files[0].fileName),
data.ranges.filter((r) => r.fileName === data.files[0].fileName)
);
});
test('rangesByText', () => {
const code = `
// @filename: file1.py
/////class A:
//// class B:
//// [|def Test(self):|]
//// pass
// @filename: file2.py
//// def Test2(self):
//// [|pass|]
`;
const { data, state } = parseAndGetTestState(code);
const map = state.getRangesByText();
assert.deepEqual(map.get('def Test(self):'), [data.ranges[0]]);
assert.deepEqual(map.get('pass'), [data.ranges[1]]);
});
test('moveCaretRight', () => {
const code = `
// @filename: file1.py
/////class A:
//// class B:
//// /*position*/def Test(self):
//// pass
////
//// def Test2(self):
//// pass
`;
const { data, state } = parseAndGetTestState(code);
const marker = data.markerPositions.get('position')!;
state.goToBOF();
assert.equal(state.currentCaretPosition, 0);
state.goToEOF();
assert.equal(state.currentCaretPosition, data.files[0].content.length);
state.goToPosition(marker.position);
state.moveCaretRight('def'.length);
assert.equal(state.currentCaretPosition, marker.position + 'def'.length);
assert.equal(state.selectionEnd, -1);
});
test('runFourSlashTestContent', () => {
const code = `
/// <reference path="typings/fourslash.d.ts" />
// @filename: file1.py
//// class A:
//// class B:
//// /*position*/def Test(self):
//// pass
////
//// def Test2(self):
//// pass
helper.getMarkerByName("position");
`;
runFourSlashTestContent(normalizeSlashes('/'), 'unused.py', code);
});
test('VerifyDiagnosticsTest1', () => {
const code = `
/// <reference path="typings/fourslash.d.ts" />
// @filename: dataclass1.py
//// # This sample validates the Python 3.7 data class feature.
////
//// from typing import NamedTuple, Optional
////
//// class Other:
//// pass
////
//// class DataTuple(NamedTuple):
//// def _m(self):
//// pass
//// id: int
//// aid: Other
//// valll: str = ''
//// name: Optional[str] = None
////
//// d1 = DataTuple(id=1, aid=Other())
//// d2 = DataTuple(id=1, aid=Other(), valll='v')
//// d3 = DataTuple(id=1, aid=Other(), name='hello')
//// d4 = DataTuple(id=1, aid=Other(), name=None)
//// id = d1.id
////
//// # This should generate an error because the name argument
//// # is the incorrect type.
//// d5 = DataTuple(id=1, aid=Other(), name=[|{|"category": "error"|}3|])
////
//// # This should generate an error because aid is a required
//// # parameter and is missing an argument here.
//// d6 = [|{|"category": "error"|}DataTuple(id=1, name=None|])
helper.verifyDiagnostics();
`;
runFourSlashTestContent(factory.srcFolder, 'unused.py', code);
});
test('VerifyDiagnosticsTest2', () => {
const code = `
//// # This sample tests the handling of the @dataclass decorator.
////
//// from dataclasses import dataclass, InitVar
////
//// @dataclass
//// class Bar():
//// bbb: int
//// ccc: str
//// aaa = 'string'
////
//// bar1 = Bar(bbb=5, ccc='hello')
//// bar2 = Bar(5, 'hello')
//// bar3 = Bar(5, 'hello', 'hello2')
//// print(bar3.bbb)
//// print(bar3.ccc)
//// print(bar3.aaa)
////
//// # This should generate an error because ddd
//// # isn't a declared value.
//// bar = Bar(bbb=5, [|/*marker1*/ddd|]=5, ccc='hello')
////
//// # This should generate an error because the
//// # parameter types don't match.
//// bar = Bar([|/*marker2*/'hello'|], 'goodbye')
////
//// # This should generate an error because a parameter
//// # is missing.
//// bar = [|/*marker3*/Bar(2)|]
////
//// # This should generate an error because there are
//// # too many parameters.
//// bar = Bar(2, 'hello', 'hello', [|/*marker4*/4|])
////
////
//// @dataclass
//// class Baz1():
//// bbb: int
//// aaa = 'string'
////
//// # This should generate an error because variables
//// # with no default cannot come after those with
//// # defaults.
//// [|/*marker5*/ccc|]: str
////
//// @dataclass
//// class Baz2():
//// aaa: str
//// ddd: InitVar[int] = 3
helper.verifyDiagnostics({
"marker1": { category: "error", message: "No parameter named 'ddd'" },
"marker2": { category: "error", message: "Argument of type 'Literal['hello']' cannot be assigned to parameter 'bbb' of type 'int'\\n 'str' is incompatible with 'int'" },
"marker3": { category: "error", message: "Argument missing for parameter 'ccc'" },
"marker4": { category: "error", message: "Expected 3 positional arguments" },
"marker5": { category: "error", message: "Data fields without default value cannot appear after data fields with default values" },
});
`;
runFourSlashTestContent(factory.srcFolder, 'unused.py', code);
});
@@ -1,368 +0,0 @@
/*
* notebook.typeServer.test.ts
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT license.
*
* Tests for the type server's notebook cell-chain maintenance (NotebookUriMapper +
* createNotebookData / openNotebookCellChain / updateNotebookStructure).
*
* This is a Vipeye-native port of Pylance's `notebook.common.ts`. It exercises only the
* cell-chain core functions against a real AnalyzerService; it does not drive the notebook
* document handler (which just sequences calls to these same functions).
*/
import {
DidChangeNotebookDocumentParams,
NotebookCellArrayChange,
NotebookCellKind,
TextDocumentIdentifier,
TextDocumentItem,
} from 'vscode-languageserver-protocol';
import { AnalyzerService } from '../../analyzer/service';
import { IPythonMode } from '../../analyzer/sourceFile';
import { CommandLineOptions } from '../../common/commandLineOptions';
import { NullConsole } from '../../common/console';
import { DiagnosticCategory } from '../../common/diagnostic';
import { normalizeSlashes } from '../../common/pathUtils';
import { ServiceKeys } from '../../common/serviceKeys';
import { createServiceProvider } from '../../common/serviceProviderExtensions';
import { Uri } from '../../common/uri/uri';
import { ViperFileSystem } from '../../viperFileSystem';
import { createNotebookData, openNotebookCellChain, updateNotebookStructure } from '../../typeServer/notebookCellChain';
import { NotebookUriMapper } from '../../typeServer/notebookUriMapper';
import { createInitStatus, WellKnownWorkspaceKinds, Workspace } from '../../workspaceFactory';
import * as host from '../harness/testHost';
import { createFromFileSystem } from '../harness/vfs/factory';
describe('updateNotebookStructure Tests (typeServer mode)', () => {
const testFileSystem = createFromFileSystem(host.HOST, /* ignoreCase */ false, {
cwd: normalizeSlashes('/'),
});
const fileSystem = new ViperFileSystem(testFileSystem);
const serviceProvider = createServiceProvider(testFileSystem, fileSystem);
let uriMapper: NotebookUriMapper;
let analyzerService: AnalyzerService;
let workspace: Workspace;
const notebookUriString = 'file:///notebook.ipynb';
const notebookUri = Uri.parse(notebookUriString, serviceProvider);
beforeEach(() => {
uriMapper = new NotebookUriMapper(serviceProvider.get(ServiceKeys.caseSensitivityDetector));
analyzerService = new AnalyzerService('name', serviceProvider, {
console: new NullConsole(),
shouldRunAnalysis: () => true,
});
// Don't run analysis automatically.
const options = new CommandLineOptions(normalizeSlashes('/src'), false);
options.languageServerSettings.enableAmbientAnalysis = false;
analyzerService.setOptions(options);
workspace = {
workspaceName: 'name',
rootUri: Uri.file('/src', serviceProvider),
kinds: [WellKnownWorkspaceKinds.Regular],
service: analyzerService,
disableLanguageServices: false,
disableOrganizeImports: false,
disableWorkspaceSymbol: false,
isInitialized: createInitStatus(),
searchPathsToWatch: [],
disableTaggedHints: false,
};
});
afterEach(() => {
analyzerService.dispose();
});
test('delete cell', () => {
const nd = _setupWorkspace(notebookUriString, [
_createCellUri('one'),
_createCellUri('two'),
_createCellUri('three'),
]);
const structure = {
array: { start: 1, deleteCount: 1 },
didOpen: undefined,
didClose: [{ uri: _createCellUri('two') }],
};
_applyChange(notebookUriString, structure);
updateNotebookStructure(structure, nd, uriMapper, workspace);
_verifyUris(nd.mappedCellUris, [_createCellFilePath('one'), _createCellFilePath('three')]);
expect(analyzerService.hasSourceFile(_createCellFilePath('two'))).toBe(false);
});
test('delete top cell', () => {
const nd = _setupWorkspace(notebookUriString, [
_createCellUri('one'),
_createCellUri('two'),
_createCellUri('three'),
]);
const structure = {
array: { start: 0, deleteCount: 1 },
didOpen: undefined,
didClose: [{ uri: _createCellUri('one') }],
};
_applyChange(notebookUriString, structure);
updateNotebookStructure(structure, nd, uriMapper, workspace);
_verifyUris(nd.mappedCellUris, [_createCellFilePath('two'), _createCellFilePath('three')]);
expect(analyzerService.hasSourceFile(_createCellFilePath('one'))).toBe(false);
});
test('insert cell', () => {
const nd = _setupWorkspace(notebookUriString, [_createCellUri('one'), _createCellUri('two')]);
const structure = {
array: {
cells: [{ kind: NotebookCellKind.Code, document: _createCellUri('new') }],
start: 1,
deleteCount: 0,
},
didOpen: [{ uri: _createCellUri('new'), languageId: 'python', version: 1, text: '' }],
didClose: undefined,
};
_applyChange(notebookUriString, structure);
updateNotebookStructure(structure, nd, uriMapper, workspace);
_verifyUris(nd.mappedCellUris, [
_createCellFilePath('one'),
_createCellFilePath('new'),
_createCellFilePath('two'),
]);
expect(analyzerService.hasSourceFile(_createCellFilePath('new'))).toBe(true);
expect(analyzerService.getChainedUri(_createCellFilePath('new'))?.equals(_createCellFilePath('one'))).toBe(
true
);
});
test('reorder cell', () => {
const nd = _setupWorkspace(notebookUriString, [
_createCellUri('one'),
_createCellUri('two'),
_createCellUri('three'),
]);
const structure = {
array: {
cells: [
{ kind: NotebookCellKind.Code, document: _createCellUri('three') },
{ kind: NotebookCellKind.Code, document: _createCellUri('two') },
],
start: 1,
deleteCount: 2,
},
didOpen: undefined,
didClose: undefined,
};
_applyChange(notebookUriString, structure);
updateNotebookStructure(structure, nd, uriMapper, workspace);
_verifyUris(nd.mappedCellUris, [
_createCellFilePath('one'),
_createCellFilePath('three'),
_createCellFilePath('two'),
]);
expect(analyzerService.getChainedUri(_createCellFilePath('two'))?.equals(_createCellFilePath('three'))).toBe(
true
);
expect(analyzerService.getChainedUri(_createCellFilePath('three'))?.equals(_createCellFilePath('one'))).toBe(
true
);
});
test('repair if duplicate cell detected', () => {
const nd = _setupWorkspace(notebookUriString, [_createCellUri('one'), _createCellUri('two')]);
const structure = {
array: {
cells: [{ kind: NotebookCellKind.Code, document: _createCellUri('one') }],
start: 2,
deleteCount: 0,
},
didOpen: [{ uri: _createCellUri('one'), languageId: 'python', version: 1, text: '' }],
didClose: undefined,
};
_applyChange(notebookUriString, structure);
// Should not throw; instead it should repair the chain by removing the duplicate.
expect(() => updateNotebookStructure(structure, nd, uriMapper, workspace)).not.toThrow();
// The duplicate entry should have been removed, leaving only the original two cells.
_verifyUris(nd.mappedCellUris, [_createCellFilePath('one'), _createCellFilePath('two')]);
});
test('delete last cell', () => {
const nd = _setupWorkspace(notebookUriString, [
_createCellUri('one'),
_createCellUri('two'),
_createCellUri('three'),
]);
const structure = {
array: { start: 2, deleteCount: 1 },
didOpen: undefined,
didClose: [{ uri: _createCellUri('three') }],
};
_applyChange(notebookUriString, structure);
updateNotebookStructure(structure, nd, uriMapper, workspace);
_verifyUris(nd.mappedCellUris, [_createCellFilePath('one'), _createCellFilePath('two')]);
expect(analyzerService.hasSourceFile(_createCellFilePath('three'))).toBe(false);
// The chain should remain intact after the last cell is deleted: prefix → one → two
expect(analyzerService.getChainedUri(_createCellFilePath('two'))?.equals(_createCellFilePath('one'))).toBe(
true
);
expect(analyzerService.getChainedUri(_createCellFilePath('one'))?.equals(nd.prefixCellUri)).toBe(true);
});
test('delete and create cells', () => {
const nd = _setupWorkspace(notebookUriString, [_createCellUri('one')]);
const structure = {
array: {
cells: [{ kind: NotebookCellKind.Code, document: _createCellUri('new') }],
start: 0,
deleteCount: 1,
},
didOpen: [{ uri: _createCellUri('new'), languageId: 'python', version: 1, text: '' }],
didClose: [{ uri: _createCellUri('one') }],
};
_applyChange(notebookUriString, structure);
updateNotebookStructure(structure, nd, uriMapper, workspace);
_verifyUris(nd.mappedCellUris, [_createCellFilePath('new')]);
expect(analyzerService.hasSourceFile(_createCellFilePath('new'))).toBe(true);
expect(analyzerService.getChainedUri(_createCellFilePath('new'))?.equals(nd.prefixCellUri)).toBe(true);
});
test('unused symbol in chained source file', () => {
_setupWorkspace(notebookUriString, [_createCellUri('one'), _createCellUri('two'), _createCellUri('three')]);
const source1 = workspace.service.test_program.getBoundSourceFileInfo(_createCellFilePath('one'))!;
const source2 = workspace.service.test_program.getBoundSourceFileInfo(_createCellFilePath('two'))!;
const source3 = workspace.service.test_program.getBoundSourceFileInfo(_createCellFilePath('three'))!;
// Since we created the workspace for tests, some flags that would be set when
// workspace.getSettings is called are not set. Manually set them here.
source1.isTracked = true;
source2.isTracked = true;
source3.isTracked = true;
_updateCellContent('one', 'import os');
expect(
source1.sourceFile
.getDiagnostics(workspace.service.getConfigOptions())
?.some((d) => d.category === DiagnosticCategory.UnusedCode)
).toBe(true);
_updateCellContent('two', 'os');
expect(
source1.sourceFile
.getDiagnostics(workspace.service.getConfigOptions())
?.some((d) => d.category === DiagnosticCategory.UnusedCode)
).toBe(false);
_updateCellContent('one', 'import os\nimport sys');
expect(
source1.sourceFile
.getDiagnostics(workspace.service.getConfigOptions())
?.some((d) => d.category === DiagnosticCategory.UnusedCode)
).toBe(true);
_updateCellContent('three', 'sys');
expect(
source1.sourceFile
.getDiagnostics(workspace.service.getConfigOptions())
?.some((d) => d.category === DiagnosticCategory.UnusedCode)
).toBe(false);
_updateCellContent('two', '');
expect(
source1.sourceFile
.getDiagnostics(workspace.service.getConfigOptions())
?.some((d) => d.category === DiagnosticCategory.UnusedCode)
).toBe(true);
});
function _updateCellContent(cellName: string, text: string) {
const filePath = _createCellFilePath(cellName);
const source = workspace.service.test_program.getBoundSourceFileInfo(filePath)!;
workspace.service.updateOpenFileContents(
filePath,
(source.sourceFile.getClientVersion() ?? 0) + 1,
text,
IPythonMode.CellDocs
);
while (workspace.service.test_program.analyze());
}
function _setupWorkspace(notebookUri: string, cellFileUris: string[]) {
const cells = cellFileUris.map((uri) => TextDocumentItem.create(uri, 'python', 1, ''));
const params = {
notebookDocument: {
notebookType: 'jupyter-notebook',
uri: notebookUri,
version: 1,
cells: cells.map((cell) => ({ kind: NotebookCellKind.Code, document: cell.uri })),
},
cellTextDocuments: cells,
};
const notebookPath = uriMapper.parseNotebookOpen(params);
const notebookData = createNotebookData(
notebookPath,
cells,
uriMapper,
serviceProvider.get(ServiceKeys.caseSensitivityDetector)
);
openNotebookCellChain(cells, notebookData, uriMapper, workspace);
return notebookData;
}
function _applyChange(
notebookUri: string,
structure: {
array: NotebookCellArrayChange;
didOpen?: TextDocumentItem[];
didClose?: TextDocumentIdentifier[];
}
) {
const params: DidChangeNotebookDocumentParams = {
notebookDocument: {
uri: notebookUri,
version: 2,
},
change: {
cells: {
structure,
},
},
};
uriMapper.parseNotebookChange(params);
}
function _createCellUri(cell: string) {
return `vscode-notebook-cell:notebook.ipynb#${cell}`;
}
function _createCellFilePath(cell: string) {
return notebookUri.addExtension('.py').withFragment(cell);
}
function _verifyUris(actual: Uri[], expected: Uri[]) {
expect(actual.length).toBe(expected.length);
for (let i = 0; i < actual.length; i++) {
expect(actual[i].equals(expected[i])).toBe(true);
}
}
});
@@ -1,269 +0,0 @@
/*
* typeServer.inProc.test.ts
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT license.
*
* In-process protocol tests for the Viper type server (TSP).
*
* These exercise the server-side request handlers end-to-end over a real JSON-RPC
* connection (in-memory streams), including the `_onGetType` conversion path
* (fromProtocolNode -> evaluate via the sync->async adapter -> ProtocolTypeFactory).
*
* Ported from Pylance's `typeServer.inProc.test.ts`. The Pylance round-trip tests that
* reconstruct a Vipeye `Type` from the protocol `Type` depend on the client-side consumer
* stack (ExternalProgram / snapshotSync), which stays in Pylance, so they are intentionally
* not ported here. Instead these tests assert on the protocol-level responses.
*/
import assert from 'assert';
import { TypeServerProtocol } from '../../typeServer/protocol/typeServerProtocol';
import { initializeDependenciesForInProcTests, withInProcTypeServer } from './inProcTypeServerTestUtils';
jest.setTimeout(120000);
/** Extract the class name from a protocol Type, if it's a ClassType with a regular declaration. */
function getClassTypeName(type: TypeServerProtocol.Type | undefined): string | undefined {
if (!type || type.kind !== TypeServerProtocol.TypeKind.Class) {
return undefined;
}
const classType = type as TypeServerProtocol.ClassType;
if (classType.declaration.kind === TypeServerProtocol.DeclarationKind.Regular) {
return classType.declaration.name;
}
return undefined;
}
describe('TypeServer in-proc protocol', () => {
beforeAll(async () => {
await initializeDependenciesForInProcTests();
});
test('initialize + get supported protocol version + snapshot', async () => {
const code = `
// @filename: main.py
//// x = 1
`;
await withInProcTypeServer(code, async (context) => {
const version = await context.sendRequest(TypeServerProtocol.GetSupportedProtocolVersionRequest.type);
assert(version.length > 0);
const snapshot = await context.refreshSnapshot();
assert(snapshot >= 0);
});
});
test('resolveImport resolves a local module', async () => {
const code = `
// @filename: main.py
//// # /*src*/
//// import foo
//// x = foo.value
// @filename: foo.py
//// value = 1
`;
await withInProcTypeServer(code, async (context) => {
const resolvedUri = await context.sendRequestWithSnapshot(TypeServerProtocol.ResolveImportRequest.type, {
sourceUri: context.getFileUriForMarker('src').toString(),
moduleDescriptor: { leadingDots: 0, nameParts: ['foo'] },
});
assert(resolvedUri !== undefined);
assert(resolvedUri.endsWith('/src/foo.py'));
});
});
test('getPythonSearchPaths returns an array', async () => {
const code = `
// @filename: main.py
//// # /*src*/
//// x = 1
`;
await withInProcTypeServer(code, async (context) => {
const paths = await context.sendRequestWithSnapshot(TypeServerProtocol.GetPythonSearchPathsRequest.type, {
fromUri: context.getFileUriForMarker('src').getDirectory().toString(),
});
assert(Array.isArray(paths));
assert(paths.every((p) => typeof p === 'string'));
});
});
test('resolveImport resolves a relative module', async () => {
const code = `
// @filename: pkg/__init__.py
////
// @filename: pkg/main.py
//// # /*src*/
//// from . import foo
//// x = foo.value
// @filename: pkg/foo.py
//// value = 1
`;
await withInProcTypeServer(code, async (context) => {
const resolvedUri = await context.sendRequestWithSnapshot(TypeServerProtocol.ResolveImportRequest.type, {
sourceUri: context.getFileUriForMarker('src').toString(),
moduleDescriptor: { leadingDots: 1, nameParts: ['foo'] },
});
assert(resolvedUri !== undefined);
assert(resolvedUri.endsWith('/src/pkg/foo.py'));
});
});
test('resolveImport resolves stdlib module from typeshed-fallback', async () => {
const code = `
// @filename: main.py
//// # /*src*/
//// import typing
//// x = typing.Any
`;
await withInProcTypeServer(code, async (context) => {
const resolvedUri = await context.sendRequestWithSnapshot(TypeServerProtocol.ResolveImportRequest.type, {
sourceUri: context.getFileUriForMarker('src').toString(),
moduleDescriptor: { leadingDots: 0, nameParts: ['typing'] },
});
assert(resolvedUri !== undefined);
// The exact URI scheme can vary depending on FS mapping; the key is that this came from typeshed.
assert(resolvedUri.includes('/typeshed-fallback/stdlib/typing.pyi'));
});
});
test('resolveImport resolves builtins module from typeshed-fallback', async () => {
const code = `
// @filename: main.py
//// # /*src*/
//// import builtins
//// x = builtins.int
`;
await withInProcTypeServer(code, async (context) => {
const resolvedUri = await context.sendRequestWithSnapshot(TypeServerProtocol.ResolveImportRequest.type, {
sourceUri: context.getFileUriForMarker('src').toString(),
moduleDescriptor: { leadingDots: 0, nameParts: ['builtins'] },
});
assert(resolvedUri !== undefined);
assert(resolvedUri.includes('/typeshed-fallback/stdlib/builtins.pyi'));
});
});
test('resolveImport resolves stdlib submodule from typeshed-fallback', async () => {
const code = `
// @filename: main.py
//// # /*src*/
//// import collections.abc
//// x = collections.abc.Iterable
`;
await withInProcTypeServer(code, async (context) => {
const resolvedUri = await context.sendRequestWithSnapshot(TypeServerProtocol.ResolveImportRequest.type, {
sourceUri: context.getFileUriForMarker('src').toString(),
moduleDescriptor: { leadingDots: 0, nameParts: ['collections', 'abc'] },
});
assert(resolvedUri !== undefined);
assert(resolvedUri.includes('/typeshed-fallback/stdlib/collections/abc.pyi'));
});
});
test('getComputedType returns a type for a node', async () => {
const code = `
// @filename: main.py
//// def takes_int(x: int) -> None:
//// pass
////
//// [|/*arg*/__arg__|] = 1
//// takes_int(__arg__)
`;
await withInProcTypeServer(code, async (context) => {
await context.openFileForMarker('arg');
const arg: TypeServerProtocol.Node = context.getNodeForMarker('arg');
await context.refreshSnapshot();
const type = await context.sendRequestWithSnapshot(TypeServerProtocol.GetComputedTypeRequest.type, {
arg,
});
assert(type !== undefined);
assert(typeof type.id === 'number');
assert.strictEqual(type.kind, TypeServerProtocol.TypeKind.Class);
assert.strictEqual(getClassTypeName(type), 'int');
});
});
test('getExpectedType returns a type for a node', async () => {
const code = `
// @filename: main.py
//// class C:
//// pass
////
//// x: C = [|/*expr*/C()|]
`;
await withInProcTypeServer(code, async (context) => {
await context.openFileForMarker('expr');
const arg: TypeServerProtocol.Node = context.getNodeForMarker('expr');
await context.refreshSnapshot();
const type = await context.sendRequestWithSnapshot(TypeServerProtocol.GetExpectedTypeRequest.type, {
arg,
});
assert(type !== undefined);
assert(typeof type.id === 'number');
assert.strictEqual(type.kind, TypeServerProtocol.TypeKind.Class);
assert.strictEqual(getClassTypeName(type), 'C');
});
});
test('getDeclaredType returns a type for an annotated symbol', async () => {
const code = `
// @filename: main.py
//// [|/*decl*/__declared__|]: str = 'x'
`;
await withInProcTypeServer(code, async (context) => {
await context.openFileForMarker('decl');
const arg: TypeServerProtocol.Node = context.getNodeForMarker('decl');
await context.refreshSnapshot();
const type = await context.sendRequestWithSnapshot(TypeServerProtocol.GetDeclaredTypeRequest.type, {
arg,
});
assert(type !== undefined);
assert(typeof type.id === 'number');
assert.strictEqual(type.kind, TypeServerProtocol.TypeKind.Class);
assert.strictEqual(getClassTypeName(type), 'str');
});
});
test('snapshotChanged notification is delivered', async () => {
const code = `
// @filename: main.py
//// [|/*open*/|]x = 1
`;
await withInProcTypeServer(code, async (context) => {
const initialSnapshot = await context.refreshSnapshot();
const received = new Promise<{ old: number; new: number }>((resolve) => {
context.onNotification(TypeServerProtocol.SnapshotChangedNotification.type, (params) => {
resolve(params);
});
});
await context.openFileForMarker('open');
const params = await received;
assert(params.new > params.old);
assert(params.old >= initialSnapshot);
});
});
});
@@ -1,118 +0,0 @@
/// <reference types="jest" />
/*
* wildcardImportPackageMerge.test.ts
*
* Regression coverage for multipart package members that survive wildcard-import merges.
*/
import { CompletionItemKind, MarkupKind } from 'vscode-languageserver-types';
import { DefinitionFilter } from '../languageService/definitionProvider';
import { parseAndGetTestState } from './harness/fourslash/testState';
import { verifyReferencesAtPosition } from './testStateUtils';
const wildcardImportPackageMergeCode = `
// @filename: mylib/__init__.py
////
// @filename: mylib/a.py
//// [|/*defA*/|]# module a
//// def do_something(value: str) -> None:
//// pass
// @filename: mylib/b.py
//// [|/*defB*/|]# module b
//// def do_other() -> None:
//// pass
// @filename: my_common_stuffs.py
//// import mylib.[|/*importB*/b|]
//// BLA = '123'
// @filename: test.py
//// import mylib.[|/*importA*/a|]
////
//// from my_common_stuffs import *
////
//// mylib.[|/*markerA*/a|].do_something(BLA)
//// mylib.[|/*markerB*/b|].do_other()
//// mylib./*completionMarker*/
`;
function createState() {
return parseAndGetTestState(wildcardImportPackageMergeCode).state;
}
function getExpectedDefinition(state: ReturnType<typeof createState>, markerName: string) {
return {
path: state.getMarkerByName(markerName).fileName,
range: state.getPositionRange(markerName),
};
}
test('wildcard import package merge - go to definition for sibling modules', () => {
const state = createState();
state.verifyFindDefinitions(
{
markerA: {
definitions: [getExpectedDefinition(state, 'defA')],
},
markerB: {
definitions: [getExpectedDefinition(state, 'defB')],
},
},
DefinitionFilter.PreferSource
);
});
test('wildcard import package merge - hover for sibling modules', () => {
const state = createState();
const marker = state.getMarkerByName('markerA');
state.openFile(marker.fileName);
state.verifyHover('markdown', {
markerA: '```python\n(module) a\n```',
markerB: '```python\n(module) b\n```',
});
});
test('wildcard import package merge - completion exposes sibling modules', async () => {
const state = createState();
const marker = state.getMarkerByName('completionMarker');
state.openFile(marker.fileName);
await state.verifyCompletion('included', MarkupKind.Markdown, {
completionMarker: {
completions: [
{
kind: CompletionItemKind.Module,
label: 'a',
},
{
kind: CompletionItemKind.Module,
label: 'b',
},
],
},
});
});
test('wildcard import package merge - find references for sibling modules', () => {
const state = createState();
for (const symbolName of ['a', 'b']) {
const ranges = state.getRangesByText().get(symbolName)!;
for (const range of ranges) {
verifyReferencesAtPosition(
state.program,
state.configOptions,
symbolName,
range.fileName,
range.pos,
ranges
);
}
}
});
@@ -1,490 +0,0 @@
/*
* workspaceEditUtils.test.ts
* Copyright (c) Microsoft Corporation.
* Licensed under the MIT license.
*
* test workspaceEditUtils
*/
import * as assert from 'assert';
import { CreateFile, DeleteFile, RenameFile, TextDocumentEdit, WorkspaceEdit } from 'vscode-languageserver-types';
import { CancellationToken } from 'vscode-languageserver';
import { AnalyzerService } from '../analyzer/service';
import { IPythonMode } from '../analyzer/sourceFile';
import { combinePaths, getDirectoryPath } from '../common/pathUtils';
import { Uri } from '../common/uri/uri';
import {
applyWorkspaceEdit,
convertToWorkspaceEdit,
generateWorkspaceEdit,
hasWorkspaceEditChanges,
} from '../common/workspaceEditUtils';
import { AnalyzerServiceExecutor } from '../languageService/analyzerServiceExecutor';
import { TestLanguageService } from './harness/fourslash/testLanguageService';
import { TestState, parseAndGetTestState } from './harness/fourslash/testState';
import { verifyWorkspaceEdit } from './harness/fourslash/workspaceEditTestUtils';
test('test applyWorkspaceEdits changes', async () => {
const code = `
// @filename: test.py
//// [|/*marker*/|]
`;
const state = parseAndGetTestState(code).state;
const cloned = await getClonedService(state);
const range = state.getRangeByMarkerName('marker')!;
const fileChanged = new Map<string, Uri>();
applyWorkspaceEditToService(
cloned,
{
changes: {
[range.fileUri.toString()]: [
{
range: state.convertPositionRange(range),
newText: 'Text Changed',
},
],
},
},
fileChanged
);
assert.strictEqual(fileChanged.size, 1);
assert.strictEqual(cloned.test_program.getSourceFile(range.fileUri)?.getFileContent(), 'Text Changed');
});
test('test edit mode for workspace', async () => {
const code = `
// @filename: test.py
//// [|/*marker*/|]
`;
const state = parseAndGetTestState(code).state;
const range = state.getRangeByMarkerName('marker')!;
const addedFileUri = Uri.file(combinePaths(getDirectoryPath(range.fileName), 'test2.py'), state.serviceProvider);
const edits = state.workspace.service.runEditMode((program) => {
const fileChanged = new Map<string, Uri>();
applyWorkspaceEdit(
program,
{
documentChanges: [
TextDocumentEdit.create(
{
uri: range.fileUri.toString(),
version: null,
},
[
{
range: state.convertPositionRange(range),
newText: 'import sys',
},
]
),
],
},
fileChanged
);
assert.strictEqual(fileChanged.size, 1);
const info = program.getSourceFileInfo(range.fileUri)!;
program.analyzeFile(info.uri, CancellationToken.None);
assert.strictEqual(info.contents, 'import sys');
assert.strictEqual(info.imports.length, 3);
// Add a new file.
program.setFileOpened(addedFileUri, 0, '', {
ipythonMode: IPythonMode.None,
chainedFileUri: undefined,
});
applyWorkspaceEdit(
program,
{
documentChanges: [
TextDocumentEdit.create(
{
uri: addedFileUri.toString(),
version: null,
},
[
{
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 0 },
},
newText: 'import sys',
},
]
),
],
},
fileChanged
);
applyWorkspaceEdit(
program,
{
documentChanges: [
TextDocumentEdit.create(
{
uri: addedFileUri.toString(),
version: null,
},
[
{
range: {
start: { line: 0, character: 7 },
end: { line: 0, character: 10 },
},
newText: 'os',
},
]
),
],
},
fileChanged
);
const addedInfo = program.getSourceFileInfo(addedFileUri)!;
program.analyzeFile(addedInfo.uri, CancellationToken.None);
assert.strictEqual(addedInfo.contents, 'import os');
assert.strictEqual(addedInfo.imports.length, 3);
}, CancellationToken.None);
// After leaving edit mode, we should be back to where we were.
const oldSourceFile = state.workspace.service.test_program.getSourceFile(range.fileUri);
state.workspace.service.backgroundAnalysisProgram.analyzeFile(oldSourceFile!.getUri(), CancellationToken.None);
assert.strictEqual(oldSourceFile?.getFileContent(), '');
assert.strictEqual(oldSourceFile.getImports().length, 2);
assert.strictEqual(edits.length, 2);
assert.deepStrictEqual(edits[0].replacementText, 'import sys');
assert.deepStrictEqual(edits[1].replacementText, 'import os');
const addedSourceFile = state.workspace.service.test_program.getSourceFile(addedFileUri);
// The added file should not be there.
assert.ok(!addedSourceFile);
});
test('test applyWorkspaceEdits documentChanges', async () => {
const code = `
// @filename: test.py
//// [|/*marker*/|]
`;
const state = parseAndGetTestState(code).state;
const cloned = await getClonedService(state);
const range = state.getRangeByMarkerName('marker')!;
const fileChanged = new Map<string, Uri>();
applyWorkspaceEditToService(
cloned,
{
documentChanges: [
TextDocumentEdit.create(
{
uri: range.fileUri.toString(),
version: null,
},
[
{
range: state.convertPositionRange(range),
newText: 'Text Changed',
},
]
),
],
},
fileChanged
);
assert.strictEqual(fileChanged.size, 1);
assert.strictEqual(cloned.test_program.getSourceFile(range.fileUri)?.getFileContent(), 'Text Changed');
});
test('test generateWorkspaceEdits', async () => {
const code = `
// @filename: test1.py
//// [|/*marker1*/|]
// @filename: test2.py
//// [|/*marker2*/|]
`;
const state = parseAndGetTestState(code).state;
const cloned = await getClonedService(state);
const range1 = state.getRangeByMarkerName('marker1')!;
const fileChanged = new Map<string, Uri>();
applyWorkspaceEditToService(
cloned,
{
changes: {
[range1.fileUri.toString()]: [
{
range: state.convertPositionRange(range1),
newText: 'Test1 Changed',
},
],
},
},
fileChanged
);
applyWorkspaceEditToService(
cloned,
{
documentChanges: [
TextDocumentEdit.create(
{
uri: range1.fileUri.toString(),
version: null,
},
[
{
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } },
newText: 'NewTest1',
},
]
),
],
},
fileChanged
);
const range2 = state.getRangeByMarkerName('marker2')!;
applyWorkspaceEditToService(
cloned,
{
documentChanges: [
TextDocumentEdit.create(
{
uri: range2.fileUri.toString(),
version: null,
},
[
{
range: state.convertPositionRange(range2),
newText: 'Test2 Changed',
},
]
),
],
},
fileChanged
);
applyWorkspaceEditToService(
cloned,
{
changes: {
[range2.fileUri.toString()]: [
{
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } },
newText: 'NewTest2',
},
],
},
},
fileChanged
);
assert.strictEqual(fileChanged.size, 2);
const actualEdits = generateWorkspaceEdit(state.workspace.service.fs, state.workspace.service, cloned, fileChanged);
verifyWorkspaceEdit(
{
changes: {
[range1.fileUri.toString()]: [
{
range: state.convertPositionRange(range1),
newText: 'NewTest1 Changed',
},
],
[range2.fileUri.toString()]: [
{
range: state.convertPositionRange(range1),
newText: 'NewTest2 Changed',
},
],
},
},
actualEdits
);
});
test('test convertToWorkspaceEdit omits annotationId without changeAnnotations', () => {
const code = `
// @filename: a.py
//// [|/*marker*/|]
`;
const state = parseAndGetTestState(code).state;
const range = state.getRangeByMarkerName('marker')!;
const renameTarget = Uri.file(
combinePaths(getDirectoryPath(range.fileName), 'a_renamed.py'),
state.serviceProvider
);
const deleteTarget = Uri.file(combinePaths(getDirectoryPath(range.fileName), 'c.py'), state.serviceProvider);
const editActions = {
edits: [
{
fileUri: range.fileUri,
range: state.convertPositionRange(range),
replacementText: 'x',
},
],
fileOperations: [
{
kind: 'create' as const,
fileUri: Uri.file(combinePaths(getDirectoryPath(range.fileName), 'b.py'), state.serviceProvider),
},
{
kind: 'rename' as const,
oldFileUri: range.fileUri,
newFileUri: renameTarget,
},
{
kind: 'delete' as const,
fileUri: deleteTarget,
},
],
};
const ws = convertToWorkspaceEdit(state.workspace.service.fs, editActions);
assert.strictEqual(ws.changeAnnotations, undefined);
const tde = ws.documentChanges!.find((d) => TextDocumentEdit.is(d)) as TextDocumentEdit;
const anyEdit = tde.edits[0] as any;
assert.strictEqual(anyEdit.annotationId, undefined);
const createOp = ws.documentChanges!.find((d) => (d as any).kind === 'create') as CreateFile;
assert.strictEqual(createOp.annotationId, undefined);
const renameOp = ws.documentChanges!.find((d) => (d as any).kind === 'rename') as RenameFile;
assert.strictEqual(renameOp.annotationId, undefined);
const deleteOp = ws.documentChanges!.find((d) => (d as any).kind === 'delete') as DeleteFile;
assert.strictEqual(deleteOp.annotationId, undefined);
});
test('test convertToWorkspaceEdit includes annotationId with changeAnnotations', () => {
const code = `
// @filename: a.py
//// [|/*marker*/|]
`;
const state = parseAndGetTestState(code).state;
const range = state.getRangeByMarkerName('marker')!;
const renameTarget = Uri.file(
combinePaths(getDirectoryPath(range.fileName), 'a_renamed.py'),
state.serviceProvider
);
const deleteTarget = Uri.file(combinePaths(getDirectoryPath(range.fileName), 'c.py'), state.serviceProvider);
const editActions = {
edits: [
{
fileUri: range.fileUri,
range: state.convertPositionRange(range),
replacementText: 'x',
},
],
fileOperations: [
{
kind: 'create' as const,
fileUri: Uri.file(combinePaths(getDirectoryPath(range.fileName), 'b.py'), state.serviceProvider),
},
{
kind: 'rename' as const,
oldFileUri: range.fileUri,
newFileUri: renameTarget,
},
{
kind: 'delete' as const,
fileUri: deleteTarget,
},
],
};
const changeAnnotations = {
default: { label: 'label', description: 'desc', needsConfirmation: false },
};
const ws = convertToWorkspaceEdit(state.workspace.service.fs, editActions, changeAnnotations, 'default');
assert.ok(ws.changeAnnotations);
assert.ok(ws.changeAnnotations!['default']);
const tde = ws.documentChanges!.find((d) => TextDocumentEdit.is(d)) as TextDocumentEdit;
const anyEdit = tde.edits[0] as any;
assert.strictEqual(anyEdit.annotationId, 'default');
const createOp = ws.documentChanges!.find((d) => (d as any).kind === 'create') as CreateFile;
assert.strictEqual(createOp.annotationId, 'default');
const renameOp = ws.documentChanges!.find((d) => (d as any).kind === 'rename') as RenameFile;
assert.strictEqual(renameOp.annotationId, 'default');
const deleteOp = ws.documentChanges!.find((d) => (d as any).kind === 'delete') as DeleteFile;
assert.strictEqual(deleteOp.annotationId, 'default');
});
test('test hasWorkspaceEditChanges for empty workspace edits', () => {
assert.strictEqual(hasWorkspaceEditChanges({ changes: {} }), false);
assert.strictEqual(hasWorkspaceEditChanges({ changes: { 'file:///test.py': [] } }), false);
assert.strictEqual(
hasWorkspaceEditChanges({
documentChanges: [
TextDocumentEdit.create(
{
uri: 'file:///test.py',
version: null,
},
[]
),
],
}),
false
);
});
test('test hasWorkspaceEditChanges for text and file operations', () => {
const textEdit = {
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 0 },
},
newText: 'x',
};
assert.strictEqual(hasWorkspaceEditChanges({ changes: { 'file:///test.py': [textEdit] } }), true);
assert.strictEqual(
hasWorkspaceEditChanges({
documentChanges: [
TextDocumentEdit.create(
{
uri: 'file:///test.py',
version: null,
},
[textEdit]
),
],
}),
true
);
assert.strictEqual(hasWorkspaceEditChanges({ documentChanges: [CreateFile.create('file:///created.py')] }), true);
});
function applyWorkspaceEditToService(service: AnalyzerService, edits: WorkspaceEdit, filesChanged: Map<string, Uri>) {
const program = service.backgroundAnalysisProgram.program;
applyWorkspaceEdit(program, edits, filesChanged);
}
async function getClonedService(state: TestState) {
return await AnalyzerServiceExecutor.cloneService(
new TestLanguageService(state.workspace, state.console, state.workspace.service.fs),
state.workspace,
{ useBackgroundAnalysis: false }
);
}