use lazy_static::lazy_static;
use regex::Regex;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use super::rules::{Diagnostic, DiagnosticSeverity, Edit, Fix, FixSafetyLevel, Range, Rule, RuleCode};
lazy_static! {
static ref TYPE_MODIFIERS: &'static [&'static str] = &[
"inline_for_extraction",
"noextract",
"private",
"abstract",
"unfold",
"noeq",
"unopteq",
];
static ref MODIFIER_PATTERN: String = {
let mods = TYPE_MODIFIERS.join("|");
format!(r"(?:(?:{})\s+)*", mods)
};
static ref TYPE_START_PATTERN: Regex = Regex::new(
&format!(r"^{}type\s+([a-zA-Z_][a-zA-Z0-9_']*)", *MODIFIER_PATTERN)
).unwrap();
static ref AND_TYPE_PATTERN: Regex = Regex::new(
r"^and\s+([a-zA-Z_][a-zA-Z0-9_']*)"
).unwrap();
static ref PRIVATE_TYPE_PATTERN: Regex = Regex::new(
&format!(r"^(?:(?:{})\s+)*private\s+(?:(?:{})\s+)*type\s+",
TYPE_MODIFIERS.join("|"), TYPE_MODIFIERS.join("|"))
).unwrap();
static ref FST_DIRECTIVE_PATTERN: Regex = Regex::new(
r"^#(push-options|pop-options|set-options|restart-solver)\b"
).unwrap();
static ref TERMINATORS: &'static [&'static str] = &[
"val ",
"let ",
"let rec ",
"unfold let ",
"inline_for_extraction let ",
"noextract let ",
"assume val ",
"friend ",
"open ",
"module ",
"instance ",
"class ",
"effect ",
"layered_effect ",
"sub_effect ",
"new_effect ",
"total_effect ",
];
static ref BLOCK_COMMENT_PATTERN: Regex = Regex::new(r"(?s)\(\*.*?\*\)").unwrap();
static ref LINE_COMMENT_PATTERN: Regex = Regex::new(r"(?m)//.*$").unwrap();
}
#[derive(Debug, Clone)]
pub struct TypeDefinition {
pub name: String,
pub block: String,
pub start_line: usize, pub end_line: usize, pub is_private: bool,
pub and_types: Vec<String>,
}
fn is_section_header(line: &str) -> bool {
let stripped = line.trim();
(stripped.starts_with("(** ==") || stripped.starts_with("(* =="))
|| (stripped.starts_with("(*") && stripped.contains("===="))
}
fn find_type_block_end(lines: &[&str], start_index: usize) -> usize {
let mut j = start_index + 1;
while j < lines.len() {
let curr_line = lines[j];
let curr_stripped = curr_line.trim();
if curr_stripped.is_empty() {
j += 1;
continue;
}
if FST_DIRECTIVE_PATTERN.is_match(curr_stripped) {
j += 1;
continue;
}
if curr_stripped.starts_with("[@") {
j += 1;
continue;
}
if !curr_line.is_empty() && !curr_line.starts_with(' ') && !curr_line.starts_with('\t') {
if AND_TYPE_PATTERN.is_match(curr_stripped) {
j += 1;
continue;
}
for term in TERMINATORS.iter() {
if curr_stripped.starts_with(term) {
let mut end_line = j - 1;
while end_line > start_index && lines[end_line].trim().is_empty() {
end_line -= 1;
}
return end_line;
}
}
if TYPE_START_PATTERN.is_match(curr_stripped) {
let mut end_line = j - 1;
while end_line > start_index && lines[end_line].trim().is_empty() {
end_line -= 1;
}
return end_line;
}
if is_section_header(curr_line) {
let mut end_line = j - 1;
while end_line > start_index && lines[end_line].trim().is_empty() {
end_line -= 1;
}
return end_line;
}
}
j += 1;
}
let mut end_line = j - 1;
while end_line > start_index && lines[end_line].trim().is_empty() {
end_line -= 1;
}
end_line
}
fn extract_and_types(block: &str) -> Vec<String> {
let mut and_types = Vec::new();
for line in block.lines() {
let stripped = line.trim();
if let Some(caps) = AND_TYPE_PATTERN.captures(stripped) {
if let Some(m) = caps.get(1) {
and_types.push(m.as_str().to_string());
}
}
}
and_types
}
fn is_private_type(block: &str) -> bool {
let first_line = block.lines().next().map(|l| l.trim()).unwrap_or("");
PRIVATE_TYPE_PATTERN.is_match(first_line)
}
fn find_attribute_prefix_lines(lines: &[&str], type_start: usize) -> usize {
let mut actual_start = type_start;
let mut j = type_start;
while j > 0 {
j -= 1;
let line = lines[j];
let stripped = line.trim();
if stripped.is_empty() {
break;
}
if stripped.starts_with("[@") || stripped.starts_with("[@@") {
actual_start = j;
continue;
}
break;
}
actual_start
}
pub fn find_type_definitions(content: &str) -> HashMap<String, TypeDefinition> {
let lines: Vec<&str> = content.lines().collect();
let mut types: HashMap<String, TypeDefinition> = HashMap::new();
let mut i = 0;
while i < lines.len() {
let line = lines[i];
let stripped = line.trim();
if let Some(caps) = TYPE_START_PATTERN.captures(stripped) {
if stripped.starts_with("(*") && stripped.contains("type ") {
i += 1;
continue;
}
if let Some(m) = caps.get(1) {
let type_name = m.as_str().to_string();
let actual_start = find_attribute_prefix_lines(&lines, i);
let end_line = find_type_block_end(&lines, i);
let block: String = lines[actual_start..=end_line].join("\n");
let is_private = is_private_type(&block);
let and_types = extract_and_types(&block);
types.insert(
type_name.clone(),
TypeDefinition {
name: type_name,
block: block.clone(),
start_line: actual_start,
end_line,
is_private,
and_types: and_types.clone(),
},
);
for and_type in &and_types {
types.insert(
and_type.clone(),
TypeDefinition {
name: and_type.clone(),
block: block.clone(),
start_line: actual_start,
end_line,
is_private,
and_types: and_types.clone(),
},
);
}
i = end_line + 1;
continue;
}
}
i += 1;
}
types
}
#[derive(Debug, Clone)]
pub struct FixValidation {
pub is_safe: bool,
pub reason: String,
pub removal_preview: String,
pub warnings: Vec<String>,
}
impl FixValidation {
fn safe(preview: String) -> Self {
Self {
is_safe: true,
reason: "Definitions match exactly; safe to remove".to_string(),
removal_preview: preview,
warnings: vec![],
}
}
fn safe_with_warnings(preview: String, warnings: Vec<String>) -> Self {
Self {
is_safe: true,
reason: "Definitions match; safe to remove with warnings".to_string(),
removal_preview: preview,
warnings,
}
}
fn unsafe_mismatch(fst_def: &str, fsti_def: &str) -> Self {
Self {
is_safe: false,
reason: format!(
"Type definitions differ:\n .fst: {}\n .fsti: {}",
fst_def.lines().next().unwrap_or(""),
fsti_def.lines().next().unwrap_or("")
),
removal_preview: String::new(),
warnings: vec![],
}
}
fn unsafe_reason(reason: String) -> Self {
Self {
is_safe: false,
reason,
removal_preview: String::new(),
warnings: vec![],
}
}
}
fn normalize_type_definition(block: &str) -> String {
let without_block_comments = BLOCK_COMMENT_PATTERN.replace_all(block, " ");
let without_comments = LINE_COMMENT_PATTERN.replace_all(&without_block_comments, "");
let normalized: String = without_comments
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
lazy_static! {
static ref ATTR_PATTERN: Regex = Regex::new(r"\[@+[^\]]*\]").unwrap();
}
let without_attrs = ATTR_PATTERN.replace_all(&normalized, "").to_string();
without_attrs.split_whitespace().collect::<Vec<_>>().join(" ")
}
pub fn definitions_match(fst_block: &str, fsti_block: &str) -> bool {
let norm_fst = normalize_type_definition(fst_block);
let norm_fsti = normalize_type_definition(fsti_block);
norm_fst == norm_fsti
}
fn extract_type_signature(block: &str) -> Option<String> {
let normalized = normalize_type_definition(block);
if let Some(idx) = normalized.find("type ") {
Some(normalized[idx..].to_string())
} else {
None
}
}
pub fn signatures_match(fst_block: &str, fsti_block: &str) -> bool {
match (extract_type_signature(fst_block), extract_type_signature(fsti_block)) {
(Some(fst_sig), Some(fsti_sig)) => fst_sig == fsti_sig,
_ => false,
}
}
fn find_type_references(content: &str, type_name: &str, def_start: usize, def_end: usize) -> Vec<usize> {
let mut references = Vec::new();
let pattern = format!(r"\b{}\b", regex::escape(type_name));
let re = Regex::new(&pattern).unwrap();
for (line_idx, line) in content.lines().enumerate() {
if line_idx >= def_start && line_idx <= def_end {
continue;
}
let trimmed = line.trim();
if trimmed.starts_with("//") || trimmed.starts_with("(*") {
continue;
}
if re.is_match(line) {
references.push(line_idx + 1); }
}
references
}
pub fn validate_fix(
fst_content: &str,
_fsti_content: &str,
type_name: &str,
fst_typedef: &TypeDefinition,
fsti_typedef: &TypeDefinition,
) -> FixValidation {
if !definitions_match(&fst_typedef.block, &fsti_typedef.block) {
if !signatures_match(&fst_typedef.block, &fsti_typedef.block) {
return FixValidation::unsafe_mismatch(&fst_typedef.block, &fsti_typedef.block);
}
let warnings = vec![
"Definitions have minor differences (comments/attributes)".to_string(),
];
let preview = create_removal_preview(&fst_typedef.block, fst_typedef.start_line);
return FixValidation::safe_with_warnings(preview, warnings);
}
let references = find_type_references(
fst_content,
type_name,
fst_typedef.start_line,
fst_typedef.end_line,
);
let preview = create_removal_preview(&fst_typedef.block, fst_typedef.start_line);
if !references.is_empty() {
let warnings = vec![format!(
"Type `{}` is referenced at lines: {:?} (will use .fsti definition)",
type_name, references
)];
return FixValidation::safe_with_warnings(preview, warnings);
}
FixValidation::safe(preview)
}
fn create_removal_preview(block: &str, start_line: usize) -> String {
let lines: Vec<&str> = block.lines().collect();
let preview_lines = if lines.len() > 5 {
format!(
"Lines {}-{} ({} lines):\n {}\n {}\n ...\n {}",
start_line + 1,
start_line + lines.len(),
lines.len(),
lines[0],
lines[1],
lines[lines.len() - 1]
)
} else {
format!(
"Lines {}-{}:\n{}",
start_line + 1,
start_line + lines.len(),
block
.lines()
.map(|l| format!(" {}", l))
.collect::<Vec<_>>()
.join("\n")
)
};
preview_lines
}
#[derive(Debug, Clone)]
pub struct DryRunResult {
pub fst_file: PathBuf,
pub fsti_file: PathBuf,
pub proposed_fixes: Vec<(String, FixValidation)>,
pub safe_count: usize,
pub unsafe_count: usize,
}
pub fn dry_run_check(
fst_file: &PathBuf,
fst_content: &str,
fsti_file: &PathBuf,
fsti_content: &str,
) -> DryRunResult {
let fst_types = find_type_definitions(fst_content);
let fsti_types = find_type_definitions(fsti_content);
let mut proposed_fixes = Vec::new();
let mut safe_count = 0;
let mut unsafe_count = 0;
let fsti_concrete: HashMap<&String, &TypeDefinition> = fsti_types
.iter()
.filter(|(_, typedef)| is_concrete_definition(&typedef.block))
.collect();
let mut processed_ranges: HashSet<(usize, usize)> = HashSet::new();
for (type_name, fst_typedef) in &fst_types {
if fst_typedef.is_private {
continue;
}
let range_key = (fst_typedef.start_line, fst_typedef.end_line);
if processed_ranges.contains(&range_key) {
continue;
}
if let Some(fsti_typedef) = fsti_concrete.get(type_name) {
processed_ranges.insert(range_key);
let validation = validate_fix(
fst_content,
fsti_content,
type_name,
fst_typedef,
fsti_typedef,
);
if validation.is_safe {
safe_count += 1;
} else {
unsafe_count += 1;
}
proposed_fixes.push((type_name.clone(), validation));
}
}
DryRunResult {
fst_file: fst_file.clone(),
fsti_file: fsti_file.clone(),
proposed_fixes,
safe_count,
unsafe_count,
}
}
fn is_concrete_definition(block: &str) -> bool {
let cleaned = BLOCK_COMMENT_PATTERN.replace_all(block, "");
let cleaned = LINE_COMMENT_PATTERN.replace_all(&cleaned, "");
let chars: Vec<char> = cleaned.chars().collect();
let len = chars.len();
let mut depth: i32 = 0;
let mut in_string = false;
let mut i = 0;
while i < len {
let ch = chars[i];
if ch == '"' && (i == 0 || chars[i - 1] != '\\') {
in_string = !in_string;
i += 1;
continue;
}
if in_string {
i += 1;
continue;
}
match ch {
'(' | '{' | '[' => depth += 1,
')' | '}' | ']' => {
if depth > 0 {
depth -= 1;
}
}
'=' if depth == 0 => {
let prev = if i > 0 { chars[i - 1] } else { ' ' };
let next = if i + 1 < len { chars[i + 1] } else { ' ' };
let is_compound = prev == '!' || prev == '<' || prev == '>'
|| prev == '=' || next == '=' || next == '>';
if !is_compound {
return true;
}
}
_ => {}
}
i += 1;
}
false
}
pub struct DuplicateTypesRule;
impl DuplicateTypesRule {
pub fn new() -> Self {
Self
}
}
impl Default for DuplicateTypesRule {
fn default() -> Self {
Self::new()
}
}
impl Rule for DuplicateTypesRule {
fn code(&self) -> RuleCode {
RuleCode::FST001
}
fn check(&self, _file: &PathBuf, _content: &str) -> Vec<Diagnostic> {
vec![]
}
fn requires_pair(&self) -> bool {
true
}
fn check_pair(
&self,
fst_file: &PathBuf,
fst_content: &str,
_fsti_file: &PathBuf,
fsti_content: &str,
) -> Vec<Diagnostic> {
let fst_types = find_type_definitions(fst_content);
let fsti_types = find_type_definitions(fsti_content);
let mut diagnostics = Vec::new();
let fsti_concrete: HashMap<&String, &TypeDefinition> = fsti_types
.iter()
.filter(|(_, typedef)| is_concrete_definition(&typedef.block))
.collect();
let mut removed_ranges: HashSet<(usize, usize)> = HashSet::new();
for (type_name, fst_typedef) in &fst_types {
if fst_typedef.is_private {
continue;
}
if let Some(fsti_typedef) = fsti_concrete.get(type_name) {
let range_key = (fst_typedef.start_line, fst_typedef.end_line);
if !removed_ranges.contains(&range_key) {
removed_ranges.insert(range_key);
let validation = validate_fix(
fst_content,
fsti_content,
type_name,
fst_typedef,
fsti_typedef,
);
let mut message = format!(
"Type `{}` is defined in both .fst and .fsti.",
type_name
);
let fix = if validation.is_safe {
if !validation.warnings.is_empty() {
message.push_str("\n\nWarnings:");
for warning in &validation.warnings {
message.push_str(&format!("\n - {}", warning));
}
}
message.push_str("\n\nSafe to remove the duplicate from .fst.");
let edits = vec![Edit {
file: fst_file.clone(),
range: Range::new(
fst_typedef.start_line + 1,
1,
fst_typedef.end_line + 2, 1,
),
new_text: String::new(),
}];
let fix = if validation.warnings.is_empty() {
Fix::safe(
format!(
"Remove duplicate type `{}` (definitions match exactly)\n\nPreview:\n{}",
type_name, validation.removal_preview
),
edits,
)
.with_reversible(false) } else {
Fix::caution(
format!(
"Remove duplicate type `{}` (definitions match with minor differences)\n\nPreview:\n{}",
type_name, validation.removal_preview
),
edits,
)
.with_reversible(false)
.with_requires_review(true)
};
Some(fix)
} else {
message.push_str(&format!(
"\n\nNO AUTOFIX: {}\n\nManual review required.",
validation.reason
));
None
};
diagnostics.push(Diagnostic {
rule: RuleCode::FST001,
severity: DiagnosticSeverity::Warning,
file: fst_file.clone(),
range: Range::new(
fst_typedef.start_line + 1,
1,
fst_typedef.end_line + 1,
1,
),
message,
fix,
});
}
}
}
diagnostics
}
}
pub fn cleanup_consecutive_blanks(content: &str) -> String {
let mut result = Vec::new();
let mut prev_blank = false;
for line in content.lines() {
let is_blank = line.trim().is_empty();
if is_blank && prev_blank {
continue;
}
result.push(line);
prev_blank = is_blank;
}
let mut output = result.join("\n");
if content.ends_with('\n') {
output.push('\n');
}
output
}
pub fn find_fst_fsti_pairs(files: &[PathBuf]) -> Vec<(PathBuf, PathBuf)> {
let mut pairs = Vec::new();
let mut fst_files: HashMap<String, PathBuf> = HashMap::new();
for file in files {
if let Some(ext) = file.extension() {
if ext == "fst" {
if let Some(stem) = file.file_stem() {
fst_files.insert(stem.to_string_lossy().to_string(), file.clone());
}
}
}
}
for file in files {
if let Some(ext) = file.extension() {
if ext == "fsti" {
if let Some(stem) = file.file_stem() {
let stem_str = stem.to_string_lossy().to_string();
let fst_path = file.with_extension("fst");
if let Some(fst) = fst_files.get(&stem_str) {
pairs.push((fst.clone(), file.clone()));
} else if fst_path.exists() {
pairs.push((fst_path, file.clone()));
}
}
}
}
}
pairs
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_find_simple_type() {
let content = r#"
module Test
type mytype = int
let x = 1
"#;
let types = find_type_definitions(content);
assert!(types.contains_key("mytype"));
}
#[test]
fn test_find_noeq_type() {
let content = r#"
noeq type buffer (a: Type) = {
content: seq a;
length: nat
}
"#;
let types = find_type_definitions(content);
assert!(types.contains_key("buffer"));
}
#[test]
fn test_find_mutual_recursion() {
let content = r#"
type expr =
| Var of string
| App of expr * expr
and pattern =
| PVar of string
| PWild
"#;
let types = find_type_definitions(content);
assert!(types.contains_key("expr"));
assert!(types.contains_key("pattern"));
assert_eq!(types["expr"].start_line, types["pattern"].start_line);
}
#[test]
fn test_private_type_detection() {
let content = r#"
private type internal_state = {
counter: nat
}
"#;
let types = find_type_definitions(content);
assert!(types.contains_key("internal_state"));
assert!(types["internal_state"].is_private);
}
#[test]
fn test_concrete_definition() {
assert!(is_concrete_definition("type foo = int"));
assert!(is_concrete_definition("noeq type bar = { x: int }"));
assert!(!is_concrete_definition("type abstract_t"));
assert!(!is_concrete_definition("type constrained : Type0"));
}
#[test]
fn test_concrete_definition_with_comments() {
assert!(!is_concrete_definition(
"// Comment with = sign\n// Another comment = here\ntype abstract_t"
));
assert!(!is_concrete_definition(
"(* Comment * with star and = inside *)\ntype abstract_t"
));
assert!(is_concrete_definition(
"// Comment with = sign\ntype foo = int"
));
assert!(is_concrete_definition("(* comment *) type bar = nat"));
}
#[test]
fn test_cleanup_consecutive_blanks() {
assert_eq!(
cleanup_consecutive_blanks("line1\n\n\n\nline2"),
"line1\n\nline2"
);
assert_eq!(
cleanup_consecutive_blanks("line1\n\nline2"),
"line1\n\nline2"
);
assert_eq!(cleanup_consecutive_blanks("line1\nline2"), "line1\nline2");
assert_eq!(
cleanup_consecutive_blanks("line1\n\n\nline2\n"),
"line1\n\nline2\n"
);
assert_eq!(cleanup_consecutive_blanks(""), "");
assert_eq!(cleanup_consecutive_blanks("\n\n\n"), "\n");
assert_eq!(
cleanup_consecutive_blanks("line1\n \n\t\n\nline2"),
"line1\n \nline2"
);
}
#[test]
fn test_detect_duplicate() {
let fst_content = r#"
module Test
type mytype = int
let foo x = x
"#;
let fsti_content = r#"
module Test
type mytype = int
val foo: int -> int
"#;
let rule = DuplicateTypesRule::new();
let diagnostics = rule.check_pair(
&PathBuf::from("Test.fst"),
fst_content,
&PathBuf::from("Test.fsti"),
fsti_content,
);
assert!(!diagnostics.is_empty());
assert!(diagnostics[0].message.contains("mytype"));
}
#[test]
fn test_no_false_positive_abstract_fsti_concrete_fst() {
let fsti_content = "module Test\n\ntype t\n\nval foo: t -> int\n";
let fst_content = "module Test\n\ntype t = int\n\nlet foo x = x\n";
let rule = DuplicateTypesRule::new();
let diagnostics = rule.check_pair(
&PathBuf::from("Test.fst"),
fst_content,
&PathBuf::from("Test.fsti"),
fsti_content,
);
assert!(diagnostics.is_empty(), "Abstract .fsti + concrete .fst should NOT be flagged");
}
#[test]
fn test_no_false_positive_abstract_with_kind() {
let fsti_content = "module Test\n\ntype t : Type0\n\nval foo: t -> int\n";
let fst_content = "module Test\n\ntype t = nat\n\nlet foo x = x\n";
let rule = DuplicateTypesRule::new();
let diagnostics = rule.check_pair(
&PathBuf::from("Test.fst"),
fst_content,
&PathBuf::from("Test.fsti"),
fsti_content,
);
assert!(diagnostics.is_empty(), "Abstract type with kind annotation should NOT be flagged");
}
#[test]
fn test_no_false_positive_abstract_eqtype() {
let fsti_content = "module Test\n\ntype t : eqtype\n";
let fst_content = "module Test\n\ntype t = | A | B | C\n";
let rule = DuplicateTypesRule::new();
let diagnostics = rule.check_pair(
&PathBuf::from("Test.fst"),
fst_content,
&PathBuf::from("Test.fsti"),
fsti_content,
);
assert!(diagnostics.is_empty(), "Abstract eqtype should NOT be flagged");
}
#[test]
fn test_concrete_equals_inside_refinement_not_concrete() {
assert!(!is_concrete_definition(
"type t (x: int{x = 0}) : Type"
));
}
#[test]
fn test_concrete_equals_inside_parens_not_concrete() {
assert!(!is_concrete_definition(
"type t (x: int) (y: (z:int{z = x})) : Type"
));
}
#[test]
fn test_concrete_multiline_abstract_with_refinement() {
let block = "type my_type\n (x: nat{x = 0})\n : Type";
assert!(!is_concrete_definition(block),
"Abstract type with = inside refinement should NOT be concrete");
}
#[test]
fn test_concrete_multiline_concrete_with_refinement() {
let block = "type my_type\n (x: nat{x = 0})\n : Type =\n | Mk : my_type";
assert!(is_concrete_definition(block),
"Concrete type with = at depth 0 should be detected");
}
#[test]
fn test_concrete_double_equals_not_concrete() {
assert!(!is_concrete_definition(
"type t (x: nat) : (y:Type{x == 0})"
));
}
#[test]
fn test_concrete_operators_not_concrete() {
assert!(!is_concrete_definition("type t (x: nat{x >= 0}) : Type"));
assert!(!is_concrete_definition("type t (x: nat{x <= 10}) : Type"));
assert!(!is_concrete_definition("type t (x: nat{x != 0}) : Type"));
}
#[test]
fn test_concrete_arrow_equals_not_concrete() {
assert!(!is_concrete_definition("type t (x: nat{x > 0 ==> True}) : Type"));
}
#[test]
fn test_concrete_adt_is_concrete() {
assert!(is_concrete_definition("type color =\n | Red\n | Green\n | Blue"));
}
#[test]
fn test_concrete_record_is_concrete() {
assert!(is_concrete_definition("noeq type rec = {\n x: int;\n y: int\n}"));
}
#[test]
fn test_concrete_type_alias_is_concrete() {
assert!(is_concrete_definition("type my_nat = nat"));
}
#[test]
fn test_find_unopteq_type() {
let content = "module Test\n\nunopteq type dtuple4\n (a: Type) (b: a -> Type)\n = | Mk : a -> dtuple4 a b\n";
let types = find_type_definitions(content);
assert!(types.contains_key("dtuple4"), "unopteq types should be detected");
}
#[test]
fn test_detect_duplicate_unopteq() {
let fsti_content = "module T\n\nunopteq type t = | A | B\n";
let fst_content = "module T\n\nunopteq type t = | A | B\n";
let rule = DuplicateTypesRule::new();
let diagnostics = rule.check_pair(
&PathBuf::from("T.fst"),
fst_content,
&PathBuf::from("T.fsti"),
fsti_content,
);
assert!(!diagnostics.is_empty(), "Duplicate unopteq types should be flagged");
}
#[test]
fn test_true_duplicate_noeq_record() {
let fsti_content = "module T\n\nnoeq type state = {\n counter: nat;\n data: list int\n}\n";
let fst_content = "module T\n\nnoeq type state = {\n counter: nat;\n data: list int\n}\n\nlet init = { counter = 0; data = [] }\n";
let rule = DuplicateTypesRule::new();
let diagnostics = rule.check_pair(
&PathBuf::from("T.fst"),
fst_content,
&PathBuf::from("T.fsti"),
fsti_content,
);
assert!(!diagnostics.is_empty(), "Identical noeq record in both files should be flagged");
}
#[test]
fn test_true_duplicate_type_alias() {
let fsti_content = "module T\n\ntype byte = UInt8.t\n";
let fst_content = "module T\n\ntype byte = UInt8.t\n\nlet zero : byte = 0uy\n";
let rule = DuplicateTypesRule::new();
let diagnostics = rule.check_pair(
&PathBuf::from("T.fst"),
fst_content,
&PathBuf::from("T.fsti"),
fsti_content,
);
assert!(!diagnostics.is_empty(), "Identical type alias in both files should be flagged");
}
#[test]
fn test_private_type_not_flagged() {
let fsti_content = "module T\n\ntype t = int\n";
let fst_content = "module T\n\ntype t = int\n\nprivate type internal = nat\n";
let rule = DuplicateTypesRule::new();
let diagnostics = rule.check_pair(
&PathBuf::from("T.fst"),
fst_content,
&PathBuf::from("T.fsti"),
fsti_content,
);
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].message.contains("t"));
}
#[test]
fn test_multiline_parameterized_concrete() {
let block = "noeq inline_for_extraction\ntype field_accessor (#k1 #k2:strong_parser_kind)\n (#t1 #t2:Type)\n (p1 : LP.parser k1 t1)\n (p2 : LP.parser k2 t2) =\n | FieldAccessor :\n (acc:LP.accessor g) ->\n field_accessor p1 p2";
assert!(is_concrete_definition(block),
"Multiline concrete type with = on later line should be detected");
}
#[test]
fn test_equals_in_string_not_concrete() {
assert!(!is_concrete_definition(
"type t (x: string{x = \"hello = world\"}) : Type"
));
}
#[test]
fn test_definitions_match_identical() {
let fst_block = "noeq type labeled_type = {\n base_type : brrr_type;\n label : sec_level;\n}";
let fsti_block = "noeq type labeled_type = {\n base_type : brrr_type;\n label : sec_level;\n}";
assert!(definitions_match(fst_block, fsti_block));
}
#[test]
fn test_definitions_match_with_whitespace_diff() {
let fst_block = "noeq type labeled_type = {\n base_type : brrr_type;\n label : sec_level;\n}";
let fsti_block = "noeq type labeled_type = { base_type : brrr_type; label : sec_level; }";
assert!(definitions_match(fst_block, fsti_block));
}
#[test]
fn test_definitions_match_with_comment_diff() {
let fst_block = "(** Labeled type *)\nnoeq type labeled_type = {\n base_type : brrr_type;\n label : sec_level;\n}";
let fsti_block = "noeq type labeled_type = {\n base_type : brrr_type;\n label : sec_level;\n}";
assert!(definitions_match(fst_block, fsti_block));
}
#[test]
fn test_definitions_dont_match_different_fields() {
let fst_block = "noeq type labeled_type = {\n base_type : brrr_type;\n label : sec_level;\n extra : nat;\n}";
let fsti_block = "noeq type labeled_type = {\n base_type : brrr_type;\n label : sec_level;\n}";
assert!(!definitions_match(fst_block, fsti_block));
}
#[test]
fn test_definitions_dont_match_different_types() {
let fst_block = "type sec_level = | Public | Secret";
let fsti_block = "type sec_level = | Public | Private | Secret";
assert!(!definitions_match(fst_block, fsti_block));
}
#[test]
fn test_no_autofix_when_definitions_differ() {
let fsti_content = "module T\n\ntype color = | Red | Green | Blue\n";
let fst_content = "module T\n\ntype color = | Red | Green\n";
let rule = DuplicateTypesRule::new();
let diagnostics = rule.check_pair(
&PathBuf::from("T.fst"),
fst_content,
&PathBuf::from("T.fsti"),
fsti_content,
);
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].fix.is_none(), "Should NOT offer autofix when definitions differ");
assert!(diagnostics[0].message.contains("NO AUTOFIX"));
}
#[test]
fn test_autofix_offered_when_definitions_match() {
let fsti_content = "module T\n\ntype sec_level =\n | Public\n | Secret\n";
let fst_content = "module T\n\ntype sec_level =\n | Public\n | Secret\n\nlet x = 1\n";
let rule = DuplicateTypesRule::new();
let diagnostics = rule.check_pair(
&PathBuf::from("T.fst"),
fst_content,
&PathBuf::from("T.fsti"),
fsti_content,
);
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].fix.is_some(), "Should offer autofix when definitions match");
assert!(diagnostics[0].message.contains("Safe to remove"));
}
#[test]
fn test_brrr_sec_level_type() {
let fsti_content = r#"module BrrrInformationFlow
type sec_level =
| Public : sec_level
| Confidential : sec_level
| Secret : sec_level
| TopSecret : sec_level
val sec_leq (l1 l2: sec_level) : bool
"#;
let fst_content = r#"module BrrrInformationFlow
type sec_level =
| Public : sec_level
| Confidential : sec_level
| Secret : sec_level
| TopSecret : sec_level
let sec_leq (l1 l2: sec_level) : bool =
match l1, l2 with
| Public, _ -> true
| _, TopSecret -> true
| Secret, Secret -> true
| Confidential, Confidential -> true
| _, _ -> false
"#;
let rule = DuplicateTypesRule::new();
let diagnostics = rule.check_pair(
&PathBuf::from("BrrrInformationFlow.fst"),
fst_content,
&PathBuf::from("BrrrInformationFlow.fsti"),
fsti_content,
);
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].fix.is_some(), "Identical sec_level should have autofix");
}
#[test]
fn test_brrr_labeled_type_noeq() {
let fsti_content = r#"module BrrrInformationFlow
noeq type labeled_type = {
base_type : brrr_type;
label : sec_level;
}
val label_type (t: brrr_type) (l: sec_level) : labeled_type
"#;
let fst_content = r#"module BrrrInformationFlow
noeq type labeled_type = {
base_type : brrr_type;
label : sec_level;
}
let label_type (t: brrr_type) (l: sec_level) : labeled_type =
{ base_type = t; label = l }
"#;
let rule = DuplicateTypesRule::new();
let diagnostics = rule.check_pair(
&PathBuf::from("BrrrInformationFlow.fst"),
fst_content,
&PathBuf::from("BrrrInformationFlow.fsti"),
fsti_content,
);
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].fix.is_some(), "Identical noeq record should have autofix");
}
#[test]
fn test_brrr_sec_check_result_adt() {
let fsti_content = r#"module BrrrInformationFlow
noeq type sec_check_result =
| SecOk : labeled_type -> sec_ctx -> sec_check_result
| SecErr : string -> sec_check_result
val check_flow (ctx: sec_ctx) (e: expr) : sec_check_result
"#;
let fst_content = r#"module BrrrInformationFlow
noeq type sec_check_result =
| SecOk : labeled_type -> sec_ctx -> sec_check_result
| SecErr : string -> sec_check_result
let check_flow (ctx: sec_ctx) (e: expr) : sec_check_result =
SecOk (public_type TUnit) ctx
"#;
let rule = DuplicateTypesRule::new();
let diagnostics = rule.check_pair(
&PathBuf::from("BrrrInformationFlow.fst"),
fst_content,
&PathBuf::from("BrrrInformationFlow.fsti"),
fsti_content,
);
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].fix.is_some(), "Identical ADT should have autofix");
}
#[test]
fn test_brrr_security_label_combined() {
let fsti_content = r#"module BrrrInformationFlow
noeq type security_label = {
confidentiality : sec_level;
integrity : integrity_level_full;
}
val security_label_leq (l1 l2: security_label) : bool
"#;
let fst_content = r#"module BrrrInformationFlow
noeq type security_label = {
confidentiality : sec_level;
integrity : integrity_level_full;
}
let security_label_leq (l1 l2: security_label) : bool =
sec_leq l1.confidentiality l2.confidentiality &&
integrity_leq_full l1.integrity l2.integrity
"#;
let rule = DuplicateTypesRule::new();
let diagnostics = rule.check_pair(
&PathBuf::from("BrrrInformationFlow.fst"),
fst_content,
&PathBuf::from("BrrrInformationFlow.fsti"),
fsti_content,
);
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].fix.is_some());
}
#[test]
fn test_no_autofix_modified_adt_variant() {
let fsti_content = r#"module T
type termination_behavior =
| MustTerminate : termination_behavior
| MayDiverge : termination_behavior
| MustDiverge : termination_behavior
"#;
let fst_content = r#"module T
type termination_behavior =
| MustTerminate : termination_behavior
| MayDiverge : termination_behavior
| Unknown : termination_behavior
"#;
let rule = DuplicateTypesRule::new();
let diagnostics = rule.check_pair(
&PathBuf::from("T.fst"),
fst_content,
&PathBuf::from("T.fsti"),
fsti_content,
);
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].fix.is_none(), "Different ADT variants should NOT have autofix");
}
#[test]
fn test_no_autofix_modified_record_field() {
let fsti_content = r#"module T
noeq type declass_policy = {
declass_name : string;
allowed_patterns : list string;
max_declass : option nat;
}
"#;
let fst_content = r#"module T
noeq type declass_policy = {
declass_name : string;
allowed_patterns : list string;
max_declass : nat;
}
"#;
let rule = DuplicateTypesRule::new();
let diagnostics = rule.check_pair(
&PathBuf::from("T.fst"),
fst_content,
&PathBuf::from("T.fsti"),
fsti_content,
);
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].fix.is_none(), "Different field types should NOT have autofix");
}
#[test]
fn test_dry_run_basic() {
let fsti_content = "module T\n\ntype t = int\n";
let fst_content = "module T\n\ntype t = int\n\nlet x = 1\n";
let result = dry_run_check(
&PathBuf::from("T.fst"),
fst_content,
&PathBuf::from("T.fsti"),
fsti_content,
);
assert_eq!(result.safe_count, 1);
assert_eq!(result.unsafe_count, 0);
assert_eq!(result.proposed_fixes.len(), 1);
assert!(result.proposed_fixes[0].1.is_safe);
}
#[test]
fn test_dry_run_mixed_safe_unsafe() {
let fsti_content = "module T\n\ntype a = int\n\ntype b = | X | Y | Z\n";
let fst_content = "module T\n\ntype a = int\n\ntype b = | X | Y\n";
let result = dry_run_check(
&PathBuf::from("T.fst"),
fst_content,
&PathBuf::from("T.fsti"),
fsti_content,
);
assert_eq!(result.safe_count, 1); assert_eq!(result.unsafe_count, 1); }
#[test]
fn test_normalize_removes_comments() {
let block = "(* This is a comment *)\ntype t = int";
let normalized = normalize_type_definition(block);
assert!(!normalized.contains("comment"));
assert!(normalized.contains("type t = int"));
}
#[test]
fn test_normalize_removes_line_comments() {
let block = "// Line comment\ntype t = int // trailing";
let normalized = normalize_type_definition(block);
assert!(!normalized.contains("comment"));
assert!(!normalized.contains("trailing"));
}
#[test]
fn test_normalize_removes_attributes() {
let block = "[@attribute] [@@another]\ntype t = int";
let normalized = normalize_type_definition(block);
assert!(!normalized.contains("attribute"));
assert!(normalized.contains("type t = int"));
}
#[test]
fn test_normalize_whitespace_collapse() {
let block = "type t = int";
let normalized = normalize_type_definition(block);
assert_eq!(normalized, "type t = int");
}
#[test]
fn test_validate_fix_safe() {
let fst_content = "module T\n\ntype t = int\n\nlet x = 1\n";
let fsti_content = "module T\n\ntype t = int\n";
let fst_types = find_type_definitions(fst_content);
let fsti_types = find_type_definitions(fsti_content);
let fst_typedef = fst_types.get("t").unwrap();
let fsti_typedef = fsti_types.get("t").unwrap();
let validation = validate_fix(fst_content, fsti_content, "t", fst_typedef, fsti_typedef);
assert!(validation.is_safe);
assert!(!validation.removal_preview.is_empty());
}
#[test]
fn test_validate_fix_unsafe_mismatch() {
let fst_content = "module T\n\ntype t = nat\n";
let fsti_content = "module T\n\ntype t = int\n";
let fst_types = find_type_definitions(fst_content);
let fsti_types = find_type_definitions(fsti_content);
let fst_typedef = fst_types.get("t").unwrap();
let fsti_typedef = fsti_types.get("t").unwrap();
let validation = validate_fix(fst_content, fsti_content, "t", fst_typedef, fsti_typedef);
assert!(!validation.is_safe);
assert!(validation.reason.contains("differ"));
}
#[test]
fn test_validate_fix_with_references() {
let fst_content = "module T\n\ntype t = int\n\nlet x : t = 0\nlet y : t = 1\n";
let fsti_content = "module T\n\ntype t = int\n";
let fst_types = find_type_definitions(fst_content);
let fsti_types = find_type_definitions(fsti_content);
let fst_typedef = fst_types.get("t").unwrap();
let fsti_typedef = fsti_types.get("t").unwrap();
let validation = validate_fix(fst_content, fsti_content, "t", fst_typedef, fsti_typedef);
assert!(validation.is_safe);
assert!(!validation.warnings.is_empty(), "Should warn about references");
}
}