// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.

//! This module provides file formatting utilities using
//! [`dprint-plugin-typescript`](https://github.com/dprint/dprint-plugin-typescript).
//!
//! At the moment it is only consumed using CLI but in
//! the future it can be easily extended to provide
//! the same functions as ops available in JS runtime.

use crate::args::CliOptions;
use crate::args::Flags;
use crate::args::FmtFlags;
use crate::args::FmtOptions;
use crate::args::FmtOptionsConfig;
use crate::args::ProseWrap;
use crate::args::UnstableFmtOptions;
use crate::cache::Caches;
use crate::colors;
use crate::factory::CliFactory;
use crate::util::diff::diff;
use crate::util::file_watcher;
use crate::util::fs::canonicalize_path;
use crate::util::path::get_extension;
use async_trait::async_trait;
use deno_ast::ParsedSource;
use deno_config::glob::FileCollector;
use deno_config::glob::FilePatterns;
use deno_core::anyhow::anyhow;
use deno_core::anyhow::bail;
use deno_core::anyhow::Context;
use deno_core::error::generic_error;
use deno_core::error::AnyError;
use deno_core::futures;
use deno_core::parking_lot::Mutex;
use deno_core::unsync::spawn_blocking;
use deno_core::url::Url;
use log::debug;
use log::info;
use log::warn;
use std::borrow::Cow;
use std::fs;
use std::io::stdin;
use std::io::stdout;
use std::io::Read;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::sync::Arc;

use crate::cache::IncrementalCache;

/// Format JavaScript/TypeScript files.
pub async fn format(
  flags: Arc<Flags>,
  fmt_flags: FmtFlags,
) -> Result<(), AnyError> {
  if fmt_flags.is_stdin() {
    let cli_options = CliOptions::from_flags(flags)?;
    let start_dir = &cli_options.start_dir;
    let fmt_config = start_dir
      .to_fmt_config(FilePatterns::new_with_base(start_dir.dir_path()))?;
    let fmt_options = FmtOptions::resolve(
      fmt_config,
      cli_options.resolve_config_unstable_fmt_options(),
      &fmt_flags,
    );
    return format_stdin(
      &fmt_flags,
      fmt_options,
      cli_options
        .ext_flag()
        .as_ref()
        .map(|s| s.as_str())
        .unwrap_or("ts"),
    );
  }

  if let Some(watch_flags) = &fmt_flags.watch {
    file_watcher::watch_func(
      flags,
      file_watcher::PrintConfig::new("Fmt", !watch_flags.no_clear_screen),
      move |flags, watcher_communicator, changed_paths| {
        let fmt_flags = fmt_flags.clone();
        watcher_communicator.show_path_changed(changed_paths.clone());
        Ok(async move {
          let factory = CliFactory::from_flags(flags);
          let cli_options = factory.cli_options()?;
          let caches = factory.caches()?;
          let mut paths_with_options_batches =
            resolve_paths_with_options_batches(cli_options, &fmt_flags)?;

          for paths_with_options in &mut paths_with_options_batches {
            let _ = watcher_communicator
              .watch_paths(paths_with_options.paths.clone());
            let files = std::mem::take(&mut paths_with_options.paths);
            paths_with_options.paths = if let Some(paths) = &changed_paths {
              if fmt_flags.check {
                // check all files on any changed (https://github.com/denoland/deno/issues/12446)
                files
                  .iter()
                  .any(|path| {
                    canonicalize_path(path)
                      .map(|path| paths.contains(&path))
                      .unwrap_or(false)
                  })
                  .then_some(files)
                  .unwrap_or_else(|| [].to_vec())
              } else {
                files
                  .into_iter()
                  .filter(|path| {
                    canonicalize_path(path)
                      .map(|path| paths.contains(&path))
                      .unwrap_or(false)
                  })
                  .collect::<Vec<_>>()
              }
            } else {
              files
            };
          }

          format_files(
            caches,
            cli_options,
            &fmt_flags,
            paths_with_options_batches,
          )
          .await?;

          Ok(())
        })
      },
    )
    .await?;
  } else {
    let factory = CliFactory::from_flags(flags);
    let cli_options = factory.cli_options()?;
    let caches = factory.caches()?;
    let paths_with_options_batches =
      resolve_paths_with_options_batches(cli_options, &fmt_flags)?;
    format_files(caches, cli_options, &fmt_flags, paths_with_options_batches)
      .await?;
  }

  Ok(())
}

struct PathsWithOptions {
  base: PathBuf,
  paths: Vec<PathBuf>,
  options: FmtOptions,
}

fn resolve_paths_with_options_batches(
  cli_options: &CliOptions,
  fmt_flags: &FmtFlags,
) -> Result<Vec<PathsWithOptions>, AnyError> {
  let members_fmt_options =
    cli_options.resolve_fmt_options_for_members(fmt_flags)?;
  let mut paths_with_options_batches =
    Vec::with_capacity(members_fmt_options.len());
  for (_ctx, member_fmt_options) in members_fmt_options {
    let files =
      collect_fmt_files(cli_options, member_fmt_options.files.clone())?;
    if !files.is_empty() {
      paths_with_options_batches.push(PathsWithOptions {
        base: member_fmt_options.files.base.clone(),
        paths: files,
        options: member_fmt_options,
      });
    }
  }
  if paths_with_options_batches.is_empty() {
    return Err(generic_error("No target files found."));
  }
  Ok(paths_with_options_batches)
}

