document_features/
lib.rs

1// Copyright © SixtyFPS GmbH <info@sixtyfps.io>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4/*!
5Document your crate's feature flags.
6
7This crates provides a macro that extracts "documentation" comments from Cargo.toml
8
9To use this crate, add `#![doc = document_features::document_features!()]` in your crate documentation.
10The `document_features!()` macro reads your `Cargo.toml` file, extracts feature comments and generates
11a markdown string for your documentation.
12
13Basic example:
14
15```rust
16//! Normal crate documentation goes here.
17//!
18//! ## Feature flags
19#![doc = document_features::document_features!()]
20
21// rest of the crate goes here.
22```
23
24## Documentation format:
25
26The documentation of your crate features goes into `Cargo.toml`, where they are defined.
27
28The `document_features!()` macro analyzes the contents of `Cargo.toml`.
29Similar to Rust's documentation comments `///` and `//!`, the macro understands
30comments that start with `## ` and `#! `. Note the required trailing space.
31Lines starting with `###` will not be understood as doc comment.
32
33`## ` comments are meant to be *above* the feature they document.
34There can be several `## ` comments, but they must always be followed by a
35feature name or an optional dependency.
36There should not be `#! ` comments between the comment and the feature they document.
37
38`#! ` comments are not associated with a particular feature, and will be printed
39in where they occur. Use them to group features, for example.
40
41## Examples:
42
43*/
44// Note: because rustdoc escapes the first `#` of a line starting with `#`,
45// these docs comments have one more `#` ,
46#![doc = self_test!(/**
47[package]
48name = "..."
49## ...
50
51[features]
52default = ["foo"]
53##! This comments goes on top
54
55### The foo feature enables the `foo` functions
56foo = []
57
58### The bar feature enables the bar module
59bar = []
60
61##! ### Experimental features
62##! The following features are experimental
63
64### Enable the fusion reactor
65###
66### ⚠️ Can lead to explosions
67fusion = []
68
69[dependencies]
70document-features = "0.2"
71
72##! ### Optional dependencies
73
74### Enable this feature to implement the trait for the types from the genial crate
75genial = { version = "0.2", optional = true }
76
77### This awesome dependency is specified in its own table
78[dependencies.awesome]
79version = "1.3.5"
80optional = true
81*/
82=>
83    /**
84This comments goes on top
85* **`foo`** *(enabled by default)* —  The foo feature enables the `foo` functions
86
87* **`bar`** —  The bar feature enables the bar module
88
89#### Experimental features
90The following features are experimental
91* **`fusion`** —  Enable the fusion reactor
92
93  ⚠️ Can lead to explosions
94
95#### Optional dependencies
96* **`genial`** —  Enable this feature to implement the trait for the types from the genial crate
97
98* **`awesome`** —  This awesome dependency is specified in its own table
99
100*/
101)]
102/*!
103
104## Customization
105
106You can customize the formatting of the features in the generated documentation by setting
107the key **`feature_label=`** to a given format string. This format string must be either
108a [string literal](https://doc.rust-lang.org/reference/tokens.html#string-literals) or
109a [raw string literal](https://doc.rust-lang.org/reference/tokens.html#raw-string-literals).
110Every occurrence of `{feature}` inside the format string will be substituted with the name of the feature.
111
112For instance, to emulate the HTML formatting used by `rustdoc` one can use the following:
113
114```rust
115#![doc = document_features::document_features!(feature_label = r#"<span class="stab portability"><code>{feature}</code></span>"#)]
116```
117
118The default formatting is equivalent to:
119
120```rust
121#![doc = document_features::document_features!(feature_label = "**`{feature}`**")]
122```
123
124## Compatibility
125
126The minimum Rust version required to use this crate is Rust 1.54 because of the
127feature to have macro in doc comments. You can make this crate optional and use
128`#[cfg_attr()]` statements to enable it only when building the documentation:
129You need to have two levels of `cfg_attr` because Rust < 1.54 doesn't parse the attribute
130otherwise.
131
132```rust,ignore
133#![cfg_attr(
134    feature = "document-features",
135    cfg_attr(doc, doc = ::document_features::document_features!())
136)]
137```
138
139In your Cargo.toml, enable this feature while generating the documentation on docs.rs:
140
141```toml
142[dependencies]
143document-features = { version = "0.2", optional = true }
144
145[package.metadata.docs.rs]
146features = ["document-features"]
147## Alternative: enable all features so they are all documented
148## all-features = true
149```
150 */
151
152#[cfg(not(feature = "default"))]
153compile_error!(
154    "The feature `default` must be enabled to ensure \
155    forward compatibility with future version of this crate"
156);
157
158extern crate proc_macro;
159
160use proc_macro::{TokenStream, TokenTree};
161use std::borrow::Cow;
162use std::collections::HashSet;
163use std::convert::TryFrom;
164use std::fmt::Write;
165use std::path::Path;
166use std::str::FromStr;
167
168fn error(e: &str) -> TokenStream {
169    TokenStream::from_str(&format!("::core::compile_error!{{\"{}\"}}", e.escape_default())).unwrap()
170}
171
172fn compile_error(msg: &str, tt: Option<TokenTree>) -> TokenStream {
173    let span = tt.as_ref().map_or_else(proc_macro::Span::call_site, TokenTree::span);
174    use proc_macro::{Delimiter, Group, Ident, Literal, Punct, Spacing};
175    use std::iter::FromIterator;
176    TokenStream::from_iter(vec![
177        TokenTree::Ident(Ident::new("compile_error", span)),
178        TokenTree::Punct({
179            let mut punct = Punct::new('!', Spacing::Alone);
180            punct.set_span(span);
181            punct
182        }),
183        TokenTree::Group({
184            let mut group = Group::new(Delimiter::Brace, {
185                TokenStream::from_iter([TokenTree::Literal({
186                    let mut string = Literal::string(msg);
187                    string.set_span(span);
188                    string
189                })])
190            });
191            group.set_span(span);
192            group
193        }),
194    ])
195}
196
197#[derive(Default)]
198struct Args {
199    feature_label: Option<String>,
200}
201
202fn parse_args(input: TokenStream) -> Result<Args, TokenStream> {
203    let mut token_trees = input.into_iter().fuse();
204
205    // parse the key, ensuring that it is the identifier `feature_label`
206    match token_trees.next() {
207        None => return Ok(Args::default()),
208        Some(TokenTree::Ident(ident)) if ident.to_string() == "feature_label" => (),
209        tt => return Err(compile_error("expected `feature_label`", tt)),
210    }
211
212    // parse a single equal sign `=`
213    match token_trees.next() {
214        Some(TokenTree::Punct(p)) if p.as_char() == '=' => (),
215        tt => return Err(compile_error("expected `=`", tt)),
216    }
217
218    // parse the value, ensuring that it is a string literal containing the substring `"{feature}"`
219    let feature_label;
220    if let Some(tt) = token_trees.next() {
221        match litrs::StringLit::<String>::try_from(&tt) {
222            Ok(string_lit) if string_lit.value().contains("{feature}") => {
223                feature_label = string_lit.value().to_string()
224            }
225            _ => {
226                return Err(compile_error(
227                    "expected a string literal containing the substring \"{feature}\"",
228                    Some(tt),
229                ))
230            }
231        }
232    } else {
233        return Err(compile_error(
234            "expected a string literal containing the substring \"{feature}\"",
235            None,
236        ));
237    }
238
239    // ensure there is nothing left after the format string
240    if let tt @ Some(_) = token_trees.next() {
241        return Err(compile_error("unexpected token after the format string", tt));
242    }
243
244    Ok(Args { feature_label: Some(feature_label) })
245}
246
247/// Produce a literal string containing documentation extracted from Cargo.toml
248///
249/// See the [crate] documentation for details
250#[proc_macro]
251pub fn document_features(tokens: TokenStream) -> TokenStream {
252    parse_args(tokens)
253        .and_then(|args| document_features_impl(&args))
254        .unwrap_or_else(std::convert::identity)
255}
256
257fn document_features_impl(args: &Args) -> Result<TokenStream, TokenStream> {
258    let path = std::env::var("CARGO_MANIFEST_DIR").unwrap();
259    let mut cargo_toml = std::fs::read_to_string(Path::new(&path).join("Cargo.toml"))
260        .map_err(|e| error(&format!("Can't open Cargo.toml: {:?}", e)))?;
261
262    if !cargo_toml.contains("\n##") && !cargo_toml.contains("\n#!") {
263        // On crates.io, Cargo.toml is usually "normalized" and stripped of all comments.
264        // The original Cargo.toml has been renamed Cargo.toml.orig
265        if let Ok(orig) = std::fs::read_to_string(Path::new(&path).join("Cargo.toml.orig")) {
266            if orig.contains("##") || orig.contains("#!") {
267                cargo_toml = orig;
268            }
269        }
270    }
271
272    let result = process_toml(&cargo_toml, args).map_err(|e| error(&e))?;
273    Ok(std::iter::once(proc_macro::TokenTree::from(proc_macro::Literal::string(&result))).collect())
274}
275
276fn process_toml(cargo_toml: &str, args: &Args) -> Result<String, String> {
277    // Get all lines between the "[features]" and the next block
278    let mut lines = cargo_toml
279        .lines()
280        .map(str::trim)
281        // and skip empty lines and comments that are not docs comments
282        .filter(|l| {
283            !l.is_empty() && (!l.starts_with("#") || l.starts_with("##") || l.starts_with("#!"))
284        });
285    let mut top_comment = String::new();
286    let mut current_comment = String::new();
287    let mut features = vec![];
288    let mut default_features = HashSet::new();
289    let mut current_table = "";
290    while let Some(line) = lines.next() {
291        if let Some(x) = line.strip_prefix("#!") {
292            if !x.is_empty() && !x.starts_with(" ") {
293                continue; // it's not a doc comment
294            }
295            if !current_comment.is_empty() {
296                return Err("Cannot mix ## and #! comments between features.".into());
297            }
298            writeln!(top_comment, "{}", x).unwrap();
299        } else if let Some(x) = line.strip_prefix("##") {
300            if !x.is_empty() && !x.starts_with(" ") {
301                continue; // it's not a doc comment
302            }
303            writeln!(current_comment, " {}", x).unwrap();
304        } else if let Some(table) = line.strip_prefix("[") {
305            current_table = table
306                .split_once("]")
307                .map(|(t, _)| t.trim())
308                .ok_or_else(|| format!("Parse error while parsing line: {}", line))?;
309            if !current_comment.is_empty() {
310                let dep = current_table
311                    .rsplit_once(".")
312                    .and_then(|(table, dep)| table.trim().ends_with("dependencies").then(|| dep))
313                    .ok_or_else(|| format!("Not a feature: `{}`", line))?;
314                features.push((
315                    dep.trim(),
316                    std::mem::take(&mut top_comment),
317                    std::mem::take(&mut current_comment),
318                ));
319            }
320        } else if let Some((dep, rest)) = line.split_once("=") {
321            let dep = dep.trim().trim_matches('"');
322            let rest = get_balanced(rest, &mut lines)
323                .map_err(|e| format!("Parse error while parsing value {}: {}", dep, e))?;
324            if current_table == "features" && dep == "default" {
325                let defaults = rest
326                    .trim()
327                    .strip_prefix("[")
328                    .and_then(|r| r.strip_suffix("]"))
329                    .ok_or_else(|| format!("Parse error while parsing dependency {}", dep))?
330                    .split(",")
331                    .map(|d| d.trim().trim_matches(|c| c == '"' || c == '\'').trim().to_string())
332                    .filter(|d| !d.is_empty());
333                default_features.extend(defaults);
334            }
335            if !current_comment.is_empty() {
336                if current_table.ends_with("dependencies") {
337                    if !rest
338                        .split_once("optional")
339                        .and_then(|(_, r)| r.trim().strip_prefix("="))
340                        .map_or(false, |r| r.trim().starts_with("true"))
341                    {
342                        return Err(format!("Dependency {} is not an optional dependency", dep));
343                    }
344                } else if current_table != "features" {
345                    return Err(format!(
346                        "Comment cannot be associated with a feature:{}",
347                        current_comment
348                    ));
349                }
350                features.push((
351                    dep,
352                    std::mem::take(&mut top_comment),
353                    std::mem::take(&mut current_comment),
354                ));
355            }
356        }
357    }
358    if !current_comment.is_empty() {
359        return Err("Found comment not associated with a feature".into());
360    }
361    if features.is_empty() {
362        return Err("Could not find documented features in Cargo.toml".into());
363    }
364    let mut result = String::new();
365    for (f, top, comment) in features {
366        let default = if default_features.contains(f) { " *(enabled by default)*" } else { "" };
367        if !comment.trim().is_empty() {
368            if let Some(feature_label) = &args.feature_label {
369                writeln!(
370                    result,
371                    "{}* {}{} —{}",
372                    top,
373                    feature_label.replace("{feature}", f),
374                    default,
375                    comment
376                )
377                .unwrap();
378            } else {
379                writeln!(result, "{}* **`{}`**{} —{}", top, f, default, comment).unwrap();
380            }
381        } else {
382            if let Some(feature_label) = &args.feature_label {
383                writeln!(
384                    result,
385                    "{}* {}{}\n",
386                    top,
387                    feature_label.replace("{feature}", f),
388                    default,
389                )
390                .unwrap();
391            } else {
392                writeln!(result, "{}* **`{}`**{}\n", top, f, default).unwrap();
393            }
394        }
395    }
396    result += &top_comment;
397    Ok(result)
398}
399
400fn get_balanced<'a>(
401    first_line: &'a str,
402    lines: &mut impl Iterator<Item = &'a str>,
403) -> Result<Cow<'a, str>, String> {
404    let mut line = first_line;
405    let mut result = Cow::from("");
406
407    let mut in_quote = false;
408    let mut level = 0;
409    loop {
410        let mut last_slash = false;
411        for (idx, b) in line.as_bytes().into_iter().enumerate() {
412            if last_slash {
413                last_slash = false
414            } else if in_quote {
415                match b {
416                    b'\\' => last_slash = true,
417                    b'"' | b'\'' => in_quote = false,
418                    _ => (),
419                }
420            } else {
421                match b {
422                    b'\\' => last_slash = true,
423                    b'"' => in_quote = true,
424                    b'{' | b'[' => level += 1,
425                    b'}' | b']' if level == 0 => return Err("unbalanced source".into()),
426                    b'}' | b']' => level -= 1,
427                    b'#' => {
428                        line = &line[..idx];
429                        break;
430                    }
431                    _ => (),
432                }
433            }
434        }
435        if result.len() == 0 {
436            result = Cow::from(line);
437        } else {
438            *result.to_mut() += line;
439        }
440        if level == 0 {
441            return Ok(result);
442        }
443        line = if let Some(l) = lines.next() {
444            l
445        } else {
446            return Err("unbalanced source".into());
447        };
448    }
449}
450
451#[test]
452fn test_get_balanced() {
453    assert_eq!(
454        get_balanced(
455            "{",
456            &mut IntoIterator::into_iter(["a", "{ abc[], #ignore", " def }", "}", "xxx"])
457        ),
458        Ok("{a{ abc[],  def }}".into())
459    );
460    assert_eq!(
461        get_balanced("{ foo = \"{#\" } #ignore", &mut IntoIterator::into_iter(["xxx"])),
462        Ok("{ foo = \"{#\" } ".into())
463    );
464    assert_eq!(
465        get_balanced("]", &mut IntoIterator::into_iter(["["])),
466        Err("unbalanced source".into())
467    );
468}
469
470#[cfg(feature = "self-test")]
471#[proc_macro]
472#[doc(hidden)]
473/// Helper macro for the tests. Do not use
474pub fn self_test_helper(input: TokenStream) -> TokenStream {
475    process_toml((&input).to_string().trim_matches(|c| c == '"' || c == '#'), &Args::default())
476        .map_or_else(
477            |e| error(&e),
478            |r| {
479                std::iter::once(proc_macro::TokenTree::from(proc_macro::Literal::string(&r)))
480                    .collect()
481            },
482        )
483}
484
485#[cfg(feature = "self-test")]
486macro_rules! self_test {
487    (#[doc = $toml:literal] => #[doc = $md:literal]) => {
488        concat!(
489            "\n`````rust\n\
490            fn normalize_md(md : &str) -> String {
491               md.lines().skip_while(|l| l.is_empty()).map(|l| l.trim())
492                .collect::<Vec<_>>().join(\"\\n\")
493            }
494            assert_eq!(normalize_md(document_features::self_test_helper!(",
495            stringify!($toml),
496            ")), normalize_md(",
497            stringify!($md),
498            "));\n`````\n\n"
499        )
500    };
501}
502
503#[cfg(not(feature = "self-test"))]
504macro_rules! self_test {
505    (#[doc = $toml:literal] => #[doc = $md:literal]) => {
506        concat!(
507            "This contents in Cargo.toml:\n`````toml",
508            $toml,
509            "\n`````\n Generates the following:\n\
510            <table><tr><th>Preview</th></tr><tr><td>\n\n",
511            $md,
512            "\n</td></tr></table>\n\n&nbsp;\n",
513        )
514    };
515}
516
517// The following struct is inserted only during generation of the documentation in order to exploit doc-tests.
518// These doc-tests are used to check that invalid arguments to the `document_features!` macro cause a compile time error.
519// For a more principled way of testing compilation error, maybe investigate <https://docs.rs/trybuild>.
520//
521/// ```rust
522/// #![doc = document_features::document_features!()]
523/// #![doc = document_features::document_features!(feature_label = "**`{feature}`**")]
524/// #![doc = document_features::document_features!(feature_label = r"**`{feature}`**")]
525/// #![doc = document_features::document_features!(feature_label = r#"**`{feature}`**"#)]
526/// #![doc = document_features::document_features!(feature_label = "<span class=\"stab portability\"><code>{feature}</code></span>")]
527/// #![doc = document_features::document_features!(feature_label = r#"<span class="stab portability"><code>{feature}</code></span>"#)]
528/// ```
529/// ```compile_fail
530/// #![doc = document_features::document_features!(feature_label > "<span>{feature}</span>")]
531/// ```
532/// ```compile_fail
533/// #![doc = document_features::document_features!(label = "<span>{feature}</span>")]
534/// ```
535/// ```compile_fail
536/// #![doc = document_features::document_features!(feature_label = "{feat}")]
537/// ```
538/// ```compile_fail
539/// #![doc = document_features::document_features!(feature_label = 3.14)]
540/// ```
541/// ```compile_fail
542/// #![doc = document_features::document_features!(feature_label = )]
543/// ```
544/// ```compile_fail
545/// #![doc = document_features::document_features!(feature_label = "**`{feature}`**" extra)]
546/// ```
547#[cfg(doc)]
548struct FeatureLabelCompilationTest;
549
550#[cfg(test)]
551mod tests {
552    use super::{process_toml, Args};
553
554    #[track_caller]
555    fn test_error(toml: &str, expected: &str) {
556        let err = process_toml(toml, &Args::default()).unwrap_err();
557        assert!(err.contains(expected), "{:?} does not contain {:?}", err, expected)
558    }
559
560    #[test]
561    fn only_get_balanced_in_correct_table() {
562        process_toml(
563            r#"
564
565[package.metadata.release]
566pre-release-replacements = [
567  {test=\"\#\# \"},
568]
569[abcd]
570[features]#xyz
571#! abc
572#
573###
574#! def
575#!
576## 123
577## 456
578feat1 = ["plop"]
579#! ghi
580no_doc = []
581##
582feat2 = ["momo"]
583#! klm
584default = ["feat1", "something_else"]
585#! end
586            "#,
587            &Args::default(),
588        )
589        .unwrap();
590    }
591
592    #[test]
593    fn parse_error1() {
594        test_error(
595            r#"
596[features]
597[dependencies]
598foo = 4;
599"#,
600            "Could not find documented features",
601        );
602    }
603
604    #[test]
605    fn parse_error2() {
606        test_error(
607            r#"
608[packages]
609[dependencies]
610"#,
611            "Could not find documented features",
612        );
613    }
614
615    #[test]
616    fn parse_error3() {
617        test_error(
618            r#"
619[features]
620ff = []
621[abcd
622efgh
623[dependencies]
624"#,
625            "Parse error while parsing line: [abcd",
626        );
627    }
628
629    #[test]
630    fn parse_error4() {
631        test_error(
632            r#"
633[features]
634## dd
635## ff
636#! ee
637## ff
638"#,
639            "Cannot mix",
640        );
641    }
642
643    #[test]
644    fn parse_error5() {
645        test_error(
646            r#"
647[features]
648## dd
649"#,
650            "not associated with a feature",
651        );
652    }
653
654    #[test]
655    fn parse_error6() {
656        test_error(
657            r#"
658[features]
659# ff
660foo = []
661default = [
662#ffff
663# ff
664"#,
665            "Parse error while parsing value default",
666        );
667    }
668
669    #[test]
670    fn parse_error7() {
671        test_error(
672            r#"
673[features]
674# f
675foo = [ x = { ]
676bar = []
677"#,
678            "Parse error while parsing value foo",
679        );
680    }
681
682    #[test]
683    fn not_a_feature1() {
684        test_error(
685            r#"
686## hallo
687[features]
688"#,
689            "Not a feature: `[features]`",
690        );
691    }
692
693    #[test]
694    fn not_a_feature2() {
695        test_error(
696            r#"
697[package]
698## hallo
699foo = []
700"#,
701            "Comment cannot be associated with a feature:  hallo",
702        );
703    }
704
705    #[test]
706    fn non_optional_dep1() {
707        test_error(
708            r#"
709[dev-dependencies]
710## Not optional
711foo = { version = "1.2", optional = false }
712"#,
713            "Dependency foo is not an optional dependency",
714        );
715    }
716
717    #[test]
718    fn non_optional_dep2() {
719        test_error(
720            r#"
721[dev-dependencies]
722## Not optional
723foo = { version = "1.2" }
724"#,
725            "Dependency foo is not an optional dependency",
726        );
727    }
728
729    #[test]
730    fn basic() {
731        let toml = r#"
732[abcd]
733[features]#xyz
734#! abc
735#
736###
737#! def
738#!
739## 123
740## 456
741feat1 = ["plop"]
742#! ghi
743no_doc = []
744##
745feat2 = ["momo"]
746#! klm
747default = ["feat1", "something_else"]
748#! end
749        "#;
750        let parsed = process_toml(toml, &Args::default()).unwrap();
751        assert_eq!(
752            parsed,
753            " abc\n def\n\n* **`feat1`** *(enabled by default)* —  123\n  456\n\n ghi\n* **`feat2`**\n\n klm\n end\n"
754        );
755        let parsed = process_toml(
756            toml,
757            &Args {
758                feature_label: Some(
759                    "<span class=\"stab portability\"><code>{feature}</code></span>".into(),
760                ),
761            },
762        )
763        .unwrap();
764        assert_eq!(
765            parsed,
766            " 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"
767        );
768    }
769
770    #[test]
771    fn dependencies() {
772        let toml = r#"
773#! top
774[dev-dependencies] #yo
775## dep1
776dep1 = { version="1.2", optional=true}
777#! yo
778dep2 = "1.3"
779## dep3
780[target.'cfg(unix)'.build-dependencies.dep3]
781version = "42"
782optional = true
783        "#;
784        let parsed = process_toml(toml, &Args::default()).unwrap();
785        assert_eq!(parsed, " top\n* **`dep1`** —  dep1\n\n yo\n* **`dep3`** —  dep3\n\n");
786        let parsed = process_toml(
787            toml,
788            &Args {
789                feature_label: Some(
790                    "<span class=\"stab portability\"><code>{feature}</code></span>".into(),
791                ),
792            },
793        )
794        .unwrap();
795        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\n");
796    }
797
798    #[test]
799    fn multi_lines() {
800        let toml = r#"
801[package.metadata.foo]
802ixyz = [
803    ["array"],
804    [
805        "of",
806        "arrays"
807    ]
808]
809[dev-dependencies]
810## dep1
811dep1 = {
812    version="1.2-}",
813    optional=true
814}
815[features]
816default = [
817    "goo",
818    "\"]",
819    "bar",
820]
821## foo
822foo = [
823   "bar"
824]
825## bar
826bar = [
827
828]
829        "#;
830        let parsed = process_toml(toml, &Args::default()).unwrap();
831        assert_eq!(
832            parsed,
833            "* **`dep1`** —  dep1\n\n* **`foo`** —  foo\n\n* **`bar`** *(enabled by default)* —  bar\n\n"
834        );
835        let parsed = process_toml(
836            toml,
837            &Args {
838                feature_label: Some(
839                    "<span class=\"stab portability\"><code>{feature}</code></span>".into(),
840                ),
841            },
842        )
843        .unwrap();
844        assert_eq!(
845            parsed,
846            "* <span class=\"stab portability\"><code>dep1</code></span> —  dep1\n\n* <span class=\"stab portability\"><code>foo</code></span> —  foo\n\n* <span class=\"stab portability\"><code>bar</code></span> *(enabled by default)* —  bar\n\n"
847        );
848    }
849
850    #[test]
851    fn dots_in_feature() {
852        let toml = r#"
853[features]
854## This is a test
855"teßt." = []
856default = ["teßt."]
857[dependencies]
858## A dep
859"dep" = { version = "123", optional = true }
860        "#;
861        let parsed = process_toml(toml, &Args::default()).unwrap();
862        assert_eq!(
863            parsed,
864            "* **`teßt.`** *(enabled by default)* —  This is a test\n\n* **`dep`** —  A dep\n\n"
865        );
866        let parsed = process_toml(
867            toml,
868            &Args {
869                feature_label: Some(
870                    "<span class=\"stab portability\"><code>{feature}</code></span>".into(),
871                ),
872            },
873        )
874        .unwrap();
875        assert_eq!(
876            parsed,
877            "* <span class=\"stab portability\"><code>teßt.</code></span> *(enabled by default)* —  This is a test\n\n* <span class=\"stab portability\"><code>dep</code></span> —  A dep\n\n"
878        );
879    }
880}