trex-re 0.3.0

TREX (Token Regular EXpression) is a regex-shaped pattern language over typed tokens, evaluated by derivatives with long-distance binding and no catastrophic backtracking.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
//! `scan --rules`: the rules of pattern files scanned over the inputs through
//! [`trex::rule_scan`], every finding reported under its rule with the
//! message rendered from the match, its severity and its fix: as
//! compiler-style lines, JSON, one SARIF document or GitHub workflow
//! annotations, or with the fixes applied through the rewrite review.

use std::path::Path;
use std::process::ExitCode;

use trex::files::{LineIndex, Source, WalkOptions, collect};
use trex::rule_scan::{Finding, Found, Place, RuleScan, declare_rule_files};

use crate::cli_files::{Editing, across_cores, edit_sources, review_sources};
use crate::cli_window::{Part, Restrict, Windowing, read_part};

/// What `scan --rules` was asked.
pub(crate) struct RulesRun<'a> {
    /// The declarations `--lib` and `--shape` made, which the rule files
    /// add to.
    pub shapes: trex::ShapeSet,
    /// The `--rules` arguments: pattern files, and directories of them.
    pub rules: &'a [String],
    pub paths: Vec<String>,
    pub text: Option<Vec<u8>>,
    pub walk: WalkOptions,
    pub json: bool,
    pub sarif: bool,
    pub github: bool,
    pub format: Option<String>,
    pub count: bool,
    pub files_with_matches: bool,
    pub files_without_match: bool,
    pub max_count: Option<usize>,
    /// `--keep-count`: a followed file truncated, replaced or removed keeps
    /// its `-m` count rather than starting it again.
    pub keep_count: bool,
    pub require_match: bool,
    pub binary: bool,
    pub fix: bool,
    pub dry_run: bool,
    pub interactive: bool,
    /// The lines of context a dry run's diff shows around each fix.
    pub context: usize,
    pub painter: trex::paint::Painter,
    /// `--head`, `--tail`, `--lines` and `--follow`: the part of each input
    /// scanned, and whether each file is followed as it grows.
    pub windowing: Windowing,
    /// What the window counts.
    pub unit: trex::records::RecordUnit,
}

/// What one input came to.
enum Outcome {
    Pending,
    Binary,
    Failed(String),
    Scanned(Found),
}

/// Which report the findings print as.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Form {
    Human,
    Json,
    Sarif,
    Github,
    Format,
    Count,
    FilesWith,
    FilesWithout,
}

