1#![doc = self_test!(=>
81 )]
97#[cfg(not(feature = "default"))]
148compile_error!(
149 "The feature `default` must be enabled to ensure \
150 forward compatibility with future version of this crate"
151);
152
153extern crate proc_macro;
154
155use proc_macro::{TokenStream, TokenTree};
156use std::borrow::Cow;
157use std::collections::{HashMap, HashSet};
158use std::convert::TryFrom;
159use std::fmt::Write;
160use std::path::Path;
161use std::str::FromStr;
162
163fn error(e: &str) -> TokenStream {
164 TokenStream::from_str(&format!("::core::compile_error!{{\"{}\"}}", e.escape_default())).unwrap()
165}
166
167fn compile_error(msg: &str, tt: Option<TokenTree>) -> TokenStream {
168 let span = tt.as_ref().map_or_else(proc_macro::Span::call_site, TokenTree::span);
169 use proc_macro::{Delimiter, Group, Ident, Literal, Punct, Spacing};
170 use std::iter::FromIterator;
171 TokenStream::from_iter(vec![
172 TokenTree::Ident(Ident::new("compile_error", span)),
173 TokenTree::Punct({
174 let mut punct = Punct::new('!', Spacing::Alone);
175 punct.set_span(span);
176 punct
177 }),
178 TokenTree::Group({
179 let mut group = Group::new(Delimiter::Brace, {
180 TokenStream::from_iter([TokenTree::Literal({
181 let mut string = Literal::string(msg);
182 string.set_span(span);
183 string
184 })])
185 });
186 group.set_span(span);
187 group
188 }),
189 ])
190}
191
192#[derive(Default)]
193struct Args {
194 feature_label: Option<String>,
195}
196
197fn parse_args(input: TokenStream) -> Result<Args, TokenStream> {
198 let mut token_trees = input.into_iter().fuse();
199
200 match token_trees.next() {
202 None => return Ok(Args::default()),
203 Some(TokenTree::Ident(ident)) if ident.to_string() == "feature_label" => (),
204 tt => return Err(compile_error("expected `feature_label`", tt)),
205 }
206
207 match token_trees.next() {
209 Some(TokenTree::Punct(p)) if p.as_char() == '=' => (),
210 tt => return Err(compile_error("expected `=`", tt)),
211 }
212
213 let feature_label;
215 if let Some(tt) = token_trees.next() {
216 match litrs::StringLit::<String>::try_from(&tt) {
217 Ok(string_lit) if string_lit.value().contains("{feature}") => {
218 feature_label = string_lit.value().to_string()
219 }
220 _ => {
221 return Err(compile_error(
222 "expected a string literal containing the substring \"{feature}\"",
223 Some(tt),
224 ))
225 }
226 }
227 } else {
228 return Err(compile_error(
229 "expected a string literal containing the substring \"{feature}\"",
230 None,
231 ));
232 }
233
234 if let tt @ Some(_) = token_trees.next() {
236 return Err(compile_error("unexpected token after the format string", tt));
237 }
238
239 Ok(Args { feature_label: Some(feature_label) })
240}
241
242#[proc_macro]
246pub fn document_features(tokens: TokenStream) -> TokenStream {
247 parse_args(tokens)
248 .and_then(|args| document_features_impl(&args))
249 .unwrap_or_else(std::convert::identity)
250}
251
252fn document_features_impl(args: &Args) -> Result<TokenStream, TokenStream> {
253 let path = std::env::var("CARGO_MANIFEST_DIR").unwrap();
254 let mut cargo_toml = std::fs::read_to_string(Path::new(&path).join("Cargo.toml"))
255 .map_err(|e| error(&format!("Can't open Cargo.toml: {:?}", e)))?;
256
257 if !has_doc_comments(&cargo_toml) {
258 if let Ok(orig) = std::fs::read_to_string(Path::new(&path).join("Cargo.toml.orig")) {
261 if has_doc_comments(&orig) {
262 cargo_toml = orig;
263 }
264 }
265 }
266
267 let result = process_toml(&cargo_toml, args).map_err(|e| error(&e))?;
268 Ok(std::iter::once(proc_macro::TokenTree::from(proc_macro::Literal::string(&result))).collect())
269}
270
271fn has_doc_comments(cargo_toml: &str) -> bool {
273 let mut lines = cargo_toml.lines().map(str::trim);
274 while let Some(line) = lines.next() {
275 if line.starts_with("## ") || line.starts_with("#! ") {
276 return true;
277 }
278 let before_coment = line.split_once('#').map_or(line, |(before, _)| before);
279 if line.starts_with("#") {
280 continue;
281 }
282 if let Some((_, mut quote)) = before_coment.split_once("\"\"\"") {
283 loop {
284 if let Some((_, s)) = quote.split_once('\\') {
286 quote = s.strip_prefix('\\').or_else(|| s.strip_prefix('"')).unwrap_or(s);
287 continue;
288 }
289 if let Some((_, out_quote)) = quote.split_once("\"\"\"") {
291 let out_quote = out_quote.trim_start_matches('"');
292 let out_quote =
293 out_quote.split_once('#').map_or(out_quote, |(before, _)| before);
294 if let Some((_, q)) = out_quote.split_once("\"\"\"") {
295 quote = q;
296 continue;
297 }
298 break;
299 };
300 match lines.next() {
301 Some(l) => quote = l,
302 None => return false,
303 }
304 }
305 }
306 }
307 false
308}
309
310#[test]
311fn test_has_doc_coment() {
312 assert!(has_doc_comments("foo\nbar\n## comment\nddd"));
313 assert!(!has_doc_comments("foo\nbar\n#comment\nddd"));
314 assert!(!has_doc_comments(
315 r#"
316[[package.metadata.release.pre-release-replacements]]
317exactly = 1 # not a doc comment
318file = "CHANGELOG.md"
319replace = """
320<!-- next-header -->
321## [Unreleased] - ReleaseDate
322"""
323search = "<!-- next-header -->"
324array = ["""foo""", """
325bar""", """eee
326## not a comment
327"""]
328 "#
329 ));
330 assert!(has_doc_comments(
331 r#"
332[[package.metadata.release.pre-release-replacements]]
333exactly = 1 # """
334file = "CHANGELOG.md"
335replace = """
336<!-- next-header -->
337## [Unreleased] - ReleaseDate
338"""
339search = "<!-- next-header -->"
340array = ["""foo""", """
341bar""", """eee
342## not a comment
343"""]
344## This is a comment
345feature = "45"
346 "#
347 ));
348
349 assert!(!has_doc_comments(
350 r#"
351[[package.metadata.release.pre-release-replacements]]
352value = """" string \"""
353## within the string
354\""""
355another_string = """"" # """
356## also within"""
357"#
358 ));
359
360 assert!(has_doc_comments(
361 r#"
362[[package.metadata.release.pre-release-replacements]]
363value = """" string \"""
364## within the string
365\""""
366another_string = """"" # """
367## also within"""
368## out of the string
369foo = bar
370 "#
371 ));
372}
373
374fn dependents(
375 feature_dependencies: &HashMap<String, Vec<String>>,
376 feature: &str,
377 collected: &mut HashSet<String>,
378) {
379 if collected.contains(feature) {
380 return;
381 }
382 collected.insert(feature.to_string());
383 if let Some(dependencies) = feature_dependencies.get(feature) {
384 for dependency in dependencies {
385 dependents(feature_dependencies, dependency, collected);
386 }
387 }
388}
389
390fn parse_feature_deps<'a>(
391 s: &'a str,
392 dep: &str,
393) -> Result<impl Iterator<Item = String> + 'a, String> {
394 Ok(s.trim()
395 .strip_prefix('[')
396 .and_then(|r| r.strip_suffix(']'))
397 .ok_or_else(|| format!("Parse error while parsing dependency {}", dep))?
398 .split(',')
399 .map(|d| d.trim().trim_matches(|c| c == '"' || c == '\'').trim().to_string())
400 .filter(|d: &String| !d.is_empty()))
401}
402
403fn process_toml(cargo_toml: &str, args: &Args) -> Result<String, String> {
404 let mut lines = cargo_toml
406 .lines()
407 .map(str::trim)
408 .filter(|l| {
410 !l.is_empty() && (!l.starts_with('#') || l.starts_with("##") || l.starts_with("#!"))
411 });
412 let mut top_comment = String::new();
413 let mut current_comment = String::new();
414 let mut features = vec![];
415 let mut default_features = HashSet::new();
416 let mut current_table = "";
417 let mut dependencies = HashMap::new();
418 while let Some(line) = lines.next() {
419 if let Some(x) = line.strip_prefix("#!") {
420 if !x.is_empty() && !x.starts_with(' ') {
421 continue; }
423 if !current_comment.is_empty() {
424 return Err("Cannot mix ## and #! comments between features.".into());
425 }
426 if top_comment.is_empty() && !features.is_empty() {
427 top_comment = "\n".into();
428 }
429 writeln!(top_comment, "{}", x).unwrap();
430 } else if let Some(x) = line.strip_prefix("##") {
431 if !x.is_empty() && !x.starts_with(' ') {
432 continue; }
434 writeln!(current_comment, " {}", x).unwrap();
435 } else if let Some(table) = line.strip_prefix('[') {
436 current_table = table
437 .split_once(']')
438 .map(|(t, _)| t.trim())
439 .ok_or_else(|| format!("Parse error while parsing line: {}", line))?;
440 if !current_comment.is_empty() {
441 #[allow(clippy::unnecessary_lazy_evaluations)]
442 let dep = current_table
443 .rsplit_once('.')
444 .and_then(|(table, dep)| table.trim().ends_with("dependencies").then(|| dep))
445 .ok_or_else(|| format!("Not a feature: `{}`", line))?;
446 features.push((
447 dep.trim(),
448 std::mem::take(&mut top_comment),
449 std::mem::take(&mut current_comment),
450 ));
451 }
452 } else if let Some((dep, rest)) = line.split_once('=') {
453 let dep = dep.trim().trim_matches('"');
454 let rest = get_balanced(rest, &mut lines)
455 .map_err(|e| format!("Parse error while parsing value {}: {}", dep, e))?;
456 if current_table == "features" {
457 if dep == "default" {
458 default_features.extend(parse_feature_deps(&rest, dep)?);
459 } else {
460 for d in parse_feature_deps(&rest, dep)? {
461 dependencies
462 .entry(dep.to_string())
463 .or_insert_with(Vec::new)
464 .push(d.clone());
465 }
466 }
467 }
468 if !current_comment.is_empty() {
469 if current_table.ends_with("dependencies") {
470 if !rest
471 .split_once("optional")
472 .and_then(|(_, r)| r.trim().strip_prefix('='))
473 .map_or(false, |r| r.trim().starts_with("true"))
474 {
475 return Err(format!("Dependency {} is not an optional dependency", dep));
476 }
477 } else if current_table != "features" {
478 return Err(format!(
479 r#"Comment cannot be associated with a feature: "{}""#,
480 current_comment.trim()
481 ));
482 }
483 features.push((
484 dep,
485 std::mem::take(&mut top_comment),
486 std::mem::take(&mut current_comment),
487 ));
488 }
489 }
490 }
491 let df = default_features.iter().cloned().collect::<Vec<_>>();
492 for feature in df {
493 let mut resolved = HashSet::new();
494 dependents(&dependencies, &feature, &mut resolved);
495 default_features.extend(resolved.into_iter());
496 }
497 if !current_comment.is_empty() {
498 return Err("Found comment not associated with a feature".into());
499 }
500 if features.is_empty() {
501 return Ok("*No documented features in Cargo.toml*".into());
502 }
503 let mut result = String::new();
504 for (f, top, comment) in features {
505 let default = if default_features.contains(f) { " *(enabled by default)*" } else { "" };
506 let feature_label = args.feature_label.as_deref().unwrap_or("**`{feature}`**");
507 let comment = if comment.trim().is_empty() {
508 String::new()
509 } else {
510 format!(" —{}", comment.trim_end())
511 };
512
513 writeln!(
514 result,
515 "{}* {}{}{}",
516 top,
517 feature_label.replace("{feature}", f),
518 default,
519 comment,
520 )
521 .unwrap();
522 }
523 result += &top_comment;
524 Ok(result)
525}
526
527fn get_balanced<'a>(
528 first_line: &'a str,
529 lines: &mut impl Iterator<Item = &'a str>,
530) -> Result<Cow<'a, str>, String> {
531 let mut line = first_line;
532 let mut result = Cow::from("");
533
534 let mut in_quote = false;
535 let mut level = 0;
536 loop {
537 let mut last_slash = false;
538 for (idx, b) in line.as_bytes().iter().enumerate() {
539 if last_slash {
540 last_slash = false
541 } else if in_quote {
542 match b {
543 b'\\' => last_slash = true,
544 b'"' | b'\'' => in_quote = false,
545 _ => (),
546 }
547 } else {
548 match b {
549 b'\\' => last_slash = true,
550 b'"' => in_quote = true,
551 b'{' | b'[' => level += 1,
552 b'}' | b']' if level == 0 => return Err("unbalanced source".into()),
553 b'}' | b']' => level -= 1,
554 b'#' => {
555 line = &line[..idx];
556 break;
557 }
558 _ => (),
559 }
560 }
561 }
562 if result.len() == 0 {
563 result = Cow::from(line);
564 } else {
565 *result.to_mut() += line;
566 }
567 if level == 0 {
568 return Ok(result);
569 }
570 line = if let Some(l) = lines.next() {
571 l
572 } else {
573 return Err("unbalanced source".into());
574 };
575 }
576}
577
578#[test]
579fn test_get_balanced() {
580 assert_eq!(
581 get_balanced(
582 "{",
583 &mut IntoIterator::into_iter(["a", "{ abc[], #ignore", " def }", "}", "xxx"])
584 ),
585 Ok("{a{ abc[], def }}".into())
586 );
587 assert_eq!(
588 get_balanced("{ foo = \"{#\" } #ignore", &mut IntoIterator::into_iter(["xxx"])),
589 Ok("{ foo = \"{#\" } ".into())
590 );
591 assert_eq!(
592 get_balanced("]", &mut IntoIterator::into_iter(["["])),
593 Err("unbalanced source".into())
594 );
595}
596
597#[cfg(feature = "self-test")]
598#[proc_macro]
599#[doc(hidden)]
600pub fn self_test_helper(input: TokenStream) -> TokenStream {
602 let mut code = String::new();
603 for line in (&input).to_string().trim_matches(|c| c == '"' || c == '#').lines() {
604 if line.strip_prefix('#').map_or(false, |x| x.is_empty() || x.starts_with(' ')) {
607 code += "#";
608 }
609 code += line;
610 code += "\n";
611 }
612 process_toml(&code, &Args::default()).map_or_else(
613 |e| error(&e),
614 |r| std::iter::once(proc_macro::TokenTree::from(proc_macro::Literal::string(&r))).collect(),
615 )
616}
617
618#[cfg(feature = "self-test")]
619macro_rules! self_test {
620 (#[doc = $toml:literal] => #[doc = $md:literal]) => {
621 concat!(
622 "\n`````rust\n\
623 fn normalize_md(md : &str) -> String {
624 md.lines().skip_while(|l| l.is_empty()).map(|l| l.trim())
625 .collect::<Vec<_>>().join(\"\\n\")
626 }
627 assert_eq!(normalize_md(document_features::self_test_helper!(",
628 stringify!($toml),
629 ")), normalize_md(",
630 stringify!($md),
631 "));\n`````\n\n"
632 )
633 };
634}
635
636#[cfg(not(feature = "self-test"))]
637macro_rules! self_test {
638 (#[doc = $toml:literal] => #[doc = $md:literal]) => {
639 concat!(
640 "This contents in Cargo.toml:\n`````toml",
641 $toml,
642 "\n`````\n Generates the following:\n\
643 <table><tr><th>Preview</th></tr><tr><td>\n\n",
644 $md,
645 "\n</td></tr></table>\n\n \n",
646 )
647 };
648}
649
650use self_test;
651
652#[cfg(doc)]
683struct FeatureLabelCompilationTest;
684
685#[cfg(test)]
686mod tests {
687 use super::{process_toml, Args};
688
689 #[track_caller]
690 fn test_error(toml: &str, expected: &str) {
691 let err = process_toml(toml, &Args::default()).unwrap_err();
692 assert!(err.contains(expected), "{:?} does not contain {:?}", err, expected)
693 }
694
695 #[test]
696 fn only_get_balanced_in_correct_table() {
697 process_toml(
698 r#"
699
700[package.metadata.release]
701pre-release-replacements = [
702 {test=\"\#\# \"},
703]
704[abcd]
705[features]#xyz
706#! abc
707#
708###
709#! def
710#!
711## 123
712## 456
713feat1 = ["plop"]
714#! ghi
715no_doc = []
716##
717feat2 = ["momo"]
718#! klm
719default = ["feat1", "something_else"]
720#! end
721 "#,
722 &Args::default(),
723 )
724 .unwrap();
725 }
726
727 #[test]
728 fn no_features() {
729 let r = process_toml(
730 r#"
731[features]
732[dependencies]
733foo = 4;
734"#,
735 &Args::default(),
736 )
737 .unwrap();
738 assert_eq!(r, "*No documented features in Cargo.toml*");
739 }
740
741 #[test]
742 fn no_features2() {
743 let r = process_toml(
744 r#"
745[packages]
746[dependencies]
747"#,
748 &Args::default(),
749 )
750 .unwrap();
751 assert_eq!(r, "*No documented features in Cargo.toml*");
752 }
753
754 #[test]
755 fn parse_error3() {
756 test_error(
757 r#"
758[features]
759ff = []
760[abcd
761efgh
762[dependencies]
763"#,
764 "Parse error while parsing line: [abcd",
765 );
766 }
767
768 #[test]
769 fn parse_error4() {
770 test_error(
771 r#"
772[features]
773## dd
774## ff
775#! ee
776## ff
777"#,
778 "Cannot mix",
779 );
780 }
781
782 #[test]
783 fn parse_error5() {
784 test_error(
785 r#"
786[features]
787## dd
788"#,
789 "not associated with a feature",
790 );
791 }
792
793 #[test]
794 fn parse_error6() {
795 test_error(
796 r#"
797[features]
798# ff
799foo = []
800default = [
801#ffff
802# ff
803"#,
804 "Parse error while parsing value default",
805 );
806 }
807
808 #[test]
809 fn parse_error7() {
810 test_error(
811 r#"
812[features]
813# f
814foo = [ x = { ]
815bar = []
816"#,
817 "Parse error while parsing value foo",
818 );
819 }
820
821 #[test]
822 fn not_a_feature1() {
823 test_error(
824 r#"
825## hallo
826[features]
827"#,
828 "Not a feature: `[features]`",
829 );
830 }
831
832 #[test]
833 fn not_a_feature2() {
834 test_error(
835 r#"
836[package]
837## hallo
838foo = []
839"#,
840 "Comment cannot be associated with a feature: \"hallo\"",
841 );
842 }
843
844 #[test]
845 fn non_optional_dep1() {
846 test_error(
847 r#"
848[dev-dependencies]
849## Not optional
850foo = { version = "1.2", optional = false }
851"#,
852 "Dependency foo is not an optional dependency",
853 );
854 }
855
856 #[test]
857 fn non_optional_dep2() {
858 test_error(
859 r#"
860[dev-dependencies]
861## Not optional
862foo = { version = "1.2" }
863"#,
864 "Dependency foo is not an optional dependency",
865 );
866 }
867
868 #[test]
869 fn basic() {
870 let toml = r#"
871[abcd]
872[features]#xyz
873#! abc
874#
875###
876#! def
877#!
878## 123
879## 456
880feat1 = ["plop"]
881#! ghi
882no_doc = []
883##
884feat2 = ["momo"]
885#! klm
886default = ["feat1", "something_else"]
887#! end
888 "#;
889 let parsed = process_toml(toml, &Args::default()).unwrap();
890 assert_eq!(
891 parsed,
892 " abc\n def\n\n* **`feat1`** *(enabled by default)* — 123\n 456\n\n ghi\n* **`feat2`**\n\n klm\n end\n"
893 );
894 let parsed = process_toml(
895 toml,
896 &Args {
897 feature_label: Some(
898 "<span class=\"stab portability\"><code>{feature}</code></span>".into(),
899 ),
900 },
901 )
902 .unwrap();
903 assert_eq!(
904 parsed,
905 " abc\n def\n\n* <span class=\"stab portability\"><code>feat1</code></span> *(enabled by default)* — 123\n 456\n\n ghi\n* <span class=\"stab portability\"><code>feat2</code></span>\n\n klm\n end\n"
906 );
907 }
908
909 #[test]
910 fn dependencies() {
911 let toml = r#"
912#! top
913[dev-dependencies] #yo
914## dep1
915dep1 = { version="1.2", optional=true}
916#! yo
917dep2 = "1.3"
918## dep3
919[target.'cfg(unix)'.build-dependencies.dep3]
920version = "42"
921optional = true
922 "#;
923 let parsed = process_toml(toml, &Args::default()).unwrap();
924 assert_eq!(parsed, " top\n* **`dep1`** — dep1\n\n yo\n* **`dep3`** — dep3\n");
925 let parsed = process_toml(
926 toml,
927 &Args {
928 feature_label: Some(
929 "<span class=\"stab portability\"><code>{feature}</code></span>".into(),
930 ),
931 },
932 )
933 .unwrap();
934 assert_eq!(parsed, " top\n* <span class=\"stab portability\"><code>dep1</code></span> — dep1\n\n yo\n* <span class=\"stab portability\"><code>dep3</code></span> — dep3\n");
935 }
936
937 #[test]
938 fn multi_lines() {
939 let toml = r#"
940[package.metadata.foo]
941ixyz = [
942 ["array"],
943 [
944 "of",
945 "arrays"
946 ]
947]
948[dev-dependencies]
949## dep1
950dep1 = {
951 version="1.2-}",
952 optional=true
953}
954[features]
955default = [
956 "goo",
957 "\"]",
958 "bar",
959]
960## foo
961foo = [
962 "bar"
963]
964## bar
965bar = [
966
967]
968 "#;
969 let parsed = process_toml(toml, &Args::default()).unwrap();
970 assert_eq!(
971 parsed,
972 "* **`dep1`** — dep1\n* **`foo`** — foo\n* **`bar`** *(enabled by default)* — bar\n"
973 );
974 let parsed = process_toml(
975 toml,
976 &Args {
977 feature_label: Some(
978 "<span class=\"stab portability\"><code>{feature}</code></span>".into(),
979 ),
980 },
981 )
982 .unwrap();
983 assert_eq!(
984 parsed,
985 "* <span class=\"stab portability\"><code>dep1</code></span> — dep1\n* <span class=\"stab portability\"><code>foo</code></span> — foo\n* <span class=\"stab portability\"><code>bar</code></span> *(enabled by default)* — bar\n"
986 );
987 }
988
989 #[test]
990 fn dots_in_feature() {
991 let toml = r#"
992[features]
993## This is a test
994"teßt." = []
995default = ["teßt."]
996[dependencies]
997## A dep
998"dep" = { version = "123", optional = true }
999 "#;
1000 let parsed = process_toml(toml, &Args::default()).unwrap();
1001 assert_eq!(
1002 parsed,
1003 "* **`teßt.`** *(enabled by default)* — This is a test\n* **`dep`** — A dep\n"
1004 );
1005 let parsed = process_toml(
1006 toml,
1007 &Args {
1008 feature_label: Some(
1009 "<span class=\"stab portability\"><code>{feature}</code></span>".into(),
1010 ),
1011 },
1012 )
1013 .unwrap();
1014 assert_eq!(
1015 parsed,
1016 "* <span class=\"stab portability\"><code>teßt.</code></span> *(enabled by default)* — This is a test\n* <span class=\"stab portability\"><code>dep</code></span> — A dep\n"
1017 );
1018 }
1019
1020 #[test]
1021 fn recursive_default() {
1022 let toml = r#"
1023[features]
1024default=["qqq"]
1025
1026## Qqq
1027qqq=["www"]
1028
1029## Www
1030www=[]
1031 "#;
1032 let parsed = process_toml(toml, &Args::default()).unwrap();
1033 assert_eq!(parsed, "* **`qqq`** *(enabled by default)* — Qqq\n* **`www`** *(enabled by default)* — Www\n");
1034 }
1035}