oxc-graphql-parser 0.0.4

Spec-compliant GraphQL parser.
Documentation
use crate::Allocator;
use crate::Lexer;
use crate::Parser;
use crate::TokenKind;
use crate::ast;
use std::fs;
use std::path::Path;
use std::path::PathBuf;

#[test]
fn lexer_tests() {
    let source = r#"
type Query {
  hello(name: String = "world"): String
}
"#;
    let (tokens, errors) = Lexer::new(source).lex();
    assert!(errors.is_empty());
    assert!(tokens.iter().any(|token| token.kind() == TokenKind::Name && token.data() == "Query"));
}

#[test]
fn parser_parses_object_type_definition() {
    let source = r#"
type Query {
  hello(name: String = "world"): String
}
"#;
    let allocator = Allocator::default();
    let ast = Parser::new(&allocator, source).parse();

    assert_eq!(ast.errors().len(), 0);
    let document = ast.document();
    assert_eq!(document.definitions.len(), 1);

    let ast::Definition::ObjectType(object) = &document.definitions[0] else {
        panic!("expected object type definition");
    };
    assert_eq!(object.name.as_str(), "Query");
    assert_eq!(object.fields.len(), 1);
    assert_eq!(object.fields[0].name.as_str(), "hello");
    assert_eq!(object.fields[0].arguments[0].name.as_str(), "name");
}

#[test]
fn parser_parses_query_variables_and_used_variables() {
    let source = r#"
query GraphQuery($graph_id: ID!, $variant: String) {
  service(id: $graph_id) {
    schema(tag: $variant) {
      document
    }
  }
}
"#;
    let allocator = Allocator::default();
    let ast = Parser::new(&allocator, source).parse();
    assert_eq!(ast.errors().len(), 0);

    let ast::Definition::Operation(operation) = &ast.document().definitions[0] else {
        panic!("expected operation definition");
    };
    assert_eq!(operation.name.as_ref().unwrap().as_str(), "GraphQuery");
    assert_eq!(operation.variable_definitions.len(), 2);

    let mut used = Vec::new();
    collect_variables(operation.selection_set.as_ref().unwrap(), &mut used);
    assert_eq!(used, ["graph_id", "variant"]);
}

#[test]
fn parser_parses_selection_set_and_type_roots() {
    let allocator = Allocator::default();
    let selection = Parser::new(&allocator, "{ product { name } }").parse_selection_set();
    assert_eq!(selection.errors().len(), 0);
    assert_eq!(selection.field_set().selections.len(), 1);

    let ty = Parser::new(&allocator, "[String!]!").parse_type();
    assert_eq!(ty.errors().len(), 0);
    assert!(matches!(ty.ty(), ast::Type::NonNull(_)));
}

#[test]
fn parser_parses_legacy_fragment_variables() {
    let source = "fragment F($v: Int) on T { f(x: $v) }";
    let allocator = Allocator::default();
    let ast = Parser::new(&allocator, source).allow_legacy_fragment_variables(true).parse();
    assert_eq!(ast.errors().len(), 0);

    let ast::Definition::Fragment(fragment) = &ast.document().definitions[0] else {
        panic!("expected fragment definition");
    };
    assert_eq!(fragment.name.as_str(), "F");
    assert_eq!(fragment.variable_definitions.len(), 1);
    assert_eq!(fragment.variable_definitions[0].variable.name.as_str(), "v");
}

#[test]
fn parser_collects_comment_spans_in_document_order() {
    let source = r#"# leading
query Q {
  # inside selection set
  field # trailing
  # before closing brace
}
# between definitions
type T {
  name: String
}
# at end of document"#;
    let allocator = Allocator::default();
    let ast = Parser::new(&allocator, source).parse();
    assert_eq!(ast.errors().len(), 0);

    let comments =
        ast.comments().iter().map(|span| &source[span.start..span.end]).collect::<Vec<_>>();
    assert_eq!(
        comments,
        [
            "# leading",
            "# inside selection set",
            "# trailing",
            "# before closing brace",
            "# between definitions",
            "# at end of document",
        ]
    );
}

#[test]
fn parser_collects_comments_without_duplicates_on_lookahead() {
    // `extend` parsing peeks ahead multiple times; comments in between must be
    // recorded only once.
    let source = "# before extend\nextend type T { name: String }";
    let allocator = Allocator::default();
    let ast = Parser::new(&allocator, source).parse();
    assert_eq!(ast.errors().len(), 0);
    assert_eq!(ast.comments(), [ast::Span::new(0, "# before extend".len())]);
}

#[test]
fn parser_collects_no_comments_when_absent() {
    let allocator = Allocator::default();
    let ast = Parser::new(&allocator, "type T { name: String }").parse();
    assert_eq!(ast.errors().len(), 0);
    assert!(ast.comments().is_empty());
}

#[test]
fn parser_comment_spans_end_before_line_terminators() {
    for line_terminator in ["\n", "\r\n", "\r"] {
        let source = format!("# comment{line_terminator}type T {{ name: String }}");
        let allocator = Allocator::default();
        let ast = Parser::new(&allocator, &source).parse();
        assert_eq!(ast.errors().len(), 0);
        assert_eq!(ast.comments(), [ast::Span::new(0, "# comment".len())]);
    }
}

