Skip to content

Commit 30bef63

Browse files
initial commit
0 parents  commit 30bef63

File tree

8 files changed

+416
-0
lines changed

8 files changed

+416
-0
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.vscode
2+
target
3+
Cargo.lock

Cargo.toml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[package]
2+
name = "css-linter"
3+
version = "0.1.0"
4+
edition = "2021"
5+
6+
[dependencies]
7+
serde_json = "1.0.140"
8+
serde = { version = "1.0.217", features = ["derive"] }
9+
swc_common = "8.0.0"
10+
swc_ecma_ast = "8.0.0"
11+
swc_ecma_parser = { version = "10.0.0", features = ["typescript"] }
12+
swc_ecma_visit = "8.0.0"
13+
anyhow = "1.0.97"

Readme.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Next.Js CSS modules using linter
2+
3+
## Description
4+
Coming soon

src/config.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
use std::{collections::HashMap, fs};
2+
3+
use serde::Deserialize;
4+
5+
#[derive(Deserialize, Debug)]
6+
pub struct CompilerOptions {
7+
pub paths: HashMap<String, Vec<String>>,
8+
}
9+
10+
#[derive(Deserialize, Debug)]
11+
pub struct Properties {
12+
#[serde(rename(deserialize = "compilerOptions"))]
13+
pub compiler_options: CompilerOptions,
14+
pub exclude: Vec<String>,
15+
}
16+
17+
pub fn get_compiler_options() -> Properties {
18+
let tsconfig_contents = fs::read_to_string("tsconfig.json")
19+
.expect("Could not load tsconfig. Is the provided directory is typescript project?");
20+
21+
let v: Properties = match serde_json::from_str(&tsconfig_contents) {
22+
Ok(res) => res,
23+
Err(err) => panic!("{}", err),
24+
};
25+
26+
v
27+
}

src/css_parser.rs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
use std::collections::HashSet;
2+
3+
fn is_char(c: char) -> bool {
4+
c.is_alphabetic() || c.is_numeric() || c == '_' || c == '-'
5+
}
6+
7+
fn is_first_char_numeric(buffer: &str) -> bool {
8+
buffer.chars().next().map_or(false, |c| c.is_numeric())
9+
}
10+
11+
#[derive(Eq, Hash, PartialEq, Clone)]
12+
pub struct ClassName {
13+
pub class_name: String,
14+
pub line_index: usize,
15+
pub column_index: usize,
16+
}
17+
18+
pub fn extract_classes(css_content: &str) -> HashSet<ClassName> {
19+
let mut defined_classes: HashSet<ClassName> = HashSet::new();
20+
const DISABLE_RULE_FLAG: &str = "css-lint-disable-rule ";
21+
let mut skip_lines_remaining = 0;
22+
23+
for (index, line) in css_content.split('\n').enumerate() {
24+
let stripped_line = line.trim_start();
25+
26+
if stripped_line.starts_with("/*") {
27+
let comment_content = stripped_line
28+
.trim_start_matches("/*")
29+
.trim_end_matches("*/")
30+
.trim();
31+
32+
if let Some(rest) = comment_content.strip_prefix(DISABLE_RULE_FLAG) {
33+
if rest.trim_start().starts_with("unused-class") {
34+
skip_lines_remaining = 2;
35+
}
36+
}
37+
}
38+
if skip_lines_remaining != 0 {
39+
skip_lines_remaining -= 1;
40+
continue;
41+
}
42+
43+
if !stripped_line.starts_with('.') {
44+
continue;
45+
}
46+
47+
let mut buffer: String = String::new();
48+
let mut is_class = true;
49+
let mut start_index = 0;
50+
for (column_index, symbol) in stripped_line.chars().enumerate() {
51+
match symbol {
52+
'.' => {
53+
if !buffer.is_empty() && !is_first_char_numeric(&buffer) {
54+
defined_classes.insert(ClassName {
55+
class_name: buffer.clone(),
56+
line_index: index,
57+
column_index: start_index,
58+
});
59+
}
60+
buffer.clear();
61+
start_index = column_index;
62+
is_class = true;
63+
}
64+
char_i => {
65+
if is_class && is_char(char_i) {
66+
buffer.push(char_i);
67+
} else if !buffer.is_empty() {
68+
if !is_first_char_numeric(&buffer) {
69+
defined_classes.insert(ClassName {
70+
class_name: buffer.clone(),
71+
line_index: index,
72+
column_index: start_index,
73+
});
74+
}
75+
buffer.clear();
76+
is_class = false;
77+
}
78+
}
79+
}
80+
}
81+
if !buffer.is_empty() && !is_first_char_numeric(&buffer) {
82+
defined_classes.insert(ClassName {
83+
class_name: buffer,
84+
line_index: index,
85+
column_index: start_index,
86+
});
87+
}
88+
}
89+
90+
defined_classes
91+
}