async fn format_files(
  caches: &Arc<Caches>,
  cli_options: &Arc<CliOptions>,
  fmt_flags: &FmtFlags,
  paths_with_options_batches: Vec<PathsWithOptions>,
) -> Result<(), AnyError> {
  let formatter: Box<dyn Formatter> = if fmt_flags.check {
    Box::new(CheckFormatter::default())
  } else {
    Box::new(RealFormatter::default())
  };
  for paths_with_options in paths_with_options_batches {
    log::debug!(
      "Formatting {} file(s) in {}",
      paths_with_options.paths.len(),
      paths_with_options.base.display()
    );
    let fmt_options = paths_with_options.options;
    let paths = paths_with_options.paths;
    let incremental_cache = Arc::new(IncrementalCache::new(
      caches.fmt_incremental_cache_db(),
      &(&fmt_options.options, &fmt_options.unstable), // cache key
      &paths,
    ));
    formatter
      .handle_files(
        paths,
        fmt_options.options,
        fmt_options.unstable,
        incremental_cache.clone(),
        cli_options.ext_flag().clone(),
      )
      .await?;
    incremental_cache.wait_completion().await;
  }

  formatter.finish()
}

fn collect_fmt_files(
  cli_options: &CliOptions,
  files: FilePatterns,
) -> Result<Vec<PathBuf>, AnyError> {
  FileCollector::new(|e| {
    is_supported_ext_fmt(e.path)
      || (e.path.extension().is_none() && cli_options.ext_flag().is_some())
  })
  .ignore_git_folder()
  .ignore_node_modules()
  .use_gitignore()
  .set_vendor_folder(cli_options.vendor_dir_path().map(ToOwned::to_owned))
  .collect_file_patterns(&deno_config::fs::RealDenoConfigFs, files)
}

/// Formats markdown (using <https://github.com/dprint/dprint-plugin-markdown>) and its code blocks
/// (ts/tsx, js/jsx).
fn format_markdown(
  file_text: &str,
  fmt_options: &FmtOptionsConfig,
  unstable_options: &UnstableFmtOptions,
) -> Result<Option<String>, AnyError> {
  let markdown_config = get_resolved_markdown_config(fmt_options);
  dprint_plugin_markdown::format_text(
    file_text,
    &markdown_config,
    move |tag, text, line_width| {
      let tag = tag.to_lowercase();
      if matches!(
        tag.as_str(),
        "ts"
          | "tsx"
          | "js"
          | "jsx"
          | "cjs"
          | "cts"
          | "mjs"
          | "mts"
          | "javascript"
          | "typescript"
          | "json"
          | "jsonc"
          | "css"
          | "scss"
          | "sass"
          | "less"
          | "html"
          | "svelte"
          | "vue"
          | "astro"
          | "vto"
          | "njk"
          | "yml"
          | "yaml"
          | "sql"
      ) {
        // It's important to tell dprint proper file extension, otherwise
        // it might parse the file twice.
        let extension = match tag.as_str() {
          "javascript" => "js",
          "typescript" => "ts",
          rest => rest,
        };

        let fake_filename =
          PathBuf::from(format!("deno_fmt_stdin.{extension}"));
        match extension {
          "json" | "jsonc" => {
            let mut json_config = get_resolved_json_config(fmt_options);
            json_config.line_width = line_width;
            dprint_plugin_json::format_text(&fake_filename, text, &json_config)
          }
          "css" | "scss" | "sass" | "less" => {
            format_css(&fake_filename, text, fmt_options)
          }
          "html" => format_html(&fake_filename, text, fmt_options),
          "svelte" | "vue" | "astro" | "vto" | "njk" => {
            if unstable_options.component {
              format_html(&fake_filename, text, fmt_options)
            } else {
              Ok(None)
            }
          }
          "yml" | "yaml" => format_yaml(text, fmt_options),
          "sql" => {
            if unstable_options.sql {
              format_sql(text, fmt_options)
            } else {
              Ok(None)
            }
          }
          _ => {
            let mut codeblock_config =
              get_resolved_typescript_config(fmt_options);
            codeblock_config.line_width = line_width;
            dprint_plugin_typescript::format_text(
              &fake_filename,
              None,
              text.to_string(),
              &codeblock_config,
            )
          }
        }
      } else {
        Ok(None)
      }
    },
  )
}

/// Formats JSON and JSONC using the rules provided by .deno()
/// of configuration builder of <https://github.com/dprint/dprint-plugin-json>.
/// See <https://github.com/dprint/dprint-plugin-json/blob/cfa1052dbfa0b54eb3d814318034cdc514c813d7/src/configuration/builder.rs#L87> for configuration.
pub fn format_json(
  file_path: &Path,
  file_text: &str,
  fmt_options: &FmtOptionsConfig,
) -> Result<Option<String>, AnyError> {
  let config = get_resolved_json_config(fmt_options);
  dprint_plugin_json::format_text(file_path, file_text, &config)
}

pub fn format_css(
  file_path: &Path,
  file_text: &str,
  fmt_options: &FmtOptionsConfig,
) -> Result<Option<String>, AnyError> {
  let formatted_str = malva::format_text(
    file_text,
    malva::detect_syntax(file_path).unwrap_or(malva::Syntax::Css),
    &get_resolved_malva_config(fmt_options),
  )
  .map_err(AnyError::from)?;

  Ok(if formatted_str == file_text {
    None
  } else {
    Some(formatted_str)
  })
}