pub(crate) fn run(mut run: RulesRun<'_>) -> ExitCode {
    if let Err(e) = declare_rule_files(&mut run.shapes, run.rules) {
        eprintln!("trex: {e}");
        return ExitCode::FAILURE;
    }
    if run.shapes.rules().is_empty() {
        eprintln!("trex scan: {} holds no rule", run.rules.join(", "));
        return ExitCode::FAILURE;
    }
    let scan = match RuleScan::new(run.shapes.clone()) {
        Ok(s) => s,
        Err(e) => {
            eprintln!("trex: {e}");
            return ExitCode::FAILURE;
        }
    };
    let template = match &run.format {
        Some(src) => match trex::Template::parse_report(src, &scan.capture_names()) {
            Ok(t) => Some(t),
            Err(e) => {
                eprintln!("trex: --format error at byte {}: {}", e.pos, e.msg);
                return ExitCode::FAILURE;
            }
        },
        None => None,
    };
    let form = if run.json {
        Form::Json
    } else if run.sarif {
        Form::Sarif
    } else if run.github {
        Form::Github
    } else if template.is_some() {
        Form::Format
    } else if run.count {
        Form::Count
    } else if run.files_with_matches {
        Form::FilesWith
    } else if run.files_without_match {
        Form::FilesWithout
    } else {
        Form::Human
    };
    let report = Report { scan: &scan, form, template: template.as_ref(), painter: &run.painter };

    // An inline string is one input with no name; a window cuts it as it
    // cuts a file.
    if let Some(text) = &run.text {
        if !run.paths.is_empty() {
            eprintln!("trex scan: --text and a path cannot both be given");
            return ExitCode::FAILURE;
        }
        if run.fix {
            eprintln!("trex scan: --fix writes files and takes no --text");
            return ExitCode::FAILURE;
        }
        if run.files_with_matches || run.files_without_match {
            eprintln!("trex scan: -l and -L name files and take no --text");
            return ExitCode::FAILURE;
        }
        if run.windowing.follow {
            eprintln!("trex scan: --follow follows files by name and takes no --text");
            return ExitCode::FAILURE;
        }
        let part = Part::of_text(text.clone(), run.windowing.select, &run.unit);
        let findings = scan.findings(None, &part.text, &part.index(), run.max_count);
        let found = vec![found_of(None, part, findings)];
        let (total, errors) = report.print(&found, false);
        return exit(false, total, errors, run.require_match);
    }

    let a_directory = run.paths.iter().any(|p| Path::new(p).is_dir());
    let (mut sources, walk_errors) = collect(&run.paths, &run.walk);
    if sources.is_empty() && run.paths.is_empty() {
        sources.push(Source::Stdin);
    }
    for e in &walk_errors {
        eprintln!("trex: {e}");
    }
    let mut failed = !walk_errors.is_empty();

    if run.fix {
        if sources.contains(&Source::Stdin) {
            eprintln!("trex scan: --fix writes files and cannot write the standard input");
            return ExitCode::FAILURE;
        }
        let edits_of = |src: &Source, piece: crate::cli_window::Piece<'_>| {
            let name = src.name();
            let (edits, skipped) = scan.fix_edits(&name, piece.text, &piece.index());
            for s in skipped {
                eprintln!(
                    "trex: {name}:{}:{}: the fix of {} overlaps an earlier fix; skipped",
                    s.line,
                    s.col,
                    scan.rules()[s.rule].name
                );
            }
            edits
        };
        let how = Editing {
            failed,
            a_directory,
            binary: run.binary,
            command: "scan",
            verb: "fixes",
            dry_run: run.dry_run,
            context: run.context,
            restrict: Restrict::of(&run.windowing, &run.unit),
        };
        return if run.interactive {
            // A rule's review explains nothing under a fix: what a rule's
            // finding means is its own message, which the report already
            // prints, rather than the axes its pattern read.
            review_sources(&sources, how, None, false, edits_of)
        } else {
            // A rule's fixes carry nothing from one input to the next, so the
            // order the cores finish in is not visible in what they write.
            edit_sources(&sources, how, edits_of)
        };
    }

    // Every report here but the counts and the file lists places a finding
    // on its line and at its offsets, as does a message writing either,
    // which a tail's lines and bytes ahead of it are counted for, and a
    // follow continues the tail's offsets.
    let places = !matches!(form, Form::Count | Form::FilesWith | Form::FilesWithout);
    let numbers = places || scan.messages_read_place();
    let offsets = places || run.windowing.follow || scan.messages_read_offsets();
    let asked = trex::window::Asked { numbers, offsets, binary: run.binary, units: false };
    if run.windowing.follow {
        if sources.contains(&Source::Stdin) {
            eprintln!(
                "trex scan: --follow follows files by name, and the standard input is read as it arrives already; name the file"
            );
            return ExitCode::FAILURE;
        }
        let prefixed = a_directory || sources.len() > 1;
        return crate::cli_follow::follow_rules(&sources, &run, &report, asked, prefixed, failed);
    }
    let mut outcomes: Vec<Outcome> = sources.iter().map(|_| Outcome::Pending).collect();
    across_cores(&mut outcomes, |k, slot| {
        let src = &sources[k];
        let part = match read_part(src, run.windowing.select, &run.unit, asked) {
            Ok(part) => part,
            Err(e) => {
                *slot = Outcome::Failed(format!("cannot read {}: {e}", src.name()));
                return;
            }
        };
        if !run.binary && part.binary {
            *slot = Outcome::Binary;
            return;
        }
        let name = src.name();
        let findings = scan.findings(Some(&name), &part.text, &part.index(), run.max_count);
        *slot = Outcome::Scanned(found_of(Some(name), part, findings));
    });
    let mut found: Vec<Found> = Vec::new();
    for (src, outcome) in sources.iter().zip(outcomes) {
        match outcome {
            Outcome::Pending => {}
            // A file named outright is refused for a NUL byte, as a scan
            // refuses it; a walk skips one without a word.
            Outcome::Binary => {
                if !a_directory {
                    eprintln!("trex: {} holds a NUL byte and is binary; --binary scans it", src.name());
                    failed = true;
                }
            }
            Outcome::Failed(e) => {
                eprintln!("trex: {e}");
                failed = true;
            }
            Outcome::Scanned(one) => found.push(one),
        }
    }
    let prefixed = a_directory || sources.len() > 1;
    let (total, errors) = report.print(&found, prefixed);
    exit(failed, total, errors, run.require_match)
}

/// One input's findings as the report reads them: its name, the part a
/// window took of it and that part's offset, and the findings over it.
fn found_of(name: Option<String>, part: Part, findings: Vec<Finding>) -> Found {
    Found { name, input: part.text, byte_base: part.byte_base, line_base: part.line_base, findings }
}

/// The exit status: failure where an input could not be read, where a
/// finding is an error, or where `--require-match` found nothing.
fn exit(failed: bool, total: usize, errors: usize, require_match: bool) -> ExitCode {
    if failed || errors > 0 || (require_match && total == 0) { ExitCode::FAILURE } else { ExitCode::SUCCESS }
}

