Skip to main content

tidy/
features.rs

1//! Tidy check to ensure that unstable features are all in order.
2//!
3//! This check will ensure properties like:
4//!
5//! * All stability attributes look reasonably well formed.
6//! * The set of library features is disjoint from the set of language features.
7//! * Library features have at most one stability level.
8//! * Library features have at most one `since` value.
9//! * All unstable lang features have tests to ensure they are actually unstable.
10//! * Language features in a group are sorted by feature name.
11
12use std::collections::BTreeSet;
13use std::collections::hash_map::{Entry, HashMap};
14use std::ffi::OsStr;
15use std::num::NonZeroU32;
16use std::path::{Path, PathBuf};
17use std::{fmt, fs};
18
19use regex::Regex;
20
21use crate::diagnostics::{RunningCheck, TidyCtx};
22use crate::walk::{filter_dirs, filter_not_rust, walk, walk_many};
23
24#[cfg(test)]
25mod tests;
26
27mod version;
28// Re-export Version. This means other crates can construct Versions from [u32;3] and from &str.
29// This is useful for filtering for features older/newer than a user-provided value.
30pub use version::Version;
31
32const FEATURE_GROUP_START_PREFIX: &str = "// feature-group-start";
33const FEATURE_GROUP_END_PREFIX: &str = "// feature-group-end";
34
35#[derive(Debug, PartialEq, Clone)]
36#[cfg_attr(feature = "build-metrics", derive(serde::Serialize))]
37pub enum Status {
38    Accepted,
39    Removed,
40    Unstable,
41}
42
43impl fmt::Display for Status {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        let as_str = match *self {
46            Status::Accepted => "accepted",
47            Status::Unstable => "unstable",
48            Status::Removed => "removed",
49        };
50        fmt::Display::fmt(as_str, f)
51    }
52}
53
54#[derive(Debug, Clone)]
55#[cfg_attr(feature = "build-metrics", derive(serde::Serialize))]
56pub struct Feature {
57    pub level: Status,
58    pub since: Option<Version>,
59    pub has_gate_test: bool,
60    pub tracking_issue: Option<NonZeroU32>,
61    pub file: PathBuf,
62    pub line: usize,
63    pub description: Option<String>,
64}
65impl Feature {
66    fn tracking_issue_display(&self) -> impl fmt::Display {
67        match self.tracking_issue {
68            None => "none".to_string(),
69            Some(x) => x.to_string(),
70        }
71    }
72}
73
74pub type Features = HashMap<String, Feature>;
75
76pub struct CollectedFeatures {
77    pub lib: Features,
78    pub lang: Features,
79}
80
81// Currently only used for unstable book generation
82pub fn collect_lib_features(base_src_path: &Path) -> Features {
83    let mut lib_features = Features::new();
84
85    map_lib_features(base_src_path, &mut |res, _, _| {
86        if let Ok((name, feature)) = res {
87            lib_features.insert(name.to_owned(), feature);
88        }
89    });
90    lib_features
91}
92
93pub fn check(
94    src_path: &Path,
95    tests_path: &Path,
96    compiler_path: &Path,
97    lib_path: &Path,
98    tidy_ctx: TidyCtx,
99) -> CollectedFeatures {
100    let mut check = tidy_ctx.start_check("features");
101
102    let mut features = collect_lang_features(compiler_path, &mut check);
103    assert!(!features.is_empty());
104
105    let lib_features = get_and_check_lib_features(lib_path, &mut check, &features);
106    assert!(!lib_features.is_empty());
107
108    walk_many(
109        &[
110            &tests_path.join("ui"),
111            &tests_path.join("ui-fulldeps"),
112            &tests_path.join("rustdoc-ui"),
113            &tests_path.join("rustdoc-html"),
114        ],
115        |path, _is_dir| {
116            filter_dirs(path)
117                || filter_not_rust(path)
118                || path.file_name() == Some(OsStr::new("features.rs"))
119                || path.file_name() == Some(OsStr::new("diagnostic_list.rs"))
120        },
121        &mut |entry, contents| {
122            let file = entry.path();
123            let filename = file.file_name().unwrap().to_string_lossy();
124            let filen_underscore = filename.replace('-', "_").replace(".rs", "");
125            let filename_gate = test_filen_gate(&filen_underscore, &mut features);
126
127            for (i, line) in contents.lines().enumerate() {
128                let mut err = |msg: &str| {
129                    check.error(format!("{}:{}: {}", file.display(), i + 1, msg));
130                };
131
132                let gate_test_str = "gate-test-";
133
134                let feature_name = match line.find(gate_test_str) {
135                    // `split` always contains at least 1 element, even if the delimiter is not present.
136                    Some(i) => line[i + gate_test_str.len()..].split(' ').next().unwrap(),
137                    None => continue,
138                };
139                match features.get_mut(feature_name) {
140                    Some(f) => {
141                        if filename_gate == Some(feature_name) {
142                            err(&format!(
143                                "The file is already marked as gate test \
144                                      through its name, no need for a \
145                                      'gate-test-{feature_name}' comment"
146                            ));
147                        }
148                        f.has_gate_test = true;
149                    }
150                    None => {
151                        err(&format!(
152                            "gate-test test found referencing a nonexistent feature '{feature_name}'"
153                        ));
154                    }
155                }
156            }
157        },
158    );
159
160    // Only check the number of lang features.
161    // Obligatory testing for library features is dumb.
162    let gate_untested = features
163        .iter()
164        .filter(|&(_, f)| f.level == Status::Unstable)
165        .filter(|&(_, f)| !f.has_gate_test)
166        .collect::<Vec<_>>();
167
168    for &(name, _) in gate_untested.iter() {
169        println!("Expected a gate test for the feature '{name}'.");
170        println!(
171            "Hint: create a failing test file named 'tests/ui/feature-gates/feature-gate-{}.rs',\
172                \n      with its failures due to missing usage of `#![feature({})]`.",
173            name.replace("_", "-"),
174            name
175        );
176        println!(
177            "Hint: If you already have such a test and don't want to rename it,\
178                \n      you can also add a // gate-test-{name} line to the test file."
179        );
180    }
181
182    if !gate_untested.is_empty() {
183        check.error(format!("Found {} features without a gate test.", gate_untested.len()));
184    }
185
186    let (version, channel) = get_version_and_channel(src_path);
187
188    let all_features_iter = features
189        .iter()
190        .map(|feat| (feat, "lang"))
191        .chain(lib_features.iter().map(|feat| (feat, "lib")));
192    for ((feature_name, feature), kind) in all_features_iter {
193        let since = if let Some(since) = feature.since { since } else { continue };
194        let file = feature.file.display();
195        let line = feature.line;
196        if since > version && since != Version::CurrentPlaceholder {
197            check.error(format!(
198                "{file}:{line}: The stabilization version {since} of {kind} feature `{feature_name}` is newer than the current {version}"
199            ));
200        }
201        if channel == "nightly" && since == version {
202            check.error(format!(
203                "{file}:{line}: The stabilization version {since} of {kind} feature `{feature_name}` is written out but should be {}",
204                version::VERSION_PLACEHOLDER
205            ));
206        }
207        if channel != "nightly" && since == Version::CurrentPlaceholder {
208            check.error(format!(
209                "{file}:{line}: The placeholder use of {kind} feature `{feature_name}` is not allowed on the {channel} channel",
210            ));
211        }
212    }
213
214    if !check.is_bad() && check.is_verbose_enabled() {
215        let mut lines = Vec::new();
216        lines.extend(format_features(&features, "lang"));
217        lines.extend(format_features(&lib_features, "lib"));
218        lines.sort();
219
220        check.verbose_msg(
221            lines.into_iter().map(|l| format!("* {l}")).collect::<Vec<String>>().join("\n"),
222        );
223    }
224
225    CollectedFeatures { lib: lib_features, lang: features }
226}
227
228fn get_version_and_channel(src_path: &Path) -> (Version, String) {
229    let version_str = t!(std::fs::read_to_string(src_path.join("version")));
230    let version_str = version_str.trim();
231    let version = t!(std::str::FromStr::from_str(version_str).map_err(|e| format!("{e:?}")));
232    let channel_str = t!(std::fs::read_to_string(src_path.join("ci").join("channel")));
233    (version, channel_str.trim().to_owned())
234}
235
236fn format_features<'a>(
237    features: &'a Features,
238    family: &'a str,
239) -> impl Iterator<Item = String> + 'a {
240    features.iter().map(move |(name, feature)| {
241        format!(
242            "{:<32} {:<8} {:<12} {:<8}",
243            name,
244            family,
245            feature.level,
246            feature.since.map_or("None".to_owned(), |since| since.to_string())
247        )
248    })
249}
250
251fn find_attr_val<'a>(line: &'a str, attr: &str) -> Option<&'a str> {
252    let r = match attr {
253        "issue" => static_regex!(r#"issue\s*=\s*"([^"]*)""#),
254        "feature" => static_regex!(r#"feature\s*=\s*"([^"]*)""#),
255        "since" => static_regex!(r#"since\s*=\s*"([^"]*)""#),
256        _ => unimplemented!("{attr} not handled"),
257    };
258
259    r.captures(line).and_then(|c| c.get(1)).map(|m| m.as_str())
260}
261
262fn test_filen_gate<'f>(filen_underscore: &'f str, features: &mut Features) -> Option<&'f str> {
263    let prefix = "feature_gate_";
264    if let Some(suffix) = filen_underscore.strip_prefix(prefix) {
265        for (n, f) in features.iter_mut() {
266            // Equivalent to filen_underscore == format!("feature_gate_{n}")
267            if suffix == n {
268                f.has_gate_test = true;
269                return Some(suffix);
270            }
271        }
272    }
273    None
274}
275
276pub fn collect_lang_features(base_compiler_path: &Path, check: &mut RunningCheck) -> Features {
277    let mut features = Features::new();
278    collect_lang_features_in(&mut features, base_compiler_path, "accepted.rs", check);
279    collect_lang_features_in(&mut features, base_compiler_path, "removed.rs", check);
280    collect_lang_features_in(&mut features, base_compiler_path, "unstable.rs", check);
281    features
282}
283
284fn collect_lang_features_in(
285    features: &mut Features,
286    base: &Path,
287    file: &str,
288    check: &mut RunningCheck,
289) {
290    let path = base.join("rustc_feature").join("src").join(file);
291    let contents = t!(fs::read_to_string(&path));
292
293    // We allow rustc-internal features to omit a tracking issue.
294    // To make tidy accept omitting a tracking issue, group the list of features
295    // without one inside `// no-tracking-issue` and `// no-tracking-issue-end`.
296    let mut next_feature_omits_tracking_issue = false;
297
298    let mut in_feature_group = false;
299    let mut prev_names = vec![];
300
301    let lines = contents.lines().zip(1..);
302    let mut doc_comments: Vec<String> = Vec::new();
303    for (line, line_number) in lines {
304        let line = line.trim();
305
306        // Within -start and -end, the tracking issue can be omitted.
307        match line {
308            "// no-tracking-issue-start" => {
309                next_feature_omits_tracking_issue = true;
310                continue;
311            }
312            "// no-tracking-issue-end" => {
313                next_feature_omits_tracking_issue = false;
314                continue;
315            }
316            _ => {}
317        }
318
319        if line.starts_with(FEATURE_GROUP_START_PREFIX) {
320            if in_feature_group {
321                check.error(format!(
322                    "{}:{line_number}: \
323                        new feature group is started without ending the previous one",
324                    path.display()
325                ));
326            }
327
328            in_feature_group = true;
329            prev_names = vec![];
330            continue;
331        } else if line.starts_with(FEATURE_GROUP_END_PREFIX) {
332            in_feature_group = false;
333            prev_names = vec![];
334            continue;
335        }
336
337        if in_feature_group && let Some(doc_comment) = line.strip_prefix("///") {
338            doc_comments.push(doc_comment.trim().to_string());
339            continue;
340        }
341
342        let mut parts = line.split(',');
343        let level = match parts.next().map(|l| l.trim().trim_start_matches('(')) {
344            Some("unstable") => Status::Unstable,
345            Some("incomplete") => Status::Unstable,
346            Some("internal") => Status::Unstable,
347            Some("removed") => Status::Removed,
348            Some("accepted") => Status::Accepted,
349            _ => continue,
350        };
351        let name = parts.next().unwrap().trim();
352
353        let since_str = parts.next().unwrap().trim().trim_matches('"');
354        let since = match since_str.parse() {
355            Ok(since) => Some(since),
356            Err(err) => {
357                check.error(format!(
358                    "{}:{line_number}: failed to parse since: {since_str} ({err:?})",
359                    path.display()
360                ));
361                None
362            }
363        };
364        if in_feature_group {
365            if prev_names.last() > Some(&name) {
366                // This assumes the user adds the feature name at the end of the list, as we're
367                // not looking ahead.
368                let correct_index = match prev_names.binary_search(&name) {
369                    Ok(_) => {
370                        // This only occurs when the feature name has already been declared.
371                        check.error(format!(
372                            "{}:{line_number}: duplicate feature {name}",
373                            path.display()
374                        ));
375                        // skip any additional checks for this line
376                        continue;
377                    }
378                    Err(index) => index,
379                };
380
381                let correct_placement = if correct_index == 0 {
382                    "at the beginning of the feature group".to_owned()
383                } else if correct_index == prev_names.len() {
384                    // I don't believe this is reachable given the above assumption, but it
385                    // doesn't hurt to be safe.
386                    "at the end of the feature group".to_owned()
387                } else {
388                    format!(
389                        "between {} and {}",
390                        prev_names[correct_index - 1],
391                        prev_names[correct_index],
392                    )
393                };
394
395                check.error(format!(
396                    "{}:{line_number}: feature {name} is not sorted by feature name (should be {correct_placement})",
397                    path.display(),
398                ));
399            }
400            prev_names.push(name);
401        }
402
403        let issue_str = parts.next().unwrap().trim();
404        let tracking_issue = if issue_str.starts_with("None") {
405            if level == Status::Unstable && !next_feature_omits_tracking_issue {
406                check.error(format!(
407                    "{}:{line_number}: no tracking issue for feature {name}",
408                    path.display(),
409                ));
410            }
411            None
412        } else {
413            let s = issue_str.split('(').nth(1).unwrap().split(')').next().unwrap();
414            Some(s.parse().unwrap())
415        };
416        match features.entry(name.to_owned()) {
417            Entry::Occupied(e) => {
418                check.error(format!(
419                    "{}:{line_number} feature {name} already specified with status '{}'",
420                    path.display(),
421                    e.get().level,
422                ));
423            }
424            Entry::Vacant(e) => {
425                e.insert(Feature {
426                    level,
427                    since,
428                    has_gate_test: false,
429                    tracking_issue,
430                    file: path.to_path_buf(),
431                    line: line_number,
432                    description: if doc_comments.is_empty() {
433                        None
434                    } else {
435                        Some(doc_comments.join(" "))
436                    },
437                });
438            }
439        }
440        doc_comments.clear();
441    }
442}
443
444fn get_and_check_lib_features(
445    base_src_path: &Path,
446    check: &mut RunningCheck,
447    lang_features: &Features,
448) -> Features {
449    let mut lib_features = Features::new();
450    map_lib_features(base_src_path, &mut |res, file, line| match res {
451        Ok((name, f)) => {
452            let mut check_features = |f: &Feature, list: &Features, display: &str| {
453                if let Some(s) = list.get(name)
454                    && f.tracking_issue != s.tracking_issue
455                    && f.level != Status::Accepted
456                {
457                    check.error(format!(
458                        "{}:{line}: feature gate {name} has inconsistent `issue`: \"{}\" mismatches the {display} `issue` of \"{}\"",
459                        file.display(),
460                        f.tracking_issue_display(),
461                        s.tracking_issue_display(),
462                    ));
463                }
464            };
465            check_features(&f, lang_features, "corresponding lang feature");
466            check_features(&f, &lib_features, "previous");
467            lib_features.insert(name.to_owned(), f);
468        }
469        Err(msg) => {
470            check.error(format!("{}:{line}: {msg}", file.display()));
471        }
472    });
473    lib_features
474}
475
476/// `mf` gets the feature or an error if it is invalid, the file path passed as `file`, and the attribute's line number.
477fn extract_lib_features<'c, 'p>(
478    contents: &'c str,
479    file: &'p Path,
480    mf: &mut (dyn Send + Sync + FnMut(Result<(&'c str, Feature), &'static str>, &'p Path, usize)),
481) {
482    let handle_issue_none = |s| match s {
483        "none" => None,
484        issue => {
485            let n = issue.parse().expect("issue number is not a valid integer");
486            assert_ne!(n, 0, "\"none\" should be used when there is no issue, not \"0\"");
487            NonZeroU32::new(n)
488        }
489    };
490    for attr in static_regex!(
491        r"#!?\[\s*(?<attr_name>rustc_const_unstable|unstable|stable)\s*(?<attr_meta>(\((?s).*?)\)|)\]"
492    )
493    .captures_iter(contents)
494    {
495        let match_index = attr.get_match().start();
496        let before_match = &contents[..match_index];
497        let before_match_line = match before_match.rsplit_once('\n') {
498            Some((_, it)) => it,
499            None => before_match,
500        };
501        if static_regex!(r"^\s*//").is_match(before_match_line) {
502            // It starts inside a comment, exclude (this does not handle block comments).
503            // Technically this will mis-handle things like:
504            // ```
505            // // #[stable
506            // #[stable(...)]
507            // ```
508            // Hopefully that's not a problem.
509            continue;
510        }
511
512        let line = before_match.bytes().filter(|b| *b == b'\n').count() + 1;
513
514        macro_rules! err {
515            ($msg:expr) => {{
516                mf(Err($msg), file, line);
517                continue;
518            }};
519        }
520
521        let attr_meta = attr.name("attr_meta").unwrap().as_str();
522        let level = match &attr["attr_name"] {
523            "rustc_const_unstable" => {
524                // `const fn` features are handled specially.
525                let feature_name = match find_attr_val(attr_meta, "feature") {
526                    Some(name) => name,
527                    None => err!("malformed stability attribute: missing `feature` key"),
528                };
529                let feature = Feature {
530                    level: Status::Unstable,
531                    since: None,
532                    has_gate_test: false,
533                    tracking_issue: find_attr_val(attr_meta, "issue").and_then(handle_issue_none),
534                    file: file.to_path_buf(),
535                    line,
536                    description: None,
537                };
538                mf(Ok((feature_name, feature)), file, line);
539                continue;
540            }
541            "unstable" => Status::Unstable,
542            "stable" => Status::Accepted,
543            _ => unreachable!("unexpected attribute name"),
544        };
545        let feature_name = match find_attr_val(attr_meta, "feature") {
546            Some(name) => name,
547            None => err!("malformed stability attribute: missing `feature` key"),
548        };
549        let since = match find_attr_val(attr_meta, "since").map(|x| x.parse()) {
550            Some(Ok(since)) => Some(since),
551            Some(Err(_err)) => {
552                err!("malformed stability attribute: can't parse `since` key");
553            }
554            None if level == Status::Accepted => {
555                err!("malformed stability attribute: missing the `since` key");
556            }
557            None => None,
558        };
559        let tracking_issue = find_attr_val(attr_meta, "issue").and_then(handle_issue_none);
560
561        let feature = Feature {
562            level,
563            since,
564            has_gate_test: false,
565            tracking_issue,
566            file: file.to_path_buf(),
567            line,
568            description: None,
569        };
570        mf(Ok((feature_name, feature)), file, line);
571    }
572}
573
574fn map_lib_features(
575    base_src_path: &Path,
576    mf: &mut (dyn Send + Sync + FnMut(Result<(&str, Feature), &str>, &Path, usize)),
577) {
578    walk(
579        base_src_path,
580        |path, _is_dir| filter_dirs(path) || path.ends_with("tests"),
581        &mut |entry, contents| {
582            let file = entry.path();
583            let filename = file.file_name().unwrap().to_string_lossy();
584            if !filename.ends_with(".rs")
585                || filename == "features.rs"
586                || filename == "diagnostic_list.rs"
587                || filename == "error_codes.rs"
588            {
589                return;
590            }
591
592            extract_lib_features(contents, file, mf);
593        },
594    );
595}
596
597fn should_document(var: &str) -> bool {
598    if var.starts_with("RUSTC_") || var.starts_with("RUST_") || var.starts_with("UNSTABLE_RUSTDOC_")
599    {
600        return true;
601    }
602    ["SDKROOT", "QNX_TARGET", "COLORTERM", "TERM"].contains(&var)
603}
604
605pub fn collect_env_vars(compiler: &Path) -> BTreeSet<String> {
606    let env_var_regex: Regex = Regex::new(r#"env::var(_os)?\("([^"]+)"#).unwrap();
607
608    let mut vars = BTreeSet::new();
609    walk(
610        compiler,
611        // skip build scripts, tests, and non-rust files
612        |path, _is_dir| {
613            filter_dirs(path)
614                || filter_not_rust(path)
615                || path.ends_with("build.rs")
616                || path.ends_with("tests.rs")
617        },
618        &mut |_entry, contents| {
619            for env_var in env_var_regex.captures_iter(contents).map(|c| c.get(2).unwrap().as_str())
620            {
621                if should_document(env_var) {
622                    vars.insert(env_var.to_owned());
623                }
624            }
625        },
626    );
627    vars
628}