fn format_yaml(
  file_text: &str,
  fmt_options: &FmtOptionsConfig,
) -> Result<Option<String>, AnyError> {
  let ignore_file = file_text
    .lines()
    .take_while(|line| line.starts_with('#'))
    .any(|line| {
      line
        .strip_prefix('#')
        .unwrap()
        .trim()
        .starts_with("deno-fmt-ignore-file")
    });

  if ignore_file {
    return Ok(None);
  }

  let formatted_str =
    pretty_yaml::format_text(file_text, &get_resolved_yaml_config(fmt_options))
      .map_err(AnyError::from)?;

  Ok(if formatted_str == file_text {
    None
  } else {
    Some(formatted_str)
  })
}

pub fn format_html(
  file_path: &Path,
  file_text: &str,
  fmt_options: &FmtOptionsConfig,
) -> Result<Option<String>, AnyError> {
  let format_result = markup_fmt::format_text(
    file_text,
    markup_fmt::detect_language(file_path)
      .unwrap_or(markup_fmt::Language::Html),
    &get_resolved_markup_fmt_config(fmt_options),
    |text, hints| {
      let mut file_name =
        file_path.file_name().expect("missing file name").to_owned();
      file_name.push(".");
      file_name.push(hints.ext);
      let path = file_path.with_file_name(file_name);
      match hints.ext {
        "css" | "scss" | "sass" | "less" => {
          let mut malva_config = get_resolved_malva_config(fmt_options);
          malva_config.layout.print_width = hints.print_width;
          if hints.attr {
            malva_config.language.quotes =
              if let Some(true) = fmt_options.single_quote {
                malva::config::Quotes::AlwaysDouble
              } else {
                malva::config::Quotes::AlwaysSingle
              };
            malva_config.language.single_line_top_level_declarations = true;
          }
          malva::format_text(
            text,
            malva::detect_syntax(path).unwrap_or(malva::Syntax::Css),
            &malva_config,
          )
          .map(Cow::from)
          .map_err(AnyError::from)
        }
        "json" | "jsonc" => {
          let mut json_config = get_resolved_json_config(fmt_options);
          json_config.line_width = hints.print_width as u32;
          dprint_plugin_json::format_text(&path, text, &json_config).map(
            |formatted| {
              if let Some(formatted) = formatted {
                Cow::from(formatted)
              } else {
                Cow::from(text)
              }
            },
          )
        }
        _ => {
          let mut typescript_config_builder =
            get_typescript_config_builder(fmt_options);
          typescript_config_builder.file_indent_level(hints.indent_level);
          let mut typescript_config = typescript_config_builder.build();
          typescript_config.line_width = hints.print_width as u32;
          dprint_plugin_typescript::format_text(
            &path,
            None,
            text.to_string(),
            &typescript_config,
          )
          .map(|formatted| {
            if let Some(formatted) = formatted {
              Cow::from(formatted)
            } else {
              Cow::from(text)
            }
          })
        }
      }
    },
  )
  .map_err(|error| match error {
    markup_fmt::FormatError::Syntax(error) => {
      fn inner(
        error: &markup_fmt::SyntaxError,
        file_path: &Path,
      ) -> Option<String> {
        let url = Url::from_file_path(file_path).ok()?;

        let error_msg = format!(
          "Syntax error ({}) at {}:{}:{}\n",
          error.kind,
          url.as_str(),
          error.line,
          error.column
        );
        Some(error_msg)
      }

      if let Some(error_msg) = inner(&error, file_path) {
        AnyError::from(generic_error(error_msg))
      } else {
        AnyError::from(error)
      }
    }
    markup_fmt::FormatError::External(errors) => {
      let last = errors.len() - 1;
      AnyError::msg(
        errors
          .into_iter()
          .enumerate()
          .map(|(i, error)| {
            if i == last {
              format!("{error}")
            } else {
              format!("{error}\n\n")
            }
          })
          .collect::<String>(),
      )
    }
  });

  let formatted_str = format_result?;

  Ok(if formatted_str == file_text {
    None
  } else {
    Some(formatted_str)
  })
}

pub fn format_sql(
  file_text: &str,
  fmt_options: &FmtOptionsConfig,
) -> Result<Option<String>, AnyError> {
  let ignore_file = file_text
    .lines()
    .take_while(|line| line.starts_with("--"))
    .any(|line| {
      line
        .strip_prefix("--")
        .unwrap()
        .trim()
        .starts_with("deno-fmt-ignore-file")
    });

  if ignore_file {
    return Ok(None);
  }

  let mut formatted_str = sqlformat::format(
    file_text,
    &sqlformat::QueryParams::None,
    &sqlformat::FormatOptions {
      ignore_case_convert: None,
      indent: if fmt_options.use_tabs.unwrap_or_default() {
        sqlformat::Indent::Tabs
      } else {
        sqlformat::Indent::Spaces(fmt_options.indent_width.unwrap_or(2))
      },
      // leave one blank line between queries.
      lines_between_queries: 2,
      uppercase: Some(true),
    },
  );

  // Add single new line to the end of file.
  formatted_str.push('\n');

  Ok(if formatted_str == file_text {
    None
  } else {
    Some(formatted_str)
  })
}

