apollo-language-server 0.6.0

A GraphQL language server with first-class support for Apollo Federation
Documentation
use apollo_compiler::{parser::LineColumn, validation::DiagnosticList};
use apollo_federation_types::composition::Issue;
use std::fmt;
use std::ops::Range;

const SYNTAX_CODE: &str = "SYNTAX";
const BUILD_CODE: &str = "BUILD";
const VALIDATION_CODE: &str = "VALIDATION";

fn map_diagnostic_list_to_lsp_diagnostics(
    maybe_diagnostic_list: Option<&DiagnosticList>,
    code: String,
    severity: lsp::DiagnosticSeverity,
) -> Vec<lsp::Diagnostic> {
    maybe_diagnostic_list
        .map(|diagnostic_list| {
            diagnostic_list
                .iter()
                .map(|e| {
                    let range = e
                        .line_column_range()
                        .map(lsp_range_from_line_column_range)
                        .unwrap_or_else(|| {
                            tracing::error!("No line column range for diagnostic: {:?}", e);
                            lsp::Range {
                                start: lsp::Position {
                                    line: 0,
                                    character: 0,
                                },
                                end: lsp::Position {
                                    line: 0,
                                    character: 1,
                                },
                            }
                        });

                    lsp::Diagnostic {
                        range,
                        message: e.error.to_string(),
                        code: Some(lsp::NumberOrString::String(code.clone())),
                        severity: Some(severity),
                        ..Default::default()
                    }
                })
                .collect()
        })
        .unwrap_or_default()
}

pub fn map_diagnostics_for_lsp(
    parse_errors: Option<&DiagnosticList>,
    build_errors: Option<&DiagnosticList>,
    validation_errors: Option<&DiagnosticList>,
    document: String,
) -> LspDiagnostics {
    let doc_diagnostics = map_diagnostic_list_to_lsp_diagnostics(
        parse_errors,
        SYNTAX_CODE.to_string(),
        lsp::DiagnosticSeverity::ERROR,
    );
    let build_diagnostics = map_diagnostic_list_to_lsp_diagnostics(
        build_errors,
        BUILD_CODE.to_string(),
        lsp::DiagnosticSeverity::ERROR,
    );
    let validation_diagnostics = map_diagnostic_list_to_lsp_diagnostics(
        validation_errors,
        VALIDATION_CODE.to_string(),
        lsp::DiagnosticSeverity::ERROR,
    );

    LspDiagnostics {
        diagnostics: [doc_diagnostics, build_diagnostics, validation_diagnostics]
            .into_iter()
            .flatten()
            .collect(),
        document,
    }
}

pub(crate) fn lsp_range_from_line_column_range(line_column: Range<LineColumn>) -> lsp::Range {
    lsp::Range {
        start: lsp::Position {
            line: line_column.start.line as u32 - 1,
            character: line_column.start.column as u32 - 1,
        },
        end: lsp::Position {
            line: line_column.end.line as u32 - 1,
            character: line_column.end.column as u32 - 1,
        },
    }
}

// This is a hack to preserve the indentation of the query in VS Code's
// diagnostics panel.
// Remove when fixed upstream: https://github.com/microsoft/vscode/pull/229454
pub fn reformat_satisfiability_error(issue: &Issue) -> String {
    let query_start = issue.message.find('{').unwrap();
    let query_end = issue.message.rfind('}').unwrap();

    let first_part = &issue.message[..query_start];
    let query_part = &issue.message[query_start..query_end + 1];
    let last_part = &issue.message[query_end + 1..];

    let query_part = query_part.replace("  ", "\u{2003}\u{2003}");
    format!("{first_part}{query_part}{last_part}")
}

#[derive(Default, Clone)]
pub struct LspDiagnostics {
    diagnostics: Vec<lsp::Diagnostic>,
    document: String,
}

impl LspDiagnostics {
    pub fn new(diagnostics: Vec<lsp::Diagnostic>, document: String) -> Self {
        Self {
            diagnostics,
            document,
        }
    }

    pub fn push(&mut self, diagnostic: lsp::Diagnostic) {
        self.diagnostics.push(diagnostic);
    }