/// How the findings print.
pub(crate) struct Report<'a> {
    pub(crate) scan: &'a RuleScan,
    form: Form,
    template: Option<&'a trex::Template>,
    painter: &'a trex::paint::Painter,
}

impl Report<'_> {
    /// Print every input's findings in the report's form, and count them
    /// and the errors among them. A count names its input where the scan
    /// runs over several.
    fn print(&self, found: &[Found], prefixed: bool) -> (usize, usize) {
        let mut objects: Vec<String> = Vec::new();
        let (total, errors) = self.print_findings(found, prefixed, &mut |object| objects.push(object));
        match self.form {
            Form::Json => crate::out::line(&format!("[{}]", objects.join(","))),
            Form::Sarif => crate::out::line(&self.scan.sarif(found)),
            Form::Human if total == 0 => crate::out::line("no finding"),
            _ => {}
        }
        (total, errors)
    }

    /// Print the findings of `found` in the report's form, each line as it
    /// is made, and count them and the errors among them; each JSON object
    /// goes to `json`, which [`Self::print`] gathers into one array and a
    /// followed file prints a line at a time as its findings settle. A SARIF
    /// document and the line saying there were none are written once, by
    /// [`Self::print`].
    pub(crate) fn print_findings(
        &self,
        found: &[Found],
        prefixed: bool,
        json: &mut dyn FnMut(String),
    ) -> (usize, usize) {
        use trex::paint::Role;
        let rules = self.scan.rules();
        let mut total = 0usize;
        let mut errors = 0usize;
        for one in found {
            let Found { name, input, findings, .. } = one;
            total += findings.len();
            errors += findings.iter().filter(|f| rules[f.rule].severity == trex::Severity::Error).count();
            let index = one.index();
            let name = name.as_deref();
            match self.form {
                Form::Count => match name {
                    Some(n) if prefixed => crate::out::line(&format!(
                        "{}{}{}",
                        self.painter.paint(Role::Path, n),
                        self.painter.paint(Role::Separator, ":"),
                        findings.len()
                    )),
                    _ => crate::out::line(&findings.len().to_string()),
                },
                Form::FilesWith => {
                    if let Some(n) = name
                        && !findings.is_empty()
                    {
                        crate::out::line(&self.painter.paint(Role::Path, n));
                    }
                }
                Form::FilesWithout => {
                    if let Some(n) = name
                        && findings.is_empty()
                    {
                        crate::out::line(&self.painter.paint(Role::Path, n));
                    }
                }
                Form::Json => {
                    for f in findings {
                        json(self.scan.finding_json(name, input, &index, f));
                    }
                }
                Form::Sarif => {}
                Form::Github => {
                    for f in findings {
                        crate::out::line(&self.scan.github_line(name, input, &index, f));
                    }
                }
                Form::Format => {
                    let Some(template) = self.template else {
                        continue;
                    };
                    for f in findings {
                        let rule = &rules[f.rule];
                        let place = Place::of(&index, input, f.start, f.end);
                        let at = trex::ReportAt {
                            path: name.unwrap_or(""),
                            line: place.line,
                            col: place.col,
                            offsets: index.offsets(input, f.m.start, f.m.end),
                            pattern: Some(&rule.name),
                            rule: Some(trex::ReportRule {
                                name: &rule.name,
                                severity: rule.severity.name(),
                                message: &f.message,
                                fix: f.fix.as_deref().unwrap_or(""),
                            }),
                        };
                        crate::out::line(&template.render_report(&f.m, input, &at));
                    }
                }
                Form::Human => {
                    for f in findings {
                        crate::out::line(&self.human_line(name, input, &index, f));
                        if let Some(fix) = &f.fix {
                            crate::out::line(&format!("  fix: {fix:?}"));
                        }
                    }
                }
            }
        }
        (total, errors)
    }

    /// `path:line:col: severity: message [rule]`, the path where the input
    /// has a name, painted by role.
    fn human_line(&self, name: Option<&str>, input: &[u8], index: &LineIndex, f: &Finding) -> String {
        use trex::paint::Role;
        let rule = &self.scan.rules()[f.rule];
        let (line, col) = index.line_col(input, f.start);
        let sep = |s: &str| self.painter.paint(Role::Separator, s);
        let mut out = String::new();
        if let Some(n) = name {
            out.push_str(&self.painter.paint(Role::Path, n));
            out.push_str(&sep(":"));
        }
        out.push_str(&self.painter.paint(Role::Line, &line.to_string()));
        out.push_str(&sep(":"));
        out.push_str(&self.painter.paint(Role::Column, &col.to_string()));
        out.push_str(&sep(":"));
        out.push(' ');
        out.push_str(rule.severity.name());
        out.push_str(": ");
        out.push_str(&f.message);
        out.push_str(" [");
        out.push_str(&rule.name);
        out.push(']');
        out
    }
}