/// Formats a single TS, TSX, JS, JSX, JSONC, JSON, MD, IPYNB or SQL file.
pub fn format_file(
  file_path: &Path,
  file_text: &str,
  fmt_options: &FmtOptionsConfig,
  unstable_options: &UnstableFmtOptions,
  ext: Option<String>,
) -> Result<Option<String>, AnyError> {
  let ext = ext
    .or_else(|| get_extension(file_path))
    .unwrap_or("ts".to_string());

  match ext.as_str() {
    "md" | "mkd" | "mkdn" | "mdwn" | "mdown" | "markdown" => {
      format_markdown(file_text, fmt_options, unstable_options)
    }
    "json" | "jsonc" => format_json(file_path, file_text, fmt_options),
    "css" | "scss" | "sass" | "less" => {
      format_css(file_path, file_text, fmt_options)
    }
    "html" => format_html(file_path, file_text, fmt_options),
    "svelte" | "vue" | "astro" | "vto" | "njk" => {
      if unstable_options.component {
        format_html(file_path, file_text, fmt_options)
      } else {
        Ok(None)
      }
    }
    "yml" | "yaml" => format_yaml(file_text, fmt_options),
    "ipynb" => dprint_plugin_jupyter::format_text(
      file_text,
      |file_path: &Path, file_text: String| {
        format_file(file_path, &file_text, fmt_options, unstable_options, None)
      },
    ),
    "sql" => {
      if unstable_options.sql {
        format_sql(file_text, fmt_options)
      } else {
        Ok(None)
      }
    }
    _ => {
      let config = get_resolved_typescript_config(fmt_options);
      dprint_plugin_typescript::format_text(
        file_path,
        Some(&ext),
        file_text.to_string(),
        &config,
      )
    }
  }
}

pub fn format_parsed_source(
  parsed_source: &ParsedSource,
  fmt_options: &FmtOptionsConfig,
) -> Result<Option<String>, AnyError> {
  dprint_plugin_typescript::format_parsed_source(
    parsed_source,
    &get_resolved_typescript_config(fmt_options),
  )
}

#[async_trait]
trait Formatter {
  async fn handle_files(
    &self,
    paths: Vec<PathBuf>,
    fmt_options: FmtOptionsConfig,
    unstable_options: UnstableFmtOptions,
    incremental_cache: Arc<IncrementalCache>,
    ext: Option<String>,
  ) -> Result<(), AnyError>;

  fn finish(&self) -> Result<(), AnyError>;
}

#[derive(Default)]
struct CheckFormatter {
  not_formatted_files_count: Arc<AtomicUsize>,
  checked_files_count: Arc<AtomicUsize>,
}

#[async_trait]
impl Formatter for CheckFormatter {
  async fn handle_files(
    &self,
    paths: Vec<PathBuf>,
    fmt_options: FmtOptionsConfig,
    unstable_options: UnstableFmtOptions,
    incremental_cache: Arc<IncrementalCache>,
    ext: Option<String>,
  ) -> Result<(), AnyError> {
    // prevent threads outputting at the same time
    let output_lock = Arc::new(Mutex::new(0));

    run_parallelized(paths, {
      let not_formatted_files_count = self.not_formatted_files_count.clone();
      let checked_files_count = self.checked_files_count.clone();
      move |file_path| {
        checked_files_count.fetch_add(1, Ordering::Relaxed);
        let file_text = read_file_contents(&file_path)?.text;

        // skip checking the file if we know it's formatted
        if incremental_cache.is_file_same(&file_path, &file_text) {
          return Ok(());
        }

        match format_file(
          &file_path,
          &file_text,
          &fmt_options,
          &unstable_options,
          ext.clone(),
        ) {
          Ok(Some(formatted_text)) => {
            not_formatted_files_count.fetch_add(1, Ordering::Relaxed);
            let _g = output_lock.lock();
            let diff = diff(&file_text, &formatted_text);
            info!("");
            info!("{} {}:", colors::bold("from"), file_path.display());
            info!("{}", diff);
          }
          Ok(None) => {
            // When checking formatting, only update the incremental cache when
            // the file is the same since we don't bother checking for stable
            // formatting here. Additionally, ensure this is done during check
            // so that CIs that cache the DENO_DIR will get the benefit of
            // incremental formatting
            incremental_cache.update_file(&file_path, &file_text);
          }
          Err(e) => {
            not_formatted_files_count.fetch_add(1, Ordering::Relaxed);
            let _g = output_lock.lock();
            warn!("Error checking: {}", file_path.to_string_lossy());
            warn!(
              "{}",
              format!("{e}")
                .split('\n')
                .map(|l| {
                  if l.trim().is_empty() {
                    String::new()
                  } else {
                    format!("  {l}")
                  }
                })
                .collect::<Vec<_>>()
                .join("\n")
            );
          }
        }
        Ok(())
      }
    })
    .await?;

    Ok(())
  }

  fn finish(&self) -> Result<(), AnyError> {
    let not_formatted_files_count =
      self.not_formatted_files_count.load(Ordering::Relaxed);
    let checked_files_count = self.checked_files_count.load(Ordering::Relaxed);
    let checked_files_str =
      format!("{} {}", checked_files_count, files_str(checked_files_count));
    if not_formatted_files_count == 0 {
      info!("Checked {}", checked_files_str);
      Ok(())
    } else {
      let not_formatted_files_str = files_str(not_formatted_files_count);
      Err(generic_error(format!(
        "Found {not_formatted_files_count} not formatted {not_formatted_files_str} in {checked_files_str}",
      )))
    }
  }
}

#[derive(Default)]
struct RealFormatter {
  formatted_files_count: Arc<AtomicUsize>,
  checked_files_count: Arc<AtomicUsize>,
}

