From 927b24df1f37cdffc794a74081cfb37d01d47c17 Mon Sep 17 00:00:00 2001 From: Christian Parpart Date: Mon, 13 Dec 2021 14:53:40 +0100 Subject: [PATCH] Initial implementation of Language Server --- .circleci/config.yml | 20 +- Changelog.md | 1 + libsolidity/CMakeLists.txt | 6 + libsolidity/lsp/FileRepository.cpp | 64 ++ libsolidity/lsp/FileRepository.h | 53 ++ libsolidity/lsp/LanguageServer.cpp | 402 ++++++++ libsolidity/lsp/LanguageServer.h | 110 +++ libsolidity/lsp/Transport.cpp | 141 +++ libsolidity/lsp/Transport.h | 101 ++ scripts/tests.sh | 3 + solc/CommandLineInterface.cpp | 19 +- solc/CommandLineInterface.h | 1 + solc/CommandLineParser.cpp | 14 + solc/CommandLineParser.h | 1 + test/libsolidity/lsp/didChange_template.sol | 6 + test/libsolidity/lsp/didOpen_with_import.sol | 12 + test/libsolidity/lsp/lib.sol | 15 + .../libsolidity/lsp/publish_diagnostics_1.sol | 18 + .../libsolidity/lsp/publish_diagnostics_2.sol | 21 + .../libsolidity/lsp/publish_diagnostics_3.sol | 10 + test/lsp.py | 874 ++++++++++++++++++ test/solc/CommandLineInterface.cpp | 2 +- 22 files changed, 1891 insertions(+), 3 deletions(-) create mode 100644 libsolidity/lsp/FileRepository.cpp create mode 100644 libsolidity/lsp/FileRepository.h create mode 100644 libsolidity/lsp/LanguageServer.cpp create mode 100644 libsolidity/lsp/LanguageServer.h create mode 100644 libsolidity/lsp/Transport.cpp create mode 100644 libsolidity/lsp/Transport.h create mode 100644 test/libsolidity/lsp/didChange_template.sol create mode 100644 test/libsolidity/lsp/didOpen_with_import.sol create mode 100644 test/libsolidity/lsp/lib.sol create mode 100644 test/libsolidity/lsp/publish_diagnostics_1.sol create mode 100644 test/libsolidity/lsp/publish_diagnostics_2.sol create mode 100644 test/libsolidity/lsp/publish_diagnostics_3.sol create mode 100755 test/lsp.py diff --git a/.circleci/config.yml b/.circleci/config.yml index cda6e5890..8123fefe8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -198,6 +198,19 @@ 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 deepdiff colorama + - run: + name: Executing solc LSP test suite + command: ./test/lsp.py ./build/solc/solc + - gitter_notify_failure_unless_pr + - steps_soltest_all: &steps_soltest_all steps: - checkout @@ -519,7 +532,7 @@ 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 deepdiff colorama # also z3-solver, parsec and tabulate to make sure pylint knows about this module, pygments-lexer-solidity for docs - run: name: Linting Python Scripts @@ -887,6 +900,10 @@ jobs: parallelism: 15 # 7 EVM versions, each with/without optimization + 1 ABIv1/@nooptions run <<: *steps_soltest_all + t_ubu_lsp: &t_ubu_lsp + <<: *base_ubuntu2004_small + <<: *steps_test_lsp + t_archlinux_soltest: &t_archlinux_soltest <<: *base_archlinux environment: @@ -1288,6 +1305,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/Changelog.md b/Changelog.md index c4b67e935..9e3526fab 100644 --- a/Changelog.md +++ b/Changelog.md @@ -4,6 +4,7 @@ Language Features: Compiler Features: + * Commandline Interface: Add ``--lsp`` option to get ``solc`` to act as a Language Server (LSP) communicating over stdio. Bugfixes: diff --git a/libsolidity/CMakeLists.txt b/libsolidity/CMakeLists.txt index 7e3791eab..ff82cd9fb 100644 --- a/libsolidity/CMakeLists.txt +++ b/libsolidity/CMakeLists.txt @@ -155,6 +155,12 @@ set(sources interface/StorageLayout.h interface/Version.cpp interface/Version.h + lsp/LanguageServer.cpp + lsp/LanguageServer.h + lsp/FileRepository.cpp + lsp/FileRepository.h + lsp/Transport.cpp + lsp/Transport.h parsing/DocStringParser.cpp parsing/DocStringParser.h parsing/Parser.cpp diff --git a/libsolidity/lsp/FileRepository.cpp b/libsolidity/lsp/FileRepository.cpp new file mode 100644 index 000000000..9c7f72e0c --- /dev/null +++ b/libsolidity/lsp/FileRepository.cpp @@ -0,0 +1,64 @@ +/* + 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 + +using namespace std; +using namespace solidity; +using namespace solidity::lsp; + +namespace +{ + +string stripFilePrefix(string const& _path) +{ + if (_path.find("file://") == 0) + return _path.substr(7); + else + return _path; +} + +} + +string FileRepository::sourceUnitNameToClientPath(string const& _sourceUnitName) const +{ + if (m_sourceUnitNamesToClientPaths.count(_sourceUnitName)) + return m_sourceUnitNamesToClientPaths.at(_sourceUnitName); + else if (_sourceUnitName.find("file://") == 0) + return _sourceUnitName; + else + return "file://" + (m_fileReader.basePath() / _sourceUnitName).generic_string(); +} + +string FileRepository::clientPathToSourceUnitName(string const& _path) const +{ + return m_fileReader.cliPathToSourceUnitName(stripFilePrefix(_path)); +} + +map const& FileRepository::sourceUnits() const +{ + return m_fileReader.sourceUnits(); +} + +void FileRepository::setSourceByClientPath(string const& _uri, string _text) +{ + // This is needed for uris outside the base path. It can lead to collisions, + // but we need to mostly rewrite this in a future version anyway. + m_sourceUnitNamesToClientPaths.emplace(clientPathToSourceUnitName(_uri), _uri); + m_fileReader.addOrUpdateFile(stripFilePrefix(_uri), move(_text)); +} diff --git a/libsolidity/lsp/FileRepository.h b/libsolidity/lsp/FileRepository.h new file mode 100644 index 000000000..b6aa5ee08 --- /dev/null +++ b/libsolidity/lsp/FileRepository.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 FileRepository +{ +public: + explicit FileRepository(boost::filesystem::path const& _basePath): + m_fileReader(_basePath) {} + + boost::filesystem::path const& basePath() const { return m_fileReader.basePath(); } + + /// Translates a compiler-internal source unit name to an LSP client path. + std::string sourceUnitNameToClientPath(std::string const& _sourceUnitName) const; + /// Translates an LSP client path into a compiler-internal source unit name. + std::string clientPathToSourceUnitName(std::string const& _uri) const; + + /// @returns all sources by their compiler-internal source unit name. + std::map const& sourceUnits() const; + /// Changes the source identified by the LSP client path _uri to _text. + void setSourceByClientPath(std::string const& _uri, std::string _text); + + frontend::ReadCallback::Callback reader() { return m_fileReader.reader(); } + +private: + std::map m_sourceUnitNamesToClientPaths; + frontend::FileReader m_fileReader; +}; + +} diff --git a/libsolidity/lsp/LanguageServer.cpp b/libsolidity/lsp/LanguageServer.cpp new file mode 100644 index 000000000..586220df1 --- /dev/null +++ b/libsolidity/lsp/LanguageServer.cpp @@ -0,0 +1,402 @@ +/* + 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 + +using namespace std; +using namespace std::placeholders; + +using namespace solidity::lsp; +using namespace solidity::langutil; +using namespace solidity::frontend; + +namespace +{ + +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(LineColumn const& _start, LineColumn const& _end) +{ + Json::Value json; + json["start"] = toJson(_start); + json["end"] = toJson(_end); + return json; +} + +optional parseLineColumn(Json::Value const& _lineColumn) +{ + if (_lineColumn.isObject() && _lineColumn["line"].isInt() && _lineColumn["character"].isInt()) + return LineColumn{_lineColumn["line"].asInt(), _lineColumn["character"].asInt()}; + else + return nullopt; +} + +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; + } + solAssert(false); + return -1; +} + +} + +LanguageServer::LanguageServer(Transport& _transport): + m_client{_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) { m_state = (m_state == State::ShutdownRequested ? State::ExitRequested : State::ExitWithoutShutdown); }}, + {"initialize", bind(&LanguageServer::handleInitialize, this, _1, _2)}, + {"initialized", [](auto, auto) {}}, + {"shutdown", [this](auto, auto) { m_state = State::ShutdownRequested; }}, + {"textDocument/didOpen", bind(&LanguageServer::handleTextDocumentDidOpen, this, _1, _2)}, + {"textDocument/didChange", bind(&LanguageServer::handleTextDocumentDidChange, this, _1, _2)}, + {"textDocument/didClose", bind(&LanguageServer::handleTextDocumentDidClose, this, _1, _2)}, + {"workspace/didChangeConfiguration", bind(&LanguageServer::handleWorkspaceDidChangeConfiguration, this, _1, _2)}, + }, + m_fileRepository("/" /* basePath */), + m_compilerStack{m_fileRepository.reader()} +{ +} + +optional LanguageServer::parsePosition( + string const& _sourceUnitName, + Json::Value const& _position +) const +{ + if (!m_fileRepository.sourceUnits().count(_sourceUnitName)) + return nullopt; + + if (optional lineColumn = parseLineColumn(_position)) + if (optional const offset = CharStream::translateLineColumnToPosition( + m_fileRepository.sourceUnits().at(_sourceUnitName), + *lineColumn + )) + return SourceLocation{*offset, *offset, make_shared(_sourceUnitName)}; + return nullopt; +} + +optional LanguageServer::parseRange(string const& _sourceUnitName, Json::Value const& _range) const +{ + if (!_range.isObject()) + return nullopt; + optional start = parsePosition(_sourceUnitName, _range["start"]); + optional end = parsePosition(_sourceUnitName, _range["end"]); + if (!start || !end) + return nullopt; + solAssert(*start->sourceName == *end->sourceName); + start->end = end->end; + return start; +} + +Json::Value LanguageServer::toRange(SourceLocation const& _location) const +{ + if (!_location.hasText()) + return toJsonRange({}, {}); + + solAssert(_location.sourceName, ""); + CharStream const& stream = m_compilerStack.charStream(*_location.sourceName); + LineColumn start = stream.translatePositionToLineColumn(_location.start); + LineColumn end = stream.translatePositionToLineColumn(_location.end); + return toJsonRange(start, end); +} + +Json::Value LanguageServer::toJson(SourceLocation const& _location) const +{ + solAssert(_location.sourceName); + Json::Value item = Json::objectValue; + item["uri"] = m_fileRepository.sourceUnitNameToClientPath(*_location.sourceName); + item["range"] = toRange(_location); + return item; +} + +void LanguageServer::changeConfiguration(Json::Value const& _settings) +{ + m_settingsObject = _settings; +} + +void LanguageServer::compile() +{ + // For files that are not open, we have to take changes on disk into account, + // so we just remove all non-open files. + + FileRepository oldRepository(m_fileRepository.basePath()); + swap(oldRepository, m_fileRepository); + + for (string const& fileName: m_openFiles) + m_fileRepository.setSourceByClientPath( + fileName, + oldRepository.sourceUnits().at(oldRepository.clientPathToSourceUnitName(fileName)) + ); + + // TODO: optimize! do not recompile if nothing has changed (file(s) not flagged dirty). + + m_compilerStack.reset(false); + m_compilerStack.setSources(m_fileRepository.sourceUnits()); + m_compilerStack.compile(CompilerStack::State::AnalysisPerformed); +} + +void LanguageServer::compileAndUpdateDiagnostics() +{ + compile(); + + // These are the source units we will sent diagnostics to the client for sure, + // even if it is just to clear previous diagnostics. + map diagnosticsBySourceUnit; + for (string const& sourceUnitName: m_fileRepository.sourceUnits() | ranges::views::keys) + diagnosticsBySourceUnit[sourceUnitName] = Json::arrayValue; + for (string const& sourceUnitName: m_nonemptyDiagnostics) + diagnosticsBySourceUnit[sourceUnitName] = Json::arrayValue; + + for (shared_ptr const& error: m_compilerStack.errors()) + { + SourceLocation const* location = error->sourceLocation(); + if (!location || !location->sourceName) + // LSP only has diagnostics applied to individual files. + continue; + + Json::Value jsonDiag; + jsonDiag["source"] = "solc"; + jsonDiag["severity"] = toDiagnosticSeverity(error->type()); + jsonDiag["code"] = Json::UInt64{error->errorId().error}; + string message = error->typeName() + ":"; + if (string const* comment = error->comment()) + message += " " + *comment; + jsonDiag["message"] = move(message); + jsonDiag["range"] = toRange(*location); + + if (auto const* secondary = error->secondarySourceLocation()) + for (auto&& [secondaryMessage, secondaryLocation]: secondary->infos) + { + Json::Value jsonRelated; + jsonRelated["message"] = secondaryMessage; + jsonRelated["location"] = toJson(secondaryLocation); + jsonDiag["relatedInformation"].append(jsonRelated); + } + + diagnosticsBySourceUnit[*location->sourceName].append(jsonDiag); + } + + m_nonemptyDiagnostics.clear(); + for (auto&& [sourceUnitName, diagnostics]: diagnosticsBySourceUnit) + { + Json::Value params; + params["uri"] = m_fileRepository.sourceUnitNameToClientPath(sourceUnitName); + if (!diagnostics.empty()) + m_nonemptyDiagnostics.insert(sourceUnitName); + params["diagnostics"] = move(diagnostics); + m_client.notify("textDocument/publishDiagnostics", move(params)); + } +} + +bool LanguageServer::run() +{ + while (m_state != State::ExitRequested && m_state != State::ExitWithoutShutdown && !m_client.closed()) + { + MessageID id; + try + { + optional const jsonMessage = m_client.receive(); + if (!jsonMessage) + continue; + + if ((*jsonMessage)["method"].isString()) + { + string const methodName = (*jsonMessage)["method"].asString(); + id = (*jsonMessage)["id"]; + + if (auto handler = valueOrDefault(m_handlers, methodName)) + handler(id, (*jsonMessage)["params"]); + else + m_client.error(id, ErrorCode::MethodNotFound, "Unknown method " + methodName); + } + else + m_client.error({}, ErrorCode::ParseError, "\"method\" has to be a string."); + } + catch (...) + { + m_client.error(id, ErrorCode::InternalError, "Unhandled exception: "s + boost::current_exception_diagnostic_information()); + } + } + return m_state == State::ExitRequested; +} + +bool LanguageServer::checkServerInitialized(MessageID _id) +{ + if (m_state != State::Initialized) + { + m_client.error(_id, ErrorCode::ServerNotInitialized, "Server is not properly initialized."); + return false; + } + else + return true; +} + +void LanguageServer::handleInitialize(MessageID _id, Json::Value const& _args) +{ + if (m_state != State::Started) + { + m_client.error(_id, ErrorCode::RequestFailed, "Initialize called at the wrong time."); + return; + } + m_state = State::Initialized; + + // 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(); + if (!boost::starts_with(rootPath, "file://")) + { + m_client.error(_id, ErrorCode::InvalidParams, "rootUri only supports file URI scheme."); + return; + } + rootPath = rootPath.substr(7); + } + else if (Json::Value rootPath = _args["rootPath"]) + rootPath = rootPath.asString(); + + m_fileRepository = FileRepository(boost::filesystem::path(rootPath)); + if (_args["initializationOptions"].isObject()) + changeConfiguration(_args["initializationOptions"]); + + Json::Value replyArgs; + replyArgs["serverInfo"]["name"] = "solc"; + replyArgs["serverInfo"]["version"] = string(VersionNumber); + replyArgs["capabilities"]["textDocumentSync"]["openClose"] = true; + replyArgs["capabilities"]["textDocumentSync"]["change"] = 2; // 0=none, 1=full, 2=incremental + + m_client.reply(_id, move(replyArgs)); +} + + +void LanguageServer::handleWorkspaceDidChangeConfiguration(MessageID _id, Json::Value const& _args) +{ + if (!checkServerInitialized(_id)) + return; + + if (_args["settings"].isObject()) + changeConfiguration(_args["settings"]); +} + +void LanguageServer::handleTextDocumentDidOpen(MessageID _id, Json::Value const& _args) +{ + if (!checkServerInitialized(_id)) + return; + + if (!_args["textDocument"]) + m_client.error(_id, ErrorCode::RequestFailed, "Text document parameter missing."); + + string text = _args["textDocument"]["text"].asString(); + string uri = _args["textDocument"]["uri"].asString(); + m_openFiles.insert(uri); + m_fileRepository.setSourceByClientPath(uri, move(text)); + compileAndUpdateDiagnostics(); +} + +void LanguageServer::handleTextDocumentDidChange(MessageID _id, Json::Value const& _args) +{ + if (!checkServerInitialized(_id)) + return; + + string const uri = _args["textDocument"]["uri"].asString(); + + for (Json::Value jsonContentChange: _args["contentChanges"]) + { + if (!jsonContentChange.isObject()) + { + m_client.error(_id, ErrorCode::RequestFailed, "Invalid content reference."); + return; + } + + string const sourceUnitName = m_fileRepository.clientPathToSourceUnitName(uri); + if (!m_fileRepository.sourceUnits().count(sourceUnitName)) + { + m_client.error(_id, ErrorCode::RequestFailed, "Unknown file: " + uri); + return; + } + + string text = jsonContentChange["text"].asString(); + if (jsonContentChange["range"].isObject()) // otherwise full content update + { + optional change = parseRange(sourceUnitName, jsonContentChange["range"]); + if (!change || !change->hasText()) + { + m_client.error( + _id, + ErrorCode::RequestFailed, + "Invalid source range: " + jsonCompactPrint(jsonContentChange["range"]) + ); + return; + } + string buffer = m_fileRepository.sourceUnits().at(sourceUnitName); + buffer.replace(static_cast(change->start), static_cast(change->end - change->start), move(text)); + text = move(buffer); + } + m_fileRepository.setSourceByClientPath(uri, move(text)); + } + + compileAndUpdateDiagnostics(); +} + +void LanguageServer::handleTextDocumentDidClose(MessageID _id, Json::Value const& _args) +{ + if (!checkServerInitialized(_id)) + return; + + if (!_args["textDocument"]) + m_client.error(_id, ErrorCode::RequestFailed, "Text document parameter missing."); + + string uri = _args["textDocument"]["uri"].asString(); + m_openFiles.erase(uri); + + compileAndUpdateDiagnostics(); +} diff --git a/libsolidity/lsp/LanguageServer.h b/libsolidity/lsp/LanguageServer.h new file mode 100644 index 000000000..802dd8198 --- /dev/null +++ b/libsolidity/lsp/LanguageServer.h @@ -0,0 +1,110 @@ +/* + 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 + +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(Transport& _transport); + + /// Re-compiles the project and updates the diagnostics pushed to the client. + void compileAndUpdateDiagnostics(); + + /// 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(); + +private: + /// Checks if the server is initialized (to be used by messages that need it to be initialized). + /// Reports an error and returns false if not. + bool checkServerInitialized(MessageID _id); + 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 handleTextDocumentDidClose(MessageID _id, Json::Value const& _args); + + /// Invoked when the server user-supplied configuration changes (initiated by the client). + void changeConfiguration(Json::Value const&); + + /// Compile everything until after analysis phase. + void compile(); + + std::optional parsePosition( + std::string const& _sourceUnitName, + Json::Value const& _position + ) const; + /// @returns the source location given a source unit name and an LSP Range object, + /// or nullopt on failure. + std::optional parseRange( + std::string const& _sourceUnitName, + Json::Value const& _range + ) const; + Json::Value toRange(langutil::SourceLocation const& _location) const; + Json::Value toJson(langutil::SourceLocation const& _location) const; + + // LSP related member fields + using MessageHandler = std::function; + + enum class State { Started, Initialized, ShutdownRequested, ExitRequested, ExitWithoutShutdown }; + State m_state = State::Started; + + Transport& m_client; + std::map m_handlers; + + /// Set of files known to be open by the client. + std::set m_openFiles; + /// Set of source unit names for which we sent diagnostics to the client in the last iteration. + std::set m_nonemptyDiagnostics; + FileRepository m_fileRepository; + + frontend::CompilerStack m_compilerStack; + + /// User-supplied custom configuration settings (such as EVM version). + Json::Value m_settingsObject; +}; + +} diff --git a/libsolidity/lsp/Transport.cpp b/libsolidity/lsp/Transport.cpp new file mode 100644 index 000000000..fcd3c8249 --- /dev/null +++ b/libsolidity/lsp/Transport.cpp @@ -0,0 +1,141 @@ +/* + 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 + +using namespace std; +using namespace solidity::lsp; + +IOStreamTransport::IOStreamTransport(istream& _in, ostream& _out): + m_input{_in}, + m_output{_out} +{ +} + +IOStreamTransport::IOStreamTransport(): + IOStreamTransport(cin, cout) +{ +} + +bool IOStreamTransport::closed() const noexcept +{ + return m_input.eof(); +} + +optional IOStreamTransport::receive() +{ + auto const headers = parseHeaders(); + if (!headers) + { + error({}, ErrorCode::ParseError, "Could not parse RPC headers."); + return nullopt; + } + + if (!headers->count("content-length")) + { + error({}, ErrorCode::ParseError, "No content-length header found."); + return nullopt; + } + + string const data = util::readBytes(m_input, stoul(headers->at("content-length"))); + + Json::Value jsonMessage; + string jsonParsingErrors; + solidity::util::jsonParseStrict(data, jsonMessage, &jsonParsingErrors); + if (!jsonParsingErrors.empty() || !jsonMessage || !jsonMessage.isObject()) + { + error({}, ErrorCode::ParseError, "Could not parse RPC JSON payload. " + jsonParsingErrors); + return nullopt; + } + + return {move(jsonMessage)}; +} + +void IOStreamTransport::notify(string _method, Json::Value _message) +{ + Json::Value json; + json["method"] = move(_method); + json["params"] = move(_message); + send(move(json)); +} + +void IOStreamTransport::reply(MessageID _id, Json::Value _message) +{ + Json::Value json; + json["result"] = move(_message); + send(move(json), _id); +} + +void IOStreamTransport::error(MessageID _id, ErrorCode _code, string _message) +{ + Json::Value json; + json["error"]["code"] = static_cast(_code); + json["error"]["message"] = move(_message); + send(move(json), _id); +} + +void IOStreamTransport::send(Json::Value _json, MessageID _id) +{ + solAssert(_json.isObject()); + _json["jsonrpc"] = "2.0"; + if (_id != Json::nullValue) + _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(); +} + +optional> IOStreamTransport::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; + + string name = boost::to_lower_copy(line.substr(0, delimiterPos)); + string value = line.substr(delimiterPos + 1); + if (!headers.emplace( + boost::trim_copy(name), + boost::trim_copy(value) + ).second) + return nullopt; + } + return {move(headers)}; +} diff --git a/libsolidity/lsp/Transport.h b/libsolidity/lsp/Transport.h new file mode 100644 index 000000000..c6ed8fa8a --- /dev/null +++ b/libsolidity/lsp/Transport.h @@ -0,0 +1,101 @@ +/* + 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, + MethodNotFound = -32601, + InvalidParams = -32602, + InternalError = -32603, + + // Defined by the protocol. + ServerNotInitialized = -32002, + RequestFailed = -32803 +}; + +/** + * Transport layer API + * + * The transport layer API is abstracted to make LSP 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 _method, Json::Value _params) = 0; + virtual void reply(MessageID _id, Json::Value _result) = 0; + virtual void error(MessageID _id, ErrorCode _code, std::string _message) = 0; +}; + +/** + * LSP Transport using JSON-RPC over iostreams. + */ +class IOStreamTransport: public Transport +{ +public: + /// Constructs a standard stream transport layer. + /// + /// @param _in for example std::cin (stdin) + /// @param _out for example std::cout (stdout) + IOStreamTransport(std::istream& _in, std::ostream& _out); + + // Constructs a JSON transport using standard I/O streams. + IOStreamTransport(); + + bool closed() const noexcept override; + std::optional receive() override; + void notify(std::string _method, Json::Value _params) override; + void reply(MessageID _id, Json::Value _result) override; + void error(MessageID _id, ErrorCode _code, std::string _message) override; + +protected: + /// 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; +}; + +} diff --git a/scripts/tests.sh b/scripts/tests.sh index 029be1629..5515a6869 100755 --- a/scripts/tests.sh +++ b/scripts/tests.sh @@ -83,6 +83,9 @@ done printTask "Testing Python scripts..." "$REPO_ROOT/test/pyscriptTests.py" +printTask "Testing LSP..." +"$REPO_ROOT/scripts/test_solidity_lsp.py" "${SOLIDITY_BUILD_DIR}/solc/solc" + printTask "Running commandline tests..." # Only run in parallel if this is run on CI infrastructure if [[ -n "$CI" ]] diff --git a/solc/CommandLineInterface.cpp b/solc/CommandLineInterface.cpp index 94bfd8a68..235059a02 100644 --- a/solc/CommandLineInterface.cpp +++ b/solc/CommandLineInterface.cpp @@ -38,6 +38,8 @@ #include #include #include +#include +#include #include @@ -56,6 +58,7 @@ #include #include +#include #include #include @@ -499,7 +502,11 @@ void CommandLineInterface::readInputFiles() m_fileReader.setStdin(readUntilEnd(m_sin)); } - if (m_fileReader.sourceUnits().empty() && !m_standardJsonInput.has_value()) + if ( + m_options.input.mode != InputMode::LanguageServer && + m_fileReader.sourceUnits().empty() && + !m_standardJsonInput.has_value() + ) solThrow(CommandLineValidationError, "All specified input files either do not exist or are not regular files."); } @@ -624,6 +631,9 @@ void CommandLineInterface::processInput() m_standardJsonInput.reset(); break; } + case InputMode::LanguageServer: + serveLSP(); + break; case InputMode::Assembler: assemble(m_options.assembly.inputLanguage, m_options.assembly.targetMachine); break; @@ -884,6 +894,13 @@ void CommandLineInterface::handleAst() } } +void CommandLineInterface::serveLSP() +{ + lsp::IOStreamTransport transport; + if (!lsp::LanguageServer{transport}.run()) + solThrow(CommandLineExecutionError, "LSP terminated abnormally."); +} + void CommandLineInterface::link() { solAssert(m_options.input.mode == InputMode::Linker, ""); diff --git a/solc/CommandLineInterface.h b/solc/CommandLineInterface.h index 9d1646e52..951731825 100644 --- a/solc/CommandLineInterface.h +++ b/solc/CommandLineInterface.h @@ -82,6 +82,7 @@ private: void printVersion(); void printLicense(); void compile(); + void serveLSP(); void link(); void writeLinkedFiles(); /// @returns the ``// -> name`` hint for library placeholders. diff --git a/solc/CommandLineParser.cpp b/solc/CommandLineParser.cpp index 87348686c..65c7d7862 100644 --- a/solc/CommandLineParser.cpp +++ b/solc/CommandLineParser.cpp @@ -59,6 +59,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"; @@ -135,6 +136,7 @@ static map const g_inputModeName = { {InputMode::Assembler, "assembler"}, {InputMode::StandardJson, "standard JSON"}, {InputMode::Linker, "linker"}, + {InputMode::LanguageServer, "language server (LSP)"}, }; void CommandLineParser::checkMutuallyExclusive(vector const& _optionNames) @@ -455,6 +457,7 @@ void CommandLineParser::parseOutputSelection() case InputMode::Help: case InputMode::License: case InputMode::Version: + case InputMode::LanguageServer: solAssert(false); case InputMode::Compiler: case InputMode::CompilerWithASTImport: @@ -633,6 +636,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); @@ -865,6 +873,7 @@ void CommandLineParser::processArgs() g_strStrictAssembly, g_strYul, g_strImportAst, + g_strLSP }); if (m_args.count(g_strHelp) > 0) @@ -875,6 +884,8 @@ void 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) @@ -910,6 +921,9 @@ void CommandLineParser::processArgs() joinOptionNames(invalidOptionsForCurrentInputMode) ); + if (m_options.input.mode == InputMode::LanguageServer) + return; + checkMutuallyExclusive({g_strColor, g_strNoColor}); array const conflictingWithStopAfter{ diff --git a/solc/CommandLineParser.h b/solc/CommandLineParser.h index c1a95a11f..791e7f1c1 100644 --- a/solc/CommandLineParser.h +++ b/solc/CommandLineParser.h @@ -56,6 +56,7 @@ enum class InputMode StandardJson, Linker, Assembler, + LanguageServer }; struct CompilerOutputs diff --git a/test/libsolidity/lsp/didChange_template.sol b/test/libsolidity/lsp/didChange_template.sol new file mode 100644 index 000000000..d08ba140e --- /dev/null +++ b/test/libsolidity/lsp/didChange_template.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.0; + +contract C +{ +} diff --git a/test/libsolidity/lsp/didOpen_with_import.sol b/test/libsolidity/lsp/didOpen_with_import.sol new file mode 100644 index 000000000..f505ca6e5 --- /dev/null +++ b/test/libsolidity/lsp/didOpen_with_import.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.0; + +import './lib.sol'; + +contract C +{ + function f(uint a, uint b) public pure returns (uint) + { + return Lib.add(2 * a, b); + } +} diff --git a/test/libsolidity/lsp/lib.sol b/test/libsolidity/lsp/lib.sol new file mode 100644 index 000000000..f4fb51e77 --- /dev/null +++ b/test/libsolidity/lsp/lib.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.0; + +library Lib +{ + function add(uint a, uint b) public pure returns (uint result) + { + result = a + b; + } + + function warningWithUnused() public pure + { + uint unused; + } +} diff --git a/test/libsolidity/lsp/publish_diagnostics_1.sol b/test/libsolidity/lsp/publish_diagnostics_1.sol new file mode 100644 index 000000000..e66718512 --- /dev/null +++ b/test/libsolidity/lsp/publish_diagnostics_1.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.0; + +contract MyContract +{ + constructor() + { + uint unused; // [Warning 2072] Unused local variable. + } +} + +contract D +{ + function main() public payable returns (uint) + { + MyContract c = new MyContract(); + } +} diff --git a/test/libsolidity/lsp/publish_diagnostics_2.sol b/test/libsolidity/lsp/publish_diagnostics_2.sol new file mode 100644 index 000000000..968618955 --- /dev/null +++ b/test/libsolidity/lsp/publish_diagnostics_2.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.0; + +contract C +{ + function makeSomeError() public pure returns (uint res) + { + uint x = "hi"; + return; + res = 2; + } +} + +contract D +{ + function main() public payable returns (uint) + { + C c = new C(); + return c.makeSomeError(2, 3); + } +} diff --git a/test/libsolidity/lsp/publish_diagnostics_3.sol b/test/libsolidity/lsp/publish_diagnostics_3.sol new file mode 100644 index 000000000..bb8998a6a --- /dev/null +++ b/test/libsolidity/lsp/publish_diagnostics_3.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.0; + +abstract contract A { + function a() public virtual; +} + +contract B is A +{ +} diff --git a/test/lsp.py b/test/lsp.py new file mode 100755 index 000000000..d558c20d9 --- /dev/null +++ b/test/lsp.py @@ -0,0 +1,874 @@ +#!/usr/bin/env python3 + +import argparse +import fnmatch +import json +import os +import subprocess +import traceback + +from typing import Any, List, Optional, Tuple, Union + +import colorama # Enables the use of SGR & CUP terminal VT sequences on Windows. +from deepdiff import DeepDiff + +# {{{ JsonRpcProcess +class BadHeader(Exception): + def __init__(self, msg: str): + super().__init__("Bad header: " + msg) + +class JsonRpcProcess: + exe_path: str + exe_args: List[str] + process: subprocess.Popen + trace_io: bool + + def __init__(self, exe_path: str, exe_args: List[str], trace_io: bool = True): + self.exe_path = exe_path + self.exe_args = exe_args + self.trace_io = trace_io + + def __enter__(self): + self.process = subprocess.Popen( + [self.exe_path, *self.exe_args], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + return self + + def __exit__(self, exception_type, exception_value, traceback) -> None: + self.process.kill() + self.process.wait(timeout=2.0) + + def trace(self, topic: str, message: str) -> None: + if self.trace_io: + print(f"{SGR_TRACE}{topic}:{SGR_RESET} {message}") + + def receive_message(self) -> Union[None, dict]: + # Note, we should make use of timeout to avoid infinite blocking if nothing is received. + CONTENT_LENGTH_HEADER = "Content-Length: " + CONTENT_TYPE_HEADER = "Content-Type: " + if self.process.stdout == None: + return None + message_size = None + while True: + # read header + line = self.process.stdout.readline() + if line == '': + # server quit + return None + line = line.decode("utf-8") + if not line.endswith("\r\n"): + raise BadHeader("missing newline") + # remove the "\r\n" + line = line[:-2] + if line == '': + break # done with the headers + if line.startswith(CONTENT_LENGTH_HEADER): + line = line[len(CONTENT_LENGTH_HEADER):] + if not line.isdigit(): + raise BadHeader("size is not int") + message_size = int(line) + elif line.startswith(CONTENT_TYPE_HEADER): + # nothing todo with type for now. + pass + else: + raise BadHeader("unknown header") + if message_size is None: + raise BadHeader("missing size") + rpc_message = self.process.stdout.read(message_size).decode("utf-8") + json_object = json.loads(rpc_message) + self.trace('receive_message', json.dumps(json_object, indent=4, sort_keys=True)) + return json_object + + def send_message(self, method_name: str, params: Optional[dict]) -> None: + if self.process.stdin == None: + return + message = { + 'jsonrpc': '2.0', + 'method': method_name, + 'params': params + } + json_string = json.dumps(obj=message) + rpc_message = f"Content-Length: {len(json_string)}\r\n\r\n{json_string}" + self.trace(f'send_message ({method_name})', json.dumps(message, indent=4, sort_keys=True)) + self.process.stdin.write(rpc_message.encode("utf-8")) + self.process.stdin.flush() + + def call_method(self, method_name: str, params: Optional[dict]) -> Any: + self.send_message(method_name, params) + return self.receive_message() + + def send_notification(self, name: str, params: Optional[dict] = None) -> None: + self.send_message(name, params) + +# }}} + +SGR_RESET = '\033[m' +SGR_TRACE = '\033[1;36m' +SGR_NOTICE = '\033[1;35m' +SGR_TEST_BEGIN = '\033[1;33m' +SGR_ASSERT_BEGIN = '\033[1;34m' +SGR_STATUS_OKAY = '\033[1;32m' +SGR_STATUS_FAIL = '\033[1;31m' + +class ExpectationFailed(Exception): + def __init__(self, actual, expected): + self.actual = actual + self.expected = expected + diff = DeepDiff(actual, expected) + super().__init__( + f"Expectation failed.\n\tExpected {expected}\n\tbut got {actual}.\n\t{diff}" + ) + +def create_cli_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Solidity LSP Test suite") + parser.set_defaults(trace_io=False) + parser.add_argument( + "-T, --trace-io", + dest="trace_io", + action="store_true", + help="Be more verbose by also printing assertions." + ) + parser.set_defaults(print_assertions=False) + parser.add_argument( + "-v, --print-assertions", + dest="print_assertions", + action="store_true", + help="Be more verbose by also printing assertions." + ) + parser.add_argument( + "-t, --test-pattern", + dest="test_pattern", + type=str, + default="*", + help="Filters all available tests by matching against this test pattern (using globbing)", + nargs="?" + ) + parser.add_argument( + "solc_path", + type=str, + default="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="?" + ) + return parser + +class Counter: + total: int = 0 + passed: int = 0 + failed: int = 0 + +class SolidityLSPTestSuite: # {{{ + test_counter = Counter() + assertion_counter = Counter() + print_assertions: bool = False + trace_io: bool = False + test_pattern: str + + def __init__(self): + colorama.init() + args = create_cli_parser().parse_args() + self.solc_path = args.solc_path + self.project_root_dir = os.path.realpath(args.project_root_dir) + "/test/libsolidity/lsp" + self.project_root_uri = "file://" + self.project_root_dir + self.print_assertions = args.print_assertions + self.trace_io = args.trace_io + self.test_pattern = args.test_pattern + + print(f"{SGR_NOTICE}test pattern: {self.test_pattern}{SGR_RESET}") + + def main(self) -> int: + """ + Runs all test cases. + Returns 0 on success and the number of failing assertions (capped to 127) otherwise. + """ + all_tests = sorted([ + str(name)[5:] + for name in dir(SolidityLSPTestSuite) + if callable(getattr(SolidityLSPTestSuite, name)) and name.startswith("test_") + ]) + filtered_tests = fnmatch.filter(all_tests, self.test_pattern) + for method_name in filtered_tests: + test_fn = getattr(self, 'test_' + method_name) + title: str = test_fn.__name__[5:] + print(f"{SGR_TEST_BEGIN}Testing {title} ...{SGR_RESET}") + try: + with JsonRpcProcess(self.solc_path, ["--lsp"], trace_io=self.trace_io) as solc: + test_fn(solc) + self.test_counter.passed += 1 + except ExpectationFailed as e: + self.test_counter.failed += 1 + print(e) + print(traceback.format_exc()) + except Exception as e: # pragma pylint: disable=broad-except + self.test_counter.failed += 1 + print(f"Unhandled exception {e.__class__.__name__} caught: {e}") + print(traceback.format_exc()) + + print( + f"\n{SGR_NOTICE}Summary:{SGR_RESET}\n\n" + f" Test cases: {self.test_counter.passed} passed, {self.test_counter.failed} failed\n" + f" Assertions: {self.assertion_counter.passed} passed, {self.assertion_counter.failed} failed\n" + ) + + return min(max(self.test_counter.failed, self.assertion_counter.failed), 127) + + def setup_lsp(self, lsp: JsonRpcProcess, expose_project_root=True): + """ + Prepares the solc LSP server by calling `initialize`, + and `initialized` methods. + """ + params = { + 'processId': None, + 'rootUri': self.project_root_uri, + 'trace': 'off', + 'initializationOptions': {}, + 'capabilities': { + 'textDocument': { + 'publishDiagnostics': {'relatedInformation': True} + }, + 'workspace': { + 'applyEdit': True, + 'configuration': True, + 'didChangeConfiguration': {'dynamicRegistration': True}, + 'workspaceEdit': {'documentChanges': True}, + 'workspaceFolders': True + } + } + } + if expose_project_root == False: + params['rootUri'] = None + lsp.call_method('initialize', params) + lsp.send_notification('initialized') + + # {{{ 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): + """ + Reads the file contents from disc for a given test case. + The `test_case_name` will be the basename of the file + in the test path (test/libsolidity/lsp). + """ + with open(self.get_test_file_path(test_case_name), mode="r", encoding="utf-8", newline='') as f: + return f.read() + + def require_params_for_method(self, method_name: str, message: dict) -> Any: + """ + Ensures the given RPC message does contain the + field 'method' with the given method name, + and then returns its passed params. + An exception is raised on expectation failures. + """ + assert message is not None + if 'error' in message.keys(): + code = message['error']["code"] + text = message['error']['message'] + raise RuntimeError(f"Error {code} received. {text}") + if 'method' not in message.keys(): + raise RuntimeError("No method received but something else.") + self.expect_equal(message['method'], method_name, "Ensure expected method name") + return message['params'] + + def wait_for_diagnostics(self, solc: JsonRpcProcess, count: int) -> List[dict]: + """ + Return `count` number of published diagnostic reports sorted by file URI. + """ + reports = [] + for _ in range(0, count): + message = solc.receive_message() + assert message is not None # This can happen if the server aborts early. + reports.append( + self.require_params_for_method( + 'textDocument/publishDiagnostics', + message, + ) + ) + return sorted(reports, key=lambda x: x['uri']) + + def open_file_and_wait_for_diagnostics( + self, + solc_process: JsonRpcProcess, + test_case_name: str, + max_diagnostic_reports: int = 1 + ) -> List[Any]: + """ + Opens file for given test case and waits for diagnostics to be published. + """ + assert max_diagnostic_reports > 0 + solc_process.send_message( + 'textDocument/didOpen', + { + 'textDocument': + { + 'uri': self.get_test_file_uri(test_case_name), + 'languageId': 'Solidity', + 'version': 1, + 'text': self.get_test_file_contents(test_case_name) + } + } + ) + return self.wait_for_diagnostics(solc_process, max_diagnostic_reports) + + def expect_equal(self, actual, expected, description="Equality") -> None: + self.assertion_counter.total += 1 + prefix = f"[{self.assertion_counter.total}] {SGR_ASSERT_BEGIN}{description}: " + diff = DeepDiff(actual, expected) + if len(diff) == 0: + self.assertion_counter.passed += 1 + if self.print_assertions: + print(prefix + SGR_STATUS_OKAY + 'OK' + SGR_RESET) + return + + # Failed assertions are always printed. + self.assertion_counter.failed += 1 + print(prefix + SGR_STATUS_FAIL + 'FAILED' + SGR_RESET) + raise ExpectationFailed(actual, expected) + + def expect_empty_diagnostics(self, published_diagnostics: List[dict]) -> None: + self.expect_equal(len(published_diagnostics), 1, "one publish diagnostics notification") + self.expect_equal(len(published_diagnostics[0]['diagnostics']), 0, "should not contain diagnostics") + + def expect_diagnostic( + self, + diagnostic, + code: int, + lineNo: int, + startEndColumns: Tuple[int, int] + ): + assert len(startEndColumns) == 2 + [startColumn, endColumn] = startEndColumns + self.expect_equal(diagnostic['code'], code, f'diagnostic: {code}') + self.expect_equal( + diagnostic['range'], + { + 'start': {'character': startColumn, 'line': lineNo}, + 'end': {'character': endColumn, 'line': lineNo} + }, + "diagnostic: check range" + ) + # }}} + + # {{{ actual tests + def test_publish_diagnostics_warnings(self, solc: JsonRpcProcess) -> None: + self.setup_lsp(solc) + TEST_NAME = 'publish_diagnostics_1' + published_diagnostics = self.open_file_and_wait_for_diagnostics(solc, TEST_NAME) + + self.expect_equal(len(published_diagnostics), 1, "One published_diagnostics message") + report = published_diagnostics[0] + + self.expect_equal(report['uri'], self.get_test_file_uri(TEST_NAME), "Correct file URI") + diagnostics = report['diagnostics'] + + self.expect_equal(len(diagnostics), 3, "3 diagnostic messages") + self.expect_diagnostic(diagnostics[0], code=6321, lineNo=13, startEndColumns=(44, 48)) + self.expect_diagnostic(diagnostics[1], code=2072, lineNo= 7, startEndColumns=( 8, 19)) + self.expect_diagnostic(diagnostics[2], code=2072, lineNo=15, startEndColumns=( 8, 20)) + + def test_publish_diagnostics_errors(self, solc: JsonRpcProcess) -> None: + self.setup_lsp(solc) + TEST_NAME = 'publish_diagnostics_2' + published_diagnostics = self.open_file_and_wait_for_diagnostics(solc, TEST_NAME) + + self.expect_equal(len(published_diagnostics), 1, "One published_diagnostics message") + report = published_diagnostics[0] + + self.expect_equal(report['uri'], self.get_test_file_uri(TEST_NAME), "Correct file URI") + diagnostics = report['diagnostics'] + + self.expect_equal(len(diagnostics), 3, "3 diagnostic messages") + self.expect_diagnostic(diagnostics[0], code=9574, lineNo= 7, startEndColumns=( 8, 21)) + self.expect_diagnostic(diagnostics[1], code=6777, lineNo= 8, startEndColumns=( 8, 15)) + self.expect_diagnostic(diagnostics[2], code=6160, lineNo=18, startEndColumns=(15, 36)) + + def test_publish_diagnostics_errors_multiline(self, solc: JsonRpcProcess) -> None: + self.setup_lsp(solc) + TEST_NAME = 'publish_diagnostics_3' + published_diagnostics = self.open_file_and_wait_for_diagnostics(solc, TEST_NAME) + + self.expect_equal(len(published_diagnostics), 1, "One published_diagnostics message") + report = published_diagnostics[0] + + self.expect_equal(report['uri'], self.get_test_file_uri(TEST_NAME), "Correct file URI") + diagnostics = report['diagnostics'] + + self.expect_equal(len(diagnostics), 1, "3 diagnostic messages") + self.expect_equal(diagnostics[0]['code'], 3656, "diagnostic: check code") + self.expect_equal( + diagnostics[0]['range'], + { + 'end': {'character': 1, 'line': 9}, + 'start': {'character': 0, 'line': 7} + }, + "diagnostic: check range" + ) + + def test_textDocument_didOpen_with_relative_import(self, solc: JsonRpcProcess) -> None: + self.setup_lsp(solc) + TEST_NAME = 'didOpen_with_import' + published_diagnostics = self.open_file_and_wait_for_diagnostics(solc, TEST_NAME, 2) + + self.expect_equal(len(published_diagnostics), 2, "Diagnostic reports for 2 files") + + # primary file: + report = published_diagnostics[0] + self.expect_equal(report['uri'], self.get_test_file_uri(TEST_NAME), "Correct file URI") + self.expect_equal(len(report['diagnostics']), 0, "no diagnostics") + + # imported file (./lib.sol): + report = published_diagnostics[1] + self.expect_equal(report['uri'], self.get_test_file_uri('lib'), "Correct file URI") + self.expect_equal(len(report['diagnostics']), 1, "one diagnostic") + self.expect_diagnostic(report['diagnostics'][0], code=2072, lineNo=12, startEndColumns=(8, 19)) + + def test_didChange_in_A_causing_error_in_B(self, solc: JsonRpcProcess) -> None: + # Reusing another test but now change some file that generates an error in the other. + self.test_textDocument_didOpen_with_relative_import(solc) + self.open_file_and_wait_for_diagnostics(solc, 'lib', 2) + solc.send_message( + 'textDocument/didChange', + { + 'textDocument': + { + 'uri': self.get_test_file_uri('lib') + }, + 'contentChanges': + [ + { + 'range': { + 'start': { 'line': 5, 'character': 0 }, + 'end': { 'line': 10, 'character': 0 } + }, + 'text': "" # deleting function `add` + } + ] + } + ) + published_diagnostics = self.wait_for_diagnostics(solc, 2) + self.expect_equal(len(published_diagnostics), 2, "Diagnostic reports for 2 files") + + # Main file now contains a new diagnostic + report = published_diagnostics[0] + self.expect_equal(report['uri'], self.get_test_file_uri('didOpen_with_import')) + diagnostics = report['diagnostics'] + self.expect_equal(len(diagnostics), 1, "now, no diagnostics") + self.expect_diagnostic(diagnostics[0], code=9582, lineNo=9, startEndColumns=(15, 22)) + + # The modified file retains the same diagnostics. + report = published_diagnostics[1] + self.expect_equal(report['uri'], self.get_test_file_uri('lib')) + self.expect_equal(len(report['diagnostics']), 0) + # The warning went away because the compiler aborts further processing after the error. + + def test_textDocument_didOpen_with_relative_import_without_project_url(self, solc: JsonRpcProcess) -> None: + self.setup_lsp(solc, expose_project_root=False) + TEST_NAME = 'didOpen_with_import' + published_diagnostics = self.open_file_and_wait_for_diagnostics(solc, TEST_NAME, 2) + self.verify_didOpen_with_import_diagnostics(published_diagnostics) + + def verify_didOpen_with_import_diagnostics( + self, + published_diagnostics: List[Any], + main_file_name='didOpen_with_import' + ): + self.expect_equal(len(published_diagnostics), 2, "Diagnostic reports for 2 files") + + # primary file: + report = published_diagnostics[0] + self.expect_equal(report['uri'], self.get_test_file_uri(main_file_name), "Correct file URI") + self.expect_equal(len(report['diagnostics']), 0, "one diagnostic") + + # imported file (./lib.sol): + report = published_diagnostics[1] + self.expect_equal(report['uri'], self.get_test_file_uri('lib'), "Correct file URI") + self.expect_equal(len(report['diagnostics']), 1, "one diagnostic") + self.expect_diagnostic(report['diagnostics'][0], code=2072, lineNo=12, startEndColumns=(8, 19)) + + def test_textDocument_didChange_updates_diagnostics(self, solc: JsonRpcProcess) -> None: + self.setup_lsp(solc) + TEST_NAME = 'publish_diagnostics_1' + published_diagnostics = self.open_file_and_wait_for_diagnostics(solc, TEST_NAME) + self.expect_equal(len(published_diagnostics), 1, "One published_diagnostics message") + report = published_diagnostics[0] + self.expect_equal(report['uri'], self.get_test_file_uri(TEST_NAME), "Correct file URI") + diagnostics = report['diagnostics'] + self.expect_equal(len(diagnostics), 3, "3 diagnostic messages") + self.expect_diagnostic(diagnostics[0], code=6321, lineNo=13, startEndColumns=(44, 48)) + self.expect_diagnostic(diagnostics[1], code=2072, lineNo= 7, startEndColumns=( 8, 19)) + self.expect_diagnostic(diagnostics[2], code=2072, lineNo=15, startEndColumns=( 8, 20)) + + solc.send_message( + 'textDocument/didChange', + { + 'textDocument': { + 'uri': self.get_test_file_uri(TEST_NAME) + }, + 'contentChanges': [ + { + 'range': { + 'start': { 'line': 7, 'character': 1 }, + 'end': { 'line': 8, 'character': 1 } + }, + 'text': "" + } + ] + } + ) + published_diagnostics = self.wait_for_diagnostics(solc, 1) + self.expect_equal(len(published_diagnostics), 1) + report = published_diagnostics[0] + self.expect_equal(report['uri'], self.get_test_file_uri(TEST_NAME), "Correct file URI") + diagnostics = report['diagnostics'] + self.expect_equal(len(diagnostics), 2) + self.expect_diagnostic(diagnostics[0], code=6321, lineNo=12, startEndColumns=(44, 48)) + self.expect_diagnostic(diagnostics[1], code=2072, lineNo=14, startEndColumns=( 8, 20)) + + def test_textDocument_didChange_delete_line_and_close(self, solc: JsonRpcProcess) -> None: + # Reuse this test to prepare and ensure it is as expected + self.test_textDocument_didOpen_with_relative_import(solc) + self.open_file_and_wait_for_diagnostics(solc, 'lib', 2) + # lib.sol: Fix the unused variable message by removing it. + solc.send_message( + 'textDocument/didChange', + { + 'textDocument': + { + 'uri': self.get_test_file_uri('lib') + }, + 'contentChanges': # delete the in-body statement: `uint unused;` + [ + { + 'range': + { + 'start': { 'line': 12, 'character': 1 }, + 'end': { 'line': 13, 'character': 1 } + }, + 'text': "" + } + ] + } + ) + published_diagnostics = self.wait_for_diagnostics(solc, 2) + self.expect_equal(len(published_diagnostics), 2, "published diagnostics count") + report1 = published_diagnostics[0] + self.expect_equal(report1['uri'], self.get_test_file_uri('didOpen_with_import'), "Correct file URI") + self.expect_equal(len(report1['diagnostics']), 0, "no diagnostics in didOpen_with_import.sol") + report2 = published_diagnostics[1] + self.expect_equal(report2['uri'], self.get_test_file_uri('lib'), "Correct file URI") + self.expect_equal(len(report2['diagnostics']), 0, "no diagnostics in lib.sol") + + # Now close the file and expect the warning to re-appear + solc.send_message( + 'textDocument/didClose', + { 'textDocument': { 'uri': self.get_test_file_uri('lib') }} + ) + + published_diagnostics = self.wait_for_diagnostics(solc, 2) + self.verify_didOpen_with_import_diagnostics(published_diagnostics) + + def test_textDocument_opening_two_new_files_edit_and_close(self, solc: JsonRpcProcess) -> None: + """ + Open two new files A and B, let A import B, expect no error, + then close B and now expect the error of file B not being found. + """ + + self.setup_lsp(solc) + FILE_A_URI = 'file:///a.sol' + solc.send_message('textDocument/didOpen', { + 'textDocument': { + 'uri': FILE_A_URI, + 'languageId': 'Solidity', + 'version': 1, + 'text': ''.join([ + '// SPDX-License-Identifier: UNLICENSED\n', + 'pragma solidity >=0.8.0;\n', + ]) + } + }) + reports = self.wait_for_diagnostics(solc, 1) + self.expect_equal(len(reports), 1, "one publish diagnostics notification") + self.expect_equal(len(reports[0]['diagnostics']), 0, "should not contain diagnostics") + + FILE_B_URI = 'file:///b.sol' + solc.send_message('textDocument/didOpen', { + 'textDocument': { + 'uri': FILE_B_URI, + 'languageId': 'Solidity', + 'version': 1, + 'text': ''.join([ + '// SPDX-License-Identifier: UNLICENSED\n', + 'pragma solidity >=0.8.0;\n', + ]) + } + }) + reports = self.wait_for_diagnostics(solc, 2) + self.expect_equal(len(reports), 2, "one publish diagnostics notification") + self.expect_equal(len(reports[0]['diagnostics']), 0, "should not contain diagnostics") + self.expect_equal(len(reports[1]['diagnostics']), 0, "should not contain diagnostics") + + solc.send_message('textDocument/didChange', { + 'textDocument': { + 'uri': FILE_A_URI + }, + 'contentChanges': [ + { + 'range': { + 'start': { 'line': 2, 'character': 0 }, + 'end': { 'line': 2, 'character': 0 } + }, + 'text': 'import "./b.sol";\n' + } + ] + }) + reports = self.wait_for_diagnostics(solc, 2) + self.expect_equal(len(reports), 2, "one publish diagnostics notification") + self.expect_equal(len(reports[0]['diagnostics']), 0, "should not contain diagnostics") + self.expect_equal(len(reports[1]['diagnostics']), 0, "should not contain diagnostics") + + solc.send_message( + 'textDocument/didClose', + { 'textDocument': { 'uri': FILE_B_URI }} + ) + # We only get one diagnostics message since the diagnostics for b.sol was empty. + reports = self.wait_for_diagnostics(solc, 1) + self.expect_equal(len(reports), 1, "one publish diagnostics notification") + self.expect_diagnostic(reports[0]['diagnostics'][0], 6275, 2, (0, 17)) # a.sol: File B not found + self.expect_equal(reports[0]['uri'], FILE_A_URI, "Correct uri") + + def test_textDocument_closing_virtual_file_removes_imported_real_file(self, solc: JsonRpcProcess) -> None: + """ + We open a virtual file that imports a real file with a warning. + Once we close the virtual file, the warning is removed from the diagnostics, + since the real file is not considered part of the project anymore. + """ + + self.setup_lsp(solc) + FILE_A_URI = f'file://{self.project_root_dir}/a.sol' + solc.send_message('textDocument/didOpen', { + 'textDocument': { + 'uri': FILE_A_URI, + 'languageId': 'Solidity', + 'version': 1, + 'text': + '// SPDX-License-Identifier: UNLICENSED\n' + 'pragma solidity >=0.8.0;\n' + 'import "./lib.sol";\n' + } + }) + reports = self.wait_for_diagnostics(solc, 2) + self.expect_equal(len(reports), 2, '') + self.expect_equal(len(reports[0]['diagnostics']), 0, "should not contain diagnostics") + self.expect_diagnostic(reports[1]['diagnostics'][0], 2072, 12, (8, 19)) # unused variable in lib.sol + + # Now close the file and expect the warning for lib.sol to be removed + solc.send_message( + 'textDocument/didClose', + { 'textDocument': { 'uri': FILE_A_URI }} + ) + reports = self.wait_for_diagnostics(solc, 1) + self.expect_equal(len(reports), 1, '') + self.expect_equal(reports[0]['uri'], f'file://{self.project_root_dir}/lib.sol', "") + self.expect_equal(len(reports[0]['diagnostics']), 0, "should not contain diagnostics") + + + def test_textDocument_didChange_at_eol(self, solc: JsonRpcProcess) -> None: + """ + Append at one line and insert a new one below. + """ + self.setup_lsp(solc) + FILE_NAME = 'didChange_template' + FILE_URI = self.get_test_file_uri(FILE_NAME) + solc.send_message('textDocument/didOpen', { + 'textDocument': { + 'uri': FILE_URI, + 'languageId': 'Solidity', + 'version': 1, + 'text': self.get_test_file_contents(FILE_NAME) + } + }) + published_diagnostics = self.wait_for_diagnostics(solc, 1) + self.expect_equal(len(published_diagnostics), 1, "one publish diagnostics notification") + self.expect_equal(len(published_diagnostics[0]['diagnostics']), 0, "no diagnostics") + solc.send_message('textDocument/didChange', { + 'textDocument': { + 'uri': FILE_URI + }, + 'contentChanges': [ + { + 'range': { + 'start': { 'line': 6, 'character': 0 }, + 'end': { 'line': 6, 'character': 0 } + }, + 'text': " f" + } + ] + }) + published_diagnostics = self.wait_for_diagnostics(solc, 1) + self.expect_equal(len(published_diagnostics), 1, "one publish diagnostics notification") + report2 = published_diagnostics[0] + self.expect_equal(report2['uri'], FILE_URI, "Correct file URI") + self.expect_equal(len(report2['diagnostics']), 1, "one diagnostic") + self.expect_diagnostic(report2['diagnostics'][0], 7858, 6, (1, 2)) + + solc.send_message('textDocument/didChange', { + 'textDocument': { 'uri': FILE_URI }, + 'contentChanges': [ + { + 'range': { + 'start': { 'line': 6, 'character': 2 }, + 'end': { 'line': 6, 'character': 2 } + }, + 'text': 'unction f() public {}' + } + ] + }) + published_diagnostics = self.wait_for_diagnostics(solc, 1) + self.expect_equal(len(published_diagnostics), 1, "one publish diagnostics notification") + report3 = published_diagnostics[0] + self.expect_equal(report3['uri'], FILE_URI, "Correct file URI") + self.expect_equal(len(report3['diagnostics']), 1, "one diagnostic") + self.expect_diagnostic(report3['diagnostics'][0], 4126, 6, (1, 23)) + + def test_textDocument_didChange_empty_file(self, solc: JsonRpcProcess) -> None: + """ + Starts with an empty file and changes it to look like + the didOpen_with_import test case. Then we can use + the same verification calls to ensure it worked as expected. + """ + # This FILE_NAME must be alphabetically before lib.sol to not over-complify + # the test logic in verify_didOpen_with_import_diagnostics. + FILE_NAME = 'a_new_file' + FILE_URI = self.get_test_file_uri(FILE_NAME) + self.setup_lsp(solc) + solc.send_message('textDocument/didOpen', { + 'textDocument': { + 'uri': FILE_URI, + 'languageId': 'Solidity', + 'version': 1, + 'text': '' + } + }) + reports = self.wait_for_diagnostics(solc, 1) + self.expect_equal(len(reports), 1) + report = reports[0] + published_diagnostics = report['diagnostics'] + self.expect_equal(len(published_diagnostics), 2) + self.expect_diagnostic(published_diagnostics[0], code=1878, lineNo=0, startEndColumns=(0, 0)) + self.expect_diagnostic(published_diagnostics[1], code=3420, lineNo=0, startEndColumns=(0, 0)) + solc.send_message('textDocument/didChange', { + 'textDocument': { + 'uri': self.get_test_file_uri('a_new_file') + }, + 'contentChanges': [ + { + 'range': { + 'start': { 'line': 0, 'character': 0 }, + 'end': { 'line': 0, 'character': 0 } + }, + 'text': self.get_test_file_contents('didOpen_with_import') + } + ] + }) + published_diagnostics = self.wait_for_diagnostics(solc, 2) + self.verify_didOpen_with_import_diagnostics(published_diagnostics, 'a_new_file') + + def test_textDocument_didChange_multi_line(self, solc: JsonRpcProcess) -> None: + """ + Starts with an empty file and changes it to multiple times, changing + content across lines. + """ + self.setup_lsp(solc) + FILE_NAME = 'didChange_template' + FILE_URI = self.get_test_file_uri(FILE_NAME) + solc.send_message('textDocument/didOpen', { + 'textDocument': { + 'uri': FILE_URI, + 'languageId': 'Solidity', + 'version': 1, + 'text': self.get_test_file_contents(FILE_NAME) + } + }) + published_diagnostics = self.wait_for_diagnostics(solc, 1) + self.expect_equal(len(published_diagnostics), 1, "one publish diagnostics notification") + self.expect_equal(len(published_diagnostics[0]['diagnostics']), 0, "no diagnostics") + solc.send_message('textDocument/didChange', { + 'textDocument': { 'uri': FILE_URI }, + 'contentChanges': [ + { + 'range': { + 'start': { 'line': 3, 'character': 3 }, + 'end': { 'line': 4, 'character': 1 } + }, + 'text': "tract D {\n\n uint x\n = -1; \n " + } + ] + }) + published_diagnostics = self.wait_for_diagnostics(solc, 1) + self.expect_equal(len(published_diagnostics), 1, "one publish diagnostics notification") + report2 = published_diagnostics[0] + self.expect_equal(report2['uri'], FILE_URI, "Correct file URI") + self.expect_equal(len(report2['diagnostics']), 1, "one diagnostic") + self.expect_diagnostic(report2['diagnostics'][0], 7407, 6, (3, 5)) + + # Now we are changing the part "x\n = -" of "uint x\n = -1;" + solc.send_message('textDocument/didChange', { + 'textDocument': { 'uri': FILE_URI }, + 'contentChanges': [ + { + 'range': { + 'start': { 'line': 5, 'character': 7 }, + 'end': { 'line': 6, 'character': 4 } + }, + 'text': "y\n = [\nuint(1),\n3,4]+" + } + ] + }) + published_diagnostics = self.wait_for_diagnostics(solc, 1) + self.expect_equal(len(published_diagnostics), 1, "one publish diagnostics notification") + report3 = published_diagnostics[0] + self.expect_equal(report3['uri'], FILE_URI, "Correct file URI") + self.expect_equal(len(report3['diagnostics']), 2, "two diagnostics") + diagnostic = report3['diagnostics'][0] + self.expect_equal(diagnostic['code'], 2271, 'diagnostic: 2271') + # check multi-line error code + self.expect_equal( + diagnostic['range'], + { + 'end': {'character': 6, 'line': 8}, + 'start': {'character': 3, 'line': 6} + }, + "diagnostic: check range" + ) + diagnostic = report3['diagnostics'][1] + self.expect_equal(diagnostic['code'], 7407, 'diagnostic: 7407') + # check multi-line error code + self.expect_equal( + diagnostic['range'], + { + 'end': {'character': 6, 'line': 8}, + 'start': {'character': 3, 'line': 6} + }, + "diagnostic: check range" + ) + + # }}} + # }}} + +if __name__ == "__main__": + suite = SolidityLSPTestSuite() + exit_code = suite.main() + exit(exit_code) diff --git a/test/solc/CommandLineInterface.cpp b/test/solc/CommandLineInterface.cpp index 494dbbd94..73c999f3f 100644 --- a/test/solc/CommandLineInterface.cpp +++ b/test/solc/CommandLineInterface.cpp @@ -158,7 +158,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."; for (string const& mode1: inputModeOptions)