use std::{borrow::Cow, io, io::Write, path::PathBuf};
use clap::{Args, Parser, ValueEnum};
use is_terminal::IsTerminal;
use serde::{Deserialize, Serialize};
use termcolor::{StandardStream, WriteColor};
use crate::{
api::{
check::{
self, parse_language_code, Data, DataAnnotation, Level, Request, DEFAULT_LANGUAGE,
},
server::ServerClient,
},
error::{Error, Result},
parsers::{html::parse_html, markdown::parse_markdown, typst::parse_typst},
};
use super::ExecuteSubcommand;
fn parse_filename(s: &str) -> Result<PathBuf> {
let path_buf = PathBuf::from(s);
if path_buf.is_file() {
Ok(path_buf)
} else {
Err(Error::InvalidFilename(s.to_string()))
}
}
#[derive(Debug, Parser)]
pub struct Command {
#[clap(short = 'r', long)]
pub raw: bool,
#[clap(long, default_value_t = 1500)]
pub max_length: usize,
#[clap(long, default_value = "\n\n")]
pub split_pattern: String,
#[clap(long, default_value_t = 5, allow_negative_numbers = true)]
pub max_suggestions: isize,
#[clap(long, value_enum, default_value_t = FileType::default(), ignore_case = true)]
pub r#type: FileType,
#[arg(conflicts_with_all(["text", "data"]), value_parser = parse_filename)]
pub filenames: Vec<PathBuf>,
#[command(flatten, next_help_heading = "Request options")]
pub request: CliRequest,
}
#[derive(Clone, Debug, Default, ValueEnum)]
#[non_exhaustive]
pub enum FileType {
#[default]
Auto,
Raw,
Markdown,
Html,
Typst,
}
fn read_from_stdin(buffer: &mut String) -> Result<()> {
if io::stdin().is_terminal() {
#[cfg(windows)]
log::info!("Reading from STDIN, press [CTRL+Z] when you're done.");
#[cfg(unix)]
log::info!("Reading from STDIN, press [CTRL+D] when you're done.");
}
let stdin = std::io::stdin();
while stdin.read_line(buffer)? > 0 {}
Ok(())
}
impl ExecuteSubcommand for Command {
async fn execute(self, mut stdout: StandardStream, server_client: ServerClient) -> Result<()> {
let mut request: check::Request = self.request.into();
#[cfg(feature = "annotate")]
let color = stdout.supports_color();
let server_client = server_client.with_max_suggestions(self.max_suggestions);
if self.filenames.is_empty() {
if request.text.is_none() && request.data.is_none() {
let mut text = String::new();
read_from_stdin(&mut text)?;
request = request.with_text(Cow::Owned(text));
}
if let Some(ref text) = request.text {
if text.is_empty() {
log::warn!("No input text was provided, skipping.");
return Ok(());
}
let requests = request.split(self.max_length, self.split_pattern.as_str());
if self.raw {
let response = server_client
.check_multiple_and_join_without_context(requests)
.await?;
writeln!(&mut stdout, "{}", serde_json::to_string_pretty(&response)?)?;
} else {
let response_with_context =
server_client.check_multiple_and_join(requests).await?;
writeln!(
&mut stdout,
"{}",
&response_with_context.annotate(
response_with_context.text.as_ref(),
None,
color
)
)?;
}
} else {
let response = server_client.check(&request).await?;
writeln!(&mut stdout, "{}", serde_json::to_string_pretty(&response)?)?;
};
return Ok(());
}
for filename in self.filenames.iter() {
let mut file_type = self.r#type.clone();
if matches!(self.r#type, FileType::Auto) {
file_type = match PathBuf::from(filename).extension().and_then(|e| e.to_str()) {
Some(ext) => {
match ext {
"typ" => FileType::Typst,
"md" | "markdown" | "mdown" | "mdwn" | "mkd" | "mkdn" | "mdx" => {
FileType::Markdown
},
"html" | "htm" => FileType::Html,
_ => {
log::debug!("Unknown file type: {ext}.");
FileType::Raw
},
}
},
None => {
log::debug!("No extension found for file: {filename:?}.");
FileType::Raw
},
};
};
let file_content = std::fs::read_to_string(filename)?;
let (response, text): (check::Response, String) = match &file_type {
FileType::Auto => unreachable!(),
FileType::Raw => {
let requests = (request.clone().with_text(&file_content))
.split(self.max_length, self.split_pattern.as_str());
if requests.is_empty() {
log::info!("Skipping empty file: {filename:?}.");
continue;
}
let response = server_client.check_multiple_and_join(requests).await?;
(response.into(), file_content)
},
FileType::Typst | FileType::Markdown | FileType::Html => {
let data = match file_type {
FileType::Typst => parse_typst(&file_content),
FileType::Html => parse_html(&file_content),
FileType::Markdown => parse_markdown(&file_content),
_ => unreachable!(),
};
let requests = (request.clone().with_data(data))
.split(self.max_length, self.split_pattern.as_str());
let response = server_client
.check_multiple_and_join_without_context(requests)
.await?;
(response, file_content)
},
};
if self.raw {
writeln!(&mut stdout, "{}", serde_json::to_string_pretty(&response)?)?;
} else {
writeln!(
&mut stdout,
"{}",
&response.annotate(&text, filename.to_str(), color)
)?;
}
}
Ok(())
}
}
#[derive(Args, Clone, Debug, Default, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct CliRequest {
#[clap(short = 't', long, conflicts_with = "data", allow_hyphen_values(true))]
pub text: Option<String>,
#[clap(short = 'd', long, conflicts_with = "text")]
pub data: Option<CliData>,
#[cfg_attr(
feature = "cli",
clap(
short = 'l',
long,
default_value = DEFAULT_LANGUAGE,
value_parser = parse_language_code
)
)]
pub language: String,
#[cfg_attr(
feature = "cli",
clap(short = 'u', long, requires = "api_key", env = "LANGUAGETOOL_USERNAME")
)]
pub username: Option<String>,
#[cfg_attr(
feature = "cli",
clap(short = 'k', long, requires = "username", env = "LANGUAGETOOL_API_KEY")
)]
pub api_key: Option<String>,
#[cfg_attr(feature = "cli", clap(long))]
pub dicts: Option<Vec<String>>,
#[cfg_attr(feature = "cli", clap(long))]
pub mother_tongue: Option<String>,
#[cfg_attr(feature = "cli", clap(long, conflicts_with = "language"))]
pub preferred_variants: Option<Vec<String>>,
#[cfg_attr(feature = "cli", clap(long))]
pub enabled_rules: Option<Vec<String>>,
#[cfg_attr(feature = "cli", clap(long))]
pub disabled_rules: Option<Vec<String>>,
#[cfg_attr(feature = "cli", clap(long))]
pub enabled_categories: Option<Vec<String>>,
#[cfg_attr(feature = "cli", clap(long))]
pub disabled_categories: Option<Vec<String>>,
#[cfg_attr(feature = "cli", clap(long))]
pub enabled_only: bool,
#[cfg_attr(
feature = "cli",
clap(long, default_value = "default", ignore_case = true, value_enum)
)]
pub level: Level,
}
impl From<CliRequest> for Request<'_> {
fn from(val: CliRequest) -> Self {
Request {
text: val.text.map(Cow::Owned),
data: val.data.map(Into::into),
language: val.language,
username: val.username,
api_key: val.api_key,
dicts: val.dicts,
mother_tongue: val.mother_tongue,
preferred_variants: val.preferred_variants,
enabled_rules: val.enabled_rules,
disabled_rules: val.disabled_rules,
enabled_categories: val.enabled_categories,
disabled_categories: val.disabled_categories,
enabled_only: val.enabled_only,
level: val.level,
}
}
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct CliData {
pub annotation: Vec<CliDataAnnotation>,
}
impl From<CliData> for Data<'_> {
fn from(val: CliData) -> Self {
Data {
annotation: val
.annotation
.into_iter()
.map(|a| a.into())
.collect::<Vec<DataAnnotation>>(),
}
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize, Hash)]
#[non_exhaustive]
#[serde(rename_all = "camelCase")]
pub struct CliDataAnnotation {
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub markup: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub interpret_as: Option<String>,
}
impl From<CliDataAnnotation> for DataAnnotation<'_> {
fn from(val: CliDataAnnotation) -> Self {
DataAnnotation {
text: val.text.map(Cow::Owned),
markup: val.markup.map(Cow::Owned),
interpret_as: val.interpret_as.map(Cow::Owned),
}
}
}
impl std::str::FromStr for CliData {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
let v: Self = serde_json::from_str(s)?;
Ok(v)
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_read_from_stdin() {
let handle = std::thread::spawn(|| {
let mut buffer = String::new();
read_from_stdin(&mut buffer).unwrap();
buffer
});
std::thread::sleep(std::time::Duration::from_millis(100));
if std::io::stdin().is_terminal() {
assert!(!handle.is_finished());
} else {
assert!(handle.is_finished());
}
}
}