#[async_trait]
impl Formatter for RealFormatter {
  async fn handle_files(
    &self,
    paths: Vec<PathBuf>,
    fmt_options: FmtOptionsConfig,
    unstable_options: UnstableFmtOptions,
    incremental_cache: Arc<IncrementalCache>,
    ext: Option<String>,
  ) -> Result<(), AnyError> {
    let output_lock = Arc::new(Mutex::new(0)); // prevent threads outputting at the same time

    run_parallelized(paths, {
      let formatted_files_count = self.formatted_files_count.clone();
      let checked_files_count = self.checked_files_count.clone();
      move |file_path| {
        checked_files_count.fetch_add(1, Ordering::Relaxed);
        let file_contents = read_file_contents(&file_path)?;

        // skip formatting the file if we know it's formatted
        if incremental_cache.is_file_same(&file_path, &file_contents.text) {
          return Ok(());
        }

        match format_ensure_stable(
          &file_path,
          &file_contents.text,
          |file_path, file_text| {
            format_file(
              file_path,
              file_text,
              &fmt_options,
              &unstable_options,
              ext.clone(),
            )
          },
        ) {
          Ok(Some(formatted_text)) => {
            incremental_cache.update_file(&file_path, &formatted_text);
            write_file_contents(
              &file_path,
              FileContents {
                had_bom: file_contents.had_bom,
                text: formatted_text,
              },
            )?;
            formatted_files_count.fetch_add(1, Ordering::Relaxed);
            let _g = output_lock.lock();
            info!("{}", file_path.to_string_lossy());
          }
          Ok(None) => {
            incremental_cache.update_file(&file_path, &file_contents.text);
          }
          Err(e) => {
            let _g = output_lock.lock();
            log::error!("Error formatting: {}", file_path.to_string_lossy());
            log::error!("   {e}");
          }
        }
        Ok(())
      }
    })
    .await?;
    Ok(())
  }

  fn finish(&self) -> Result<(), AnyError> {
    let formatted_files_count =
      self.formatted_files_count.load(Ordering::Relaxed);
    debug!(
      "Formatted {} {}",
      formatted_files_count,
      files_str(formatted_files_count),
    );

    let checked_files_count = self.checked_files_count.load(Ordering::Relaxed);
    info!(
      "Checked {} {}",
      checked_files_count,
      files_str(checked_files_count)
    );
    Ok(())
  }
}

/// When storing any formatted text in the incremental cache, we want
/// to ensure that anything stored when formatted will have itself as
/// the output as well. This is to prevent "double format" issues where
/// a user formats their code locally and it fails on the CI afterwards.
fn format_ensure_stable(
  file_path: &Path,
  file_text: &str,
  fmt_func: impl Fn(&Path, &str) -> Result<Option<String>, AnyError>,
) -> Result<Option<String>, AnyError> {
  let formatted_text = fmt_func(file_path, file_text)?;

  match formatted_text {
    Some(mut current_text) => {
      let mut count = 0;
      loop {
        match fmt_func(file_path, &current_text) {
          Ok(Some(next_pass_text)) => {
            // just in case
            if next_pass_text == current_text {
              return Ok(Some(next_pass_text));
            }
            current_text = next_pass_text;
          }
          Ok(None) => {
            return Ok(Some(current_text));
          }
          Err(err) => {
            bail!(
              concat!(
                "Formatting succeeded initially, but failed when ensuring a ",
                "stable format. This indicates a bug in the formatter where ",
                "the text it produces is not syntactically correct. As a temporary ",
                "workaround you can ignore this file.\n\n{:#}"
              ),
              err,
            )
          }
        }
        count += 1;
        if count == 5 {
          bail!(
            concat!(
              "Formatting not stable. Bailed after {} tries. This indicates a bug ",
              "in the formatter where it formats the file differently each time. As a ",
              "temporary workaround you can ignore this file."
            ),
            count,
          )
        }
      }
    }
    None => Ok(None),
  }
}

/// Format stdin and write result to stdout.
/// Treats input as set by `--ext` flag.
/// Compatible with `--check` flag.
fn format_stdin(
  fmt_flags: &FmtFlags,
  fmt_options: FmtOptions,
  ext: &str,
) -> Result<(), AnyError> {
  let mut source = String::new();
  if stdin().read_to_string(&mut source).is_err() {
    bail!("Failed to read from stdin");
  }
  let file_path = PathBuf::from(format!("_stdin.{ext}"));
  let formatted_text = format_file(
    &file_path,
    &source,
    &fmt_options.options,
    &fmt_options.unstable,
    None,
  )?;
  if fmt_flags.check {
    #[allow(clippy::print_stdout)]
    if formatted_text.is_some() {
      println!("Not formatted stdin");
    }
  } else {
    stdout().write_all(formatted_text.unwrap_or(source).as_bytes())?;
  }
  Ok(())
}

fn files_str(len: usize) -> &'static str {
  if len <= 1 {
    "file"
  } else {
    "files"
  }
}

fn get_typescript_config_builder(
  options: &FmtOptionsConfig,
) -> dprint_plugin_typescript::configuration::ConfigurationBuilder {
  let mut builder =
    dprint_plugin_typescript::configuration::ConfigurationBuilder::new();
  builder.deno();

  if let Some(use_tabs) = options.use_tabs {
    builder.use_tabs(use_tabs);
  }

  if let Some(line_width) = options.line_width {
    builder.line_width(line_width);
  }

  if let Some(indent_width) = options.indent_width {
    builder.indent_width(indent_width);
  }

  if let Some(single_quote) = options.single_quote {
    if single_quote {
      builder.quote_style(
        dprint_plugin_typescript::configuration::QuoteStyle::PreferSingle,
      );
    }
  }

  if let Some(semi_colons) = options.semi_colons {
    builder.semi_colons(match semi_colons {
      true => dprint_plugin_typescript::configuration::SemiColons::Prefer,
      false => dprint_plugin_typescript::configuration::SemiColons::Asi,
    });
  }

  builder
}