src/main.rs

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
use std::{
2+
collections::{HashMap, HashSet},
3+
env, fs,
4+
path::{Path, PathBuf},
5+
process,
6+
};
7+
8+
use anyhow::Result;
9+
use config::get_compiler_options;
10+
use css_parser::{extract_classes, ClassName};
11+
use tsx_parser::{extract_default_css_imports, extract_used_classes};
12+
use utils::{process_relative_import, replace_aliases};
13+
14+
mod config;
15+
mod css_parser;
16+
mod tsx_parser;
17+
mod utils;
18+
19+
fn list_files_in_directory(path: PathBuf, exclude: Vec<String>) -> Vec<String> {
20+
let mut files = Vec::new();
21+
22+
if let Ok(entries) = fs::read_dir(&path) {
23+
for entry in entries.flatten() {
24+
let path = entry.path();
25+
26+
if path.is_dir() {
27+
if let Some(p) = path.file_name() {
28+
let p_str = p.to_string_lossy();
29+
if p_str.starts_with('.') || exclude.iter().any(|i| p_str == *i) {
30+
continue;
31+
}
32+
}
33+
files.extend(list_files_in_directory(path, exclude.clone()));
34+
} else if path.is_file() {
35+
if let Some(path_str) = path.to_str() {
36+
files.push(path_str.to_string());
37+
}
38+
}
39+
}
40+
} else {
41+
eprintln!("Cannot open target dir: {:?}", path);
42+
}
43+
44+
files
45+
}
46+
47+
fn main() -> Result<()> {
48+
const COLOR_BLUE: &str = "\x1b[34m";
49+
const COLOR_YELLOW: &str = "\x1b[33m";
50+
const COLOR_GREEN: &str = "\x1b[32m";
51+
const COLOR_RED: &str = "\x1b[31m";
52+
const COLOR_RESET: &str = "\u{001B}[0m";
53+
54+
let args: Vec<String> = env::args().collect();
55+
let path = args.get(1).unwrap_or_else(|| {
56+
eprintln!(
57+
"\n{}Error{}: Linting path must be specified",
58+
COLOR_RED, COLOR_RESET
59+
);
60+
process::exit(1);
61+
});
62+
63+
if let Err(e) = env::set_current_dir(Path::new(path)) {
64+
eprintln!(
65+
"\n{}Error{}: Failed to set current directory: {}",
66+
COLOR_RED, COLOR_RESET, e
67+
);
68+
process::exit(1);
69+
}
70+
let tsconfig = get_compiler_options();
71+
72+
let dir = list_files_in_directory(Path::new(".").to_path_buf(), tsconfig.exclude);
73+
74+
let mut used_classnames: HashMap<String, HashSet<String>> = Default::default();
75+
let mut defined_classnames: HashMap<String, HashSet<ClassName>> = Default::default();
76+
77+
for entry in &dir {
78+
let path = entry.replace("\\", "/");
79+
80+
if path.ends_with(".tsx") {
81+
let code = fs::read_to_string(entry)?;
82+
let imported_css = extract_default_css_imports(&code);
83+
84+
for (mut style_path, class_names) in imported_css {
85+
process_relative_import(Path::new(entry), &mut style_path)?;
86+
replace_aliases(&mut style_path, tsconfig.compiler_options.paths.clone());
87+
88+
let used_fields = extract_used_classes(&code, &class_names);
89+
used_classnames
90+
.entry(style_path)
91+
.or_insert_with(HashSet::new)
92+
.extend(used_fields);
93+
}
94+
} else if path.ends_with(".module.css") {
95+
let code = fs::read_to_string(entry)?;
96+
let classes = extract_classes(&code);
97+
defined_classnames
98+
.entry(path)
99+
.or_insert_with(HashSet::new)
100+
.extend(classes);
101+
}
102+
}
103+
104+
let mut files_count = 0;
105+
let mut errors_count = 0;
106+
println!(); // Initial spacing
107+
108+
for (css_file, mut classes) in defined_classnames {
109+
if let Some(used_css) = used_classnames.get(&css_file) {
110+
classes.retain(|v| !used_css.contains(&v.class_name));
111+
}
112+
113+
if classes.is_empty() {
114+
continue;
115+
}
116+
117+
files_count += 1;
118+
errors_count += classes.len();
119+
120+
println!("{}{}{}", COLOR_BLUE, css_file, COLOR_RESET);
121+
for extra in classes {
122+
println!(
123+
"{}{}:{} {}Warn{}: Unused class `{}` found",
124+
COLOR_YELLOW,
125+
extra.line_index + 1,
126+
extra.column_index + 1,
127+
COLOR_YELLOW,
128+
COLOR_RESET,
129+
extra.class_name
130+
);
131+
}
132+
133+
println!();
134+
}
135+
136+
if errors_count == 0 {
137+
println!("{}✔{} No CSS lint warnings found", COLOR_GREEN, COLOR_RESET);
138+
} else {
139+
println!(
140+
"Found {}{} warnings{} in {} files",
141+
COLOR_YELLOW, errors_count, COLOR_RESET, files_count
142+
);
143+
}
144+
145+
Ok(())
146+
}

