apollo-language-server 0.7.0

A GraphQL language server with first-class support for Apollo Federation
Documentation
use apollo_compiler::Schema;
use apollo_parser::{
    cst::{CstNode, Document},
    SyntaxKind,
};
use ropey::Rope;

use crate::utils::{
    get_ancestor_node_of_kind::get_ancestor_node_of_kind,
    lsp_range_from_cst_textrange::lsp_range_from_cst_textrange,
};

pub fn get_hover_at_position(
    document: &Document,
    schema: &Schema,
    source_text: &Rope,
    position: &lsp::Position,
) -> Option<lsp::Hover> {
    let char = source_text.try_line_to_byte(position.line as usize).ok()?;
    let offset = char + position.character as usize;
    if document.syntax().text_range().end() < offset.try_into().unwrap() {
        return None;
    }

    hover_named_type(document, schema, source_text, offset).or(hover_directive_application(
        document,
        schema,
        source_text,
        offset,
    ))
}

fn hover_named_type(
    document: &Document,
    schema: &Schema,
    source_text: &Rope,
    offset: usize,
) -> Option<lsp::Hover> {
    let token = document
        .syntax()
        .token_at_offset((offset).try_into().unwrap())
        .right_biased()?;
    let parent = token.parent()?;
    let grandparent = parent.parent()?;

    let type_in_schema = schema.types.get(token.text())?;

    // When a `NamedType`` is hovered, the offset will point to an identifier
    // token. In order to be sure it's a named type, we need to look up to the
    // token's ancestors to be sure it's a `NamedType`` and not an identifier in
    // a different context.
    (token.kind() == SyntaxKind::IDENT
        && parent.kind() == SyntaxKind::NAME
        && grandparent.kind() == SyntaxKind::NAMED_TYPE)
        .then(|| lsp::Hover {
            contents: lsp::HoverContents::Markup(lsp::MarkupContent {
                kind: lsp::MarkupKind::Markdown,
                value: format_hover_contents(&type_in_schema.to_string()),
            }),
            range: Some(lsp_range_from_cst_textrange(
                token.text_range(),
                source_text,
                None,
            )),
        })
}

fn hover_directive_application(
    document: &Document,
    schema: &Schema,
    source_text: &Rope,
    offset: usize,
) -> Option<lsp::Hover> {
    let token = document
        .syntax()
        .token_at_offset((offset as u32).into())
        .right_biased()?
        .parent()?;

    let directive_node = get_ancestor_node_of_kind(
        &token,
        SyntaxKind::DIRECTIVE,
        Some(vec![SyntaxKind::ARGUMENTS]),
    )?;
    let directive_name = directive_node.children().next()?;
    let directive_in_schema = schema
        .directive_definitions
        .get(directive_name.text().to_string().as_str())?;

    let range = lsp_range_from_cst_textrange(directive_name.text_range(), source_text, None);

    Some(lsp::Hover {
        contents: lsp::HoverContents::Markup(lsp::MarkupContent {
            kind: lsp::MarkupKind::Markdown,
            value: format_hover_contents(&directive_in_schema.to_string()),
        }),
        range: Some(lsp::Range {
            start: lsp::Position {
                line: range.start.line,
                // The hover highlight range should include the @ symbol
                character: range.start.character - 1,
            },
            end: range.end,
        }),
    })
}

// If a serialized definition starts with a blockstring comment, we can reliably
// extract the description from the graphql code fence. This allows markdown to
// be rendered correctly in the hover window. Otherwise, we can just code fence
// the whole thing.
fn format_hover_contents(serialized_definition: &str) -> String {
    if serialized_definition.starts_with("\"\"\"") {
        let vec = serialized_definition
            .splitn(3, "\"\"\"")
            .collect::<Vec<_>>();
        format!(
            // *** is a horizontal rule
            "{}\n***\n```graphql\n{}\n```",
            vec[1].trim(),
            vec[2].trim()
        )
    } else {
        format!("```graphql\n{}\n```", serialized_definition)
    }
}

#[cfg(test)]
mod tests {
    use crate::graph::{supergraph::KnownSubgraphs, Graph, GraphConfig};
    use insta::assert_snapshot;
    use tower_lsp::lsp_types::{self as lsp, HoverContents};

    const URI: &str = "file:///test.graphql";

    fn get_testing_graph() -> Graph {
        Graph::new(
            lsp::Url::parse(URI).unwrap(),
            r#"
type Query {
  user: User
  me: ID!
}

"""
User description
spanning multiple lines
"""
type User {
  id: ID! @deprecated(reason: "Use `me` instead")
  me: ID!
  name: String
}
"#
            .to_string(),
            0,
            KnownSubgraphs::default(),
            GraphConfig::default(),
        )
    }

    fn get_hover_start_end_cases(
        graph: &Graph,
        line: u32,
        character: u32,
        length: u32,
    ) -> (lsp::Hover, lsp::Hover) {
        let before_start = graph.on_hover(
            &lsp::Url::parse(URI).unwrap(),
            &lsp::Position {
                line,
                character: character - 1,
            },
        );

        let start = graph.on_hover(
            &lsp::Url::parse(URI).unwrap(),
            &lsp::Position { line, character },
        );

        let end = graph.on_hover(
            &lsp::Url::parse(URI).unwrap(),
            &lsp::Position {
                line,
                character: character + length - 1,
            },
        );

        let beyond_end = graph.on_hover(
            &lsp::Url::parse(URI).unwrap(),
            &lsp::Position {
                line,
                character: character + length,
            },
        );

        assert!(before_start.is_none());
        assert!(beyond_end.is_none());
        (
            start.expect("Expected `start` to be Some"),
            end.expect("Expected `end` to be Some"),
        )
    }
    #[test]
    fn test_hover_builtin_type() {
        let graph = get_testing_graph();

        // 'ID' reference at line 3, character 6 (with length 2)
        let (start, end) = get_hover_start_end_cases(&graph, 3, 6, 2);

        let HoverContents::Markup(markup) = &start.contents else {
            panic!("Expected HoverContents::Markup");
        };

        assert_snapshot!(markup.value);
        assert_eq!(start.range.unwrap().start.character, 6);
        assert_eq!(start.range.unwrap().end.character, 8);
        assert_eq!(end, start);
    }

    #[test]
    fn test_hover_user_type() {
        let graph = get_testing_graph();

        // 'User' reference at line 2, character 8 (with length 4)
        let (start, end) = get_hover_start_end_cases(&graph, 2, 8, 4);

        let HoverContents::Markup(markup) = &start.contents else {
            panic!("Expected HoverContents::Markup");
        };

        assert_snapshot!(markup.value);
        assert_eq!(start.range.unwrap().start.character, 8);
        assert_eq!(start.range.unwrap().end.character, 12);
        assert_eq!(end, start);
    }

    #[test]
    fn test_hover_directive() {
        let graph = get_testing_graph();

        // '@deprecated' reference at line 11, character 10 (with length 11)
        // The hover itself is inclusive of the @ symbol as well
        let (start, end) = get_hover_start_end_cases(&graph, 11, 10, 11);

        let HoverContents::Markup(markup) = &start.contents else {
            panic!("Expected HoverContents::Markup");
        };

        assert_snapshot!(markup.value);
        assert_eq!(start.range.unwrap().start.character, 10);
        assert_eq!(start.range.unwrap().end.character, 21);
        assert_eq!(end, start);
    }
}