| // Licensed to the Apache Software Foundation (ASF) under one | |
| // or more contributor license agreements. See the NOTICE file | |
| // distributed with this work for additional information | |
| // regarding copyright ownership. The ASF licenses this file | |
| // to you under the Apache License, Version 2.0 (the | |
| // "License"); you may not use this file except in compliance | |
| // with the License. You may obtain a copy of the License at | |
| // | |
| // http://www.apache.org/licenses/LICENSE-2.0 | |
| // | |
| // Unless required by applicable law or agreed to in writing, | |
| // software distributed under the License is distributed on an | |
| // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| // KIND, either express or implied. See the License for the | |
| // specific language governing permissions and limitations | |
| // under the License. | |
| //! Parser for example metadata embedded in `main.rs` documentation comments. | |
| //! | |
| //! This module scans `//!` doc comments to extract example subcommands | |
| //! and their associated metadata (file name and description), enforcing | |
| //! a strict ordering and structure to avoid ambiguous documentation. | |
| use std::{collections::HashSet, fs, path::Path}; | |
| use datafusion::common::exec_err; | |
| use datafusion::error::Result; | |
| use nom::{ | |
| Err, IResult, Parser, | |
| bytes::complete::{tag, take_until, take_while}, | |
| character::complete::multispace0, | |
| combinator::all_consuming, | |
| error::{Error, ErrorKind}, | |
| sequence::{delimited, preceded}, | |
| }; | |
| use crate::utils::example_metadata::ExampleEntry; | |
| /// Parsing state machine used while scanning `main.rs` docs. | |
| /// | |
| /// This makes the "subcommand - metadata" relationship explicit: | |
| /// metadata is only valid immediately after a subcommand has been seen. | |
| enum ParserState<'a> { | |
| /// Not currently expecting metadata. | |
| Idle, | |
| /// A subcommand was just parsed; the next valid metadata (if any) | |
| /// must belong to this subcommand. | |
| SeenSubcommand(&'a str), | |
| } | |
| /// Parses a subcommand declaration line from `main.rs` docs. | |
| /// | |
| /// Expected format: | |
| /// ```text | |
| /// //! - `<subcommand>` | |
| /// ``` | |
| fn parse_subcommand_line(input: &str) -> IResult<&str, &str> { | |
| let parser = preceded( | |
| multispace0, | |
| delimited(tag("//! - `"), take_until("`"), tag("`")), | |
| ); | |
| all_consuming(parser).parse(input) | |
| } | |
| /// Parses example metadata (file name and description) from `main.rs` docs. | |
| /// | |
| /// Expected format: | |
| /// ```text | |
| /// //! (file: <file>.rs, desc: <description>) | |
| /// ``` | |
| fn parse_metadata_line(input: &str) -> IResult<&str, (&str, &str)> { | |
| let parser = preceded( | |
| multispace0, | |
| preceded(tag("//!"), preceded(multispace0, take_while(|_| true))), | |
| ); | |
| let (rest, payload) = all_consuming(parser).parse(input)?; | |
| let content = payload | |
| .strip_prefix("(") | |
| .and_then(|s| s.strip_suffix(")")) | |
| .ok_or_else(|| Err::Error(Error::new(payload, ErrorKind::Tag)))?; | |
| let (file, desc) = content | |
| .strip_prefix("file:") | |
| .ok_or_else(|| Err::Error(Error::new(payload, ErrorKind::Tag)))? | |
| .split_once(", desc:") | |
| .ok_or_else(|| Err::Error(Error::new(payload, ErrorKind::Tag)))?; | |
| Ok((rest, (file.trim(), desc.trim()))) | |
| } | |
| /// Parses example entries from a group's `main.rs` file. | |
| pub fn parse_main_rs_docs(path: &Path) -> Result<Vec<ExampleEntry>> { | |
| let content = fs::read_to_string(path)?; | |
| let mut entries = vec![]; | |
| let mut state = ParserState::Idle; | |
| let mut seen_subcommands = HashSet::new(); | |
| for (line_no, raw_line) in content.lines().enumerate() { | |
| let line = raw_line.trim(); | |
| // Try parsing subcommand, excluding `all` because it's not used in README | |
| if let Ok((_, sub)) = parse_subcommand_line(line) { | |
| state = if sub == "all" { | |
| ParserState::Idle | |
| } else { | |
| ParserState::SeenSubcommand(sub) | |
| }; | |
| continue; | |
| } | |
| // Try parsing metadata | |
| if let Ok((_, (file, desc))) = parse_metadata_line(line) { | |
| let subcommand = match state { | |
| ParserState::SeenSubcommand(s) => s, | |
| ParserState::Idle => { | |
| return exec_err!( | |
| "Metadata without preceding subcommand at {}:{}", | |
| path.display(), | |
| line_no + 1 | |
| ); | |
| } | |
| }; | |
| if !seen_subcommands.insert(subcommand) { | |
| return exec_err!("Duplicate metadata for subcommand `{subcommand}`"); | |
| } | |
| entries.push(ExampleEntry { | |
| subcommand: subcommand.to_string(), | |
| file: file.to_string(), | |
| desc: desc.to_string(), | |
| }); | |
| state = ParserState::Idle; | |
| continue; | |
| } | |
| // If a non-blank doc line interrupts a pending subcommand, reset the state | |
| if let ParserState::SeenSubcommand(_) = state | |
| && is_non_blank_doc_line(line) | |
| { | |
| state = ParserState::Idle; | |
| } | |
| } | |
| Ok(entries) | |
| } | |
| /// Returns `true` for non-blank Rust doc comment lines (`//!`). | |
| /// | |
| /// Used to detect when a subcommand is interrupted by unrelated documentation, | |
| /// so metadata is only accepted immediately after a subcommand (blank doc lines | |
| /// are allowed in between). | |
| fn is_non_blank_doc_line(line: &str) -> bool { | |
| line.starts_with("//!") && !line.trim_start_matches("//!").trim().is_empty() | |
| } | |
| #[cfg(test)] | |
| mod tests { | |
| use super::*; | |
| use tempfile::TempDir; | |
| #[test] | |
| fn parse_subcommand_line_accepts_valid_input() { | |
| let line = "//! - `date_time`"; | |
| let sub = parse_subcommand_line(line); | |
| assert_eq!(sub, Ok(("", "date_time"))); | |
| } | |
| #[test] | |
| fn parse_subcommand_line_invalid_inputs() { | |
| let err_lines = [ | |
| "//! - ", | |
| "//! - foo", | |
| "//! - `foo` bar", | |
| "//! --", | |
| "//!-", | |
| "//!--", | |
| "//!", | |
| "//", | |
| "/", | |
| "", | |
| ]; | |
| for line in err_lines { | |
| assert!( | |
| parse_subcommand_line(line).is_err(), | |
| "expected error for input: {line}" | |
| ); | |
| } | |
| } | |
| #[test] | |
| fn parse_metadata_line_accepts_valid_input() { | |
| let line = | |
| "//! (file: date_time.rs, desc: Examples of date-time related functions)"; | |
| let res = parse_metadata_line(line); | |
| assert_eq!( | |
| res, | |
| Ok(( | |
| "", | |
| ("date_time.rs", "Examples of date-time related functions") | |
| )) | |
| ); | |
| let line = "//! (file: foo.rs, desc: Foo, bar, baz)"; | |
| let res = parse_metadata_line(line); | |
| assert_eq!(res, Ok(("", ("foo.rs", "Foo, bar, baz")))); | |
| let line = "//! (file: foo.rs, desc: Foo(FOO))"; | |
| let res = parse_metadata_line(line); | |
| assert_eq!(res, Ok(("", ("foo.rs", "Foo(FOO)")))); | |
| } | |
| #[test] | |
| fn parse_metadata_line_invalid_inputs() { | |
| let bad_lines = [ | |
| "//! (file: foo.rs)", | |
| "//! (desc: missing file)", | |
| "//! file: foo.rs, desc: test", | |
| "//! file: foo.rs,desc: test", | |
| "//! (file: foo.rs desc: test)", | |
| "//! (file: foo.rs,desc: test)", | |
| "//! (desc: test, file: foo.rs)", | |
| "//! ()", | |
| "//! (file: foo.rs, desc: test) extra", | |
| "", | |
| ]; | |
| for line in bad_lines { | |
| assert!( | |
| parse_metadata_line(line).is_err(), | |
| "expected error for input: {line}" | |
| ); | |
| } | |
| } | |
| #[test] | |
| fn parse_main_rs_docs_extracts_entries() -> Result<()> { | |
| let tmp = TempDir::new().unwrap(); | |
| let main_rs = tmp.path().join("main.rs"); | |
| fs::write( | |
| &main_rs, | |
| r#" | |
| //! - `foo` | |
| //! (file: foo.rs, desc: first example) | |
| //! | |
| //! - `bar` | |
| //! (file: bar.rs, desc: second example) | |
| "#, | |
| )?; | |
| let entries = parse_main_rs_docs(&main_rs)?; | |
| assert_eq!(entries.len(), 2); | |
| assert_eq!(entries[0].subcommand, "foo"); | |
| assert_eq!(entries[0].file, "foo.rs"); | |
| assert_eq!(entries[0].desc, "first example"); | |
| assert_eq!(entries[1].subcommand, "bar"); | |
| assert_eq!(entries[1].file, "bar.rs"); | |
| assert_eq!(entries[1].desc, "second example"); | |
| Ok(()) | |
| } | |
| } |