use std::path::PathBuf;
use super::parser::{parse_fstar_file, BlockType};
use super::rules::{
Diagnostic, DiagnosticSeverity, Edit, Fix, FixConfidence, FixSafetyLevel, Range, Rule, RuleCode,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExtractionMode {
Minimal,
Full,
DocFocused,
}
impl Default for ExtractionMode {
fn default() -> Self {
Self::Full
}
}
#[derive(Debug, Clone)]
pub struct FstiGenerationStats {
pub line_count: usize,
pub val_count: usize,
pub type_count: usize,
pub effect_count: usize,
pub class_count: usize,
pub open_count: usize,
pub exported_names: Vec<String>,
pub validation_errors: Vec<String>,
}
impl FstiGenerationStats {
pub fn summary(&self) -> String {
let mut parts = Vec::new();
if self.val_count > 0 {
parts.push(format!("{} val(s)", self.val_count));
}
if self.type_count > 0 {
parts.push(format!("{} type(s)", self.type_count));
}
if self.effect_count > 0 {
parts.push(format!("{} effect(s)", self.effect_count));
}
if self.class_count > 0 {
parts.push(format!("{} class(es)", self.class_count));
}
format!(
"{} lines, {} declaration(s): {}",
self.line_count,
self.val_count + self.type_count + self.effect_count + self.class_count,
parts.join(", ")
)
}
}
#[derive(Debug, Clone)]
pub struct FstiValidationError {
pub message: String,
pub line: Option<usize>,
}
pub fn validate_fsti_content(content: &str) -> Vec<FstiValidationError> {
let mut errors = Vec::new();
if !content.lines().any(|line| line.trim().starts_with("module ")) {
errors.push(FstiValidationError {
message: "Generated .fsti is missing module declaration".to_string(),
line: None,
});
}
let mut paren_depth: i32 = 0;
let mut brace_depth: i32 = 0;
let mut bracket_depth: i32 = 0;
let mut in_comment = false;
let mut in_string = false;
let chars: Vec<char> = content.chars().collect();
for (i, &c) in chars.iter().enumerate() {
if c == '"' && !in_comment {
if !in_string {
in_string = true;
} else if i == 0 || chars[i - 1] != '\\' {
in_string = false;
}
continue;
}
if in_string {
continue;
}
if i + 1 < chars.len() && c == '(' && chars[i + 1] == '*' {
in_comment = true;
continue;
}
if i > 0 && chars[i - 1] == '*' && c == ')' && in_comment {
in_comment = false;
continue;
}
if in_comment {
continue;
}
match c {
'(' => paren_depth += 1,
')' => paren_depth -= 1,
'{' => brace_depth += 1,
'}' => brace_depth -= 1,
'[' => bracket_depth += 1,
']' => bracket_depth -= 1,
_ => {}
}
if paren_depth < 0 || brace_depth < 0 || bracket_depth < 0 {
errors.push(FstiValidationError {
message: "Unbalanced delimiters in generated .fsti".to_string(),
line: Some(content[..i].lines().count()),
});
break;
}
}
if paren_depth != 0 {
errors.push(FstiValidationError {
message: format!(
"Unclosed parentheses in generated .fsti (depth: {})",
paren_depth
),
line: None,
});
}
if brace_depth != 0 {
errors.push(FstiValidationError {
message: format!(
"Unclosed braces in generated .fsti (depth: {})",
brace_depth
),
line: None,
});
}
if bracket_depth != 0 {
errors.push(FstiValidationError {
message: format!(
"Unclosed brackets in generated .fsti (depth: {})",
bracket_depth
),
line: None,
});
}
for (line_num, line) in content.lines().enumerate() {
let stripped = line.trim();
if stripped.starts_with("val ") && !stripped.contains(':') && !stripped.ends_with(',') {
errors.push(FstiValidationError {
message: format!(
"Incomplete val declaration (missing type): {}",
stripped.chars().take(50).collect::<String>()
),
line: Some(line_num + 1),
});
}
}
let non_empty_lines = content
.lines()
.filter(|line| {
let stripped = line.trim();
!stripped.is_empty()
&& !stripped.starts_with("module ")
&& !stripped.starts_with("open ")
})
.count();
if non_empty_lines == 0 {
errors.push(FstiValidationError {
message: "Generated .fsti has no declarations (only module header)".to_string(),
line: None,
});
}
errors
}
pub fn generate_fsti_with_stats(content: &str, mode: ExtractionMode) -> (String, FstiGenerationStats) {
let fsti = generate_fsti(content, mode);
let mut stats = FstiGenerationStats {
line_count: fsti.lines().count(),
val_count: 0,
type_count: 0,
effect_count: 0,
class_count: 0,
open_count: 0,
exported_names: Vec::new(),
validation_errors: Vec::new(),
};
for line in fsti.lines() {
let stripped = line.trim();
if stripped.starts_with("val ") || stripped.starts_with("assume val ") {
stats.val_count += 1;
if let Some(name) = extract_val_name(stripped) {
stats.exported_names.push(name);
}
} else if stripped.starts_with("type ") || stripped.starts_with("noeq type ") {
stats.type_count += 1;
if let Some(name) = extract_type_name(stripped) {
stats.exported_names.push(name);
}
} else if stripped.starts_with("effect ") || stripped.starts_with("layered_effect ") {
stats.effect_count += 1;
} else if stripped.starts_with("class ") {
stats.class_count += 1;
} else if stripped.starts_with("open ") {
stats.open_count += 1;
}
}
let validation_errors = validate_fsti_content(&fsti);
stats.validation_errors = validation_errors
.iter()
.map(|e| e.message.clone())
.collect();
(fsti, stats)
}
fn extract_val_name(line: &str) -> Option<String> {
let stripped = line.trim();
let after_val = if stripped.starts_with("assume val ") {
&stripped[11..]
} else if stripped.starts_with("val ") {
&stripped[4..]
} else {
return None;
};
if after_val.starts_with('(') {
if let Some(end) = after_val.find(')') {
return Some(after_val[1..end].trim().to_string());
}
}
after_val
.split_whitespace()
.next()
.map(|s| s.trim_end_matches(':').to_string())
}
fn extract_type_name(line: &str) -> Option<String> {
let stripped = line.trim();
let after_type = if stripped.starts_with("noeq type ") {
&stripped[10..]
} else if stripped.starts_with("type ") {
&stripped[5..]
} else {
return None;
};
after_type
.split_whitespace()
.next()
.map(|s| s.to_string())
}
pub fn generate_fsti(content: &str, mode: ExtractionMode) -> String {
let mut fsti = String::new();
let (header, blocks) = parse_fstar_file(content);
for line in &header {
let stripped = line.trim();
if stripped.starts_with("module ") {
fsti.push_str(stripped);
fsti.push_str("\n\n");
break;
}
}
let mut has_opens = false;
for line in &header {
let stripped = line.trim();
if stripped.starts_with("open ") {
fsti.push_str(stripped);
fsti.push('\n');
has_opens = true;
}
}
if has_opens {
fsti.push('\n');
}
for block in &blocks {
let text = block.lines.join("");
if text.contains("private ") {
continue;
}
if block.names.iter().all(|n| n.starts_with('_')) {
continue;
}
match block.block_type {
BlockType::Val => {
append_block_with_doc_comment(&mut fsti, &block.lines, mode);
}
BlockType::Type => {
match mode {
ExtractionMode::Minimal => {
for name in &block.names {
if !name.starts_with('_') {
let type_params = extract_type_params(&block.lines);
if type_params.is_empty() {
fsti.push_str(&format!("val {} : Type0\n\n", name));
} else {
fsti.push_str(&format!(
"val {} : {} -> Type0\n\n",
name, type_params
));
}
}
}
}
ExtractionMode::Full | ExtractionMode::DocFocused => {
append_block_with_doc_comment(&mut fsti, &block.lines, mode);
}
}
}
BlockType::Let => {
if let Some(sig) = extract_let_signature(&block.lines, mode) {
for name in &block.names {
if !name.starts_with('_') {
if sig.contains(&format!("{} :", name))
|| sig.contains(&format!("{}:", name))
{
fsti.push_str(&format!("val {}\n\n", sig));
} else {
fsti.push_str(&format!("val {} : {}\n\n", name, sig));
}
}
}
}
}
BlockType::Class => {
append_block_with_doc_comment(&mut fsti, &block.lines, mode);
}
BlockType::Effect => {
append_block_with_doc_comment(&mut fsti, &block.lines, mode);
}
BlockType::Exception => {
append_block_with_doc_comment(&mut fsti, &block.lines, mode);
}
BlockType::Assume => {
append_block_with_doc_comment(&mut fsti, &block.lines, mode);
}
BlockType::UnfoldLet | BlockType::InlineLet => {
if let Some(sig) = extract_let_signature(&block.lines, mode) {
let first_line = block.lines.first().map(|s| s.as_str()).unwrap_or("");
let prefix = if first_line.contains("unfold")
&& first_line.contains("inline_for_extraction")
{
"unfold inline_for_extraction "
} else if first_line.contains("inline_for_extraction") {
"inline_for_extraction "
} else if first_line.contains("unfold") {
"unfold "
} else {
""
};
for name in &block.names {
if !name.starts_with('_') {
if sig.contains(&format!("{} :", name))
|| sig.contains(&format!("{}:", name))
{
fsti.push_str(&format!("{}val {}\n\n", prefix, sig));
} else {
fsti.push_str(&format!("{}val {} : {}\n\n", prefix, name, sig));
}
}
}
}
}
_ => {}
}
}
let trimmed = fsti.trim_end();
if !trimmed.is_empty() {
format!("{}\n", trimmed)
} else {
fsti
}
}
fn extract_type_params(lines: &[String]) -> String {
let first_line = lines.first().map(|s| s.as_str()).unwrap_or("");
let stripped = first_line.trim();
if let Some(eq_pos) = stripped.find('=') {
let before_eq = &stripped[..eq_pos];
let parts: Vec<&str> = before_eq.split_whitespace().collect();
if parts.len() > 2 {
let param_count = parts.len() - 2;
let params: Vec<&str> = (0..param_count).map(|_| "Type0").collect();
return params.join(" -> ");
}
}
String::new()
}
fn extract_let_signature(lines: &[String], mode: ExtractionMode) -> Option<String> {
let full_text = lines.join(" ");
let stripped = full_text.trim();
let sig_portion = match find_definition_equals(stripped) {
Some(eq_pos) => stripped[..eq_pos].trim(),
None => stripped, };
let sig_part = strip_let_keywords(sig_portion);
if !sig_part.contains(':') {
return None;
}
let result = if mode == ExtractionMode::DocFocused {
simplify_refinements(&sig_part)
} else {
sig_part
};
let cleaned = result
.replace('\n', " ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if cleaned.is_empty() {
None
} else {
Some(cleaned)
}
}
fn strip_let_keywords(text: &str) -> String {
let mut result = text.trim();
while result.starts_with("[@@") || result.starts_with("[@") {
if let Some(end_bracket) = result.find(']') {
result = result[end_bracket + 1..].trim();
} else {
break;
}
}
let keywords = [
"private",
"unfold",
"inline_for_extraction",
"noextract",
"let",
"rec",
];
for keyword in keywords {
if result.starts_with(keyword) {
let rest = &result[keyword.len()..];
if rest.is_empty() || rest.starts_with(char::is_whitespace) {
result = rest.trim_start();
}
}
}
result.to_string()
}
fn find_definition_equals(text: &str) -> Option<usize> {
let chars: Vec<char> = text.chars().collect();
let mut depth: usize = 0;
for (i, &c) in chars.iter().enumerate() {
match c {
'{' | '(' | '[' => depth += 1,
'}' | ')' | ']' => depth = depth.saturating_sub(1),
'=' if depth == 0 => {
let next = chars.get(i + 1);
let prev = chars.get(i.saturating_sub(1));
if next != Some(&'=')
&& next != Some(&'>')
&& prev != Some(&'=')
&& prev != Some(&'<')
&& prev != Some(&'>')
&& prev != Some(&'!')
{
return Some(i);
}
}
':' if depth == 0 => {
}
_ => {}
}
}
None
}
fn simplify_refinements(text: &str) -> String {
let mut result = String::new();
let chars: Vec<char> = text.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i] == '{' {
let before: String = result
.chars()
.rev()
.take(20)
.collect::<String>()
.chars()
.rev()
.collect();
if before.contains(':') || before.ends_with(|c: char| c.is_alphanumeric()) {
result.push('{');
let mut depth = 1;
let start = i + 1;
i += 1;
while i < chars.len() && depth > 0 {
if chars[i] == '{' {
depth += 1;
} else if chars[i] == '}' {
depth -= 1;
}
i += 1;
}
let refinement: String = chars[start..i.saturating_sub(1)].iter().collect();
if refinement.contains("/\\") || refinement.contains("\\/") || refinement.len() > 30
{
result.push_str("...");
} else {
result.push_str(&refinement);
}
result.push('}');
continue;
}
}
result.push(chars[i]);
i += 1;
}
result
}
fn append_block_with_doc_comment(output: &mut String, lines: &[String], mode: ExtractionMode) {
let mut started_content = false;
for line in lines {
let stripped = line.trim();
if stripped.starts_with("(*") || stripped.starts_with("*)") || stripped.contains("(*") {
if mode == ExtractionMode::DocFocused || started_content {
output.push_str(line);
}
continue;
}
if stripped.is_empty() && !started_content {
continue;
}
started_content = true;
output.push_str(line);
}
if !output.ends_with('\n') {
output.push('\n');
}
output.push('\n');
}
pub struct SpecExtractorRule {
mode: ExtractionMode,
}
impl SpecExtractorRule {
pub fn new() -> Self {
Self {
mode: ExtractionMode::Full,
}
}
pub fn with_mode(mode: ExtractionMode) -> Self {
Self { mode }
}
pub fn extract_public_decls(content: &str) -> Vec<(String, BlockType, usize)> {
let (_, blocks) = parse_fstar_file(content);
let mut public_decls = Vec::new();
for block in blocks {
let text = block.lines.join("");
if text.contains("private ") {
continue;
}
for name in &block.names {
if name.starts_with('_') {
continue;
}
match block.block_type {
BlockType::Val
| BlockType::Type
| BlockType::Let
| BlockType::Class
| BlockType::Effect
| BlockType::Exception
| BlockType::Assume
| BlockType::UnfoldLet
| BlockType::InlineLet => {
public_decls.push((name.clone(), block.block_type, block.start_line));
}
_ => {}
}
}
}
public_decls
}
}
impl Default for SpecExtractorRule {
fn default() -> Self {
Self::new()
}
}
const MIN_PUBLIC_DECLS_FOR_WARNING: usize = 3;
fn should_skip_fsti_check(file_stem: &str) -> Option<&'static str> {
let segments: Vec<&str> = file_stem.split('.').collect();
if segments
.iter()
.any(|s| s.eq_ignore_ascii_case("test") || s.eq_ignore_ascii_case("tests"))
{
return Some("test file");
}
let last = segments.last().unwrap_or(&"");
if last.ends_with("Test") || last.ends_with("Tests") || last.ends_with("_test") {
return Some("test file");
}
if segments
.iter()
.any(|s| *s == "Lemma" || *s == "Lemmas" || s.ends_with("Lemmas") || s.ends_with("Lemma"))
{
return Some("lemma/proof file");
}
if segments.iter().any(|s| {
s.eq_ignore_ascii_case("example")
|| s.eq_ignore_ascii_case("examples")
|| s.eq_ignore_ascii_case("demo")
|| s.eq_ignore_ascii_case("sample")
}) {
return Some("example/demo file");
}
None
}
impl Rule for SpecExtractorRule {
fn code(&self) -> RuleCode {
RuleCode::FST010
}
fn check(&self, file: &PathBuf, content: &str) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
if file.extension().map(|e| e != "fst").unwrap_or(true) {
return diagnostics;
}
let fsti_path = file.with_extension("fsti");
if fsti_path.exists() {
return diagnostics;
}
let file_stem = file
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
if should_skip_fsti_check(file_stem).is_some() {
return diagnostics;
}
let public_decls = Self::extract_public_decls(content);
if !public_decls.is_empty() {
let severity = if public_decls.len() < MIN_PUBLIC_DECLS_FOR_WARNING {
DiagnosticSeverity::Hint
} else {
DiagnosticSeverity::Info
};
let (fsti_content, stats) = generate_fsti_with_stats(content, self.mode);
let mut message = format!(
"No .fsti file found. {} public declaration{} could be exposed.",
public_decls.len(),
if public_decls.len() == 1 { "" } else { "s" }
);
if !fsti_content.trim().is_empty() {
message.push_str(&format!("\n [DRY-RUN] Would generate: {}", stats.summary()));
if !stats.exported_names.is_empty() {
let preview_names: Vec<&str> = stats
.exported_names
.iter()
.take(5)
.map(|s| s.as_str())
.collect();
let suffix = if stats.exported_names.len() > 5 {
format!(" ... and {} more", stats.exported_names.len() - 5)
} else {
String::new()
};
message.push_str(&format!(
"\n [DRY-RUN] Exports: {}{}",
preview_names.join(", "),
suffix
));
}
}
let validation_errors = validate_fsti_content(&fsti_content);
let fix = if !fsti_content.trim().is_empty()
&& fsti_content.lines().count() > 1
&& validation_errors.is_empty()
&& !fsti_path.exists()
{
let fix_message = format!(
"[REQUIRES REVIEW] Generate .fsti interface file ({} declarations, {} lines). \
Run with --dry-run to preview full content.",
stats.val_count + stats.type_count + stats.effect_count + stats.class_count,
stats.line_count
);
Some(
Fix::unsafe_fix(
fix_message,
vec![Edit {
file: fsti_path,
range: Range::new(1, 1, 1, 1),
new_text: fsti_content.clone(),
}],
FixConfidence::Low,
"Creates a new .fsti file. Generated content should be reviewed for \
correctness. May include incorrect signatures or miss important declarations.",
)
.with_safety_level(FixSafetyLevel::Caution) .with_reversible(true) .with_requires_review(true) )
} else if !validation_errors.is_empty() {
message.push_str("\n [WARNING] Generated content has issues:");
for err in validation_errors.iter().take(3) {
message.push_str(&format!("\n - {}", err.message));
}
if validation_errors.len() > 3 {
message.push_str(&format!(
"\n ... and {} more issues",
validation_errors.len() - 3
));
}
message.push_str("\n [NO FIX OFFERED] Fix generation quality issues first.");
None
} else {
None
};
diagnostics.push(Diagnostic {
rule: RuleCode::FST010,
severity,
file: file.clone(),
range: Range::point(1, 1),
message,
fix,
});
}
diagnostics
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_public_decls() {
let content = r#"module Test
open FStar.All
val public_func : int -> int
let public_func x = x + 1
private let internal_helper y = y * 2
type public_type = int
private type secret_type = string
let _hidden_func z = z
"#;
let decls = SpecExtractorRule::extract_public_decls(content);
let names: Vec<&str> = decls.iter().map(|(n, _, _)| n.as_str()).collect();
assert!(names.contains(&"public_func"));
assert!(names.contains(&"public_type"));
assert!(!names.contains(&"internal_helper"));
assert!(!names.contains(&"secret_type"));
assert!(!names.contains(&"_hidden_func"));
}
#[test]
fn test_no_public_decls() {
let content = r#"module Test
private let helper x = x
"#;
let decls = SpecExtractorRule::extract_public_decls(content);
assert!(decls.is_empty());
}
#[test]
fn test_check_fsti_exists() {
let rule = SpecExtractorRule::new();
let content = "module Test\n\nval foo : int";
let diags = rule.check(&PathBuf::from("test.fsti"), content);
assert!(diags.is_empty());
}
#[test]
fn test_generate_fsti_full_mode() {
let content = r#"module Test
open FStar.All
val foo : int -> int
let foo x = x + 1
type mytype = | A | B
let bar : string -> string
let bar s = s
"#;
let fsti = generate_fsti(content, ExtractionMode::Full);
assert!(fsti.contains("module Test"));
assert!(fsti.contains("open FStar.All"));
assert!(fsti.contains("val foo : int -> int"));
assert!(fsti.contains("type mytype"));
assert!(!fsti.contains("x + 1"));
}
#[test]
fn test_generate_fsti_minimal_mode() {
let content = r#"module Test
type mytype = | A | B | C
let foo : int -> int
let foo x = x + 1
"#;
let fsti = generate_fsti(content, ExtractionMode::Minimal);
assert!(fsti.contains("module Test"));
assert!(fsti.contains("val mytype : Type0"));
assert!(!fsti.contains("| A | B | C"));
}
#[test]
fn test_generate_fsti_with_type_params() {
let content = r#"module Test
type container a = | Empty | Full of a
"#;
let fsti = generate_fsti(content, ExtractionMode::Minimal);
assert!(fsti.contains("val container : Type0 -> Type0"));
}
#[test]
fn test_generate_fsti_skips_private() {
let content = r#"module Test
val public_func : int -> int
let public_func x = x
private val private_func : string -> string
private let private_func s = s
private type secret = int
"#;
let fsti = generate_fsti(content, ExtractionMode::Full);
assert!(fsti.contains("public_func"));
assert!(!fsti.contains("private_func"));
assert!(!fsti.contains("secret"));
}
#[test]
fn test_generate_fsti_skips_underscore_names() {
let content = r#"module Test
let _internal x = x
let public_thing : int -> int
let public_thing x = x
"#;
let fsti = generate_fsti(content, ExtractionMode::Full);
assert!(!fsti.contains("_internal"));
assert!(fsti.contains("public_thing"));
}
#[test]
fn test_generate_fsti_preserves_effects() {
let content = r#"module Test
effect MyEffect (a:Type) = a
exception MyException of string
"#;
let fsti = generate_fsti(content, ExtractionMode::Full);
assert!(fsti.contains("effect MyEffect"));
assert!(fsti.contains("exception MyException"));
}
#[test]
fn test_generate_fsti_inline_for_extraction() {
let content = r#"module Test
inline_for_extraction let size : nat
inline_for_extraction let size = 42
"#;
let fsti = generate_fsti(content, ExtractionMode::Full);
assert!(fsti.contains("inline_for_extraction"));
assert!(fsti.contains("val"));
}
#[test]
fn test_generate_fsti_unfold() {
let content = r#"module Test
unfold let max_size : nat
unfold let max_size = 100
"#;
let fsti = generate_fsti(content, ExtractionMode::Full);
assert!(fsti.contains("unfold"));
assert!(fsti.contains("val"));
}
#[test]
fn test_extract_let_signature_simple() {
let lines = vec!["let foo : int -> int\n".to_string()];
let sig = extract_let_signature(&lines, ExtractionMode::Full);
assert!(sig.is_some());
let sig = sig.unwrap();
assert!(sig.contains("int -> int") || sig.contains("foo : int -> int"));
}
#[test]
fn test_extract_let_signature_with_params() {
let lines = vec!["let add (x: int) (y: int) : int = x + y\n".to_string()];
let sig = extract_let_signature(&lines, ExtractionMode::Full);
assert!(sig.is_some());
}
#[test]
fn test_extract_let_signature_no_annotation() {
let lines = vec!["let foo x = x + 1\n".to_string()];
let sig = extract_let_signature(&lines, ExtractionMode::Full);
assert!(sig.is_none());
}
#[test]
fn test_simplify_refinements() {
let complex = "x:nat{x > 0 /\\ x < 100 /\\ is_prime x}";
let simplified = simplify_refinements(complex);
assert!(simplified.contains("{...}"));
}
#[test]
fn test_simplify_refinements_simple() {
let simple = "x:nat{x > 0}";
let simplified = simplify_refinements(simple);
assert!(simplified.contains("x > 0"));
}
#[test]
fn test_doc_focused_mode() {
let content = r#"module Test
(** This is a documented function *)
val documented_func : x:nat{x > 0 /\ x < 1000000 /\ is_valid x} -> nat
let helper : int -> int
let helper x = x
"#;
let fsti = generate_fsti(content, ExtractionMode::DocFocused);
assert!(fsti.contains("module Test"));
assert!(fsti.contains("documented_func"));
}
#[test]
fn test_find_definition_equals() {
assert!(find_definition_equals("let x = 1").is_some());
let complex = "let foo : x:int{x = 0} -> int = fun x -> x";
let pos = find_definition_equals(complex);
assert!(pos.is_some());
let pos = pos.unwrap();
assert!(complex[pos..].starts_with("= fun"));
}
#[test]
fn test_full_fsti_generation() {
let content = r#"module MyModule
open FStar.All
open FStar.List.Tot
(** Main processing function *)
val process : list int -> list int
let process xs =
let helper x = x + 1 in
List.map helper xs
type config = {
name: string;
value: int
}
private let secret_impl x = x * 2
let public_const : nat
let public_const = 42
"#;
let fsti = generate_fsti(content, ExtractionMode::Full);
assert!(fsti.starts_with("module MyModule"));
assert!(fsti.contains("open FStar.All"));
assert!(fsti.contains("open FStar.List.Tot"));
assert!(fsti.contains("val process"));
assert!(fsti.contains("type config"));
assert!(fsti.contains("public_const"));
assert!(!fsti.contains("secret_impl"));
assert!(!fsti.contains("helper x = x + 1"));
assert!(!fsti.contains("List.map"));
}
#[test]
fn test_check_generates_fix() {
let rule = SpecExtractorRule::new();
let content = r#"module MyLib
val foo : int -> int
let foo x = x + 1
val bar : string -> string
let bar s = s
type config = int
"#;
let diags = rule.check(&PathBuf::from("/nonexistent/MyLib.fst"), content);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].rule, RuleCode::FST010);
assert!(diags[0].fix.is_some());
let fix = diags[0].fix.as_ref().unwrap();
assert!(fix.message.contains("Generate .fsti"));
assert!(!fix.edits.is_empty());
let edit = &fix.edits[0];
assert!(edit.new_text.contains("module MyLib"));
assert!(edit.new_text.contains("val foo"));
}
#[test]
fn test_extraction_mode_default() {
let mode = ExtractionMode::default();
assert_eq!(mode, ExtractionMode::Full);
}
#[test]
fn test_with_mode_constructor() {
let rule = SpecExtractorRule::with_mode(ExtractionMode::Minimal);
assert_eq!(rule.mode, ExtractionMode::Minimal);
}
#[test]
fn test_complex_type_extraction() {
let content = r#"module Types
noeq type buffer (a:Type) = {
ptr: ref a;
len: nat
}
type result a b =
| Ok of a
| Err of b
"#;
let fsti_full = generate_fsti(content, ExtractionMode::Full);
let fsti_minimal = generate_fsti(content, ExtractionMode::Minimal);
assert!(fsti_full.contains("noeq type buffer"));
assert!(fsti_full.contains("type result"));
assert!(fsti_minimal.contains("val buffer"));
assert!(fsti_minimal.contains("val result"));
assert!(!fsti_minimal.contains("noeq"));
}
#[test]
fn test_assume_val_extraction() {
let content = r#"module Extern
assume val external_func : int -> int
val normal_func : string -> string
"#;
let fsti = generate_fsti(content, ExtractionMode::Full);
assert!(fsti.contains("assume val external_func"));
assert!(fsti.contains("val normal_func"));
}
#[test]
fn test_class_extraction() {
let content = r#"module Classes
class eq (a:Type) = {
op_eq: a -> a -> bool
}
"#;
let fsti = generate_fsti(content, ExtractionMode::Full);
assert!(fsti.contains("class eq"));
}
#[test]
fn test_empty_module() {
let content = r#"module Empty
(* Just a comment *)
"#;
let fsti = generate_fsti(content, ExtractionMode::Full);
assert!(fsti.contains("module Empty"));
assert!(fsti.lines().count() <= 2);
}
#[test]
fn test_multiple_opens_ordering() {
let content = r#"module Test
open A
open B
open C
val foo : int
"#;
let fsti = generate_fsti(content, ExtractionMode::Full);
let a_pos = fsti.find("open A").unwrap();
let b_pos = fsti.find("open B").unwrap();
let c_pos = fsti.find("open C").unwrap();
assert!(a_pos < b_pos);
assert!(b_pos < c_pos);
}
#[test]
fn test_should_skip_test_files() {
assert!(should_skip_fsti_check("Hacl.Test.SHA2").is_some());
assert!(should_skip_fsti_check("Spec.AES.Test").is_some());
assert!(should_skip_fsti_check("ASN1.Test.Interpreter").is_some());
assert!(should_skip_fsti_check("ASN1Test").is_some());
assert!(should_skip_fsti_check("MyModule_test").is_some());
assert!(should_skip_fsti_check("Hacl.Tests.Integration").is_some());
assert!(should_skip_fsti_check("Hacl.Impl.SHA2").is_none());
assert!(should_skip_fsti_check("Attestation").is_none());
}
#[test]
fn test_should_skip_lemma_files() {
assert!(should_skip_fsti_check("Hacl.Spec.Chacha20.Lemmas").is_some());
assert!(should_skip_fsti_check("Hacl.Hash.Lemmas").is_some());
assert!(should_skip_fsti_check("Hacl.Spec.K256.MathLemmas").is_some());
assert!(should_skip_fsti_check("Hacl.Impl.Curve25519.Lemma").is_some());
assert!(should_skip_fsti_check("Hacl.Spec.K256.Field52").is_none());
}
#[test]
fn test_should_skip_example_files() {
assert!(should_skip_fsti_check("Example").is_some());
assert!(should_skip_fsti_check("My.Example.Module").is_some());
assert!(should_skip_fsti_check("Demo").is_some());
assert!(should_skip_fsti_check("Sample").is_some());
assert!(should_skip_fsti_check("ExampleParser").is_none());
}
#[test]
fn test_should_not_skip_normal_files() {
assert!(should_skip_fsti_check("Hacl.Bignum.Addition").is_none());
assert!(should_skip_fsti_check("EverCrypt.AEAD").is_none());
assert!(should_skip_fsti_check("CBOR.Pulse.Raw.Type").is_none());
assert!(should_skip_fsti_check("ASN1.Base").is_none());
}
#[test]
fn test_check_skips_test_file() {
let rule = SpecExtractorRule::new();
let content = r#"module Hacl.Test.SHA2
val test_sha2 : int -> int
let test_sha2 x = x + 1
val test_helper : string -> string
let test_helper s = s
val another_public : nat -> nat
"#;
let diags = rule.check(&PathBuf::from("/nonexistent/Hacl.Test.SHA2.fst"), content);
assert!(diags.is_empty(), "Test files should not trigger FST010");
}
#[test]
fn test_check_skips_lemma_file() {
let rule = SpecExtractorRule::new();
let content = r#"module Hacl.Spec.Chacha20.Lemmas
val lemma_chacha : x:nat -> Lemma (x >= 0)
val lemma_round : x:nat -> Lemma (x + 0 = x)
val lemma_quarter : x:nat -> y:nat -> Lemma (x + y >= x)
"#;
let diags = rule.check(
&PathBuf::from("/nonexistent/Hacl.Spec.Chacha20.Lemmas.fst"),
content,
);
assert!(diags.is_empty(), "Lemma files should not trigger FST010");
}
#[test]
fn test_check_skips_example_file() {
let rule = SpecExtractorRule::new();
let content = r#"module Example
val main : unit -> unit
let main () = ()
"#;
let diags = rule.check(&PathBuf::from("/nonexistent/Example.fst"), content);
assert!(diags.is_empty(), "Example files should not trigger FST010");
}
#[test]
fn test_check_hint_severity_for_few_decls() {
let rule = SpecExtractorRule::new();
let content = r#"module SmallHelper
val helper : int -> int
let helper x = x + 1
"#;
let diags = rule.check(&PathBuf::from("/nonexistent/SmallHelper.fst"), content);
assert_eq!(diags.len(), 1);
assert_eq!(
diags[0].severity,
DiagnosticSeverity::Hint,
"Files with fewer than {} public decls should be Hint severity",
MIN_PUBLIC_DECLS_FOR_WARNING
);
}
#[test]
fn test_check_info_severity_for_many_decls() {
let rule = SpecExtractorRule::new();
let content = r#"module BigAPI
val func_a : int -> int
let func_a x = x + 1
val func_b : string -> string
let func_b s = s
val func_c : nat -> nat
let func_c n = n
type my_config = int
"#;
let diags = rule.check(&PathBuf::from("/nonexistent/BigAPI.fst"), content);
assert_eq!(diags.len(), 1);
assert_eq!(
diags[0].severity,
DiagnosticSeverity::Info,
"Files with {} or more public decls should be Info severity",
MIN_PUBLIC_DECLS_FOR_WARNING
);
}
#[test]
fn test_validate_fsti_content_valid() {
let content = r#"module Test
open FStar.All
val foo : int -> int
type config = int
"#;
let errors = validate_fsti_content(content);
assert!(
errors.is_empty(),
"Valid .fsti should have no validation errors: {:?}",
errors
);
}
#[test]
fn test_validate_fsti_content_missing_module() {
let content = r#"val foo : int -> int
type config = int
"#;
let errors = validate_fsti_content(content);
assert!(
!errors.is_empty(),
"Missing module declaration should be detected"
);
assert!(errors.iter().any(|e| e.message.contains("module")));
}
#[test]
fn test_validate_fsti_content_unbalanced_parens() {
let content = r#"module Test
val foo : (int -> int
"#;
let errors = validate_fsti_content(content);
assert!(
!errors.is_empty(),
"Unbalanced parens should be detected"
);
assert!(errors.iter().any(|e| e.message.contains("parentheses") || e.message.contains("Unbalanced")));
}
#[test]
fn test_validate_fsti_content_unbalanced_braces() {
let content = r#"module Test
type config = {
name: string
"#;
let errors = validate_fsti_content(content);
assert!(
!errors.is_empty(),
"Unbalanced braces should be detected"
);
}
#[test]
fn test_validate_fsti_content_empty_module() {
let content = r#"module Test
"#;
let errors = validate_fsti_content(content);
assert!(
!errors.is_empty(),
"Empty module should be detected"
);
assert!(errors.iter().any(|e| e.message.contains("no declarations")));
}
#[test]
fn test_generate_fsti_with_stats() {
let content = r#"module TestStats
open FStar.All
open FStar.List.Tot
val foo : int -> int
let foo x = x + 1
val bar : string -> string
let bar s = s
type config = int
effect MyEffect (a:Type) = a
"#;
let (fsti, stats) = generate_fsti_with_stats(content, ExtractionMode::Full);
assert!(fsti.contains("module TestStats"));
assert!(fsti.contains("val foo"));
assert!(fsti.contains("val bar"));
assert!(fsti.contains("type config"));
assert!(stats.line_count > 0, "Should have lines");
assert!(stats.val_count >= 2, "Should have at least 2 vals: {}", stats.val_count);
assert!(stats.type_count >= 1, "Should have at least 1 type: {}", stats.type_count);
assert!(stats.open_count == 2, "Should have 2 opens: {}", stats.open_count);
assert!(!stats.exported_names.is_empty(), "Should have exported names");
assert!(stats.validation_errors.is_empty(), "Should have no validation errors");
}
#[test]
fn test_stats_summary() {
let stats = FstiGenerationStats {
line_count: 15,
val_count: 3,
type_count: 2,
effect_count: 1,
class_count: 0,
open_count: 2,
exported_names: vec!["foo".to_string(), "bar".to_string()],
validation_errors: Vec::new(),
};
let summary = stats.summary();
assert!(summary.contains("15 lines"), "Summary should include line count");
assert!(summary.contains("val"), "Summary should include val count");
assert!(summary.contains("type"), "Summary should include type count");
assert!(summary.contains("effect"), "Summary should include effect count");
}
#[test]
fn test_fix_is_low_confidence() {
let rule = SpecExtractorRule::new();
let content = r#"module MyLib
val foo : int -> int
let foo x = x + 1
val bar : string -> string
let bar s = s
type config = int
"#;
let diags = rule.check(&PathBuf::from("/nonexistent/MyLib.fst"), content);
assert_eq!(diags.len(), 1);
let fix = diags[0].fix.as_ref().expect("Should have a fix");
assert_eq!(
fix.confidence,
FixConfidence::Low,
"FST010 fix MUST be Low confidence to prevent auto-application"
);
assert!(
!fix.is_safe,
"FST010 fix MUST be marked unsafe"
);
assert!(
fix.unsafe_reason.is_some(),
"FST010 fix MUST have an unsafe reason"
);
assert!(
!fix.can_auto_apply(),
"FST010 fix MUST NOT be auto-applicable"
);
}
#[test]
fn test_fix_message_indicates_review_required() {
let rule = SpecExtractorRule::new();
let content = r#"module MyLib
val foo : int -> int
let foo x = x + 1
val bar : string -> string
"#;
let diags = rule.check(&PathBuf::from("/nonexistent/MyLib.fst"), content);
assert_eq!(diags.len(), 1);
let fix = diags[0].fix.as_ref().expect("Should have a fix");
assert!(
fix.message.contains("REVIEW") || fix.message.contains("review"),
"Fix message should indicate review is required: {}",
fix.message
);
}
#[test]
fn test_diagnostic_includes_dry_run_info() {
let rule = SpecExtractorRule::new();
let content = r#"module MyLib
val foo : int -> int
let foo x = x + 1
val bar : string -> string
let bar s = s
type config = int
"#;
let diags = rule.check(&PathBuf::from("/nonexistent/MyLib.fst"), content);
assert_eq!(diags.len(), 1);
assert!(
diags[0].message.contains("DRY-RUN"),
"Diagnostic should include DRY-RUN info: {}",
diags[0].message
);
}
#[test]
fn test_no_fix_when_validation_fails() {
let content = r#"module Test
val incomplete_val
"#;
let errors = validate_fsti_content(content);
}
#[test]
fn test_extract_val_name() {
assert_eq!(extract_val_name("val foo : int -> int"), Some("foo".to_string()));
assert_eq!(extract_val_name("assume val bar : string"), Some("bar".to_string()));
assert_eq!(extract_val_name("val (+) : int -> int -> int"), Some("+".to_string()));
assert_eq!(extract_val_name("type foo = int"), None);
}
#[test]
fn test_extract_type_name() {
assert_eq!(extract_type_name("type foo = int"), Some("foo".to_string()));
assert_eq!(extract_type_name("noeq type buffer a = ..."), Some("buffer".to_string()));
assert_eq!(extract_type_name("val foo : int"), None);
}
#[test]
fn test_effect_extraction() {
let content = r#"module Effects
effect MyEffect (a:Type) = Tot a
layered_effect StackEffect (a:Type) {
return = fun x -> x;
bind = fun m f -> f m
}
"#;
let fsti = generate_fsti(content, ExtractionMode::Full);
assert!(fsti.contains("effect MyEffect"), "Effect should be extracted");
assert!(fsti.contains("layered_effect StackEffect"), "Layered effect should be extracted");
}
#[test]
fn test_mutual_recursion_extraction() {
let content = r#"module Mutual
type expr =
| ELit of int
| EApp of expr * stmt
and stmt =
| SExpr of expr
| SSeq of stmt * stmt
"#;
let fsti = generate_fsti(content, ExtractionMode::Full);
assert!(fsti.contains("type expr"), "First type should be extracted");
assert!(fsti.contains("and stmt"), "Mutual recursive type should be extracted with 'and'");
}
#[test]
fn test_type_class_extraction() {
let content = r#"module TypeClasses
class eq (a:Type) = {
op_eq: a -> a -> bool
}
class ord (a:Type) = {
op_lt: a -> a -> bool;
op_le: a -> a -> bool
}
"#;
let fsti = generate_fsti(content, ExtractionMode::Full);
assert!(fsti.contains("class eq"), "Class should be extracted");
assert!(fsti.contains("class ord"), "Second class should be extracted");
assert!(fsti.contains("op_eq"), "Class members should be included");
}
#[test]
fn test_exception_extraction() {
let content = r#"module Exceptions
exception MyError of string
exception AnotherError of int * string
"#;
let fsti = generate_fsti(content, ExtractionMode::Full);
assert!(fsti.contains("exception MyError"), "Exception should be extracted");
assert!(fsti.contains("exception AnotherError"), "Second exception should be extracted");
}
#[test]
fn test_combined_complex_module() {
let content = r#"module Complex
open FStar.All
(** A simple effect *)
effect MyEff (a:Type) = Tot a
(** Type class for equality *)
class eq (a:Type) = {
op_eq: a -> a -> bool
}
(** Mutual recursive types *)
type expr =
| ELit of int
| EApp of expr * stmt
and stmt =
| SExpr of expr
(** Exception type *)
exception ParseError of string
(** Public function *)
val eval : expr -> int
let eval e = match e with | ELit n -> n | _ -> 0
private let helper x = x + 1
"#;
let (fsti, stats) = generate_fsti_with_stats(content, ExtractionMode::Full);
assert!(fsti.contains("module Complex"), "Module declaration");
assert!(fsti.contains("open FStar.All"), "Open statement");
assert!(fsti.contains("effect MyEff"), "Effect");
assert!(fsti.contains("class eq"), "Type class");
assert!(fsti.contains("type expr"), "First mutual type");
assert!(fsti.contains("and stmt"), "Second mutual type");
assert!(fsti.contains("exception ParseError"), "Exception");
assert!(fsti.contains("val eval"), "Public function");
assert!(!fsti.contains("helper"), "Private helper should not be in .fsti");
assert!(stats.effect_count >= 1, "Should count effect");
assert!(stats.class_count >= 1, "Should count class");
assert!(stats.type_count >= 1, "Should count types");
assert!(stats.validation_errors.is_empty(), "Should be valid");
}
}