Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<trigger completion>` (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
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<trigger completion>` (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.<tab>` will fail to complete

### How to start Blender with shortcut?

Limited shortcuts are supported by editing `keybindings.json`.
Expand Down
11 changes: 11 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@

/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
globals: {
'ts-jest': {
tsconfig: 'tsconfig.json',
},
},
};
20 changes: 16 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,6 @@
"name": {
"type": "string",
"description": "Custom name for this Blender version."

},
"isDefault": {
"type": "boolean",
Expand All @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
29 changes: 29 additions & 0 deletions pythonFiles/include/blender_vscode/autocomplete.py
Original file line number Diff line number Diff line change
@@ -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]}
4 changes: 4 additions & 0 deletions pythonFiles/include/blender_vscode/autocomplete_locals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import bpy, bgl, gpu, blf, mathutils


BUILTIN_LOCALS = locals()
10 changes: 7 additions & 3 deletions pythonFiles/include/blender_vscode/communication.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ def handle_post():
return POST_HANDLERS[data["type"]](data)
else:
LOG.warning(f"Unhandled POST: {data}")

return "OK"


Expand All @@ -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"
Expand Down
68 changes: 68 additions & 0 deletions src/blender_completion_provider.ts
Original file line number Diff line number Diff line change
@@ -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<any>) => r.value);

const seen = new Set<string>();
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
}
46 changes: 46 additions & 0 deletions src/blender_completion_provider_utils.test.ts
Original file line number Diff line number Diff line change
@@ -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)');
});
});
29 changes: 29 additions & 0 deletions src/blender_completion_provider_utils.ts
Original file line number Diff line number Diff line change
@@ -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];
}
40 changes: 37 additions & 3 deletions src/communication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,26 @@ export class BlenderInstance {
});
}

async getAsync(data: any): Promise<any> {
return new Promise<any>((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<boolean>(resolve => {
this.ping().then(() => resolve(true)).catch();
Expand Down Expand Up @@ -122,15 +142,30 @@ 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 { }
}
return sentTo;
}

async sendGetToResponsive(data: object, timeout: number = RESPONSIVE_LIMIT_MS) {
let sentTo: Promise<any>[] = []
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);
Expand Down Expand Up @@ -171,7 +206,6 @@ function SERVER_handleRequest(request: any, response: any) {
instance.attachDebugger().then(() => {
RunningBlenders.registerInstance(instance)
RunningBlenders.getTask(instance.vscodeIdentifier)?.onStartDebugging()

}
)
break;
Expand Down
Loading