diff --git a/.circleci/config.yml b/.circleci/config.yml index dce055375..bc4af158d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -198,6 +198,23 @@ defaults: - store_artifacts: *artifacts_test_results - gitter_notify_failure_unless_pr + - steps_test_lsp: &steps_test_lsp + steps: + - checkout + - attach_workspace: + at: build + - run: + name: Install dependencies + command: | + pip install --user git+https://github.com/christianparpart/pylspclient.git + pip install --user deepdiff pytest + - run: + name: LSP_test + command: ./test/libsolidity/lsp/solc-lsp-test.py + - store_test_results: *store_test_results + - store_artifacts: *artifacts_test_results + - gitter_notify_failure_unless_pr + - steps_soltest_all: &steps_soltest_all steps: - checkout @@ -458,7 +475,9 @@ jobs: command: apt -q update && apt install -y python3-pip - run: name: Install pylint - command: python3 -m pip install pylint z3-solver pygments-lexer-solidity parsec tabulate + command: | + python3 -m pip install pylint z3-solver pygments-lexer-solidity parsec tabulate + pip install --user git+https://github.com/christianparpart/pylspclient.git deepdiff pytest # also z3-solver, parsec and tabulate to make sure pylint knows about this module, pygments-lexer-solidity for docs - run: name: Linting Python Scripts @@ -810,6 +829,11 @@ jobs: parallelism: 6 <<: *steps_soltest_all + t_ubu_lsp: &t_ubu_lsp + <<: *base_ubuntu2004 + parallelism: 6 + <<: *steps_test_lsp + t_archlinux_soltest: &t_archlinux_soltest <<: *base_archlinux environment: @@ -1189,6 +1213,7 @@ workflows: - t_ubu_soltest_enforce_yul: *workflow_ubuntu2004 - b_ubu_clang: *workflow_trigger_on_tags - t_ubu_clang_soltest: *workflow_ubuntu2004_clang + - t_ubu_lsp: *workflow_ubuntu2004 # Ubuntu fake release build and tests - b_ubu_release: *workflow_trigger_on_tags diff --git a/CMakeLists.txt b/CMakeLists.txt index e3dc030c1..008c796d4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -138,4 +138,10 @@ endif() if (TESTS AND NOT EMSCRIPTEN) add_subdirectory(test) + + add_custom_target(lsptest + COMMAND ./test/libsolidity/lsp/solc-lsp-test.py + WORKING_DIRECTORY "${PROJECT_SOURCE_DIR}" + DEPENDS solc + ) endif() diff --git a/liblangutil/Exceptions.h b/liblangutil/Exceptions.h index 0c8f03cb9..dcd237dda 100644 --- a/liblangutil/Exceptions.h +++ b/liblangutil/Exceptions.h @@ -206,7 +206,7 @@ public: return nullptr; } - static Severity errorSeverity(Type _type) + static constexpr Severity errorSeverity(Type _type) { if (_type == Type::Info) return Severity::Info; diff --git a/libsolidity/CMakeLists.txt b/libsolidity/CMakeLists.txt index 7e3791eab..e8b16a1ea 100644 --- a/libsolidity/CMakeLists.txt +++ b/libsolidity/CMakeLists.txt @@ -155,6 +155,14 @@ set(sources interface/StorageLayout.h interface/Version.cpp interface/Version.h + lsp/LanguageServer.cpp + lsp/LanguageServer.h + lsp/ReferenceCollector.cpp + lsp/ReferenceCollector.h + lsp/SemanticTokensBuilder.cpp + lsp/SemanticTokensBuilder.h + lsp/Transport.cpp + lsp/Transport.h parsing/DocStringParser.cpp parsing/DocStringParser.h parsing/Parser.cpp diff --git a/libsolidity/interface/FileReader.cpp b/libsolidity/interface/FileReader.cpp index cfd1feb33..b023a863a 100644 --- a/libsolidity/interface/FileReader.cpp +++ b/libsolidity/interface/FileReader.cpp @@ -45,7 +45,7 @@ namespace solidity::frontend FileReader::FileReader( boost::filesystem::path _basePath, - vector const& _includePaths, + vector _includePaths, FileSystemPathSet _allowedDirectories ): m_allowedDirectories(std::move(_allowedDirectories)), @@ -172,7 +172,7 @@ ReadCallback::Result FileReader::readFile(string const& _kind, string const& _so } } -string FileReader::cliPathToSourceUnitName(boost::filesystem::path const& _cliPath) +string FileReader::cliPathToSourceUnitName(boost::filesystem::path const& _cliPath) const { vector prefixes = {m_basePath.empty() ? normalizeCLIPathForVFS(".") : m_basePath}; prefixes += m_includePaths; diff --git a/libsolidity/interface/FileReader.h b/libsolidity/interface/FileReader.h index 71f5819df..fb0ed3c42 100644 --- a/libsolidity/interface/FileReader.h +++ b/libsolidity/interface/FileReader.h @@ -48,7 +48,7 @@ public: /// that will be used when requesting files from this file reader instance. explicit FileReader( boost::filesystem::path _basePath = {}, - std::vector const& _includePaths = {}, + std::vector _includePaths = {}, FileSystemPathSet _allowedDirectories = {} ); @@ -94,7 +94,7 @@ public: /// Creates a source unit name by normalizing a path given on the command line and, if possible, /// making it relative to base path or one of the include directories. - std::string cliPathToSourceUnitName(boost::filesystem::path const& _cliPath); + std::string cliPathToSourceUnitName(boost::filesystem::path const& _cliPath) const; /// Checks if a set contains any paths that lead to different files but would receive identical /// source unit names. Files are considered the same if their paths are exactly the same after diff --git a/libsolidity/lsp/LSPTypes.h b/libsolidity/lsp/LSPTypes.h new file mode 100644 index 000000000..2712ce93f --- /dev/null +++ b/libsolidity/lsp/LSPTypes.h @@ -0,0 +1,70 @@ +/* + This file is part of solidity. + + solidity is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + solidity is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with solidity. If not, see . +*/ +// SPDX-License-Identifier: GPL-3.0 +#pragma once + +#include +#include // LineColumn + +#include +#include +#include +#include + +namespace solidity::lsp +{ + +struct LineColumnRange +{ + langutil::LineColumn start; + langutil::LineColumn end; +}; + +struct DocumentPosition +{ + /// Path in lsp client notation. + std::string path; + langutil::LineColumn position; +}; + +enum class DocumentHighlightKind +{ + Unspecified, + Text, //!< a textual occurrence + Read, //!< read access to a variable + Write, //!< write access to a variable +}; + +// Represents a symbol / AST node that is to be highlighted, with some context associated. +struct DocumentHighlight +{ + langutil::SourceLocation location; + // std::string sourceName; + // LineColumnRange range; + DocumentHighlightKind kind = DocumentHighlightKind::Unspecified; +}; + +/// Represents a related message and source code location for a diagnostic. This should be +/// used to point to code locations that cause or related to a diagnostics, e.g when duplicating +/// a symbol in a scope. +struct DiagnosticRelatedInformation +{ + langutil::SourceLocation location; // The location of this related diagnostic information. + std::string message; // The message of this related diagnostic information. +}; + +} diff --git a/libsolidity/lsp/LanguageServer.cpp b/libsolidity/lsp/LanguageServer.cpp new file mode 100644 index 000000000..7d2200295 --- /dev/null +++ b/libsolidity/lsp/LanguageServer.cpp @@ -0,0 +1,740 @@ +/* + This file is part of solidity. + + solidity is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + solidity is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with solidity. If not, see . +*/ +// SPDX-License-Identifier: GPL-3.0 +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include + +#include +#include + +#include + +#include +#include + +using namespace std; +using namespace std::placeholders; + +using namespace solidity::lsp; +using namespace solidity::langutil; +using namespace solidity::frontend; + +namespace +{ + +void log(string const& _message) +{ +#if 0 + static ofstream logFile("/tmp/solc.lsp.log", std::ios::app); + logFile << _message << endl; +#else + (void)_message; +#endif +} + +struct MarkdownBuilder +{ + std::stringstream result; + + MarkdownBuilder& code(std::string const& _code) + { + // TODO: Use solidity as language Id as soon as possible. + auto constexpr SolidityLanguageId = "javascript"; + result << "```" << SolidityLanguageId << '\n' << _code << "\n```\n\n"; + return *this; + } + + MarkdownBuilder& text(std::string const& _text) + { + if (_text.empty()) + { + result << _text << '\n'; + if (_text.back() != '\n') // We want double-LF to ensure constructing a paragraph. + result << '\n'; + } + return *this; + } +}; + +Json::Value toJson(LineColumn _pos) +{ + Json::Value json = Json::objectValue; + json["line"] = max(_pos.line, 0); + json["character"] = max(_pos.column, 0); + + return json; +} + +Json::Value toJsonRange(int _startLine, int _startColumn, int _endLine, int _endColumn) +{ + Json::Value json; + json["start"] = toJson({_startLine, _startColumn}); + json["end"] = toJson({_endLine, _endColumn}); + return json; +} + +constexpr int toDiagnosticSeverity(Error::Type _errorType) +{ + // 1=Error, 2=Warning, 3=Info, 4=Hint + switch (Error::errorSeverity(_errorType)) + { + case Error::Severity::Error: return 1; + case Error::Severity::Warning: return 2; + case Error::Severity::Info: return 3; + } + return 1; +} + +vector allAnnotatedDeclarations(Identifier const* _identifier) +{ + vector output; + output.push_back(_identifier->annotation().referencedDeclaration); + output += _identifier->annotation().candidateDeclarations; + return output; +} + +Json::Value semanticTokensLegend() +{ + Json::Value legend = Json::objectValue; + + // NOTE! The (alphabetical) order and items must match exactly the items of + // their respective enum class members. + + Json::Value tokenTypes = Json::arrayValue; + tokenTypes.append("class"); + tokenTypes.append("comment"); + tokenTypes.append("enum"); + tokenTypes.append("enumMember"); + tokenTypes.append("event"); + tokenTypes.append("function"); + tokenTypes.append("interface"); + tokenTypes.append("keyword"); + tokenTypes.append("macro"); + tokenTypes.append("method"); + tokenTypes.append("modifier"); + tokenTypes.append("number"); + tokenTypes.append("operator"); + tokenTypes.append("parameter"); + tokenTypes.append("property"); + tokenTypes.append("string"); + tokenTypes.append("struct"); + tokenTypes.append("type"); + tokenTypes.append("typeParameter"); + tokenTypes.append("variable"); + legend["tokenTypes"] = tokenTypes; + + Json::Value tokenModifiers = Json::arrayValue; + tokenModifiers.append("abstract"); + tokenModifiers.append("declaration"); + tokenModifiers.append("definition"); + tokenModifiers.append("deprecated"); + tokenModifiers.append("documentation"); + tokenModifiers.append("modification"); + tokenModifiers.append("readonly"); + legend["tokenModifiers"] = tokenModifiers; + + return legend; +} + +} + +LanguageServer::LanguageServer(unique_ptr _transport): + m_client{move(_transport)}, + m_handlers{ + {"$/cancelRequest", [](auto, auto) {/*nothing for now as we are synchronous */} }, + {"cancelRequest", [](auto, auto) {/*nothing for now as we are synchronous */} }, + {"exit", [this](auto, auto) { terminate(); }}, + {"initialize", bind(&LanguageServer::handleInitialize, this, _1, _2)}, + {"initialized", [](auto, auto) {} }, + {"shutdown", [this](auto, auto) { m_shutdownRequested = true; }}, + {"textDocument/definition", [this](auto _id, auto _args) { handleGotoDefinition(_id, _args); }}, + {"textDocument/didChange", bind(&LanguageServer::handleTextDocumentDidChange, this, _1, _2)}, + {"textDocument/didClose", [](auto, auto) {/*nothing for now*/}}, + {"textDocument/didOpen", bind(&LanguageServer::handleTextDocumentDidOpen, this, _1, _2)}, + {"textDocument/documentHighlight", bind(&LanguageServer::handleTextDocumentHighlight, this, _1, _2)}, + {"textDocument/hover", bind(&LanguageServer::handleTextDocumentHover, this, _1, _2)}, + {"textDocument/implementation", [this](auto _id, auto _args) { handleGotoDefinition(_id, _args); }}, + {"textDocument/references", bind(&LanguageServer::handleTextDocumentReferences, this, _1, _2)}, + {"textDocument/semanticTokens/full", bind(&LanguageServer::semanticTokensFull, this, _1, _2)}, + {"workspace/didChangeConfiguration", bind(&LanguageServer::handleWorkspaceDidChangeConfiguration, this, _1, _2)}, + }, + m_fileReader{"/"}, + m_compilerStack{bind(&FileReader::readFile, ref(m_fileReader), _1, _2)} + +{ +} + +DocumentPosition LanguageServer::extractDocumentPosition(Json::Value const& _json) const +{ + DocumentPosition dpos{}; + + dpos.path = _json["textDocument"]["uri"].asString(); + dpos.position.line = _json["position"]["line"].asInt(); + dpos.position.column = _json["position"]["character"].asInt(); + + return dpos; +} + +Json::Value LanguageServer::toRange(SourceLocation const& _location) const +{ + solAssert(_location.sourceName, ""); + CharStream const& stream = m_compilerStack.charStream(*_location.sourceName); + auto const [startLine, startColumn] = stream.translatePositionToLineColumn(_location.start); + auto const [endLine, endColumn] = stream.translatePositionToLineColumn(_location.end); + return toJsonRange(startLine, startColumn, endLine, endColumn); +} + +Json::Value LanguageServer::toJson(SourceLocation const& _location) const +{ + solAssert(_location.sourceName); + Json::Value item = Json::objectValue; + item["uri"] = m_fileMappings.at(*_location.sourceName); + item["range"] = toRange(_location); + return item; +} + +string LanguageServer::clientPathToSourceUnitName(string const& _path) const +{ + return m_fileReader.cliPathToSourceUnitName(_path); +} + +bool LanguageServer::clientPathSourceKnown(string const& _path) const +{ + return m_fileReader.sourceCodes().count(clientPathToSourceUnitName(_path)); +} + +void LanguageServer::changeConfiguration(Json::Value const& _settings) +{ + m_settingsObject = _settings; +} + +bool LanguageServer::compile(string const& _path) +{ + // TODO: optimize! do not recompile if nothing has changed (file(s) not flagged dirty). + + if (!clientPathSourceKnown(_path)) + return false; + + m_compilerStack.reset(false); + m_compilerStack.setSources(m_fileReader.sourceCodes()); + m_compilerStack.compile(CompilerStack::State::AnalysisPerformed); + + return true; +} + +void LanguageServer::compileSourceAndReport(string const& _path) +{ + compile(_path); + + Json::Value params; + params["uri"] = _path; + + params["diagnostics"] = Json::arrayValue; + for (shared_ptr const& error: m_compilerStack.errors()) + { + SourceReferenceExtractor::Message const message = SourceReferenceExtractor::extract(m_compilerStack, *error); + + Json::Value jsonDiag; + jsonDiag["source"] = "solc"; + jsonDiag["severity"] = toDiagnosticSeverity(error->type()); + jsonDiag["message"] = message.primary.message; + jsonDiag["range"] = toJsonRange( + message.primary.position.line, message.primary.startColumn, + message.primary.position.line, message.primary.endColumn + ); + if (message.errorId.has_value()) + jsonDiag["code"] = Json::UInt64{message.errorId.value().error}; + + for (SourceReference const& secondary: message.secondary) + { + Json::Value jsonRelated; + jsonRelated["message"] = secondary.message; + // TODO translate back? + jsonRelated["location"]["uri"] = secondary.sourceName; + jsonRelated["location"]["range"] = toJsonRange( + secondary.position.line, secondary.startColumn, + secondary.position.line, secondary.endColumn + ); + jsonDiag["relatedInformation"].append(jsonRelated); + } + + params["diagnostics"].append(jsonDiag); + } + + m_client->notify("textDocument/publishDiagnostics", params); +} + +ASTNode const* LanguageServer::requestASTNode(DocumentPosition _filePos) +{ + if (m_compilerStack.state() < CompilerStack::AnalysisPerformed) + return nullptr; + + if (!clientPathSourceKnown(_filePos.path)) + return nullptr; + + string sourceUnitName = clientPathToSourceUnitName(_filePos.path); + + optional sourcePos = m_compilerStack.charStream(sourceUnitName) + .translateLineColumnToPosition(_filePos.position.line, _filePos.position.column); + if (!sourcePos.has_value()) + return nullptr; + + return locateInnermostASTNode(*sourcePos, m_compilerStack.ast(sourceUnitName)); +} + +optional LanguageServer::declarationPosition(Declaration const* _declaration) +{ + if (!_declaration) + return nullopt; + + if (_declaration->nameLocation().isValid()) + return _declaration->nameLocation(); + + if (_declaration->location().isValid()) + return _declaration->location(); + + return nullopt; +} + +vector LanguageServer::findAllReferences( + Declaration const* _declaration, + string const& _sourceIdentifierName, + SourceUnit const& _sourceUnit +) +{ + vector output; + for (DocumentHighlight& highlight: ReferenceCollector::collect(_declaration, _sourceUnit, _sourceIdentifierName)) + output.emplace_back(move(highlight.location)); + return output; +} + +void LanguageServer::findAllReferences( + Declaration const* _declaration, + string const& _sourceIdentifierName, + SourceUnit const& _sourceUnit, + vector& _output +) +{ + for (DocumentHighlight& highlight: ReferenceCollector::collect(_declaration, _sourceUnit, _sourceIdentifierName)) + _output.emplace_back(move(highlight.location)); +} + +vector LanguageServer::references(DocumentPosition _documentPosition) +{ + ASTNode const* sourceNode = requestASTNode(_documentPosition); + if (!sourceNode) + return {}; + + SourceUnit const& sourceUnit = m_compilerStack.ast(clientPathToSourceUnitName(_documentPosition.path)); + vector output; + if (auto const* identifier = dynamic_cast(sourceNode)) + { + for (auto const* declaration: allAnnotatedDeclarations(identifier)) + output += findAllReferences(declaration, declaration->name(), sourceUnit); + } + else if (auto const* declaration = dynamic_cast(sourceNode)) + { + output += findAllReferences(declaration, declaration->name(), sourceUnit); + } + else if (auto const* memberAccess = dynamic_cast(sourceNode)) + { + if (Declaration const* decl = memberAccess->annotation().referencedDeclaration) + output += findAllReferences(decl, memberAccess->memberName(), sourceUnit); + } + return output; +} + +vector LanguageServer::semanticHighlight(ASTNode const* _sourceNode, string const& _path) +{ + ASTNode const* sourceNode = _sourceNode; // TODO + if (!sourceNode) + return {}; + + SourceUnit const& sourceUnit = m_compilerStack.ast(clientPathToSourceUnitName(_path)); + + vector output; + if (auto const* declaration = dynamic_cast(sourceNode)) + { + output += ReferenceCollector::collect(declaration, sourceUnit, declaration->name()); + } + else if (auto const* identifier = dynamic_cast(sourceNode)) + { + for (auto const* declaration: allAnnotatedDeclarations(identifier)) + output += ReferenceCollector::collect(declaration, sourceUnit, identifier->name()); + } + else if (auto const* identifierPath = dynamic_cast(sourceNode)) + { + solAssert(!identifierPath->path().empty(), ""); + output += ReferenceCollector::collect(identifierPath->annotation().referencedDeclaration, sourceUnit, identifierPath->path().back()); + } + else if (auto const* memberAccess = dynamic_cast(sourceNode)) + { + Type const* type = memberAccess->expression().annotation().type; + if (auto const* ttype = dynamic_cast(type)) + { + auto const memberName = memberAccess->memberName(); + + if (auto const* enumType = dynamic_cast(ttype->actualType())) + { + // find the definition + vector output; + for (ASTPointer const& enumMember: enumType->enumDefinition().members()) + if (enumMember->name() == memberName) + output += ReferenceCollector::collect(enumMember.get(), sourceUnit, enumMember->name()); + + // TODO: find uses of the enum value + } + } + else if (auto const* structType = dynamic_cast(type)) + { + (void) structType; // TODO + // TODO: highlight all struct member occurrences. + // memberAccess->memberName() + // structType-> + } + else + { + // TODO: EnumType, ... + //log("semanticHighlight: member type is: "s + (type ? typeid(*type).name() : "NULL")); + } + } + return output; +} + +bool LanguageServer::run() +{ + while (!m_exitRequested && !m_client->closed()) + { + optional const jsonMessage = m_client->receive(); + if (!jsonMessage) + continue; + + try + { + string const methodName = (*jsonMessage)["method"].asString(); + MessageID const id = (*jsonMessage)["id"]; + + if (auto handler = valueOrDefault(m_handlers, methodName)) + handler(id, (*jsonMessage)["params"]); + else + m_client->error(id, ErrorCode::MethodNotFound, "Unknown method " + methodName); + } + catch (exception const& e) + { + log("Unhandled exception caught when handling message. "s + e.what()); + } + } + return m_shutdownRequested; +} + +void LanguageServer::handleInitialize(MessageID _id, Json::Value const& _args) +{ + // The default of FileReader is to use `.`, but the path from where the LSP was started + // should not matter. + string rootPath("/"); + if (Json::Value uri = _args["rootUri"]) + rootPath = uri.asString(); + else if (Json::Value rootPath = _args["rootPath"]) + rootPath = rootPath.asString(); + + //log("root path: " + rootPath); + m_fileReader.setBasePath(boost::filesystem::path(rootPath)); + if (_args["initializationOptions"].isObject()) + changeConfiguration(_args["initializationOptions"]); + + Json::Value replyArgs; + replyArgs["serverInfo"]["name"] = "solc"; + replyArgs["serverInfo"]["version"] = string(VersionNumber); + replyArgs["hoverProvider"] = true; + replyArgs["capabilities"]["hoverProvider"] = true; + replyArgs["capabilities"]["textDocumentSync"]["openClose"] = true; + replyArgs["capabilities"]["textDocumentSync"]["change"] = 2; // 0=none, 1=full, 2=incremental + replyArgs["capabilities"]["definitionProvider"] = true; + replyArgs["capabilities"]["implementationProvider"] = true; + replyArgs["capabilities"]["documentHighlightProvider"] = true; + replyArgs["capabilities"]["referencesProvider"] = true; + replyArgs["capabilities"]["semanticTokensProvider"]["legend"] = semanticTokensLegend(); + replyArgs["capabilities"]["semanticTokensProvider"]["range"] = true; + replyArgs["capabilities"]["semanticTokensProvider"]["full"] = true; // XOR requests.full.delta = true + + m_client->reply(_id, replyArgs); +} + +void LanguageServer::handleWorkspaceDidChangeConfiguration(MessageID, Json::Value const& _args) +{ + if (_args["settings"].isObject()) + changeConfiguration(_args["settings"]); +} + +void LanguageServer::handleTextDocumentDidOpen(MessageID /*_id*/, Json::Value const& _args) +{ + if (!_args["textDocument"]) + return; + + auto const text = _args["textDocument"]["text"].asString(); + auto uri = _args["textDocument"]["uri"].asString(); + m_fileMappings[clientPathToSourceUnitName(uri)] = uri; + m_fileReader.setSource(uri, text); + compileSourceAndReport(uri); +} + +void LanguageServer::handleTextDocumentDidChange(MessageID /*_id*/, Json::Value const& _args) +{ + auto const uri = _args["textDocument"]["uri"].asString(); + auto const contentChanges = _args["contentChanges"]; + + for (Json::Value jsonContentChange: contentChanges) + { + if (!jsonContentChange.isObject()) // Protocol error, will only happen on broken clients, so silently ignore it. + continue; + + if (!clientPathSourceKnown(uri)) + // should be an error as well + continue; + + string text = jsonContentChange["text"].asString(); + if (!jsonContentChange["range"].isObject()) // full content update + { + m_fileReader.setSource(uri, move(text)); + continue; + } + + Json::Value const jsonRange = jsonContentChange["range"]; + // TODO could use a general helper to read line/characer json objects into int pairs or whateveer + int const startLine = jsonRange["start"]["line"].asInt(); + int const startColumn = jsonRange["start"]["character"].asInt(); + int const endLine = jsonRange["end"]["line"].asInt(); + int const endColumn = jsonRange["end"]["character"].asInt(); + + string buffer = m_fileReader.sourceCodes().at(clientPathToSourceUnitName(uri)); + optional const startOpt = CharStream::translateLineColumnToPosition(buffer, startLine, startColumn); + optional const endOpt = CharStream::translateLineColumnToPosition(buffer, endLine, endColumn); + if (!startOpt || !endOpt) + continue; + + size_t const start = static_cast(startOpt.value()); + size_t const count = static_cast(endOpt.value()) - start; // TODO: maybe off-by-1 bug? +1 missing? + buffer.replace(start, count, move(text)); + m_fileReader.setSource(uri, move(buffer)); + } + + if (!contentChanges.empty()) + compileSourceAndReport(uri); +} + +void LanguageServer::handleGotoDefinition(MessageID _id, Json::Value const& _args) +{ + ASTNode const* sourceNode = requestASTNode(extractDocumentPosition(_args)); + vector locations; + if (auto const* identifier = dynamic_cast(sourceNode)) + { + for (auto const* declaration: allAnnotatedDeclarations(identifier)) + if (auto location = declarationPosition(declaration); location.has_value()) + locations.emplace_back(move(location.value())); + } + else if (auto const* identifierPath = dynamic_cast(sourceNode)) + { + if (auto const* declaration = identifierPath->annotation().referencedDeclaration) + if (auto location = declarationPosition(declaration); location.has_value()) + locations.emplace_back(move(location.value())); + } + else if (auto const* memberAccess = dynamic_cast(sourceNode)) + { + auto const location = declarationPosition(memberAccess->annotation().referencedDeclaration); + if (location.has_value()) + locations.emplace_back(location.value()); + } + else if (auto const* importDirective = dynamic_cast(sourceNode)) + { + auto const& path = *importDirective->annotation().absolutePath; + if (m_fileReader.sourceCodes().count(path)) + locations.emplace_back(SourceLocation{0, 0, make_shared(path)}); + } + else if (auto const* declaration = dynamic_cast(sourceNode)) + { + if (auto location = declarationPosition(declaration); location.has_value()) + locations.emplace_back(move(location.value())); + } + else if (sourceNode) + { + log(fmt::format("Could not infer def of {}", typeid(*sourceNode).name())); + } + + Json::Value reply = Json::arrayValue; + for (SourceLocation const& location: locations) + reply.append(toJson(location)); + m_client->reply(_id, reply); +} + +string LanguageServer::symbolHoverInformation(ASTNode const* _sourceNode) +{ + MarkdownBuilder markdown{}; + + // Try getting the type definition of the underlying AST node, if available. + if (auto const* expression = dynamic_cast(_sourceNode)) + { + if (expression->annotation().type) + markdown.code(expression->annotation().type->toString(false)); + if (auto const* declaration = ASTNode::referencedDeclaration(*expression)) + if (declaration->type()) + markdown.code(declaration->type()->toString(false)); + } + else if (auto const* declaration = dynamic_cast(_sourceNode)) + { + if (declaration->type()) + markdown.code(declaration->type()->toString(false)); + } + else if (auto const* identifierPath = dynamic_cast(_sourceNode)) + { + Declaration const* decl = identifierPath->annotation().referencedDeclaration; + if (decl && decl->type()) + markdown.code(decl->type()->toString(false)); + if (auto const* node = dynamic_cast(decl)) + if (node->documentation()->text()) + markdown.text(*node->documentation()->text()); + } + else if (auto const* expression = dynamic_cast(_sourceNode)) + { + if (auto const* declaration = ASTNode::referencedDeclaration(*expression)) + if (declaration->type()) + markdown.code(declaration->type()->toString(false)); + } + else + { + markdown.text(fmt::format("Unhandled AST node type in hover: {}\n", typeid(*_sourceNode).name())); + log(fmt::format("Unhandled AST node type in hover: {}\n", typeid(*_sourceNode).name())); + } + + // If this AST node contains documentation itself, append it. + if (auto const* documented = dynamic_cast(_sourceNode)) + { + if (documented->documentation()) + markdown.text(*documented->documentation()->text()); + } + + return markdown.result.str(); +} + +void LanguageServer::handleTextDocumentHover(MessageID _id, Json::Value const& _args) +{ + auto const sourceNode = requestASTNode(extractDocumentPosition(_args)); + string tooltipText = symbolHoverInformation(sourceNode); + if (tooltipText.empty()) + { + m_client->reply(_id, Json::nullValue); + return; + } + + Json::Value reply = Json::objectValue; + reply["range"] = toRange(sourceNode->location()); + reply["contents"]["kind"] = "markdown"; + reply["contents"]["value"] = move(tooltipText); + m_client->reply(_id, reply); +} + +void LanguageServer::handleTextDocumentHighlight(MessageID _id, Json::Value const& _args) +{ + auto const dpos = extractDocumentPosition(_args); + ASTNode const* sourceNode = requestASTNode(dpos); + Json::Value jsonReply = Json::arrayValue; + for (DocumentHighlight const& highlight: semanticHighlight(sourceNode, dpos.path)) + { + Json::Value item = Json::objectValue; + item["range"] = toRange(highlight.location); + if (highlight.kind != DocumentHighlightKind::Unspecified) + item["kind"] = int(highlight.kind); + jsonReply.append(item); + } + m_client->reply(_id, jsonReply); +} + +void LanguageServer::handleTextDocumentReferences(MessageID _id, Json::Value const& _args) +{ + auto const dpos = extractDocumentPosition(_args); + + auto const sourceNode = requestASTNode(dpos); + if (!sourceNode) + { + Json::Value emptyResponse = Json::arrayValue; + m_client->reply(_id, emptyResponse); // reply with "No references". + return; + } + string sourceUnitName = clientPathToSourceUnitName(dpos.path); + SourceUnit const& sourceUnit = m_compilerStack.ast(sourceUnitName); + + auto output = vector{}; + if (auto const* identifier = dynamic_cast(sourceNode)) + { + for (auto const* declaration: allAnnotatedDeclarations(identifier)) + output += findAllReferences(declaration, declaration->name(), sourceUnit); + } + else if (auto const* identifierPath = dynamic_cast(sourceNode)) + { + if (auto decl = identifierPath->annotation().referencedDeclaration) + output += findAllReferences(decl, decl->name(), sourceUnit); + } + else if (auto const* memberAccess = dynamic_cast(sourceNode)) + { + output += findAllReferences(memberAccess->annotation().referencedDeclaration, memberAccess->memberName(), sourceUnit); + } + else if (auto const* declaration = dynamic_cast(sourceNode)) + { + output += findAllReferences(declaration, declaration->name(), sourceUnit); + } + + Json::Value jsonReply = Json::arrayValue; + for (SourceLocation const& location: output) + jsonReply.append(toJson(location)); + log("Sending reply"); + m_client->reply(_id, jsonReply); +} + +void LanguageServer::semanticTokensFull(MessageID _id, Json::Value const& _args) +{ + auto uri = _args["textDocument"]["uri"]; + + if (!compile(uri.as())) + return; + + auto const sourceName = clientPathToSourceUnitName(uri.as()); + SourceUnit const& ast = m_compilerStack.ast(sourceName); + m_compilerStack.charStream(sourceName); + SemanticTokensBuilder semanticTokensBuilder; + Json::Value data = semanticTokensBuilder.build(ast, m_compilerStack.charStream(sourceName)); + + Json::Value reply = Json::objectValue; + reply["data"] = data; + + m_client->reply(_id, reply); +} + +void LanguageServer::terminate() +{ + m_exitRequested = true; +} diff --git a/libsolidity/lsp/LanguageServer.h b/libsolidity/lsp/LanguageServer.h new file mode 100644 index 000000000..a496f87eb --- /dev/null +++ b/libsolidity/lsp/LanguageServer.h @@ -0,0 +1,164 @@ +/* + This file is part of solidity. + + solidity is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + solidity is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with solidity. If not, see . +*/ +// SPDX-License-Identifier: GPL-3.0 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace solidity::lsp +{ + +enum class ErrorCode; + +/// Solidity Language Server, managing one LSP client. +/// +/// This implements a subset of LSP version 3.16 that can be found at: +/// https://microsoft.github.io/language-server-protocol/specifications/specification-3-16/ +class LanguageServer +{ +public: + /// @param _transport Customizable transport layer. + explicit LanguageServer(std::unique_ptr _transport); + + /// Compiles the source behind path @p _file and updates the diagnostics pushed to the client. + /// + /// update diagnostics and also pushes any updates to the client. + void compileSourceAndReport(std::string const& _file); + + /// Loops over incoming messages via the transport layer until shutdown condition is met. + /// + /// The standard shutdown condition is when the maximum number of consecutive failures + /// has been exceeded. + /// + /// @return boolean indicating normal or abnormal termination. + bool run(); + + /// Initiates the main event loop to terminate as soon as possible. + void terminate(); + + Transport& transport() noexcept { return *m_client; } + +protected: + void handleInitialize(MessageID _id, Json::Value const& _args); + void handleWorkspaceDidChangeConfiguration(MessageID _id, Json::Value const& _args); + void handleTextDocumentDidOpen(MessageID _id, Json::Value const& _args); + void handleTextDocumentDidChange(MessageID _id, Json::Value const& _args); + void handleTextDocumentHover(MessageID _id, Json::Value const& _args); + void handleTextDocumentHighlight(MessageID _id, Json::Value const& _args); + void handleTextDocumentReferences(MessageID _id, Json::Value const& _args); + void handleGotoDefinition(MessageID _id, Json::Value const& _args); + void semanticTokensFull(MessageID _id, Json::Value const& _args); + + /** + * Constructs some tooltip (hover) text. + * + * The resulting text string should be in markdown format. + */ + std::string symbolHoverInformation(frontend::ASTNode const* _node); + + /// Invoked when the server user-supplied configuration changes (initiated by the client). + void changeConfiguration(Json::Value const&); + + /// Find all semantically equivalent occurrences of the symbol the current cursor is located at. + /// + /// @returns a list of ranges to highlight as well as their use kind (read fraom, written to, other text). + std::vector semanticHighlight(frontend::ASTNode const* _node, std::string const& _path); + + /// Finds all references of the current symbol at the given document position. + /// + /// @returns all references as document ranges as well as their use kind (read fraom, written to, other text). + std::vector references(DocumentPosition _documentPosition); + + /// Requests compilation of given client path. + /// @returns false if the file was not found. + bool compile(std::string const& _path); + + frontend::ASTNode const* requestASTNode(DocumentPosition _filePos); + + std::optional declarationPosition(frontend::Declaration const* _declaration); + + std::vector findAllReferences( + frontend::Declaration const* _declaration, + std::string const& _sourceIdentifierName, + frontend::SourceUnit const& _sourceUnit + ); + + void findAllReferences( + frontend::Declaration const* _declaration, + std::string const& _sourceIdentifierName, + frontend::SourceUnit const& _sourceUnit, + std::vector& _output + ); + + DocumentPosition extractDocumentPosition(Json::Value const& _json) const; + Json::Value toRange(langutil::SourceLocation const& _location) const; + Json::Value toJson(langutil::SourceLocation const& _location) const; + + /// Translates an LSP client path to the internal source unit name for the compiler. + std::string clientPathToSourceUnitName(std::string const& _path) const; + /// @returns true if we store the source for given the LSP client path. + bool clientPathSourceKnown(std::string const& _path) const; + + // LSP related member fields + using Handler = std::function; + using HandlerMap = std::unordered_map; + + std::unique_ptr m_client; + HandlerMap m_handlers; + bool m_shutdownRequested = false; + bool m_exitRequested = false; + + /// FileReader is used for reading files during comilation phase but is also used as VFS for the LSP. + frontend::FileReader m_fileReader; + /// Mapping from LSP client path to source unit name. + std::map m_fileMappings; + + frontend::CompilerStack m_compilerStack; + Json::Value m_settingsObject; +}; + +} // namespace solidity + diff --git a/libsolidity/lsp/ReferenceCollector.cpp b/libsolidity/lsp/ReferenceCollector.cpp new file mode 100644 index 000000000..99e4a7ecc --- /dev/null +++ b/libsolidity/lsp/ReferenceCollector.cpp @@ -0,0 +1,106 @@ +/* + This file is part of solidity. + + solidity is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + solidity is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with solidity. If not, see . +*/ +// SPDX-License-Identifier: GPL-3.0 +#include + +#include +#include + +using namespace solidity::frontend; +using namespace std::string_literals; +using namespace std; + +namespace solidity::lsp +{ + +ReferenceCollector::ReferenceCollector( + frontend::Declaration const& _declaration, + std::string const& _sourceIdentifierName +): + m_declaration{_declaration}, + m_sourceIdentifierName{_sourceIdentifierName.empty() ? _declaration.name() : _sourceIdentifierName} +{ +} + +std::vector ReferenceCollector::collect( + frontend::Declaration const* _declaration, + frontend::ASTNode const& _ast, + std::string const& _sourceIdentifierName +) +{ + if (!_declaration) + return {}; + + // TODO if vardecl, just use decl's scope (for lower overhead). + ReferenceCollector collector(*_declaration, _sourceIdentifierName); + _ast.accept(collector); + return move(collector.m_result); +} + +void ReferenceCollector::endVisit(frontend::ImportDirective const& _import) +{ + for (auto const& symbolAlias: _import.symbolAliases()) + if (m_sourceIdentifierName == *symbolAlias.alias) + { + m_result.emplace_back(DocumentHighlight{symbolAlias.location, DocumentHighlightKind::Text}); + break; + } +} + +bool ReferenceCollector::tryAddReference(frontend::Declaration const* _declaration, solidity::langutil::SourceLocation const& _location) +{ + if (&m_declaration != _declaration) + return false; + + m_result.emplace_back(DocumentHighlight{_location, DocumentHighlightKind::Text}); + return true; +} + +void ReferenceCollector::endVisit(frontend::Identifier const& _identifier) +{ + if (auto const* declaration = _identifier.annotation().referencedDeclaration) + tryAddReference(declaration, _identifier.location()); + + for (auto const* declaration: _identifier.annotation().candidateDeclarations + _identifier.annotation().overloadedDeclarations) + tryAddReference(declaration, _identifier.location()); +} + +void ReferenceCollector::endVisit(frontend::IdentifierPath const& _identifierPath) +{ + tryAddReference(_identifierPath.annotation().referencedDeclaration, _identifierPath.location()); +} + +void ReferenceCollector::endVisit(frontend::MemberAccess const& _memberAccess) +{ + if (_memberAccess.annotation().referencedDeclaration == &m_declaration) + m_result.emplace_back(DocumentHighlight{_memberAccess.location(), DocumentHighlightKind::Text}); +} + +bool ReferenceCollector::visitNode(frontend::ASTNode const& _node) +{ + if (&_node == &m_declaration) + { + if (auto const* declaration = dynamic_cast(&_node)) + m_result.emplace_back(DocumentHighlight{declaration->nameLocation(), DocumentHighlightKind::Text}); + else + m_result.emplace_back(DocumentHighlight{_node.location(), DocumentHighlightKind::Text}); + } + + return true; +} + +} diff --git a/libsolidity/lsp/ReferenceCollector.h b/libsolidity/lsp/ReferenceCollector.h new file mode 100644 index 000000000..aea3344d2 --- /dev/null +++ b/libsolidity/lsp/ReferenceCollector.h @@ -0,0 +1,53 @@ +/* + This file is part of solidity. + + solidity is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + solidity is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with solidity. If not, see . +*/ +// SPDX-License-Identifier: GPL-3.0 +#pragma once + +#include +#include +#include + +namespace solidity::lsp +{ + +class ReferenceCollector: public frontend::ASTConstVisitor +{ +public: + ReferenceCollector(frontend::Declaration const& _declaration, std::string const& _sourceIdentifierName); + + static std::vector collect( + frontend::Declaration const* _declaration, + frontend::ASTNode const& _ast, + std::string const& _sourceIdentifierName = {} + ); + + void endVisit(frontend::ImportDirective const& _import) override; + void endVisit(frontend::Identifier const& _identifier) override; + void endVisit(frontend::IdentifierPath const& _identifierPath) override; + void endVisit(frontend::MemberAccess const& _memberAccess) override; + bool visitNode(frontend::ASTNode const& _node) override; + +private: + bool tryAddReference(frontend::Declaration const* _declaration, solidity::langutil::SourceLocation const& _location); + +private: + frontend::Declaration const& m_declaration; + std::string const& m_sourceIdentifierName; + std::vector m_result; +}; + +} // end namespace diff --git a/libsolidity/lsp/SemanticTokensBuilder.cpp b/libsolidity/lsp/SemanticTokensBuilder.cpp new file mode 100644 index 000000000..1f1b6eaa7 --- /dev/null +++ b/libsolidity/lsp/SemanticTokensBuilder.cpp @@ -0,0 +1,289 @@ +/* + This file is part of solidity. + + solidity is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + solidity is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with solidity. If not, see . +*/ +// SPDX-License-Identifier: GPL-3.0 +#include + +#include +#include + +#include + +using namespace std; +using namespace solidity::langutil; +using namespace solidity::frontend; + +#if 0 // !defined(NDEBUG) +#define DPRINT(x) do { std::cerr << (x) << std::endl; } while (0) +#else +#define DPRINT(x) do {} while (false) +#endif + +namespace solidity::lsp +{ + +namespace +{ + +optional semanticTokenTypeForType(frontend::Type const* _type) +{ + if (!_type) + return nullopt; + + switch (_type->category()) + { + case frontend::Type::Category::Address: return SemanticTokenType::Class; + case frontend::Type::Category::Bool: return SemanticTokenType::Number; + case frontend::Type::Category::Enum: return SemanticTokenType::Enum; + case frontend::Type::Category::Function: return SemanticTokenType::Function; + case frontend::Type::Category::Integer: return SemanticTokenType::Number; + case frontend::Type::Category::RationalNumber: return SemanticTokenType::Number; + case frontend::Type::Category::StringLiteral: return SemanticTokenType::String; + case frontend::Type::Category::Struct: return SemanticTokenType::Struct; + case frontend::Type::Category::Contract: return SemanticTokenType::Class; + default: + DPRINT(fmt::format("semanticTokenTypeForType: unknown category: {}", static_cast(_type->category()))); + return nullopt; + } +} + +SemanticTokenType semanticTokenTypeForExpression(frontend::Type const* _type) +{ + if (!_type) + return SemanticTokenType::Variable; + + switch (_type->category()) + { + case frontend::Type::Category::Enum: + return SemanticTokenType::Enum; + default: + return SemanticTokenType::Variable; + } +} + +} // end namespace + +Json::Value SemanticTokensBuilder::build(SourceUnit const& _sourceUnit, CharStream const& _charStream) +{ + reset(&_charStream); + _sourceUnit.accept(*this); + return m_encodedTokens; +} + +void SemanticTokensBuilder::reset(CharStream const* _charStream) +{ + m_encodedTokens = Json::arrayValue; + m_charStream = _charStream; + m_lastLine = 0; + m_lastStartChar = 0; +} + +void SemanticTokensBuilder::encode( + SourceLocation const& _sourceLocation, + SemanticTokenType _tokenType, + SemanticTokenModifiers _modifiers +) +{ + /* + https://microsoft.github.io/language-server-protocol/specifications/specification-3-17/#textDocument_semanticTokens + + // Step-1: Absolute positions + { line: 2, startChar: 5, length: 3, tokenType: 0, tokenModifiers: 3 }, + { line: 2, startChar: 10, length: 4, tokenType: 1, tokenModifiers: 0 }, + { line: 5, startChar: 2, length: 7, tokenType: 2, tokenModifiers: 0 } + + // Step-2: Relative positions as intermediate step + { deltaLine: 2, deltaStartChar: 5, length: 3, tokenType: 0, tokenModifiers: 3 }, + { deltaLine: 0, deltaStartChar: 5, length: 4, tokenType: 1, tokenModifiers: 0 }, + { deltaLine: 3, deltaStartChar: 2, length: 7, tokenType: 2, tokenModifiers: 0 } + + // Step-3: final array result + // 1st token, 2nd token, 3rd token + [ 2,5,3,0,3, 0,5,4,1,0, 3,2,7,2,0 ] + + So traverse through the AST and assign each leaf a token 5-tuple. + */ + + // solAssert(_sourceLocation.isValid()); + if (!_sourceLocation.isValid()) + return; + + auto const [line, startChar] = m_charStream->translatePositionToLineColumn(_sourceLocation.start); + auto const length = _sourceLocation.end - _sourceLocation.start; + + DPRINT(fmt::format("encode [{}:{}..{}] {}", line, startChar, length, _tokenType)); + + m_encodedTokens.append(line - m_lastLine); + if (line == m_lastLine) + m_encodedTokens.append(startChar - m_lastStartChar); + else + m_encodedTokens.append(startChar); + m_encodedTokens.append(length); + m_encodedTokens.append(static_cast(_tokenType)); + m_encodedTokens.append(static_cast(_modifiers)); + + m_lastLine = line; + m_lastStartChar = startChar; +} + +bool SemanticTokensBuilder::visit(frontend::ContractDefinition const& _node) +{ + encode(_node.nameLocation(), SemanticTokenType::Class); + return true; +} + +bool SemanticTokensBuilder::visit(frontend::ElementaryTypeName const& _node) +{ + encode(_node.location(), SemanticTokenType::Type); + return true; +} + +bool SemanticTokensBuilder::visit(frontend::ElementaryTypeNameExpression const& _node) +{ + if (auto const tokenType = semanticTokenTypeForType(_node.annotation().type); tokenType.has_value()) + encode(_node.location(), tokenType.value()); + return true; +} + +bool SemanticTokensBuilder::visit(frontend::EnumDefinition const& _node) +{ + encode(_node.nameLocation(), SemanticTokenType::Enum); + return true; +} + +bool SemanticTokensBuilder::visit(frontend::EnumValue const& _node) +{ + encode(_node.nameLocation(), SemanticTokenType::EnumMember); + return true; +} + +bool SemanticTokensBuilder::visit(frontend::ErrorDefinition const& _node) +{ + encode(_node.nameLocation(), SemanticTokenType::Event); + return true; +} + +bool SemanticTokensBuilder::visit(frontend::FunctionDefinition const& _node) +{ + encode(_node.nameLocation(), SemanticTokenType::Function); + return true; +} + +void SemanticTokensBuilder::endVisit(frontend::Literal const& _literal) +{ + encode(_literal.location(), SemanticTokenType::Number); +} + +void SemanticTokensBuilder::endVisit(frontend::Identifier const& _identifier) +{ + DPRINT(fmt::format("Identifier: {}, {}..{} cat={}", _identifier.name(), _identifier.location().start, _identifier.location().end, _identifier.annotation().type->category())); + + SemanticTokenModifiers modifiers = SemanticTokenModifiers::None; + if (_identifier.annotation().isConstant.set() && *_identifier.annotation().isConstant) + { + DPRINT("OMG We've found a const!"); + modifiers = modifiers | SemanticTokenModifiers::Readonly; + } + + encode(_identifier.location(), semanticTokenTypeForExpression(_identifier.annotation().type), modifiers); +} + +void SemanticTokensBuilder::endVisit(frontend::IdentifierPath const& _node) +{ + DPRINT(fmt::format("IdentifierPath: identifier path [{}..{}]", _node.location().start, _node.location().end)); + for (size_t i = 0; i < _node.path().size(); ++i) + DPRINT(fmt::format(" [{}]: {}", i, _node.path().at(i))); + if (dynamic_cast(_node.annotation().referencedDeclaration)) + encode(_node.location(), SemanticTokenType::EnumMember); + else + encode(_node.location(), SemanticTokenType::Variable); +} + +bool SemanticTokensBuilder::visit(frontend::MemberAccess const& _node) +{ + DPRINT(fmt::format("[{}..{}] MemberAccess({}): {}", _node.location().start, _node.location().end, _node.annotation().referencedDeclaration ? _node.annotation().referencedDeclaration->name() : "?", _node.memberName())); + + auto const memberNameLength = static_cast(_node.memberName().size()); + auto const memberTokenType = semanticTokenTypeForExpression(_node.annotation().type); + + auto lhsLocation = _node.location(); + lhsLocation.end -= (memberNameLength + 1 /*exclude the dot*/); + + auto rhsLocation = _node.location(); + rhsLocation.start = rhsLocation.end - static_cast(memberNameLength); + + if (memberTokenType == SemanticTokenType::Enum) + { + // Special handling for enumeration symbols. + encode(lhsLocation, SemanticTokenType::Enum); + encode(rhsLocation, SemanticTokenType::EnumMember); + } + else if (memberTokenType == SemanticTokenType::Function) + { + // Special handling for function symbols. + encode(lhsLocation, SemanticTokenType::Variable); + encode(rhsLocation, memberTokenType); + } + else + { + encode(rhsLocation, memberTokenType); + } + + return false; // we handle LHS and RHS explicitly above. +} + +bool SemanticTokensBuilder::visit(frontend::ParameterList const& _node) +{ + (void) _node; + for (ASTPointer const& parameter: _node.parameters()) + { + // NOTE: Should only highlight the name but it seems that nameLocation() + // also contains the type name. + encode(parameter->nameLocation(), SemanticTokenType::Parameter); + } + return false; // do not descent into child nodes +} + +void SemanticTokensBuilder::endVisit(PragmaDirective const& _pragma) +{ + encode(_pragma.location(), SemanticTokenType::Macro); + // NOTE: It would be nice if we could highlight based on the symbols, + // such as (version) numerics be different than identifiers. +} + +bool SemanticTokensBuilder::visit(frontend::UserDefinedTypeName const& _node) +{ + if (auto const token = semanticTokenTypeForType(_node.annotation().type); token.has_value()) + encode(_node.location(), *token); + return false; +} + +bool SemanticTokensBuilder::visit(frontend::VariableDeclaration const& _node) +{ + DPRINT(fmt::format("VariableDeclaration: {}", _node.name())); + + if (auto const token = semanticTokenTypeForType(_node.typeName().annotation().type); token.has_value()) + encode(_node.typeName().location(), *token); + + encode(_node.nameLocation(), SemanticTokenType::Variable); + if (_node.overrides()) + _node.overrides()->accept(*this); + if (_node.value()) + _node.value()->accept(*this); + return false; +} + +} // end namespace diff --git a/libsolidity/lsp/SemanticTokensBuilder.h b/libsolidity/lsp/SemanticTokensBuilder.h new file mode 100644 index 000000000..f4fed1425 --- /dev/null +++ b/libsolidity/lsp/SemanticTokensBuilder.h @@ -0,0 +1,122 @@ +/* + This file is part of solidity. + + solidity is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + solidity is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with solidity. If not, see . +*/ +// SPDX-License-Identifier: GPL-3.0 +#include +#include +#include + +#include + +namespace solidity::langutil +{ +class CharStream; +struct SourceLocation; +} + +namespace solidity::lsp +{ + +// See: https://microsoft.github.io/language-server-protocol/specifications/specification-3-17/#semanticTokenTypes +enum class SemanticTokenType +{ + Class, + Comment, + Enum, + EnumMember, + Event, + Function, + Interface, + Keyword, + Macro, + Method, + Modifier, + Number, + Operator, + Parameter, + Property, + String, + Struct, + Type, + TypeParameter, + Variable, + + // Unused below: + // Namespace, + // Regexp, +}; + +enum class SemanticTokenModifiers +{ + None = 0, + + // Member integer values must be bit-values as + // they can be OR'd together. + Abstract = 0x0001, + Declaration = 0x0002, + Definition = 0x0004, + Deprecated = 0x0008, + Documentation = 0x0010, + Modification = 0x0020, + Readonly = 0x0040, + + // Unused below: + // Static, + // Async, + // DefaultLibrary, +}; + +constexpr SemanticTokenModifiers operator|(SemanticTokenModifiers a, SemanticTokenModifiers b) noexcept +{ + return static_cast(static_cast(a) | static_cast(b)); +} + +class SemanticTokensBuilder: public frontend::ASTConstVisitor +{ +public: + Json::Value build(frontend::SourceUnit const& _sourceUnit, langutil::CharStream const& _charStream); + + void reset(langutil::CharStream const* _charStream); + void encode( + langutil::SourceLocation const& _sourceLocation, + SemanticTokenType _tokenType, + SemanticTokenModifiers _modifiers = SemanticTokenModifiers::None + ); + + bool visit(frontend::ContractDefinition const&) override; + bool visit(frontend::ElementaryTypeName const&) override; + bool visit(frontend::ElementaryTypeNameExpression const&) override; + bool visit(frontend::EnumDefinition const&) override; + bool visit(frontend::EnumValue const&) override; + bool visit(frontend::ErrorDefinition const&) override; + bool visit(frontend::FunctionDefinition const&) override; + void endVisit(frontend::Literal const&) override; + void endVisit(frontend::Identifier const&) override; + void endVisit(frontend::IdentifierPath const&) override; + bool visit(frontend::MemberAccess const&) override; + bool visit(frontend::ParameterList const&) override; + void endVisit(frontend::PragmaDirective const&) override; + bool visit(frontend::UserDefinedTypeName const&) override; + bool visit(frontend::VariableDeclaration const&) override; + +private: + Json::Value m_encodedTokens; + langutil::CharStream const* m_charStream; + int m_lastLine; + int m_lastStartChar; +}; + +} // end namespace diff --git a/libsolidity/lsp/Transport.cpp b/libsolidity/lsp/Transport.cpp new file mode 100644 index 000000000..f3eb6e81d --- /dev/null +++ b/libsolidity/lsp/Transport.cpp @@ -0,0 +1,138 @@ +/* + This file is part of solidity. + + solidity is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + solidity is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with solidity. If not, see . +*/ +// SPDX-License-Identifier: GPL-3.0 +#include + +#include +#include + +#include + +#include +#include + +using namespace std; +using namespace solidity::lsp; + +JSONTransport::JSONTransport(istream& _in, ostream& _out): + m_input{_in}, + m_output{_out} +{ +} + +JSONTransport::JSONTransport(): JSONTransport(cin, cout) +{ +} + +bool JSONTransport::closed() const noexcept +{ + return m_input.eof(); +} + +optional JSONTransport::receive() +{ + auto const headers = parseHeaders(); + if (!headers) + return nullopt; + + if (!headers->count("content-length")) + return nullopt; + + string const data = readBytes(stoi(headers->at("content-length"))); + + Json::Value jsonMessage; + string errs; + solidity::util::jsonParseStrict(data, jsonMessage, &errs); + if (!errs.empty()) + return nullopt; // JsonParseError + + //traceMessage(jsonMessage, "Request"); + + return {jsonMessage}; +} + +void JSONTransport::notify(string const& _method, Json::Value const& _message) +{ + Json::Value json; + json["method"] = _method; + json["params"] = _message; + send(json); +} + +void JSONTransport::reply(MessageID _id, Json::Value const& _message) +{ + Json::Value json; + json["result"] = _message; + send(json, _id); +} + +void JSONTransport::error(MessageID _id, ErrorCode _code, string const& _message) +{ + Json::Value json; + json["error"]["code"] = static_cast(_code); + json["error"]["message"] = _message; + send(json, _id); +} + +void JSONTransport::send(Json::Value _json, MessageID _id) +{ + _json["jsonrpc"] = "2.0"; + if (!_id.isNull()) + _json["id"] = _id; + + string const jsonString = solidity::util::jsonCompactPrint(_json); + + m_output << "Content-Length: " << jsonString.size() << "\r\n"; + m_output << "\r\n"; + m_output << jsonString; + + m_output.flush(); + //traceMessage(_json, "Response"); +} + +optional> JSONTransport::parseHeaders() +{ + map headers; + + while (true) + { + string line; + getline(m_input, line); + if (boost::trim_copy(line).empty()) + break; + + auto const delimiterPos = line.find(':'); + if (delimiterPos == string::npos) + return nullopt; + + auto const name = boost::to_lower_copy(line.substr(0, delimiterPos)); + auto const value = boost::trim_copy(line.substr(delimiterPos + 1)); + headers[move(name)] = value; + } + return {move(headers)}; +} + +string JSONTransport::readBytes(int _n) +{ + if (_n < 0) + return {}; + + string data; + data.resize(static_cast(_n)); + m_input.read(data.data(), _n); + return data; +} diff --git a/libsolidity/lsp/Transport.h b/libsolidity/lsp/Transport.h new file mode 100644 index 000000000..ba3daf4df --- /dev/null +++ b/libsolidity/lsp/Transport.h @@ -0,0 +1,105 @@ +/* + This file is part of solidity. + + solidity is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + solidity is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with solidity. If not, see . +*/ +// SPDX-License-Identifier: GPL-3.0 +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace solidity::lsp +{ + +using MessageID = Json::Value; + +enum class ErrorCode +{ + // Defined by JSON RPC + ParseError = -32700, + InvalidRequest = -32600, + MethodNotFound = -32601, + InvalidParams = -32602, + InternalError = -32603, + serverErrorStart = -32099, + serverErrorEnd = -32000, + ServerNotInitialized = -32002, + UnknownErrorCode = -32001, + + // Defined by the protocol. + RequestCancelled = -32800, + ContentModified = -32801, +}; + +/// Transport layer API +/// +/// The transport layer API is abstracted so it users become more testable as well as +/// this way it could be possible to support other transports (HTTP for example) easily. +class Transport +{ +public: + virtual ~Transport() = default; + + virtual bool closed() const noexcept = 0; + virtual std::optional receive() = 0; + virtual void notify(std::string const& _method, Json::Value const& _params) = 0; + virtual void reply(MessageID _id, Json::Value const& _result) = 0; + virtual void error(MessageID _id, ErrorCode _code, std::string const& _message) = 0; +}; + +/// LSP Transport using JSON-RPC over iostreams. +class JSONTransport : public Transport +{ +public: + /// Constructs a standard stream transport layer. + /// + /// @param _in for example std::cin (stdin) + /// @param _out for example std::cout (stdout) + JSONTransport(std::istream& _in, std::ostream& _out); + + // Constructs a JSON transport using standard I/O streams. + JSONTransport(); + + bool closed() const noexcept override; + std::optional receive() override; + void notify(std::string const& _method, Json::Value const& _params) override; + void reply(MessageID _id, Json::Value const& _result) override; + void error(MessageID _id, ErrorCode _code, std::string const& _message) override; + +protected: + /// Reads given number of bytes from the client. + virtual std::string readBytes(int _n); + + /// Sends an arbitrary raw message to the client. + /// + /// Used by the notify/reply/error function family. + virtual void send(Json::Value _message, MessageID _id = Json::nullValue); + + /// Parses header section from the client including message-delimiting empty line. + std::optional> parseHeaders(); + +private: + std::istream& m_input; + std::ostream& m_output; +}; + +} // end namespace diff --git a/scripts/test_solidity_lsp.py b/scripts/test_solidity_lsp.py new file mode 100755 index 000000000..0667be4f1 --- /dev/null +++ b/scripts/test_solidity_lsp.py @@ -0,0 +1,410 @@ +#!/usr/bin/env python3.9 + +# DEPENDENCIES: +# pip install git+https://github.com/christianparpart/pylspclient.git --user +# WHEN DEVELOPING (editable module): +# pip install -e /path/to/checkout/pylspclient + +import argparse +import os +import subprocess +import threading + +from pprint import pprint +from threading import Condition +from deepdiff import DeepDiff + +# Requires the one from https://github.com/christianparpart/pylspclient +# Use `pip install -e $PATH_TO_LIB_CHECKOUT --user` for local development & testing +import pylspclient + +lsp_types = pylspclient.lsp_structs + +SGR_RESET = '\033[m' +SGR_TEST_BEGIN = '\033[1;33m' +SGR_STATUS_OKAY = '\033[1;32m' +SGR_STATUS_FAIL = '\033[1;31m' +SGR_INSPECT = '\033[1;35m' + +TEST_NAME = 'test_definition' + +def dprint(text: str): + print(SGR_INSPECT + "-- " + text + ":" + SGR_RESET) + +def dinspect(text, obj): + dprint(text) + if not obj is None: + pprint(obj) + +class ReadPipe(threading.Thread): + """ + Used to link (solc) process stdio. + """ + def __init__(self, pipe): + threading.Thread.__init__(self) + self.pipe = pipe + + def run(self): + dprint("ReadPipe: starting") + line = self.pipe.readline().decode('utf-8') + while line: + print(line) + #print("\033[1;42m{}\033[m\n".format(line)) + line = self.pipe.readline().decode('utf-8') + +SOLIDITY_LANGUAGE_ID = "solidity" # lsp_types.LANGUAGE_IDENTIFIER.C + +LSP_CLIENT_CAPS = { + 'textDocument': {'codeAction': {'dynamicRegistration': True}, + 'codeLens': {'dynamicRegistration': True}, + 'colorProvider': {'dynamicRegistration': True}, + 'completion': {'completionItem': {'commitCharactersSupport': True, + 'documentationFormat': ['markdown', 'plaintext'], + 'snippetSupport': True}, + 'completionItemKind': {'valueSet': [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, + 17, 18, 19, 20, 21, 22, 23, 24, 25 + ]}, + 'contextSupport': True, + 'dynamicRegistration': True}, + 'definition': {'dynamicRegistration': True}, + 'documentHighlight': {'dynamicRegistration': True}, + 'documentLink': {'dynamicRegistration': True}, + 'documentSymbol': { + 'dynamicRegistration': True, + 'symbolKind': {'valueSet': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, + 17, 18, 19, 20, 21, 22, 23, 24, 25, 26 + ]} + }, + 'formatting': {'dynamicRegistration': True}, + 'hover': {'contentFormat': ['markdown', 'plaintext'], + 'dynamicRegistration': True}, + 'implementation': {'dynamicRegistration': True}, + 'onTypeFormatting': {'dynamicRegistration': True}, + 'publishDiagnostics': {'relatedInformation': True}, + 'rangeFormatting': {'dynamicRegistration': True}, + 'references': {'dynamicRegistration': True}, + 'rename': {'dynamicRegistration': True}, + 'signatureHelp': {'dynamicRegistration': True, + 'signatureInformation': {'documentationFormat': ['markdown', 'plaintext']}}, + 'synchronization': {'didSave': True, + 'dynamicRegistration': True, + 'willSave': True, + 'willSaveWaitUntil': True}, + 'typeDefinition': {'dynamicRegistration': True}}, + 'workspace': {'applyEdit': True, + 'configuration': True, + 'didChangeConfiguration': {'dynamicRegistration': True}, + 'didChangeWatchedFiles': {'dynamicRegistration': True}, + 'executeCommand': {'dynamicRegistration': True}, + 'symbol': { + 'dynamicRegistration': True, + 'symbolKind': {'valueSet': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, + 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, + 23, 24, 25, 26 ]} + }, + 'workspaceEdit': {'documentChanges': True}, + 'workspaceFolders': True} + } + +class SolcInstance: + """ + Manages the solc executable instance and provides the handle to communicate with it + """ + + process: subprocess.Popen + endpoint: pylspclient.LspEndpoint + client: pylspclient.LspClient + read_pipe: ReadPipe + diagnostics_cond: Condition + #published_diagnostics: object + + def __init__(self, _solc_path: str) -> None: + self.solc_path = _solc_path + self.published_diagnostics = [] + self.client = pylspclient.LspClient(None) + self.diagnostics_cond = Condition() + + def __enter__(self): + dprint(f"Starting solc LSP instance: {self.solc_path}") + self.process = subprocess.Popen( + [self.solc_path, "--lsp"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + self.read_pipe = ReadPipe(self.process.stderr) + self.read_pipe.start() + self.endpoint = pylspclient.LspEndpoint( + json_rpc_endpoint=pylspclient.JsonRpcEndpoint( + self.process.stdin, + self.process.stdout + ), + notify_callbacks={ + 'textDocument/publishDiagnostics': self.on_publish_diagnostics + } + ) + self.client = pylspclient.LspClient(self.endpoint) + return self + + def __exit__(self, _exception_type, _exception_value, _traceback) -> None: + dprint("Stopping solc instance.") + self.client.shutdown() + self.client.exit() + self.read_pipe.join() + + def on_publish_diagnostics(self, _diagnostics) -> None: + dprint("Receiving published diagnostics:") + pprint(_diagnostics) + self.published_diagnostics.append(_diagnostics) + self.diagnostics_cond.acquire() + self.diagnostics_cond.notify() + self.diagnostics_cond.release() + +class SolcTests: + def __init__(self, _client: SolcInstance, _project_root_dir: str): + self.solc = _client + self.project_root_dir = _project_root_dir + self.project_root_uri = 'file://' + self.project_root_dir + self.tests = 0 + dprint("root dir: {self.project_root_dir}") + + # {{{ helpers + def get_test_file_path(self, _test_case_name): + return f"{self.project_root_dir}/{_test_case_name}.sol" + + def get_test_file_uri(self, _test_case_name): + return "file://" + self.get_test_file_path(_test_case_name) + + def get_test_file_contents(self, _test_case_name): + return open(self.get_test_file_path(_test_case_name,), mode="r", encoding="utf-8").read() + + def lsp_open_file(self, _test_case_name): + """ Opens file for given test case. """ + version = 1 + file_uri = self.get_test_file_uri(_test_case_name) + file_contents = self.get_test_file_contents(_test_case_name) + self.solc.client.didOpen(lsp_types.TextDocumentItem( + file_uri, SOLIDITY_LANGUAGE_ID, version, file_contents + )) + + def lsp_open_file_and_wait_for_diagnostics(self, _test_case_name): + """ + Opens file for given test case and waits for diagnostics to be published. + """ + self.solc.diagnostics_cond.acquire() + self.lsp_open_file(_test_case_name) + self.solc.diagnostics_cond.wait_for( + predicate=lambda: len(self.solc.published_diagnostics) != 0, + timeout=2.0 + ) + self.solc.diagnostics_cond.release() + + def expect(self, _cond: bool, _description: str) -> None: + self.tests = self.tests + 1 + prefix = f"[{self.tests}] {SGR_TEST_BEGIN}{_description}{SGR_RESET} : " + if _cond: + print(prefix + SGR_STATUS_OKAY + 'OK' + SGR_RESET) + else: + print(prefix + SGR_STATUS_FAIL + 'FAILED' + SGR_RESET) + raise RuntimeError("Expectation failed.") + + def expect_equal(self, _description: str, _actual, _expected) -> None: + self.tests = self.tests + 1 + prefix = f"[{self.tests}] {SGR_TEST_BEGIN}{_description}: " + diff = DeepDiff(_actual, _expected) + if len(diff) == 0: + print(prefix + SGR_STATUS_OKAY + 'OK' + SGR_RESET) + return + + print(prefix + SGR_STATUS_FAIL + 'FAILED' + SGR_RESET) + pprint(diff) + raise RuntimeError('Expectation failed.') + + # }}} + + # {{{ actual tests + def run(self): + self.open_files_and_test_publish_diagnostics() + self.test_definition() + self.test_documentHighlight() + # self.test_hover() + # self.test_implementation() + # self.test_references() + # self.test_signatureHelp() + # self.test_semanticTokensFull() + + def extract_test_file_name(self, _uri: str): + """ + Extracts the project-root URI prefix from the URI. + """ + subLength = len(self.project_root_uri) + return _uri[subLength:] + + def open_files_and_test_publish_diagnostics(self): + self.lsp_open_file_and_wait_for_diagnostics(TEST_NAME) + + # should have received one published_diagnostics notification + dprint("len: {len(self.solc.published_diagnostics)}") + self.expect(len(self.solc.published_diagnostics) == 1, "one published_diagnostics message") + published_diagnostics = self.solc.published_diagnostics[0] + + self.expect(published_diagnostics['uri'] == self.get_test_file_uri(TEST_NAME), + 'diagnostic: uri') + + # containing one single diagnostics report + diagnostics = published_diagnostics['diagnostics'] + self.expect(len(diagnostics) == 1, "one diagnostics") + diagnostic = diagnostics[0] + self.expect(diagnostic['code'] == 3805, 'diagnostic: pre-release compiler') + self.expect_equal( + 'check range', + diagnostic['range'], + {'end': {'character': 0, 'line': 0}, 'start': {'character': 0, 'line': 0}} + ) + + def test_definition(self): + """ + Tests goto-definition. The following tokens can be used to jump from: + """ + + self.solc.published_diagnostics.clear() + + # LHS enum variable in assignment: `weather` + result = self.solc.client.definition( + lsp_types.TextDocumentIdentifier(self.get_test_file_uri(TEST_NAME)), + lsp_types.Position(23, 9)) # line/col numbers are 0-based + self.expect(len(result) == 1, "only one definition returned") + self.expect(result[0].range == lsp_types.Range(lsp_types.Position(19, 16), + lsp_types.Position(19, 23)), "range check") + + # Test on return parameter symbol: `result` at 35:9 (begin of identifier) + result = self.solc.client.definition( + lsp_types.TextDocumentIdentifier(self.get_test_file_uri(TEST_NAME)), + lsp_types.Position(24, 8)) + + # Test goto-def of a function-parameter. + result = self.solc.client.definition( + lsp_types.TextDocumentIdentifier(self.get_test_file_uri(TEST_NAME)), + lsp_types.Position(24, 17)) + + # Test on function parameter symbol + # Test on enum type symbol in expression + # Test on enum value symbol in expression + # Test on import statement to jump to imported file + + def test_documentHighlight(self): + """ + Tests symbolic hover-hints, that is, highlighting all other symbols + that are semantically the same. + + - hovering a variable symbol will highlight all occurrences of that variable. + - hovering a function symbol will hover all other calls to it as well + as the function definition's function name itself. + - hovering an enum value will highlight all other uses of that enum value, + including the name of the definition of that enum value. + - hovering an enum type will highlight all other uses of that enum type, + including the name of the definition of that enum type. + - anything else will reply with an empty set. + """ + + # variable + reply = self.solc.client.documentHighlight( + lsp_types.TextDocumentIdentifier(self.get_test_file_uri(TEST_NAME)), + lsp_types.Position(23, 9) # line/col numbers are 0-based + ) + self.expect(len(reply) == 2, "2 highlights") + self.expect(reply[0].kind == lsp_types.DocumentHighlightKind.Text, "kind") + self.expect(reply[0].range == lsp_types.Range(lsp_types.Position(19, 16), + lsp_types.Position(19, 23)), "range check") + self.expect(reply[1].kind == lsp_types.DocumentHighlightKind.Text, "kind") + self.expect(reply[1].range == lsp_types.Range(lsp_types.Position(23, 8), + lsp_types.Position(23, 15)), "range check") + + # enum type: Weather, line 24, col 19 .. 25 + reply = self.solc.client.documentHighlight( + lsp_types.TextDocumentIdentifier(self.get_test_file_uri(TEST_NAME)), + lsp_types.Position(23, 18) # line/col numbers are 0-based + ) + ENUM_TYPE_HIGHLIGHTS = [ + { 'kind': lsp_types.DocumentHighlightKind.Text, 'line': 6, 'from': 5, 'to': 12 }, + { 'kind': lsp_types.DocumentHighlightKind.Text, 'line': 14, 'from': 4, 'to': 11 }, + { 'kind': lsp_types.DocumentHighlightKind.Text, 'line': 14, 'from': 27, 'to': 34 }, + { 'kind': lsp_types.DocumentHighlightKind.Text, 'line': 19, 'from': 8, 'to': 15 }, + { 'kind': lsp_types.DocumentHighlightKind.Text, 'line': 19, 'from': 26, 'to': 33 }, + { 'kind': lsp_types.DocumentHighlightKind.Text, 'line': 23, 'from': 18, 'to': 25 } + ] + self.expect(len(reply) == len(ENUM_TYPE_HIGHLIGHTS), f"expect {len(ENUM_TYPE_HIGHLIGHTS)} highlights") + for i in range(0, len(reply)): + self.expect(reply[i].kind == ENUM_TYPE_HIGHLIGHTS[i]['kind'], "check kind") + self.expect( + reply[i].range == lsp_types.Range( + lsp_types.Position( + ENUM_TYPE_HIGHLIGHTS[i]['line'], + ENUM_TYPE_HIGHLIGHTS[i]['from'] + ), + lsp_types.Position( + ENUM_TYPE_HIGHLIGHTS[i]['line'], + ENUM_TYPE_HIGHLIGHTS[i]['to'] + ) + ), + "range check" + ) + + + def test_references(self): + # Shows all references of given symbol def + pass + + def test_hover(self): + pass + + # }}} + +class SolidityLSPTestSuite: # {{{ + def __init__(self): + self.project_root_dir = '' + self.solc_path = '' + + def main(self): + solc_path, project_root_dir = self.parse_args_and_prepare() + + with SolcInstance(solc_path) as solc: + project_root_uri = 'file://' + project_root_dir + workspace_folders = [ {'name': 'solidity-lsp', 'uri': project_root_uri} ] + traceServer = 'off' + solc.client.initialize(solc.process.pid, None, project_root_uri, + None, LSP_CLIENT_CAPS, traceServer, + workspace_folders) + solc.client.initialized() + tests = SolcTests(solc, project_root_dir) + tests.run() + + def parse_args_and_prepare(self): + """ + Parses CLI args and retruns tuple of path to solc executable + and path to solidity-project root dir. + """ + parser = argparse.ArgumentParser(description='Solidity LSP Test suite') + parser.add_argument( + 'solc_path', + type=str, + default="/home/trapni/work/solidity/build/solc/solc", + help='Path to solc binary to test against', + nargs="?" + ) + parser.add_argument( + 'project_root_dir', + type=str, + default=f"{os.path.dirname(os.path.realpath(__file__))}/..", + help='Path to Solidity project\'s root directory (must be fully qualified).', + nargs="?" + ) + args = parser.parse_args() + project_root_dir = os.path.realpath(args.project_root_dir) + '/test/libsolidity/lsp' + return [args.solc_path, project_root_dir] + # }}} + +if __name__ == "__main__": + suite = SolidityLSPTestSuite() + suite.main() diff --git a/solc/CMakeLists.txt b/solc/CMakeLists.txt index fe68d4eb9..51b77d29b 100644 --- a/solc/CMakeLists.txt +++ b/solc/CMakeLists.txt @@ -3,8 +3,24 @@ set(libsolcli_sources CommandLineParser.cpp CommandLineParser.h ) +set(libsolcli_libs solidity Boost::boost Boost::program_options) + +if(${CMAKE_BUILD_TYPE} STREQUAL "Debug") + # Since we do not want to depend on networking code on production binaries, + # this feature is only available when creating a debug build. + # the LSP's TCP listener is exclusively used more convenient debugging. + option(SOLC_LSP_TCP "Solidity LSP: Enables TCP listener support (should only be eanbled for debugging purposes)." OFF) + if(SOLC_LSP_TCP) + set(libsolcli_defines SOLC_LSP_TCP=1) + list(APPEND libsolcli_sources LSPTCPTransport.cpp LSPTCPTransport.h) + find_package(Threads REQUIRED) + list(APPEND libsolcli_libs Threads::Threads) + endif() +endif() + add_library(solcli ${libsolcli_sources}) -target_link_libraries(solcli PUBLIC solidity Boost::boost Boost::program_options) +target_compile_definitions(solcli PUBLIC ${libsolcli_defines}) +target_link_libraries(solcli PUBLIC ${libsolcli_libs}) set(sources main.cpp) diff --git a/solc/CommandLineInterface.cpp b/solc/CommandLineInterface.cpp index 1d069f925..e5200028a 100644 --- a/solc/CommandLineInterface.cpp +++ b/solc/CommandLineInterface.cpp @@ -24,6 +24,11 @@ #include #include "license.h" + +#if defined(SOLC_LSP_TCP) +#include +#endif + #include "solidity/BuildInfo.h" #include @@ -36,6 +41,8 @@ #include #include #include +#include +#include #include @@ -53,7 +60,10 @@ #include #include +#include + #include +#include #include #include @@ -507,7 +517,11 @@ bool CommandLineInterface::readInputFiles() m_fileReader.setStdin(readUntilEnd(m_sin)); } - if (m_fileReader.sourceCodes().empty() && !m_standardJsonInput.has_value()) + if ( + m_options.input.mode != InputMode::LanguageServer && + m_fileReader.sourceCodes().empty() && + !m_standardJsonInput.has_value() + ) { serr() << "All specified input files either do not exist or are not regular files." << endl; return false; @@ -623,6 +637,9 @@ bool CommandLineInterface::processInput() m_standardJsonInput.reset(); break; } + case InputMode::LanguageServer: + serveLSP(); + break; case InputMode::Assembler: if (!assemble(m_options.assembly.inputLanguage, m_options.assembly.targetMachine)) return false; @@ -887,6 +904,21 @@ void CommandLineInterface::handleAst() } } +void CommandLineInterface::serveLSP() +{ + std::unique_ptr transport = make_unique(); +#if defined(SOLC_LSP_TCP) + if (m_options.lsp.port.has_value()) + { + unsigned const port = m_options.lsp.port.value(); + transport = make_unique(static_cast(port), traceLevel, traceLogger); + } +#endif + + lsp::LanguageServer languageServer(move(transport)); + languageServer.run(); +} + bool CommandLineInterface::link() { solAssert(m_options.input.mode == InputMode::Linker, ""); diff --git a/solc/CommandLineInterface.h b/solc/CommandLineInterface.h index ee5057468..76523e98a 100644 --- a/solc/CommandLineInterface.h +++ b/solc/CommandLineInterface.h @@ -66,6 +66,7 @@ private: void printVersion(); void printLicense(); bool compile(); + void serveLSP(); bool link(); void writeLinkedFiles(); /// @returns the ``// -> name`` hint for library placeholders. diff --git a/solc/CommandLineParser.cpp b/solc/CommandLineParser.cpp index 9356c9662..b7e2828cf 100644 --- a/solc/CommandLineParser.cpp +++ b/solc/CommandLineParser.cpp @@ -63,6 +63,7 @@ static string const g_strIPFS = "ipfs"; static string const g_strLicense = "license"; static string const g_strLibraries = "libraries"; static string const g_strLink = "link"; +static string const g_strLSP = "lsp"; static string const g_strMachine = "machine"; static string const g_strMetadataHash = "metadata-hash"; static string const g_strMetadataLiteral = "metadata-literal"; @@ -136,6 +137,7 @@ static map const g_inputModeName = { {InputMode::Assembler, "assembler"}, {InputMode::StandardJson, "standard JSON"}, {InputMode::Linker, "linker"}, + {InputMode::LanguageServer, "language server (LSP)"}, }; bool CommandLineParser::checkMutuallyExclusive(vector const& _optionNames) @@ -243,6 +245,10 @@ bool CommandLineOptions::operator==(CommandLineOptions const& _other) const noex optimizer.expectedExecutionsPerDeployment == _other.optimizer.expectedExecutionsPerDeployment && optimizer.noOptimizeYul == _other.optimizer.noOptimizeYul && optimizer.yulSteps == _other.optimizer.yulSteps && + lsp.trace == _other.lsp.trace && +#if defined(SOLC_LSP_TCP) + lsp.port == _other.lsp.port && +#endif modelChecker.initialize == _other.modelChecker.initialize && modelChecker.settings == _other.modelChecker.settings; } @@ -334,7 +340,7 @@ bool CommandLineParser::parseInputPathsAndRemappings() // Keep it working that way for backwards-compatibility. m_options.input.addStdin = true; } - else if (m_options.input.paths.size() == 0 && !m_options.input.addStdin) + else if (m_options.input.paths.size() == 0 && !m_options.input.addStdin && m_options.input.mode != InputMode::LanguageServer) { serr() << "No input files given. If you wish to use the standard input please specify \"-\" explicitly." << endl; return false; @@ -472,6 +478,7 @@ bool CommandLineParser::parseOutputSelection() return contains(assemblerModeOutputs, _outputName); case InputMode::StandardJson: case InputMode::Linker: + case InputMode::LanguageServer: return false; } @@ -644,6 +651,11 @@ General Information)").c_str(), "Supported Inputs is the output of the --" + g_strStandardJSON + " or the one produced by " "--" + g_strCombinedJson + " " + CombinedJsonRequests::componentName(&CombinedJsonRequests::ast)).c_str() ) + ( + g_strLSP.c_str(), + "Switch to language server mode (\"LSP\"). Allows the compiler to be used as an analysis backend " + "for your favourite IDE." + ) ; desc.add(alternativeInputModes); @@ -779,6 +791,22 @@ General Information)").c_str(), ; desc.add(optimizerOptions); + po::options_description lspOptions("LSP Options"); + lspOptions.add_options() +#if defined(SOLC_LSP_TCP) + ( + "lsp-port", + po::value()->value_name("PORT"), + "Enables LSP over TCP instead of stdio and uses the specified TCP port." + ) +#endif + ( + "lsp-trace", + po::value()->value_name("FILE"), + "Enables LSP request tracing to be logged into the specified file." + ); + desc.add(lspOptions); + po::options_description smtCheckerOptions("Model Checker Options"); smtCheckerOptions.add_options() ( @@ -879,6 +907,7 @@ bool CommandLineParser::processArgs() g_strStrictAssembly, g_strYul, g_strImportAst, + g_strLSP })) return false; @@ -890,6 +919,8 @@ bool CommandLineParser::processArgs() m_options.input.mode = InputMode::Version; else if (m_args.count(g_strStandardJSON) > 0) m_options.input.mode = InputMode::StandardJson; + else if (m_args.count(g_strLSP)) + m_options.input.mode = InputMode::LanguageServer; else if (m_args.count(g_strAssemble) > 0 || m_args.count(g_strStrictAssembly) > 0 || m_args.count(g_strYul) > 0) m_options.input.mode = InputMode::Assembler; else if (m_args.count(g_strLink) > 0) @@ -946,7 +977,8 @@ bool CommandLineParser::processArgs() if ( m_options.input.mode != InputMode::Compiler && m_options.input.mode != InputMode::CompilerWithASTImport && - m_options.input.mode != InputMode::Assembler + m_options.input.mode != InputMode::Assembler && + m_options.input.mode != InputMode::LanguageServer ) { if (!m_args[g_strOptimizeRuns].defaulted()) @@ -1235,6 +1267,22 @@ bool CommandLineParser::processArgs() } } + if (m_args.count("lsp-trace")) + m_options.lsp.trace = boost::filesystem::path(m_args.at("lsp-trace").as()); + +#if defined(SOLC_LSP_TCP) + if (m_args.count("lsp-port")) + { + unsigned const port = m_args.at("lsp-port").as(); + if (port > 0xFFFF) + { + serr() << "LSP port number not in valid port range " << port << '.' << endl; + return false; + } + m_options.lsp.port = port; + } +#endif + if (m_args.count(g_strModelCheckerContracts)) { string contractsStr = m_args[g_strModelCheckerContracts].as(); @@ -1318,7 +1366,11 @@ bool CommandLineParser::processArgs() if (m_options.input.mode == InputMode::Compiler) m_options.input.errorRecovery = (m_args.count(g_strErrorRecovery) > 0); - solAssert(m_options.input.mode == InputMode::Compiler || m_options.input.mode == InputMode::CompilerWithASTImport); + solAssert( + m_options.input.mode == InputMode::Compiler || + m_options.input.mode == InputMode::CompilerWithASTImport || + m_options.input.mode == InputMode::LanguageServer + ); return true; } diff --git a/solc/CommandLineParser.h b/solc/CommandLineParser.h index a1d13c690..27cb8d0cc 100644 --- a/solc/CommandLineParser.h +++ b/solc/CommandLineParser.h @@ -56,6 +56,7 @@ enum class InputMode StandardJson, Linker, Assembler, + LanguageServer }; struct CompilerOutputs @@ -226,6 +227,14 @@ struct CommandLineOptions std::optional yulSteps; } optimizer; + struct + { + boost::filesystem::path trace; +#if defined(SOLC_LSP_TCP) + std::optional port; +#endif + } lsp; + struct { bool initialize = false; diff --git a/solc/LSPTCPTransport.cpp b/solc/LSPTCPTransport.cpp new file mode 100644 index 000000000..5c82111fb --- /dev/null +++ b/solc/LSPTCPTransport.cpp @@ -0,0 +1,115 @@ +/* + This file is part of solidity. + + solidity is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + solidity is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with solidity. If not, see . +*/ +// SPDX-License-Identifier: GPL-3.0 +#include + +#include +#include + +#include +#include +#include + +namespace solidity::lsp +{ + +using std::function; +using std::nullopt; +using std::optional; +using std::string_view; +using std::to_string; + +using namespace std::string_literals; + +LSPTCPTransport::LSPTCPTransport(unsigned short _port, Trace _traceLevel, function _trace): + m_io_service(), + m_endpoint(boost::asio::ip::make_address("127.0.0.1"), _port), + m_acceptor(m_io_service), + m_stream(), + m_jsonTransport(), + m_traceLevel{_traceLevel}, + m_trace(_trace ? std::move(_trace) : [](string_view) {}) +{ + m_acceptor.open(m_endpoint.protocol()); + m_acceptor.set_option(boost::asio::ip::tcp::acceptor::reuse_address(true)); + m_acceptor.bind(m_endpoint); + m_acceptor.listen(); + + if (m_traceLevel >= Trace::Messages) + m_trace("Listening on tcp://127.0.0.1:"s + to_string(_port)); +} + +void LSPTCPTransport::setTraceLevel(Trace _traceLevel) +{ + m_traceLevel = _traceLevel; + + if (m_jsonTransport) + m_jsonTransport->setTraceLevel(_traceLevel); +} + +bool LSPTCPTransport::closed() const noexcept +{ + return !m_acceptor.is_open(); +} + +optional LSPTCPTransport::receive() +{ + auto const clientClosed = [&]() { return !m_stream || !m_stream.value().good() || m_stream.value().eof(); }; + + if (clientClosed()) + { + m_stream.emplace(m_acceptor.accept()); + if (clientClosed()) + return nullopt; + + auto const remoteAddr = m_stream.value().socket().remote_endpoint().address().to_string(); + auto const remotePort = m_stream.value().socket().remote_endpoint().port(); + m_trace("New client connected from "s + remoteAddr + ":" + to_string(remotePort) + "."); + m_jsonTransport.emplace(m_stream.value(), m_stream.value(), m_traceLevel, [this](auto msg) { m_trace(msg); }); + } + if (auto value = m_jsonTransport.value().receive(); value.has_value()) + return value; + + if (clientClosed()) + { + m_trace("Client disconnected."); + m_jsonTransport.reset(); + m_stream.reset(); + } + return nullopt; +} + +void LSPTCPTransport::notify(std::string const& _method, Json::Value const& _params) +{ + if (m_jsonTransport.has_value()) + m_jsonTransport.value().notify(_method, _params); +} + +void LSPTCPTransport::reply(MessageID _id, Json::Value const& _result) +{ + fmt::print("reply: {}\n{}\n", util::jsonPrettyPrint(_id), util::jsonPrettyPrint(_result)); + if (m_jsonTransport.has_value()) + m_jsonTransport.value().reply(_id, _result); +} + +void LSPTCPTransport::error(MessageID _id, ErrorCode _code, std::string const& _message) +{ + if (m_jsonTransport.has_value()) + m_jsonTransport.value().error(_id, _code, _message); +} + +} diff --git a/solc/LSPTCPTransport.h b/solc/LSPTCPTransport.h new file mode 100644 index 000000000..3610e0c73 --- /dev/null +++ b/solc/LSPTCPTransport.h @@ -0,0 +1,49 @@ +/* + This file is part of solidity. + + solidity is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + solidity is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with solidity. If not, see . +*/ +// SPDX-License-Identifier: GPL-3.0 +#pragma once + +#include + +#include +#include + +namespace solidity::lsp +{ + +class LSPTCPTransport: public lsp::Transport { +public: + LSPTCPTransport(unsigned short _port, Trace _traceLevel, std::function); + + void setTraceLevel(Trace _traceLevel) override; + bool closed() const noexcept override; + std::optional receive() override; + void notify(std::string const& _method, Json::Value const& _params) override; + void reply(lsp::MessageID _id, Json::Value const& _result) override; + void error(lsp::MessageID _id, lsp::ErrorCode _code, std::string const& _message) override; + +private: + boost::asio::io_service m_io_service; + boost::asio::ip::tcp::endpoint m_endpoint; + boost::asio::ip::tcp::acceptor m_acceptor; + std::optional m_stream; + std::optional m_jsonTransport; + Trace m_traceLevel; + std::function m_trace; +}; + +} // end namespace diff --git a/test/libsolidity/lsp/test.sol b/test/libsolidity/lsp/test.sol new file mode 100644 index 000000000..bfd570e2c --- /dev/null +++ b/test/libsolidity/lsp/test.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.0; + +/// Some Error type E. +error E(uint, uint); + +enum Weather { + Sunny, + Cloudy, + Rainy +} + +/// My contract MyContract. +/// +contract MyContract +{ + Weather lastWeather = Weather.Rainy; + uint constant fixedValue = 1234; + + constructor() + { + } + + /// Sum is summing two args and returning the result + /// + /// @param a me it is + /// @param b me it is also + function sum(uint a, uint b) public pure returns (uint result) + { + Weather weather = Weather.Sunny; + uint foo = 12345 + fixedValue; + if (a == b) + revert E(a, b); + weather = Weather.Cloudy; + result = a + b + foo; + } + + function main() public pure returns (uint) + { + return sum(2, 3 - 123 + 456); + } +} + +contract D +{ + function main() public payable returns (uint) + { + MyContract c = new MyContract(); + return c.sum(2, 3); + } +} diff --git a/test/libsolidity/lsp/test_definition.sol b/test/libsolidity/lsp/test_definition.sol new file mode 100644 index 000000000..13898fc6e --- /dev/null +++ b/test/libsolidity/lsp/test_definition.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.0; + +/// Some Error type E. +error E(uint, uint); + +enum Weather { + Sunny, + Cloudy, + Rainy +} + +contract MyContract +{ + Weather lastWheather = Weather.Rainy; + uint constant someFixedValue = 1234; + + function sum(uint a, uint b) public pure returns (uint result) + { + Weather weather = Weather.Sunny; + uint foo = 12345 + someFixedValue; + if (a == b) + revert E(a, b); + weather = Weather.Cloudy; + result = a + b + foo; + } + + function main() public pure returns (uint) + { + return sum(2, 3 - 123 + 456); + } +} diff --git a/test/solc/CommandLineInterface.cpp b/test/solc/CommandLineInterface.cpp index 0269ec462..7ce4a9759 100644 --- a/test/solc/CommandLineInterface.cpp +++ b/test/solc/CommandLineInterface.cpp @@ -157,7 +157,7 @@ BOOST_AUTO_TEST_CASE(multiple_input_modes) }; string expectedMessage = "The following options are mutually exclusive: " - "--help, --license, --version, --standard-json, --link, --assemble, --strict-assembly, --yul, --import-ast. " + "--help, --license, --version, --standard-json, --link, --assemble, --strict-assembly, --yul, --import-ast, --lsp. " "Select at most one.\n"; for (string const& mode1: inputModeOptions) diff --git a/test/tools/isoltest.cpp b/test/tools/isoltest.cpp index 5ae1a3f99..a2dfec9b1 100644 --- a/test/tools/isoltest.cpp +++ b/test/tools/isoltest.cpp @@ -463,6 +463,8 @@ int main(int argc, char const *argv[]) return 1; } + // TODO(pr): Iterate here also through all LSP test cases + cout << endl << "Summary: "; AnsiColorized(cout, !options.noColor, {BOLD, global_stats ? GREEN : RED}) << global_stats.successCount << "/" << global_stats.testCount;