src/tsx_parser.rs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
use std::collections::HashSet;
2+
use swc_common::{sync::Lrc, FileName, SourceMap};
3+
use swc_ecma_ast::{ImportSpecifier, Module, ModuleDecl};
4+
use swc_ecma_parser::{Parser, StringInput, Syntax, TsSyntax};
5+
use swc_ecma_visit::{Visit, VisitWith};
6+
7+
pub fn module_parser(tsx_code: &str) -> Module {
8+
let cm: Lrc<SourceMap> = Default::default();
9+
let fm = cm.new_source_file(FileName::Custom("input.tsx".into()).into(), tsx_code.into());
10+
11+
let syntax = Syntax::Typescript(TsSyntax {
12+
tsx: true,
13+
decorators: false,
14+
dts: false,
15+
no_early_errors: false,
16+
disallow_ambiguous_jsx_like: false,
17+
});
18+
19+
let mut parser = Parser::new(syntax, StringInput::from(&*fm), None);
20+
21+
parser.parse_module().expect("Failed to parse")
22+
}
23+
24+
struct PropertyFinder {
25+
variable_name: String,
26+
properties: HashSet<String>,
27+
}
28+
29+
impl Visit for PropertyFinder {
30+
fn visit_member_expr(&mut self, node: &swc_ecma_ast::MemberExpr) {
31+
if let swc_ecma_ast::Expr::Ident(ref obj) = *node.obj {
32+
if obj.sym == self.variable_name {
33+
if let swc_ecma_ast::MemberProp::Ident(ref prop) = node.prop {
34+
self.properties.insert(prop.sym.to_string());
35+
}
36+
}
37+
}
38+
39+
node.visit_children_with(self);
40+
}
41+
}
42+
43+
pub fn extract_used_classes(tsx_code: &str, variable_name: &str) -> HashSet<String> {
44+
let module = module_parser(tsx_code);
45+
46+
let mut finder = PropertyFinder {
47+
variable_name: variable_name.to_string(),
48+
properties: HashSet::new(),
49+
};
50+
51+
module.visit_with(&mut finder);
52+
53+
finder.properties
54+
}
55+
56+
struct DefaultCssImportFinder {
57+
imported_variables: HashSet<(String, String)>,
58+
}
59+
60+
impl Visit for DefaultCssImportFinder {
61+
fn visit_module(&mut self, node: &Module) {
62+
for stmt in &node.body {
63+
if let swc_ecma_ast::ModuleItem::ModuleDecl(ModuleDecl::Import(import)) = stmt {
64+
if import.src.value.ends_with(".module.css") {
65+
for specifier in &import.specifiers {
66+
if let ImportSpecifier::Default(default) = specifier {
67+
self.imported_variables.insert((
68+
import.src.value.to_string(),
69+
default.local.sym.to_string(),
70+
));
71+
}
72+
}
73+
}
74+
}
75+
}
76+
}
77+
}
78+
79+
pub fn extract_default_css_imports(tsx_code: &str) -> HashSet<(String, String)> {
80+
let module = module_parser(tsx_code);
81+
82+
let mut finder = DefaultCssImportFinder {
83+
imported_variables: HashSet::new(),
84+
};
85+
86+
module.visit_with(&mut finder);
87+
88+
finder.imported_variables
89+
}

0 commit comments

Comments
 (0)