diff --git a/clang-tools-extra/clangd/unittests/CMakeLists.txt b/clang-tools-extra/clangd/unittests/CMakeLists.txt --- a/clang-tools-extra/clangd/unittests/CMakeLists.txt +++ b/clang-tools-extra/clangd/unittests/CMakeLists.txt @@ -30,6 +30,7 @@ CancellationTests.cpp CanonicalIncludesTests.cpp ClangdTests.cpp + ClangdLSPServerTests.cpp CodeCompleteTests.cpp CodeCompletionStringsTests.cpp CollectMacrosTests.cpp @@ -55,6 +56,7 @@ IndexActionTests.cpp IndexTests.cpp JSONTransportTests.cpp + LSPClient.cpp ParsedASTTests.cpp PathMappingTests.cpp PrintASTTests.cpp diff --git a/clang-tools-extra/clangd/unittests/ClangdLSPServerTests.cpp b/clang-tools-extra/clangd/unittests/ClangdLSPServerTests.cpp new file mode 100644 --- /dev/null +++ b/clang-tools-extra/clangd/unittests/ClangdLSPServerTests.cpp @@ -0,0 +1,131 @@ +//===-- ClangdLSPServerTests.cpp ------------------------------------------===// +// +// Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions. +// See https://llvm.org/LICENSE.txt for license information. +// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +// +//===----------------------------------------------------------------------===// + +#include "Annotations.h" +#include "ClangdLSPServer.h" +#include "CodeComplete.h" +#include "LSPClient.h" +#include "Logger.h" +#include "Protocol.h" +#include "TestFS.h" +#include "refactor/Rename.h" +#include "llvm/Support/JSON.h" +#include "llvm/Testing/Support/SupportHelpers.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +namespace clang { +namespace clangd { +namespace { + +MATCHER_P(DiagMessage, M, "") { + if (const auto *O = arg.getAsObject()) { + if (const auto Msg = O->getString("message")) + return *Msg == M; + } + return false; +} + +class LSPTest : public ::testing::Test, private clangd::Logger { +protected: + LSPTest() : LogSession(*this) {} + + LSPClient &start() { + EXPECT_FALSE(Server.hasValue()) << "Already initialized"; + Server.emplace(Client.transport(), FS, CCOpts, RenameOpts, + /*CompileCommandsDir=*/llvm::None, /*UseDirBasedCDB=*/false, + /*ForcedOffsetEncoding=*/llvm::None, Opts); + ServerThread.emplace([&] { EXPECT_TRUE(Server->run()); }); + Client.call("initialize", llvm::json::Object{}); + return Client; + } + + void stop() { + assert(Server); + Client.call("shutdown", nullptr); + Client.notify("exit", nullptr); + Client.stop(); + ServerThread->join(); + Server.reset(); + ServerThread.reset(); + } + + ~LSPTest() { + if (Server) + stop(); + } + + MockFSProvider FS; + CodeCompleteOptions CCOpts; + RenameOptions RenameOpts; + ClangdServer::Options Opts = ClangdServer::optsForTest(); + +private: + // Color logs so we can distinguish them from test output. + void log(Level L, const llvm::formatv_object_base &Message) override { + raw_ostream::Colors Color; + switch (L) { + case Level::Verbose: + Color = raw_ostream::BLUE; + break; + case Level::Error: + Color = raw_ostream::RED; + break; + default: + Color = raw_ostream::YELLOW; + break; + } + std::lock_guard Lock(LogMu); + (llvm::outs().changeColor(Color) << Message << "\n").resetColor(); + } + std::mutex LogMu; + + LoggingSession LogSession; + llvm::Optional Server; + llvm::Optional ServerThread; + LSPClient Client; +}; + +TEST_F(LSPTest, GoToDefinition) { + Annotations Code(R"cpp( + int [[fib]](int n) { + return n >= 2 ? ^fib(n - 1) + fib(n - 2) : 1; + } + )cpp"); + auto &Client = start(); + Client.didOpen("foo.cpp", Code.code()); + auto &Def = Client.call("textDocument/definition", + llvm::json::Object{ + {"textDocument", Client.documentID("foo.cpp")}, + {"position", Code.point()}, + }); + llvm::json::Value Want = llvm::json::Array{llvm::json::Object{ + {"uri", Client.uri("foo.cpp")}, {"range", Code.range()}}}; + EXPECT_EQ(Def.takeValue(), Want); +} + +TEST_F(LSPTest, Diagnostics) { + auto &Client = start(); + Client.didOpen("foo.cpp", "void main(int, char**);"); + EXPECT_THAT(Client.diagnostics("foo.cpp"), + llvm::ValueIs(testing::ElementsAre( + DiagMessage("'main' must return 'int' (fix available)")))); + + Client.didChange("foo.cpp", "int x = \"42\";"); + EXPECT_THAT(Client.diagnostics("foo.cpp"), + llvm::ValueIs(testing::ElementsAre( + DiagMessage("Cannot initialize a variable of type 'int' with " + "an lvalue of type 'const char [3]'")))); + + Client.didClose("foo.cpp"); + EXPECT_THAT(Client.diagnostics("foo.cpp"), llvm::ValueIs(testing::IsEmpty())); +} + +} // namespace +} // namespace clangd +} // namespace clang diff --git a/clang-tools-extra/clangd/unittests/LSPClient.h b/clang-tools-extra/clangd/unittests/LSPClient.h new file mode 100644 --- /dev/null +++ b/clang-tools-extra/clangd/unittests/LSPClient.h @@ -0,0 +1,82 @@ +//===-- LSPClient.h - Helper for ClangdLSPServer tests ----------*- C++ -*-===// +// +// Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions. +// See https://llvm.org/LICENSE.txt for license information. +// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +// +//===----------------------------------------------------------------------===// + +#include +#include +#include +#include +#include +#include + +namespace clang { +namespace clangd { +class Transport; + +// A client library for talking to ClangdLSPServer in tests. +// Manages serialization of messages, pairing requests/repsonses, and implements +// the Transport abstraction. +class LSPClient { + class TransportImpl; + std::unique_ptr T; + +public: + // Represents the result of an LSP call: a promise for a result or error. + class CallResult { + public: + ~CallResult(); + // Blocks up to 10 seconds for the result to be ready. + // Records a test failure if there was no reply. + llvm::Expected take(); + // Like take(), but records a test failure if the result was an error. + llvm::json::Value takeValue(); + + private: + // Should be called once to provide the value. + void set(llvm::Expected V); + + llvm::Optional> Value; + std::mutex Mu; + std::condition_variable CV; + + friend TransportImpl; // Calls set(). + }; + + LSPClient(); + ~LSPClient(); + LSPClient(LSPClient &&) = delete; + LSPClient &operator=(LSPClient &&) = delete; + + // Enqueue an LSP method call, returns a promise for the reply. Threadsafe. + CallResult &call(llvm::StringRef Method, llvm::json::Value Params); + // Enqueue an LSP notification. Threadsafe. + void notify(llvm::StringRef Method, llvm::json::Value Params); + // Returns matching notifications since the last call to takeNotifications. + std::vector takeNotifications(llvm::StringRef Method); + // The transport is shut down after all pending messages are sent. + void stop(); + + // Shorthand for common LSP methods. Relative paths are passed to testPath(). + static llvm::json::Value uri(llvm::StringRef Path); + static llvm::json::Value documentID(llvm::StringRef Path); + void didOpen(llvm::StringRef Path, llvm::StringRef Content); + void didChange(llvm::StringRef Path, llvm::StringRef Content); + void didClose(llvm::StringRef Path); + // Blocks until the server is idle (using the 'sync' protocol extension). + void sync(); + // sync()s to ensure pending diagnostics arrive, and returns the newest set. + llvm::Optional> + diagnostics(llvm::StringRef Path); + + // Get the transport used to connect this client to a ClangdLSPServer. + Transport &transport(); + +private: +}; + +} // namespace clangd +} // namespace clang diff --git a/clang-tools-extra/clangd/unittests/LSPClient.cpp b/clang-tools-extra/clangd/unittests/LSPClient.cpp new file mode 100644 --- /dev/null +++ b/clang-tools-extra/clangd/unittests/LSPClient.cpp @@ -0,0 +1,211 @@ +#include "LSPClient.h" +#include "gtest/gtest.h" +#include + +#include "Protocol.h" +#include "TestFS.h" +#include "Threading.h" +#include "Transport.h" +#include "llvm/Support/Path.h" +#include "llvm/Support/raw_ostream.h" +#include + +namespace clang { +namespace clangd { + +llvm::Expected clang::clangd::LSPClient::CallResult::take() { + std::unique_lock Lock(Mu); + if (!clangd::wait(Lock, CV, timeoutSeconds(10), + [this] { return Value.hasValue(); })) { + ADD_FAILURE() << "No result from call after 10 seconds!"; + return llvm::json::Value(nullptr); + } + return std::move(*Value); +} + +llvm::json::Value LSPClient::CallResult::takeValue() { + auto ExpValue = take(); + if (!ExpValue) { + ADD_FAILURE() << "takeValue(): " << llvm::toString(ExpValue.takeError()); + return llvm::json::Value(nullptr); + } + return std::move(*ExpValue); +} + +void LSPClient::CallResult::set(llvm::Expected V) { + std::lock_guard Lock(Mu); + if (Value) { + ADD_FAILURE() << "Multiple replies"; + llvm::consumeError(V.takeError()); + return; + } + Value = std::move(V); + CV.notify_all(); +} + +LSPClient::CallResult::~CallResult() { + if (Value && !*Value) { + ADD_FAILURE() << llvm::toString(Value->takeError()); + } +} + +static void logBody(llvm::StringRef Method, llvm::json::Value V, bool Send) { + // We invert <<< and >>> as the combined log is from the server's viewpoint. + vlog("{0} {1}: {2:2}", Send ? "<<<" : ">>>", Method, V); +} + +class LSPClient::TransportImpl : public Transport { +public: + std::pair addCallSlot() { + std::lock_guard Lock(Mu); + unsigned ID = CallResults.size(); + CallResults.emplace_back(); + return {ID, &CallResults.back()}; + } + + // A null action causes the transport to shut down. + void enqueue(std::function Action) { + std::lock_guard Lock(Mu); + Actions.push(std::move(Action)); + CV.notify_all(); + } + + std::vector takeNotifications(llvm::StringRef Method) { + std::vector Result; + { + std::lock_guard Lock(Mu); + std::swap(Result, Notifications[Method]); + } + return Result; + } + +private: + void reply(llvm::json::Value ID, + llvm::Expected V) override { + if (V) // Nothing additional to log for error. + logBody("reply", *V, /*Send=*/false); + std::lock_guard Lock(Mu); + if (auto I = ID.getAsInteger()) { + if (*I >= 0 && *I < static_cast(CallResults.size())) { + CallResults[*I].set(std::move(V)); + return; + } + } + ADD_FAILURE() << "Invalid reply to ID " << ID; + llvm::consumeError(std::move(V).takeError()); + } + + void notify(llvm::StringRef Method, llvm::json::Value V) override { + logBody(Method, V, /*Send=*/false); + std::lock_guard Lock(Mu); + Notifications[Method].push_back(std::move(V)); + } + + void call(llvm::StringRef Method, llvm::json::Value Params, + llvm::json::Value ID) override { + logBody(Method, Params, /*Send=*/false); + ADD_FAILURE() << "Unexpected server->client call " << Method; + } + + llvm::Error loop(MessageHandler &H) override { + std::unique_lock Lock(Mu); + while (true) { + CV.wait(Lock, [&] { return !Actions.empty(); }); + if (!Actions.front()) // Stop! + return llvm::Error::success(); + auto Action = std::move(Actions.front()); + Actions.pop(); + Lock.unlock(); + Action(H); + Lock.lock(); + } + } + + std::mutex Mu; + std::deque CallResults; + std::queue> Actions; + std::condition_variable CV; + llvm::StringMap> Notifications; +}; + +LSPClient::LSPClient() : T(std::make_unique()) {} +LSPClient::~LSPClient() = default; + +LSPClient::CallResult &LSPClient::call(llvm::StringRef Method, + llvm::json::Value Params) { + auto Slot = T->addCallSlot(); + T->enqueue([ID(Slot.first), Method(Method.str()), + Params(std::move(Params))](Transport::MessageHandler &H) { + logBody(Method, Params, /*Send=*/true); + H.onCall(Method, std::move(Params), ID); + }); + return *Slot.second; +} + +void LSPClient::notify(llvm::StringRef Method, llvm::json::Value Params) { + T->enqueue([Method(Method.str()), + Params(std::move(Params))](Transport::MessageHandler &H) { + logBody(Method, Params, /*Send=*/true); + H.onNotify(Method, std::move(Params)); + }); +} + +std::vector +LSPClient::takeNotifications(llvm::StringRef Method) { + return T->takeNotifications(Method); +} + +void LSPClient::stop() { T->enqueue(nullptr); } + +Transport &LSPClient::transport() { return *T; } + +using Obj = llvm::json::Object; + +llvm::json::Value LSPClient::uri(llvm::StringRef Path) { + std::string Storage; + if (!llvm::sys::path::is_absolute(Path)) + Path = Storage = testPath(Path); + return toJSON(URIForFile::canonicalize(Path, Path)); +} +llvm::json::Value LSPClient::documentID(llvm::StringRef Path) { + return Obj{{"uri", uri(Path)}}; +} + +void LSPClient::didOpen(llvm::StringRef Path, llvm::StringRef Content) { + notify( + "textDocument/didOpen", + Obj{{"textDocument", + Obj{{"uri", uri(Path)}, {"text", Content}, {"languageId", "cpp"}}}}); +} +void LSPClient::didChange(llvm::StringRef Path, llvm::StringRef Content) { + notify("textDocument/didChange", + Obj{{"textDocument", documentID(Path)}, + {"contentChanges", llvm::json::Array{Obj{{"text", Content}}}}}); +} +void LSPClient::didClose(llvm::StringRef Path) { + notify("textDocument/didClose", Obj{{"textDocument", documentID(Path)}}); +} + +void LSPClient::sync() { call("sync", nullptr).takeValue(); } + +llvm::Optional> +LSPClient::diagnostics(llvm::StringRef Path) { + sync(); + auto Notifications = takeNotifications("textDocument/publishDiagnostics"); + for (const auto &Notification : llvm::reverse(Notifications)) { + if (const auto *PubDiagsParams = Notification.getAsObject()) { + auto U = PubDiagsParams->getString("uri"); + auto *D = PubDiagsParams->getArray("diagnostics"); + if (!U || !D) { + ADD_FAILURE() << "Bad PublishDiagnosticsParams: " << PubDiagsParams; + continue; + } + if (*U == uri(Path)) + return std::vector(D->begin(), D->end()); + } + } + return {}; +} + +} // namespace clangd +} // namespace clang