    pub fn len(&self) -> usize {
        self.diagnostics.len()
    }

    pub fn is_empty(&self) -> bool {
        self.diagnostics.is_empty()
    }
}

impl IntoIterator for LspDiagnostics {
    type Item = lsp::Diagnostic;
    type IntoIter = std::vec::IntoIter<Self::Item>;

    fn into_iter(self) -> Self::IntoIter {
        self.diagnostics.into_iter()
    }
}

impl From<LspDiagnostics> for Vec<lsp::Diagnostic> {
    fn from(diagnostics: LspDiagnostics) -> Vec<lsp::Diagnostic> {
        diagnostics.diagnostics
    }
}

impl fmt::Debug for LspDiagnostics {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let mut display = String::new();
        // if `self.document`` ends in a '\n' character, append a space to the end of the document
        // to ensure that the last line is iterated over (else it's just ignored by `.lines()`)
        let document = if self.document.ends_with('\n') {
            format!("{} ", self.document)
        } else {
            self.document.clone()
        };

        for (line_index, line) in document.lines().enumerate() {
            let line_length = line.len();
            let relevant_diagnostics = self
                .diagnostics
                .iter()
                .filter(|d| {
                    let start_line = d.range.start.line as usize;
                    let end_line = d.range.end.line as usize;
                    line_index >= start_line && line_index <= end_line
                })
                .collect::<Vec<_>>();
            // if there are no diagnostics for this line, just print the line and skip
            if relevant_diagnostics.is_empty() {
                display.push_str(line);
                display.push('\n');
                continue;
            }
            // for each character, check if it is within the range of any diagnostic
            // if it is, add a squiggly
            for (character_index, character) in line.chars().enumerate() {
                display.push(character);
                for diagnostic in &relevant_diagnostics {
                    let start_line = diagnostic.range.start.line as usize;
                    let start_character = diagnostic.range.start.character as usize;
                    let end_line = diagnostic.range.end.line as usize;
                    let end_character = diagnostic.range.end.character as usize;

                    if line_index == start_line && line_index == end_line {
                        if (character_index >= start_character && character_index < end_character)
                            || (character_index == start_character
                                && character_index == end_character)
                        {
                            display.push('\u{0330}');
                            // Once we've pushed a squiggle for this character, we don't need to push another if multiple diagnostics overlap
                            break;
                        // edge case: if the range is at the end of the line, add a
                        // squiggly after the last character by pushing an extra space + squiggly
                        } else if character_index == line_length - 1
                            && start_character >= line_length
                        {
                            display.push_str(" \u{0330}");
                            // Once we've pushed a squiggle for this character, we don't need to push another if multiple diagnostics overlap
                            break;
                        }
                    } else {
                        todo!("multi-line diagnostics not yet supported");
                    }
                }
            }
            display.push('\n');
        }

        write!(f, "{}", display)
    }
}

#[cfg(test)]
mod tests {
    use crate::{diagnostics::reformat_satisfiability_error, testing::*};
    use apollo_federation_types::composition::{Issue, Severity};
    use insta::assert_snapshot;

    fn get_base_text() -> Option<String> {
        Some("type Query {\n  hello: String\n}\n\n".to_string())
    }

    #[test]
    fn expected_definition() {
        let expected_errors = ["syntax error: expected definition"];
        let source_texts = ["typ", "typ ", "typ\n"];
        assert_snapshot!(collect_diagnostic_comparisons(
            &expected_errors,
            &source_texts,
            get_base_text()
        ));
    }

    #[test]
    fn expected_type_name() {
        let expected_errors = ["syntax error: expected a name"];
        let source_texts = ["type", "type ", "type\n"];
        assert_snapshot!(collect_diagnostic_comparisons(
            &expected_errors,
            &source_texts,
            get_base_text()
        ));
    }

