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