use anyhow::Result;
use tracing::{error, warn};
use crate::cli::args::FmtArgs;
use crate::optimizer;
use crate::parser;
use crate::semantics;
use crate::utils::FileUtils;
use crate::utils::gctf_style::trailing_blank_line_count;
fn normalize_hash_comment_line(line: &str) -> Option<String> {
let trimmed = line.trim_start();
if !trimmed.starts_with('#') {
return None;
}
let indent_len = line.len() - trimmed.len();
let indent = &line[..indent_len];
let rest = trimmed.trim_start_matches('#').trim_start();
if rest.is_empty() {
Some(format!("{}//", indent))
} else {
Some(format!("{}// {}", indent, rest))
}
}
fn format_json_content(value: &serde_json::Value) -> Vec<String> {
serde_json::to_string_pretty(value)
.unwrap_or_else(|_| value.to_string())
.lines()
.map(str::to_string)
.collect()
}
fn format_json_with_comments(raw: &str) -> Vec<String> {
fn push_indent(buf: &mut String, indent: usize) {
for _ in 0..indent {
buf.push_str(" ");
}
}
let mut out = String::new();
let mut chars = raw.chars().peekable();
let mut indent = 0usize;
let mut in_string = false;
let mut escaped = false;
let mut in_line_comment = false;
let mut in_block_comment = false;
while let Some(ch) = chars.next() {
if in_line_comment {
if ch == '\n' {
in_line_comment = false;
if !out.ends_with('\n') {
out.push('\n');
}
push_indent(&mut out, indent);
} else if ch != '\r' {
out.push(ch);
}
continue;
}
if in_block_comment {
out.push(ch);
if ch == '*'
&& let Some('/') = chars.peek()
{
out.push('/');
chars.next();
in_block_comment = false;
}
continue;
}
if in_string {
out.push(ch);
if escaped {
escaped = false;
continue;
}
if ch == '\\' {
escaped = true;
} else if ch == '"' {
in_string = false;
}
continue;
}
if ch == '"' {
in_string = true;
out.push(ch);
continue;
}
if ch == '#' || (ch == '/' && matches!(chars.peek(), Some('/') | Some('*'))) {
if !out.ends_with('\n') && !out.ends_with(' ') {
out.push(' ');
}
if ch == '/' {
match chars.peek() {
Some('/') => {
out.push('/');
out.push('/');
chars.next();
in_line_comment = true;
}
Some('*') => {
out.push('/');
out.push('*');
chars.next();
in_block_comment = true;
}
_ => out.push('/'),
}
} else {
out.push('#');
in_line_comment = true;
}
continue;
}
match ch {
'{' | '[' => {
out.push(ch);
out.push('\n');
indent += 1;
push_indent(&mut out, indent);
}
'}' | ']' => {
if (ch == '}' && out.ends_with('{')) || (ch == ']' && out.ends_with('[')) {
out.push(ch);
continue;
}
indent = indent.saturating_sub(1);
while out.ends_with(' ') {
out.pop();
}
if !out.ends_with('\n') {
out.push('\n');
}
push_indent(&mut out, indent);
out.push(ch);
}
',' => {
out.push(',');
out.push('\n');
push_indent(&mut out, indent);
}
':' => {
out.push(':');
out.push(' ');
}
c if c.is_whitespace() => {}
_ => out.push(ch),
}
}
out.lines().map(str::to_string).collect()
}
fn has_json_style_comments(raw: &str) -> bool {
for line in raw.lines() {
let mut chars = line.chars().peekable();
let mut in_string = false;
let mut escaped = false;
while let Some(ch) = chars.next() {
if escaped {
escaped = false;
continue;
}
if ch == '\\' {
escaped = true;
continue;
}
if ch == '"' {
in_string = !in_string;
continue;
}
if in_string {
continue;
}
if ch == '#' {
return true;
}
if ch == '/'
&& let Some('/') = chars.peek()
{
return true;
}
if ch == '/'
&& let Some('*') = chars.peek()
{
return true;
}
}
}
false
}
fn ensure_single_section_separator(output: &mut Vec<String>, has_next_section: bool) {
if !has_next_section {
return;
}
if output.last().is_some_and(|line| !line.trim().is_empty()) {
output.push(String::new());
}
}
fn detect_line_ending(source: &str) -> &'static str {
if source.contains("\r\n") {
"\r\n"
} else {
"\n"
}
}
fn normalize_eol_for_compare(s: &str) -> std::borrow::Cow<'_, str> {
if s.contains("\r\n") {
std::borrow::Cow::Owned(s.replace("\r\n", "\n"))
} else {
std::borrow::Cow::Borrowed(s)
}
}
fn format_gctf_preserve_comments(doc: &crate::parser::GctfDocument, source: &str) -> String {
let eol = detect_line_ending(source);
let lines: Vec<&str> = source.lines().collect();
let mut output: Vec<String> = Vec::with_capacity(lines.len());
let mut current_line = 0usize;
for (section_idx, section) in doc.sections.iter().enumerate() {
let has_next_section = section_idx + 1 < doc.sections.len();
while current_line < section.start_line && current_line < lines.len() {
if let Some(normalized) = normalize_hash_comment_line(lines[current_line]) {
output.push(normalized);
} else {
output.push(lines[current_line].to_string());
}
current_line += 1;
}
output.push(section.format_header());
current_line = current_line.saturating_add(1);
match (§ion.section_type, §ion.content) {
(
crate::parser::ast::SectionType::Request
| crate::parser::ast::SectionType::Error
| crate::parser::ast::SectionType::Response,
crate::parser::ast::SectionContent::Json(value),
) => {
let content_start = current_line;
let end = section.end_line.min(lines.len());
if has_json_style_comments(§ion.raw_content) {
output.extend(format_json_with_comments(§ion.raw_content));
let trailing_blanks = trailing_blank_line_count(&lines, content_start, end);
let blanks_to_keep = if has_next_section {
trailing_blanks.max(1)
} else {
trailing_blanks
};
for _ in 0..blanks_to_keep {
output.push(String::new());
}
current_line = end;
} else {
output.extend(format_json_content(value));
let trailing_blanks = trailing_blank_line_count(&lines, content_start, end);
let blanks_to_keep = if has_next_section {
trailing_blanks.max(1)
} else {
trailing_blanks
};
for _ in 0..blanks_to_keep {
output.push(String::new());
}
current_line = end;
}
}
(
crate::parser::ast::SectionType::Response,
crate::parser::ast::SectionContent::JsonLines(values),
) => {
let content_start = current_line;
for value in values {
output.extend(format_json_content(value));
}
let end = section.end_line.min(lines.len());
let trailing_blanks = trailing_blank_line_count(&lines, content_start, end);
let blanks_to_keep = if has_next_section {
trailing_blanks.max(1)
} else {
trailing_blanks
};
for _ in 0..blanks_to_keep {
output.push(String::new());
}
current_line = end;
}
_ => {
let end = section.end_line.min(lines.len());
while current_line < end {
if let Some(normalized) = normalize_hash_comment_line(lines[current_line]) {
output.push(normalized);
} else {
output.push(lines[current_line].to_string());
}
current_line += 1;
}
}
}
ensure_single_section_separator(&mut output, has_next_section);
}
while current_line < lines.len() {
if let Some(normalized) = normalize_hash_comment_line(lines[current_line]) {
output.push(normalized);
} else {
output.push(lines[current_line].to_string());
}
current_line += 1;
}
let mut rendered = output.join(eol);
if source.ends_with('\n') {
rendered.push_str(eol);
}
rendered
}
pub fn format_gctf_content(source: &str, file_name: &str) -> Result<String> {
let doc = parser::parse_gctf_from_str(source, file_name)?;
Ok(format_gctf_preserve_comments(&doc, source))
}
fn apply_optimizer_rewrites(source: &str, file_name: &str) -> Result<String> {
let eol = detect_line_ending(source);
let doc = parser::parse_gctf_from_str(source, file_name)?;
let hints = optimizer::collect_assertion_optimizations(&doc);
if hints.is_empty() {
return Ok(source.to_string());
}
let mut lines: Vec<String> = source.lines().map(str::to_string).collect();
for hint in hints {
let line_idx = hint.line.saturating_sub(1);
if line_idx >= lines.len() {
continue;
}
let line = &mut lines[line_idx];
if let Some(start) = line.find(&hint.before) {
let end = start + hint.before.len();
line.replace_range(start..end, &hint.after);
}
}
let mut rewritten = lines.join(eol);
if source.ends_with('\n') {
rewritten.push_str(eol);
}
Ok(rewritten)
}
fn collect_check_errors_for_content(
source: &str,
file_name: &str,
) -> Vec<(usize, String, String, Option<String>)> {
let mut errors = Vec::new();
let doc = match parser::parse_gctf_from_str(source, file_name) {
Ok(doc) => doc,
Err(e) => {
errors.push((1, "PARSE_ERROR".to_string(), e.to_string(), None));
return errors;
}
};
if let Err(e) = parser::validate_document(&doc) {
errors.push((1, "VALIDATION_ERROR".to_string(), e.to_string(), None));
}
for mismatch in semantics::collect_assertion_type_mismatches(&doc) {
errors.push((
mismatch.line,
mismatch.rule_id,
mismatch.message,
Some(format!(
"Type contract violation in assertion: {}",
mismatch.expression
)),
));
}
for unknown in semantics::collect_unknown_plugin_calls(&doc) {
errors.push((
unknown.line,
unknown.rule_id,
unknown.message,
Some(format!("Assertion: {}", unknown.expression)),
));
}
errors
}
pub async fn handle_fmt(args: &FmtArgs) -> Result<()> {
let mut files = Vec::new();
let mut has_error = false;
let mut files_needing_format = 0usize;
for path in &args.files {
if path.is_dir() {
files.extend(FileUtils::collect_test_files(path));
} else if path.is_file() {
files.push(path.clone());
} else {
error!("Path not found: {}", path.display());
has_error = true;
}
}
if files.is_empty() {
if !has_error {
warn!("No .gctf files found to format");
}
return Ok(());
}
for file in files {
let original = match std::fs::read_to_string(&file) {
Ok(content) => content,
Err(e) => {
error!("Failed to read {}: {}", file.display(), e);
has_error = true;
continue;
}
};
let file_name = file.to_string_lossy();
let check_errors = collect_check_errors_for_content(&original, &file_name);
if !check_errors.is_empty() {
for (line, code, message, hint) in check_errors {
error!("{}:{}: [{}] {}", file.display(), line, code, message);
if let Some(hint) = hint {
error!(" hint: {}", hint);
}
}
has_error = true;
continue;
}
let preformatted = match apply_optimizer_rewrites(&original, &file_name) {
Ok(content) => content,
Err(e) => {
error!("Failed to optimize {}: {}", file.display(), e);
has_error = true;
continue;
}
};
let formatted = match format_gctf_content(&preformatted, &file_name) {
Ok(formatted) => formatted,
Err(e) => {
error!("Failed to parse {}: {}", file.display(), e);
has_error = true;
continue;
}
};
if args.write {
if formatted != original
&& let Err(e) = std::fs::write(&file, &formatted)
{
error!("Failed to write {}: {}", file.display(), e);
has_error = true;
}
} else {
let formatted_cmp = normalize_eol_for_compare(&formatted);
let original_cmp = normalize_eol_for_compare(&original);
if formatted_cmp != original_cmp {
println!(
"{}:1: [FORMAT_NEEDED] File is not formatted",
file.display()
);
println!(" hint: Run `grpctestify fmt -w {}`", file.display());
has_error = true;
files_needing_format += 1;
} else {
println!("{} ... OK", file.display());
}
}
}
if !args.write && files_needing_format > 0 {
error!(
"{} file(s) require formatting. Run `grpctestify fmt -w ...`",
files_needing_format
);
}
if has_error {
std::process::exit(1);
}
Ok(())
}