fn get_resolved_typescript_config(
  options: &FmtOptionsConfig,
) -> dprint_plugin_typescript::configuration::Configuration {
  get_typescript_config_builder(options).build()
}

fn get_resolved_markdown_config(
  options: &FmtOptionsConfig,
) -> dprint_plugin_markdown::configuration::Configuration {
  let mut builder =
    dprint_plugin_markdown::configuration::ConfigurationBuilder::new();

  builder.deno();

  if let Some(line_width) = options.line_width {
    builder.line_width(line_width);
  }

  if let Some(prose_wrap) = options.prose_wrap {
    builder.text_wrap(match prose_wrap {
      ProseWrap::Always => {
        dprint_plugin_markdown::configuration::TextWrap::Always
      }
      ProseWrap::Never => {
        dprint_plugin_markdown::configuration::TextWrap::Never
      }
      ProseWrap::Preserve => {
        dprint_plugin_markdown::configuration::TextWrap::Maintain
      }
    });
  }

  builder.build()
}

fn get_resolved_json_config(
  options: &FmtOptionsConfig,
) -> dprint_plugin_json::configuration::Configuration {
  let mut builder =
    dprint_plugin_json::configuration::ConfigurationBuilder::new();

  builder.deno();

  if let Some(use_tabs) = options.use_tabs {
    builder.use_tabs(use_tabs);
  }

  if let Some(line_width) = options.line_width {
    builder.line_width(line_width);
  }

  if let Some(indent_width) = options.indent_width {
    builder.indent_width(indent_width);
  }

  builder.build()
}

fn get_resolved_malva_config(
  options: &FmtOptionsConfig,
) -> malva::config::FormatOptions {
  use malva::config::*;

  let layout_options = LayoutOptions {
    print_width: options.line_width.unwrap_or(80) as usize,
    use_tabs: options.use_tabs.unwrap_or_default(),
    indent_width: options.indent_width.unwrap_or(2) as usize,
    line_break: LineBreak::Lf,
  };

  let language_options = LanguageOptions {
    align_comments: true,
    hex_case: HexCase::Lower,
    hex_color_length: None,
    quotes: if let Some(true) = options.single_quote {
      Quotes::PreferSingle
    } else {
      Quotes::PreferDouble
    },
    operator_linebreak: OperatorLineBreak::Before,
    block_selector_linebreak: BlockSelectorLineBreak::Consistent,
    omit_number_leading_zero: false,
    trailing_comma: true,
    format_comments: false,
    linebreak_in_pseudo_parens: true,
    declaration_order: None,
    single_line_block_threshold: None,
    keyframe_selector_notation: None,
    attr_value_quotes: AttrValueQuotes::Always,
    prefer_single_line: false,
    selectors_prefer_single_line: None,
    function_args_prefer_single_line: None,
    sass_content_at_rule_prefer_single_line: None,
    sass_include_at_rule_prefer_single_line: None,
    sass_map_prefer_single_line: None,
    sass_module_config_prefer_single_line: None,
    sass_params_prefer_single_line: None,
    less_import_options_prefer_single_line: None,
    less_mixin_args_prefer_single_line: None,
    less_mixin_params_prefer_single_line: None,
    single_line_top_level_declarations: false,
    selector_override_comment_directive: "deno-fmt-selector-override".into(),
    ignore_comment_directive: "deno-fmt-ignore".into(),
    ignore_file_comment_directive: "deno-fmt-ignore-file".into(),
  };

  FormatOptions {
    layout: layout_options,
    language: language_options,
  }
}

fn get_resolved_markup_fmt_config(
  options: &FmtOptionsConfig,
) -> markup_fmt::config::FormatOptions {
  use markup_fmt::config::*;

  let layout_options = LayoutOptions {
    print_width: options.line_width.unwrap_or(80) as usize,
    use_tabs: options.use_tabs.unwrap_or_default(),
    indent_width: options.indent_width.unwrap_or(2) as usize,
    line_break: LineBreak::Lf,
  };

  let language_options = LanguageOptions {
    script_formatter: Some(markup_fmt::config::ScriptFormatter::Dprint),
    quotes: Quotes::Double,
    format_comments: false,
    script_indent: true,
    html_script_indent: None,
    vue_script_indent: Some(false),
    svelte_script_indent: None,
    astro_script_indent: None,
    style_indent: true,
    html_style_indent: None,
    vue_style_indent: Some(false),
    svelte_style_indent: None,
    astro_style_indent: None,
    closing_bracket_same_line: false,
    closing_tag_line_break_for_empty: ClosingTagLineBreakForEmpty::Fit,
    max_attrs_per_line: None,
    prefer_attrs_single_line: false,
    html_normal_self_closing: None,
    html_void_self_closing: None,
    component_self_closing: None,
    svg_self_closing: None,
    mathml_self_closing: None,
    whitespace_sensitivity: WhitespaceSensitivity::Css,
    component_whitespace_sensitivity: None,
    doctype_keyword_case: DoctypeKeywordCase::Upper,
    v_bind_style: None,
    v_on_style: None,
    v_for_delimiter_style: None,
    v_slot_style: None,
    component_v_slot_style: None,
    default_v_slot_style: None,
    named_v_slot_style: None,
    v_bind_same_name_short_hand: None,
    strict_svelte_attr: false,
    svelte_attr_shorthand: Some(true),
    svelte_directive_shorthand: Some(true),
    astro_attr_shorthand: Some(true),
    ignore_comment_directive: "deno-fmt-ignore".into(),
    ignore_file_comment_directive: "deno-fmt-ignore-file".into(),
  };

  FormatOptions {
    layout: layout_options,
    language: language_options,
  }
}

