use cpp_code_analysis::{
DeclFact, DeclKind, DiskFiles, FactGuard, FileView, Known, ProjectDefinition, ProjectMacro,
Session, UnknownReason,
};
use cpp_parser::CppDocComment;
use lsp_types::{
ClientCapabilities, Hover, HoverContents, HoverParams, HoverProviderCapability, MarkedString,
MarkupContent, MarkupKind, ServerCapabilities,
};
use tokio_util::sync::CancellationToken;
use super::RegisterCapabilities;
use crate::context::{RequestOutcome, ServerContextSnapshot, snapshot_query};
use crate::util::{file_offset_at_position, uri_to_file_path};
mod vocabulary;
const MAX_INLINE_CHARS: usize = 80;
pub async fn on_hover(
context: ServerContextSnapshot,
params: HoverParams,
cancel_token: CancellationToken,
) -> RequestOutcome<Hover> {
let uri = params.text_document_position_params.text_document.uri;
let position = params.text_document_position_params.position;
if let Some(path) = uri_to_file_path(&uri) {
context.analysis().prepare_for_a_read(&path).await;
crate::handlers::read_the_modules(&context, &path, false).await;
}
snapshot_query(context.analysis(), cancel_token, move |session| {
struct Step(&'static str, std::time::Instant);
impl Drop for Step {
fn drop(&mut self) {
let ms = self.1.elapsed().as_millis();
if ms >= 15 {
log::warn!("hover: stage {} took {ms} ms", self.0);
}
}
}
let _whole = Step("query", std::time::Instant::now());
let path = uri_to_file_path(&uri)?;
let view_step = Step("view_of_the_file", std::time::Instant::now());
let written = session.view_of_the_file(&path)?;
drop(view_step);
let in_the_file = file_offset_at_position(&written, position)?;
let view = written.clone();
let offset = cpp_code_analysis::file::coordinates::ReadingOffset::new(in_the_file.get());
if let Known::Yes(header) = session.header_at(&view, in_the_file) {
return Some(markdown(header_markdown(&header)));
}
hover(session, &view, offset, in_the_file)
})
.await
}
fn doc_command_at(text: &str, offset: usize) -> Option<String> {
let offset = offset.min(text.len());
let before = text.get(..offset)?;
let line_start = before.rfind('\n').map_or(0, |at| at + 1);
let line_end = text[offset..]
.find('\n')
.map_or(text.len(), |at| offset + at);
let line = text.get(line_start..line_end)?;
let caret = offset - line_start;
let trimmed = line.trim_start();
let is_a_documentation_line = trimmed.starts_with("///")
|| trimmed.starts_with("//!")
|| trimmed.starts_with("/**")
|| trimmed.starts_with("/*!");
let inside_a_documentation_block = if is_a_documentation_line {
true
} else {
match (before.rfind("/*"), before.rfind("*/")) {
(Some(opened), closed) => {
let still_open = closed.is_none_or(|closed| closed < opened);
still_open
&& (before[opened..].starts_with("/**") || before[opened..].starts_with("/*!"))
}
_ => false,
}
};
if !inside_a_documentation_block {
return None;
}
let bytes = line.as_bytes();
let mut at = 0usize;
while at < bytes.len() {
if !matches!(bytes[at], b'@' | b'\\') {
at += 1;
continue;
}
let mut end = at + 1;
while end < bytes.len() && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_') {
end += 1;
}
let word_start = at + 1;
if end > word_start && word_start <= caret && caret <= end {
return Some(line[word_start..end].to_string());
}
at = end.max(at + 1);
}
None
}
pub(crate) fn doc_target_at(
text: &str,
offset: usize,
) -> Option<cpp_code_analysis::doc::DocTarget> {
cpp_code_analysis::doc::target_at(text, offset)
}
fn word_in_the_file(
view: &cpp_code_analysis::FileView,
offset: cpp_code_analysis::file::coordinates::FileOffset,
) -> String {
let Some(written) = view.the_written_text() else {
return String::new();
};
let bytes = written.as_bytes();
let is_a_word = |byte: u8| byte.is_ascii_alphanumeric() || byte == b'_';
let at = offset.get().min(bytes.len());
let mut start = at;
while start > 0 && is_a_word(bytes[start - 1]) {
start -= 1;
}
let mut end = at;
while end < bytes.len() && is_a_word(bytes[end]) {
end += 1;
}
written.get(start..end).unwrap_or_default().to_string()
}
pub fn hover_text(
session: &Session<DiskFiles>,
view: &FileView,
offset: cpp_code_analysis::file::coordinates::ReadingOffset,
in_the_file: cpp_code_analysis::file::coordinates::FileOffset,
) -> Option<String> {
let hover = hover(session, view, offset, in_the_file)?;
Some(match hover.contents {
HoverContents::Markup(markup) => markup.value,
HoverContents::Scalar(one) => match one {
MarkedString::String(text) => text,
MarkedString::LanguageString(string) => string.value,
},
HoverContents::Array(many) => many
.into_iter()
.map(|one| match one {
MarkedString::String(text) => text,
MarkedString::LanguageString(string) => string.value,
})
.collect::<Vec<_>>()
.join("\n"),
})
}
macro_rules! timed {
($name:expr, $call:expr) => {{
let started = std::time::Instant::now();
let value = $call;
let ms = started.elapsed().as_millis();
if ms >= 15 {
log::warn!("hover: stage {} took {ms} ms", $name);
}
value
}};
}
pub fn hover(
session: &Session<DiskFiles>,
view: &FileView,
offset: cpp_code_analysis::file::coordinates::ReadingOffset,
in_the_file: cpp_code_analysis::file::coordinates::FileOffset,
) -> Option<Hover> {
if let Some(found) = timed!(
"program",
hover_of_the_program(session, view, offset, in_the_file)
) {
return Some(found);
}
if let Some(token) = cpp_parser::token_at(&view.root, offset.get()) {
let in_the_rendering = cpp_parser::source_range(token.text_range());
let start = view
.file_offset_of(cpp_code_analysis::file::coordinates::ReadingOffset::new(
in_the_rendering.start_offset,
))
.map(|at| at.get());
let end = view
.file_offset_of_end(cpp_code_analysis::file::coordinates::ReadingOffset::new(
in_the_rendering.end_offset(),
))
.map(|at| at.get());
let covers_the_cursor = match (start, end) {
(Some(start), Some(end)) => start <= in_the_file.get() && in_the_file.get() <= end,
_ => false,
};
if covers_the_cursor && let Some(text) = vocabulary::keyword_hover(&token) {
return Some(markdown(text));
}
if covers_the_cursor && let Some(text) = vocabulary::construct_hover(&token) {
return Some(markdown(text));
}
if covers_the_cursor
&& let Some(text) = vocabulary::cuda_hover(&token, session.config().cuda())
{
return Some(markdown(text));
}
}
if let Some(target) = doc_target_at(
view.written_text().unwrap_or(&view.source),
in_the_file.get(),
) {
let spelling = target.spelled_for_a_lookup();
if let Known::Yes(found) = session.index().definition(spelling, &view.path) {
return Some(markdown(declaration_markdown(session, view, &found, None)));
}
if let Known::Yes(found) = session.macro_at_a_file_offset(view, in_the_file) {
return Some(markdown(macro_markdown(
session,
view,
&found,
Some(in_the_file),
)));
}
}
if let Some(name) = doc_command_at(
view.written_text().unwrap_or(&view.source),
in_the_file.get(),
) && let Some(text) = vocabulary::command_hover(&name)
{
return Some(markdown(text));
}
None
}
fn hover_of_the_program(
session: &Session<DiskFiles>,
view: &FileView,
offset: cpp_code_analysis::file::coordinates::ReadingOffset,
in_the_file: cpp_code_analysis::file::coordinates::FileOffset,
) -> Option<Hover> {
if session.project_config().config.hover.enable == Some(false) {
return None;
}
if let Known::Yes(header) = timed!("header_at", session.header_at(view, in_the_file)) {
return Some(markdown(header_markdown(&header)));
}
if let Some(found) = timed!(
"format_string",
crate::handlers::format::format_string_hover(session, view, in_the_file)
) {
return Some(found);
}
if let Some(access) =
cpp_code_analysis::sema::resolve::member_access_at(&view.root, offset.get())
{
return Some(markdown(member_markdown(session, view, &access)));
}
if let Known::Yes(found) = timed!(
"macro_at_a_file_offset",
session.macro_at_a_file_offset(view, in_the_file)
) {
return Some(markdown(macro_markdown(
session,
view,
&found,
Some(in_the_file),
)));
}
match timed!("macro_definition", session.macro_definition(view, offset)) {
Known::Yes(found) => {
return Some(markdown(macro_markdown(
session,
view,
&found,
Some(in_the_file),
)));
}
Known::Unknown(UnknownReason::ConditionalCompilation) => {
let name = session
.definitions(view, offset)
.value()
.and_then(|found| found.found.first().map(|one| one.fact.name.clone()));
let _ = name;
let written = cpp_parser::token_at(&view.root, offset.get())
.map(|token| token.text().to_string())
.unwrap_or_default();
if !written.is_empty() {
return Some(markdown(format!(
"`{written}` — a macro whose `#define` is inside a conditional block, so whether it exists here \
depends on the compilation. The analysis is reporting what the header says, not what a compiler \
concluded."
)));
}
}
Known::Unknown(_) | Known::No => {}
}
let word = word_in_the_file(view, in_the_file);
if !word.is_empty()
&& let Some(directive) = session.macro_definition_text(&view.path, in_the_file.get(), &word)
{
return Some(markdown(format!("```c\n{directive}\n```")));
}
if !word.is_empty()
&& let Some(predefined) = session.predefined_macro(&word, &view.path)
{
return Some(markdown(if predefined.is_empty() {
format!("`{word}` — predefined by the compiler this project builds with.")
} else {
format!(
"`{word}` — predefined by the compiler this project builds with, as `{predefined}`."
)
}));
}
if let Some(what) = a_standard_macro_meaning(&word) {
return Some(markdown(format!(
"`{word}` — predefined by the compiler: {what}."
)));
}
let written = written_template_id(view, offset);
match session.definition(view, offset) {
Known::Yes(found) => Some(markdown(declaration_markdown(
session,
view,
&found,
written.as_deref(),
))),
Known::Unknown(UnknownReason::Ambiguous(_)) => match session.definitions(view, offset) {
Known::Yes(found) => {
let found = crate::handlers::references::narrowed_to_the_cursors_class(
view,
offset.get(),
found,
);
if std::env::var("CPPLS_TRACE_HOVER").is_ok() {
eprintln!(
"[types] {:?}",
found
.found
.iter()
.map(|definition| format!(
"{} definition {} pattern {:?}",
definition.file.display(),
definition.fact.definition,
definition.fact.pattern
))
.collect::<Vec<_>>()
);
}
if let Some(the_type) = found
.found
.iter()
.filter(|definition| {
matches!(definition.fact.kind, cpp_code_analysis::DeclKind::Type)
})
.max_by_key(|definition| {
(definition.fact.definition, definition.fact.parameters.len())
})
{
return Some(markdown(declaration_markdown(
session,
view,
the_type,
written.as_deref(),
)));
}
match found.found.len() {
1 => Some(markdown(declaration_markdown(
session,
view,
&found.found[0],
written.as_deref(),
))),
2.. => Some(markdown(overloads_markdown(
session,
Some(view),
offset,
&found,
))),
_ => None,
}
}
_ => expression_markdown(session, view, offset).map(markdown),
},
Known::No | Known::Unknown(_) => {
match session.member_of_the_enclosing_class(view, offset) {
Some(found) => Some(markdown(declaration_markdown(session, view, &found, None))),
None => expression_markdown(session, view, offset).map(markdown),
}
}
}
}
fn member_markdown(
session: &Session<DiskFiles>,
view: &FileView,
access: &cpp_code_analysis::sema::resolve::MemberAccess,
) -> String {
let object = access.object.text().to_string();
let object = object.trim();
let cpp_code_analysis::Known::Yes(found) = session.type_of_the_expression(view, &access.object)
else {
return format!(
"`{}`\n\nThe type of `{object}` is not known here, so `{}` cannot be resolved. That usually means the \
declaration's type is `auto`, a template parameter, or comes from a header this project has not read.",
access.member, access.member
);
};
let written = found.type_of;
let reached = match access.arrow {
true => session.the_class_after_the_operator(view, &written, true),
false => None,
};
let mut list = None;
for class in reached.iter().chain(std::iter::once(&written)) {
if let cpp_code_analysis::Known::Yes(candidates) = session.members_of(view, class) {
if candidates
.members
.iter()
.any(|member| member.fact.name == access.member)
{
list = Some(candidates);
break;
}
list = list.or(Some(candidates));
}
}
let Some(list) = list else {
return format!(
"`{written}` has no member `{}`\n\n`{object}` is a `{written}`, and its members could not be read — the \
class is out of the index, or a base is.",
access.member
);
};
let candidates: Vec<&cpp_code_analysis::ProjectMember> = list
.members
.iter()
.filter(|member| member.fact.name == access.member)
.collect();
let call_selected: Option<Vec<usize>> = (candidates.len() > 1)
.then(|| {
let parameter_lists: Vec<Option<String>> = candidates
.iter()
.map(|candidate| candidate.fact.parameter_list.clone())
.collect();
let template_parameters: Vec<Vec<String>> = candidates
.iter()
.map(|candidate| {
session
.text(&candidate.file)
.map(|text| {
cpp_code_analysis::sema::deduction::template_parameters_before(
&text,
candidate.fact.name_range.start_offset,
)
})
.unwrap_or_default()
})
.collect();
session.the_declarations_a_call_means(
view,
cpp_code_analysis::file::coordinates::ReadingOffset::new(
access.member_range.start_offset,
),
¶meter_lists,
&template_parameters,
)
})
.flatten();
if let Some(selected) = call_selected
.as_ref()
.filter(|selected| selected.len() == 1)
&& let Some(member) = candidates.get(selected[0])
{
let definition = cpp_code_analysis::ProjectDefinition {
file: member.file.clone(),
fact: member.fact.clone(),
};
return declaration_markdown(session, view, &definition, None);
}
let shown: Vec<&cpp_code_analysis::ProjectMember> = match &call_selected {
Some(selected) => selected
.iter()
.filter_map(|at| candidates.get(*at).copied())
.collect(),
None => candidates.clone(),
};
if shown.len() > 1 {
let mut out = String::from("```cpp\n");
for candidate in &shown {
out.push_str(&a_line(&candidate.fact));
out.push('\n');
}
out.push_str("```\n\n");
let scope = shown
.iter()
.find_map(|candidate| candidate.fact.scope.clone())
.unwrap_or_else(|| written.clone());
let sentence = if call_selected.is_some() {
format!(
"`{scope}::{}` — {} of {} declarations can accept this call's arguments; the types do not decide \
between them, so they are all shown. Which one a call means depends on `{object}`: a `const` object can \
only call the `const` one.",
access.member,
shown.len(),
candidates.len()
)
} else {
format!(
"`{scope}::{}` — {} declarations, told apart by their qualifiers.\n\nWhich one a call means depends \
on `{object}`: a `const` object can only call the `const` one.",
access.member,
candidates.len()
)
};
out.push_str(&sentence);
return out;
}
let Some(member) = candidates.first().copied().or_else(|| {
list.members
.iter()
.find(|member| member.fact.name == access.member)
}) else {
if let Some(expansion) =
session.macro_body_at(&view.path, access.member_range.start_offset, &access.member)
{
return format!(
"`{}` is a macro here: it expands to `{expansion}`, and `{expansion}` is a member of `{written}`.\n\n\
This hover reads the file's own text, where the macro name is what was written.",
access.member
);
}
return format!(
"`{written}` has no member `{}`\n\n`{object}` is a `{written}`.",
access.member
);
};
let definition = cpp_code_analysis::ProjectDefinition {
file: member.file.clone(),
fact: member.fact.clone(),
};
declaration_markdown(session, view, &definition, None)
}
fn overloads_markdown(
session: &Session<DiskFiles>,
view: Option<&FileView>,
offset: cpp_code_analysis::file::coordinates::ReadingOffset,
found: &cpp_code_analysis::ProjectDefinitions,
) -> String {
let call_selected: Option<Vec<usize>> = view.and_then(|view| {
let parameter_lists: Vec<Option<String>> = found
.found
.iter()
.map(|declaration| declaration.fact.parameter_list.clone())
.collect();
let template_parameters: Vec<Vec<String>> = found
.found
.iter()
.map(|declaration| {
session
.text(&declaration.file)
.map(|text| {
cpp_code_analysis::sema::deduction::template_parameters_before(
&text,
declaration.fact.name_range.start_offset,
)
})
.unwrap_or_default()
})
.collect();
let selected = session.the_declarations_a_call_means(
view,
offset,
¶meter_lists,
&template_parameters,
)?;
(!selected.is_empty()).then_some(selected)
});
const MAX_SHOWN: usize = 6;
let chosen_index = call_selected
.as_ref()
.filter(|selected| selected.len() == 1)
.map(|selected| selected[0]);
let mut shown: Vec<usize> = match &call_selected {
Some(selected) => selected.clone(),
None => (0..found.found.len()).collect(),
};
let hidden = found.found.len().saturating_sub(shown.len().min(MAX_SHOWN));
shown.truncate(MAX_SHOWN);
let mut lines = String::new();
let mut shown_lines: Vec<String> = Vec::new();
for index in &shown {
let declaration = &found.found[*index];
let is_the_one = chosen_index == Some(*index);
let mut fact = declaration.fact.clone();
if fact.split_by_a_directive
&& let Some(read) = session.fact_from_the_rendering(
&declaration.file,
&fact.name,
fact.scope.as_deref(),
)
{
fact.returns = read.returns;
fact.type_of = read.type_of;
}
let mut line = a_line(&fact);
if is_the_one
&& let Some(view) = view
&& let Some(text) = session.text(&declaration.file)
{
let names = cpp_code_analysis::sema::deduction::template_parameters_before(
&text,
declaration.fact.name_range.start_offset,
);
let (parameters, _) = declaration
.fact
.parameter_list
.as_deref()
.map(cpp_code_analysis::sema::overload::parameters_in)
.unwrap_or_default();
if let Some(arguments) =
session.the_deduced_arguments(view, offset, &names, ¶meters)
{
line =
cpp_code_analysis::sema::types::substituted_spelling(&line, &names, &arguments);
}
}
let marked = format!("{}{line}", if is_the_one { "→ " } else { " " });
let duplicate = shown_lines
.iter()
.any(|existing| existing.trim_start_matches("→ ").trim_start_matches(" ") == line);
shown_lines.push(marked);
if !duplicate {
lines.push_str(&format!("{}{line}\n", if is_the_one { "→ " } else { " " }));
}
}
let first = &found.found[chosen_index.unwrap_or(0)];
let qualified = first.fact.qualified_name();
let distinct = shown_lines
.iter()
.map(|line| line.trim_start_matches("→ ").trim_start_matches(" "))
.collect::<std::collections::BTreeSet<_>>()
.len();
let mut out = code_block(lines.trim_end());
out.push_str(&format!(
"\n`{qualified}` — {} declarations. {}\n",
found.found.len(),
if let Some(selected) = &call_selected {
if selected.len() == 1 {
"The `→` one is what this call means: its parameter list accepts the arguments, and the others \\
cannot. `go to definition` lists them all."
} else {
"These are the declarations whose parameter list accepts this call's arguments; the types do not \\
decide between them, so they are all shown — `go to definition` lists the rest."
}
} else if hidden > 0 {
"The ones printed here are the call's own (when the cursor is on one) and the first of the rest — the \
count and the files below are complete, and `go to definition` lists them all."
} else if distinct == 1 {
"The declarations spell the **same signature** — a redeclaration, or the two branches of a conditional \
header — so which one is in effect depends on the compilation, not on a call."
} else {
"Which one a call means is decided by the arguments, so they are all here rather than one of them being \
guessed at."
}
));
if found.conditional > 0 {
out.push_str(&format!(
"\n{} more declaration(s) of this name are reachable only through a conditional `#include`, so whether \
they are in scope depends on the compilation.\n",
found.conditional
));
}
let files: std::collections::BTreeSet<&std::path::Path> = found
.found
.iter()
.map(|declaration| declaration.file.as_path())
.collect();
out.push_str(&format!(
"\nDeclared in {}",
where_clause(
session,
view,
first.file.as_path(),
first.fact.name_range.start_offset
)
));
if files.len() > 1 {
out.push_str(&format!(" and {} other file(s)", files.len() - 1));
}
out
}
fn the_type_the_macros_leave<F: cpp_code_analysis::FileProvider + Clone>(
session: &Session<F>,
file: &std::path::Path,
fact: &DeclFact,
returns: &str,
) -> Option<String> {
let last = returns.split_whitespace().next_back()?;
if !last
.chars()
.all(|character| character.is_alphanumeric() || character == '_' || character == ':')
{
return None;
}
if session.index().files_defining_macro(last).is_empty() {
return None;
}
let text = session.text(file)?;
let before = text.get(..fact.name_range.start_offset)?;
let mut specifiers = String::new();
for line in before.lines().rev() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.ends_with(';') || line.ends_with('{') {
if !specifiers.is_empty() {
break;
}
continue;
}
if line.ends_with('}') {
break;
}
specifiers.insert_str(0, line);
specifiers.insert(0, ' ');
if specifiers.matches(' ').count() > 24 {
break;
}
}
if specifiers.trim().is_empty() {
return None;
}
let mut kept: Vec<&str> = Vec::new();
for word in specifiers.split_whitespace() {
let trimmed = word.trim_matches(|character: char| {
!(character.is_alphanumeric() || character == '_' || character == ':')
});
if trimmed.is_empty() {
continue;
}
if session.index().files_defining_macro(trimmed).is_empty() {
kept.push(word);
}
}
if kept.is_empty() {
return None;
}
let spelling = kept.join(" ");
let spelling = spelling.trim().to_string();
(!spelling.is_empty() && spelling != returns).then_some(spelling)
}
fn a_line(fact: &DeclFact) -> String {
cpp_code_analysis::sema::render::a_line(fact, None, None)
}
pub(crate) fn markdown(value: String) -> Hover {
Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value,
}),
range: None,
}
}
fn header_markdown(header: &cpp_code_analysis::HeaderTarget) -> String {
let (open, close) = match header.form {
cpp_code_analysis::IncludeForm::Quote => ('"', '"'),
cpp_code_analysis::IncludeForm::Angle | cpp_code_analysis::IncludeForm::Macro => ('<', '>'),
};
format!(
"```cpp\n#include {open}{}{close}\n```\n\n`{}`\n",
header.spelling,
header.resolved.display()
)
}
fn macro_markdown(
session: &Session<DiskFiles>,
view: &FileView,
found: &ProjectMacro,
at: Option<cpp_code_analysis::file::coordinates::FileOffset>,
) -> String {
let fact = &found.fact;
if !fact.kind.is_definition() {
return format!(
"`#undef {}`{}\n\n`{}` is not a macro at this point in the file.",
fact.name,
where_clause(session, Some(view), &found.file, fact.range.start_offset),
fact.name
);
}
let mut out = String::new();
let definition = definition_line(
session,
view,
&found.file,
fact.range.start_offset,
fact.body_range,
);
out.push_str(&code_block(&definition.text));
out.push_str(&format!(
"\n`{}` — a {}{}, defined in {}\n",
fact.name,
if fact.function_like {
"function-like macro"
} else {
"macro"
},
match &fact.value {
Some(value) => format!(" whose value is `{value}`"),
None => String::new(),
},
where_clause(session, Some(view), &found.file, fact.range.start_offset)
));
if let Some(at) = at
&& let Some(expanded) = session.the_expansion_of_a_macro(view, at)
&& !expanded.trim().is_empty()
{
out.push_str(&format!(
"\nExpands to `{}`\n\n*A textual expansion: `#` and `##` are not applied, and the result is not expanded again.*\n",
expanded.trim()
));
}
if let Some(documentation) =
documentation_of_a_fact(session, view, &found.file, fact.range.start_offset)
{
out.push_str(&format!("\n{documentation}\n"));
}
out
}
fn written_template_id(
view: &FileView,
offset: cpp_code_analysis::file::coordinates::ReadingOffset,
) -> Option<String> {
let node = cpp_code_analysis::sema::resolve::name_node_at(&view.root, offset.get())?;
let written = node.text().to_string();
let written = written.trim();
(written.contains('<') && !written.is_empty()).then(|| written.to_string())
}
fn declaration_markdown(
session: &Session<DiskFiles>,
view: &FileView,
found: &ProjectDefinition,
written: Option<&str>,
) -> String {
let indexed = if !found.fact.local
&& !matches!(
found.fact.kind,
cpp_code_analysis::DeclKind::Type | cpp_code_analysis::DeclKind::Namespace
)
&& found.fact.type_of.is_none()
&& found.fact.returns.is_none()
&& found.fact.parameter_list.is_none()
&& found.fact.reflection.is_none()
{
session
.index()
.definition(&found.fact.qualified_name(), &found.file)
.value()
} else {
None
};
if std::env::var("CPPLS_TRACE_HOVER").is_ok() {
eprintln!(
"[card] heard {} local {} type_of {:?} => indexed {}",
found.fact.name,
found.fact.local,
found.fact.type_of,
indexed
.as_ref()
.map(|seen| format!("{}:{}", seen.file.display(), seen.fact.name))
.unwrap_or_else(|| "no".to_string())
);
}
let found = match &indexed {
Some(indexed) => indexed,
None => found,
};
let mut corrected = found.fact.clone();
if matches!(corrected.kind, DeclKind::Variable)
&& corrected
.type_of
.as_deref()
.is_none_or(cpp_code_analysis::sema::types::is_a_deduced_type)
&& is_the_viewed_file(view, &found.file)
&& let Known::Yes(deduced) = session.type_at(
view,
cpp_code_analysis::file::coordinates::ReadingOffset::new(
corrected.name_range.start_offset,
),
)
{
corrected.type_of = Some(deduced.type_of.to_string());
}
if let Some(returns) = corrected.returns.clone()
&& let Some(better) = the_type_the_macros_leave(session, &found.file, &corrected, &returns)
{
corrected.returns = Some(better);
}
if corrected.reflection.is_none()
&& let Some(facts) = session
.view_of_the_file(&found.file)
.map(|view| session.facts_of_the_view(&view))
&& let Some(the_files) = facts
.iter()
.find(|theirs| theirs.name_range.start_offset == corrected.name_range.start_offset)
{
corrected.reflection = the_files.reflection.clone();
}
let fact = &corrected;
if std::env::var_os("CPPLS_DEBUG_CARD").is_some() {
eprintln!(
"[card] name={:?} kind={:?} scope={:?} returns={:?} parameter_list={:?} range={:?}",
fact.name, fact.kind, fact.scope, fact.returns, fact.parameter_list, fact.range
);
}
let mut out = String::new();
let subject = match written {
Some(written) if matches!(fact.kind, DeclKind::Type) && !fact.parameters.is_empty() => {
written
}
_ => "",
};
let enumerator = enumerator_value(view, fact.name_range.start_offset);
let constant_value = session.constant_value_at(
view,
cpp_code_analysis::file::coordinates::ReadingOffset::new(fact.name_range.start_offset),
);
let node = session.the_declaration_node(&found.file, fact.range.start_offset);
let declaration = cpp_code_analysis::sema::render::an_item(fact, node.as_ref(), constant_value);
out.push_str(&code_block(&declaration.join("\n")));
if !subject.is_empty() {
out.push_str(&format!(
"\nAs written here: `{}`\n",
cpp_code_analysis::sema::render::a_type(subject)
));
}
let qualified = if subject.is_empty() {
fact.qualified_name()
} else {
subject.to_string()
};
match enumerator {
Some(_) => out.push_str(&format!(
"\n`{}` — an enumerator of `{}`\n",
qualified,
fact.scope.as_deref().unwrap_or("(an anonymous enum)")
)),
None => out.push_str(&format!("\n`{}` — {}\n", qualified, kind_words(fact))),
}
if matches!(fact.kind, DeclKind::Variable)
&& fact
.type_of
.as_deref()
.is_none_or(|written| written == "auto")
&& is_the_viewed_file(view, &found.file)
&& let Known::Unknown(reason) = session.type_at(
view,
cpp_code_analysis::file::coordinates::ReadingOffset::new(fact.name_range.start_offset),
)
{
out.push_str(&format!(
"\nThe type is deduced from the initializer, and that could not be typed: {}.\n",
reason.describe()
));
}
if let Some(value) = constant_value {
let how = if enumerator.is_some() {
"counted from the enumerators written before it, as a compiler does."
} else {
"folded from its initializer, as a compiler does."
};
out.push_str(&format!("\nValue **{value}** — {how}\n"));
}
if let Some(documentation) =
documentation_of_a_fact(session, view, &found.file, fact.range.start_offset)
{
out.push_str(&format!("\n{documentation}\n"));
}
out.push_str(&format!(
"\nDeclared in {}",
where_clause(
session,
Some(view),
&found.file,
fact.name_range.start_offset
)
));
if let Some(reflection) = &fact.reflection {
out.push_str(&format!(
"\n\n`{}({})`",
reflection.macro_name,
reflection.specifiers.join(", ")
));
out.push_str(match reflection.specifiers.len() {
0 => "\n\nThe declaration is annotated for Unreal's reflection system, with no specifiers.",
_ => "\n\nThe specifiers Unreal's reflection system acts on, as the declaration wrote them.",
});
}
if let FactGuard::Region(_) = fact.guard {
out.push_str(
"\n\nThis declaration is inside a conditional block, so whether it exists depends on the compilation \
— the analysis is reporting what the file says, not what a compiler concluded.",
);
}
if !fact.clean {
out.push_str(
"\n\nThe parser recovered from an error inside this declaration, so its text may not mean what it \
looks like.",
);
}
out
}
fn documentation_markdown(
session: &Session<DiskFiles>,
view: &FileView,
file: &std::path::Path,
offset: usize,
) -> Option<String> {
documentation_text(&session.documentation(view, file, offset)?)
}
pub(crate) fn documentation_text(comment: &CppDocComment) -> Option<String> {
if !comment.is_documentation() {
return None;
}
let text = comment.get_comment_text();
let text = text.trim();
(!text.is_empty()).then(|| render_documentation(text))
}
fn render_documentation(text: &str) -> String {
let mut blocks: Vec<String> = Vec::new();
let mut list: Vec<String> = Vec::new();
let mut prose: Option<String> = None;
fn end_prose(blocks: &mut Vec<String>, prose: &mut Option<String>) {
if let Some(text) = prose.take()
&& !text.trim().is_empty()
{
blocks.push(text);
}
}
fn end_list(blocks: &mut Vec<String>, list: &mut Vec<String>) {
if !list.is_empty() {
blocks.push(list.join("\n"));
list.clear();
}
}
fn say(prose: &mut Option<String>, line: &str) {
match prose {
Some(text) => {
text.push(' ');
text.push_str(line);
}
None => *prose = Some(line.to_string()),
}
}
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
end_prose(&mut blocks, &mut prose);
end_list(&mut blocks, &mut list);
continue;
}
let Some((command, rest)) = command_of(trimmed) else {
if let Some(prose) = &mut prose {
prose.push(' ');
prose.push_str(trimmed);
} else if let Some(item) = list.last_mut() {
item.push(' ');
item.push_str(trimmed);
} else {
say(&mut prose, trimmed);
}
continue;
};
match command {
"param" | "tparam" | "arg" | "return" | "returns" | "result" | "note" | "warning"
| "attention" | "see" | "deprecated" | "todo" | "pre" | "post" | "remark" => {
end_prose(&mut blocks, &mut prose);
let item = match command {
"param" | "tparam" | "arg" => {
let (direction, rest) = direction_of(rest);
match rest.split_once(char::is_whitespace) {
Some((name, description)) => match direction {
Some(direction) => {
format!("- **`{name}`** ({direction}) — {}", description.trim())
}
None => format!("- **`{name}`** — {}", description.trim()),
},
None => format!("- **{}** — {rest}", t!("doc.parameter")),
}
}
"return" | "returns" | "result" => {
format!("- **{}** — {rest}", t!("doc.returns"))
}
"note" => format!("- **{}** — {rest}", t!("doc.note")),
"warning" => format!("- **{}** — {rest}", t!("doc.warning")),
"attention" => format!("- **{}** — {rest}", t!("doc.attention")),
"see" => format!("- **{}** — {rest}", t!("doc.see")),
"deprecated" => format!("- **{}** — {rest}", t!("doc.deprecated")),
"todo" => format!("- **{}** — {rest}", t!("doc.todo")),
other => format!("- **{}** — {rest}", capitalize(other)),
};
list.push(item);
}
"brief" | "details" | "short" => {
end_list(&mut blocks, &mut list);
say(&mut prose, rest);
}
_ => {
end_list(&mut blocks, &mut list);
end_prose(&mut blocks, &mut prose);
say(&mut prose, trimmed);
}
}
}
end_prose(&mut blocks, &mut prose);
end_list(&mut blocks, &mut list);
blocks.join("\n\n")
}
fn direction_of(rest: &str) -> (Option<String>, &str) {
let Some(after) = rest.strip_prefix('[') else {
return (None, rest);
};
let Some(end) = after.find(']') else {
return (None, rest);
};
(
Some(after[..end].trim().to_string()),
after[end + 1..].trim_start(),
)
}
fn command_of(line: &str) -> Option<(&str, &str)> {
let rest = line.strip_prefix('@').or_else(|| line.strip_prefix('\\'))?;
let end = rest
.find(|character: char| !character.is_ascii_alphanumeric())
.unwrap_or(rest.len());
let command = &rest[..end];
(!command.is_empty()).then(|| (command, rest[end..].trim_start()))
}
fn capitalize(word: &str) -> String {
let mut characters = word.chars();
match characters.next() {
Some(first) => first.to_uppercase().collect::<String>() + characters.as_str(),
None => String::new(),
}
}
fn expression_markdown(
session: &Session<DiskFiles>,
view: &FileView,
offset: cpp_code_analysis::file::coordinates::ReadingOffset,
) -> Option<String> {
let Known::Yes(found) = session.type_at(view, offset) else {
return None;
};
if found.expression == "this" {
return Some(format!(
"`this` — a pointer to the enclosing class `{}`",
found.type_of
));
}
Some(format!(
"`{}` — has type `{}`",
inline(&found.expression),
found.type_of
))
}
fn inline(text: &str) -> String {
let mut collapsed = String::new();
let mut after_space = false;
for character in text.chars() {
if character.is_whitespace() {
after_space = true;
continue;
}
if after_space && !collapsed.is_empty() {
collapsed.push(' ');
}
after_space = false;
collapsed.push(character);
}
if collapsed.chars().count() <= MAX_INLINE_CHARS {
return collapsed;
}
let cut: String = collapsed.chars().take(MAX_INLINE_CHARS - 1).collect();
format!("{cut}…")
}
fn enumerator_value(view: &FileView, offset: usize) -> Option<i64> {
let token = cpp_parser::token_at(&view.root, offset)?;
let declaration = token.parent_ancestors().find(|node| {
cpp_parser::CppSyntaxKind::from(node.kind()) == cpp_parser::CppSyntaxKind::EnumeratorDecl
})?;
let body = declaration.parent()?;
let mut value: i64 = -1;
for enumerator in body.children() {
if cpp_parser::CppSyntaxKind::from(enumerator.kind())
!= cpp_parser::CppSyntaxKind::EnumeratorDecl
{
continue;
}
let mut tokens: Vec<cpp_parser::CppSyntaxToken> = enumerator
.descendants_with_tokens()
.filter_map(|element| element.into_token())
.filter(|token| !cpp_parser::is_trivia(cpp_parser::CppTokenKind::from(token.kind())))
.collect();
tokens.retain(|token| !token.text().trim().is_empty());
match tokens.get(1) {
Some(token)
if cpp_parser::CppTokenKind::from(token.kind())
== cpp_parser::CppTokenKind::Assign =>
{
let literal = tokens.get(2)?;
if tokens.len() != 3
|| cpp_parser::CppTokenKind::from(literal.kind())
!= cpp_parser::CppTokenKind::IntegerLiteral
{
return None;
}
value = an_integer(literal.text())?;
}
_ => value += 1,
}
if enumerator.text_range() == declaration.text_range() {
return Some(value);
}
}
None
}
fn an_integer(text: &str) -> Option<i64> {
let text = text.trim_end_matches(|character: char| {
character.is_ascii_alphabetic() && !matches!(character, 'x' | 'X' | 'b' | 'B')
});
let text = text.replace('\'', "");
let (digits, radix) = match text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) {
Some(digits) => (digits, 16),
None => match text.strip_prefix("0b").or_else(|| text.strip_prefix("0B")) {
Some(digits) => (digits, 2),
None => (text.as_str(), 10),
},
};
i64::from_str_radix(digits, radix).ok()
}
fn kind_words(fact: &DeclFact) -> String {
let kind = match fact.kind {
DeclKind::Type => "a type",
DeclKind::Function => "a function",
DeclKind::Variable => "a variable",
DeclKind::Namespace => "a namespace",
DeclKind::MacroLike => "a macro-like declaration",
DeclKind::Other => "a declaration",
};
let mut words = kind.to_string();
let named: Vec<&str> = fact
.parameters
.iter()
.filter(|name| !name.is_empty())
.map(String::as_str)
.collect();
if !named.is_empty() && fact.scope.is_none() {
words.push_str(&format!(" template over `{}`", named.join("`, `")));
}
match (&fact.type_of, &fact.returns) {
(Some(written), _) => words.push_str(&format!(
" of type `{}`",
cpp_code_analysis::sema::render::a_type(written)
)),
(None, Some(written)) => words.push_str(&format!(
" returning `{}`",
cpp_code_analysis::sema::render::a_type(written)
)),
(None, None) => {}
}
if !fact.bases.is_empty() {
words.push_str(&format!(" derived from `{}`", fact.bases.join("`, `")));
}
if fact.local {
words.push_str(", declared in a function body");
}
if let Some(scope) = &fact.scope {
words.push_str(&format!(", in `{scope}`"));
}
words
}
fn the_text_a_facts_offsets_are_in<'a>(
session: &'a Session<DiskFiles>,
view: Option<&'a FileView>,
file: &std::path::Path,
) -> Option<&'a str> {
match view {
Some(view) if is_the_viewed_file(view, file) => Some(&view.source),
_ => session.files().held(file).map(|held| &*held.text),
}
}
fn is_the_viewed_file(view: &FileView, file: &std::path::Path) -> bool {
cpp_code_analysis::normalize_path(&view.path, cfg!(windows))
== cpp_code_analysis::normalize_path(file, cfg!(windows))
}
fn definition_line(
session: &Session<DiskFiles>,
view: &FileView,
file: &std::path::Path,
name_offset: usize,
body_range: Option<cpp_parser::SourceRange>,
) -> RenderedText {
let text = if is_the_viewed_file(view, file) {
view.written_text().unwrap_or(&view.source)
} else {
match the_text_a_facts_offsets_are_in(session, Some(view), file) {
Some(text) => text,
None => {
return RenderedText {
text: format!(
"<the macro is defined in an unreadable file: {}>",
file.display()
),
};
}
}
};
let name_offset = name_offset.min(text.len());
let end = body_range.map_or(name_offset, |body| body.end_offset());
let line_end = text[name_offset..]
.find('\n')
.map_or(text.len(), |offset| name_offset + offset);
let end = end.max(line_end.min(text.len()));
let line_start = text[..name_offset]
.rfind('\n')
.map_or(0, |offset| offset + 1);
RenderedText {
text: text[line_start..end].trim_end().to_string(),
}
}
fn where_clause(
session: &Session<DiskFiles>,
view: Option<&FileView>,
file: &std::path::Path,
offset: usize,
) -> String {
let name = file
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| file.display().to_string());
let in_the_file = view
.and_then(|view| {
view.file_offset_of(cpp_code_analysis::file::coordinates::ReadingOffset::new(
offset,
))
})
.map(|at| at.get())
.unwrap_or(offset);
let asked = session
.files()
.held(file)
.and_then(|declaring| crate::util::position_in_file(declaring, in_the_file));
match asked {
Some(position) => format!("`{name}:{}:{}`", position.line + 1, position.character + 1),
None => format!("`{name}`"),
}
}
fn code_block(text: &str) -> String {
if text.is_empty() {
return String::new();
}
let fence = "`".repeat(3.max(longest_backtick_run(text) + 1));
format!("{fence}cpp\n{text}\n{fence}\n")
}
fn longest_backtick_run(text: &str) -> usize {
text.split(|character| character != '`')
.map(str::len)
.max()
.unwrap_or(0)
}
struct RenderedText {
text: String,
}
pub struct HoverCapabilities;
impl RegisterCapabilities for HoverCapabilities {
fn register_capabilities(server_capabilities: &mut ServerCapabilities, _: &ClientCapabilities) {
server_capabilities.hover_provider = Some(HoverProviderCapability::Simple(true));
}
}
fn a_standard_macro_meaning(word: &str) -> Option<&'static str> {
Some(match word {
"__LINE__" => "the line number of the line it stands on",
"__FILE__" => "the name of the file it stands in",
"__DATE__" => "the date this translation unit was compiled",
"__TIME__" => "the time this translation unit was compiled",
"__COUNTER__" => "a number one higher each time it is written",
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_position_dependent_standard_macro_explains_itself() {
for (word, expected) in [
("__LINE__", "the line number of the line it stands on"),
("__FILE__", "the name of the file it stands in"),
("__DATE__", "the date this translation unit was compiled"),
("__TIME__", "the time this translation unit was compiled"),
("__COUNTER__", "a number one higher each time it is written"),
] {
assert_eq!(
a_standard_macro_meaning(word),
Some(expected),
"`{word}` is explained rather than left as a bare `predefined`"
);
}
assert_eq!(
a_standard_macro_meaning("__cplusplus"),
None,
"the valued one is not here"
);
assert_eq!(
a_standard_macro_meaning("Widget"),
None,
"and neither is an ordinary name"
);
}
#[test]
fn an_enumerators_value_is_counted_from_the_ones_before_it() {
let text = "enum class Kind { A = 1, B, C = 0x10, D, E = 100, F };\n";
let tree = cpp_parser::CppParser::parse(text, cpp_parser::ParserConfig::default());
let files = cpp_code_analysis::MemoryFiles::new().with_file("/p/a.cpp", text);
let mut session = cpp_code_analysis::Session::with_config(
"/p",
cpp_code_analysis::SessionFiles::new(cpp_code_analysis::OpenDocuments::new(), files),
cpp_code_analysis::WatchFilter::new("/p"),
cpp_code_analysis::CompilerConfig::default(),
);
session.load("/p/a.cpp");
let view = session
.view_of_the_file(std::path::Path::new("/p/a.cpp"))
.expect("a view");
let _ = tree;
for (name, expected) in [
("A", 1),
("B", 2),
("C", 16),
("D", 17),
("E", 100),
("F", 101),
] {
let offset = text.find(&format!(" {name}")).expect("the fixture") + 1;
assert_eq!(
enumerator_value(&view, offset),
Some(expected),
"`{name}` counts from the enumerators before it"
);
}
let shifted = "enum class S { A = 1 << 4, B };\n";
let files = cpp_code_analysis::MemoryFiles::new().with_file("/p/s.cpp", shifted);
let mut session = cpp_code_analysis::Session::with_config(
"/p",
cpp_code_analysis::SessionFiles::new(cpp_code_analysis::OpenDocuments::new(), files),
cpp_code_analysis::WatchFilter::new("/p"),
cpp_code_analysis::CompilerConfig::default(),
);
session.load("/p/s.cpp");
let view = session
.view_of_the_file(std::path::Path::new("/p/s.cpp"))
.expect("a view");
let offset = shifted.find(" A").expect("the fixture") + 1;
assert_eq!(enumerator_value(&view, offset), None);
}
#[test]
fn a_return_type_that_is_a_macros_name_is_read_without_the_macros() {
let definition = "#define WINBASEAPI __declspec(dllimport)\n#define WINAPI __attribute__((__stdcall__))\n";
let declaration = "#include \"defs.h\"\nWINBASEAPI WINBOOL WINAPI SetConsoleOutputCP(UINT wCodePageID);\n";
let files = cpp_code_analysis::MemoryFiles::new()
.with_file("/p/defs.h", definition)
.with_file("/p/wincon.h", declaration)
.with_file("/p/main.cpp", "#include \"wincon.h\"\n");
let mut session = cpp_code_analysis::Session::with_config(
"/p",
cpp_code_analysis::SessionFiles::new(cpp_code_analysis::OpenDocuments::new(), files),
cpp_code_analysis::WatchFilter::new("/p"),
cpp_code_analysis::CompilerConfig::default(),
);
session.add_project_files([std::path::PathBuf::from("/p/main.cpp")]);
session.load("/p/wincon.h");
session.index_everything();
while session.pending_work() > 0 {
session.advance(64);
}
let fact = session
.index()
.summary(std::path::Path::new("/p/wincon.h"))
.and_then(|summary| {
summary
.declarations
.iter()
.find(|declaration| &*declaration.name == "SetConsoleOutputCP")
})
.expect("the declaration")
.clone();
let corrected = the_type_the_macros_leave(
&session,
std::path::Path::new("/p/wincon.h"),
&fact,
fact.returns.as_deref().unwrap_or_default(),
);
assert_eq!(
corrected.as_deref(),
Some("WINBOOL"),
"the two macro names drop out and the type that is left is `WINBOOL` (the fact said {:?})",
fact.returns
);
let other = DeclFact {
returns: Some("std::size_t".to_string()),
..fact.clone()
};
assert_eq!(
the_type_the_macros_leave(
&session,
std::path::Path::new("/p/wincon.h"),
&other,
"std::size_t"
),
None
);
}
#[test]
fn the_chinese_table_translates_the_same_keys() {
let english = (t!("keyword.constexpr"), t!("command.param"));
rust_i18n::set_locale("zh-CN");
let chinese = (t!("keyword.constexpr"), t!("command.param"));
rust_i18n::set_locale("en");
assert_ne!(
english.0, chinese.0,
"`keyword.constexpr` has a translation"
);
assert_ne!(english.1, chinese.1, "`command.param` has a translation");
assert!(chinese.0.starts_with("**`constexpr`**"), "{chinese:?}");
assert!(chinese.1.contains("@param"), "{chinese:?}");
println!("en: {english:?}\nzh: {chinese:?}");
}
#[test]
fn a_keyword_and_a_doc_command_explain_themselves() {
let tree = cpp_parser::CppParser::parse(
"constexpr int f(int value);\n",
cpp_parser::ParserConfig::default(),
);
let root = tree.get_red_root();
let token = root
.descendants_with_tokens()
.filter_map(|element| element.into_token())
.find(|token| token.text() == "constexpr")
.expect("the fixture writes it");
let keyword =
vocabulary::keyword_hover(&token).expect("`constexpr` is in the keyword table");
assert!(
keyword.contains("constexpr"),
"the popup names the word it is about: {keyword}"
);
assert!(
!keyword.starts_with("keyword."),
"and it is text, not the key that was not found: {keyword}"
);
let command = vocabulary::command_hover("param").expect("`@param` is in the command table");
assert!(command.contains("@param"), "{command}");
let text = "/// @param value the value\n// @param not documentation\nint f(int value);\n";
let at = text.find("@param").expect("the fixture") + 3;
assert_eq!(
doc_command_at(text, at).as_deref(),
Some("param"),
"a cursor inside `@param` in a `///` line"
);
let end = text.find("@param").expect("the fixture") + "@param".len();
assert_eq!(
doc_command_at(text, end).as_deref(),
Some("param"),
"and a cursor just past it, which is where a keystroke leaves it"
);
let plain = text.rfind("// @param").expect("the fixture") + "// @param".len();
assert_eq!(
doc_command_at(text, plain),
None,
"a `//` comment is prose with an `@` in it"
);
}
#[test]
fn a_fence_cannot_be_closed_by_the_declaration_it_quotes() {
let block = code_block("const char* s = R\"(```)\";");
assert!(block.starts_with("````cpp\n"));
assert!(block.ends_with("````\n"));
}
#[test]
fn an_expression_is_collapsed_to_one_bounded_line() {
assert_eq!(inline("scale(1,\n 2)"), "scale(1, 2)");
assert_eq!(inline(" spaced out "), "spaced out");
let long = "a".repeat(MAX_INLINE_CHARS + 20);
let shown = inline(&long);
assert_eq!(shown.chars().count(), MAX_INLINE_CHARS);
assert!(shown.ends_with('…'), "the cut is visible: {shown}");
let exact = "b".repeat(MAX_INLINE_CHARS);
assert_eq!(inline(&exact), exact);
}
#[test]
fn nothing_is_shown_for_an_empty_declaration() {
assert_eq!(code_block(""), "");
}
}
fn documentation_of_a_fact(
session: &Session<DiskFiles>,
view: &FileView,
file: &std::path::Path,
offset: usize,
) -> Option<String> {
documentation_markdown(session, view, file, offset)
}
#[test]
fn a_documented_function_is_rendered_as_blocks() {
let rendered = render_documentation(
"@brief Prints a message with a preceding newline.\n\
@param message The message to print after a newline.\n\
@param count The number of times to print the message\n\
after a newline.\n\
@return whether anything was written\n\
@note the stream is flushed",
);
assert_eq!(
rendered,
"Prints a message with a preceding newline.\n\n\
- **`message`** — The message to print after a newline.\n\
- **`count`** — The number of times to print the message after a newline.\n\
- **Returns** — whether anything was written\n\
- **Note** — the stream is flushed",
"brief is prose, the commands are one list, and a continuation line belongs to its item"
);
}
#[test]
fn an_unknown_command_stays_in_the_text() {
let rendered = render_documentation(
"\\brief One line.\n@precondition the file is open\n@param[in] p a pointer",
);
assert!(rendered.contains("One line."), "{rendered}");
assert!(
!rendered.contains("@brief") && !rendered.contains("\\brief"),
"a command this table knows is not shown as a tag: {rendered}"
);
assert!(
rendered.contains("@precondition the file is open"),
"an unknown one is kept, tag and text together: {rendered}"
);
assert!(
rendered.contains("- **`p`** (in) — a pointer"),
"and a parameter's direction is kept beside its name: {rendered}"
);
}