feature: add commands for creating modules and packages#1840
Conversation
| export function hasForbiddenFileNameChars(name: string): boolean { | ||
| for (const ch of name) { | ||
| const code = ch.charCodeAt(0); | ||
| if (code < 0x20 || code === 0x7f) { | ||
| return true; | ||
| } | ||
| if ( | ||
| ch === '<' || | ||
| ch === '>' || | ||
| ch === ':' || | ||
| ch === '"' || | ||
| ch === '/' || | ||
| ch === '\\' || | ||
| ch === '|' || | ||
| ch === '?' || | ||
| ch === '*' | ||
| ) { | ||
| return true; | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| function hasNonAsciiChars(name: string): boolean { | ||
| for (const ch of name) { | ||
| if (ch.charCodeAt(0) > 127) { | ||
| return true; | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| export function validatePythonName(name: string): NameValidation { | ||
| if (!name || hasForbiddenFileNameChars(name)) { | ||
| return 'forbidden'; | ||
| } | ||
| // Pure-ASCII Python identifier | ||
| if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { | ||
| return 'ok'; | ||
| } | ||
| // Dot: always conflicts with Python's package-path resolution. | ||
| // A file named `foo.bar.py` can never be imported — `import foo.bar` | ||
| // looks for `foo/bar.py`, not `foo.bar.py`. | ||
| if (name.includes('.')) { | ||
| return 'dot'; | ||
| } | ||
| // Non-ASCII: could be valid PEP 3131 identifier, but warn about portability | ||
| if (hasNonAsciiChars(name)) { | ||
| return 'unicode'; | ||
| } | ||
| return 'nonIdentifier'; | ||
| } |
There was a problem hiding this comment.
this sounds like something the pyright codebase would already have a function for. i found this:
basedpyright/packages/pyright-internal/src/analyzer/packageTypeVerifier.ts
Lines 499 to 504 in a3f7c2e
it should probably be moved to symbolNameUtils.ts and used by both modules
There was a problem hiding this comment.
these two commands should be split up into separate modules, like the other commands
| async function resolveWorkspace( | ||
| ls: LanguageServerInterface, | ||
| primaryUri: Uri | undefined, | ||
| fallbackUri: Uri | undefined | ||
| ) { | ||
| for (const uri of [primaryUri, fallbackUri]) { | ||
| if (uri) { | ||
| const workspace = await ls.getWorkspaceForFile(uri); | ||
| if (workspace) { | ||
| return workspace; | ||
| } | ||
| } | ||
| } | ||
| return undefined; | ||
| } |
There was a problem hiding this comment.
can we just resolve the workspace the same way as other commands? eg.
| export class CreateModuleCommand implements ServerCommand { | ||
| constructor(private _ls: LanguageServerInterface) {} | ||
|
|
||
| async execute(cmdParams: ExecuteCommandParams, _token: CancellationToken): Promise<any> { |
| export class CreatePackageCommand implements ServerCommand { | ||
| constructor(private _ls: LanguageServerInterface) {} | ||
|
|
||
| async execute(cmdParams: ExecuteCommandParams, _token: CancellationToken): Promise<any> { |
| const nameCheck = validatePythonName(packageName); | ||
| if (nameCheck === 'forbidden') { | ||
| this._ls.window.showErrorMessage(Service.invalidPythonNameForbidden().format({ name: packageName })); | ||
| return; | ||
| } | ||
| if (nameCheck === 'dot') { | ||
| this._ls.window.showErrorMessage(Service.invalidPythonNameDot().format({ name: packageName })); | ||
| return; | ||
| } | ||
| if (nameCheck === 'nonIdentifier') { | ||
| this._ls.window.showWarningMessage(Service.invalidPythonNameNonIdentifier().format({ name: packageName })); | ||
| } | ||
| if (nameCheck === 'unicode') { | ||
| this._ls.window.showWarningMessage(Service.invalidPythonNameUnicode().format({ name: packageName })); | ||
| } | ||
|
|
||
| const workspaceUri = tryParseUri(args[0] as string | undefined, this._ls); | ||
| const workspace = await resolveWorkspace(this._ls, workspaceUri, targetDir); | ||
| if (!workspace) { | ||
| this._ls.window.showErrorMessage(Service.noWorkspaceFound()); | ||
| return; | ||
| } | ||
|
|
||
| const fs = workspace.service.fs; | ||
|
|
||
| if (!fs.existsSync(targetDir)) { | ||
| this._ls.window.showErrorMessage(Service.dirNotExist().format({ path: targetDir.toUserVisibleString() })); | ||
| return; | ||
| } | ||
| // If the target is a file (e.g. from command palette), use its parent directory | ||
| if (!fs.statSync(targetDir).isDirectory()) { | ||
| targetDir = targetDir.getDirectory(); | ||
| } |
| // other test files should also be fixed. | ||
| setLocaleOverride('en-us'); |
There was a problem hiding this comment.
| // other test files should also be fixed. | |
| setLocaleOverride('en-us'); | |
| // TODO: other test files should also be fixed. | |
| setLocaleOverride('en-us'); |
| class MockWindow implements WindowInterface { | ||
| private _text = ''; | ||
| messages: CapturedMessage[] = []; | ||
|
|
||
| get statusBarText(): string { | ||
| return this._text; | ||
| } | ||
| set statusBarText(value: string) { | ||
| this._text = value; | ||
| } | ||
|
|
||
| showErrorMessage(message: string): void; | ||
| showErrorMessage(message: string, ...actions: MessageAction[]): Promise<MessageAction | undefined>; | ||
| showErrorMessage(message: string, ..._actions: MessageAction[]): void | Promise<MessageAction | undefined> { | ||
| this.messages.push({ type: 'error', text: message }); | ||
| return undefined; | ||
| } | ||
| showWarningMessage(message: string): void; | ||
| showWarningMessage(message: string, ...actions: MessageAction[]): Promise<MessageAction | undefined>; | ||
| showWarningMessage(message: string, ..._actions: MessageAction[]): void | Promise<MessageAction | undefined> { | ||
| this.messages.push({ type: 'warning', text: message }); | ||
| return undefined; | ||
| } | ||
| showInformationMessage(message: string): void; | ||
| showInformationMessage(message: string, ...actions: MessageAction[]): Promise<MessageAction | undefined>; | ||
| showInformationMessage( | ||
| message: string, | ||
| ..._actions: MessageAction[] | ||
| ): void | Promise<MessageAction | undefined> { | ||
| this.messages.push({ type: 'info', text: message }); | ||
| return undefined; | ||
| } | ||
| } | ||
|
|
||
| const _mockCancellationToken: CancellationToken = { | ||
| isCancellationRequested: false, | ||
| onCancellationRequested: () => ({ dispose: () => {} }), | ||
| }; | ||
|
|
||
| function createMockLanguageServerInterface(testFS: TestFileSystem, window: MockWindow): LanguageServerInterface { | ||
| const sp = createServiceProvider(testFS); | ||
|
|
||
| const mockWorkspace: Workspace = { | ||
| workspaceName: 'test', | ||
| rootUri: Uri.file('/project', sp), | ||
| kinds: [WellKnownWorkspaceKinds.Test], | ||
| service: { | ||
| fs: testFS, | ||
| } as unknown as Workspace['service'], | ||
| disableLanguageServices: false, | ||
| disableTaggedHints: false, | ||
| disableOrganizeImports: false, | ||
| disableWorkspaceSymbol: false, | ||
| isInitialized: createInitStatus(), | ||
| searchPathsToWatch: [], | ||
| useTypingExtensions: false, | ||
| fileEnumerationTimeoutInSec: 10, | ||
| autoFormatStrings: true, | ||
| baselineMode: 'auto', | ||
| }; | ||
|
|
||
| return { | ||
| serviceProvider: sp, | ||
| console: new NullConsole(), | ||
| window, | ||
| supportAdvancedEdits: false, | ||
| createBackgroundAnalysis: () => undefined, | ||
| reanalyze: () => {}, | ||
| restart: () => {}, | ||
| getWorkspaces: async () => [mockWorkspace], | ||
| getSettings: async () => ({ | ||
| watchForSourceChanges: true, | ||
| watchForLibraryChanges: true, | ||
| watchForConfigChanges: true, | ||
| openFilesOnly: true, | ||
| useLibraryCodeForTypes: true, | ||
| disableLanguageServices: false, | ||
| disableTaggedHints: false, | ||
| disableOrganizeImports: false, | ||
| typeCheckingMode: 'off', | ||
| diagnosticSeverityOverrides: {}, | ||
| logLevel: LogLevel.Info, | ||
| autoImportCompletions: true, | ||
| functionSignatureDisplay: SignatureDisplayType.formatted, | ||
| inlayHints: { | ||
| callArgumentNames: true, | ||
| callArgumentNamesMatching: false, | ||
| functionReturnTypes: true, | ||
| variableTypes: true, | ||
| genericTypes: false, | ||
| }, | ||
| useTypingExtensions: false, | ||
| }), | ||
| getWorkspaceForFile: async () => mockWorkspace, | ||
| convertUriToLspUriString: (_fs, uri) => uri.toString(), | ||
| documentsWithDiagnostics: {}, | ||
| }; | ||
| } |
There was a problem hiding this comment.
we already have a test LanguageServerInterface & WindoeInterface at https://github.com/DetachHead/basedpyright/blob/main/packages/pyright-internal/src/tests/harness/fourslash/testLanguageService.ts
| cmd = new CreateModuleCommand(ls); | ||
| }); | ||
|
|
||
| // --- golden path --- |
There was a problem hiding this comment.
instead of these comments you can use a nested describe
| const explorerMenuCreateCommands = [Commands.createNewModule, Commands.createNewPackage]; | ||
| explorerMenuCreateCommands.forEach((commandName) => { | ||
| context.subscriptions.push(registerRefactorCommand(client, commandName, true)); | ||
| }); |
There was a problem hiding this comment.
see 7177e59, we should avoid registering commands in the vscode extension if possible because they dont work in other clients
There was a problem hiding this comment.
we indeed have client logic (asking extra inputs). is there fine replacements?
once used during trying workspaceEdits api but cannot get that work
|
maybe I can implement this feature in another way (like a different editor extension out of here). so feel free to close this if introducing this breaks availability. |
Changes:
basedpyright.explorer.createNewModule: create a new module from current active file uri or given file uri with name validationbasedpyright.explorer.createNewPackage: create a new package (including__init__.py) from current active file uri or given file uri with name validationbasedpyright.explorer.createNewModuleexplorer/context(passing uri to langserver):basedpyright.explorer.createNewModulebasedpyright.explorer.createNewPackage