diff --git a/.vscode/settings.json b/.vscode/settings.json index cae69cc..68f44a4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,6 +3,9 @@ "typescript.tsc.autoDetect": "off", "typescript.preferences.quoteStyle": "single", "editor.codeActionsOnSave": { - "source.fixAll.eslint": true - } + "source.fixAll.eslint": "explicit" + }, + "cSpell.words": [ + "Karabel" + ] } \ No newline at end of file diff --git a/server/src/analyzer.ts b/server/src/analyzer.ts new file mode 100644 index 0000000..93315ae --- /dev/null +++ b/server/src/analyzer.ts @@ -0,0 +1,45 @@ +/* -------------------------------------------------------------------------------------------- + * Copyright (c) 2023 Andreas Heuermann, Osman Karabel + * Licensed under the MIT License. See License.txt in the project root for license information. + * ------------------------------------------------------------------------------------------ */ + +import * as LSP from 'vscode-languageserver/node'; +import { TextDocument } from 'vscode-languageserver-textdocument'; + +import { Query } from 'web-tree-sitter'; +import Parser = require('web-tree-sitter'); + +import { logger } from './util/logger'; + +type AnalyzedDocument = { + document: TextDocument; + tree: Parser.Tree +} + +export default class Analyzer { + private parser: Parser; + private uriToAnalyzedDocument: Record = {}; + + constructor (parser: Parser) { + this.parser = parser; + } + + public analyze(document: TextDocument): LSP.Diagnostic[] { + logger.debug('analyze:'); + + const diagnostics: LSP.Diagnostic[] = []; + const fileContent = document.getText(); + const uri = document.uri; + + const tree = this.parser.parse(fileContent); + logger.debug(tree.rootNode.toString()); + + // Update saved analysis for document uri + this.uriToAnalyzedDocument[uri] = { + document, + tree + }; + + return diagnostics; + } +} diff --git a/server/src/parser.ts b/server/src/parser.ts index abb09e5..26a0e0d 100644 --- a/server/src/parser.ts +++ b/server/src/parser.ts @@ -1,3 +1,8 @@ +/* -------------------------------------------------------------------------------------------- + * Copyright (c) 2023 Andreas Heuermann, Osman Karabel + * Licensed under the MIT License. See License.txt in the project root for license information. + * ------------------------------------------------------------------------------------------ */ + import * as Parser from 'web-tree-sitter'; /** diff --git a/server/src/server.ts b/server/src/server.ts index 14f38ad..86695f6 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -1,249 +1,121 @@ /* -------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. + * Copyright (c) 2023 Andreas Heuermann, Osman Karabel * Licensed under the MIT License. See License.txt in the project root for license information. * ------------------------------------------------------------------------------------------ */ -import { - createConnection, - TextDocuments, - Diagnostic, - DiagnosticSeverity, - ProposedFeatures, - InitializeParams, - DidChangeConfigurationNotification, - CompletionItem, - CompletionItemKind, - TextDocumentPositionParams, - TextDocumentSyncKind, - InitializeResult -} from 'vscode-languageserver/node'; - -import { - TextDocument -} from 'vscode-languageserver-textdocument'; -import { initializeParser } from './parser'; -import Parser = require('web-tree-sitter'); +import * as LSP from 'vscode-languageserver/node'; +import { TextDocument} from 'vscode-languageserver-textdocument'; -// Create a connection for the server, using Node's IPC as a transport. -// Also include all preview / proposed LSP features. -const connection = createConnection(ProposedFeatures.all); - -// Create a simple text document manager. -const documents: TextDocuments = new TextDocuments(TextDocument); -let parser: Parser; - -let hasConfigurationCapability = false; -let hasWorkspaceFolderCapability = false; -let hasDiagnosticRelatedInformationCapability = false; - -connection.onInitialize(async (params: InitializeParams) => { - // Initialize parser - connection.console.log('Modelica LSP: Initializing parser'); - parser = await initializeParser(); - connection.console.log('Modelica LSP: Initializing parser done'); - - const capabilities = params.capabilities; - - // Does the client support the `workspace/configuration` request? - // If not, we fall back using global settings. - hasConfigurationCapability = !!( - capabilities.workspace && !!capabilities.workspace.configuration - ); - hasWorkspaceFolderCapability = !!( - capabilities.workspace && !!capabilities.workspace.workspaceFolders - ); - hasDiagnosticRelatedInformationCapability = !!( - capabilities.textDocument && - capabilities.textDocument.publishDiagnostics && - capabilities.textDocument.publishDiagnostics.relatedInformation - ); - - const result: InitializeResult = { - capabilities: { - textDocumentSync: TextDocumentSyncKind.Incremental, - // Tell the client that this server supports code completion. - completionProvider: { - resolveProvider: true - } - } - }; - if (hasWorkspaceFolderCapability) { - result.capabilities.workspace = { - workspaceFolders: { - supported: true - } - }; +import { initializeParser } from './parser'; +import Analyzer from './analyzer'; +import { logger, setLogConnection, setLogLevel } from './util/logger'; + +/** + * ModelicaServer collection all the important bits and bobs. + */ +export class ModelicaServer { + analyzer: Analyzer; + private clientCapabilities: LSP.ClientCapabilities; + private connection: LSP.Connection; + private documents: LSP.TextDocuments = new LSP.TextDocuments(TextDocument); + + private constructor( + analyzer: Analyzer, + clientCapabilities: LSP.ClientCapabilities, + connection: LSP.Connection + ) { + this.analyzer = analyzer; + this.clientCapabilities = clientCapabilities; + this.connection = connection; } - return result; -}); -connection.onInitialized(() => { - if (hasConfigurationCapability) { - // Register for all configuration changes. - connection.client.register(DidChangeConfigurationNotification.type, undefined); - } - if (hasWorkspaceFolderCapability) { - connection.workspace.onDidChangeWorkspaceFolders(_event => { - connection.console.log('Workspace folder change event received.'); - }); - } -}); + public static async initialize( + connection: LSP.Connection, + { capabilities }: LSP.InitializeParams, + ): Promise { -// The example settings -interface ExampleSettings { - maxNumberOfProblems: number; -} + // Initialize logger + setLogConnection(connection); + setLogLevel('debug'); + logger.debug('Initializing...'); -// The global settings, used when the `workspace/configuration` request is not supported by the client. -// Please note that this is not the case when using this server with the client provided in this example -// but could happen with other clients. -const defaultSettings: ExampleSettings = { maxNumberOfProblems: 1000 }; -let globalSettings: ExampleSettings = defaultSettings; - -// Cache the settings of all open documents -const documentSettings: Map> = new Map(); - -connection.onDidChangeConfiguration(change => { - if (hasConfigurationCapability) { - // Reset all cached document settings - documentSettings.clear(); - } else { - globalSettings = ( - (change.settings.languageServerModelica || defaultSettings) - ); - } + const parser = await initializeParser(); + const analyzer = new Analyzer(parser); - // Revalidate all open text documents - documents.all().forEach(validateTextDocument); -}); + const server = new ModelicaServer(analyzer, capabilities, connection); -function getDocumentSettings(resource: string): Thenable { - if (!hasConfigurationCapability) { - return Promise.resolve(globalSettings); - } - let result = documentSettings.get(resource); - if (!result) { - result = connection.workspace.getConfiguration({ - scopeUri: resource, - section: 'languageServerModelica' - }); - documentSettings.set(resource, result); + logger.debug('Initialized'); + return server; } - return result; -} -// Only keep settings for open documents -documents.onDidClose(e => { - documentSettings.delete(e.document.uri); -}); - -documents.onDidOpen(({ document }) => { - const content = document.getText(); - const syntaxTree = parser.parse(content); - connection.console.log(syntaxTree.rootNode.toString()); -}); - -// The content of a text document has changed. This event is emitted -// when the text document first opened or when its content has changed. -documents.onDidChangeContent(change => { - const content = change.document.getText(); - const syntaxTree = parser.parse(content); - connection.console.log(syntaxTree.rootNode.toString()); - validateTextDocument(change.document); -}); - -async function validateTextDocument(textDocument: TextDocument): Promise { - // In this simple example we get the settings for every validate run. - const settings = await getDocumentSettings(textDocument.uri); - - // The validator creates diagnostics for all uppercase words length 2 and more - const text = textDocument.getText(); - const pattern = /\b[A-Z]{2,}\b/g; - let m: RegExpExecArray | null; - - let problems = 0; - const diagnostics: Diagnostic[] = []; - while ((m = pattern.exec(text)) && problems < settings.maxNumberOfProblems) { - problems++; - const diagnostic: Diagnostic = { - severity: DiagnosticSeverity.Warning, - range: { - start: textDocument.positionAt(m.index), - end: textDocument.positionAt(m.index + m[0].length) - }, - message: `${m[0]} is all uppercase.`, - source: 'ex' + /** + * Return what parts of the language server protocol are supported by ModelicaServer. + */ + public capabilities(): LSP.ServerCapabilities { + return { + textDocumentSync: LSP.TextDocumentSyncKind.Full, + completionProvider: undefined, + hoverProvider: false, + signatureHelpProvider: undefined, + documentSymbolProvider: false, + colorProvider: false, + semanticTokensProvider: undefined }; - if (hasDiagnosticRelatedInformationCapability) { - diagnostic.relatedInformation = [ - { - location: { - uri: textDocument.uri, - range: Object.assign({}, diagnostic.range) - }, - message: 'Spelling matters' - }, - { - location: { - uri: textDocument.uri, - range: Object.assign({}, diagnostic.range) - }, - message: 'Particularly for names' - } - ]; - } - diagnostics.push(diagnostic); } - // Send the computed diagnostics to VSCode. - connection.sendDiagnostics({ uri: textDocument.uri, diagnostics }); -} + public register(connection: LSP.Connection): void { + + let currentDocument: TextDocument | null = null; + let initialized = false; -connection.onDidChangeWatchedFiles(_change => { - // Monitored files have change in VSCode - connection.console.log('We received a file change event'); -}); - -// This handler provides the initial list of the completion items. -connection.onCompletion( - (_textDocumentPosition: TextDocumentPositionParams): CompletionItem[] => { - // The pass parameter contains the position of the text document in - // which code complete got requested. For the example we ignore this - // info and always provide the same completion items. - console.log("Autocomplete used"); - return [ - { - label: 'TypeScript', - kind: CompletionItemKind.Text, - data: 1 - }, - { - label: 'JavaScript', - kind: CompletionItemKind.Text, - data: 2 + // Make the text document manager listen on the connection + // for open, change and close text document events + this.documents.listen(this.connection); + + connection.onInitialized(async () => { + initialized = true; + if (currentDocument) { + // If we already have a document, analyze it now that we're initialized + // and the linter is ready. + this.analyzeDocument(currentDocument); } - ]; + }); + + // The content of a text document has changed. This event is emitted + // when the text document first opened or when its content has changed. + this.documents.onDidChangeContent(({ document }) => { + logger.debug('onDidChangeContent'); + + // We need to define some timing to wait some time or until whitespace is typed + // to update the tree or we are doing this on every key stroke + + currentDocument = document; + if (initialized) { + this.analyzeDocument(document); + } + }); } -); -// This handler resolves additional information for the item selected in -// the completion list. -connection.onCompletionResolve( - (item: CompletionItem): CompletionItem => { - if (item.data === 1) { - item.detail = 'TypeScript details'; - item.documentation = 'TypeScript documentation'; - } else if (item.data === 2) { - item.detail = 'JavaScript details'; - item.documentation = 'JavaScript documentation'; - } - return item; + + private async analyzeDocument(document: TextDocument) { + const diagnostics = this.analyzer.analyze(document); } -); -// Make the text document manager listen on the connection -// for open, change and close text document events -documents.listen(connection); +} + +// Create a connection for the server, using Node's IPC as a transport. +// Also include all preview / proposed LSP features. +const connection = LSP.createConnection(LSP.ProposedFeatures.all); + +connection.onInitialize( + async (params: LSP.InitializeParams): Promise => { + const server = await ModelicaServer.initialize(connection, params); + server.register(connection); + return { + capabilities: server.capabilities(), + }; + } +); // Listen on the connection connection.listen(); diff --git a/server/src/test/server.test.ts b/server/src/test/server.test.ts index edb9747..1e3d066 100644 --- a/server/src/test/server.test.ts +++ b/server/src/test/server.test.ts @@ -24,4 +24,4 @@ describe('Modelica tree-sitter parser', () => { const parsedString = tree.rootNode.toString(); assert.equal(parsedString, parsedModelicaTestString); }); -}); \ No newline at end of file +}); diff --git a/server/src/util/logger.ts b/server/src/util/logger.ts new file mode 100644 index 0000000..96ed80f --- /dev/null +++ b/server/src/util/logger.ts @@ -0,0 +1,134 @@ +/* -------------------------------------------------------------------------------------------- + * Copyright (c) 2018 Mads Hartmann, Andreas Heuermann + * Licensed under the MIT License. See License.txt in the project root for license information. + * Taken from bash-language-server and adapted to Modelica language server + * https://github.com/bash-lsp/bash-language-server/blob/main/server/src/util/logger.ts + * ------------------------------------------------------------------------------------------ */ + +import * as LSP from 'vscode-languageserver'; + +export const LOG_LEVEL_ENV_VAR = 'MODELICA_IDE_LOG_LEVEL'; +export const LOG_LEVELS = ['debug', 'log', 'info', 'warning', 'error'] as const; +export const DEFAULT_LOG_LEVEL: LogLevel = 'info'; + +type LogLevel = (typeof LOG_LEVELS)[number] + +const LOG_LEVELS_TO_MESSAGE_TYPES: { + [logLevel in LogLevel]: LSP.MessageType +} = { + debug: LSP.MessageType.Debug, + log: LSP.MessageType.Log, + info: LSP.MessageType.Info, + warning: LSP.MessageType.Warning, + error: LSP.MessageType.Error, +} as const; + +// Singleton madness to allow for logging from anywhere in the codebase +let _connection: LSP.Connection | null = null; +let _logLevel: LSP.MessageType = getLogLevelFromEnvironment(); + +/** + * Set the log connection. Should be done at startup. + */ +export function setLogConnection(connection: LSP.Connection) { + _connection = connection; +} + +/** + * Set the minimum log level. + */ +export function setLogLevel(logLevel: LogLevel) { + _logLevel = LOG_LEVELS_TO_MESSAGE_TYPES[logLevel]; +} + +export class Logger { + private prefix: string; + + constructor({ prefix = '' }: { prefix?: string } = {}) { + this.prefix = prefix; + } + + static MESSAGE_TYPE_TO_LOG_LEVEL_MSG: Record = { + [LSP.MessageType.Error]: 'ERROR ⛔️', + [LSP.MessageType.Warning]: 'WARNING ⛔️', + [LSP.MessageType.Info]: 'INFO', + [LSP.MessageType.Log]: 'LOG', + [LSP.MessageType.Debug]: 'DEBUG' + }; + + public log(severity: LSP.MessageType, messageObjects: any[]) { + if (_logLevel < severity) { + return; + } + + if (!_connection) { + // eslint-disable-next-line no-console + console.warn(`The logger's LSP Connection is not set. Dropping messages`); + return; + } + + const formattedMessage = messageObjects + .map((p) => { + if (p instanceof Error) { + return p.stack || p.message; + } + + if (typeof p === 'object') { + return JSON.stringify(p, null, 2); + } + + return p; + }) + .join(' '); + + const level = Logger.MESSAGE_TYPE_TO_LOG_LEVEL_MSG[severity]; + const prefix = this.prefix ? `${this.prefix} - ` : ''; + const time = new Date().toISOString().substring(11, 23); + const message = `${time} ${level} ${prefix}${formattedMessage}`; + + _connection.sendNotification(LSP.LogMessageNotification.type, { + type: severity, + message, + }); + } + + public debug(message: string, ...additionalArgs: any[]) { + this.log(LSP.MessageType.Debug, [message, ...additionalArgs]); + } + public info(message: string, ...additionalArgs: any[]) { + this.log(LSP.MessageType.Info, [message, ...additionalArgs]); + } + public warn(message: string, ...additionalArgs: any[]) { + this.log(LSP.MessageType.Warning, [message, ...additionalArgs]); + } + public error(message: string, ...additionalArgs: any[]) { + this.log(LSP.MessageType.Error, [message, ...additionalArgs]); + } +} + +/** + * Default logger. + */ +export const logger = new Logger(); + +/** + * Get the log level from the environment, before the server initializes. + * Should only be used internally. + */ +export function getLogLevelFromEnvironment(): LSP.MessageType { + const logLevelFromEnvironment = process.env[LOG_LEVEL_ENV_VAR] as LogLevel | undefined; + if (logLevelFromEnvironment) { + const logLevel = LOG_LEVELS_TO_MESSAGE_TYPES[logLevelFromEnvironment]; + if (logLevel) { + return logLevel; + } + // eslint-disable-next-line no-console + console.warn( + `Invalid ${LOG_LEVEL_ENV_VAR} "${logLevelFromEnvironment}", expected one of: ${Object.keys( + LOG_LEVELS_TO_MESSAGE_TYPES, + ).join(', ')}`, + ); + } + + return LOG_LEVELS_TO_MESSAGE_TYPES[DEFAULT_LOG_LEVEL]; +} \ No newline at end of file diff --git a/server/tsconfig.json b/server/tsconfig.json index 9172187..b3d83c8 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -13,7 +13,8 @@ "rootDir": "src" }, "include": [ - "src" + "src", + "src/util" ], "exclude": [ "node_modules",