unmake/
warnings.rs

1//! warnings generates makefile recommendations.
2
3use crate::ast;
4use crate::inspect;
5use std::collections::HashSet;
6use std::fmt;
7
8lazy_static::lazy_static! {
9    /// WD_COMMANDS collects common commands for modifying a shell's current working directory.
10    pub static ref WD_COMMANDS: Vec<&'static str> = vec![
11        "cd",
12        "pushd",
13        "popd",
14    ];
15
16    /// LOWER_CONVENTIONAL_PHONY_TARGETS_PATTERN matches common artifactless target names,
17    /// specified in lowercase.
18    pub static ref LOWER_CONVENTIONAL_PHONY_TARGETS_PATTERN: regex::Regex = regex::Regex::new(
19        "^all|lint|install|uninstall|publish|(test.*)|(clean.*)$"
20    ).unwrap();
21
22    /// COMMAND_PREFIX_PATTERN matches commands with prefixes.
23    pub static ref COMMAND_PREFIX_PATTERN: regex::Regex = regex::Regex::new(r"^(?P<prefix>[-+@]+)").unwrap();
24
25    /// BLANK_COMMAND_PATTERN matches empty commands.
26    ///
27    /// Empty commands are distinct from a rule without commands.
28    pub static ref BLANK_COMMAND_PATTERN: regex::Regex = regex::Regex::new(r"^[-+@]+\s*$").unwrap();
29
30    /// WHITESPACE_LEADING_COMMAND_PATTERN matches commands that start with whitespace.
31    pub static ref WHITESPACE_LEADING_COMMAND_PATTERN: regex::Regex = regex::Regex::new(r"^[-+@]*\s+").unwrap();
32
33    /// RESERVED_TARGET_PATTERN matches targets reserved either for POSIX use, or for extensions.
34    pub static ref RESERVED_TARGET_PATTERN: regex::Regex = regex::Regex::new(r"^.[A-Z]+").unwrap();
35
36    /// WARNING_DEFAULT_PATH assumes stdin (unimplemented).
37    static ref WARNING_DEFAULT_PATH: String = "-".to_string();
38
39    /// CHECKS collects the set of available high level makefile scans.
40    pub static ref CHECKS: Vec<Check> = vec![
41        check_ub_late_posix_marker,
42        check_ub_ambiguous_include,
43        check_ub_makeflags_assignment,
44        check_ub_shell_macro,
45        check_silent_include,
46        check_strict_posix,
47        check_implementation_defined_target,
48        check_makefile_precedence,
49        check_curdir_assignment_nop,
50        check_wd_nop,
51        check_wait_nop,
52        check_phony_nop,
53        check_redundant_notparallel_wait,
54        check_redundant_silent_at,
55        check_redundant_ignore_minus,
56        check_global_ignore,
57        check_simplify_at,
58        check_simplify_minus,
59        check_command_comment,
60        check_phony_target,
61        check_repeated_command_prefix,
62        check_blank_command,
63        check_whitespace_leading_command,
64        check_no_rules,
65        check_reserved_target,
66        check_rule_all,
67        check_final_eol,
68        check_portable_assignment,
69    ];
70}
71
72/// Check implements a linter scan.
73pub type Check = fn(&inspect::Metadata, &[ast::Gem]) -> Vec<Warning>;
74
75/// Warning models a linter recommendation.
76#[derive(Debug, PartialEq)]
77pub struct Warning {
78    /// path denotes an offending file path.
79    pub path: String,
80
81    /// line denotes the location of the relevant code section to enhance.
82    pub line: usize,
83
84    /// message denotes a brief description of the recommendation.
85    pub message: String,
86}
87
88impl Warning {
89    /// new constructs a Warning.
90    pub fn new() -> Warning {
91        Warning {
92            path: WARNING_DEFAULT_PATH.to_string(),
93            line: 0,
94            message: String::new(),
95        }
96    }
97}
98
99impl Default for Warning {
100    /// default generates a basic Warning.
101    fn default() -> Self {
102        Warning::new()
103    }
104}
105
106impl fmt::Display for Warning {
107    /// fmt renders a Warning for console use.
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        write!(f, "warning: {}:", self.path)?;
110
111        if self.line > 0 {
112            write!(f, "{}:", self.line)?;
113        }
114
115        write!(f, " {}", self.message)
116    }
117}
118
119/// mock_md constructs simulated Metadata for a hypothetical path.
120///
121/// Assume a lintable POSIX makefile.
122///
123/// Certain fields are given dummy values.
124pub fn mock_md(pth: &str) -> inspect::Metadata {
125    inspect::Metadata {
126        path: pth.to_string(),
127        filename: pth.to_string(),
128        is_makefile: true,
129        build_system: String::new(),
130        is_machine_generated: false,
131        is_include_file: false,
132        is_empty: true,
133        lines: 0,
134        has_final_eol: false,
135    }
136}
137
138pub static UB_LATE_POSIX_MARKER: &str = "UB_LATE_POSIX_MARKER: the special rule \".POSIX:\" should be the first uncommented instruction in POSIX makefiles, or else absent from *.include.mk files";
139
140/// check_ub_late_posix_marker reports UB_LATE_POSIX_MARKER violations.
141fn check_ub_late_posix_marker(metadata: &inspect::Metadata, gems: &[ast::Gem]) -> Vec<Warning> {
142    gems.iter()
143        .enumerate()
144        .filter(|(i, e)| match &e.n {
145            ast::Ore::Ru { ps: _, ts, cs: _ } => {
146                (metadata.is_include_file || i > &0) && ts == &vec![".POSIX"]
147            }
148            _ => false,
149        })
150        .map(|(_, e)| Warning {
151            path: metadata.path.to_string(),
152            line: e.l,
153            message: UB_LATE_POSIX_MARKER.to_string(),
154        })
155        .collect()
156}
157
158#[test]
159fn test_late_posix_marker() {
160    assert!(
161        lint(&mock_md("-"), "PKG=curl\n.POSIX:\n")
162            .unwrap()
163            .into_iter()
164            .map(|e| e.message)
165            .collect::<Vec<String>>()
166            .contains(&UB_LATE_POSIX_MARKER.to_string())
167    );
168
169    assert!(
170        !lint(&mock_md("-"), "# strict posix\n.POSIX:\nPKG=curl\n")
171            .unwrap()
172            .into_iter()
173            .map(|e| e.message)
174            .collect::<Vec<String>>()
175            .contains(&UB_LATE_POSIX_MARKER.to_string())
176    );
177
178    assert!(
179        !lint(&mock_md("-"), ".POSIX:\nPKG=curl\n")
180            .unwrap()
181            .into_iter()
182            .map(|e| e.message)
183            .collect::<Vec<String>>()
184            .contains(&UB_LATE_POSIX_MARKER.to_string())
185    );
186
187    let mut md_include = mock_md("provision.include.mk");
188    md_include.is_include_file = true;
189
190    assert!(
191        lint(&md_include, ".POSIX:\nPKG=curl\n")
192            .unwrap()
193            .into_iter()
194            .map(|e| e.message)
195            .collect::<Vec<String>>()
196            .contains(&UB_LATE_POSIX_MARKER.to_string())
197    );
198
199    assert!(
200        !lint(&md_include, "PKG=curl\n")
201            .unwrap()
202            .into_iter()
203            .map(|e| e.message)
204            .collect::<Vec<String>>()
205            .contains(&UB_LATE_POSIX_MARKER.to_string())
206    );
207
208    assert!(
209        lint(&mock_md("-"), ".POSIX:\ninclude =foo.mk\n")
210            .unwrap()
211            .into_iter()
212            .map(|e| e.message)
213            .collect::<Vec<String>>()
214            .contains(&UB_AMBIGUOUS_INCLUDE.to_string())
215    );
216}
217
218pub static UB_AMBIGUOUS_INCLUDE: &str =
219    "UB_AMBIGUOUS_INCLUDE: unclear whether include line or macro definition";
220
221/// check_ub_ambiguous_include reports UB_AMBIGUOUS_INCLUDE violations.
222fn check_ub_ambiguous_include(metadata: &inspect::Metadata, gems: &[ast::Gem]) -> Vec<Warning> {
223    gems.iter()
224        .filter(|e| match &e.n {
225            ast::Ore::In { s: _, ps } => ps.iter().any(|e2| e2.starts_with('=')),
226            _ => false,
227        })
228        .map(|e| Warning {
229            path: metadata.path.to_string(),
230            line: e.l,
231            message: UB_AMBIGUOUS_INCLUDE.to_string(),
232        })
233        .collect()
234}
235
236#[test]
237fn test_ub_ambiguous_include() {
238    assert!(
239        lint(&mock_md("-"), ".POSIX:\ninclude = foo.mk\n")
240            .unwrap()
241            .into_iter()
242            .map(|e| e.message)
243            .collect::<Vec<String>>()
244            .contains(&UB_AMBIGUOUS_INCLUDE.to_string())
245    );
246
247    assert!(
248        lint(&mock_md("-"), ".POSIX:\ninclude =foo.mk\n")
249            .unwrap()
250            .into_iter()
251            .map(|e| e.message)
252            .collect::<Vec<String>>()
253            .contains(&UB_AMBIGUOUS_INCLUDE.to_string())
254    );
255
256    assert!(
257        !lint(&mock_md("-"), ".POSIX:\ninclude=foo.mk\n")
258            .unwrap()
259            .into_iter()
260            .map(|e| e.message)
261            .collect::<Vec<String>>()
262            .contains(&UB_AMBIGUOUS_INCLUDE.to_string())
263    );
264}
265
266pub static UB_MAKEFLAGS_ASSIGNMENT: &str = "UB_MAKEFLAGS_MACRO: do not modify MAKEFLAGS macro";
267
268/// check_ub_makeflags_assignment reports UB_MAKEFLAGS_ASSIGNMENT violations.
269fn check_ub_makeflags_assignment(metadata: &inspect::Metadata, gems: &[ast::Gem]) -> Vec<Warning> {
270    gems.iter()
271        .filter(|e| match &e.n {
272            ast::Ore::Mc { n, o: _, v: _ } => n == "MAKEFLAGS",
273            _ => false,
274        })
275        .map(|e| Warning {
276            path: metadata.path.to_string(),
277            line: e.l,
278            message: UB_MAKEFLAGS_ASSIGNMENT.to_string(),
279        })
280        .collect()
281}
282
283#[test]
284fn test_ub_makeflags_assignment() {
285    assert!(
286        lint(&mock_md("-"), ".POSIX:\nMAKEFLAGS ?= -j\nMAKEFLAGS = -j\nMAKEFLAGS ::= -j\nMAKEFLAGS :::= -j\nMAKEFLAGS += -j\nMAKEFLAGS != echo \"-j\"\n")
287            .unwrap()
288            .into_iter()
289            .map(|e| e.message)
290            .collect::<Vec<String>>()
291            .contains(&UB_MAKEFLAGS_ASSIGNMENT.to_string()));
292
293    assert!(
294        !lint(&mock_md("-"), ".POSIX:\nPKG = curl\n")
295            .unwrap()
296            .into_iter()
297            .map(|e| e.message)
298            .collect::<Vec<String>>()
299            .contains(&UB_MAKEFLAGS_ASSIGNMENT.to_string())
300    );
301}
302
303pub static UB_SHELL_MACRO: &str = "UB_SHELL_MACRO: do not use or modify SHELL macro";
304
305/// check_ub_shell_macro reports UB_SHELL_MACRO violations.
306fn check_ub_shell_macro(metadata: &inspect::Metadata, gems: &[ast::Gem]) -> Vec<Warning> {
307    gems.iter()
308        .filter(|e| match &e.n {
309            ast::Ore::Mc { n, o: _, v: _ } => n == "SHELL",
310            _ => false,
311        })
312        .map(|e| Warning {
313            path: metadata.path.to_string(),
314            line: e.l,
315            message: UB_SHELL_MACRO.to_string(),
316        })
317        .collect()
318}
319
320#[test]
321fn test_ub_shell_macro() {
322    assert!(lint(
323        &mock_md("-"),
324        ".POSIX:\nSHELL ?= sh\nSHELL = sh\nSHELL ::= sh\nSHELL :::= sh\nSHELL += sh\nSHELL != sh\n"
325    )
326    .unwrap()
327    .into_iter()
328    .map(|e| e.message)
329    .collect::<Vec<String>>()
330    .contains(&UB_SHELL_MACRO.to_string()));
331
332    assert!(
333        !lint(&mock_md("-"), ".POSIX:\nPKG = curl\n")
334            .unwrap()
335            .into_iter()
336            .map(|e| e.message)
337            .collect::<Vec<String>>()
338            .contains(&UB_SHELL_MACRO.to_string())
339    );
340}
341
342pub static SILENT_INCLUDE: &str = "SILENT_INCLUDE: dashed includes may obfuscate systemic errors";
343
344/// check_silent_include reports SILENT_INCLUDE violations.
345fn check_silent_include(metadata: &inspect::Metadata, gems: &[ast::Gem]) -> Vec<Warning> {
346    gems.iter()
347        .filter(|e| match &e.n {
348            ast::Ore::In { s, ps: _ } => *s,
349            _ => false,
350        })
351        .map(|e| Warning {
352            path: metadata.path.to_string(),
353            line: e.l,
354            message: SILENT_INCLUDE.to_string(),
355        })
356        .collect()
357}
358
359#[test]
360fn test_silent_include() {
361    assert!(
362        lint(&mock_md("-"), "-include = foo.mk\n")
363            .unwrap()
364            .into_iter()
365            .map(|e| e.message)
366            .collect::<Vec<String>>()
367            .contains(&SILENT_INCLUDE.to_string())
368    );
369
370    assert!(
371        !lint(&mock_md("-"), "include=foo.mk\n")
372            .unwrap()
373            .into_iter()
374            .map(|e| e.message)
375            .collect::<Vec<String>>()
376            .contains(&SILENT_INCLUDE.to_string())
377    );
378}
379
380pub static MAKEFILE_PRECEDENCE: &str =
381    "MAKEFILE_PRECEDENCE: lowercase Makefile to makefile for launch speed";
382
383/// check_makefile_precedence reports MAKEFILE_PRECEDENCE violations.
384fn check_makefile_precedence(metadata: &inspect::Metadata, _: &[ast::Gem]) -> Vec<Warning> {
385    if metadata.filename == "Makefile" {
386        return vec![Warning {
387            path: metadata.path.to_string(),
388            line: 0,
389            message: MAKEFILE_PRECEDENCE.to_string(),
390        }];
391    }
392
393    Vec::new()
394}
395
396#[test]
397pub fn test_makefile_precedence() {
398    assert!(
399        lint(&mock_md("Makefile"), ".POSIX:\nPKG=curl\n")
400            .unwrap()
401            .into_iter()
402            .map(|e| e.message)
403            .collect::<Vec<String>>()
404            .contains(&MAKEFILE_PRECEDENCE.to_string())
405    );
406
407    assert!(
408        !lint(&mock_md("makefile"), ".POSIX:\nPKG=curl\n")
409            .unwrap()
410            .into_iter()
411            .map(|e| e.message)
412            .collect::<Vec<String>>()
413            .contains(&MAKEFILE_PRECEDENCE.to_string())
414    );
415
416    assert!(
417        !lint(&mock_md("foo.mk"), ".POSIX:\nPKG=curl\n")
418            .unwrap()
419            .into_iter()
420            .map(|e| e.message)
421            .collect::<Vec<String>>()
422            .contains(&MAKEFILE_PRECEDENCE.to_string())
423    );
424
425    assert!(
426        !lint(&mock_md("foo.Makefile"), ".POSIX:\nPKG=curl\n")
427            .unwrap()
428            .into_iter()
429            .map(|e| e.message)
430            .collect::<Vec<String>>()
431            .contains(&MAKEFILE_PRECEDENCE.to_string())
432    );
433
434    assert!(
435        !lint(&mock_md("foo.makefile"), ".POSIX:\nPKG=curl\n")
436            .unwrap()
437            .into_iter()
438            .map(|e| e.message)
439            .collect::<Vec<String>>()
440            .contains(&MAKEFILE_PRECEDENCE.to_string())
441    );
442}
443
444pub static CURDIR_ASSIGNMENT_NOP: &str =
445    "CURDIR_ASSIGNMENT_NOP: CURDIR assignment does not change the make working directory";
446
447/// check_curdir_assignment_nop reports CURDIR_ASSIGNMENT_NOP violations.
448fn check_curdir_assignment_nop(metadata: &inspect::Metadata, gems: &[ast::Gem]) -> Vec<Warning> {
449    gems.iter()
450        .filter(|e| match &e.n {
451            ast::Ore::Mc { n, o: _, v: _ } => n == "CURDIR",
452            _ => false,
453        })
454        .map(|e| Warning {
455            path: metadata.path.to_string(),
456            line: e.l,
457            message: CURDIR_ASSIGNMENT_NOP.to_string(),
458        })
459        .collect()
460}
461
462#[test]
463pub fn test_curdir_assignment_nop() {
464    assert!(
465        lint(&mock_md("-"), ".POSIX:\nCURDIR = build\n")
466            .unwrap()
467            .into_iter()
468            .map(|e| e.message)
469            .collect::<Vec<String>>()
470            .contains(&CURDIR_ASSIGNMENT_NOP.to_string())
471    );
472
473    assert!(
474        !lint(&mock_md("-"), ".POSIX:\n")
475            .unwrap()
476            .into_iter()
477            .map(|e| e.message)
478            .collect::<Vec<String>>()
479            .contains(&CURDIR_ASSIGNMENT_NOP.to_string())
480    );
481}
482
483pub static WD_NOP: &str =
484    "WD_NOP: change directory commands may not persist across successive commands or rules";
485
486/// check_wd_nop reports WD_NOP violations.
487fn check_wd_nop(metadata: &inspect::Metadata, gems: &[ast::Gem]) -> Vec<Warning> {
488    gems.iter()
489        .filter(|e| match &e.n {
490            ast::Ore::Ru { ps: _, ts: _, cs } => cs
491                .iter()
492                .any(|e2| WD_COMMANDS.contains(&e2.split_whitespace().next().unwrap_or(""))),
493            _ => false,
494        })
495        .map(|e| Warning {
496            path: metadata.path.to_string(),
497            line: e.l,
498            message: WD_NOP.to_string(),
499        })
500        .collect()
501}
502
503#[test]
504pub fn test_wd_nop() {
505    assert!(
506        lint(
507            &mock_md("-"),
508            ".POSIX:\n.PHONY: all\nall:\n\tcd foo\n\n\tpushd bar\n\tpopd\n"
509        )
510        .unwrap()
511        .into_iter()
512        .map(|e| e.message)
513        .collect::<Vec<String>>()
514        .contains(&WD_NOP.to_string())
515    );
516
517    assert!(
518        !lint(
519            &mock_md("-"),
520            ".POSIX:\n.PHONY: all\nall:\n\ttar -C foo czvf foo.tgz .\n"
521        )
522        .unwrap()
523        .into_iter()
524        .map(|e| e.message)
525        .collect::<Vec<String>>()
526        .contains(&WD_NOP.to_string())
527    );
528}
529
530pub static WAIT_NOP: &str = "WAIT_NOP: .WAIT as a target has no effect";
531
532/// check_makefile_precedence reports WAIT_NOP violations.
533fn check_wait_nop(metadata: &inspect::Metadata, gems: &[ast::Gem]) -> Vec<Warning> {
534    gems.iter()
535        .filter(|e| match &e.n {
536            ast::Ore::Ru { ps: _, ts, cs: _ } => ts.contains(&".WAIT".to_string()),
537            _ => false,
538        })
539        .map(|e| Warning {
540            path: metadata.path.to_string(),
541            line: e.l,
542            message: WAIT_NOP.to_string(),
543        })
544        .collect()
545}
546
547#[test]
548pub fn test_wait_nop() {
549    assert!(
550        lint(&mock_md("-"), ".POSIX:\n.WAIT:\n")
551            .unwrap()
552            .into_iter()
553            .map(|e| e.message)
554            .collect::<Vec<String>>()
555            .contains(&WAIT_NOP.to_string())
556    );
557
558    assert!(
559        !lint(&mock_md("-"), ".POSIX:\n.PHONY: test test-1 test-2\ntest: test-1 .WAIT test-2\ntest-1:\n\techo \"Hello World!\"\ntest-2:\n\techo \"Hi World!\"\n")
560            .unwrap()
561            .into_iter()
562            .map(|e| e.message)
563            .collect::<Vec<String>>()
564            .contains(&WAIT_NOP.to_string()));
565}
566
567pub static PHONY_NOP: &str = "PHONY_NOP: empty .PHONY has no effect";
568
569/// check_phony_nop reports PHONY_NOP violations.
570fn check_phony_nop(metadata: &inspect::Metadata, gems: &[ast::Gem]) -> Vec<Warning> {
571    gems.iter()
572        .filter(|e| match &e.n {
573            ast::Ore::Ru { ps, ts, cs: _ } => ts.contains(&".PHONY".to_string()) && ps.is_empty(),
574            _ => false,
575        })
576        .map(|e| Warning {
577            path: metadata.path.to_string(),
578            line: e.l,
579            message: PHONY_NOP.to_string(),
580        })
581        .collect()
582}
583
584#[test]
585pub fn test_phony_nop() {
586    assert!(
587        lint(
588            &mock_md("-"),
589            ".POSIX:\n.PHONY:\nfoo: foo.c\n\tgcc -o foo foo.c\n"
590        )
591        .unwrap()
592        .into_iter()
593        .map(|e| e.message)
594        .collect::<Vec<String>>()
595        .contains(&PHONY_NOP.to_string())
596    );
597
598    assert!(
599        !lint(
600            &mock_md("-"),
601            ".POSIX:\n.PHONY: test\ntest:\n\techo \"Hello World!\"\n"
602        )
603        .unwrap()
604        .into_iter()
605        .map(|e| e.message)
606        .collect::<Vec<String>>()
607        .contains(&PHONY_NOP.to_string())
608    );
609}
610
611pub static REDUNDANT_NOTPARALLEL_WAIT: &str =
612    "REDUNDANT_NOTPARALLEL_WAIT: .NOTPARALLEL with .WAIT is redundant and superfluous";
613
614/// check_redundant_notparallel_wait reports REDUNDANT_NOTPARALLEL_WAIT violations.
615fn check_redundant_notparallel_wait(
616    metadata: &inspect::Metadata,
617    gems: &[ast::Gem],
618) -> Vec<Warning> {
619    let has_notparallel: bool = gems.iter().any(|e| match &e.n {
620        ast::Ore::Ru { ps: _, ts, cs: _ } => ts.contains(&".NOTPARALLEL".to_string()),
621        _ => false,
622    });
623
624    if !has_notparallel {
625        return Vec::new();
626    }
627
628    gems.iter()
629        .filter(|e| match &e.n {
630            ast::Ore::Ru { ps, ts: _, cs: _ } => ps.contains(&".WAIT".to_string()),
631            _ => false,
632        })
633        .map(|e| Warning {
634            path: metadata.path.to_string(),
635            line: e.l,
636            message: REDUNDANT_NOTPARALLEL_WAIT.to_string(),
637        })
638        .collect()
639}
640
641#[test]
642pub fn test_redundant_nonparallel_wait() {
643    assert!(
644        lint(&mock_md("-"), ".POSIX:\n.NOTPARALLEL:\n.PHONY: test test-1 test-2\ntest: test-1 .WAIT test-2\ntest-1:\n\techo \"Hello World!\"\ntest-2:\n\techo \"Hi World!\"\n")
645            .unwrap()
646            .into_iter()
647            .map(|e| e.message)
648            .collect::<Vec<String>>()
649            .contains(&REDUNDANT_NOTPARALLEL_WAIT.to_string()));
650
651    assert!(
652        !lint(&mock_md("-"), ".POSIX:\n.PHONY: test test-1 test-2\ntest: test-1 .WAIT test-2\ntest-1:\n\techo \"Hello World!\"\ntest-2:\n\techo \"Hi World!\"\n")
653            .unwrap()
654            .into_iter()
655            .map(|e| e.message)
656            .collect::<Vec<String>>()
657            .contains(&REDUNDANT_NOTPARALLEL_WAIT.to_string()));
658
659    assert!(
660        !lint(&mock_md("-"), ".POSIX:\n.NOTPARALLEL:\n.PHONY: test test-1 test-2\ntest: test-1 test-2\ntest-1:\n\techo \"Hello World!\"\ntest-2:\n\techo \"Hi World!\"\n")
661            .unwrap()
662            .into_iter()
663            .map(|e| e.message)
664            .collect::<Vec<String>>()
665            .contains(&REDUNDANT_NOTPARALLEL_WAIT.to_string()));
666}
667
668pub static REDUNDANT_SILENT_AT: &str =
669    "REDUNDANT_SILENT_AT: .SILENT with @ is redundant and superfluous";
670
671/// check_redundant_silent_at reports REDUNDANT_SILENT_AT violations.
672fn check_redundant_silent_at(metadata: &inspect::Metadata, gems: &[ast::Gem]) -> Vec<Warning> {
673    let mut has_global_silence: bool = false;
674    let mut marked_silent_targets: HashSet<&String> = HashSet::new();
675
676    for gem in gems {
677        if let ast::Ore::Ru { ps, ts, cs: _ } = &gem.n {
678            if ts.contains(&".SILENT".to_string()) {
679                if ps.is_empty() {
680                    has_global_silence = true;
681                }
682
683                for p in ps {
684                    marked_silent_targets.insert(p);
685                }
686            }
687        }
688    }
689
690    gems.iter()
691        .filter(|e| match &e.n {
692            ast::Ore::Ru { ps: _, ts, cs } => {
693                cs.iter().any(|e2| e2.starts_with('@'))
694                    && (has_global_silence
695                        || ts.iter().any(|e2| marked_silent_targets.contains(e2)))
696            }
697            _ => false,
698        })
699        .map(|e| Warning {
700            path: metadata.path.to_string(),
701            line: e.l,
702            message: REDUNDANT_SILENT_AT.to_string(),
703        })
704        .collect()
705}
706
707#[test]
708pub fn test_redundant_silent_at() {
709    assert!(
710        lint(
711            &mock_md("-"),
712            ".POSIX:\n.PHONY: lint\n.SILENT:\nlint:\n\t@unmake .\n"
713        )
714        .unwrap()
715        .into_iter()
716        .map(|e| e.message)
717        .collect::<Vec<String>>()
718        .contains(&REDUNDANT_SILENT_AT.to_string())
719    );
720
721    assert!(
722        lint(
723            &mock_md("-"),
724            ".POSIX:\n.PHONY: lint\n.SILENT: lint\nlint:\n\t@unmake .\n"
725        )
726        .unwrap()
727        .into_iter()
728        .map(|e| e.message)
729        .collect::<Vec<String>>()
730        .contains(&REDUNDANT_SILENT_AT.to_string())
731    );
732
733    assert!(
734        !lint(
735            &mock_md("-"),
736            ".POSIX:\n.PHONY: lint\n.SILENT: lint\nlint:\n\tunmake .\n"
737        )
738        .unwrap()
739        .into_iter()
740        .map(|e| e.message)
741        .collect::<Vec<String>>()
742        .contains(&REDUNDANT_SILENT_AT.to_string())
743    );
744
745    assert!(
746        !lint(&mock_md("-"), ".POSIX:\n.PHONY: lint\nlint:\n\t@unmake .\n")
747            .unwrap()
748            .into_iter()
749            .map(|e| e.message)
750            .collect::<Vec<String>>()
751            .contains(&REDUNDANT_SILENT_AT.to_string())
752    );
753}
754
755pub static REDUNDANT_IGNORE_MINUS: &str =
756    "REDUNDANT_IGNORE_MINUS: .IGNORE with - is redundant and superfluous";
757
758/// check_redundant_ignore_minus reports REDUNDANT_IGNORE_MINUS violations.
759fn check_redundant_ignore_minus(metadata: &inspect::Metadata, gems: &[ast::Gem]) -> Vec<Warning> {
760    let mut marked_ignored_targets: HashSet<&String> = HashSet::new();
761    for gem in gems {
762        if let ast::Ore::Ru { ps, ts, cs: _ } = &gem.n {
763            if ts.contains(&".IGNORE".to_string()) {
764                for p in ps {
765                    marked_ignored_targets.insert(p);
766                }
767            }
768        }
769    }
770
771    gems.iter()
772        .filter(|e| match &e.n {
773            ast::Ore::Ru { ps: _, ts, cs } => {
774                cs.iter().any(|e2| e2.starts_with('-'))
775                    && ts.iter().any(|e2| marked_ignored_targets.contains(e2))
776            }
777            _ => false,
778        })
779        .map(|e| Warning {
780            path: metadata.path.to_string(),
781            line: e.l,
782            message: REDUNDANT_IGNORE_MINUS.to_string(),
783        })
784        .collect()
785}
786
787#[test]
788pub fn test_redundant_ignore_minus() {
789    assert!(
790        lint(
791            &mock_md("-"),
792            ".POSIX:\n.PHONY: clean\n.IGNORE: clean\nclean:\n\t-rm -rf bin\n"
793        )
794        .unwrap()
795        .into_iter()
796        .map(|e| e.message)
797        .collect::<Vec<String>>()
798        .contains(&REDUNDANT_IGNORE_MINUS.to_string())
799    );
800
801    assert!(
802        !lint(
803            &mock_md("-"),
804            ".POSIX:\n.PHONY: clean\nclean:\n\t-rm -rf bin\n"
805        )
806        .unwrap()
807        .into_iter()
808        .map(|e| e.message)
809        .collect::<Vec<String>>()
810        .contains(&REDUNDANT_IGNORE_MINUS.to_string())
811    );
812}
813
814pub static GLOBAL_IGNORE: &str =
815    "GLOBAL_IGNORE: .IGNORE without prerequisites may corrupt artifacts";
816
817/// check_global_ignore reports GLOBAL_IGNORE violations.
818fn check_global_ignore(metadata: &inspect::Metadata, gems: &[ast::Gem]) -> Vec<Warning> {
819    gems.iter()
820        .filter(|e| match &e.n {
821            ast::Ore::Ru { ps, ts, cs: _ } => ts.contains(&".IGNORE".to_string()) && ps.is_empty(),
822            _ => false,
823        })
824        .map(|e| Warning {
825            path: metadata.path.to_string(),
826            line: e.l,
827            message: GLOBAL_IGNORE.to_string(),
828        })
829        .collect()
830}
831
832#[test]
833pub fn test_global_ignore() {
834    assert!(
835        lint(&mock_md("-"), ".POSIX:\n.IGNORE:\n")
836            .unwrap()
837            .into_iter()
838            .map(|e| e.message)
839            .collect::<Vec<String>>()
840            .contains(&GLOBAL_IGNORE.to_string())
841    );
842
843    assert!(
844        !lint(
845            &mock_md("-"),
846            ".POSIX:\n.PHONY: clean\n.IGNORE: clean\nclean:\n\trm -rf bin"
847        )
848        .unwrap()
849        .into_iter()
850        .map(|e| e.message)
851        .collect::<Vec<String>>()
852        .contains(&GLOBAL_IGNORE.to_string())
853    );
854
855    assert!(
856        !lint(
857            &mock_md("-"),
858            ".POSIX:\n.PHONY: clean\nclean:\n\t-rm -rf bin"
859        )
860        .unwrap()
861        .into_iter()
862        .map(|e| e.message)
863        .collect::<Vec<String>>()
864        .contains(&GLOBAL_IGNORE.to_string())
865    );
866}
867
868pub static SIMPLIFY_AT: &str =
869    "SIMPLIFY_AT: replace individual at (@) signs with .SILENT target declaration(s)";
870
871/// check_simplify_at reports SIMPLIFY_AT violations.
872fn check_simplify_at(metadata: &inspect::Metadata, gems: &[ast::Gem]) -> Vec<Warning> {
873    let mut has_global_silence: bool = false;
874    let mut marked_silent_targets: HashSet<&String> = HashSet::new();
875
876    for gem in gems {
877        if let ast::Ore::Ru { ps, ts, cs: _ } = &gem.n {
878            if ts.contains(&".SILENT".to_string()) {
879                if ps.is_empty() {
880                    has_global_silence = true;
881                }
882
883                for p in ps {
884                    marked_silent_targets.insert(p);
885                }
886            }
887        }
888    }
889
890    if has_global_silence {
891        return Vec::new();
892    }
893
894    gems.iter()
895        .filter(|e| match &e.n {
896            ast::Ore::Ru { ps: _, ts, cs } => {
897                cs.len() > 1
898                    && cs.iter().all(|e2| e2.starts_with('@'))
899                    && !ts.iter().any(|e2| marked_silent_targets.contains(e2))
900            }
901            _ => false,
902        })
903        .map(|e| Warning {
904            path: metadata.path.to_string(),
905            line: e.l,
906            message: SIMPLIFY_AT.to_string(),
907        })
908        .collect()
909}
910
911#[test]
912pub fn test_simplify_at() {
913    assert!(
914        lint(
915            &mock_md("-"),
916            ".POSIX:\nwelcome:\n\t@echo foo\n\t@echo bar\n\t@echo baz\n"
917        )
918        .unwrap()
919        .into_iter()
920        .map(|e| e.message)
921        .collect::<Vec<String>>()
922        .contains(&SIMPLIFY_AT.to_string())
923    );
924
925    assert!(
926        !lint(
927            &mock_md("-"),
928            ".POSIX:\nwelcome:\n\t@echo foo\n\t@echo bar\n\techo baz\n"
929        )
930        .unwrap()
931        .into_iter()
932        .map(|e| e.message)
933        .collect::<Vec<String>>()
934        .contains(&SIMPLIFY_AT.to_string())
935    );
936
937    assert!(
938        !lint(
939            &mock_md("-"),
940            ".POSIX:\n.SILENT: welcome\nwelcome:\n\techo foo\n\techo bar\n\techo baz\n"
941        )
942        .unwrap()
943        .into_iter()
944        .map(|e| e.message)
945        .collect::<Vec<String>>()
946        .contains(&SIMPLIFY_AT.to_string())
947    );
948}
949
950pub static SIMPLIFY_MINUS: &str =
951    "SIMPLIFY_MINUS: replace individual hyphen-minus (-) signs with .IGNORE target declaration(s)";
952
953/// check_simplify_minus reports SIMPLIFY_MINUS violations.
954fn check_simplify_minus(metadata: &inspect::Metadata, gems: &[ast::Gem]) -> Vec<Warning> {
955    let mut has_global_ignore: bool = false;
956    let mut marked_ignored_targets: HashSet<&String> = HashSet::new();
957
958    for gem in gems {
959        if let ast::Ore::Ru { ps, ts, cs: _ } = &gem.n {
960            if ts.contains(&".IGNORE".to_string()) {
961                if ps.is_empty() {
962                    has_global_ignore = true;
963                }
964
965                for p in ps {
966                    marked_ignored_targets.insert(p);
967                }
968            }
969        }
970    }
971
972    if has_global_ignore {
973        return Vec::new();
974    }
975
976    gems.iter()
977        .filter(|e| match &e.n {
978            ast::Ore::Ru { ps: _, ts, cs } => {
979                cs.len() > 1
980                    && cs.iter().all(|e2| e2.starts_with('-'))
981                    && !ts.iter().any(|e2| marked_ignored_targets.contains(e2))
982            }
983            _ => false,
984        })
985        .map(|e| Warning {
986            path: metadata.path.to_string(),
987            line: e.l,
988            message: SIMPLIFY_MINUS.to_string(),
989        })
990        .collect()
991}
992
993#[test]
994pub fn test_simplify_minus() {
995    assert!(
996        lint(
997            &mock_md("-"),
998            ".POSIX:\nwelcome:\n\t-echo foo\n\t-echo bar\n\t-echo baz\n"
999        )
1000        .unwrap()
1001        .into_iter()
1002        .map(|e| e.message)
1003        .collect::<Vec<String>>()
1004        .contains(&SIMPLIFY_MINUS.to_string())
1005    );
1006
1007    assert!(
1008        !lint(
1009            &mock_md("-"),
1010            ".POSIX:\nwelcome:\n\t-echo foo\n\t-echo bar\n\techo baz\n"
1011        )
1012        .unwrap()
1013        .into_iter()
1014        .map(|e| e.message)
1015        .collect::<Vec<String>>()
1016        .contains(&SIMPLIFY_MINUS.to_string())
1017    );
1018
1019    assert!(
1020        !lint(
1021            &mock_md("-"),
1022            ".POSIX:\n.IGNORE: welcome\nwelcome:\n\techo foo\n\techo bar\n\techo baz\n"
1023        )
1024        .unwrap()
1025        .into_iter()
1026        .map(|e| e.message)
1027        .collect::<Vec<String>>()
1028        .contains(&SIMPLIFY_MINUS.to_string())
1029    );
1030}
1031
1032pub static STRICT_POSIX: &str = "STRICT_POSIX: lead makefiles with the \".POSIX:\" compliance marker, or else rename include files like *.include.mk";
1033
1034/// check_strict_posix reports STRICT_POSIX violations.
1035fn check_strict_posix(metadata: &inspect::Metadata, gems: &[ast::Gem]) -> Vec<Warning> {
1036    if metadata.is_include_file {
1037        return Vec::new();
1038    }
1039
1040    let has_strict_posix: bool = gems.iter().any(|e| match &e.n {
1041        ast::Ore::Ru { ps: _, ts, cs: _ } => ts.contains(&".POSIX".to_string()),
1042        _ => false,
1043    });
1044
1045    if !has_strict_posix {
1046        return vec![Warning {
1047            path: metadata.path.to_string(),
1048            line: 1,
1049            message: STRICT_POSIX.to_string(),
1050        }];
1051    }
1052
1053    Vec::new()
1054}
1055
1056#[test]
1057pub fn test_strict_posix() {
1058    let md_stdin: inspect::Metadata = mock_md("-");
1059
1060    assert!(
1061        lint(&md_stdin, "PKG = curl\n")
1062            .unwrap()
1063            .into_iter()
1064            .map(|e| e.message)
1065            .collect::<Vec<String>>()
1066            .contains(&STRICT_POSIX.to_string())
1067    );
1068
1069    assert!(
1070        !lint(&md_stdin, ".POSIX:\nPKG = curl\n")
1071            .unwrap()
1072            .into_iter()
1073            .map(|e| e.message)
1074            .collect::<Vec<String>>()
1075            .contains(&STRICT_POSIX.to_string())
1076    );
1077
1078    let mut md_sys: inspect::Metadata = mock_md("sys.mk");
1079    md_sys.is_include_file = true;
1080
1081    assert!(
1082        !lint(&md_sys, "PKG = curl\n")
1083            .unwrap()
1084            .into_iter()
1085            .map(|e| e.message)
1086            .collect::<Vec<String>>()
1087            .contains(&STRICT_POSIX.to_string())
1088    );
1089
1090    let mut md_include_mk: inspect::Metadata = mock_md("foo.include.mk");
1091    md_include_mk.is_include_file = true;
1092
1093    assert!(
1094        !lint(&md_include_mk, "PKG = curl\n")
1095            .unwrap()
1096            .into_iter()
1097            .map(|e| e.message)
1098            .collect::<Vec<String>>()
1099            .contains(&STRICT_POSIX.to_string())
1100    );
1101}
1102
1103pub static IMPLEMENTATTION_DEFINED_TARGET: &str = "IMPLEMENTATTION_DEFINED_TARGET: non-portable percent (%) or double-quote (\") in target or prerequisite";
1104
1105/// check_implementation_defined_target reports IMPLEMENTATTION_DEFINED_TARGET violations.
1106fn check_implementation_defined_target(
1107    metadata: &inspect::Metadata,
1108    gems: &[ast::Gem],
1109) -> Vec<Warning> {
1110    gems.iter()
1111        .filter(|e| match &e.n {
1112            ast::Ore::Ru { ps, ts, cs: _ } => {
1113                ps.iter().any(|e2| e2.contains('%') || e2.contains('\"'))
1114                    || ts.iter().any(|e2| e2.contains('%') || e2.contains('\"'))
1115            }
1116            _ => false,
1117        })
1118        .map(|e| Warning {
1119            path: metadata.path.to_string(),
1120            line: e.l,
1121            message: IMPLEMENTATTION_DEFINED_TARGET.to_string(),
1122        })
1123        .collect()
1124}
1125
1126#[test]
1127pub fn test_implementation_defined_target() {
1128    assert!(
1129        lint(
1130            &mock_md("-"),
1131            ".POSIX:\n.PHONY: all\nall: foo%\nfoo%: foo.c\n\tgcc -o foo% foo.c\n"
1132        )
1133        .unwrap()
1134        .into_iter()
1135        .map(|e| e.message)
1136        .collect::<Vec<String>>()
1137        .contains(&IMPLEMENTATTION_DEFINED_TARGET.to_string())
1138    );
1139
1140    assert!(
1141        lint(
1142            &mock_md("-"),
1143            ".POSIX:\n.PHONY: all\nall: \"foo\"\n\"foo\": foo.c\n\tgcc -o \"foo\" foo.c\n"
1144        )
1145        .unwrap()
1146        .into_iter()
1147        .map(|e| e.message)
1148        .collect::<Vec<String>>()
1149        .contains(&IMPLEMENTATTION_DEFINED_TARGET.to_string())
1150    );
1151
1152    assert!(
1153        !lint(
1154            &mock_md("-"),
1155            ".POSIX:\n.PHONY: all\nall: foo\nfoo: foo.c\n\tgcc -o foo foo.c\n"
1156        )
1157        .unwrap()
1158        .into_iter()
1159        .map(|e| e.message)
1160        .collect::<Vec<String>>()
1161        .contains(&IMPLEMENTATTION_DEFINED_TARGET.to_string())
1162    );
1163}
1164
1165pub static COMMAND_COMMENT: &str =
1166    "COMMAND_COMMENT: comment embedded inside commands will forward to the shell interpreter";
1167
1168/// check_command_comment reports COMMAND_COMMENT violations.
1169fn check_command_comment(metadata: &inspect::Metadata, gems: &[ast::Gem]) -> Vec<Warning> {
1170    gems.iter()
1171        .filter(|e| match &e.n {
1172            ast::Ore::Ru { ps: _, ts: _, cs } => cs.iter().any(|e2| e2.contains('#')),
1173            _ => false,
1174        })
1175        .map(|e| Warning {
1176            path: metadata.path.to_string(),
1177            line: e.l,
1178            message: COMMAND_COMMENT.to_string(),
1179        })
1180        .collect()
1181}
1182
1183#[test]
1184pub fn test_command_comment() {
1185    assert!(
1186        lint(
1187            &mock_md("-"),
1188            ".POSIX:\nfoo: foo.c\n\t#build foo\n\tgcc -o foo foo.c\n"
1189        )
1190        .unwrap()
1191        .into_iter()
1192        .map(|e| e.message)
1193        .collect::<Vec<String>>()
1194        .contains(&COMMAND_COMMENT.to_string())
1195    );
1196
1197    assert!(
1198        lint(&mock_md("-"), ".POSIX:\nfoo: foo.c\n\t@#gcc -o foo foo.c\n")
1199            .unwrap()
1200            .into_iter()
1201            .map(|e| e.message)
1202            .collect::<Vec<String>>()
1203            .contains(&COMMAND_COMMENT.to_string())
1204    );
1205
1206    assert!(
1207        lint(&mock_md("-"), ".POSIX:\nfoo: foo.c\n\t-#gcc -o foo foo.c\n")
1208            .unwrap()
1209            .into_iter()
1210            .map(|e| e.message)
1211            .collect::<Vec<String>>()
1212            .contains(&COMMAND_COMMENT.to_string())
1213    );
1214
1215    assert!(
1216        lint(&mock_md("-"), ".POSIX:\nfoo: foo.c\n\t+#gcc -o foo foo.c\n")
1217            .unwrap()
1218            .into_iter()
1219            .map(|e| e.message)
1220            .collect::<Vec<String>>()
1221            .contains(&COMMAND_COMMENT.to_string())
1222    );
1223
1224    assert!(
1225        lint(
1226            &mock_md("-"),
1227            ".POSIX:\nfoo: foo.c\n\tgcc \\\n#output file \\\n\t\t-o foo \\\n\t\tfoo.c\n"
1228        )
1229        .unwrap()
1230        .into_iter()
1231        .map(|e| e.message)
1232        .collect::<Vec<String>>()
1233        .contains(&COMMAND_COMMENT.to_string())
1234    );
1235
1236    assert!(
1237        !lint(
1238            &mock_md("-"),
1239            ".POSIX:\nfoo: foo.c\n#build foo\n\tgcc -o foo foo.c\n"
1240        )
1241        .unwrap()
1242        .into_iter()
1243        .map(|e| e.message)
1244        .collect::<Vec<String>>()
1245        .contains(&COMMAND_COMMENT.to_string())
1246    );
1247}
1248
1249pub static REPEATED_COMMAND_PREFIX: &str =
1250    "REPEATED_COMMAND_PREFIX: redundant prefixes are superfluous";
1251
1252/// check_repeated_command_prefix reports REPEATED_COMMAND_PREFIX violations.
1253fn check_repeated_command_prefix(metadata: &inspect::Metadata, gems: &[ast::Gem]) -> Vec<Warning> {
1254    gems.iter()
1255        .filter(|e| match &e.n {
1256            ast::Ore::Ru { ps: _, ts: _, cs } => cs.iter().any(|e2| {
1257                if BLANK_COMMAND_PATTERN.is_match(e2) {
1258                    return false;
1259                }
1260
1261                let prefix: &str = COMMAND_PREFIX_PATTERN
1262                    .captures(e2)
1263                    .and_then(|e3| e3.name("prefix"))
1264                    .map(|e3| e3.as_str())
1265                    .unwrap_or("");
1266
1267                prefix.matches('@').count() > 1
1268                    || prefix.matches('+').count() > 1
1269                    || prefix.matches('-').count() > 1
1270            }),
1271            _ => false,
1272        })
1273        .map(|e| Warning {
1274            path: metadata.path.to_string(),
1275            line: e.l,
1276            message: REPEATED_COMMAND_PREFIX.to_string(),
1277        })
1278        .collect()
1279}
1280
1281#[test]
1282pub fn test_repeated_command_prefix() {
1283    assert!(
1284        lint(
1285            &mock_md("-"),
1286            ".POSIX:\n.PHONY: test\ntest:\n\t@@echo \"Hello World!\"\n"
1287        )
1288        .unwrap()
1289        .into_iter()
1290        .map(|e| e.message)
1291        .collect::<Vec<String>>()
1292        .contains(&REPEATED_COMMAND_PREFIX.to_string())
1293    );
1294
1295    assert!(
1296        lint(
1297            &mock_md("-"),
1298            ".POSIX:\n.PHONY: test\ntest:\n\t--echo \"Hello World!\"\n"
1299        )
1300        .unwrap()
1301        .into_iter()
1302        .map(|e| e.message)
1303        .collect::<Vec<String>>()
1304        .contains(&REPEATED_COMMAND_PREFIX.to_string())
1305    );
1306
1307    assert!(
1308        lint(
1309            &mock_md("-"),
1310            ".POSIX:\n.PHONY: test\ntest:\n\t@-@echo \"Hello World!\"\n"
1311        )
1312        .unwrap()
1313        .into_iter()
1314        .map(|e| e.message)
1315        .collect::<Vec<String>>()
1316        .contains(&REPEATED_COMMAND_PREFIX.to_string())
1317    );
1318
1319    assert!(
1320        !lint(
1321            &mock_md("-"),
1322            ".POSIX:\n.PHONY: test\ntest:\n\t@+-echo \"Hello World!\"\n"
1323        )
1324        .unwrap()
1325        .into_iter()
1326        .map(|e| e.message)
1327        .collect::<Vec<String>>()
1328        .contains(&REPEATED_COMMAND_PREFIX.to_string())
1329    );
1330}
1331
1332pub static BLANK_COMMAND: &str = "BLANK_COMMAND: indeterminate behavior when empty commands are sent to assorted shell interpreters";
1333
1334/// check_blank_command reports BLANK_COMMAND violations.
1335fn check_blank_command(metadata: &inspect::Metadata, gems: &[ast::Gem]) -> Vec<Warning> {
1336    gems.iter()
1337        .filter(|e| match &e.n {
1338            ast::Ore::Ru { ps: _, ts: _, cs } => {
1339                cs.iter().any(|e2| BLANK_COMMAND_PATTERN.is_match(e2))
1340            }
1341            _ => false,
1342        })
1343        .map(|e| Warning {
1344            path: metadata.path.to_string(),
1345            line: e.l,
1346            message: BLANK_COMMAND.to_string(),
1347        })
1348        .collect()
1349}
1350
1351#[test]
1352pub fn test_blank_command() {
1353    assert!(
1354        lint(&mock_md("-"), ".POSIX:\n.PHONY: test\ntest:\n\t@\n")
1355            .unwrap()
1356            .into_iter()
1357            .map(|e| e.message)
1358            .collect::<Vec<String>>()
1359            .contains(&BLANK_COMMAND.to_string())
1360    );
1361
1362    assert!(
1363        lint(&mock_md("-"), ".POSIX:\n.PHONY: test\ntest:\n\t-\n")
1364            .unwrap()
1365            .into_iter()
1366            .map(|e| e.message)
1367            .collect::<Vec<String>>()
1368            .contains(&BLANK_COMMAND.to_string())
1369    );
1370
1371    assert!(
1372        lint(&mock_md("-"), ".POSIX:\n.PHONY: test\ntest:\n\t+\n")
1373            .unwrap()
1374            .into_iter()
1375            .map(|e| e.message)
1376            .collect::<Vec<String>>()
1377            .contains(&BLANK_COMMAND.to_string())
1378    );
1379
1380    assert!(
1381        lint(&mock_md("-"), ".POSIX:\n.PHONY: test\ntest:\n\t@+- \n")
1382            .unwrap()
1383            .into_iter()
1384            .map(|e| e.message)
1385            .collect::<Vec<String>>()
1386            .contains(&BLANK_COMMAND.to_string())
1387    );
1388
1389    assert!(
1390        !lint(
1391            &mock_md("-"),
1392            ".POSIX:\n.PHONY: test\ntest:\n\techo \"Hello World!\"\n"
1393        )
1394        .unwrap()
1395        .into_iter()
1396        .map(|e| e.message)
1397        .collect::<Vec<String>>()
1398        .contains(&BLANK_COMMAND.to_string())
1399    );
1400}
1401
1402pub static WHITESPACE_LEADING_COMMAND: &str =
1403    "WHITESPACE_LEADING_COMMAND: questionable whitespace detected at the start of a command";
1404
1405/// check_whitespace_leading_command reports WHITESPACE_LEADING_COMMAND violations.
1406fn check_whitespace_leading_command(
1407    metadata: &inspect::Metadata,
1408    gems: &[ast::Gem],
1409) -> Vec<Warning> {
1410    gems.iter()
1411        .filter(|e| match &e.n {
1412            ast::Ore::Ru { ps: _, ts: _, cs } => cs
1413                .iter()
1414                .any(|e2| WHITESPACE_LEADING_COMMAND_PATTERN.is_match(e2)),
1415            _ => false,
1416        })
1417        .map(|e| Warning {
1418            path: metadata.path.to_string(),
1419            line: e.l,
1420            message: WHITESPACE_LEADING_COMMAND.to_string(),
1421        })
1422        .collect()
1423}
1424
1425#[test]
1426pub fn test_whitespace_leading_command() {
1427    assert!(
1428        lint(&mock_md("-"), "foo:\n\t gcc -o foo foo.c\n")
1429            .unwrap()
1430            .into_iter()
1431            .map(|e| e.message)
1432            .collect::<Vec<String>>()
1433            .contains(&WHITESPACE_LEADING_COMMAND.to_string())
1434    );
1435
1436    assert!(
1437        lint(&mock_md("-"), "foo:\n\t\tgcc -o foo foo.c\n")
1438            .unwrap()
1439            .into_iter()
1440            .map(|e| e.message)
1441            .collect::<Vec<String>>()
1442            .contains(&WHITESPACE_LEADING_COMMAND.to_string())
1443    );
1444
1445    assert!(
1446        lint(&mock_md("-"), "foo:\n\t@+- gcc -o foo foo.c\n")
1447            .unwrap()
1448            .into_iter()
1449            .map(|e| e.message)
1450            .collect::<Vec<String>>()
1451            .contains(&WHITESPACE_LEADING_COMMAND.to_string())
1452    );
1453
1454    assert!(
1455        !lint(&mock_md("-"), "foo:\n\tgcc -o foo foo.c\n")
1456            .unwrap()
1457            .into_iter()
1458            .map(|e| e.message)
1459            .collect::<Vec<String>>()
1460            .contains(&WHITESPACE_LEADING_COMMAND.to_string())
1461    );
1462
1463    assert!(
1464        !lint(&mock_md("-"), "foo:\n\t@+-gcc -o foo foo.c\n")
1465            .unwrap()
1466            .into_iter()
1467            .map(|e| e.message)
1468            .collect::<Vec<String>>()
1469            .contains(&WHITESPACE_LEADING_COMMAND.to_string())
1470    );
1471
1472    assert!(
1473        !lint(&mock_md("-"), "foo:\n\tgcc \\\n\t\t-o \\\n\t\tfoo foo.c\n")
1474            .unwrap()
1475            .into_iter()
1476            .map(|e| e.message)
1477            .collect::<Vec<String>>()
1478            .contains(&WHITESPACE_LEADING_COMMAND.to_string())
1479    );
1480}
1481
1482pub static MISSING_FINAL_EOL: &str =
1483    "MISSING_FINAL_EOL: UNIX text files may process poorly without a final LF";
1484
1485/// check_final_eol reports MISSING_FINAL_EOL violations.
1486fn check_final_eol(metadata: &inspect::Metadata, _: &[ast::Gem]) -> Vec<Warning> {
1487    if !metadata.is_empty && !metadata.has_final_eol {
1488        return vec![Warning {
1489            path: metadata.path.to_string(),
1490            line: metadata.lines,
1491            message: MISSING_FINAL_EOL.to_string(),
1492        }];
1493    }
1494
1495    Vec::new()
1496}
1497
1498#[test]
1499pub fn test_final_eol() {
1500    let mf_pkg: &str = ".POSIX:\nPKG = curl";
1501    let mut md_pkg: inspect::Metadata = mock_md("-");
1502    md_pkg.is_empty = &mf_pkg.len() == &0;
1503    md_pkg.has_final_eol = &mf_pkg.chars().last().unwrap_or(' ') == &'\n';
1504
1505    assert!(
1506        lint(&md_pkg, &mf_pkg)
1507            .unwrap()
1508            .into_iter()
1509            .map(|e| e.message)
1510            .collect::<Vec<String>>()
1511            .contains(&MISSING_FINAL_EOL.to_string())
1512    );
1513
1514    let mf_pkg_final_eol: &str = ".POSIX:\nPKG = curl\n";
1515    let mut md_pkg_final_eol: inspect::Metadata = mock_md("-");
1516    md_pkg_final_eol.is_empty = &mf_pkg_final_eol.len() == &0;
1517    md_pkg_final_eol.has_final_eol = &mf_pkg_final_eol.chars().last().unwrap_or(' ') == &'\n';
1518
1519    assert!(
1520        !lint(&md_pkg_final_eol, &mf_pkg_final_eol)
1521            .unwrap()
1522            .into_iter()
1523            .map(|e| e.message)
1524            .collect::<Vec<String>>()
1525            .contains(&MISSING_FINAL_EOL.to_string())
1526    );
1527
1528    let mf_empty: &str = "";
1529    let mut md_empty: inspect::Metadata = mock_md("-");
1530    md_empty.is_empty = &mf_empty.len() == &0;
1531    md_empty.has_final_eol = &mf_empty.chars().last().unwrap_or(' ') == &'\n';
1532
1533    assert!(
1534        !lint(&md_empty, &mf_empty)
1535            .unwrap()
1536            .into_iter()
1537            .map(|e| e.message)
1538            .collect::<Vec<String>>()
1539            .contains(&MISSING_FINAL_EOL.to_string())
1540    );
1541}
1542
1543pub static PHONY_TARGET: &str = "PHONY_TARGET: mark common artifactless rules as .PHONY";
1544
1545/// check_phony_target reports PHONY_TARGET violations.
1546fn check_phony_target(metadata: &inspect::Metadata, gems: &[ast::Gem]) -> Vec<Warning> {
1547    let mut marked_phony_targets: HashSet<&String> = HashSet::new();
1548    for gem in gems {
1549        if let ast::Ore::Ru { ps, ts, cs: _ } = &gem.n {
1550            if ts.contains(&".PHONY".to_string()) {
1551                for p in ps {
1552                    marked_phony_targets.insert(p);
1553                }
1554            }
1555        }
1556    }
1557
1558    gems.iter()
1559        .filter(|e| match &e.n {
1560            ast::Ore::Ru { ps: _, ts, cs: _ }
1561                if !ts.iter().any(|e2| ast::SPECIAL_TARGETS.contains(e2))
1562                    && !marked_phony_targets.iter().any(|e2| e2.starts_with('$'))
1563                    && ts.iter().any(|e2| !marked_phony_targets.contains(e2)) =>
1564            {
1565                ts.iter().any(|e2| {
1566                    LOWER_CONVENTIONAL_PHONY_TARGETS_PATTERN.is_match(e2.to_lowercase().as_str())
1567                })
1568            }
1569            _ => false,
1570        })
1571        .map(|e| Warning {
1572            path: metadata.path.to_string(),
1573            line: e.l,
1574            message: PHONY_TARGET.to_string(),
1575        })
1576        .collect()
1577}
1578
1579#[test]
1580pub fn test_phony_target() {
1581    assert!(
1582        lint(&mock_md("-"), ".POSIX:\nall:\n\techo \"Hello World!\"\n")
1583            .unwrap()
1584            .into_iter()
1585            .map(|e| e.message)
1586            .collect::<Vec<String>>()
1587            .contains(&PHONY_TARGET.to_string())
1588    );
1589
1590    assert!(
1591        lint(
1592            &mock_md("-"),
1593            ".POSIX:\nlint:;\ninstall:;\nuninstall:;\npublish:;\n"
1594        )
1595        .unwrap()
1596        .into_iter()
1597        .map(|e| e.message)
1598        .collect::<Vec<String>>()
1599        .contains(&PHONY_TARGET.to_string())
1600    );
1601
1602    assert!(
1603        !lint(
1604            &mock_md("-"),
1605            ".POSIX:\n.PHONY: all\nall:\n\techo \"Hello World!\"\n"
1606        )
1607        .unwrap()
1608        .into_iter()
1609        .map(|e| e.message)
1610        .collect::<Vec<String>>()
1611        .contains(&PHONY_TARGET.to_string())
1612    );
1613
1614    assert!(
1615        lint(
1616            &mock_md("-"),
1617            ".POSIX:\ntest: test-1 test-2\ntest-1:\n\techo \"Hello World!\"\ntest-2:\n\techo \"Hi World!\"\n"
1618        )
1619        .unwrap()
1620        .into_iter()
1621        .map(|e| e.message)
1622        .collect::<Vec<String>>()
1623        .contains(&PHONY_TARGET.to_string()));
1624
1625    assert!(
1626        !lint(
1627            &mock_md("-"),
1628            ".POSIX:\n.PHONY: test test-1 test-2\ntest: test-1 test-2\ntest-1:\n\techo \"Hello World!\"\ntest-2:\n\techo \"Hi World!\"\n"
1629        )
1630        .unwrap()
1631        .into_iter()
1632        .map(|e| e.message)
1633        .collect::<Vec<String>>()
1634        .contains(&PHONY_TARGET.to_string()));
1635
1636    assert!(
1637        !lint(
1638            &mock_md("-"),
1639            ".POSIX:\n.PHONY: test\ntest: test-1 test-2\n.PHONY: test-1\ntest-1:\n\techo \"Hello World!\"\n.PHONY: test-2\ntest-2:\n\techo \"Hi World!\"\n"
1640        )
1641        .unwrap()
1642        .into_iter()
1643        .map(|e| e.message)
1644        .collect::<Vec<String>>()
1645        .contains(&PHONY_TARGET.to_string()));
1646
1647    assert!(
1648        lint(&mock_md("-"), ".POSIX:\nclean:\n\t-rm -rf bin\n")
1649            .unwrap()
1650            .into_iter()
1651            .map(|e| e.message)
1652            .collect::<Vec<String>>()
1653            .contains(&PHONY_TARGET.to_string())
1654    );
1655
1656    assert!(
1657        !lint(
1658            &mock_md("-"),
1659            ".POSIX:\n.PHONY: clean\nclean:\n\t-rm -rf bin\n"
1660        )
1661        .unwrap()
1662        .into_iter()
1663        .map(|e| e.message)
1664        .collect::<Vec<String>>()
1665        .contains(&PHONY_TARGET.to_string())
1666    );
1667
1668    assert!(
1669        !lint(&mock_md("-"), ".POSIX:\nport: cross-compile archive\n")
1670            .unwrap()
1671            .into_iter()
1672            .map(|e| e.message)
1673            .collect::<Vec<String>>()
1674            .contains(&PHONY_TARGET.to_string())
1675    );
1676
1677    assert!(
1678        !lint(
1679            &mock_md("-"),
1680            ".POSIX:\n.PHONY: port\nport: cross-compile archive\n"
1681        )
1682        .unwrap()
1683        .into_iter()
1684        .map(|e| e.message)
1685        .collect::<Vec<String>>()
1686        .contains(&PHONY_TARGET.to_string())
1687    );
1688
1689    assert!(
1690        !lint(&mock_md("-"), ".POSIX:\nempty:;\n")
1691            .unwrap()
1692            .into_iter()
1693            .map(|e| e.message)
1694            .collect::<Vec<String>>()
1695            .contains(&PHONY_TARGET.to_string())
1696    );
1697
1698    // Ensure that absent targets do not trigger a PHONY_TARGET warning,
1699    // unlike some makefile linters in the past.
1700    assert!(
1701        !lint(&mock_md("-"), ".POSIX:\nPKG = curl\n")
1702            .unwrap()
1703            .into_iter()
1704            .map(|e| e.message)
1705            .collect::<Vec<String>>()
1706            .contains(&PHONY_TARGET.to_string())
1707    );
1708
1709    // Ensure that .PHONY declarations featuring variable prerequisites
1710    // do not trigger a PHONY_TARGET warning.
1711    assert!(
1712        !lint(&mock_md("-"), "ALLTARGETS!=ls -a\n.PHONY: $(ALLTARGETS)\nall: welcome\nwelcome:\n\techo \"Hello World!\"\n")
1713            .unwrap()
1714            .into_iter()
1715            .map(|e| e.message)
1716            .collect::<Vec<String>>()
1717            .contains(&PHONY_TARGET.to_string())
1718    );
1719}
1720
1721pub static NO_RULES: &str =
1722    "NO_RULES: declare at least one non-special rule, or else rename to *.include.mk";
1723
1724/// check_no_rules reports NO_RULES violations.
1725fn check_no_rules(metadata: &inspect::Metadata, gems: &[ast::Gem]) -> Vec<Warning> {
1726    if metadata.is_include_file {
1727        return Vec::new();
1728    }
1729
1730    let has_nonspecial_rule: bool = !gems
1731        .iter()
1732        .filter(|e| match &e.n {
1733            ast::Ore::Ru { ps: _, ts, cs: _ } => {
1734                ts.iter().any(|e2| !ast::SPECIAL_TARGETS.contains(e2))
1735            }
1736            _ => false,
1737        })
1738        .collect::<Vec<&ast::Gem>>()
1739        .is_empty();
1740
1741    if !has_nonspecial_rule {
1742        return vec![Warning {
1743            path: metadata.path.to_string(),
1744            line: 0,
1745            message: NO_RULES.to_string(),
1746        }];
1747    }
1748
1749    Vec::new()
1750}
1751
1752#[test]
1753pub fn test_no_rules() {
1754    let md_stdin: inspect::Metadata = mock_md("-");
1755
1756    assert!(
1757        lint(&md_stdin, ".POSIX:\nPKG = curl\n")
1758            .unwrap()
1759            .into_iter()
1760            .map(|e| e.message)
1761            .collect::<Vec<String>>()
1762            .contains(&NO_RULES.to_string())
1763    );
1764
1765    let mut md_include: inspect::Metadata = mock_md("foo.include.mk");
1766    md_include.is_include_file = true;
1767
1768    assert!(
1769        !lint(&md_include, "PKG = curl\n")
1770            .unwrap()
1771            .into_iter()
1772            .map(|e| e.message)
1773            .collect::<Vec<String>>()
1774            .contains(&NO_RULES.to_string())
1775    );
1776
1777    assert!(
1778        !lint(&md_stdin, "all:\n\techo \"Hello World!\"\n")
1779            .unwrap()
1780            .into_iter()
1781            .map(|e| e.message)
1782            .collect::<Vec<String>>()
1783            .contains(&NO_RULES.to_string())
1784    );
1785}
1786
1787pub static RULE_ALL: &str = "RULE_ALL: makefiles conventionally name the first non-special, default rule \"all\", excepting certain *.include.mk files";
1788
1789/// check_rule_all reports RULE_ALL violations.
1790fn check_rule_all(metadata: &inspect::Metadata, gems: &[ast::Gem]) -> Vec<Warning> {
1791    if metadata.is_include_file {
1792        return Vec::new();
1793    }
1794
1795    let mut first_nonspecial_target: &str = "";
1796    let mut found_nonspecial_target: bool = false;
1797
1798    for gem in gems {
1799        match &gem.n {
1800            ast::Ore::Ru { ps: _, ts, cs: _ }
1801                if !ts.is_empty() && ts.iter().all(|e2| !ast::SPECIAL_TARGETS.contains(e2)) =>
1802            {
1803                found_nonspecial_target = true;
1804                first_nonspecial_target = ts.first().unwrap();
1805                break;
1806            }
1807            _ => (),
1808        }
1809    }
1810
1811    if found_nonspecial_target && first_nonspecial_target != "all" {
1812        return vec![Warning {
1813            path: metadata.path.to_string(),
1814            line: 0,
1815            message: RULE_ALL.to_string(),
1816        }];
1817    }
1818
1819    Vec::new()
1820}
1821
1822#[test]
1823pub fn test_rule_all() {
1824    assert!(
1825        lint(&mock_md("-"), "build:\n\techo \"Hello World!\"\n")
1826            .unwrap()
1827            .into_iter()
1828            .map(|e| e.message)
1829            .collect::<Vec<String>>()
1830            .contains(&RULE_ALL.to_string())
1831    );
1832
1833    assert!(
1834        !lint(&mock_md("-"), "all:\n\techo \"Hello World!\"\n")
1835            .unwrap()
1836            .into_iter()
1837            .map(|e| e.message)
1838            .collect::<Vec<String>>()
1839            .contains(&RULE_ALL.to_string())
1840    );
1841
1842    assert!(
1843        !lint(
1844            &mock_md("-"),
1845            "all: build\nbuild:\n\techo \"Hello World!\"\n"
1846        )
1847        .unwrap()
1848        .into_iter()
1849        .map(|e| e.message)
1850        .collect::<Vec<String>>()
1851        .contains(&RULE_ALL.to_string())
1852    );
1853
1854    assert!(
1855        !lint(&mock_md("-"), "PKG = curl\n")
1856            .unwrap()
1857            .into_iter()
1858            .map(|e| e.message)
1859            .collect::<Vec<String>>()
1860            .contains(&RULE_ALL.to_string())
1861    );
1862
1863    let mut md_include = mock_md("foo.include.mk");
1864    md_include.is_include_file = true;
1865
1866    assert!(
1867        !lint(&md_include, "build:\n\techo \"Hello World!\"\n")
1868            .unwrap()
1869            .into_iter()
1870            .map(|e| e.message)
1871            .collect::<Vec<String>>()
1872            .contains(&RULE_ALL.to_string())
1873    );
1874}
1875
1876pub static RESERVED_TARGET: &str =
1877    "RESERVED_TARGET: non-special targets named like \".(A-Z)\"... are reserved";
1878
1879/// check_reserved_target reports RESERVED_TARGET violations.
1880fn check_reserved_target(metadata: &inspect::Metadata, gems: &[ast::Gem]) -> Vec<Warning> {
1881    gems.iter()
1882        .filter(|e| match &e.n {
1883            ast::Ore::Ru { ts, ps, cs: _ } => [&ts[..], &ps[..]].concat().iter().any(|e2| {
1884                RESERVED_TARGET_PATTERN.is_match(e2) && !ast::SPECIAL_TARGETS.contains(e2)
1885            }),
1886            _ => false,
1887        })
1888        .map(|e| Warning {
1889            path: metadata.path.to_string(),
1890            line: e.l,
1891            message: RESERVED_TARGET.to_string(),
1892        })
1893        .collect()
1894}
1895
1896#[test]
1897fn test_reserved_target() {
1898    assert!(lint(&mock_md("-"), ".POSIXX:\n").is_err());
1899
1900    assert!(
1901        lint(&mock_md("-"), ".PHONYY: all\n")
1902            .unwrap()
1903            .into_iter()
1904            .map(|e| e.message)
1905            .collect::<Vec<String>>()
1906            .contains(&RESERVED_TARGET.to_string())
1907    );
1908
1909    assert!(
1910        lint(&mock_md("-"), ".TEST:\n\techo \"Hello World!\"\n")
1911            .unwrap()
1912            .into_iter()
1913            .map(|e| e.message)
1914            .collect::<Vec<String>>()
1915            .contains(&RESERVED_TARGET.to_string())
1916    );
1917
1918    assert!(
1919        lint(&mock_md("-"), "test: .TEST-UNIT .TEST-INTEGRATION\n")
1920            .unwrap()
1921            .into_iter()
1922            .map(|e| e.message)
1923            .collect::<Vec<String>>()
1924            .contains(&RESERVED_TARGET.to_string())
1925    );
1926
1927    assert!(
1928        !lint(&mock_md("-"), "test:\n\t./foo\n")
1929            .unwrap()
1930            .into_iter()
1931            .map(|e| e.message)
1932            .collect::<Vec<String>>()
1933            .contains(&RESERVED_TARGET.to_string())
1934    );
1935}
1936
1937pub static NONPORTABLE_ASSIGNMENT: &str =
1938    "NONPORTABLE_ASSIGNMENT: single colon equals (:=) assignment is nonportable";
1939
1940/// check_portable_assignment reports NONPORTABLE_ASSIGNMENT violations.
1941fn check_portable_assignment(metadata: &inspect::Metadata, gems: &[ast::Gem]) -> Vec<Warning> {
1942    gems.iter()
1943        .filter(|e| match &e.n {
1944            ast::Ore::Mc { n: _, o, v: _ } => o == ":=",
1945            _ => false,
1946        })
1947        .map(|e| Warning {
1948            path: metadata.path.to_string(),
1949            line: e.l,
1950            message: NONPORTABLE_ASSIGNMENT.to_string(),
1951        })
1952        .collect()
1953}
1954
1955#[test]
1956fn test_nonportable_assignment() {
1957    assert!(
1958        lint(&mock_md("-"), "CLIENT:=curl --version\n")
1959            .unwrap()
1960            .into_iter()
1961            .map(|e| e.message)
1962            .collect::<Vec<String>>()
1963            .contains(&NONPORTABLE_ASSIGNMENT.to_string())
1964    );
1965
1966    assert!(
1967        !lint(&mock_md("-"), "CLIENT::=curl --version\n")
1968            .unwrap()
1969            .into_iter()
1970            .map(|e| e.message)
1971            .collect::<Vec<String>>()
1972            .contains(&NONPORTABLE_ASSIGNMENT.to_string())
1973    );
1974
1975    assert!(
1976        !lint(&mock_md("-"), "CLIENT:::=curl --version\n")
1977            .unwrap()
1978            .into_iter()
1979            .map(|e| e.message)
1980            .collect::<Vec<String>>()
1981            .contains(&NONPORTABLE_ASSIGNMENT.to_string())
1982    );
1983
1984    assert!(
1985        !lint(&mock_md("-"), "CLIENT?=curl --version\n")
1986            .unwrap()
1987            .into_iter()
1988            .map(|e| e.message)
1989            .collect::<Vec<String>>()
1990            .contains(&NONPORTABLE_ASSIGNMENT.to_string())
1991    );
1992
1993    assert!(
1994        !lint(&mock_md("-"), "CLIENT!=curl --version\n")
1995            .unwrap()
1996            .into_iter()
1997            .map(|e| e.message)
1998            .collect::<Vec<String>>()
1999            .contains(&NONPORTABLE_ASSIGNMENT.to_string())
2000    );
2001
2002    assert!(
2003        !lint(&mock_md("-"), "CLIENT+=curl --version\n")
2004            .unwrap()
2005            .into_iter()
2006            .map(|e| e.message)
2007            .collect::<Vec<String>>()
2008            .contains(&NONPORTABLE_ASSIGNMENT.to_string())
2009    );
2010
2011    assert!(
2012        !lint(&mock_md("-"), "CLIENT=curl --version\n")
2013            .unwrap()
2014            .into_iter()
2015            .map(|e| e.message)
2016            .collect::<Vec<String>>()
2017            .contains(&NONPORTABLE_ASSIGNMENT.to_string())
2018    );
2019}
2020
2021/// lint generates warnings for a makefile.
2022pub fn lint(metadata: &inspect::Metadata, makefile: &str) -> Result<Vec<Warning>, String> {
2023    let gems: Vec<ast::Gem> = ast::parse_posix(&metadata.path, makefile)?.ns;
2024    let mut warnings: Vec<Warning> = Vec::new();
2025
2026    for check in CHECKS.iter() {
2027        warnings.extend(check(metadata, &gems));
2028    }
2029
2030    Ok(warnings)
2031}
2032
2033#[test]
2034pub fn test_line_numbers() {
2035    let md: inspect::Metadata = mock_md("-");
2036
2037    assert_eq!(
2038        check_ub_late_posix_marker(
2039            &md,
2040            &ast::parse_posix(md.path.as_str(), "PKG=curl\n.POSIX:\n")
2041                .unwrap()
2042                .ns
2043        ),
2044        vec![Warning {
2045            path: WARNING_DEFAULT_PATH.to_string(),
2046            line: 2,
2047            message: UB_LATE_POSIX_MARKER.to_string(),
2048        },]
2049    );
2050}