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::hash_map::{Entry, HashMap};
13use std::ffi::OsStr;
14use std::num::NonZeroU32;
15use std::path::{Path, PathBuf};
16use std::{fmt, fs};
17
18use crate::walk::{filter_dirs, filter_not_rust, walk, walk_many};
19
20#[cfg(test)]
21mod tests;
22
23mod version;
24use version::Version;
25
26const FEATURE_GROUP_START_PREFIX: &str = "// feature-group-start";
27const FEATURE_GROUP_END_PREFIX: &str = "// feature-group-end";
28
29#[derive(Debug, PartialEq, Clone)]
30#[cfg_attr(feature = "build-metrics", derive(serde::Serialize))]
31pub enum Status {
32    Accepted,
33    Removed,
34    Unstable,
35}
36
37impl fmt::Display for Status {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        let as_str = match *self {
40            Status::Accepted => "accepted",
41            Status::Unstable => "unstable",
42            Status::Removed => "removed",
43        };
44        fmt::Display::fmt(as_str, f)
45    }
46}
47
48#[derive(Debug, Clone)]
49#[cfg_attr(feature = "build-metrics", derive(serde::Serialize))]
50pub struct Feature {
51    pub level: Status,
52    pub since: Option<Version>,
53    pub has_gate_test: bool,
54    pub tracking_issue: Option<NonZeroU32>,
55    pub file: PathBuf,
56    pub line: usize,
57    pub description: Option<String>,
58}
59impl Feature {
60    fn tracking_issue_display(&self) -> impl fmt::Display {
61        match self.tracking_issue {
62            None => "none".to_string(),
63            Some(x) => x.to_string(),
64        }
65    }
66}
67
68pub type Features = HashMap<String, Feature>;
69
70pub struct CollectedFeatures {
71    pub lib: Features,
72    pub lang: Features,
73}
74
75// Currently only used for unstable book generation
76pub fn collect_lib_features(base_src_path: &Path) -> Features {
77    let mut lib_features = Features::new();
78
79    map_lib_features(base_src_path, &mut |res, _, _| {
80        if let Ok((name, feature)) = res {
81            lib_features.insert(name.to_owned(), feature);
82        }
83    });
84    lib_features
85}
86
87pub fn check(
88    src_path: &Path,
89    tests_path: &Path,
90    compiler_path: &Path,
91    lib_path: &Path,
92    bad: &mut bool,
93    verbose: bool,
94) -> CollectedFeatures {
95    let mut features = collect_lang_features(compiler_path, bad);
96    assert!(!features.is_empty());
97
98    let lib_features = get_and_check_lib_features(lib_path, bad, &features);
99    assert!(!lib_features.is_empty());
100
101    walk_many(
102        &[
103            &tests_path.join("ui"),
104            &tests_path.join("ui-fulldeps"),
105            &tests_path.join("rustdoc-ui"),
106            &tests_path.join("rustdoc"),
107        ],
108        |path, _is_dir| {
109            filter_dirs(path)
110                || filter_not_rust(path)
111                || path.file_name() == Some(OsStr::new("features.rs"))
112                || path.file_name() == Some(OsStr::new("diagnostic_list.rs"))
113        },
114        &mut |entry, contents| {
115            let file = entry.path();
116            let filename = file.file_name().unwrap().to_string_lossy();
117            let filen_underscore = filename.replace('-', "_").replace(".rs", "");
118            let filename_gate = test_filen_gate(&filen_underscore, &mut features);
119
120            for (i, line) in contents.lines().enumerate() {
121                let mut err = |msg: &str| {
122                    tidy_error!(bad, "{}:{}: {}", file.display(), i + 1, msg);
123                };
124
125                let gate_test_str = "gate-test-";
126
127                let feature_name = match line.find(gate_test_str) {
128                    // `split` always contains at least 1 element, even if the delimiter is not present.
129                    Some(i) => line[i + gate_test_str.len()..].split(' ').next().unwrap(),
130                    None => continue,
131                };
132                match features.get_mut(feature_name) {
133                    Some(f) => {
134                        if filename_gate == Some(feature_name) {
135                            err(&format!(
136                                "The file is already marked as gate test \
137                                      through its name, no need for a \
138                                      'gate-test-{feature_name}' comment"
139                            ));
140                        }
141                        f.has_gate_test = true;
142                    }
143                    None => {
144                        err(&format!(
145                            "gate-test test found referencing a nonexistent feature '{feature_name}'"
146                        ));
147                    }
148                }
149            }
150        },
151    );
152
153    // Only check the number of lang features.
154    // Obligatory testing for library features is dumb.
155    let gate_untested = features
156        .iter()
157        .filter(|&(_, f)| f.level == Status::Unstable)
158        .filter(|&(_, f)| !f.has_gate_test)
159        .collect::<Vec<_>>();
160
161    for &(name, _) in gate_untested.iter() {
162        println!("Expected a gate test for the feature '{name}'.");
163        println!(
164            "Hint: create a failing test file named 'tests/ui/feature-gates/feature-gate-{}.rs',\
165                \n      with its failures due to missing usage of `#![feature({})]`.",
166            name.replace("_", "-"),
167            name
168        );
169        println!(
170            "Hint: If you already have such a test and don't want to rename it,\
171                \n      you can also add a // gate-test-{name} line to the test file."
172        );
173    }
174
175    if !gate_untested.is_empty() {
176        tidy_error!(bad, "Found {} features without a gate test.", gate_untested.len());
177    }
178
179    let (version, channel) = get_version_and_channel(src_path);
180
181    let all_features_iter = features
182        .iter()
183        .map(|feat| (feat, "lang"))
184        .chain(lib_features.iter().map(|feat| (feat, "lib")));
185    for ((feature_name, feature), kind) in all_features_iter {
186        let since = if let Some(since) = feature.since { since } else { continue };
187        let file = feature.file.display();
188        let line = feature.line;
189        if since > version && since != Version::CurrentPlaceholder {
190            tidy_error!(
191                bad,
192                "{file}:{line}: The stabilization version {since} of {kind} feature `{feature_name}` is newer than the current {version}"
193            );
194        }
195        if channel == "nightly" && since == version {
196            tidy_error!(
197                bad,
198                "{file}:{line}: The stabilization version {since} of {kind} feature `{feature_name}` is written out but should be {}",
199                version::VERSION_PLACEHOLDER
200            );
201        }
202        if channel != "nightly" && since == Version::CurrentPlaceholder {
203            tidy_error!(
204                bad,
205                "{file}:{line}: The placeholder use of {kind} feature `{feature_name}` is not allowed on the {channel} channel",
206            );
207        }
208    }
209
210    if *bad {
211        return CollectedFeatures { lib: lib_features, lang: features };
212    }
213
214    if verbose {
215        let mut lines = Vec::new();
216        lines.extend(format_features(&features, "lang"));
217        lines.extend(format_features(&lib_features, "lib"));
218
219        lines.sort();
220        for line in lines {
221            println!("* {line}");
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, bad: &mut bool) -> Features {
277    let mut features = Features::new();
278    collect_lang_features_in(&mut features, base_compiler_path, "accepted.rs", bad);
279    collect_lang_features_in(&mut features, base_compiler_path, "removed.rs", bad);
280    collect_lang_features_in(&mut features, base_compiler_path, "unstable.rs", bad);
281    features
282}
283
284fn collect_lang_features_in(features: &mut Features, base: &Path, file: &str, bad: &mut bool) {
285    let path = base.join("rustc_feature").join("src").join(file);
286    let contents = t!(fs::read_to_string(&path));
287
288    // We allow rustc-internal features to omit a tracking issue.
289    // To make tidy accept omitting a tracking issue, group the list of features
290    // without one inside `// no-tracking-issue` and `// no-tracking-issue-end`.
291    let mut next_feature_omits_tracking_issue = false;
292
293    let mut in_feature_group = false;
294    let mut prev_names = vec![];
295
296    let lines = contents.lines().zip(1..);
297    let mut doc_comments: Vec<String> = Vec::new();
298    for (line, line_number) in lines {
299        let line = line.trim();
300
301        // Within -start and -end, the tracking issue can be omitted.
302        match line {
303            "// no-tracking-issue-start" => {
304                next_feature_omits_tracking_issue = true;
305                continue;
306            }
307            "// no-tracking-issue-end" => {
308                next_feature_omits_tracking_issue = false;
309                continue;
310            }
311            _ => {}
312        }
313
314        if line.starts_with(FEATURE_GROUP_START_PREFIX) {
315            if in_feature_group {
316                tidy_error!(
317                    bad,
318                    "{}:{}: \
319                        new feature group is started without ending the previous one",
320                    path.display(),
321                    line_number,
322                );
323            }
324
325            in_feature_group = true;
326            prev_names = vec![];
327            continue;
328        } else if line.starts_with(FEATURE_GROUP_END_PREFIX) {
329            in_feature_group = false;
330            prev_names = vec![];
331            continue;
332        }
333
334        if in_feature_group {
335            if let Some(doc_comment) = line.strip_prefix("///") {
336                doc_comments.push(doc_comment.trim().to_string());
337                continue;
338            }
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                    if f.tracking_issue != s.tracking_issue && f.level != Status::Accepted {
470                        tidy_error!(
471                            bad,
472                            "{}:{}: feature gate {} has inconsistent `issue`: \"{}\" mismatches the {} `issue` of \"{}\"",
473                            file.display(),
474                            line,
475                            name,
476                            f.tracking_issue_display(),
477                            display,
478                            s.tracking_issue_display(),
479                        );
480                    }
481                }
482            };
483            check_features(&f, lang_features, "corresponding lang feature");
484            check_features(&f, &lib_features, "previous");
485            lib_features.insert(name.to_owned(), f);
486        }
487        Err(msg) => {
488            tidy_error!(bad, "{}:{}: {}", file.display(), line, msg);
489        }
490    });
491    lib_features
492}
493
494fn map_lib_features(
495    base_src_path: &Path,
496    mf: &mut (dyn Send + Sync + FnMut(Result<(&str, Feature), &str>, &Path, usize)),
497) {
498    walk(
499        base_src_path,
500        |path, _is_dir| filter_dirs(path) || path.ends_with("tests"),
501        &mut |entry, contents| {
502            let file = entry.path();
503            let filename = file.file_name().unwrap().to_string_lossy();
504            if !filename.ends_with(".rs")
505                || filename == "features.rs"
506                || filename == "diagnostic_list.rs"
507                || filename == "error_codes.rs"
508            {
509                return;
510            }
511
512            // This is an early exit -- all the attributes we're concerned with must contain this:
513            // * rustc_const_unstable(
514            // * unstable(
515            // * stable(
516            if !contents.contains("stable(") {
517                return;
518            }
519
520            let handle_issue_none = |s| match s {
521                "none" => None,
522                issue => {
523                    let n = issue.parse().expect("issue number is not a valid integer");
524                    assert_ne!(n, 0, "\"none\" should be used when there is no issue, not \"0\"");
525                    NonZeroU32::new(n)
526                }
527            };
528            let mut becoming_feature: Option<(&str, Feature)> = None;
529            let mut iter_lines = contents.lines().enumerate().peekable();
530            while let Some((i, line)) = iter_lines.next() {
531                macro_rules! err {
532                    ($msg:expr) => {{
533                        mf(Err($msg), file, i + 1);
534                        continue;
535                    }};
536                }
537
538                // exclude commented out lines
539                if static_regex!(r"^\s*//").is_match(line) {
540                    continue;
541                }
542
543                if let Some((name, ref mut f)) = becoming_feature {
544                    if f.tracking_issue.is_none() {
545                        f.tracking_issue = find_attr_val(line, "issue").and_then(handle_issue_none);
546                    }
547                    if line.ends_with(']') {
548                        mf(Ok((name, f.clone())), file, i + 1);
549                    } else if !line.ends_with(',') && !line.ends_with('\\') && !line.ends_with('"')
550                    {
551                        // We need to bail here because we might have missed the
552                        // end of a stability attribute above because the ']'
553                        // might not have been at the end of the line.
554                        // We could then get into the very unfortunate situation that
555                        // we continue parsing the file assuming the current stability
556                        // attribute has not ended, and ignoring possible feature
557                        // attributes in the process.
558                        err!("malformed stability attribute");
559                    } else {
560                        continue;
561                    }
562                }
563                becoming_feature = None;
564                if line.contains("rustc_const_unstable(") {
565                    // `const fn` features are handled specially.
566                    let feature_name = match find_attr_val(line, "feature").or_else(|| {
567                        iter_lines.peek().and_then(|next| find_attr_val(next.1, "feature"))
568                    }) {
569                        Some(name) => name,
570                        None => err!("malformed stability attribute: missing `feature` key"),
571                    };
572                    let feature = Feature {
573                        level: Status::Unstable,
574                        since: None,
575                        has_gate_test: false,
576                        tracking_issue: find_attr_val(line, "issue").and_then(handle_issue_none),
577                        file: file.to_path_buf(),
578                        line: i + 1,
579                        description: None,
580                    };
581                    mf(Ok((feature_name, feature)), file, i + 1);
582                    continue;
583                }
584                let level = if line.contains("[unstable(") {
585                    Status::Unstable
586                } else if line.contains("[stable(") {
587                    Status::Accepted
588                } else {
589                    continue;
590                };
591                let feature_name = match find_attr_val(line, "feature")
592                    .or_else(|| iter_lines.peek().and_then(|next| find_attr_val(next.1, "feature")))
593                {
594                    Some(name) => name,
595                    None => err!("malformed stability attribute: missing `feature` key"),
596                };
597                let since = match find_attr_val(line, "since").map(|x| x.parse()) {
598                    Some(Ok(since)) => Some(since),
599                    Some(Err(_err)) => {
600                        err!("malformed stability attribute: can't parse `since` key");
601                    }
602                    None if level == Status::Accepted => {
603                        err!("malformed stability attribute: missing the `since` key");
604                    }
605                    None => None,
606                };
607                let tracking_issue = find_attr_val(line, "issue").and_then(handle_issue_none);
608
609                let feature = Feature {
610                    level,
611                    since,
612                    has_gate_test: false,
613                    tracking_issue,
614                    file: file.to_path_buf(),
615                    line: i + 1,
616                    description: None,
617                };
618                if line.contains(']') {
619                    mf(Ok((feature_name, feature)), file, i + 1);
620                } else {
621                    becoming_feature = Some((feature_name, feature));
622                }
623            }
624        },
625    );
626}