    #[test]
    fn expected_field_definition() {
        let expected_errors = [
            "syntax error: expected Field Definition",
            "`Query` has no fields",
        ];
        let source_texts = ["type Query {}", "type Query { }"];
        // need multi-line diagnostic support in our snapshot serializer for this case
        // "type Query {\n}"

        assert_snapshot!(collect_diagnostic_comparisons(
            &expected_errors,
            &source_texts,
            None,
        ));
    }

    #[test]
    fn expected_rcurly() {
        let expected_errors = ["syntax error: expected R_CURLY, got EOF"];
        let source_texts = [
            "type Query {\n  field: String",
            "type Query {\n  field: String ",
            "type Query {\n  field: String\n",
        ];
        assert_snapshot!(collect_diagnostic_comparisons(
            &expected_errors,
            &source_texts,
            None,
        ));
    }

    #[test]
    fn multiple_field_definitions() {
        let expected_errors = ["duplicate definitions for the `field` field of object type `A`"];
        let source_texts = ["type A {\n  field: String!\n  field: String!\n}"];
        assert_snapshot!(collect_diagnostic_comparisons(
            &expected_errors,
            &source_texts,
            get_base_text()
        ));
    }

    #[test]
    fn missing_root_operation_type_definition() {
        let expected_errors = [
            "syntax error: expected Root Operation Type Definition",
            "missing query root operation type in schema definition",
        ];
        let source_texts = ["schema {}", "schema { }"];
        assert_snapshot!(collect_diagnostic_comparisons(
            &expected_errors,
            &source_texts,
            None,
        ));
    }

    #[test]
    fn missing_root_operation_type_or_directive() {
        let expected_errors =
            ["syntax error: expected directives or Root Operation Type Definition"];
        let source_texts = ["extend schema", "extend schema {}"];
        assert_snapshot!(collect_diagnostic_comparisons(
            &expected_errors,
            &source_texts,
            get_base_text()
        ));
    }

    #[test]
    fn missing_directive_name() {
        let expected_errors = ["syntax error: expected a Name"];
        let source_texts = ["type TypeName @{ field: String }"];
        assert_snapshot!(collect_diagnostic_comparisons(
            &expected_errors,
            &source_texts,
            get_base_text()
        ));
    }

    #[test]
    fn multiple_diagnostics() {
        let expected_errors = [
            "syntax error: expected Field Definition",
            "syntax error: expected Field Definition",
            "`A` has no fields",
            "`B` has no fields",
        ];
        let source_texts = ["type A {}\ntype B {}"];
        assert_snapshot!(collect_diagnostic_comparisons(
            &expected_errors,
            &source_texts,
            get_base_text()
        ));
    }

    #[test]
    fn federation_simple() {
        let subgraph = r#"
            schema @link(
                url: "https://specs.apollo.dev/federation/v2.7",
                import: ["@key", "@override"]
            ) {
                query: Query
            }

            type Query {
                a: A
            }

            type A @key(fields: "a") {
                a: ID @override(from: "abc")
                b: String!
            }
        "#;
        assert!(get_diagnostics(subgraph).is_none());
    }

    #[test]
    fn test_reformat_satisfiability_error() {
        let issue = Issue {
            message: r#"The following supergraph API query:
{
  order(id: "<any id>") {
    items {
      coffee {
        temperature
      }
    }
  }
}
cannot be satisfied by the subgraphs because:
- from subgraph "file:///packages/orders/src/schema.graphql":
  - cannot find field "Coffee.temperature".
  - cannot move to subgraph "file:///connectors/coffees.graphql", which has field "Coffee.temperature", because type "Coffee" has no @key defined in subgraph "file:///connectors/coffees.graphql".
  - cannot move to subgraph "file:///connectors/coffees.graphql", which has field "Coffee.temperature", because type "Coffee" has no @key defined in subgraph "file:///connectors/coffees.graphql".
  - cannot move to subgraph "file:///connectors/coffees.graphql", which has field "Coffee.temperature", because type "Coffee" has no @key defined in subgraph "file:///connectors/coffees.graphql"."#.to_string(),
            code: "SATISFIABILITY".to_string(),
            severity: Severity::Error,
            locations: vec![],
        };

        assert_snapshot!(reformat_satisfiability_error(&issue));
    }
}