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                    // NB: the `splitn` always succeeds, even if the delimiter is not present.
129                    Some(i) => line[i + gate_test_str.len()..].splitn(2, ' ').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-{}' comment",
139                                feature_name
140                            ));
141                        }
142                        f.has_gate_test = true;
143                    }
144                    None => {
145                        err(&format!(
146                            "gate-test test found referencing a nonexistent feature '{}'",
147                            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-{} line to the test file.",
174            name
175        );
176    }
177
178    if !gate_untested.is_empty() {
179        tidy_error!(bad, "Found {} features without a gate test.", gate_untested.len());
180    }
181
182    let (version, channel) = get_version_and_channel(src_path);
183
184    let all_features_iter = features
185        .iter()
186        .map(|feat| (feat, "lang"))
187        .chain(lib_features.iter().map(|feat| (feat, "lib")));
188    for ((feature_name, feature), kind) in all_features_iter {
189        let since = if let Some(since) = feature.since { since } else { continue };
190        let file = feature.file.display();
191        let line = feature.line;
192        if since > version && since != Version::CurrentPlaceholder {
193            tidy_error!(
194                bad,
195                "{file}:{line}: The stabilization version {since} of {kind} feature `{feature_name}` is newer than the current {version}"
196            );
197        }
198        if channel == "nightly" && since == version {
199            tidy_error!(
200                bad,
201                "{file}:{line}: The stabilization version {since} of {kind} feature `{feature_name}` is written out but should be {}",
202                version::VERSION_PLACEHOLDER
203            );
204        }
205        if channel != "nightly" && since == Version::CurrentPlaceholder {
206            tidy_error!(
207                bad,
208                "{file}:{line}: The placeholder use of {kind} feature `{feature_name}` is not allowed on the {channel} channel",
209            );
210        }
211    }
212
213    if *bad {
214        return CollectedFeatures { lib: lib_features, lang: features };
215    }
216
217    if verbose {
218        let mut lines = Vec::new();
219        lines.extend(format_features(&features, "lang"));
220        lines.extend(format_features(&lib_features, "lib"));
221
222        lines.sort();
223        for line in lines {
224            println!("* {line}");
225        }
226    }
227
228    CollectedFeatures { lib: lib_features, lang: features }
229}
230
231fn get_version_and_channel(src_path: &Path) -> (Version, String) {
232    let version_str = t!(std::fs::read_to_string(src_path.join("version")));
233    let version_str = version_str.trim();
234    let version = t!(std::str::FromStr::from_str(&version_str).map_err(|e| format!("{e:?}")));
235    let channel_str = t!(std::fs::read_to_string(src_path.join("ci").join("channel")));
236    (version, channel_str.trim().to_owned())
237}
238
239fn format_features<'a>(
240    features: &'a Features,
241    family: &'a str,
242) -> impl Iterator<Item = String> + 'a {
243    features.iter().map(move |(name, feature)| {
244        format!(
245            "{:<32} {:<8} {:<12} {:<8}",
246            name,
247            family,
248            feature.level,
249            feature.since.map_or("None".to_owned(), |since| since.to_string())
250        )
251    })
252}
253
254fn find_attr_val<'a>(line: &'a str, attr: &str) -> Option<&'a str> {
255    let r = match attr {
256        "issue" => static_regex!(r#"issue\s*=\s*"([^"]*)""#),
257        "feature" => static_regex!(r#"feature\s*=\s*"([^"]*)""#),
258        "since" => static_regex!(r#"since\s*=\s*"([^"]*)""#),
259        _ => unimplemented!("{attr} not handled"),
260    };
261
262    r.captures(line).and_then(|c| c.get(1)).map(|m| m.as_str())
263}
264
265fn test_filen_gate<'f>(filen_underscore: &'f str, features: &mut Features) -> Option<&'f str> {
266    let prefix = "feature_gate_";
267    if let Some(suffix) = filen_underscore.strip_prefix(prefix) {
268        for (n, f) in features.iter_mut() {
269            // Equivalent to filen_underscore == format!("feature_gate_{n}")
270            if suffix == n {
271                f.has_gate_test = true;
272                return Some(suffix);
273            }
274        }
275    }
276    None
277}
278
279pub fn collect_lang_features(base_compiler_path: &Path, bad: &mut bool) -> Features {
280    let mut features = Features::new();
281    collect_lang_features_in(&mut features, base_compiler_path, "accepted.rs", bad);
282    collect_lang_features_in(&mut features, base_compiler_path, "removed.rs", bad);
283    collect_lang_features_in(&mut features, base_compiler_path, "unstable.rs", bad);
284    features
285}
286
287fn collect_lang_features_in(features: &mut Features, base: &Path, file: &str, bad: &mut bool) {
288    let path = base.join("rustc_feature").join("src").join(file);
289    let contents = t!(fs::read_to_string(&path));
290
291    // We allow rustc-internal features to omit a tracking issue.
292    // To make tidy accept omitting a tracking issue, group the list of features
293    // without one inside `// no-tracking-issue` and `// no-tracking-issue-end`.
294    let mut next_feature_omits_tracking_issue = false;
295
296    let mut in_feature_group = false;
297    let mut prev_names = vec![];
298
299    let lines = contents.lines().zip(1..);
300    let mut doc_comments: Vec<String> = Vec::new();
301    for (line, line_number) in lines {
302        let line = line.trim();
303
304        // Within -start and -end, the tracking issue can be omitted.
305        match line {
306            "// no-tracking-issue-start" => {
307                next_feature_omits_tracking_issue = true;
308                continue;
309            }
310            "// no-tracking-issue-end" => {
311                next_feature_omits_tracking_issue = false;
312                continue;
313            }
314            _ => {}
315        }
316
317        if line.starts_with(FEATURE_GROUP_START_PREFIX) {
318            if in_feature_group {
319                tidy_error!(
320                    bad,
321                    "{}:{}: \
322                        new feature group is started without ending the previous one",
323                    path.display(),
324                    line_number,
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 {
338            if let Some(doc_comment) = line.strip_prefix("///") {
339                doc_comments.push(doc_comment.trim().to_string());
340                continue;
341            }
342        }
343
344        let mut parts = line.split(',');
345        let level = match parts.next().map(|l| l.trim().trim_start_matches('(')) {
346            Some("unstable") => Status::Unstable,
347            Some("incomplete") => Status::Unstable,
348            Some("internal") => Status::Unstable,
349            Some("removed") => Status::Removed,
350            Some("accepted") => Status::Accepted,
351            _ => continue,
352        };
353        let name = parts.next().unwrap().trim();
354
355        let since_str = parts.next().unwrap().trim().trim_matches('"');
356        let since = match since_str.parse() {
357            Ok(since) => Some(since),
358            Err(err) => {
359                tidy_error!(
360                    bad,
361                    "{}:{}: failed to parse since: {} ({:?})",
362                    path.display(),
363                    line_number,
364                    since_str,
365                    err,
366                );
367                None
368            }
369        };
370        if in_feature_group {
371            if prev_names.last() > Some(&name) {
372                // This assumes the user adds the feature name at the end of the list, as we're
373                // not looking ahead.
374                let correct_index = match prev_names.binary_search(&name) {
375                    Ok(_) => {
376                        // This only occurs when the feature name has already been declared.
377                        tidy_error!(
378                            bad,
379                            "{}:{}: duplicate feature {}",
380                            path.display(),
381                            line_number,
382                            name,
383                        );
384                        // skip any additional checks for this line
385                        continue;
386                    }
387                    Err(index) => index,
388                };
389
390                let correct_placement = if correct_index == 0 {
391                    "at the beginning of the feature group".to_owned()
392                } else if correct_index == prev_names.len() {
393                    // I don't believe this is reachable given the above assumption, but it
394                    // doesn't hurt to be safe.
395                    "at the end of the feature group".to_owned()
396                } else {
397                    format!(
398                        "between {} and {}",
399                        prev_names[correct_index - 1],
400                        prev_names[correct_index],
401                    )
402                };
403
404                tidy_error!(
405                    bad,
406                    "{}:{}: feature {} is not sorted by feature name (should be {})",
407                    path.display(),
408                    line_number,
409                    name,
410                    correct_placement,
411                );
412            }
413            prev_names.push(name);
414        }
415
416        let issue_str = parts.next().unwrap().trim();
417        let tracking_issue = if issue_str.starts_with("None") {
418            if level == Status::Unstable && !next_feature_omits_tracking_issue {
419                tidy_error!(
420                    bad,
421                    "{}:{}: no tracking issue for feature {}",
422                    path.display(),
423                    line_number,
424                    name,
425                );
426            }
427            None
428        } else {
429            let s = issue_str.split('(').nth(1).unwrap().split(')').next().unwrap();
430            Some(s.parse().unwrap())
431        };
432        match features.entry(name.to_owned()) {
433            Entry::Occupied(e) => {
434                tidy_error!(
435                    bad,
436                    "{}:{} feature {name} already specified with status '{}'",
437                    path.display(),
438                    line_number,
439                    e.get().level,
440                );
441            }
442            Entry::Vacant(e) => {
443                e.insert(Feature {
444                    level,
445                    since,
446                    has_gate_test: false,
447                    tracking_issue,
448                    file: path.to_path_buf(),
449                    line: line_number,
450                    description: if doc_comments.is_empty() {
451                        None
452                    } else {
453                        Some(doc_comments.join(" "))
454                    },
455                });
456            }
457        }
458        doc_comments.clear();
459    }
460}
461
462fn get_and_check_lib_features(
463    base_src_path: &Path,
464    bad: &mut bool,
465    lang_features: &Features,
466) -> Features {
467    let mut lib_features = Features::new();
468    map_lib_features(base_src_path, &mut |res, file, line| match res {
469        Ok((name, f)) => {
470            let mut check_features = |f: &Feature, list: &Features, display: &str| {
471                if let Some(ref s) = list.get(name) {
472                    if f.tracking_issue != s.tracking_issue && f.level != Status::Accepted {
473                        tidy_error!(
474                            bad,
475                            "{}:{}: feature gate {} has inconsistent `issue`: \"{}\" mismatches the {} `issue` of \"{}\"",
476                            file.display(),
477                            line,
478                            name,
479                            f.tracking_issue_display(),
480                            display,
481                            s.tracking_issue_display(),
482                        );
483                    }
484                }
485            };
486            check_features(&f, &lang_features, "corresponding lang feature");
487            check_features(&f, &lib_features, "previous");
488            lib_features.insert(name.to_owned(), f);
489        }
490        Err(msg) => {
491            tidy_error!(bad, "{}:{}: {}", file.display(), line, msg);
492        }
493    });
494    lib_features
495}
496
497fn map_lib_features(
498    base_src_path: &Path,
499    mf: &mut (dyn Send + Sync + FnMut(Result<(&str, Feature), &str>, &Path, usize)),
500) {
501    walk(
502        base_src_path,
503        |path, _is_dir| filter_dirs(path) || path.ends_with("tests"),
504        &mut |entry, contents| {
505            let file = entry.path();
506            let filename = file.file_name().unwrap().to_string_lossy();
507            if !filename.ends_with(".rs")
508                || filename == "features.rs"
509                || filename == "diagnostic_list.rs"
510                || filename == "error_codes.rs"
511            {
512                return;
513            }
514
515            // This is an early exit -- all the attributes we're concerned with must contain this:
516            // * rustc_const_unstable(
517            // * unstable(
518            // * stable(
519            if !contents.contains("stable(") {
520                return;
521            }
522
523            let handle_issue_none = |s| match s {
524                "none" => None,
525                issue => {
526                    let n = issue.parse().expect("issue number is not a valid integer");
527                    assert_ne!(n, 0, "\"none\" should be used when there is no issue, not \"0\"");
528                    NonZeroU32::new(n)
529                }
530            };
531            let mut becoming_feature: Option<(&str, Feature)> = None;
532            let mut iter_lines = contents.lines().enumerate().peekable();
533            while let Some((i, line)) = iter_lines.next() {
534                macro_rules! err {
535                    ($msg:expr) => {{
536                        mf(Err($msg), file, i + 1);
537                        continue;
538                    }};
539                }
540
541                // exclude commented out lines
542                if static_regex!(r"^\s*//").is_match(line) {
543                    continue;
544                }
545
546                if let Some((ref name, ref mut f)) = becoming_feature {
547                    if f.tracking_issue.is_none() {
548                        f.tracking_issue = find_attr_val(line, "issue").and_then(handle_issue_none);
549                    }
550                    if line.ends_with(']') {
551                        mf(Ok((name, f.clone())), file, i + 1);
552                    } else if !line.ends_with(',') && !line.ends_with('\\') && !line.ends_with('"')
553                    {
554                        // We need to bail here because we might have missed the
555                        // end of a stability attribute above because the ']'
556                        // might not have been at the end of the line.
557                        // We could then get into the very unfortunate situation that
558                        // we continue parsing the file assuming the current stability
559                        // attribute has not ended, and ignoring possible feature
560                        // attributes in the process.
561                        err!("malformed stability attribute");
562                    } else {
563                        continue;
564                    }
565                }
566                becoming_feature = None;
567                if line.contains("rustc_const_unstable(") {
568                    // `const fn` features are handled specially.
569                    let feature_name = match find_attr_val(line, "feature").or_else(|| {
570                        iter_lines.peek().and_then(|next| find_attr_val(next.1, "feature"))
571                    }) {
572                        Some(name) => name,
573                        None => err!("malformed stability attribute: missing `feature` key"),
574                    };
575                    let feature = Feature {
576                        level: Status::Unstable,
577                        since: None,
578                        has_gate_test: false,
579                        tracking_issue: find_attr_val(line, "issue").and_then(handle_issue_none),
580                        file: file.to_path_buf(),
581                        line: i + 1,
582                        description: None,
583                    };
584                    mf(Ok((feature_name, feature)), file, i + 1);
585                    continue;
586                }
587                let level = if line.contains("[unstable(") {
588                    Status::Unstable
589                } else if line.contains("[stable(") {
590                    Status::Accepted
591                } else {
592                    continue;
593                };
594                let feature_name = match find_attr_val(line, "feature")
595                    .or_else(|| iter_lines.peek().and_then(|next| find_attr_val(next.1, "feature")))
596                {
597                    Some(name) => name,
598                    None => err!("malformed stability attribute: missing `feature` key"),
599                };
600                let since = match find_attr_val(line, "since").map(|x| x.parse()) {
601                    Some(Ok(since)) => Some(since),
602                    Some(Err(_err)) => {
603                        err!("malformed stability attribute: can't parse `since` key");
604                    }
605                    None if level == Status::Accepted => {
606                        err!("malformed stability attribute: missing the `since` key");
607                    }
608                    None => None,
609                };
610                let tracking_issue = find_attr_val(line, "issue").and_then(handle_issue_none);
611
612                let feature = Feature {
613                    level,
614                    since,
615                    has_gate_test: false,
616                    tracking_issue,
617                    file: file.to_path_buf(),
618                    line: i + 1,
619                    description: None,
620                };
621                if line.contains(']') {
622                    mf(Ok((feature_name, feature)), file, i + 1);
623                } else {
624                    becoming_feature = Some((feature_name, feature));
625                }
626            }
627        },
628    );
629}