fn get_resolved_yaml_config(
  options: &FmtOptionsConfig,
) -> pretty_yaml::config::FormatOptions {
  use pretty_yaml::config::*;

  let layout_options = LayoutOptions {
    print_width: options.line_width.unwrap_or(80) as usize,
    indent_width: options.indent_width.unwrap_or(2) as usize,
    line_break: LineBreak::Lf,
  };

  let language_options = LanguageOptions {
    quotes: if let Some(true) = options.single_quote {
      Quotes::PreferSingle
    } else {
      Quotes::PreferDouble
    },
    trailing_comma: true,
    format_comments: false,
    indent_block_sequence_in_map: true,
    brace_spacing: true,
    bracket_spacing: false,
    dash_spacing: DashSpacing::OneSpace,
    prefer_single_line: false,
    flow_sequence_prefer_single_line: None,
    flow_map_prefer_single_line: None,
    trim_trailing_whitespaces: true,
    trim_trailing_zero: false,
    ignore_comment_directive: "deno-fmt-ignore".into(),
  };

  FormatOptions {
    layout: layout_options,
    language: language_options,
  }
}

struct FileContents {
  text: String,
  had_bom: bool,
}

fn read_file_contents(file_path: &Path) -> Result<FileContents, AnyError> {
  let file_bytes = fs::read(file_path)
    .with_context(|| format!("Error reading {}", file_path.display()))?;
  let had_bom = file_bytes.starts_with(&[0xEF, 0xBB, 0xBF]);
  // will have the BOM stripped
  let text = deno_graph::source::decode_owned_file_source(file_bytes)
    .with_context(|| {
      anyhow!("{} is not a valid UTF-8 file", file_path.display())
    })?;

  Ok(FileContents { text, had_bom })
}

fn write_file_contents(
  file_path: &Path,
  mut file_contents: FileContents,
) -> Result<(), AnyError> {
  let file_text = if file_contents.had_bom {
    // add back the BOM
    file_contents.text.insert(0, '\u{FEFF}');
    file_contents.text
  } else {
    file_contents.text
  };

  Ok(fs::write(file_path, file_text)?)
}

pub async fn run_parallelized<F>(
  file_paths: Vec<PathBuf>,
  f: F,
) -> Result<(), AnyError>
where
  F: FnOnce(PathBuf) -> Result<(), AnyError> + Send + 'static + Clone,
{
  let handles = file_paths.iter().map(|file_path| {
    let f = f.clone();
    let file_path = file_path.clone();
    spawn_blocking(move || f(file_path))
  });
  let join_results = futures::future::join_all(handles).await;

  // find the tasks that panicked and let the user know which files
  let panic_file_paths = join_results
    .iter()
    .enumerate()
    .filter_map(|(i, join_result)| {
      join_result
        .as_ref()
        .err()
        .map(|_| file_paths[i].to_string_lossy())
    })
    .collect::<Vec<_>>();
  if !panic_file_paths.is_empty() {
    panic!("Panic formatting: {}", panic_file_paths.join(", "))
  }

  // check for any errors and if so return the first one
  let mut errors = join_results.into_iter().filter_map(|join_result| {
    join_result
      .ok()
      .and_then(|handle_result| handle_result.err())
  });

  if let Some(e) = errors.next() {
    Err(e)
  } else {
    Ok(())
  }
}

/// This function is similar to is_supported_ext but adds additional extensions
/// supported by `deno fmt`.
fn is_supported_ext_fmt(path: &Path) -> bool {
  get_extension(path).is_some_and(|ext| {
    matches!(
      ext.as_str(),
      "ts"
        | "tsx"
        | "js"
        | "jsx"
        | "cjs"
        | "cts"
        | "mjs"
        | "mts"
        | "json"
        | "jsonc"
        | "css"
        | "scss"
        | "sass"
        | "less"
        | "html"
        | "svelte"
        | "vue"
        | "astro"
        | "vto"
        | "njk"
        | "md"
        | "mkd"
        | "mkdn"
        | "mdwn"
        | "mdown"
        | "markdown"
        | "yml"
        | "yaml"
        | "ipynb"
        | "sql"
    )
  })
}

#[cfg(test)]
mod test {
  use test_util::assert_starts_with;

  use super::*;

