use anyhow::Result;
use necessist_core::{LightContext, LineColumn, SourceFile, Span, WarnFlags, Warning, source_warn};
use std::collections::BTreeSet;
#[derive(Default)]
pub struct Directives {
pub skip_file: bool,
skip_lines: BTreeSet<usize>,
}
impl Directives {
pub fn collect(context: &LightContext, source_file: &SourceFile) -> Result<Self> {
let contents = source_file.contents();
let mut directives = Self::default();
let mut in_file_header = true;
for (index, line) in contents.lines().enumerate() {
let line_number = index + 1;
if is_skip_file_directive(line) {
if in_file_header {
directives.skip_file = true;
} else {
source_warn(
context,
Warning::SkipFileMispositioned,
&directive_span(source_file, line_number, line),
"`necessist: skip-file` is preceded by a line that is not a line comment, \
whitespace, or `<?php`",
WarnFlags::empty(),
)?;
}
} else if is_skip_directive(line) {
directives.skip_lines.insert(line_number + 1);
} else if let Some(directive) = directive_text(line) {
source_warn(
context,
Warning::DirectiveUnrecognized,
&directive_span(source_file, line_number, line),
&format!("`necessist: {directive}` is not a recognized directive"),
WarnFlags::empty(),
)?;
}
if !is_file_header_line(line) {
in_file_header = false;
}
}
Ok(directives)
}
pub fn skip(&self, span: &Span) -> bool {
!self.skip_lines.is_empty() && self.skip_lines.contains(&span_code_start_line(span))
}
}
fn span_code_start_line(span: &Span) -> usize {
let Ok(text) = span.source_text() else {
return span.start.line;
};
let line_offset = text
.lines()
.position(|line| !is_line_comment_or_whitespace(line))
.unwrap_or_default();
span.start.line + line_offset
}
fn directive_span(source_file: &SourceFile, line_number: usize, line: &str) -> Span {
let column = line.chars().take_while(|ch| ch.is_whitespace()).count();
Span {
source_file: source_file.clone(),
start: LineColumn {
line: line_number,
column,
},
end: LineColumn {
line: line_number,
column: column + line.trim().chars().count(),
},
}
}
fn is_skip_file_directive(line: &str) -> bool {
directive_text(line)
.and_then(|rest| rest.strip_prefix("skip-file"))
.is_some_and(has_word_boundary)
}
fn is_skip_directive(line: &str) -> bool {
directive_text(line)
.and_then(|rest| rest.strip_prefix("skip"))
.is_some_and(has_word_boundary)
}
fn directive_text(line: &str) -> Option<&str> {
let rest = line.trim_start().strip_prefix("//")?;
let rest = rest.trim_start().strip_prefix("necessist:")?;
Some(rest.trim_start())
}
fn has_word_boundary(rest: &str) -> bool {
rest.chars()
.next()
.is_none_or(|ch| ch != '-' && !ch.is_alphanumeric() && ch != '_')
}
fn is_file_header_line(line: &str) -> bool {
is_line_comment_or_whitespace(line) || line.trim() == "<?php"
}
fn is_line_comment_or_whitespace(line: &str) -> bool {
let rest = line.trim_start();
rest.is_empty() || rest.starts_with("//")
}
#[cfg(test)]
mod test {
use super::{
is_file_header_line, is_line_comment_or_whitespace, is_skip_directive,
is_skip_file_directive,
};
#[test]
fn skip_directive_syntax() {
const CASES: &[&str] = &[
" // necessist: skip",
" //necessist:skip",
" // necessist: skip, reason for skipping",
];
for &line in CASES {
assert!(is_skip_directive(line), "{line:?}");
assert!(!is_skip_file_directive(line), "{line:?}");
}
}
#[test]
fn skip_file_directive_syntax() {
const CASES: &[&str] = &[
" // necessist: skip-file, too late 😞",
"// necessist: skip-file",
"// necessist: skip-file, deliberately invalid Rust follows",
];
for &line in CASES {
assert!(!is_skip_directive(line), "{line:?}");
assert!(is_skip_file_directive(line), "{line:?}");
}
}
#[test]
fn unrecognized_directive_syntax() {
const CASES: &[&str] = &[
" n += 5; // necessist: skip",
" // necessist: skip-filex",
];
for &line in CASES {
assert!(!is_skip_directive(line), "{line:?}");
assert!(!is_skip_file_directive(line), "{line:?}");
}
}
#[test]
fn file_header_lines_allowed() {
const CASES: &[&str] = &[
"",
"\t",
" ",
"// comment",
"/// doc comment",
"//! inner doc comment",
"<?php",
];
for &line in CASES {
assert!(is_file_header_line(line), "{line:?}");
}
}
#[test]
fn file_header_lines_rejected() {
const CASES: &[&str] = &[
"# comment",
"#! /usr/bin/env bash",
"/* comment */",
"<?php declare(strict_types=1);",
"other",
];
for &line in CASES {
assert!(!is_file_header_line(line), "{line:?}");
}
}
#[test]
fn php_open_tag_is_not_a_line_comment() {
assert!(!is_line_comment_or_whitespace("<?php"));
}
}