Skip to content

Commit ead162c

Browse files
authored
rust: Add glint (#2811)
Signed-off-by: Ryan Northey <[email protected]>
1 parent 661ef31 commit ead162c

20 files changed

+578
-5
lines changed

.github/workflows/rust.yml

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,33 @@ permissions:
1818
contents: read
1919

2020
jobs:
21-
test:
21+
coverage:
2222
runs-on: ubuntu-24.04
2323
if: github.repository_owner == 'envoyproxy'
2424
steps:
2525
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
2626
- uses: actions-rust-lang/setup-rust-toolchain@v1
2727
with:
2828
toolchain: nightly
29-
- run: |
30-
cargo install cargo-tarpaulin
31-
cargo-tarpaulin
29+
- name: Install cargo-tarpaulin
30+
run: cargo install cargo-tarpaulin
31+
- name: Run coverage
32+
run: |
33+
cargo tarpaulin --config tarpaulin.toml
34+
working-directory: rust
35+
36+
integration:
37+
runs-on: ubuntu-24.04
38+
strategy:
39+
matrix:
40+
rust:
41+
- stable
42+
steps:
43+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
44+
- uses: actions-rust-lang/setup-rust-toolchain@v1
45+
with:
46+
toolchain: ${{ matrix.rust }}
47+
- name: Run glint integration tests specifically
48+
run: |
49+
cargo test --package glint --test integration_test --verbose
3250
working-directory: rust

rust/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
members = [
33
"core",
44
"echo",
5+
"glint",
56
"runner",
67
"test",
78
]

rust/glint/Cargo.toml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
[package]
2+
name = "glint"
3+
version = "0.1.0"
4+
edition = "2024"
5+
6+
[dependencies]
7+
clap = { version = "4.5", features = ["derive"] }
8+
anyhow = "1.0"
9+
regex = "1.10"
10+
rayon = "1.10"
11+
serde = { version = "1.0", features = ["derive"] }
12+
serde_json = "1.0"
13+
14+
[dev-dependencies]
15+
serde_json = "1.0"

rust/glint/src/args.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
use clap::Parser;
2+
use std::path::PathBuf;
3+
4+
#[derive(Parser, Debug)]
5+
#[command(name = "glint")]
6+
#[command(about = "Lint files for whitespace issues", long_about = None)]
7+
pub struct Args {
8+
#[arg(required = true)]
9+
pub paths: Vec<PathBuf>,
10+
}

rust/glint/src/check.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
use anyhow::{Context, Result};
2+
use std::fs::File;
3+
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
4+
use std::path::Path;
5+
6+
pub fn final_newline(path: &Path) -> Result<bool> {
7+
let mut file = File::open(path).context("Failed to open file")?;
8+
let size = file.metadata()?.len();
9+
if size == 0 {
10+
return Ok(true); // Empty files are considered OK
11+
}
12+
file.seek(SeekFrom::End(-1))?;
13+
let mut last_byte = [0u8; 1];
14+
file.read_exact(&mut last_byte)?;
15+
Ok(last_byte[0] != b'\n')
16+
}
17+
18+
pub fn trailing_whitespace(path: &Path) -> Result<Vec<usize>> {
19+
let file = File::open(path).context("Failed to open file")?;
20+
let reader = BufReader::new(file);
21+
let mut lines_with_trailing = Vec::new();
22+
for (line_num, line) in reader.lines().enumerate() {
23+
let line = line?;
24+
if line.ends_with(' ') || line.ends_with('\t') {
25+
lines_with_trailing.push(line_num + 1); // 1-based line numbers
26+
}
27+
}
28+
Ok(lines_with_trailing)
29+
}
30+
31+
pub fn mixed_indentation(path: &Path) -> Result<bool> {
32+
let file = File::open(path).context("Failed to open file")?;
33+
let reader = BufReader::new(file);
34+
let mut has_tab_indent = false;
35+
let mut has_space_indent = false;
36+
for line in reader.lines() {
37+
let line = line?;
38+
if line.starts_with('\t') {
39+
has_tab_indent = true;
40+
} else if line.starts_with(' ') {
41+
has_space_indent = true;
42+
}
43+
if has_tab_indent && has_space_indent {
44+
return Ok(true);
45+
}
46+
}
47+
Ok(false)
48+
}

rust/glint/src/files.rs

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
use crate::check;
2+
#[allow(unused_imports)]
3+
use anyhow::{Context, Result};
4+
use regex::Regex;
5+
use serde::{Deserialize, Serialize};
6+
use std::path::{Path, PathBuf};
7+
8+
#[derive(Debug, Clone, Serialize, Deserialize)]
9+
pub struct FileIssues {
10+
#[serde(skip_serializing_if = "Vec::is_empty")]
11+
pub trailing_whitespace: Vec<usize>,
12+
#[serde(skip_serializing_if = "std::ops::Not::not")]
13+
pub mixed_indentation: bool,
14+
#[serde(skip_serializing_if = "std::ops::Not::not")]
15+
pub no_final_newline: bool,
16+
}
17+
18+
impl FileIssues {
19+
pub fn new() -> Self {
20+
FileIssues {
21+
trailing_whitespace: Vec::new(),
22+
mixed_indentation: false,
23+
no_final_newline: false,
24+
}
25+
}
26+
27+
pub fn has_issues(&self) -> bool {
28+
!self.trailing_whitespace.is_empty() || self.mixed_indentation || self.no_final_newline
29+
}
30+
31+
pub fn total_issues(&self) -> usize {
32+
self.trailing_whitespace.len()
33+
+ if self.mixed_indentation { 1 } else { 0 }
34+
+ if self.no_final_newline { 1 } else { 0 }
35+
}
36+
}
37+
38+
pub fn should_exclude(path: &Path) -> bool {
39+
let path_str = path.to_string_lossy();
40+
let exclude_patterns = [
41+
r"[\w\W/-]*\.go$",
42+
r"[\w\W/-]*\.patch$",
43+
r"^test/[\w/]*_corpus/[\w/]*",
44+
r"^tools/[\w/]*_corpus/[\w/]*",
45+
r"[\w/]*password_protected_password.txt$",
46+
];
47+
for pattern in &exclude_patterns {
48+
if let Ok(re) = Regex::new(pattern) {
49+
if re.is_match(&path_str) {
50+
return true;
51+
}
52+
}
53+
}
54+
false
55+
}
56+
57+
pub fn process_file(path: &Path) -> Result<Option<(String, FileIssues)>> {
58+
if should_exclude(path) {
59+
return Ok(None);
60+
}
61+
let mut issues = FileIssues::new();
62+
63+
match check::trailing_whitespace(path) {
64+
Ok(lines) => issues.trailing_whitespace = lines,
65+
Err(e) => {
66+
return Err(e.context(format!(
67+
"Failed to check trailing whitespace for {}",
68+
path.display()
69+
)));
70+
}
71+
}
72+
73+
match check::mixed_indentation(path) {
74+
Ok(mixed) => issues.mixed_indentation = mixed,
75+
Err(e) => {
76+
return Err(e.context(format!(
77+
"Failed to check mixed indentation for {}",
78+
path.display()
79+
)));
80+
}
81+
}
82+
83+
match check::final_newline(path) {
84+
Ok(missing) => issues.no_final_newline = missing,
85+
Err(e) => {
86+
return Err(e.context(format!(
87+
"Failed to check final newline for {}",
88+
path.display()
89+
)));
90+
}
91+
}
92+
93+
if issues.has_issues() {
94+
Ok(Some((path.display().to_string(), issues)))
95+
} else {
96+
Ok(None)
97+
}
98+
}
99+
100+
/// Recursively find all files in a directory
101+
pub fn find_files(path: &Path) -> Result<Vec<PathBuf>> {
102+
let mut files = Vec::new();
103+
if path.is_file() {
104+
files.push(path.to_path_buf());
105+
} else if path.is_dir() {
106+
for entry in std::fs::read_dir(path)? {
107+
let entry = entry?;
108+
let path = entry.path();
109+
if path.is_file() {
110+
files.push(path);
111+
} else if path.is_dir() {
112+
files.extend(find_files(&path)?);
113+
}
114+
}
115+
}
116+
Ok(files)
117+
}

rust/glint/src/main.rs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
mod args;
2+
mod check;
3+
mod files;
4+
5+
use anyhow::Result;
6+
use clap::Parser;
7+
use rayon::prelude::*;
8+
use serde::{Deserialize, Serialize};
9+
use std::collections::HashMap;
10+
use std::sync::{
11+
Mutex,
12+
atomic::{AtomicBool, Ordering},
13+
};
14+
15+
use crate::args::Args;
16+
use crate::files::{FileIssues, find_files, process_file};
17+
18+
#[derive(Debug, Serialize, Deserialize)]
19+
struct LintResult {
20+
files: HashMap<String, FileIssues>,
21+
summary: Summary,
22+
}
23+
24+
#[derive(Debug, Serialize, Deserialize)]
25+
struct Summary {
26+
total_files: usize,
27+
files_with_issues: usize,
28+
total_issues: usize,
29+
}
30+
31+
fn main() -> Result<()> {
32+
let args = Args::parse();
33+
let mut all_files = Vec::new();
34+
for path in &args.paths {
35+
if !path.exists() {
36+
eprintln!("Warning: Path does not exist: {}", path.display());
37+
continue;
38+
}
39+
all_files.extend(find_files(path)?);
40+
}
41+
42+
if all_files.is_empty() {
43+
eprintln!("No files to process");
44+
return Ok(());
45+
}
46+
47+
let file_issues = Mutex::new(HashMap::new());
48+
let had_error = AtomicBool::new(false);
49+
all_files
50+
.par_iter()
51+
.for_each(|file| match process_file(file) {
52+
Ok(Some((path, issues))) => {
53+
let mut map = file_issues.lock().unwrap();
54+
map.insert(path, issues);
55+
}
56+
Ok(None) => {}
57+
Err(e) => {
58+
eprintln!("Error processing {}: {}", file.display(), e);
59+
had_error.store(true, Ordering::Relaxed);
60+
}
61+
});
62+
63+
let files = file_issues.into_inner().unwrap();
64+
65+
// Calculate summary
66+
let total_files = all_files.len();
67+
let files_with_issues = files.len();
68+
let total_issues: usize = files.values().map(|f| f.total_issues()).sum();
69+
70+
let result = LintResult {
71+
files,
72+
summary: Summary {
73+
total_files,
74+
files_with_issues,
75+
total_issues,
76+
},
77+
};
78+
79+
// Output JSON
80+
println!("{}", serde_json::to_string_pretty(&result)?);
81+
82+
// Exit with non-zero status if there were issues
83+
if files_with_issues > 0 || had_error.load(Ordering::Relaxed) {
84+
std::process::exit(1);
85+
}
86+
87+
Ok(())
88+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Test Fixtures for glint
2+
3+
This directory contains test files and their expected outputs.
4+
5+
## Test Files:
6+
- `bad.txt` - 50 lines with all types of issues (trailing whitespace, mixed indentation, no final newline)
7+
- `bad2.txt` - 7 lines with all types of issues
8+
- `bad3.txt` - 7 lines with only trailing whitespace issues
9+
- `good.txt` - Clean file with no issues
10+
11+
## Expected Files:
12+
Each test file has a corresponding `.expected.json` file containing the exact JSON output that glint should produce.
13+
14+
To generate/update an expected file:
15+
```bash
16+
cargo run -- tests/fixtures/FILENAME.txt > tests/fixtures/FILENAME.txt.expected.json
17+
```
18+
19+
Then manually edit to ensure paths are relative (e.g., `tests/fixtures/bad.txt` not the full path).

0 commit comments

Comments
 (0)