use crate::{
query::{lookup_keys, Query},
world::World,
};
use itertools::Itertools;
use lsp_async_stub::{
rpc::Error,
util::{LspExt, Position},
Context, Params,
};
use lsp_types::{Hover, HoverContents, HoverParams, MarkupContent, MarkupKind};
use serde_json::Value;
use taplo::{
dom::{KeyOrIndex, Keys},
syntax::SyntaxKind::{
self, BOOL, DATE, DATE_TIME_LOCAL, DATE_TIME_OFFSET, IDENT, INTEGER, INTEGER_BIN,
INTEGER_HEX, INTEGER_OCT, MULTI_LINE_STRING, MULTI_LINE_STRING_LITERAL, STRING,
STRING_LITERAL, TIME,
},
};
use taplo_common::{environment::Environment, schema::ext::schema_ext_of};
#[tracing::instrument(skip_all)]
pub(crate) async fn hover<E: Environment>(
context: Context<World<E>>,
params: Params<HoverParams>,
) -> Result<Option<Hover>, Error> {
let p = params.required()?;
let document_uri = p.text_document_position_params.text_document.uri;
let workspaces = context.workspaces.read().await;
let ws = workspaces.by_document(&document_uri);
let doc = match ws.document(&document_uri) {
Ok(d) => d,
Err(error) => {
tracing::debug!(%error, "failed to get document from workspace");
return Ok(None);
}
};
let position = p.text_document_position_params.position;
let offset = match doc.mapper.offset(Position::from_lsp(position)) {
Some(ofs) => ofs,
None => {
tracing::error!(?position, "document position not found");
return Ok(None);
}
};
let query = Query::at(&doc.dom, offset);
let position_info = match query.before.clone().and_then(|p| {
if p.syntax.kind() == IDENT || is_primitive(p.syntax.kind()) {
Some(p)
} else {
None
}
}) {
Some(before) => before,
None => match query.after.clone().and_then(|p| {
if p.syntax.kind() == IDENT || is_primitive(p.syntax.kind()) {
Some(p)
} else {
None
}
}) {
Some(after) => after,
None => return Ok(None),
},
};
if let Some(schema_association) = ws.schemas.associations().association_for(&document_uri) {
tracing::debug!(
schema.url = %schema_association.url,
schema.name = schema_association.meta["name"].as_str().unwrap_or(""),
schema.source = schema_association.meta["source"].as_str().unwrap_or(""),
"using schema"
);
let value = match serde_json::to_value(&doc.dom) {
Ok(v) => v,
Err(error) => {
tracing::warn!(%error, "cannot turn DOM into JSON");
return Ok(None);
}
};
let (keys, _) = match &position_info.dom_node {
Some(n) => n,
None => return Ok(None),
};
let links_in_hover = !ws.config.schema.links;
let mut keys = keys.clone();
if let Some(header_key) = query.header_key() {
let key_idx = header_key
.descendants_with_tokens()
.filter(|t| t.kind() == SyntaxKind::IDENT)
.position(|t| t.as_token().unwrap() == &position_info.syntax)
.unwrap();
keys = lookup_keys(
doc.dom.clone(),
&Keys::new(keys.into_iter().take(key_idx + 1)),
);
}
let node = match doc.dom.path(&keys) {
Some(n) => n,
None => return Ok(None),
};
if position_info.syntax.kind() == SyntaxKind::IDENT {
keys = lookup_keys(doc.dom.clone(), &keys);
if let Some(KeyOrIndex::Index(_)) = keys.iter().last() {
keys = keys.skip_right(1);
}
let schemas = match ws
.schemas
.schemas_at_path(&schema_association.url, &value, &keys)
.await
{
Ok(s) => s,
Err(error) => {
tracing::error!(?error, "schema resolution failed");
return Ok(None);
}
};
let content = schemas
.iter()
.map(|(_, schema)| {
let ext = schema_ext_of(schema).unwrap_or_default();
let ext_docs = ext.docs.unwrap_or_default();
let ext_links = ext.links.unwrap_or_default();
let mut s = String::new();
if let Some(docs) = ext_docs.main {
s += &docs;
} else if let Some(desc) = schema["description"].as_str() {
s += desc;
}
let link_title = if let Some(s) = schema["title"].as_str() {
s
} else {
"..."
};
if links_in_hover {
if let Some(link) = &ext_links.key {
s = format!("[{link_title}]({link})\n\n{s}");
}
}
s
})
.join("\n\n");
if content.is_empty() {
return Ok(None);
}
return Ok(Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: content,
}),
range: Some(
doc.mapper
.range(position_info.syntax.text_range())
.unwrap()
.into_lsp(),
),
}));
} else if is_primitive(position_info.syntax.kind()) {
let schemas = match ws
.schemas
.schemas_at_path(&schema_association.url, &value, &keys)
.await
{
Ok(s) => s,
Err(error) => {
tracing::error!(?error, "schema resolution failed");
return Ok(None);
}
};
let value = match serde_json::to_value(node) {
Ok(v) => v,
Err(error) => {
tracing::warn!(%error, "failed to turn DOM into JSON");
Value::Null
}
};
let content = schemas
.iter()
.map(|(_, schema)| {
let ext = schema_ext_of(schema).unwrap_or_default();
let ext_docs = ext.docs.unwrap_or_default();
let enum_docs = ext_docs.enum_values.unwrap_or_default();
let ext_links = ext.links.unwrap_or_default();
let enum_links = ext_links.enum_values.unwrap_or_default();
if !enum_docs.is_empty() {
if let Some(enum_values) = schema["enum"].as_array() {
for (idx, val) in enum_values.iter().enumerate() {
if val == &value {
if let Some(enum_docs) = enum_docs.get(idx).cloned().flatten() {
if links_in_hover {
let link_title =
if let Some(s) = schema["title"].as_str() {
s
} else {
"..."
};
if let Some(enum_link) =
enum_links.get(idx).and_then(Option::as_ref)
{
return format!(
"[{link_title}]({enum_link})\n\n{enum_docs}"
);
}
}
return enum_docs;
}
}
}
}
}
if let (Some(docs), Some(default_value)) =
(ext_docs.default_value, schema.get("default"))
{
if &value == default_value {
return docs;
}
}
if let (Some(docs), Some(const_value)) =
(ext_docs.const_value, schema.get("const"))
{
if &value == const_value {
return docs;
}
}
if let Some(docs) = ext_docs.main {
docs
} else if let Some(desc) = schema["description"].as_str() {
desc.to_string()
} else {
"".to_string()
}
})
.join("\n");
if content.is_empty() {
return Ok(None);
}
return Ok(Some(Hover {
contents: HoverContents::Markup(MarkupContent {
kind: MarkupKind::Markdown,
value: content,
}),
range: Some(
doc.mapper
.range(position_info.syntax.text_range())
.unwrap()
.into_lsp(),
),
}));
}
}
Ok(None)
}
fn is_primitive(kind: SyntaxKind) -> bool {
matches!(
kind,
BOOL | DATE
| DATE_TIME_LOCAL
| DATE_TIME_OFFSET
| TIME
| STRING
| MULTI_LINE_STRING
| STRING_LITERAL
| MULTI_LINE_STRING_LITERAL
| INTEGER
| INTEGER_HEX
| INTEGER_OCT
| INTEGER_BIN
)
}