diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e82bb5..48d0170 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,23 @@ ### Added +- Basic autocompletion support, powered by Blender console-like completion engine. It is not mean to replace other completion engines but work in tandem. + - hidden under setting `blender.autocompletion`, by default disabled + - Pros: + - should not break other completion methods like `fake-bpy-module` - in fact, all methods can be used together + - this method can complete dynamic items like `bpy.ops`, `bpy.data.object` names + - can resolve function arguments but ony best effort (extracted from docs) + - can show docs for functions + - Cons: + - bpy import will be still marked as missing + - type hints are ignored + - variables (aliases) will not work + - How to use + 1. Enable setting `blender.autocompletion` and restart extension + 1. Use `Blender: Start` command - blender must me running for completion to work + 1. In any python file write `bpy.` (crtl+space in my case) + + - A `blender.executable` can be now marked as default. - When no blender is marked as default, a notification will appear after and offer setting it as default ```json diff --git a/README.md b/README.md index 39e78ad..cfa0db4 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,29 @@ By default, debug breakpoints work only for files and directories opened in the Disable the VS Code setting [`blender.addon.justMyCode`](vscode://settings/blender.addon.justMyCode) to debug code anywhere. In rare cases debugging with VS Code can crash Blender (ex. https://github.com/JacquesLucke/blender_vscode/issues/188). +### Python autocomplete `bpy` + +There is **experimental** autocomplete engine available that can work together with classic solution like `fake-bpy-module`. + +How to use: + +1. Enable setting [`blender.autocompletion`](vscode://settings/blender.autocompletion) and **restart** extension (or vs code) +1. Use `Blender: Start` command - blender must me running for completion to work +1. In any python file write `bpy.` (crtl+space in my case) + +Pros: + +- This method can complete dynamic items like `bpy.ops`, `bpy.data.object` names +- Can resolve function arguments but ony best effort (extracted from docs) +- Can show docs for functions + +Cons: + +- bpy import will be still marked as missing +- Blender must be running for completion to work with debug session attached +- Type hints are not supported +- Variables (aliases) will not work: `D = bpy.data; D.` will fail to complete + ### How to start Blender with shortcut? Limited shortcuts are supported by editing `keybindings.json`. diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..5bbef91 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,11 @@ + +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + globals: { + 'ts-jest': { + tsconfig: 'tsconfig.json', + }, + }, +}; diff --git a/package.json b/package.json index e7b78f6..9f04aa1 100644 --- a/package.json +++ b/package.json @@ -133,7 +133,6 @@ "name": { "type": "string", "description": "Custom name for this Blender version." - }, "isDefault": { "type": "boolean", @@ -151,6 +150,15 @@ "markdownDeprecationMessage": "**Deprecated**: modules are now installed to `bpy.utils.user_resource(\"SCRIPTS\", path=\"modules\")`.", "deprecationMessage": "Deprecated: modules are now installed to `bpy.utils.user_resource(\"SCRIPTS\", path=\"modules\")`." }, + "blender.autocompletion": { + "type": "boolean", + "scope": "resource", + "default": false, + "markdownDescription": "Add basic autocompletion powered by Blender internal (console like) code completion. You must use `Blender: Start` before completion starts working.\n\nRequires restart of extension.\n\nFor classic code completion use `fake-bpy-module` or similar.", + "tags": [ + "experimental" + ] + }, "blender.addon.reloadOnSave": { "type": "boolean", "scope": "resource", @@ -360,15 +368,19 @@ "scripts": { "vscode:prepublish": "npm run compile", "compile": "tsc -p ./", - "watch": "tsc -watch -p ./" + "watch": "tsc -watch -p ./", + "test": "jest" }, "devDependencies": { + "@types/jest": "^30.0.0", "@types/mocha": "^2.2.42", - "@types/node": "^8.10.25", + "@types/node": "^22.17.2", "@types/request": "^2.48.1", "@types/vscode": "^1.28.0", + "jest": "^30.0.5", + "ts-jest": "^29.4.1", "tslint": "^5.8.0", - "typescript": "^5.5.2" + "typescript": "^5.9.2" }, "dependencies": { "request": "^2.87.0" diff --git a/pythonFiles/include/blender_vscode/autocomplete.py b/pythonFiles/include/blender_vscode/autocomplete.py new file mode 100644 index 0000000..dff5de0 --- /dev/null +++ b/pythonFiles/include/blender_vscode/autocomplete.py @@ -0,0 +1,29 @@ +from pprint import pformat + +from .autocomplete_locals import BUILTIN_LOCALS +from . import log +import bpy + + +from bl_console_utils.autocomplete import intellisense + +complete_locals = {} +complete_locals.update(BUILTIN_LOCALS) + + +LOG = log.getLogger() + + +def complete(data): + line: str = data["line"] + current_character: str = data["current_character"] + + resultExpand = intellisense.expand(line=line, cursor=current_character, namespace=complete_locals, private=True) + + resultComplete = intellisense.complete(line=line, cursor=current_character, namespace=complete_locals, private=True) + + if resultComplete[0]: + for result in resultComplete[0]: + yield {"complete": result, "description": "", "prefixToRemove": resultComplete[1]} + else: + yield {"complete": resultExpand[0], "description": resultExpand[2]} diff --git a/pythonFiles/include/blender_vscode/autocomplete_locals.py b/pythonFiles/include/blender_vscode/autocomplete_locals.py new file mode 100644 index 0000000..964f9ad --- /dev/null +++ b/pythonFiles/include/blender_vscode/autocomplete_locals.py @@ -0,0 +1,4 @@ +import bpy, bgl, gpu, blf, mathutils + + +BUILTIN_LOCALS = locals() \ No newline at end of file diff --git a/pythonFiles/include/blender_vscode/communication.py b/pythonFiles/include/blender_vscode/communication.py index 4f7e60f..9b7293f 100644 --- a/pythonFiles/include/blender_vscode/communication.py +++ b/pythonFiles/include/blender_vscode/communication.py @@ -86,7 +86,6 @@ def handle_post(): return POST_HANDLERS[data["type"]](data) else: LOG.warning(f"Unhandled POST: {data}") - return "OK" @@ -98,9 +97,14 @@ def handle_get(): if data["type"] == "ping": pass elif data["type"] == "complete": - from .blender_complete import complete + from .autocomplete import complete + + items = list(complete(data)) - return {"items": complete(data)} + # print("repondings:") + out = flask.jsonify({"type": "completeResponse", "items": items}) + # print(out) + return out else: LOG.warning(f"Unhandled GET: {data}") return "OK" diff --git a/src/blender_completion_provider.ts b/src/blender_completion_provider.ts new file mode 100644 index 0000000..4b89975 --- /dev/null +++ b/src/blender_completion_provider.ts @@ -0,0 +1,68 @@ + +import * as vscode from 'vscode'; +import { RunningBlenders } from './communication'; +import { removeCommonPrefixSuffix, guesFuncSignature } from './blender_completion_provider_utils'; +import { getRandomString } from './utils'; + + +export function blenderCompletionProvider() { + const provider1 = vscode.languages.registerCompletionItemProvider('python', { + async provideCompletionItems(document: vscode.TextDocument, position: vscode.Position, _token: vscode.CancellationToken, _context: vscode.CompletionContext) { + const line = document.lineAt(position.line); + const requestData = { + "type": "complete", + "sessionId": getRandomString(), + "line": line.text, + "document": document.getText(), + "current_line": position.line, + "current_character": position.character, + } + const resultsToAwait = await RunningBlenders.sendGetToResponsive(requestData) + const results = await Promise.allSettled(resultsToAwait); + const items = results.filter(r => r.status === "fulfilled").map((r: PromiseFulfilledResult) => r.value); + + const seen = new Set(); + const deduplicatedItems: any[] = items.reduce((acc, responseBody) => { + for (const item of responseBody.items) { + if (!seen.has(item.complete)) { + seen.add(item.complete) + acc.push(item) + } + } + return acc; + }, []) + + return deduplicatedItems.map(item => { + const complete = new vscode.CompletionItem(item.complete) + complete.range = new vscode.Range(position, position); + if (item.prefixToRemove !== undefined) { + complete.insertText = item.complete.substring(item.prefixToRemove.length) + } else { + complete.insertText = removeCommonPrefixSuffix(item.complete, line.text) + } + if (item.description) { + complete.documentation = new vscode.MarkdownString(item.description.replace("\n", "\n\n")) + } + if (item.complete.endsWith('(') || item.complete.endsWith('()') ) { + complete.kind = vscode.CompletionItemKind.Function + let maybeFuncSignature; + if (item.complete.endsWith('()') ) { + complete.range = new vscode.Range(position, position.translate(0, 1)); + maybeFuncSignature = guesFuncSignature(item.description.slice(0, -1)) + } else { + maybeFuncSignature = guesFuncSignature(item.description) + } + complete.label = maybeFuncSignature + if (maybeFuncSignature) { + complete.insertText = removeCommonPrefixSuffix(maybeFuncSignature, line.text) + } + complete.sortText = '\0'; + } + return complete + }); + } + }, + ".", "(", '[', '\'', '"', // trigger characters + ); + return provider1 +} \ No newline at end of file diff --git a/src/blender_completion_provider_utils.test.ts b/src/blender_completion_provider_utils.test.ts new file mode 100644 index 0000000..be163a8 --- /dev/null +++ b/src/blender_completion_provider_utils.test.ts @@ -0,0 +1,46 @@ +import { guesFuncSignature, removeCommonPrefixSuffix } from './blender_completion_provider_utils'; + +describe('removeCommonPrefix', () => { + it('item longer than line', () => { + expect(removeCommonPrefixSuffix('bpy.data.objects', 'bpy.')).toBe('data.objects'); + expect(removeCommonPrefixSuffix('bpy.data.objects', 'bpy.da')).toBe('ta.objects'); + expect(removeCommonPrefixSuffix('bpy.data.objects[\'', 'bpy')).toBe('.data.objects[\''); + }); + it('line same len item', () => { + expect(removeCommonPrefixSuffix('bpy.data.', 'bpy.data.')).toBe(''); + }); + it('line longer than item', () => { + expect(removeCommonPrefixSuffix('', 'bpy.data')).toBe(''); + expect(removeCommonPrefixSuffix('bpy.ops.get(', '')).toBe('bpy.ops.get('); + expect(removeCommonPrefixSuffix('bpy.ops.get(type=\'NONE\')', 'bpy.ops.get(')).toBe('type=\'NONE\')'); + }); + it('line longer than item with repeated text', () => { + expect(removeCommonPrefixSuffix('bpy.data.objects', 'D = bpy.data; bpy.data.')).toBe('objects'); + }); +}); + + +describe('guesFuncSignature', () => { + it('returns the line itself if there is only one line', () => { + expect(guesFuncSignature('singleLine')).toBe('singleLine'); + expect(guesFuncSignature('')).toBe(''); + }); + + it('returns the second line if it contains parentheses', () => { + expect(guesFuncSignature('first line\nsecondLine()')).toBe('secondLine()'); + }); + it('strip typical function signature', () => { + expect(guesFuncSignature('ignore\n.. method:: myFunc(arg)')).toBe('myFunc(arg)'); + }); + + it('returns the first line if the second line has no parentheses', () => { + expect(guesFuncSignature('first line\nsecond line')).toBe('first line'); + }); + + it('handles multiple lines correctly', () => { + const text = `first line +.. method:: exampleFunc(arg1, arg2) +third line`; + expect(guesFuncSignature(text)).toBe('exampleFunc(arg1, arg2)'); + }); +}); \ No newline at end of file diff --git a/src/blender_completion_provider_utils.ts b/src/blender_completion_provider_utils.ts new file mode 100644 index 0000000..b0a456d --- /dev/null +++ b/src/blender_completion_provider_utils.ts @@ -0,0 +1,29 @@ +// longest prefix-suffix match problem +export function removeCommonPrefixSuffix(completionItem: string, line: string): string { + let prefixLenToRemove = 0; + + for (let i = 0; i < line.length; i++) { + const suffix = line.slice(i); + if (completionItem.startsWith(suffix)) { + prefixLenToRemove = suffix.length; + break; // earliest (longest suffix) match found + } + } + return completionItem.slice(prefixLenToRemove); +} + +export function guesFuncSignature(text: string) { + const lines = text.split("\n"); + + if (lines.length < 2) return lines[0] || ""; + + const secondLine = lines[1]; + if (secondLine.includes("(") || secondLine.includes(")")) { + if (secondLine.startsWith(".. method:: ")) { + return secondLine.substring(".. method:: ".length); + } + return secondLine; + } + + return lines[0]; +} \ No newline at end of file diff --git a/src/communication.ts b/src/communication.ts index b91969d..328ac22 100644 --- a/src/communication.ts +++ b/src/communication.ts @@ -48,6 +48,26 @@ export class BlenderInstance { }); } + async getAsync(data: any): Promise { + return new Promise((resolve, reject) => { + request.get(this.address, { json: data }, + (error, response, body) => { + if (error) { + reject(error) + return; + } + + if (response.statusCode && response.statusCode >= 200 && response.statusCode < 300) { + resolve(body) + } else { + reject(error) + return; + } + } + ); + }); + } + async isResponsive(timeout: number = RESPONSIVE_LIMIT_MS) { return new Promise(resolve => { this.ping().then(() => resolve(true)).catch(); @@ -122,8 +142,9 @@ export class RunningBlenderInstances { let sentTo: request.Request[] = [] for (const instance of this.instances) { const isResponsive = await instance.isResponsive(timeout) - if (!isResponsive) - continue + + if (!isResponsive) continue + try { sentTo.push(instance.post(data)) } catch { } @@ -131,6 +152,20 @@ export class RunningBlenderInstances { return sentTo; } + async sendGetToResponsive(data: object, timeout: number = RESPONSIVE_LIMIT_MS) { + let sentTo: Promise[] = [] + for (const instance of this.instances) { + const isResponsive = await instance.isResponsive(timeout) + + if (!isResponsive) continue + + try { + sentTo.push(instance.getAsync(data)) + } catch { } + } + return sentTo; + } + sendToAll(data: object) { for (const instance of this.instances) { instance.post(data); @@ -171,7 +206,6 @@ function SERVER_handleRequest(request: any, response: any) { instance.attachDebugger().then(() => { RunningBlenders.registerInstance(instance) RunningBlenders.getTask(instance.vscodeIdentifier)?.onStartDebugging() - } ) break; diff --git a/src/extension.ts b/src/extension.ts index 7122d5d..6f574e2 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,11 +2,10 @@ import * as vscode from 'vscode'; import { AddonWorkspaceFolder } from './addon_folder'; +import { blenderCompletionProvider } from './blender_completion_provider'; import { BlenderExecutableData, BlenderExecutableSettings, LaunchAny, LaunchAnyInteractive } from './blender_executable'; -import { RunningBlenders, startServer, stopServer } from './communication'; import { COMMAND_newAddon } from './commands_new_addon'; import { COMMAND_newOperator } from './commands_new_operator'; -import { factoryShowNotificationAddDefault } from './notifications'; import { COMMAND_newScript, COMMAND_openScriptsFolder, @@ -14,7 +13,9 @@ import { COMMAND_runScript_registerCleanup, COMMAND_setScriptContext } from './commands_scripts'; -import { getDefaultBlenderSettings, handleErrors } from './utils'; +import { RunningBlenders, startServer, stopServer } from './communication'; +import { factoryShowNotificationAddDefault } from './notifications'; +import { getConfig, getDefaultBlenderSettings, handleErrors } from './utils'; export let outputChannel: vscode.OutputChannel; @@ -54,6 +55,11 @@ export function activate(context: vscode.ExtensionContext) { } disposables.push(...COMMAND_runScript_registerCleanup()) + const useAutocomplete = getConfig().get('autocompletion') + if (useAutocomplete) { + disposables.push(blenderCompletionProvider()) + } + context.subscriptions.push(...disposables); showNotificationAddDefault = factoryShowNotificationAddDefault(context) startServer(); @@ -63,7 +69,6 @@ export function deactivate() { stopServer(); } - /* Commands *********************************************/ diff --git a/src/utils.ts b/src/utils.ts index 9967559..b7704f4 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -241,4 +241,4 @@ export function toTitleCase(str: string) { /\w\S*/g, text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase() ); -} \ No newline at end of file +} diff --git a/tsconfig.json b/tsconfig.json index 3210eca..d24592e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,10 @@ ], "sourceMap": true, "rootDir": "src", - + "types": [ + "jest", + "node" + ], "strict": true, "noUnusedLocals": true, "noImplicitReturns": true, @@ -18,4 +21,4 @@ "node_modules", ".vscode-test" ] -} +} \ No newline at end of file diff --git a/tslint.json b/tslint.json index 9b0fd6a..e3078cb 100644 --- a/tslint.json +++ b/tslint.json @@ -3,7 +3,10 @@ "no-string-throw": true, "no-unused-expression": true, "no-duplicate-variable": true, - "curly": [true, "ignore-same-line"], + "curly": [ + true, + "ignore-same-line" + ], "class-name": true, "semicolon": [ true, @@ -12,4 +15,4 @@ "triple-equals": true }, "defaultSeverity": "warning" -} +} \ No newline at end of file