  #[test]
  fn test_is_supported_ext_fmt() {
    assert!(!is_supported_ext_fmt(Path::new("tests/subdir/redirects")));
    assert!(is_supported_ext_fmt(Path::new("README.md")));
    assert!(is_supported_ext_fmt(Path::new("readme.MD")));
    assert!(is_supported_ext_fmt(Path::new("readme.mkd")));
    assert!(is_supported_ext_fmt(Path::new("readme.mkdn")));
    assert!(is_supported_ext_fmt(Path::new("readme.mdwn")));
    assert!(is_supported_ext_fmt(Path::new("readme.mdown")));
    assert!(is_supported_ext_fmt(Path::new("readme.markdown")));
    assert!(is_supported_ext_fmt(Path::new("lib/typescript.d.ts")));
    assert!(is_supported_ext_fmt(Path::new("testdata/run/001_hello.js")));
    assert!(is_supported_ext_fmt(Path::new("testdata/run/002_hello.ts")));
    assert!(is_supported_ext_fmt(Path::new("foo.jsx")));
    assert!(is_supported_ext_fmt(Path::new("foo.tsx")));
    assert!(is_supported_ext_fmt(Path::new("foo.TS")));
    assert!(is_supported_ext_fmt(Path::new("foo.TSX")));
    assert!(is_supported_ext_fmt(Path::new("foo.JS")));
    assert!(is_supported_ext_fmt(Path::new("foo.JSX")));
    assert!(is_supported_ext_fmt(Path::new("foo.mjs")));
    assert!(!is_supported_ext_fmt(Path::new("foo.mjsx")));
    assert!(is_supported_ext_fmt(Path::new("foo.jsonc")));
    assert!(is_supported_ext_fmt(Path::new("foo.JSONC")));
    assert!(is_supported_ext_fmt(Path::new("foo.json")));
    assert!(is_supported_ext_fmt(Path::new("foo.JsON")));
    assert!(is_supported_ext_fmt(Path::new("foo.css")));
    assert!(is_supported_ext_fmt(Path::new("foo.Css")));
    assert!(is_supported_ext_fmt(Path::new("foo.scss")));
    assert!(is_supported_ext_fmt(Path::new("foo.SCSS")));
    assert!(is_supported_ext_fmt(Path::new("foo.sass")));
    assert!(is_supported_ext_fmt(Path::new("foo.Sass")));
    assert!(is_supported_ext_fmt(Path::new("foo.less")));
    assert!(is_supported_ext_fmt(Path::new("foo.LeSS")));
    assert!(is_supported_ext_fmt(Path::new("foo.html")));
    assert!(is_supported_ext_fmt(Path::new("foo.HTML")));
    assert!(is_supported_ext_fmt(Path::new("foo.svelte")));
    assert!(is_supported_ext_fmt(Path::new("foo.Svelte")));
    assert!(is_supported_ext_fmt(Path::new("foo.vue")));
    assert!(is_supported_ext_fmt(Path::new("foo.VUE")));
    assert!(is_supported_ext_fmt(Path::new("foo.astro")));
    assert!(is_supported_ext_fmt(Path::new("foo.AsTrO")));
    assert!(is_supported_ext_fmt(Path::new("foo.vto")));
    assert!(is_supported_ext_fmt(Path::new("foo.Vto")));
    assert!(is_supported_ext_fmt(Path::new("foo.njk")));
    assert!(is_supported_ext_fmt(Path::new("foo.NJk")));
    assert!(is_supported_ext_fmt(Path::new("foo.yml")));
    assert!(is_supported_ext_fmt(Path::new("foo.Yml")));
    assert!(is_supported_ext_fmt(Path::new("foo.yaml")));
    assert!(is_supported_ext_fmt(Path::new("foo.YaML")));
    assert!(is_supported_ext_fmt(Path::new("foo.ipynb")));
    assert!(is_supported_ext_fmt(Path::new("foo.sql")));
    assert!(is_supported_ext_fmt(Path::new("foo.Sql")));
    assert!(is_supported_ext_fmt(Path::new("foo.sQl")));
    assert!(is_supported_ext_fmt(Path::new("foo.sqL")));
    assert!(is_supported_ext_fmt(Path::new("foo.SQL")));
  }

  #[test]
  fn test_format_ensure_stable_unstable_format() {
    let err =
      format_ensure_stable(&PathBuf::from("mod.ts"), "1", |_, file_text| {
        Ok(Some(format!("1{file_text}")))
      })
      .unwrap_err();
    assert_starts_with!(
      err.to_string(),
      "Formatting not stable. Bailed after 5 tries."
    );
  }

  #[test]
  fn test_format_ensure_stable_error_first() {
    let err = format_ensure_stable(&PathBuf::from("mod.ts"), "1", |_, _| {
      bail!("Error formatting.")
    })
    .unwrap_err();

    assert_eq!(err.to_string(), "Error formatting.");
  }

  #[test]
  fn test_format_ensure_stable_error_second() {
    let err =
      format_ensure_stable(&PathBuf::from("mod.ts"), "1", |_, file_text| {
        if file_text == "1" {
          Ok(Some("11".to_string()))
        } else {
          bail!("Error formatting.")
        }
      })
      .unwrap_err();
    assert_starts_with!(
      err.to_string(),
      "Formatting succeeded initially, but failed when"
    );
  }

  #[test]
  fn test_format_stable_after_two() {
    let result =
      format_ensure_stable(&PathBuf::from("mod.ts"), "1", |_, file_text| {
        if file_text == "1" {
          Ok(Some("11".to_string()))
        } else if file_text == "11" {
          Ok(None)
        } else {
          unreachable!();
        }
      })
      .unwrap();

    assert_eq!(result, Some("11".to_string()));
  }

  #[test]
  fn test_single_quote_true_prefers_single_quote() {
    let file_text = format_file(
      &PathBuf::from("test.ts"),
      "console.log(\"there's\");\nconsole.log('hi');\nconsole.log(\"bye\")\n",
      &FmtOptionsConfig {
        single_quote: Some(true),
        ..Default::default()
      },
      &UnstableFmtOptions::default(),
      None,
    )
    .unwrap()
    .unwrap();
    assert_eq!(
      file_text,
      // should use double quotes for the string with a single quote
      "console.log(\"there's\");\nconsole.log('hi');\nconsole.log('bye');\n",
    );
  }
}