1use std::fmt;
2use std::str::FromStr;
3
4use cmake_tidy_ast::{CommandInvocation, File, Statement, TextRange};
5use cmake_tidy_config::{NameCase, RuleSelector};
6use cmake_tidy_lexer::{Token, TokenKind};
7use cmake_tidy_parser::{ParseError, parse_file};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10pub struct CheckOptions {
11 pub project_root: bool,
12 pub function_name_case: NameCase,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct CheckResult {
17 pub diagnostics: Vec<Diagnostic>,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Diagnostic {
22 pub code: RuleCode,
23 pub message: String,
24 pub range: TextRange,
25 pub fix: Option<Edit>,
26}
27
28impl Diagnostic {
29 #[must_use]
30 pub fn new(code: RuleCode, message: impl Into<String>, range: TextRange) -> Self {
31 Self {
32 code,
33 message: message.into(),
34 range,
35 fix: None,
36 }
37 }
38
39 #[must_use]
40 pub fn with_fix(mut self, fix: Edit) -> Self {
41 self.fix = Some(fix);
42 self
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct Edit {
48 pub range: TextRange,
49 pub replacement: String,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
53pub enum RuleCode {
54 E001,
55 N001,
56 W201,
57 W202,
58 W203,
59 W301,
60 W302,
61}
62
63impl fmt::Display for RuleCode {
64 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
65 let code = match self {
66 Self::E001 => "E001",
67 Self::N001 => "N001",
68 Self::W201 => "W201",
69 Self::W202 => "W202",
70 Self::W203 => "W203",
71 Self::W301 => "W301",
72 Self::W302 => "W302",
73 };
74
75 formatter.write_str(code)
76 }
77}
78
79#[must_use]
80pub fn check_source(source: &str, options: &CheckOptions) -> CheckResult {
81 let parsed = parse_file(source);
82 let mut diagnostics = Vec::new();
83
84 diagnostics.extend(parsed.errors.iter().map(parse_error_diagnostic));
85 diagnostics.extend(check_file(&parsed.syntax, *options));
86 let noqa = NoqaDirectives::from_tokens(source, &parsed.tokens);
87 diagnostics.retain(|diagnostic| !noqa.suppresses(diagnostic));
88 diagnostics.sort_by_key(|diagnostic| {
89 (
90 diagnostic.range.start,
91 diagnostic.range.end,
92 diagnostic.code,
93 )
94 });
95
96 CheckResult { diagnostics }
97}
98
99fn parse_error_diagnostic(error: &ParseError) -> Diagnostic {
100 Diagnostic::new(RuleCode::E001, error.message.clone(), error.range)
101}
102
103fn check_file(file: &File, options: CheckOptions) -> Vec<Diagnostic> {
104 let commands = file
105 .items
106 .iter()
107 .map(|statement| match statement {
108 Statement::Command(command) => command,
109 })
110 .collect::<Vec<_>>();
111
112 let mut diagnostics = Vec::new();
113 check_function_name_case(&commands, options.function_name_case, &mut diagnostics);
114 check_empty_project_calls(&commands, &mut diagnostics);
115
116 if options.project_root {
117 check_project_root_commands(file, &commands, &mut diagnostics);
118 }
119
120 diagnostics
121}
122
123fn check_function_name_case(
124 commands: &[&CommandInvocation],
125 naming: NameCase,
126 diagnostics: &mut Vec<Diagnostic>,
127) {
128 for command in commands {
129 let expected = match naming {
130 NameCase::Lower => command.name.text.to_ascii_lowercase(),
131 NameCase::Upper => command.name.text.to_ascii_uppercase(),
132 };
133
134 if command.name.text == expected {
135 continue;
136 }
137
138 let description = match naming {
139 NameCase::Lower => "lowercase",
140 NameCase::Upper => "uppercase",
141 };
142 diagnostics.push(
143 Diagnostic::new(
144 RuleCode::N001,
145 format!("function names should use {description} style"),
146 command.name.range,
147 )
148 .with_fix(Edit {
149 range: command.name.range,
150 replacement: expected,
151 }),
152 );
153 }
154}
155
156#[must_use]
157pub fn apply_fixes(source: &str, diagnostics: &[Diagnostic]) -> Option<String> {
158 let mut edits = diagnostics
159 .iter()
160 .filter_map(|diagnostic| diagnostic.fix.as_ref())
161 .collect::<Vec<_>>();
162 if edits.is_empty() {
163 return None;
164 }
165
166 edits.sort_by_key(|edit| (edit.range.start, edit.range.end));
167
168 let mut output = String::with_capacity(source.len());
169 let mut offset = 0;
170
171 for edit in edits {
172 if edit.range.start < offset {
173 continue;
174 }
175 output.push_str(&source[offset..edit.range.start]);
176 output.push_str(&edit.replacement);
177 offset = edit.range.end;
178 }
179
180 output.push_str(&source[offset..]);
181 (output != source).then_some(output)
182}
183
184fn check_empty_project_calls(commands: &[&CommandInvocation], diagnostics: &mut Vec<Diagnostic>) {
185 for command in commands {
186 if command.name.text.eq_ignore_ascii_case("project") && command.arguments.is_empty() {
187 diagnostics.push(Diagnostic::new(
188 RuleCode::W203,
189 "`project()` should declare at least a project name",
190 command.name.range,
191 ));
192 }
193 }
194}
195
196fn check_project_root_commands(
197 file: &File,
198 commands: &[&CommandInvocation],
199 diagnostics: &mut Vec<Diagnostic>,
200) {
201 let cmake_minimum_required = find_commands(commands, "cmake_minimum_required");
202 let project_commands = find_commands(commands, "project");
203
204 if cmake_minimum_required.is_empty() {
205 diagnostics.push(Diagnostic::new(
206 RuleCode::W301,
207 "missing `cmake_minimum_required()` in the project root `CMakeLists.txt`",
208 file.range,
209 ));
210 } else {
211 for duplicate in &cmake_minimum_required[1..] {
212 diagnostics.push(Diagnostic::new(
213 RuleCode::W201,
214 "duplicate `cmake_minimum_required()` declaration",
215 duplicate.name.range,
216 ));
217 }
218 }
219
220 if project_commands.is_empty() {
221 diagnostics.push(Diagnostic::new(
222 RuleCode::W302,
223 "missing `project()` in the project root `CMakeLists.txt`",
224 file.range,
225 ));
226 } else {
227 for duplicate in &project_commands[1..] {
228 diagnostics.push(Diagnostic::new(
229 RuleCode::W202,
230 "duplicate `project()` declaration",
231 duplicate.name.range,
232 ));
233 }
234 }
235}
236
237fn find_commands<'a>(commands: &[&'a CommandInvocation], name: &str) -> Vec<&'a CommandInvocation> {
238 commands
239 .iter()
240 .copied()
241 .filter(|command| command.name.text.eq_ignore_ascii_case(name))
242 .collect()
243}
244
245#[derive(Debug)]
246struct NoqaDirectives {
247 file: Option<Directive>,
248 line_index: LineIndex,
249 line_directives: Vec<(usize, Directive)>,
250}
251
252impl NoqaDirectives {
253 fn from_tokens(source: &str, tokens: &[Token]) -> Self {
254 let line_index = LineIndex::new(source);
255 let mut directives = Self {
256 file: leading_file_directive(tokens),
257 line_index,
258 line_directives: Vec::new(),
259 };
260
261 for token in tokens {
262 let TokenKind::Comment(comment) = &token.kind else {
263 continue;
264 };
265
266 if let Some(directive) = parse_line_directive(comment) {
267 directives.line_directives.push((
268 directives.line_index.line_number(token.range.start),
269 directive,
270 ));
271 }
272 }
273
274 directives
275 }
276
277 fn suppresses(&self, diagnostic: &Diagnostic) -> bool {
278 let code = diagnostic.code.to_string();
279
280 if self
281 .file
282 .as_ref()
283 .is_some_and(|directive| directive.matches(&code))
284 {
285 return true;
286 }
287
288 let line = self.line_index.line_number(diagnostic.range.start);
289 self.line_directives
290 .iter()
291 .any(|(directive_line, directive)| *directive_line == line && directive.matches(&code))
292 }
293}
294
295#[derive(Debug)]
296struct Directive {
297 selectors: Vec<RuleSelector>,
298}
299
300impl Directive {
301 fn matches(&self, code: &str) -> bool {
302 self.selectors.iter().any(|selector| selector.matches(code))
303 }
304}
305
306fn leading_file_directive(tokens: &[Token]) -> Option<Directive> {
307 for token in tokens {
308 match &token.kind {
309 TokenKind::Comment(comment) => {
310 if let Some(directive) = parse_file_directive(comment) {
311 return Some(directive);
312 }
313 }
314 TokenKind::Whitespace(_) | TokenKind::Newline => {}
315 _ => break,
316 }
317 }
318
319 None
320}
321
322fn parse_file_directive(comment: &str) -> Option<Directive> {
323 let body = comment.strip_prefix('#')?.trim();
324 parse_noqa_directive(body)
325}
326
327fn parse_line_directive(comment: &str) -> Option<Directive> {
328 let body = comment.strip_prefix('#')?.trim();
329 parse_noqa_directive(body)
330}
331
332fn parse_noqa_directive(value: &str) -> Option<Directive> {
333 let directive = value.strip_prefix("noqa")?.trim();
334 if directive.is_empty() {
335 return Some(Directive {
336 selectors: vec![RuleSelector::All],
337 });
338 }
339
340 let selectors = directive
341 .strip_prefix(':')?
342 .split(',')
343 .map(str::trim)
344 .map(RuleSelector::from_str)
345 .collect::<Result<Vec<_>, _>>()
346 .ok()?;
347 if selectors.is_empty() {
348 return None;
349 }
350
351 Some(Directive { selectors })
352}
353
354#[derive(Debug)]
355struct LineIndex {
356 line_starts: Vec<usize>,
357}
358
359impl LineIndex {
360 fn new(source: &str) -> Self {
361 let mut line_starts = vec![0];
362 for (index, character) in source.char_indices() {
363 if character == '\n' {
364 line_starts.push(index + 1);
365 }
366 }
367
368 Self { line_starts }
369 }
370
371 fn line_number(&self, offset: usize) -> usize {
372 match self.line_starts.binary_search(&offset) {
373 Ok(index) => index + 1,
374 Err(index) => index,
375 }
376 }
377}
378
379#[cfg(test)]
380mod tests {
381 use cmake_tidy_config::NameCase;
382
383 use super::{CheckOptions, RuleCode, apply_fixes, check_source};
384
385 fn diagnostic_codes(source: &str, options: CheckOptions) -> Vec<RuleCode> {
386 check_source(source, &options)
387 .diagnostics
388 .into_iter()
389 .map(|diagnostic| diagnostic.code)
390 .collect()
391 }
392
393 #[test]
394 fn reports_parse_errors() {
395 let codes = diagnostic_codes(
396 "project(example",
397 CheckOptions {
398 project_root: true,
399 function_name_case: NameCase::Lower,
400 },
401 );
402 assert_eq!(codes.len(), 2);
403 assert!(codes.contains(&RuleCode::E001));
404 assert!(codes.contains(&RuleCode::W301));
405 }
406
407 #[test]
408 fn reports_missing_root_project_commands() {
409 let codes = diagnostic_codes(
410 "add_library(example STATIC main.cpp)\n",
411 CheckOptions {
412 project_root: true,
413 function_name_case: NameCase::Lower,
414 },
415 );
416 assert_eq!(codes, vec![RuleCode::W301, RuleCode::W302]);
417 }
418
419 #[test]
420 fn reports_duplicate_root_commands() {
421 let codes = diagnostic_codes(
422 "cmake_minimum_required(VERSION 3.30)\ncmake_minimum_required(VERSION 3.31)\nproject(example)\nproject(example-again)\n",
423 CheckOptions {
424 project_root: true,
425 function_name_case: NameCase::Lower,
426 },
427 );
428 assert_eq!(codes, vec![RuleCode::W201, RuleCode::W202]);
429 }
430
431 #[test]
432 fn reports_empty_project_calls() {
433 let codes = diagnostic_codes(
434 "cmake_minimum_required(VERSION 3.30)\nproject()\n",
435 CheckOptions {
436 project_root: true,
437 function_name_case: NameCase::Lower,
438 },
439 );
440 assert_eq!(codes, vec![RuleCode::W203]);
441 }
442
443 #[test]
444 fn skips_root_only_rules_for_non_root_files() {
445 let codes = diagnostic_codes(
446 "add_subdirectory(src)\n",
447 CheckOptions {
448 project_root: false,
449 function_name_case: NameCase::Lower,
450 },
451 );
452 assert!(codes.is_empty());
453 }
454
455 #[test]
456 fn line_noqa_suppresses_matching_rule() {
457 let codes = diagnostic_codes(
458 "project() # noqa: W203\nproject(example)\n",
459 CheckOptions {
460 project_root: true,
461 function_name_case: NameCase::Lower,
462 },
463 );
464 assert!(!codes.contains(&RuleCode::W203));
465 assert!(codes.contains(&RuleCode::W202));
466 assert!(codes.contains(&RuleCode::W301));
467 }
468
469 #[test]
470 fn file_noqa_suppresses_all_rules() {
471 let codes = diagnostic_codes(
472 "# noqa\nproject()\nproject(example)\n",
473 CheckOptions {
474 project_root: true,
475 function_name_case: NameCase::Lower,
476 },
477 );
478 assert!(codes.is_empty());
479 }
480
481 #[test]
482 fn file_noqa_can_target_specific_rules() {
483 let codes = diagnostic_codes(
484 "# noqa: W301,W202\nproject()\nproject(example)\n",
485 CheckOptions {
486 project_root: true,
487 function_name_case: NameCase::Lower,
488 },
489 );
490 assert_eq!(codes, vec![RuleCode::W203]);
491 }
492
493 #[test]
494 fn reports_lowercase_naming_rule() {
495 let codes = diagnostic_codes(
496 "ADD_LIBRARY(example STATIC main.cpp)\n",
497 CheckOptions {
498 project_root: false,
499 function_name_case: NameCase::Lower,
500 },
501 );
502 assert_eq!(codes, vec![RuleCode::N001]);
503 }
504
505 #[test]
506 fn reports_uppercase_naming_rule() {
507 let codes = diagnostic_codes(
508 "add_library(example STATIC main.cpp)\n",
509 CheckOptions {
510 project_root: false,
511 function_name_case: NameCase::Upper,
512 },
513 );
514 assert_eq!(codes, vec![RuleCode::N001]);
515 }
516
517 #[test]
518 fn applies_naming_fixes() {
519 let result = check_source(
520 "ADD_LIBRARY(example STATIC main.cpp)\n",
521 &CheckOptions {
522 project_root: false,
523 function_name_case: NameCase::Lower,
524 },
525 );
526 let fixed = apply_fixes(
527 "ADD_LIBRARY(example STATIC main.cpp)\n",
528 &result.diagnostics,
529 )
530 .expect("naming fix should produce an edit");
531 assert_eq!(fixed, "add_library(example STATIC main.cpp)\n");
532 }
533}