## Summary Add LSP trace recording and replay-based smoke testing infrastructure. ### clice changes (`src/clice.cc`) - Add `--log-level` CLI option with validation (rejects unknown levels instead of silently defaulting to off) - Add `--record <path>` CLI option that wraps the transport with `RecordingTransport` to capture client→server messages as timestamped JSONL - Works in both pipe and socket modes - Fix exit code: `loop.run()` returns non-zero after `uv_stop()`, explicitly return 0 after clean shutdown ### Compile logging (`src/compile/compilation.cpp`) - Print compile commands at debug log level ### Replay script (`tests/replay.py`) - Timestamp-based pacing: sleeps between messages based on recorded intervals, faithful to original editor session - Automatic workspace path rewriting: infers repo root from script location, rewrites absolute paths in trace so CI replay works without extra arguments - Handles server→client requests (workDoneProgress/create, registerCapability, etc.) with default responses - Waits for all pending responses before sending shutdown/exit - Detects server exit mid-replay and fails pending futures immediately instead of hanging - Reports PASS/FAIL/SKIP with stderr tail on failure ### CI & config - Add `smoke-test` pixi task and CI workflow step (runs after integration tests) - `.gitattributes`: mark `tests/smoke/*.jsonl` as `linguist-generated binary` to suppress diffs - Add sample trace file `tests/smoke/session.jsonl` ### VSCode extension - Add restart command (`clice.restart`) - Support `CLICE_MODE` env var to override mode setting (for debug launch configs) - Split launch configs into socket/pipe variants with `--disable-extensions` ## Test plan - [x] `python tests/replay.py tests/smoke/session.jsonl --clice ./build/RelWithDebInfo/bin/clice` passes locally - [ ] CI smoke test passes on Linux/macOS/Windows 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
451 lines
17 KiB
C++
451 lines
17 KiB
C++
#include "compile/compilation.h"
|
|
|
|
#include "command/command.h"
|
|
#include "compile/diagnostic.h"
|
|
#include "compile/implement.h"
|
|
#include "semantic/ast_utility.h"
|
|
#include "support/logging.h"
|
|
|
|
#include "llvm/Support/Error.h"
|
|
#include "clang/Frontend/MultiplexConsumer.h"
|
|
#include "clang/Frontend/TextDiagnosticPrinter.h"
|
|
#include "clang/Lex/PreprocessorOptions.h"
|
|
|
|
namespace clice {
|
|
|
|
CompilationUnitRef::Self::~Self() {
|
|
if(action) {
|
|
// We already notified the pp of end-of-file earlier, so detach it first.
|
|
// We must keep it alive until after EndSourceFile(), Sema relies on this.
|
|
std::shared_ptr<clang::Preprocessor> pp = instance->getPreprocessorPtr();
|
|
// Detach so we don't send EOF again
|
|
instance->setPreprocessor(nullptr);
|
|
action->EndSourceFile();
|
|
}
|
|
}
|
|
|
|
std::unique_ptr<clang::CompilerInvocation>
|
|
CompilationUnitRef::Self::create_invocation(this Self& self,
|
|
CompilationParams& params,
|
|
clang::DiagnosticConsumer* consumer) {
|
|
if(params.arguments.empty()) {
|
|
LOG_ERROR_RET(nullptr, "Fail to create invocation: empty argument list from database");
|
|
}
|
|
|
|
/// Temporary diagnostic engine, only used for command line parsing.
|
|
/// For compilation, we need to create a new diagnostic engine. See also
|
|
/// https://github.com/llvm/llvm-project/pull/139584#issuecomment-2920704282.
|
|
clang::DiagnosticOptions options;
|
|
llvm::IntrusiveRefCntPtr diagnostic_engine =
|
|
clang::CompilerInstance::createDiagnostics(*params.vfs, options, consumer, false);
|
|
if(!diagnostic_engine) {
|
|
LOG_ERROR_RET(nullptr, "Fail to create diagnostics engine");
|
|
}
|
|
|
|
std::unique_ptr<clang::CompilerInvocation> invocation;
|
|
|
|
/// If the second argument is "-cc1", the arguments are already expanded
|
|
/// (e.g. from compilation database + query_toolchain). Skip driver and "-cc1"
|
|
/// and create invocation directly from the cc1 args.
|
|
bool is_cc1 = params.arguments.size() >= 2 && llvm::StringRef(params.arguments[1]) == "-cc1";
|
|
if(is_cc1) {
|
|
invocation = std::make_unique<clang::CompilerInvocation>();
|
|
if(!clang::CompilerInvocation::CreateFromArgs(
|
|
*invocation,
|
|
llvm::ArrayRef(params.arguments).drop_front(2),
|
|
*diagnostic_engine,
|
|
params.arguments[0])) {
|
|
LOG_ERROR_RET(nullptr,
|
|
" Fail to create invocation, arguments list is: {}",
|
|
print_argv(params.arguments));
|
|
}
|
|
} else {
|
|
/// Create clang invocation.
|
|
clang::CreateInvocationOptions options = {
|
|
.Diags = diagnostic_engine,
|
|
.VFS = params.vfs,
|
|
|
|
/// Avoid replacing -include with -include-pch, also
|
|
/// see https://github.com/clangd/clangd/issues/856.
|
|
.ProbePrecompiled = false,
|
|
};
|
|
|
|
invocation = clang::createInvocation(params.arguments, options);
|
|
if(!invocation) {
|
|
LOG_ERROR_RET(nullptr,
|
|
" Fail to create invocation, arguments list is: {}",
|
|
print_argv(params.arguments));
|
|
}
|
|
}
|
|
|
|
auto& pp_opts = invocation->getPreprocessorOpts();
|
|
|
|
// CompilerInstance does not deterministically clear RetainRemappedFileBuffers,
|
|
// especially if compilation aborts early, so we keep them alive and clean up
|
|
// in CompilationUnit's destructor instead.
|
|
pp_opts.RetainRemappedFileBuffers = true;
|
|
|
|
for(auto& [file, buffer]: params.buffers) {
|
|
pp_opts.addRemappedFile(file, buffer.get());
|
|
}
|
|
self.remapped_buffers = std::move(params.buffers);
|
|
|
|
auto [pch, bound] = params.pch;
|
|
pp_opts.ImplicitPCHInclude = std::move(pch);
|
|
if(bound != 0) {
|
|
pp_opts.PrecompiledPreambleBytes = {bound, false};
|
|
}
|
|
|
|
// We don't want to write comment locations into PCM. They are racy and slow
|
|
// to read back. We rely on dynamic index for the comments instead.
|
|
pp_opts.WriteCommentListToPCH = false;
|
|
|
|
auto& header_search_opts = invocation->getHeaderSearchOpts();
|
|
header_search_opts.Verbose = false;
|
|
for(auto& [name, path]: params.pcms) {
|
|
header_search_opts.PrebuiltModuleFiles.try_emplace(name.str(), std::move(path));
|
|
}
|
|
|
|
auto& front_opts = invocation->getFrontendOpts();
|
|
front_opts.DisableFree = false;
|
|
front_opts.ShowHelp = false;
|
|
front_opts.ShowStats = false;
|
|
front_opts.ShowVersion = false;
|
|
front_opts.StatsFile = "";
|
|
front_opts.TimeTracePath = "";
|
|
front_opts.TimeTraceVerbose = false;
|
|
front_opts.TimeTraceGranularity = 0;
|
|
front_opts.PrintSupportedCPUs = false;
|
|
front_opts.PrintEnabledExtensions = false;
|
|
front_opts.PrintSupportedExtensions = false;
|
|
|
|
/// Compiler flags (like gcc/clang's -M, -MD, -MMD, -H, or msvc's /showIncludes)
|
|
/// can generate dependency files or print included headers to stdout/stderr.
|
|
///
|
|
/// This output can interfere with or corrupt the Language Server Protocol (LSP)
|
|
/// communication if the server is configured to use stdio for its JSON-RPC transport.
|
|
/// We explicitly disables all related options to ensure no side-effect output is
|
|
/// generated during parsing.
|
|
auto& deps_opts = invocation->getDependencyOutputOpts();
|
|
deps_opts.IncludeSystemHeaders = false;
|
|
deps_opts.ShowSkippedHeaderIncludes = false;
|
|
deps_opts.UsePhonyTargets = false;
|
|
deps_opts.AddMissingHeaderDeps = false;
|
|
deps_opts.IncludeModuleFiles = false;
|
|
deps_opts.ShowIncludesDest = clang::ShowIncludesDestination::None;
|
|
deps_opts.OutputFile.clear();
|
|
deps_opts.HeaderIncludeOutputFile.clear();
|
|
deps_opts.Targets.clear();
|
|
deps_opts.ExtraDeps.clear();
|
|
deps_opts.DOTOutputFile.clear();
|
|
deps_opts.ModuleDependencyOutputDir.clear();
|
|
|
|
auto& lang_opts = invocation->getLangOpts();
|
|
lang_opts.CommentOpts.ParseAllComments = true;
|
|
lang_opts.RetainCommentsFromSystemHeaders = true;
|
|
|
|
return invocation;
|
|
}
|
|
|
|
void CompilationUnitRef::Self::configure_tidy(tidy::TidyParams tidy_params) {
|
|
checker = tidy::configure(*instance, tidy_params);
|
|
}
|
|
|
|
void CompilationUnitRef::Self::run_tidy() {
|
|
if(checker) {
|
|
// AST traversals should exclude the preamble, to avoid performance cliffs.
|
|
// TODO: is it okay to affect the unit-level traversal scope here?
|
|
auto& Ctx = instance->getASTContext();
|
|
Ctx.setTraversalScope(top_level_decls);
|
|
checker->finder.matchAST(Ctx);
|
|
|
|
/// XXX: This is messy: clang-tidy checks flush some diagnostics at EOF.
|
|
/// However Action->EndSourceFile() would destroy the ASTContext!
|
|
/// So just inform the preprocessor of EOF, while keeping everything alive.
|
|
instance->getPreprocessor().EndSourceFile();
|
|
}
|
|
}
|
|
|
|
namespace {
|
|
|
|
/// A wrapper ast consumer, so that we can cancel the ast parse
|
|
class ProxyASTConsumer final : public clang::MultiplexConsumer {
|
|
public:
|
|
ProxyASTConsumer(std::unique_ptr<clang::ASTConsumer> consumer, CompilationUnitRef unit) :
|
|
clang::MultiplexConsumer(std::move(consumer)), unit(unit) {}
|
|
|
|
void collect_decl(clang::Decl* decl) {
|
|
if(unit.file_id(unit.expansion_location(decl->getLocation())) != unit.interested_file()) {
|
|
return;
|
|
}
|
|
|
|
if(const clang::NamedDecl* named_decl = dyn_cast<clang::NamedDecl>(decl)) {
|
|
if(ast::is_implicit_template_instantiation(named_decl)) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
unit->top_level_decls.push_back(decl);
|
|
}
|
|
|
|
auto HandleTopLevelDecl(clang::DeclGroupRef group) -> bool final {
|
|
if(unit->kind == CompilationKind::Content) {
|
|
if(group.isDeclGroup()) {
|
|
for(auto decl: group) {
|
|
collect_decl(decl);
|
|
}
|
|
} else {
|
|
collect_decl(group.getSingleDecl());
|
|
}
|
|
}
|
|
|
|
/// TODO: check atomic variable after the parse of each declaration
|
|
/// may result in performance issue, benchmark in the future.
|
|
if(unit->stop && unit->stop->load()) {
|
|
return false;
|
|
}
|
|
|
|
return clang::MultiplexConsumer::HandleTopLevelDecl(group);
|
|
}
|
|
|
|
private:
|
|
CompilationUnitRef unit;
|
|
};
|
|
|
|
class ProxyAction final : public clang::WrapperFrontendAction {
|
|
public:
|
|
ProxyAction(std::unique_ptr<clang::FrontendAction> action, CompilationUnitRef unit) :
|
|
clang::WrapperFrontendAction(std::move(action)), unit(unit) {}
|
|
|
|
auto CreateASTConsumer(clang::CompilerInstance& instance, llvm::StringRef file)
|
|
-> std::unique_ptr<clang::ASTConsumer> final {
|
|
return std::make_unique<ProxyASTConsumer>(
|
|
WrapperFrontendAction::CreateASTConsumer(instance, file),
|
|
unit);
|
|
}
|
|
|
|
/// Make this public.
|
|
using clang::WrapperFrontendAction::EndSourceFile;
|
|
|
|
private:
|
|
CompilationUnitRef unit;
|
|
};
|
|
|
|
} // namespace
|
|
|
|
CompilationStatus CompilationUnitRef::Self::run_clang(
|
|
this Self& self,
|
|
CompilationParams& params,
|
|
std::unique_ptr<clang::FrontendAction> action,
|
|
llvm::function_ref<void(clang::CompilerInstance&)> before_execute) {
|
|
std::unique_ptr diagnostic_consumer = self.create_diagnostic();
|
|
std::unique_ptr invocation = self.create_invocation(params, diagnostic_consumer.get());
|
|
if(!invocation) {
|
|
return CompilationStatus::SetupFail;
|
|
}
|
|
|
|
self.instance = std::make_unique<clang::CompilerInstance>(std::move(invocation));
|
|
auto& instance = *self.instance;
|
|
instance.createDiagnostics(*params.vfs, diagnostic_consumer.release(), true);
|
|
|
|
if(auto remapping = clang::createVFSFromCompilerInvocation(instance.getInvocation(),
|
|
instance.getDiagnostics(),
|
|
params.vfs)) {
|
|
instance.createFileManager(std::move(remapping));
|
|
}
|
|
|
|
if(!instance.createTarget()) {
|
|
return CompilationStatus::SetupFail;
|
|
}
|
|
|
|
if(before_execute) {
|
|
before_execute(instance);
|
|
}
|
|
|
|
self.action = std::make_unique<ProxyAction>(std::move(action), &self);
|
|
|
|
if(!self.action->BeginSourceFile(instance, instance.getFrontendOpts().Inputs[0])) {
|
|
/// If the action is not empty, we will call `EndSourceFile` at the destructor of `Self`.
|
|
/// But if we fail to `BeginSourceFile` we don't need to call `EndSourceFile`. So just
|
|
/// reset it.
|
|
self.action.reset();
|
|
return CompilationStatus::SetupFail;
|
|
}
|
|
|
|
/// FIXME: include-fixer, etc?
|
|
|
|
/// Add PPCallbacks to collect preprocessing information.
|
|
self.collect_directives();
|
|
|
|
if(params.clang_tidy) {
|
|
self.configure_tidy({});
|
|
}
|
|
|
|
std::optional<clang::syntax::TokenCollector> token_collector;
|
|
if(!instance.hasCodeCompletionConsumer()) {
|
|
/// It is not necessary to collect tokens if we are running code completion.
|
|
/// And in fact will cause assertion failure.
|
|
token_collector.emplace(instance.getPreprocessor());
|
|
}
|
|
|
|
if(auto error = self.action->Execute()) {
|
|
// Upstream FrontendAction::Execute() always returns success (errors go through
|
|
// diagnostics); log here only as a guard in case a custom action ever returns
|
|
// an unexpected llvm::Error.
|
|
LOG_ERROR("FrontendAction::Execute failed: {}", error);
|
|
return CompilationStatus::FatalError;
|
|
}
|
|
|
|
/// If the output file is not empty, it represents that we are
|
|
/// generating a PCH or PCM. If error occurs, the AST must be
|
|
/// invalid to some extent, serialization of such AST may result
|
|
/// in crash frequently. So forbidden it here and return as error.
|
|
if(!instance.getFrontendOpts().OutputFile.empty() &&
|
|
instance.getDiagnostics().hasErrorOccurred()) {
|
|
return CompilationStatus::FatalError;
|
|
}
|
|
|
|
/// Check whether the compilation is canceled, if so we think
|
|
/// it is an error.
|
|
if(self.stop && self.stop->load()) {
|
|
return CompilationStatus::Cancelled;
|
|
}
|
|
|
|
if(token_collector) {
|
|
self.buffer = std::move(*token_collector).consume();
|
|
}
|
|
|
|
self.run_tidy();
|
|
|
|
if(instance.hasSema()) {
|
|
self.resolver.emplace(instance.getSema());
|
|
}
|
|
|
|
return CompilationStatus::Completed;
|
|
}
|
|
|
|
CompilationUnit run_clang(CompilationParams& params,
|
|
std::unique_ptr<clang::FrontendAction> action,
|
|
llvm::function_ref<void(clang::CompilerInstance&)> before_execute = {},
|
|
llvm::function_ref<void(CompilationUnitRef)> after_execute = {}) {
|
|
LOG_DEBUG("Compile command: {}", print_argv(params.arguments));
|
|
|
|
auto self = new CompilationUnitRef::Self();
|
|
self->kind = params.kind;
|
|
self->stop = std::move(params.stop);
|
|
|
|
using namespace std::chrono;
|
|
self->build_at = duration_cast<milliseconds>(system_clock::now().time_since_epoch());
|
|
auto build_start = steady_clock::now().time_since_epoch();
|
|
|
|
self->status = self->run_clang(params, std::move(action), before_execute);
|
|
|
|
auto build_end = steady_clock::now().time_since_epoch();
|
|
self->build_duration = duration_cast<milliseconds>(build_end - build_start);
|
|
|
|
if(self->status == CompilationStatus::Completed && after_execute) {
|
|
after_execute(self);
|
|
}
|
|
|
|
return CompilationUnit(self);
|
|
}
|
|
|
|
CompilationUnit preprocess(CompilationParams& params) {
|
|
return run_clang(params, std::make_unique<clang::PreprocessOnlyAction>());
|
|
}
|
|
|
|
CompilationUnit compile(CompilationParams& params) {
|
|
return run_clang(params,
|
|
std::make_unique<clang::SyntaxOnlyAction>(),
|
|
[](clang::CompilerInstance& instance) {
|
|
/// Make sure the output file is empty.
|
|
instance.getFrontendOpts().OutputFile.clear();
|
|
});
|
|
}
|
|
|
|
CompilationUnit compile(CompilationParams& params, PCHInfo& out) {
|
|
assert(!params.output_file.empty() && "PCH file path cannot be empty");
|
|
|
|
/// Record the begin time of PCH building.
|
|
auto now = std::chrono::system_clock::now().time_since_epoch();
|
|
out.mtime = std::chrono::duration_cast<std::chrono::milliseconds>(now).count();
|
|
|
|
return run_clang(
|
|
params,
|
|
std::make_unique<clang::GeneratePCHAction>(),
|
|
[&](clang::CompilerInstance& instance) {
|
|
/// Set options to generate PCH.
|
|
instance.getFrontendOpts().OutputFile = params.output_file.str();
|
|
instance.getFrontendOpts().ProgramAction = clang::frontend::GeneratePCH;
|
|
instance.getPreprocessorOpts().GeneratePreamble = true;
|
|
|
|
// We don't want to write comment locations into PCH. They are racy and slow
|
|
// to read back. We rely on dynamic index for the comments instead.
|
|
instance.getPreprocessorOpts().WriteCommentListToPCH = false;
|
|
|
|
instance.getLangOpts().CompilingPCH = true;
|
|
},
|
|
[&](CompilationUnitRef unit) {
|
|
out.path = params.output_file.str();
|
|
out.preamble = unit.interested_content();
|
|
out.deps = unit.deps();
|
|
out.arguments = params.arguments;
|
|
});
|
|
}
|
|
|
|
CompilationUnit compile(CompilationParams& params, PCMInfo& out) {
|
|
assert(!params.output_file.empty() && "PCM file path cannot be empty");
|
|
|
|
return run_clang(
|
|
params,
|
|
std::make_unique<clang::GenerateReducedModuleInterfaceAction>(),
|
|
[&](clang::CompilerInstance& instance) {
|
|
/// Set options to generate PCH.
|
|
instance.getFrontendOpts().OutputFile = params.output_file.str();
|
|
instance.getFrontendOpts().ProgramAction =
|
|
clang::frontend::GenerateReducedModuleInterface;
|
|
|
|
out.srcPath = instance.getFrontendOpts().Inputs[0].getFile();
|
|
},
|
|
[&](CompilationUnitRef unit) {
|
|
out.path = params.output_file.str();
|
|
|
|
for(auto& [name, path]: params.pcms) {
|
|
out.mods.emplace_back(name);
|
|
}
|
|
});
|
|
}
|
|
|
|
CompilationUnit complete(CompilationParams& params, clang::CodeCompleteConsumer* consumer) {
|
|
auto& [file, offset] = params.completion;
|
|
|
|
/// The location of clang is 1-1 based.
|
|
std::uint32_t line = 1;
|
|
std::uint32_t column = 1;
|
|
|
|
/// FIXME:
|
|
assert(params.buffers.size() == 1);
|
|
llvm::StringRef content = params.buffers.begin()->second->getBuffer();
|
|
|
|
for(auto c: content.substr(0, offset)) {
|
|
if(c == '\n') {
|
|
line += 1;
|
|
column = 1;
|
|
continue;
|
|
}
|
|
column += 1;
|
|
}
|
|
|
|
return run_clang(params,
|
|
std::make_unique<clang::SyntaxOnlyAction>(),
|
|
[&](clang::CompilerInstance& instance) {
|
|
/// Set options to run code completion.
|
|
instance.getFrontendOpts().CodeCompletionAt.FileName = std::move(file);
|
|
instance.getFrontendOpts().CodeCompletionAt.Line = line;
|
|
instance.getFrontendOpts().CodeCompletionAt.Column = column;
|
|
instance.setCodeCompletionConsumer(consumer);
|
|
});
|
|
}
|
|
|
|
} // namespace clice
|