Local LSP server that improves functionality like item completion (#122)

* Initial commit with LSP from MS examples

* Code quality fixes

* added remote LSP server to run parallel to local LSP server

* removed lspEnabled check as its done in extension already

* Removed completion from REST

* removed comments

* Turned Server into a class and extracted validation

* Removed log output

* WIP: Added items completion in LSP server
Items are taken from REST API at start and are getting cached in LSP Server. Their values are updated from SSE (/rest/events). When Items are added or removed the cache is updated. So now we have a very responsive completion list.

* Code quality changes

* made code more robust

* Added parsing of StateChange and uItemUdate events

* Fixed wrongly called cb(error)

* removed log output

* Cleaned up validation to not return anything

* Cleaned up and added bit more docs

* Fixed capital letter of class in import - worked on win but not in linux

* Same as before

* Removed unused getter

* Code Quality

* Moved @types dependencies to root package.json and cleaned up tsconfig files

* Test commit for sign off

* Added author in docs

* Added pure JS impl of server and wrote tests with jest

Coverage is not goot yet, ItemCompletionProvider and Item still miss
some tests. Other files are good already.

* Added more tests, use of preomise instead of callbacks in completionitem

* Moved tests to unit folder

* Added more tests

* Cleaned up and removed TS impl

* Fixed compile problems by increasing vscode version

* Fallback to empty array if no items map is present

* More tests

* Improved npm scripts

npm run build builds a .vsix
npm run publish publishes the extension
both commands run tests before to make sure a working version is
built/deployed

* use bind() instead of anonymous function

* improved scripts

* Renamed config properties and removed useRestCompletions as its not
needed anymre

* changed config in remote language client

* removed TODO comment

* added changelog and fixed description for settings

* fixed typo

* Removed commented code

Signed-off-by: Samuel Brucksch <sasliga@freenet.de> (github: SamuelBrucksch)
pull/136/head
Samuel Brucksch 2019-01-11 11:01:45 +01:00 committed by Kuba Wolanin
parent 82afd6fdfc
commit 91e7770c25
53 changed files with 14544 additions and 1167 deletions

7
.gitignore vendored
View File

@ -2,4 +2,9 @@ out
node_modules
*.vsix
*.todo
*.zip
*.zip
**/out
**/node_modules
.vscode
.vscode-test
coverage

15
.vscode/launch.json vendored
View File

@ -10,7 +10,7 @@
"args": ["--extensionDevelopmentPath=${workspaceRoot}" ],
"stopOnEntry": false,
"sourceMaps": true,
"outFiles": [ "${workspaceRoot}/out/src/**/*.js" ],
"outFiles": [ "${workspaceRoot}/**/out/**/*.js" ],
"preLaunchTask": "npm"
},
{
@ -23,6 +23,19 @@
"sourceMaps": true,
"outFiles": [ "${workspaceRoot}/out/test/**/*.js" ],
"preLaunchTask": "npm"
},
{
"type": "node",
"name": "lsp-server-jest-tests",
"request": "launch",
"program": "${workspaceFolder}/serverJS/node_modules/jest/bin/jest",
"args": [
"--runInBand",
"--detectOpenHandles"
],
"cwd": "${workspaceFolder}/serverJS",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
}
]
}

View File