#[test]
fn parser_collects_comments_for_selection_set_and_type_roots() {
    let allocator = Allocator::default();
    let selection = Parser::new(&allocator, "{ field # inside\n}").parse_selection_set();
    assert_eq!(selection.errors().len(), 0);
    assert_eq!(selection.comments().len(), 1);

    let ty = Parser::new(&allocator, "String").parse_type();
    assert_eq!(ty.errors().len(), 0);
    assert!(ty.comments().is_empty());
}

#[test]
fn definition_and_selection_spans_match_inner_nodes() {
    let source = r#"query Q {
  field
  ...spread
  ... on T {
    inline
  }
}
type T {
  name: String
}
extend type T {
  extra: String
}"#;
    let allocator = Allocator::default();
    let ast = Parser::new(&allocator, source).parse();
    assert_eq!(ast.errors().len(), 0);

    let definitions = &ast.document().definitions;
    let ast::Definition::Operation(operation) = &definitions[0] else {
        panic!("expected operation definition");
    };
    assert_eq!(definitions[0].span(), operation.span);
    let ast::Definition::ObjectType(object) = &definitions[1] else {
        panic!("expected object type definition");
    };
    assert_eq!(definitions[1].span(), object.span);
    let ast::Definition::ObjectTypeExtension(extension) = &definitions[2] else {
        panic!("expected object type extension");
    };
    assert_eq!(definitions[2].span(), extension.span);

    let selections = &operation.selection_set.as_ref().unwrap().selections;
    let ast::Selection::Field(field) = &selections[0] else {
        panic!("expected field");
    };
    assert_eq!(selections[0].span(), field.span);
    let ast::Selection::FragmentSpread(spread) = &selections[1] else {
        panic!("expected fragment spread");
    };
    assert_eq!(selections[1].span(), spread.span);
    let ast::Selection::InlineFragment(inline) = &selections[2] else {
        panic!("expected inline fragment");
    };
    assert_eq!(selections[2].span(), inline.span);
}

#[test]
fn parser_ok_fixtures_have_no_errors() {
    for path in graphql_files("parser/ok") {
        let source = fs::read_to_string(&path).unwrap();
        let allocator = Allocator::default();
        let ast = Parser::new(&allocator, &source).parse();
        let errors = ast.errors().collect::<Vec<_>>();
        assert!(errors.is_empty(), "{}: {errors:?}", path.display());
    }
}

#[test]
fn parser_err_fixtures_have_errors() {
    for path in graphql_files("parser/err") {
        let source = fs::read_to_string(&path).unwrap();
        let allocator = Allocator::default();
        let ast = Parser::new(&allocator, &source).parse();
        assert!(ast.errors().len() > 0, "{}", path.display());
    }
}

#[test]
#[ignore]
fn ecosystem_graphql_corpus_has_no_parse_errors() {
    let root = std::env::var_os("OXC_GRAPHQL_ECOSYSTEM_REPOS")
        .map(PathBuf::from)
        .expect("set OXC_GRAPHQL_ECOSYSTEM_REPOS to an ecosystem-ci repos directory");
    let mut files = Vec::new();
    collect_graphql_files(&root, &mut files);

    let mut failures = Vec::new();
    for path in &files {
        let source = fs::read_to_string(path).unwrap();
        let allocator = Allocator::default();
        let ast = Parser::new(&allocator, &source).parse();
        let errors = ast.errors().collect::<Vec<_>>();
        if !errors.is_empty() {
            failures.push(format!("{}: {errors:?}", path.display()));
        }
    }

    assert!(
        failures.is_empty(),
        "{} of {} ecosystem GraphQL files failed to parse:\n{}",
        failures.len(),
        files.len(),
        failures.join("\n")
    );
}

fn collect_variables<'a>(selection_set: &'a ast::SelectionSet<'_>, output: &mut Vec<&'a str>) {
    for selection in &selection_set.selections {
        if let ast::Selection::Field(field) = selection {
            for argument in &field.arguments {
                collect_variable_value(argument.value.as_ref(), output);
            }
            if let Some(selection_set) = &field.selection_set {
                collect_variables(selection_set, output);
            }
        }
    }
}

fn collect_variable_value<'a>(value: Option<&'a ast::Value<'_>>, output: &mut Vec<&'a str>) {
    match value {
        Some(ast::Value::Variable(variable)) => output.push(variable.name.as_str()),
        Some(ast::Value::List(list)) => {
            for value in &list.values {
                collect_variable_value(Some(value), output);
            }
        }
        Some(ast::Value::Object(object)) => {
            for field in &object.fields {
                collect_variable_value(field.value.as_ref(), output);
            }
        }
        _ => {}
    }
}

fn graphql_files(path: &str) -> Vec<PathBuf> {
    let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("test_data").join(path);
    let mut files = fs::read_dir(dir)
        .unwrap()
        .filter_map(Result::ok)
        .map(|entry| entry.path())
        .filter(|path| path.extension().is_some_and(|extension| extension == "graphql"))
        .collect::<Vec<_>>();
    files.sort();
    files
}

fn collect_graphql_files(dir: &Path, files: &mut Vec<PathBuf>) {
    for entry in fs::read_dir(dir).unwrap() {
        let path = entry.unwrap().path();
        if path.is_dir() {
            let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
                continue;
            };
            if matches!(name, ".git" | "node_modules" | "target") {
                continue;
            }
            collect_graphql_files(&path, files);
        } else if path
            .extension()
            .is_some_and(|extension| matches!(extension.to_str(), Some("gql" | "graphql")))
        {
            files.push(path);
        }
    }
    files.sort();
}