@ -1,5 +1,12 @@
# openHAB VS Code Extension Change Log
## 0.5.0 - TBD
- Added local LSP server (#122)
- Fixed sorting order in items explorer (#125)
- removed settings param 'restCompletions'
- renamed settings param 'lspEnabled' to 'remoteLspEnabled'
- renamed settings param 'lspPort' to 'remoteLspPort'
## 0.4.1 - 2018-12-09
- Fixed Basic UI Preview (#117)
- Fixed Show in Paper UI command (#117)

View File

@ -85,14 +85,14 @@ In the unlikely case that your language server is running on a port other than t
```json
{
"openhab.lspPort": 5007
"openhab.remoteLspPort": 5007
}
```
If you don't want to have your openHAB files validated by Language Server, simply disable it in the extension:
```json
{
"openhab.lspEnabled": false
"openhab.remoteLspEnabled": false
}
```
@ -107,7 +107,7 @@ The following configuration will allow you to access REST API remotely:
```
"openhab.host": "https://home.myopenhab.org",
"openhab.port": 80,
"openhab.lspEnabled": false,
"openhab.remoteLspEnabled": false,
"openhab.username": "your_myopenhab_email",
"openhab.password": "your_myopenhab_password",
```

1676
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
client/package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "openhab-vscode-extension",
"displayName": "openHAB Visual Studio Code Extension",
"description": "Robust tool for openHAB textual configurations. Includes code snippets, syntax highlighting, language server integration and more.",
"version": "0.4.1",
"publisher": "openhab",
"icon": "../openhab.png",
"repository": {
"type": "git",
"url": "https://github.com/openhab/openhab-vscode.git"
},
"license": "SEE LICENSE IN LICENSE",
"engines": {
"vscode": "^1.30.0"
},
"scripts": {
"postinstall": "vscode-install",
"test": "echo \"No tests in client yet!\""
},
"dependencies": {
"ascii-table": "0.0.9",
"copy-paste": "^1.3.0",
"lodash": "^4.17.4",
"request": "^2.83.0",
"request-promise-native": "^1.0.5",
"underscore.string": "^3.3.5",
"vscode": "^1.1.26",
"vscode-languageclient": "^5.2.1"
}
}

View File

@ -0,0 +1,55 @@
import {
Disposable,
workspace
} from 'vscode'
import {
LanguageClient,
LanguageClientOptions,
TransportKind,
ServerOptions
} from 'vscode-languageclient'
import * as path from 'path';
/**
* @author Samuel Brucksch
*/
export class LocalLanguageClientProvider {
constructor() { }
public connect(context): Disposable {
// The debug options for the server
// --inspect=6009: runs the server in Node's Inspector mode so VS Code can attach to the server for debugging
const debugOptions = { execArgv: ["--nolazy", "--inspect=6009"] };
const serverModule = context.asAbsolutePath(path.join("serverJS", "src", "LSPServer.js"));
// If the extension is launched in debug mode then the debug server options are used
// Otherwise the run options are used
const serverOptions: ServerOptions = {
run: {
module: serverModule,
transport: TransportKind.ipc,
},
debug: {
module: serverModule,
transport: TransportKind.ipc,
options: debugOptions,
},
};
const extensions = ["things", "items", "rules", "script", "sitemap", "persist"];
const clientOptions: LanguageClientOptions = {
documentSelector: [{ scheme: "file", language: "openhab", pattern: `**/*.{${extensions.join(",")}}` }],
synchronize: {
configurationSection: "openhab",
fileEvents: workspace.createFileSystemWatcher("**/.clientrc"),
},
};
// Create the language client and start the client.
const lc = new LanguageClient("openhabLanguageServer", "Openhab Language Server", serverOptions, clientOptions);
return lc.start();
}
}

View File

@ -11,7 +11,7 @@ import {
LanguageClientOptions
} from 'vscode-languageclient'
export class LanguageClientProvider {
export class RemoteLanguageClientProvider {
constructor() {
}
@ -19,7 +19,7 @@ export class LanguageClientProvider {
let config = workspace.getConfiguration('openhab')
let connectionInfo = {
host: config.host,
port: config.lspPort
port: config.remoteLspPort
}
let extensions = [
@ -44,16 +44,15 @@ export class LanguageClientProvider {
}
let clientOptions: LanguageClientOptions = {
documentSelector: ['openhab'],
documentSelector: [{ scheme: "file", language: "openhab", pattern: `**/*.{${extensions.join(",")}}` }],
synchronize: {
configurationSection: 'openhabLSP',
fileEvents: workspace.createFileSystemWatcher('**/*.{' + extensions.join(',') + '}')
configurationSection: "openhab",
fileEvents: workspace.createFileSystemWatcher("**/.clientrc"),
}
}
if (config.useRestApi) {
let lc = new LanguageClient('openHABlsp', 'openHAB Server', serverOptions, clientOptions)
return lc.start()
}
// Create the language client and start the client.
let lc = new LanguageClient('openHABlsp', 'openHAB Server', serverOptions, clientOptions)
return lc.start()
}
}

View File

@ -22,7 +22,8 @@ import { ItemsProvider } from './ThingsExplorer/ItemsProvider'
import { ItemsCompletion } from './ItemsExplorer/ItemsCompletion'
import { RuleProvider } from './ItemsExplorer/RuleProvider'
import { SitemapPartialProvider } from './ItemsExplorer/SitemapPartialProvider'
import { LanguageClientProvider } from './LanguageClient/LanguageClientProvider'
import { LocalLanguageClientProvider } from './LanguageClient/LocalLanguageClientProvider'
import { RemoteLanguageClientProvider } from './LanguageClient/RemoteLanguageClientProvider'
import { Item } from './ItemsExplorer/Item'
import { Thing } from './ThingsExplorer/Thing'
import { Channel } from './ThingsExplorer/Channel'
@ -33,7 +34,7 @@ import * as path from 'path'
let _extensionPath: string;
async function init(disposables: Disposable[], config): Promise<void> {
async function init(disposables: Disposable[], config, context): Promise<void> {
disposables.push(commands.registerCommand('openhab.basicUI', () => {
let editor = window.activeTextEditor
@ -162,16 +163,15 @@ async function init(disposables: Disposable[], config): Promise<void> {
disposables.push(commands.registerCommand('openhab.command.things.copyUID', (query) =>
ncp.copy(query.UID || query.uid)))
if (config.restCompletions) {
disposables.push(languages.registerCompletionItemProvider('openhab', itemsCompletion))
}
}
if (config.lspEnabled) {
const languageClientProvider = new LanguageClientProvider()
disposables.push(languageClientProvider.connect())
if (config.remoteLspEnabled) {
const remoteLanguageClientProvider = new RemoteLanguageClientProvider()
disposables.push(remoteLanguageClientProvider.connect())
}
const localLanguageClientProvider = new LocalLanguageClientProvider()
disposables.push(localLanguageClientProvider.connect(context))
}
export function activate(context: ExtensionContext) {
@ -180,7 +180,7 @@ export function activate(context: ExtensionContext) {
let config = workspace.getConfiguration('openhab')
context.subscriptions.push(new Disposable(() => Disposable.from(...disposables).dispose()))
init(disposables, config)
init(disposables, config, context)
.catch(err => console.error(err));
}

14
client/tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"moduleResolution": "node",
"sourceMap": true,
"outDir": "out",
"rootDir": "src",
"lib": ["es6"],
"types":["node"]
},
"include": ["src"],
"exclude": ["node_modules", ".vscode-test"]
}

10
client/tslint.json Normal file
View File

@ -0,0 +1,10 @@
{
"rules": {
"no-unused-expression": true,
"no-duplicate-variable": true,
"curly": true,
"class-name": true,
"semicolon": ["never"],
"triple-equals": true
}
}

5867
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,7 @@
},
"license": "SEE LICENSE IN LICENSE",
"engines": {
"vscode": "^1.28.0"
"vscode": "^1.30.0"
},
"categories": [
"Programming Languages",
@ -33,7 +33,7 @@
"onCommand:openhab.command.things.addItems",
"onLanguage:openhab"
],
"main": "./out/src/extension",
"main": "./client/out/extension",
"contributes": {
"menus": {
"editor/title": [
@ -171,7 +171,7 @@
"openhab.useRestApi": {
"type": "boolean",
"default": true,
"description": "Connects to openHAB REST API if set to true. If not, Items tree view and code completions are disabled."
"description": "Connects to openHAB REST API if set to true. If not, Items tree view and things tree view are disabled."
},
"openhab.host": {
"type": [
@ -188,7 +188,7 @@
"default": 8080,
"description": "Specifies the port for the openHAB preview."
},
"openhab.lspPort": {
"openhab.remoteLspPort": {
"type": [
"number",
"null"
@ -196,19 +196,12 @@
"default": 5007,
"description": "Specifies the port where openHAB is running its Language Server."
},
"openhab.lspEnabled": {
"openhab.remoteLspEnabled": {
"type": [
"boolean"
],
"default": true,
"description": "Enables communication with Language Server Protocol - installed in openHAB as 'misc-lsp' add-on"
},
"openhab.restCompletions": {
"type": [
"boolean"
],
"default": true,
"description": "Activates item completions from REST API. (Disable this option, when you are facing performance problems on your openHAB environment.)"
"description": "Enables communication with Language Server of openHAB instance."
},
"openhab.username": {
"type": [
@ -329,29 +322,24 @@
]
},
"scripts": {
"vscode:prepublish": "tsc -p ./",
"compile": "tsc -watch -p ./",
"postinstall": "node ./node_modules/vscode/bin/install",
"test": "node ./node_modules/vscode/bin/test"
"compile": "tsc -b",
"watch": "tsc -b -w",
"postinstall": "cd client && npm install && cd ../serverJS && npm install && cd ..",
"test": "cd client && npm test && cd ../serverJS && npm test && cd ..",
"clean": "rm -rf node_modules && rm -rf **/node_modules && rm -rf **/out",
"pruneProduction": "npm prune --production && cd client && npm prune --production && cd ../serverJS && npm prune --production && cd ..",
"build": "npm run clean && npm install && npm run compile && npm run test && npm run pruneProduction",
"package": "npm run build && vsce package",
"publish": "npm run build && vsce publish"
},
"devDependencies": {
"@types/mocha": "^2.2.48",
"@types/node": "^6.14.2",
"mocha": "^2.3.3",
"typescript": "^2.9.2"
},
"dependencies": {
"@types/form-data": "^2.2.0",
"@types/form-data": "^2.2.1",
"@types/lodash": "^4.14.119",
"@types/node": "^8.10.0",
"@types/request": "^2.48.1",
"@types/request-promise-native": "^1.0.15",
"ascii-table": "0.0.9",
"copy-paste": "^1.3.0",
"lodash": "^4.17.4",
"request": "^2.83.0",
"jest": "^23.6.0",
"request-promise-native": "^1.0.5",
"underscore.string": "^3.3.5",
"vscode": "^1.1.26",
"vscode-languageclient": "^3.5.1"
"typescript": "^3.2.2"
}
}

View File

@ -0,0 +1,6 @@
/* eslint-env jest */
jest.mock('../../src/DocumentValidation/DocumentValidator', () => {
return {
validateTextDocument: jest.fn((str) => 'MockedDiagnosticsFor' + str)
}
})

View File

@ -0,0 +1,27 @@
/* eslint-env jest */
jest.mock('../../src/ItemCompletion/ItemCompletionProvider', () => {
const itemCompletionProvider = jest.fn(() => {
const instance = {
start: jest.fn(() => Promise.resolve()),
stop: jest.fn(),
completionItems: {},
restartIfConfigChanged: jest.fn(() => Promise.resolve())
}
// required to spy on getter
Object.defineProperty(instance, 'completionItems', {
get: function () {
// faked value to check if server returns correct value
return [{
label: 'Label',
kind: 0,
detail: 'Switch'
}]
}
})
return instance
})
return itemCompletionProvider
})

View File

@ -0,0 +1,8 @@
/* eslint-env jest */
jest.mock('../src/Server', () => {
return jest.fn(() => {
return {
start: jest.fn()
}
})
})

View File

@ -0,0 +1,51 @@
/* eslint-env jest */
const vscodeLanguageServer = jest.genMockFromModule('vscode-languageserver')
this.defaultConnection = {
onInitialize: jest.fn(),
onInitialized: jest.fn(),
onExit: jest.fn(),
onDidChangeConfiguration: jest.fn(),
onDidChangeWatchedFiles: jest.fn(),
onCompletion: jest.fn(),
listen: jest.fn(),
workspace: {
getConfiguration: jest.fn(() => {
return Promise.resolve({ mock: 'config' })
})
},
client: {
register: jest.fn()
},
sendDiagnostics: jest.fn()
}
this.defaultDocuments = {
onDidSave: jest.fn(),
onDidOpen: jest.fn(),
onDidClose: jest.fn(),
onDidChangeContent: jest.fn(),
listen: jest.fn(),
syncKind: 'mockValue'
}
vscodeLanguageServer.createConnection = jest.fn(() => this.connection || this.defaultConnection)
vscodeLanguageServer.TextDocuments = jest.fn(() => this.documents || this.defaultDocuments)
vscodeLanguageServer.__setConnection = connObj => {
this.connection = connObj
}
vscodeLanguageServer.__getConnection = () => {
return this.connection || this.defaultConnection
}
vscodeLanguageServer.__setDocuments = docsObj => {
this.documents = docsObj
}
vscodeLanguageServer.__getDocuments = () => {
return this.documents || this.defaultDocuments
}
module.exports = vscodeLanguageServer

View File

@ -0,0 +1,86 @@
/* eslint-env jest */
const ItemCompletionProvider = require('../../../src/ItemCompletion/ItemCompletionProvider')
// const request = require('request')
jest.setTimeout(20000)
describe('Integration tests for item completion', () => {
test('Create and start item completion', async () => {
const icp = new ItemCompletionProvider()
const err = await icp.start('demo.openhab.org', 8080)
expect(err).toBeUndefined()
expect(icp.isRunning).toBeTruthy()
expect(icp.items).toBeDefined()
expect(icp.items.size).toBeGreaterThan(0)
expect(icp.completionItems).toBeDefined()
expect(icp.completionItems.length).toBeGreaterThan(0)
// check one item if it looks like completion items from vscode
expect(icp.completionItems[0]).toEqual({ detail: expect.anything(String), documentation: expect.anything(String), kind: 6, label: expect.anything(String) })
icp.stop()
})
test('Create and start item completion with localhost where no service is available', async () => {
// eventsource timeout will fail test because default is 5s
const icp = new ItemCompletionProvider()
const err = await icp.start('localhost', 8080)
expect(err.message).toEqual('connect ECONNREFUSED 127.0.0.1:8080')
expect(icp.isRunning).toBeFalsy()
icp.stop()
})
test('Create and start item completion with wrong host', async () => {
// eventsource timeout will fail test because default is 5s
const icp = new ItemCompletionProvider()
const err = await icp.start(123, 8080)
expect(err.message).toEqual('getaddrinfo ENOTFOUND 123 123:8080')
expect(icp.isRunning).toBeFalsy()
icp.stop()
})
test('Create and start item completion with wrong port', async () => {
// eventsource timeout will fail test because default is 5s
const icp = new ItemCompletionProvider()
const err = await icp.start(123, 'abc')
// seems to fallback to 80 if port is NaN
expect(err.message).toEqual('getaddrinfo ENOTFOUND 123 123:80')
expect(icp.isRunning).toBeFalsy()
icp.stop()
})
test('Restart service', async () => {
const icp = new ItemCompletionProvider()
let err = await icp.start('demo.openhab.org', 8080)
expect(err).toBeUndefined()
expect(icp.items).toBeDefined()
expect(icp.items.size).toBeGreaterThan(0)
expect(icp.isRunning).toBeTruthy()
err = await icp.restartIfConfigChanged('localhost', 1234)
expect(err.message).toEqual('connect ECONNREFUSED 127.0.0.1:1234')
expect(icp.items).toBeDefined()
expect(icp.items.size).toBe(0)
expect(icp.isRunning).toBeFalsy()
err = await icp.restartIfConfigChanged('demo.openhab.org', 8080)
expect(err).toBeUndefined()
expect(icp.items).toBeDefined()
expect(icp.items.size).toBeGreaterThan(0)
expect(icp.isRunning).toBeTruthy()
icp.stop()
})
test('test events', async () => {
const icp = new ItemCompletionProvider()
let err = await icp.start('demo.openhab.org', 8080)
expect(err).toBeUndefined()
expect(icp.items).toBeDefined()
expect(icp.items.size).toBeGreaterThan(0)
expect(icp.isRunning).toBeTruthy()
// TODO send state changes, remove, update and create events to REST api and check if we get the results
icp.stop()
})
})

View File

@ -0,0 +1,11 @@
/* eslint-env jest */
const Server = require('../../src/Server')
describe('Integration tests for LSP server', () => {
test('Start LSP server', async () => {
const LSPServer = require('../../src/LSPServer')
expect(LSPServer.server).toBeDefined()
expect(LSPServer.server).toBeInstanceOf(Server)
})
})

View File

@ -0,0 +1,47 @@
/* eslint-env jest */
const Server = require('../../src/Server')
describe('Integration tests for server', () => {
test('Start server, initialize item completion and get completion items', async () => {
const server = new Server()
server.globalSettings = { host: 'demo.openhab.org', port: 8080 }
server.start()
const err = await server.initializeItemCompletionProvider()
expect(err).toBeUndefined()
const completions = server.getCompletion()
expect(completions).toBeDefined()
expect(completions.length).toBeGreaterThan(0)
server.exit()
})
test('Start server, fail initialize item completion and get completion items', async () => {
const server = new Server()
server.globalSettings = { host: 'localhost', port: 8080 }
server.start()
const err = await server.initializeItemCompletionProvider()
expect(err.message).toEqual('connect ECONNREFUSED 127.0.0.1:8080')
const completions = server.getCompletion()
expect(completions).toBeDefined()
expect(completions.length).toEqual(0)
server.exit()
})
test('Start server and validate documents', async () => {
const server = new Server()
server.globalSettings = { host: 'demo.openhab.org', port: 8080 }
server.start()
// as we do not have a client for testing here we mock the sendDiagnostics to check if we get the result
server.connection.sendDiagnostics = jest.fn()
server.validateDocument({ uri: 'testDocument.txt' })
// this test does not make so much sense yet, however when document validation is available we can test everything here
expect(server.connection.sendDiagnostics).toHaveBeenCalledTimes(1)
expect(server.connection.sendDiagnostics).toHaveBeenCalledWith({ diagnostics: [], uri: 'testDocument.txt' })
server.exit()
})
})

View File

@ -0,0 +1,8 @@
/* eslint-env jest */
const { validateTextDocument } = require('../../../src/DocumentValidation/DocumentValidator')
describe('Tests for validation', () => {
test('this does not do much yet', () => {
expect(validateTextDocument({ uri: 'test.txt' })).toEqual({ uri: 'test.txt', diagnostics: [] })
})
})

View File

@ -0,0 +1,56 @@
/* eslint-env jest */
const Item = require('../../../src/ItemCompletion/Item')
describe('Tests for Item', () => {
test('Create item and check if methods return expected values', () => {
const restItem = {
members: [],
link: 'http://demo.openhab.org:8080/rest/items/Weather_Chart',
state: 'NULL',
editable: false,
type: 'Group',
name: 'Weather_Chart',
tags: [],
groupNames: []
}
const item = new Item(restItem)
expect(item.item).toBe(restItem)
expect(item.name).toEqual('Weather_Chart')
expect(item.type).toEqual('Group')
expect(item.label).toBeUndefined()
expect(item.category).toBeUndefined()
expect(item.state).toEqual('')
expect(item.link).toEqual('http://demo.openhab.org:8080/rest/items/Weather_Chart')
expect(item.icon).toEqual('none.svg')
expect(item.isGroup).toBeTruthy()
expect(item.isRootItem).toBeTruthy()
expect(item.tags).toEqual([])
expect(item.groupNames).toEqual([])
expect(item.members).toEqual([])
})
test('Create item and check special cases', () => {
const restItem = {
members: [],
link: 'http://demo.openhab.org:8080/rest/items/Weather_Chart',
state: 'UNDEF',
editable: false,
type: 'Switch',
name: 'Weather_Chart',
tags: [],
groupNames: ['group5'],
category: 'window'
}
const item = new Item(restItem)
expect(item.item).toBe(restItem)
expect(item.state).toEqual('')
expect(item.icon).toEqual('window.svg')
expect(item.isRootItem).toBeFalsy()
expect(item.isGroup).toBeFalsy()
})
})

View File

@ -0,0 +1,528 @@
/* eslint-env jest */
require('../../../__mocks__/vscode-languageserver')
jest.mock('eventsource', () => {
const es = jest.fn(host => {
return {
addEventListener: jest.fn()
}
})
return es
})
jest.mock('request', () => {
const request = jest.fn((host, json, cb) => {
cb(this.error, undefined, this.items)
})
request.__setError = err => {
this.error = err
}
request.__setItems = items => {
this.items = items
}
return request
})
const ItemCompletionProvider = require('../../../src/ItemCompletion/ItemCompletionProvider')
const request = require('request')
const Item = require('../../../src/ItemCompletion/Item')
beforeEach(() => {
jest.clearAllMocks()
request.__setItems(undefined)
request.__setError(undefined)
})
describe('Tests for item completion', () => {
test('.getItemsFromRestApi where request has error', () => {
const completions = new ItemCompletionProvider()
request.__setError(new Error('Error'))
return completions
.getItemsFromRestApi('localhost', 1234)
.then(() => {
// Should never get here
expect(1).toBe(2)
})
.catch(error => {
expect(request).toHaveBeenCalledTimes(1)
expect(request).toHaveBeenCalledWith(
'http://localhost:1234/rest/items/',
{ json: true },
expect.any(Function)
)
expect(error).toEqual(new Error('Error'))
})
})
test('.getItemsFromRestApi where request does not get valid items', () => {
const completions = new ItemCompletionProvider()
// invalid response
request.__setItems({ items: false })
return completions
.getItemsFromRestApi('localhost', 1234)
.then(() => {
// should never get here
expect(1).toBe(2)
})
.catch(err => {
expect(request).toHaveBeenCalledTimes(1)
expect(request).toHaveBeenCalledWith(
'http://localhost:1234/rest/items/',
{ json: true },
expect.any(Function)
)
expect(err).toEqual(new Error('Could not get valid data from REST API'))
})
})
test('.getItemsFromRestApi where request gets empty items array', () => {
const completions = new ItemCompletionProvider()
completions.items = new Map()
// response with empty array (no items on openhab)
request.__setItems([])
return completions.getItemsFromRestApi('localhost', 1234).then(() => {
expect(request).toHaveBeenCalledTimes(1)
expect(request).toHaveBeenCalledWith(
'http://localhost:1234/rest/items/',
{ json: true },
expect.any(Function)
)
expect(completions.items.size).toBe(0)
})
})
test('.getItemsFromRestApi where request gets valid items array', () => {
const completions = new ItemCompletionProvider()
completions.items = new Map()
request.__setItems([
{
members: [],
link: 'http://demo.openhab.org:8080/rest/items/Weather_Chart',
state: 'NULL',
editable: false,
type: 'Group',
name: 'Weather_Chart',
tags: [],
groupNames: []
},
{
members: [],
link: 'http://demo.openhab.org:8080/rest/items/FF_Bathroom',
state: 'NULL',
editable: true,
type: 'Group',
name: 'FF_Bathroom',
label: 'Bathroom',
category: 'bath',
tags: ['Bathroom'],
groupNames: ['Home', 'FF']
},
{
members: [],
link: 'http://demo.openhab.org:8080/rest/items/Status',
state: 'NULL',
editable: false,
type: 'Group',
name: 'Status',
tags: [],
groupNames: []
}
])
return completions.getItemsFromRestApi('localhost', 1234).then(() => {
expect(request).toHaveBeenCalledTimes(1)
expect(request).toHaveBeenCalledWith(
'http://localhost:1234/rest/items/',
{ json: true },
expect.any(Function)
)
expect(completions.items.size).toBe(3)
}).catch(() => {
// should never get here
expect(1).toBe(2)
})
})
test('.stop with running eventsource', () => {
const completions = new ItemCompletionProvider()
completions.items = {}
completions.status = 'started'
completions.es = {
close: jest.fn()
}
completions.stop()
expect(completions.items).toBeUndefined()
expect(completions.status).toEqual('stopped')
expect(completions.es.close).toHaveBeenCalledTimes(1)
})
test('.stop without eventsource', () => {
const completions = new ItemCompletionProvider()
completions.items = {}
completions.status = 'started'
completions.stop()
expect(completions.items).toBeUndefined()
expect(completions.status).toEqual('stopped')
})
test('.restartIfConfigChanged', async () => {
const completion = new ItemCompletionProvider()
completion.host = 'localhost'
completion.port = 1234
completion.stop = jest.fn()
completion.start = jest.fn()
// nothing changes
let err = await completion.restartIfConfigChanged('localhost', 1234)
expect(err).toBeUndefined()
expect(completion.stop).toHaveBeenCalledTimes(0)
expect(completion.start).toHaveBeenCalledTimes(0)
// port changes
err = await completion.restartIfConfigChanged('localhost', 12345)
expect(err).toBeUndefined()
expect(completion.stop).toHaveBeenCalledTimes(1)
expect(completion.start).toHaveBeenCalledTimes(1)
expect(completion.start).toHaveBeenCalledWith('localhost', 12345)
// host changes
err = await completion.restartIfConfigChanged('localhost1', 1234)
expect(err).toBeUndefined()
expect(completion.stop).toHaveBeenCalledTimes(2) // this increments as we do not clear the mock inbetween
expect(completion.start).toHaveBeenCalledTimes(2)
expect(completion.start).toHaveBeenCalledWith('localhost1', 1234)
// both changes
err = await completion.restartIfConfigChanged('text', 0)
expect(err).toBeUndefined()
expect(completion.stop).toHaveBeenCalledTimes(3) // this increments as we do not clear the mock inbetween
expect(completion.start).toHaveBeenCalledTimes(3)
expect(completion.start).toHaveBeenCalledWith('text', 0)
})
test('.completionItems returns empty array if no items map is present', () => {
const completion = new ItemCompletionProvider()
expect(completion.completionItems).toEqual([])
})
test('.completionItems returns array if items are present', () => {
const completion = new ItemCompletionProvider()
completion.items = new Map()
completion.items.set(
'Weather_Chart',
new Item({
members: [],
link: 'http://demo.openhab.org:8080/rest/items/Weather_Chart',
state: 'NULL',
editable: false,
type: 'Group',
name: 'Weather_Chart',
tags: [],
groupNames: []
})
)
expect(completion.completionItems).toEqual([
{ detail: 'Group', documentation: '', kind: 6, label: 'Weather_Chart' }
])
})
test('.getDocumentation', () => {
const completion = new ItemCompletionProvider()
// no values available
expect(completion.getDocumentation({ tags: [], groupNames: [] })).toEqual(
''
)
// label only
expect(
completion.getDocumentation({ label: 'label', tags: [], groupNames: [] })
).toEqual('label')
// state only
expect(
completion.getDocumentation({ state: 'ON', tags: [], groupNames: [] })
).toEqual('(ON)')
// tag only
expect(
completion.getDocumentation({ tags: ['LIGHTING'], groupNames: [] })
).toEqual('Tags: LIGHTING')
// group only
expect(
completion.getDocumentation({ tags: [], groupNames: ['group'] })
).toEqual('Groups: group')
// mixed
expect(
completion.getDocumentation({
label: 'label',
state: 'ON',
tags: ['LIGHTING', 'SWITCH'],
groupNames: ['group', 'light']
})
).toEqual('label (ON)\nTags: LIGHTING, SWITCH\nGroups: group, light')
})
test('.isRunning', () => {
const completion = new ItemCompletionProvider()
completion.es = { CONNECTING: true }
completion.status = undefined
expect(completion.isRunning).toBeTruthy()
completion.es = { OPEN: true }
completion.status = undefined
expect(completion.isRunning).toBeTruthy()
completion.es = {}
completion.status = 'connecting'
expect(completion.isRunning).toBeTruthy()
completion.es = { CLOSED: true }
completion.status = undefined
expect(completion.isRunning).toBeFalsy()
completion.es = {}
completion.status = 'stopped'
expect(completion.isRunning).toBeFalsy()
})
test('.event', () => {
const completion = new ItemCompletionProvider()
completion.items = new Map()
// item state event
completion.event({
type: 'message',
data:
'{"topic":"smarthome/items/MC_Wohnzimmer_Volume/state","payload":"{\\"type\\":\\"Decimal\\",\\"value\\":\\"93\\"}","type":"ItemStateEvent"}',
lastEventId: '',
origin: 'http://openhabianpi.local:8080'
})
// if no item exists for item state event nothing should happen
expect(completion.items.size).toBe(0)
// item added
completion.event({
type: 'message',
data:
'{"topic":"smarthome/items/TestItem/added","payload":"{\\"type\\":\\"String\\",\\"name\\":\\"TestItem\\",\\"label\\":\\"lala\\",\\"tags\\":[],\\"groupNames\\":[]}","type":"ItemAddedEvent"}',
lastEventId: '',
origin: 'http://openhabianpi.local:8080'
})
expect(completion.items.size).toBe(1)
expect(completion.items.get('TestItem')).toEqual({
item: {
groupNames: [],
label: 'lala',
name: 'TestItem',
tags: [],
type: 'String'
}
})
// item updated
completion.event({
type: 'message',
data:
'{"topic":"smarthome/items/TestItem/updated","payload":"[{\\"type\\":\\"String\\",\\"name\\":\\"TestItem\\",\\"label\\":\\"l1ala\\",\\"tags\\":[],\\"groupNames\\":[]},{\\"type\\":\\"String\\",\\"name\\":\\"TestItem\\",\\"label\\":\\"lala\\",\\"tags\\":[],\\"groupNames\\":[]}]","type":"ItemUpdatedEvent"}',
lastEventId: '',
origin: 'http://openhabianpi.local:8080'
})
expect(completion.items.size).toBe(1)
expect(completion.items.get('TestItem')).toEqual({
item: {
groupNames: [],
label: 'l1ala',
name: 'TestItem',
tags: [],
type: 'String'
}
})
// item state event
completion.event({
type: 'message',
data:
'{"topic":"smarthome/items/TestItem/state","payload":"{\\"type\\":\\"String\\",\\"value\\":\\"BlaBla\\"}","type":"ItemStateEvent"}',
lastEventId: '',
origin: 'http://openhabianpi.local:8080'
})
// update item
expect(completion.items.size).toBe(1)
expect(completion.items.get('TestItem')).toEqual({
item: {
groupNames: [],
label: 'l1ala',
name: 'TestItem',
tags: [],
type: 'String',
state: 'BlaBla'
}
})
// item state changed event does not change data
const itembefore = completion.items.get('TestItem')
completion.event({
type: 'message',
data:
'{"topic":"smarthome/items/TestItem/statechanged","payload":"{\\"type\\":\\"String\\",\\"value\\":\\"lala\\",\\"oldType\\":\\"String\\",\\"oldValue\\":\\"blabla\\"}","type":"ItemStateChangedEvent"}',
lastEventId: '',
origin: 'http://openhabianpi.local:8080'
})
const itemAfter = completion.items.get('TestItem')
expect(itembefore).toEqual(itemAfter)
// item removed
completion.event({
type: 'message',
data:
'{"topic":"smarthome/items/TestItem/removed","payload":"{\\"type\\":\\"String\\",\\"name\\":\\"TestItem\\",\\"label\\":\\"lala\\",\\"tags\\":[],\\"groupNames\\":[]}","type":"ItemRemovedEvent"}',
lastEventId: '',
origin: 'http://openhabianpi.local:8080'
})
expect(completion.items.size).toBe(0)
// any other
// item state changed event
completion.event({
type: 'message',
data:
'{"topic":"smarthome/items/TestItem/statechanged","payload":"{\\"type\\":\\"String\\",\\"value\\":\\"lala\\",\\"oldType\\":\\"String\\",\\"oldValue\\":\\"blabla\\"}","type":"ItemStateChangedEvent"}',
lastEventId: '',
origin: 'http://openhabianpi.local:8080'
})
expect(completion.items.size).toBe(0)
})
test('.start() is sucessful', async () => {
const completion = new ItemCompletionProvider()
request.__setItems([
{
members: [],
link: 'http://demo.openhab.org:8080/rest/items/Weather_Chart',
state: 'NULL',
editable: false,
type: 'Group',
name: 'Weather_Chart',
tags: [],
groupNames: []
},
{
members: [],
link: 'http://demo.openhab.org:8080/rest/items/FF_Bathroom',
state: 'NULL',
editable: true,
type: 'Group',
name: 'FF_Bathroom',
label: 'Bathroom',
category: 'bath',
tags: ['Bathroom'],
groupNames: ['Home', 'FF']
},
{
members: [],
link: 'http://demo.openhab.org:8080/rest/items/Status',
state: 'NULL',
editable: false,
type: 'Group',
name: 'Status',
tags: [],
groupNames: []
}
])
const res = await completion.start('localhost', 1234)
expect(res).toBeUndefined()
expect(completion.status).toEqual('connecting')
expect(completion.es.addEventListener).toHaveBeenCalledTimes(1)
expect(completion.items.size).toBe(3)
})
test('.start() is sucessful, empty item array', async () => {
const completion = new ItemCompletionProvider()
request.__setItems([])
const res = await completion.start('localhost', 1234)
expect(res).toBeUndefined()
expect(completion.status).toEqual('connecting')
expect(completion.es.addEventListener).toHaveBeenCalledTimes(1)
expect(completion.items.size).toBe(0)
})
test('.start() is not sucessful, no valid item array', async () => {
const completion = new ItemCompletionProvider()
request.__setItems()
const res = await completion.start('localhost', 1234)
expect(res).toEqual(new Error('Could not get valid data from REST API'))
expect(completion.status).toEqual('stopped')
expect(completion.es).toBeUndefined()
expect(completion.items.size).toBe(0)
})
test('.start() is not sucessful, error in request', async () => {
const completion = new ItemCompletionProvider()
request.__setError(new Error('mocked error'))
const res = await completion.start('localhost', 1234)
expect(res).toEqual(new Error('mocked error'))
expect(completion.status).toEqual('stopped')
expect(completion.es).toBeUndefined()
expect(completion.items.size).toBe(0)
})
test('.event() is called on event', async () => {
const completion = new ItemCompletionProvider()
request.__setItems([
{
members: [],
link: 'http://demo.openhab.org:8080/rest/items/Weather_Chart',
state: 'NULL',
editable: false,
type: 'Group',
name: 'Weather_Chart',
tags: [],
groupNames: []
}])
completion.event = jest.fn()
const res = await completion.start('localhost', 1234)
expect(res).toBeUndefined()
const arg1 = completion.es.addEventListener.mock.calls[0][0]
const callback = completion.es.addEventListener.mock.calls[0][1]
expect(arg1).toEqual('message')
const event = {
type: 'message',
data:
'{"topic":"smarthome/items/TestItem/statechanged","payload":"{\\"type\\":\\"String\\",\\"value\\":\\"lala\\",\\"oldType\\":\\"String\\",\\"oldValue\\":\\"blabla\\"}","type":"ItemStateChangedEvent"}',
lastEventId: '',
origin: 'http://openhabianpi.local:8080'
}
callback(event)
expect(completion.event).toHaveBeenCalledWith(event)
})
})

View File

@ -0,0 +1,10 @@
/* eslint-env jest */
require('../../__mocks__/Server')
const lspServer = require('../../src/LSPServer')
describe('LSP Server tests', () => {
test('server is started', () => {
expect(lspServer.server.start).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,187 @@
/* eslint-env jest */
require('../../__mocks__/ItemCompletion/ItemCompletionProvider')
require('../../__mocks__/DocumentValidation/DocumentValidator')
const Server = require('../../src/Server')
const vscodeLanguageserver = require('vscode-languageserver')
const { validateTextDocument } = require('../../src/DocumentValidation/DocumentValidator')
beforeEach(() => {
jest.clearAllMocks()
})
describe('Server Tests', () => {
test('.start() initializes vscode language server', () => {
const server = new Server()
server.start()
expect(vscodeLanguageserver.createConnection).toHaveBeenCalledTimes(1)
// connection listeners are registered
const connection = vscodeLanguageserver.__getConnection()
expect(connection.onInitialize).toHaveBeenCalledTimes(1)
expect(connection.onInitialized).toHaveBeenCalledTimes(1)
expect(connection.onExit).toHaveBeenCalledTimes(1)
expect(connection.onDidChangeConfiguration).toHaveBeenCalledTimes(1)
// expect(connection.onDidChangeWatchedFiles).toHaveBeenCalledTimes(1)
expect(connection.onCompletion).toHaveBeenCalledTimes(1)
expect(connection.listen).toHaveBeenCalledTimes(1)
// text documents listeners are registered
const documents = vscodeLanguageserver.__getDocuments()
// expect(documents.onDidSave).toHaveBeenCalledTimes(1)
expect(documents.onDidOpen).toHaveBeenCalledTimes(1)
// expect(documents.onDidClose).toHaveBeenCalledTimes(1)
expect(documents.onDidChangeContent).toHaveBeenCalledTimes(1)
expect(documents.listen).toHaveBeenCalledTimes(1)
expect(documents.listen).toHaveBeenCalledWith(connection)
})
test('.initializeItemCompletionProvider initializes ItemCompletionProvider with params from settings', () => {
const server = new Server()
server.globalSettings = { host: 'localhost', port: 123 }
server.initializeItemCompletionProvider()
expect(server.itemsCompletionProvider.start).toHaveBeenCalledTimes(1)
expect(server.itemsCompletionProvider.start).toHaveBeenCalledWith('localhost', 123)
})
test('.getCompletion calls method from ItemCompletionProvider and returns value', () => {
const server = new Server()
server.start()
server.globalSettings = { host: 'localhost', port: 123 }
server.initializeItemCompletionProvider()
const completionItems = jest.spyOn(server.itemsCompletionProvider, 'completionItems', 'get')
const getCompletion = server.connection.onCompletion.mock.calls[0][0]
const completion = getCompletion()
expect(completionItems).toHaveBeenCalledTimes(1)
expect(completion).toEqual([{ detail: 'Switch', kind: 0, label: 'Label' }])
})
test('.exit should also close ItemCompletionProvider', () => {
const server = new Server()
server.start()
server.globalSettings = { host: 'localhost', port: 123 }
const exit = server.connection.onExit.mock.calls[0][0]
// without ItemCompletionProvider exit should work
exit()
expect(server.itemsCompletionProvider).toBeUndefined()
server.initializeItemCompletionProvider()
exit()
expect(server.itemsCompletionProvider.stop).toHaveBeenCalledTimes(1)
})
test('.getGlobalConfig gets config from client', async () => {
const server = new Server()
server.start()
const res = await server.getGlobalConfig()
expect(res).toEqual({ mock: 'config' })
})
test('.initialize returns capabilities object', () => {
const server = new Server()
server.start()
// get callback method that was passed in .start()
// this.connection.onInitialize(() => this.initialize(arguments))
// with the mock.calls[0][0] we get () => this.initialize(arguments)
// we need to pass this in an anonymous function to keep the 'this' context
const initialize = server.connection.onInitialize.mock.calls[0][0]
const res = initialize({ capablities: 'mockedCapability' })
expect(res).toEqual({
capabilities: { completionProvider: { resolveProvider: false }, textDocumentSync: 'mockValue' }
})
})
test('.initialized registers more listeners and gets settings and starts item completion listener', async () => {
const server = new Server()
server.start()
const initialized = server.connection.onInitialized.mock.calls[0][0]
await initialized()
expect(server.connection.client.register).toHaveBeenCalledTimes(1)
expect(server.itemsCompletionProvider.start).toHaveBeenCalledTimes(1)
expect(server.globalSettings).toEqual({ mock: 'config' })
})
test('.configurationChanged event with no fitting values are ignored', () => {
const server = new Server()
server.start()
server.globalSettings = { host: 'localhost', port: 123 }
const configurationChanged = server.connection.onDidChangeConfiguration.mock.calls[0][0]
configurationChanged({ host: 'remotehost', port: 456 })
expect(server.globalSettings).toEqual({ host: 'localhost', port: 123 })
configurationChanged({ settings: { host: 'remotehost', port: 456 } })
expect(server.globalSettings).toEqual({ host: 'localhost', port: 123 })
configurationChanged({ settings: { openhab: undefined } })
expect(server.globalSettings).toEqual({ host: 'localhost', port: 123 })
})
test('.configurationChanged called with changes will update config', () => {
const server = new Server()
server.start()
server.globalSettings = { host: 'localhost', port: 123 }
const configurationChanged = server.connection.onDidChangeConfiguration.mock.calls[0][0]
configurationChanged({ settings: { openhab: {} } })
expect(server.globalSettings).toEqual({})
configurationChanged({ settings: { openhab: { host: 'remotehost', port: 456 } } })
expect(server.globalSettings).toEqual({ host: 'remotehost', port: 456 })
})
test('.configurationChanged called with changes will update config', async () => {
const server = new Server()
server.start()
const initialized = server.connection.onInitialized.mock.calls[0][0]
await initialized()
server.globalSettings = { host: 'localhost', port: 123 }
server.configurationChanged({ settings: { openhab: { host: 'remotehost', port: 456 } } })
expect(server.globalSettings).toEqual({ host: 'remotehost', port: 456 })
expect(server.itemsCompletionProvider.restartIfConfigChanged).toHaveBeenCalledTimes(1)
expect(server.itemsCompletionProvider.restartIfConfigChanged).toHaveBeenCalledWith('remotehost', 456)
})
test('.documentOpened', () => {
const server = new Server()
server.start()
server.globalSettings = { settings: 'mocked' }
const documentOpened = server.documents.onDidOpen.mock.calls[0][0]
documentOpened({ document: 'MockedDocument' })
expect(validateTextDocument).toHaveBeenCalledTimes(1)
expect(validateTextDocument).toHaveBeenCalledWith('MockedDocument', { settings: 'mocked' })
expect(server.connection.sendDiagnostics).toHaveBeenCalledTimes(1)
expect(server.connection.sendDiagnostics).toHaveBeenCalledWith('MockedDiagnosticsForMockedDocument')
})
test('.documentChanged', () => {
const server = new Server()
server.start()
server.globalSettings = { settings: 'mocked' }
const documentChanged = server.documents.onDidChangeContent.mock.calls[0][0]
documentChanged({ document: 'MockedDocumentChanged' })
expect(validateTextDocument).toHaveBeenCalledTimes(1)
expect(validateTextDocument).toHaveBeenCalledWith('MockedDocumentChanged', { settings: 'mocked' })
expect(server.connection.sendDiagnostics).toHaveBeenCalledTimes(1)
expect(server.connection.sendDiagnostics).toHaveBeenCalledWith('MockedDiagnosticsForMockedDocumentChanged')
})
})

View File

@ -0,0 +1,19 @@
{
"coverageThreshold": {
"global": {
"branches": 50,
"functions": 50,
"lines": 50,
"statements": 50
}
},
"testEnvironment": "node",
"collectCoverageFrom": [
"src/**/*.js",
"!__mocks__/**",
"!__tests__/**"
],
"testMatch": [
"**/__tests__/integration/**/*.js"
]
}

19
serverJS/jest-unit.json Normal file
View File

@ -0,0 +1,19 @@
{
"coverageThreshold": {
"global": {
"branches": 90,
"functions": 100,
"lines": 90,
"statements": 90
}
},
"testEnvironment": "node",
"collectCoverageFrom": [
"src/**/*.js",
"!__mocks__/**",
"!__tests__/**"
],
"testMatch": [
"**/__tests__/unit/**/*.js"
]
}

0
serverJS/jest.config.js Normal file
View File

6380
serverJS/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
serverJS/package.json Normal file
View File

@ -0,0 +1,35 @@
{
"name": "openhab-vscode-languageserver",
"displayName": "openHAB VSCode Language Server for Viasual Studio Code",
"description": "Provides completions and validation of openhab files.",
"version": "0.1.0",
"publisher": "openhab",
"icon": "../openhab.png",
"repository": {
"type": "git",
"url": "https://github.com/openhab/openhab-vscode.git"
},
"dependencies": {
"vscode-languageserver": "^5.2.1",
"eventsource": "^1.0.7",
"lodash": "^4.17.11",
"request": "^2.88.0"
},
"devDependencies": {
"jest": "^23.6.0",
"standard": "^12.0.1"
},
"scripts": {
"test-unit": "jest --coverage --detectOpenHandles --config=jest-unit.json",
"test-integration": "jest --coverage --detectOpenHandles --config=jest-integration.json",
"test": "npm run test-unit && npm run test-integration"
},
"standard": {
"env": [
"jest"
],
"globals": [
"jest"
]
}
}

View File

@ -0,0 +1,18 @@
'use strict'
/**
* WIP this does not do anything yet
* @param textDocument
* @param settings
* @author Samuel Brucksch
*/
const validateTextDocument = (textDocument, settings) => {
// TODO think about reasonable validations
let diagnostics = []
// Send the computed diagnostics to VSCode.
return { uri: textDocument.uri, diagnostics }
}
module.exports = {
validateTextDocument
}

View File

@ -0,0 +1,111 @@
'use strict'
const _ = require('lodash')
class Item {
constructor (item) {
this.item = item
}
/**
* The Item name is the unique identified of the Item.
* The name should only consist of letters, numbers and the underscore character.
* Spaces and special characters cannot be used.
* e.g. 'Kitchen_Temperature'
*/
get name () {
return this.item.name
}
/**
* The Item type defines which kind of state can be stored in that Item and which commands can be sent to it.
* Each Item type has been optimized for certain components in your smart home.
* This optimization is reflected in the data types, and command types.
*
* Color|Contact|DateTime|Dimmer|Group|Number|Player|Rollershutter|String|Switch
*/
get type () {
return this.item.type
}
set type (v) {
this.item.type = v
}
/**
* The label text has two purposes.
* First, this text is used to display a description of the specific Item (for example, in the Sitemap).
* Secondly, the label also includes the value displaying definition for the Items state.
* e.g. "Kitchen thermometer"
*/
get label () {
return this.item.label
}
/**
* Item's category. Used for icons
*/
get category () {
return this.item.category
}
set state (v) {
this.item.state = v
}
/**
* The state part of the Item definition determines the Item value presentation,
* e.g., regarding formatting, decimal places, unit display and more.
* The state definition is part of the Item Label definition and contained inside square brackets.
* e.g. 'OFF' or '22'
*/
get state () {
const nullType = ['NULL', 'UNDEF']
return !_.includes(nullType, this.item.state) ? this.item.state : ''
}
/**
* Absolute path to the Item in the REST API
* e.g. 'http://home:8080/rest/items/Ground_Floor'
*/
get link () {
return this.item.link.toString()
}
/**
* Relative path to item's icon
* e.g. '/icon/kitchen?format=svg'
*/
get icon () {
let icon = this.item.category || 'none'
return icon + '.svg'
// return '/icon/' + icon + '?format=svg'
}
/**
* True if type of the item is equal to 'Group'.
*
* The Group is a special Item Type.
* It is used to define a category or collection in which you can nest/collect other Items or other Groups.
* Groups are supported in Sitemaps, Automation Rules and other areas of openHAB.
*/
get isGroup () {
return this.item.type === 'Group'
}
/**
* True if the item doesn't belong to any group
*/
get isRootItem () {
return this.item.groupNames && this.item.groupNames.length === 0
}
/**
* Tags are used by some I/O add-ons.
* Tags are only of interest if an add-on or integration README explicitly discusses their usage.
*/
get tags () {
return this.item.tags
}
/**
* Returns an Array of Groups the item belongs to.
*/
get groupNames () {
return this.item.groupNames
}
/**
* True if type of the item is equal to 'Group'
*/
get members () {
return this.isGroup && this.item.members
}
}
module.exports = Item

View File

@ -0,0 +1,175 @@
'use strict'
const vscodeLanguageserver = require('vscode-languageserver')
const Eventsource = require('eventsource')
const request = require('request')
const Item = require('./Item')
const _ = require('lodash')
/**
* Provides completion items from REST API from openhab instance.
*
* Currently only works with http.
* Example host: http://openhab:8080/rest/items
*
* Completion items are cached and updated with SSE
* http://openhab:8080/rest/events
*
* @author Samuel Brucksch
*
*/
class ItemCompletionProvider {
/**
* Start item completion
*
* @param host REST API host
* @param port Port to access REST API
*/
async start (host, port) {
this.items = new Map()
this.status = 'connecting'
return this.getItemsFromRestApi(host, port)
.then(() => {
if (this.status !== 'stopped') {
this.es = new Eventsource(
`http://${host}:${port}/rest/events?topics=smarthome/items`
)
this.es.addEventListener('message', (...params) =>
this.event(...params)
)
}
})
.catch(error => {
// TODO where to correctly log this?
console.log(error)
this.status = 'stopped'
this.es = undefined
return error
})
}
stop () {
if (this.es) {
this.es.close()
}
this.status = 'stopped'
this.items = undefined
}
event (eventPayload) {
const event = JSON.parse(eventPayload.data)
event.payload = JSON.parse(event.payload)
const itemName = event.topic.split('/')[2]
let item
switch (event.type) {
case 'ItemStateEvent':
// called when openhab reaceives an item state. There is also ItemStateChangedEvent, that only notifies you about changes
// however the ItemStateChangedEvent is more or less the same as the ItemStateEvent for the change so we do not need to read both
item = this.items.get(itemName)
if (item) {
// add new values to item
item.state = event.payload.value
item.type = event.payload.type
this.items.set(itemName, item)
}
break
case 'ItemUpdatedEvent':
// update events use an array with 2 elements: array[0] = new, array[1] = old
// we do not need to compare old and new name as renaming items causes a remove and added event
// all changes are already in array[0] so we can just overwrite the old item with the new one
item = new Item(event.payload[0])
this.items.set(item.name, item)
break
case 'ItemAddedEvent':
item = new Item(event.payload)
this.items.set(item.name, item)
break
case 'ItemRemovedEvent':
item = event.payload
this.items.delete(item.name)
break
default:
break
}
}
/**
* Restarts item completion if host/port changed
*
* @param host REST API host
* @param port Port to access REST API
*/
async restartIfConfigChanged (host, port) {
if (host !== this.host || port !== this.port) {
this.stop()
const err = await this.start(host, port)
return err
}
}
/**
* Returns an array of CompletionItems
*/
get completionItems () {
if (this.items) {
return Array.from(this.items.values()).map(item => {
return {
label: item.name,
kind: vscodeLanguageserver.CompletionItemKind.Variable,
detail: item.type,
documentation: this.getDocumentation(item)
}
})
}
// return empty erray if no map is available
return []
}
/**
* Indicates if ItemCompletionProvider is already running
*/
get isRunning () {
return (this.es && (this.es.CONNECTING || this.es.OPEN)) || this.status === 'connecting'
}
/**
* Generates a documentation string for the IntelliSense auto-completion
* Contains Item's label, state, tags and group names.
* @param item openHAB Item
*/
getDocumentation (item) {
const label = item.label ? item.label + ' ' : ''
const state = item.state ? '(' + item.state + ')' : ''
const tags = item.tags.length && 'Tags: ' + item.tags.join(', ')
const groupNames =
item.groupNames.length && 'Groups: ' + item.groupNames.join(', ')
const documentation = [(label + state).trim(), tags, groupNames]
return _.compact(documentation).join('\n')
}
getItemsFromRestApi (host, port) {
this.host = host
this.port = port
return new Promise((resolve, reject) => {
request(
`http://${host}:${port}/rest/items/`,
{ json: true },
(err, res, items) => {
if (err) {
reject(err)
return
}
if (Array.isArray(items)) {
items.map(item => {
this.items.set(item.name, new Item(item))
})
return resolve()
}
reject(new Error('Could not get valid data from REST API'))
}
)
})
}
}
module.exports = ItemCompletionProvider

11
serverJS/src/LSPServer.js Normal file
View File

@ -0,0 +1,11 @@
/**
* @author Samuel Brucksch
*/
const Server = require('./Server')
const server = new Server()
server.start()
module.exports = {
server
}

117
serverJS/src/Server.js Normal file
View File

@ -0,0 +1,117 @@
'use strict'
const {
TextDocuments,
createConnection,
ProposedFeatures,
DidChangeConfigurationNotification
} = require('vscode-languageserver')
const { validateTextDocument } = require('./DocumentValidation/DocumentValidator')
const ItemCompletionProvider = require('./ItemCompletion/ItemCompletionProvider')
/**
* Actual LSP server implementation. Client requires a script that starts the server so we can not give it a class diretly.
* @author Samuel Brucksch
*/
class Server {
start () {
this.connection = createConnection(ProposedFeatures.all)
// add handlers to connection
this.connection.onInitialize(this.initialize.bind(this))
this.connection.onInitialized(this.initialized.bind(this))
this.connection.onExit(this.exit.bind(this))
this.connection.onDidChangeConfiguration(this.configurationChanged.bind(this))
// documents handler
this.documents = new TextDocuments()
// add handlers to documents
this.documents.onDidOpen(this.documentOpened.bind(this))
this.documents.onDidChangeContent(this.documentChanged.bind(this))
// Attach LSP features
this.connection.onCompletion(this.getCompletion.bind(this))
// Make the text document manager listen on the connection
// for open, change and close text document events
this.documents.listen(this.connection)
// Listen on the connection
this.connection.listen()
}
getCompletion (textDocumentPosition) {
// TODO check if completion items can be proposed in right context -> new feature, currently items are proposed everywhere
return this.itemsCompletionProvider.completionItems
}
initialize (params) {
// const capabilities = params.capabilities
// TODO check if client supports capabilities? Might make no sense as we support vscode only currently, but will make sense when used in other lsp clients
return {
capabilities: {
textDocumentSync: this.documents.syncKind,
completionProvider: { resolveProvider: false }
}
}
}
async initialized () {
this.globalSettings = await this.getGlobalConfig()
// Register for all configuration changes.
this.connection.client.register(DidChangeConfigurationNotification.type, undefined)
await this.initializeItemCompletionProvider()
}
async initializeItemCompletionProvider () {
this.itemsCompletionProvider = new ItemCompletionProvider()
// TODO what todo here if it fails?
const err = await this.itemsCompletionProvider.start(this.globalSettings.host, this.globalSettings.port)
return err
}
exit () {
if (this.itemsCompletionProvider) {
this.itemsCompletionProvider.stop()
}
}
configurationChanged (change) {
// sometimes we get an empty settings object
if (!change.settings || !change.settings.openhab) {
return
}
this.globalSettings = change.settings.openhab
if (this.itemsCompletionProvider) {
this.itemsCompletionProvider.restartIfConfigChanged(this.globalSettings.host, this.globalSettings.port)
}
// Revalidate all open text documents - not needed right now but might make sense based on settings for validation
// this.documents.all().forEach(this.validateDocument)
}
documentOpened (event) {
this.validateDocument(event.document)
}
documentChanged (event) {
this.validateDocument(event.document)
}
validateDocument (document) {
const diagnostics = validateTextDocument(document, this.globalSettings)
this.connection.sendDiagnostics(diagnostics)
}
async getGlobalConfig () {
const result = this.connection.workspace.getConfiguration({
section: 'openhab'
})
return result
}
}
module.exports = Server

View File

@ -1,19 +1,12 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "es6",
"outDir": "out",
"lib": [
"es6"
],
"types":[
"node"
],
"sourceMap": true,
"rootDir": "."
},
"exclude": [
"node_modules",
".vscode-test"
]
"include": [
"src"
],
"exclude": [
"node_modules",
".vscode-test"
],
"references": [
{ "path": "./client" }
]
}