diff --git a/Cargo.lock b/Cargo.lock index b7939ed..5106778 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -447,6 +447,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -813,6 +825,7 @@ dependencies = [ "clap_complete", "codspeed-criterion-compat", "criterion", + "enum_dispatch", "futures", "is-terminal", "predicates", diff --git a/Cargo.toml b/Cargo.toml index 890062c..25e5859 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ required-features = ["cli"] annotate-snippets = {version = "^0.9.1", optional = true} clap = {version = "^4.5.18", features = ["cargo", "derive", "env", "wrap_help"], optional = true} clap_complete = {version = "^4.5.2", optional = true} +enum_dispatch = {version = "0.3.13", optional = true} is-terminal = {version = "0.4.3", optional = true} pulldown-cmark = {version = "0.10.2", optional = true} reqwest = {version = "^0.11", default-features = false, features = ["json"]} @@ -33,7 +34,7 @@ tokio = {version = "^1.0", features = ["macros"]} [features] annotate = ["dep:annotate-snippets"] -cli = ["annotate", "color", "dep:clap", "dep:is-terminal", "multithreaded"] +cli = ["annotate", "color", "dep:clap", "dep:enum_dispatch", "dep:is-terminal", "multithreaded"] cli-complete = ["cli", "clap_complete"] color = ["annotate-snippets?/color", "dep:termcolor"] default = ["cli", "native-tls"] @@ -61,7 +62,7 @@ license = "MIT" name = "languagetool-rust" readme = "README.md" repository = "https://github.com/jeertmans/languagetool-rust" -rust-version = "1.74.0" +rust-version = "1.75.0" version = "2.1.4" [package.metadata.docs.rs] diff --git a/README.md b/README.md index f591fd8..6394f83 100644 --- a/README.md +++ b/README.md @@ -154,13 +154,14 @@ languagetool-rust = "^2.1" ```rust use languagetool_rust::api::{check, server::ServerClient}; +use std::borrow::Cow; #[tokio::main] async fn main() -> Result<(), Box> { let client = ServerClient::from_env_or_default(); let req = check::Request::default() - .with_text("Some phrase with a smal mistake".to_string()); // # codespell:ignore smal + .with_text(Cow::Borrowed("Some phrase with a smal mistake")); // # codespell:ignore smal println!( "{}", diff --git a/benches/benchmarks/check_texts.rs b/benches/benchmarks/check_texts.rs index 04d645f..1f4d66b 100644 --- a/benches/benchmarks/check_texts.rs +++ b/benches/benchmarks/check_texts.rs @@ -1,3 +1,5 @@ +use std::borrow::Cow; + use codspeed_criterion_compat::{criterion_group, Criterion, Throughput}; use futures::future::join_all; use languagetool_rust::{ @@ -29,12 +31,12 @@ async fn request_until_success(req: &Request, client: &ServerClient) -> Response } #[tokio::main] -async fn check_text_basic(text: &str) -> Response { +async fn check_text_basic(text: &'static str) -> Response { let client = ServerClient::from_env().expect( "Please use a local server for benchmarking, and configure the environ variables to use \ it.", ); - let req = Request::default().with_text(text.to_string()); + let req = Request::default().with_text(Cow::Borrowed(text)); request_until_success(&req, &client).await } @@ -48,7 +50,7 @@ async fn check_text_split(text: &str) -> Response { let resps = join_all(lines.map(|line| { async { - let req = Request::default().with_text(line.to_string()); + let req = Request::default().with_text(Cow::Owned(line.to_string())); let resp = request_until_success(&req, &client).await; check::ResponseWithContext::new(req.get_text(), resp) } diff --git a/rustfmt.toml b/rustfmt.toml index 4c2e3c4..10fcdbd 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,4 +1,5 @@ condense_wildcard_suffixes = true +edition = "2021" # error_on_line_overflow = true # error_on_unformatted = true force_multiline_blocks = true @@ -9,5 +10,4 @@ imports_granularity = "Crate" match_block_trailing_comma = true normalize_doc_attributes = true unstable_features = true -version = "Two" wrap_comments = true diff --git a/src/api/check.rs b/src/api/check.rs index 9bce829..2e05859 100644 --- a/src/api/check.rs +++ b/src/api/check.rs @@ -1,7 +1,6 @@ //! Structures for `check` requests and responses. -#[cfg(feature = "cli")] -use std::path::PathBuf; +use std::{borrow::Cow, mem, ops::Deref}; #[cfg(feature = "annotate")] use annotate_snippets::{ @@ -9,12 +8,12 @@ use annotate_snippets::{ snippet::{Annotation, AnnotationType, Slice, Snippet, SourceAnnotation}, }; #[cfg(feature = "cli")] -use clap::{Args, Parser, ValueEnum}; +use clap::{Args, ValueEnum}; use serde::{Deserialize, Serialize, Serializer}; use crate::error::{Error, Result}; -/// Requests +// REQUESTS /// Parse `v` is valid language code. /// @@ -123,65 +122,74 @@ where } } +/// A portion of text to be checked. #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize, Hash)] #[non_exhaustive] #[serde(rename_all = "camelCase")] -/// A portion of text to be checked. -pub struct DataAnnotation { - /// If set, the markup will be interpreted as this. - #[serde(skip_serializing_if = "Option::is_none")] - pub interpret_as: Option, +pub struct DataAnnotation<'source> { + /// Text that should be treated as normal text. + /// + /// This or `markup` is required. #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option>, /// Text that should be treated as markup. - pub markup: Option, + /// + /// This or `text` is required. #[serde(skip_serializing_if = "Option::is_none")] - /// Text that should be treated as normal text. - pub text: Option, -} - -impl Default for DataAnnotation { - fn default() -> Self { - Self { - interpret_as: None, - markup: None, - text: Some(String::new()), - } - } + pub markup: Option>, + /// If set, the markup will be interpreted as this. + #[serde(skip_serializing_if = "Option::is_none")] + pub interpret_as: Option>, } -impl DataAnnotation { +impl<'source> DataAnnotation<'source> { /// Instantiate a new `DataAnnotation` with text only. #[inline] #[must_use] - pub fn new_text(text: String) -> Self { + pub fn new_text>>(text: T) -> Self { Self { - interpret_as: None, + text: Some(text.into()), markup: None, - text: Some(text), + interpret_as: None, } } /// Instantiate a new `DataAnnotation` with markup only. #[inline] #[must_use] - pub fn new_markup(markup: String) -> Self { + pub fn new_markup>>(markup: M) -> Self { Self { - interpret_as: None, - markup: Some(markup), text: None, + markup: Some(markup.into()), + interpret_as: None, } } /// Instantiate a new `DataAnnotation` with markup and its interpretation. #[inline] #[must_use] - pub fn new_interpreted_markup(markup: String, interpret_as: String) -> Self { + pub fn new_interpreted_markup>, I: Into>>(markup: M, interpret_as: I) -> Self { Self { - interpret_as: Some(interpret_as), - markup: Some(markup), + interpret_as: Some(interpret_as.into()), + markup: Some(markup.into()), text: None, } } + + /// Return the text or markup within the data annotation. + /// + /// # Errors + /// + /// If this data annotation does not contain text or markup. + pub fn try_get_text(&self) -> Result> { + if let Some(ref text) = self.text { + Ok(text.clone()) + } else if let Some(ref markup) = self.markup { + Ok(markup.clone()) + } else{ + Err(Error::InvalidDataAnnotation(format!("missing either text or markup field in {self:?}"))) + } + } } #[cfg(test)] @@ -191,49 +199,49 @@ mod data_annotation_tests { #[test] fn test_text() { - let da = DataAnnotation::new_text("Hello".to_string()); + let da = DataAnnotation::new_text("Hello"); - assert_eq!(da.text.unwrap(), "Hello".to_string()); + assert_eq!(da.text.unwrap(), "Hello"); assert!(da.markup.is_none()); assert!(da.interpret_as.is_none()); } #[test] fn test_markup() { - let da = DataAnnotation::new_markup("Hello".to_string()); + let da = DataAnnotation::new_markup("Hello"); assert!(da.text.is_none()); - assert_eq!(da.markup.unwrap(), "Hello".to_string()); + assert_eq!(da.markup.unwrap(), "Hello"); assert!(da.interpret_as.is_none()); } #[test] fn test_interpreted_markup() { let da = - DataAnnotation::new_interpreted_markup("Hello".to_string(), "Hello".to_string()); + DataAnnotation::new_interpreted_markup("Hello", "Hello"); assert!(da.text.is_none()); - assert_eq!(da.markup.unwrap(), "Hello".to_string()); - assert_eq!(da.interpret_as.unwrap(), "Hello".to_string()); + assert_eq!(da.markup.unwrap(), "Hello"); + assert_eq!(da.interpret_as.unwrap(), "Hello"); } } /// Alternative text to be checked. #[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Hash)] #[non_exhaustive] -pub struct Data { +pub struct Data<'source> { /// Vector of markup text, see [`DataAnnotation`]. - pub annotation: Vec, + pub annotation: Vec>, } -impl> FromIterator for Data { +impl<'source, T: Into>> FromIterator for Data<'source> { fn from_iter>(iter: I) -> Self { let annotation = iter.into_iter().map(std::convert::Into::into).collect(); Data { annotation } } } -impl Serialize for Data { +impl Serialize for Data<'_> { fn serialize(&self, serializer: S) -> std::result::Result where S: serde::Serializer, @@ -246,7 +254,7 @@ impl Serialize for Data { } #[cfg(feature = "cli")] -impl std::str::FromStr for Data { +impl std::str::FromStr for Data<'_> { type Err = Error; fn from_str(s: &str) -> Result { @@ -378,6 +386,10 @@ pub fn split_len<'source>(s: &'source str, n: usize, pat: &str) -> Vec<&'source vec } + +macro_rules! declare_request { + ($name:ident, $lt:lifetime) => { + /// LanguageTool POST check request. /// /// The main feature - check a text with LanguageTool for possible style and @@ -385,18 +397,18 @@ pub fn split_len<'source>(s: &'source str, n: usize, pat: &str) -> Vec<&'source /// /// The structure below tries to follow as closely as possible the JSON API /// described [here](https://languagetool.org/http-api/swagger-ui/#!/default/post_check). -#[cfg_attr(feature = "cli", derive(Args))] -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Hash)] +#[cfg_attr(all(feature = "cli", $lt == 'static), derive(Args))] +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Hash)] #[serde(rename_all = "camelCase")] #[non_exhaustive] -pub struct Request { +pub struct $name<$lt> { /// The text to be checked. This or 'data' is required. #[cfg_attr( feature = "cli", clap(short = 't', long, conflicts_with = "data", allow_hyphen_values(true)) )] #[serde(skip_serializing_if = "Option::is_none")] - pub text: Option, + pub text: Option>, /// The text to be checked, given as a JSON document that specifies what's /// text and what's markup. This or 'text' is required. /// @@ -423,7 +435,7 @@ pub struct Request { /// kind of markup. Entities will need to be expanded in this input. #[cfg_attr(feature = "cli", clap(short = 'd', long, conflicts_with = "text"))] #[serde(skip_serializing_if = "Option::is_none")] - pub data: Option, + pub data: Option>, /// A language code like `en-US`, `de-DE`, `fr`, or `auto` to guess the /// language automatically (see `preferredVariants` below). /// @@ -431,7 +443,7 @@ pub struct Request { /// will only be activated when you specify the variant, e.g. `en-GB` /// instead of just `en`. #[cfg_attr( - all(feature = "cli", feature = "cli", feature = "cli"), + feature = "cli", clap( short = 'l', long, @@ -448,8 +460,7 @@ pub struct Request { )] #[serde(skip_serializing_if = "Option::is_none")] pub username: Option, - /// Set to get Premium API access: [your API - /// key](https://languagetool.org/editor/settings/api). + /// Set to get Premium API access: your API key (see ). #[cfg_attr( feature = "cli", clap(short = 'k', long, requires = "username", env = "LANGUAGETOOL_API_KEY") @@ -497,7 +508,7 @@ pub struct Request { /// If true, only the rules and categories whose IDs are specified with /// `enabledRules` or `enabledCategories` are enabled. #[cfg_attr(feature = "cli", clap(long))] - #[serde(skip_serializing_if = "is_false")] + #[serde(skip_serializing_if = "std::ops::Not::not")] pub enabled_only: bool, /// If set to `picky`, additional rules will be activated, i.e. rules that /// you might only find useful when checking formal text. @@ -509,45 +520,32 @@ pub struct Request { pub level: Level, } -impl Default for Request { - #[inline] - fn default() -> Request { - Request { - text: Default::default(), - data: Default::default(), + }; +} + +declare_request!(Request, 'source); + +impl<'source> Request<'source> { + /// Create a new empty request with language set to `"auto"`. + #[must_use] + pub fn new() -> Self { + Self { language: "auto".to_string(), - username: Default::default(), - api_key: Default::default(), - dicts: Default::default(), - mother_tongue: Default::default(), - preferred_variants: Default::default(), - enabled_rules: Default::default(), - disabled_rules: Default::default(), - enabled_categories: Default::default(), - disabled_categories: Default::default(), - enabled_only: Default::default(), - level: Default::default(), + ..Default::default() } } -} -#[inline] -fn is_false(b: &bool) -> bool { - !(*b) -} - -impl Request { /// Set the text to be checked and remove potential data field. #[must_use] - pub fn with_text(mut self, text: String) -> Self { - self.text = Some(text); + pub fn with_text>>(mut self, text: T) -> Self { + self.text = Some(text.into()); self.data = None; self } /// Set the data to be checked and remove potential text field. #[must_use] - pub fn with_data(mut self, data: Data) -> Self { + pub fn with_data(mut self, data: Data<'source>) -> Self { self.data = Some(data); self.text = None; self @@ -556,7 +554,7 @@ impl Request { /// Set the data (obtained from string) to be checked and remove potential /// text field pub fn with_data_str(self, data: &str) -> serde_json::Result { - Ok(self.with_data(serde_json::from_str(data)?)) + serde_json::from_str(data).map(|data| self.with_data(data)) } /// Set the language of the text / data. @@ -566,29 +564,29 @@ impl Request { self } - /// Return a copy of the text within the request. + /// Return the text within the request. /// /// # Errors /// /// If both `self.text` and `self.data` are [`None`]. /// If any data annotation does not contain text or markup. - pub fn try_get_text(&self) -> Result { + pub fn try_get_text(&self) -> Result> { if let Some(ref text) = self.text { Ok(text.clone()) } else if let Some(ref data) = self.data { - let mut text = String::new(); - for da in data.annotation.iter() { - if let Some(ref t) = da.text { - text.push_str(t.as_str()); - } else if let Some(ref t) = da.markup { - text.push_str(t.as_str()); - } else { - return Err(Error::InvalidDataAnnotation( - "missing either text or markup field in {da:?}".to_string(), - )); + match data.annotation.len() { + 0 => Ok(Default::default()), + 1 => data.annotation[0].try_get_text(), + _ => { + let mut text = String::new(); + + for da in data.annotation.iter() { + text.push_str(da.try_get_text()?.deref()); + } + + Ok(Cow::Owned(text)) } } - Ok(text) } else { Err(Error::InvalidRequest( "missing either text or data field".to_string(), @@ -604,7 +602,7 @@ impl Request { /// If both `self.text` and `self.data` are [`None`]. /// If any data annotation does not contain text or markup. #[must_use] - pub fn get_text(&self) -> String { + pub fn get_text(&self) -> Cow<'source, str> { self.try_get_text().unwrap() } @@ -614,15 +612,20 @@ impl Request { /// # Errors /// /// If `self.text` is none. - pub fn try_split(&self, n: usize, pat: &str) -> Result> { - let text = self - .text - .as_ref() - .ok_or(Error::InvalidRequest("missing text field".to_string()))?; + pub fn try_split(mut self, n: usize, pat: &str) -> Result> { + let text = mem::take(&mut self.text) + .ok_or_else(|| Error::InvalidRequest("missing text field".to_string()))?; + let string: &str = match &text { + Cow::Owned(s) => s.as_str(), + Cow::Borrowed(s) => s, + }; - Ok(split_len(text.as_str(), n, pat) + Ok(split_len(string, n, pat) .iter() - .map(|text_fragment| self.clone().with_text(text_fragment.to_string())) + .map(|text_fragment| { + self.clone() + .with_text(Cow::Owned(text_fragment.to_string())) + }) .collect()) } @@ -634,78 +637,11 @@ impl Request { /// /// If `self.text` is none. #[must_use] - pub fn split(&self, n: usize, pat: &str) -> Vec { + pub fn split(self, n: usize, pat: &str) -> Vec { self.try_split(n, pat).unwrap() } } -/// Parse a string slice into a [`PathBuf`], and error if the file does not -/// exist. -#[cfg(feature = "cli")] -fn parse_filename(s: &str) -> Result { - let path_buf: PathBuf = s.parse().unwrap(); - - if path_buf.is_file() { - Ok(path_buf) - } else { - Err(Error::InvalidFilename(s.to_string())) - } -} - -/// Support file types. -#[cfg(feature = "cli")] -#[derive(Clone, Debug, Default, ValueEnum)] -#[non_exhaustive] -pub enum FileType { - /// Auto. - #[default] - Auto, - /// Markdown. - Markdown, - /// Typst. - Typst, -} - -/// Check text using LanguageTool server. -/// -/// The input can be one of the following: -/// -/// - raw text, if `--text TEXT` is provided; -/// - annotated data, if `--data TEXT` is provided; -/// - raw text, if `-- [FILE]...` are provided. Note that some file types will -/// use a -/// - raw text, through stdin, if nothing is provided. -#[cfg(feature = "cli")] -#[derive(Debug, Parser)] -pub struct CheckCommand { - /// If present, raw JSON output will be printed instead of annotated text. - /// This has no effect if `--data` is used, because it is never - /// annotated. - #[cfg(feature = "cli")] - #[clap(short = 'r', long)] - pub raw: bool, - /// Sets the maximum number of characters before splitting. - #[clap(long, default_value_t = 1500)] - pub max_length: usize, - /// If text is too long, will split on this pattern. - #[clap(long, default_value = "\n\n")] - pub split_pattern: String, - /// Max. number of suggestions kept. If negative, all suggestions are kept. - #[clap(long, default_value_t = 5, allow_negative_numbers = true)] - pub max_suggestions: isize, - /// Specify the files type to use the correct parser. - /// - /// If set to auto, the type is guessed from the filename extension. - #[clap(long, value_enum, default_value_t = FileType::default(), ignore_case = true)] - pub r#type: FileType, - /// Optional filenames from which input is read. - #[arg(conflicts_with_all(["text", "data"]), value_parser = parse_filename)] - pub filenames: Vec, - /// Inner [`Request`]. - #[command(flatten, next_help_heading = "Request options")] - pub request: Request, -} - #[cfg(test)] mod request_tests { @@ -713,22 +649,22 @@ mod request_tests { #[test] fn test_with_text() { - let req = Request::default().with_text("hello".to_string()); + let req = Request::default().with_text("hello"); - assert_eq!(req.text.unwrap(), "hello".to_string()); + assert_eq!(req.text.unwrap(), "hello"); assert!(req.data.is_none()); } #[test] fn test_with_data() { - let req = Request::default().with_text("hello".to_string()); + let req = Request::default().with_text("hello"); - assert_eq!(req.text.unwrap(), "hello".to_string()); + assert_eq!(req.text.unwrap(), "hello"); assert!(req.data.is_none()); } } -/// Responses +// RESPONSES /// Detected language from check request. #[allow(clippy::derive_partial_eq_without_eq)] @@ -1027,17 +963,24 @@ impl Response { #[derive(Debug, Clone, PartialEq)] pub struct ResponseWithContext { /// Original text that was checked by LT. - pub text: String, + pub text: Cow<'static, str>, /// Check response. pub response: Response, /// Text's length. pub text_length: usize, } +impl Deref for ResponseWithContext { + type Target = Response; + fn deref(&self) -> &Self::Target { + &self.response + } +} + impl ResponseWithContext { /// Bind a check response with its original text. #[must_use] - pub fn new(text: String, response: Response) -> Self { + pub fn new(text: Cow<'static, str>, response: Response) -> Self { let text_length = text.chars().count(); Self { text, @@ -1090,8 +1033,12 @@ impl ResponseWithContext { } self.response.matches.append(&mut other.response.matches); - self.text.push_str(other.text.as_str()); + + let mut string = self.text.into_owned(); + string.push_str(other.text.as_ref()); + self.text = Cow::Owned(string); self.text_length += other.text_length; + self } } @@ -1228,11 +1175,11 @@ mod tests { } } - impl<'source> From> for DataAnnotation { + impl<'source> From> for DataAnnotation<'source> { fn from(token: Token<'source>) -> Self { match token { - Token::Text(s) => DataAnnotation::new_text(s.to_string()), - Token::Skip(s) => DataAnnotation::new_markup(s.to_string()), + Token::Text(s) => DataAnnotation::new_text(s), + Token::Skip(s) => DataAnnotation::new_markup(s), } } } @@ -1244,10 +1191,10 @@ mod tests { let expected_data = Data { annotation: vec![ - DataAnnotation::new_text("My".to_string()), - DataAnnotation::new_text("name".to_string()), - DataAnnotation::new_text("is".to_string()), - DataAnnotation::new_markup("Q34XY".to_string()), + DataAnnotation::new_text("My"), + DataAnnotation::new_text("name"), + DataAnnotation::new_text("is"), + DataAnnotation::new_markup("Q34XY"), ], }; diff --git a/src/api/mod.rs b/src/api/mod.rs index f6ed976..813a72f 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -11,7 +11,7 @@ pub mod languages; pub mod server; pub mod words; -use crate::error::{Error, Result}; +use crate::error::Result; /// A HTTP client for making requests to a LanguageTool server. #[derive(Debug)] @@ -48,7 +48,7 @@ impl Client { } /// Send a check request to the server and await for the response. - pub async fn check(&self, request: &check::Request) -> Result { + pub async fn check(&self, request: &check::Request<'_>) -> Result { self.client .post(self.url("/check")) .query(request) diff --git a/src/api/server.rs b/src/api/server.rs index ebb3a08..0a18753 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -1,5 +1,7 @@ //! Structure to communicate with some `LanguageTool` server through the API. +use std::borrow::Cow; + use crate::{ api::{ check::{self, Request, Response}, @@ -262,6 +264,7 @@ impl Default for ServerParameters { /// To use your local server instead of online api, set: /// * `hostname` to "http://localhost" /// * `port` to "8081" +/// /// if you used the default configuration to start the server. #[cfg_attr(feature = "cli", derive(Args))] #[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] @@ -366,7 +369,7 @@ impl ServerClient { } /// Send a check request to the server and await for the response. - pub async fn check(&self, request: &Request) -> Result { + pub async fn check(&self, request: &Request<'_>) -> Result { match self .client .post(format!("{0}/check", self.api)) @@ -408,7 +411,10 @@ impl ServerClient { /// /// If any of the requests has `self.text` field which is none. #[cfg(feature = "multithreaded")] - pub async fn check_multiple_and_join(&self, requests: Vec) -> Result { + pub async fn check_multiple_and_join( + &self, + requests: Vec>, + ) -> Result { let mut tasks = Vec::with_capacity(requests.len()); for request in requests.into_iter() { @@ -418,7 +424,7 @@ impl ServerClient { let text = request.text.ok_or(Error::InvalidRequest( "missing text field; cannot join requests with data annotations".to_string(), ))?; - Result::<(String, Response)>::Ok((text, response)) + Result::<(Cow<'static, str>, Response)>::Ok((text, response)) })); } @@ -437,7 +443,7 @@ impl ServerClient { } } - Ok(response_with_context.unwrap().into()) + Ok(response_with_context.unwrap()) } /// Send a check request to the server, await for the response and annotate @@ -452,7 +458,7 @@ impl ServerClient { let text = request.get_text(); let resp = self.check(request).await?; - Ok(resp.annotate(text.as_str(), origin, color)) + Ok(resp.annotate(text.as_ref(), origin, color)) } /// Send a languages request to the server and await for the response. @@ -583,6 +589,8 @@ impl ServerClient { #[cfg(test)] mod tests { + use std::borrow::Cow; + use super::ServerClient; use crate::api::check::Request; @@ -595,7 +603,7 @@ mod tests { #[tokio::test] async fn test_server_check_text() { let client = ServerClient::from_env_or_default(); - let req = Request::default().with_text("je suis une poupee".to_string()); + let req = Request::default().with_text(Cow::Borrowed("je suis une poupee")); assert!(client.check(&req).await.is_ok()); } diff --git a/src/api/words/mod.rs b/src/api/words/mod.rs index b23f24b..204d81c 100644 --- a/src/api/words/mod.rs +++ b/src/api/words/mod.rs @@ -4,7 +4,7 @@ use crate::error::{Error, Result}; use super::check::serialize_option_vec_string; #[cfg(feature = "cli")] -use clap::{Args, Parser, Subcommand}; +use clap::Args; use serde::{Deserialize, Serialize}; pub mod add; @@ -43,7 +43,7 @@ pub struct LoginArgs { clap(short = 'u', long, required = true, env = "LANGUAGETOOL_USERNAME") )] pub username: String, - /// [Your API key](https://languagetool.org/editor/settings/api). + /// Your API key (see ). #[cfg_attr( feature = "cli", clap(short = 'k', long, required = true, env = "LANGUAGETOOL_API_KEY") @@ -84,7 +84,7 @@ pub struct Request { /// Copy of [`Request`], but used to CLI only. /// -/// This is a temporary solution, until [#3165](https://github.com/clap-rs/clap/issues/3165) is +/// This is a temporary solution, until [#4697](https://github.com/clap-rs/clap/issues/4697) is /// closed. #[cfg(feature = "cli")] #[derive(Args, Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] @@ -120,30 +120,6 @@ impl From for Request { } } -/// Words' optional subcommand. -#[cfg(feature = "cli")] -#[derive(Clone, Debug, Subcommand)] -pub enum WordsSubcommand { - /// Add a word to some user's list. - Add(add::Request), - /// Remove a word from some user's list. - Delete(delete::Request), -} - -/// Retrieve some user's words list. -#[cfg(feature = "cli")] -#[derive(Debug, Parser)] -#[clap(args_conflicts_with_subcommands = true)] -#[clap(subcommand_negates_reqs = true)] -pub struct WordsCommand { - /// Actual GET request. - #[command(flatten)] - pub request: RequestArgs, - /// Optional subcommand. - #[command(subcommand)] - pub subcommand: Option, -} - /// LanguageTool GET words response. #[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] #[non_exhaustive] diff --git a/src/cli.rs b/src/cli.rs deleted file mode 100644 index 00292bc..0000000 --- a/src/cli.rs +++ /dev/null @@ -1,342 +0,0 @@ -//! Command line tools. -//! -//! This module is specifically designed to be used by LTRS's binary target. -//! It contains all the content needed to create LTRS's command line interface. -use std::io::{self, Write}; - -use clap::{CommandFactory, Parser, Subcommand}; -use is_terminal::IsTerminal; -#[cfg(feature = "annotate")] -use termcolor::WriteColor; -use termcolor::{ColorChoice, StandardStream}; - -use crate::{ - api::{ - check, - server::{ServerCli, ServerClient}, - words::WordsSubcommand, - }, - error::Result, -}; - -/// Read lines from standard input and write to buffer string. -/// -/// Standard output is used when waiting for user to input text. -fn read_from_stdin(stdout: &mut W, buffer: &mut String) -> Result<()> -where - W: io::Write, -{ - if io::stdin().is_terminal() { - #[cfg(windows)] - writeln!( - stdout, - "Reading from STDIN, press [CTRL+Z] when you're done." - )?; - - #[cfg(unix)] - writeln!( - stdout, - "Reading from STDIN, press [CTRL+D] when you're done." - )?; - } - let stdin = std::io::stdin(); - - while stdin.read_line(buffer)? > 0 {} - Ok(()) -} - -/// Main command line structure. Contains every subcommand. -#[derive(Parser, Debug)] -#[command( - author, - version, - about = "LanguageTool API bindings in Rust.", - propagate_version(true), - subcommand_required(true), - verbatim_doc_comment -)] -pub struct Cli { - /// Specify WHEN to colorize output. - #[arg(short, long, value_name = "WHEN", default_value = "auto", default_missing_value = "always", num_args(0..=1), require_equals(true))] - pub color: clap::ColorChoice, - /// [`ServerCli`] arguments. - #[command(flatten, next_help_heading = "Server options")] - pub server_cli: ServerCli, - /// Subcommand. - #[command(subcommand)] - #[allow(missing_docs)] - pub command: Command, -} - -/// Enumerate all possible commands. -#[derive(Subcommand, Debug)] -#[allow(missing_docs)] -pub enum Command { - /// Check text using LanguageTool server. - Check(crate::api::check::CheckCommand), - /// Commands to easily run a LanguageTool server with Docker. - #[cfg(feature = "docker")] - Docker(crate::docker::DockerCommand), - /// Return list of supported languages. - #[clap(visible_alias = "lang")] - Languages, - /// Ping the LanguageTool server and return time elapsed in ms if success. - Ping, - /// Retrieve some user's words list, or add / delete word from it. - Words(crate::api::words::WordsCommand), - /// Generate tab-completion scripts for supported shells - #[cfg(feature = "cli-complete")] - Completions(complete::CompleteCommand), -} - -impl Cli { - /// Return a standard output stream that optionally supports color. - #[must_use] - fn stdout(&self) -> StandardStream { - let mut choice: ColorChoice = match self.color { - clap::ColorChoice::Auto => ColorChoice::Auto, - clap::ColorChoice::Always => ColorChoice::Always, - clap::ColorChoice::Never => ColorChoice::Never, - }; - - if choice == ColorChoice::Auto && !io::stdout().is_terminal() { - choice = ColorChoice::Never; - } - - StandardStream::stdout(choice) - } - - /// Execute command, possibly returning an error. - pub async fn execute(self) -> Result<()> { - let mut stdout = self.stdout(); - - let server_client: ServerClient = self.server_cli.into(); - - match self.command { - Command::Check(cmd) => { - let mut request = cmd.request; - #[cfg(feature = "annotate")] - let color = stdout.supports_color(); - - let server_client = server_client.with_max_suggestions(cmd.max_suggestions); - - if cmd.filenames.is_empty() { - if request.text.is_none() && request.data.is_none() { - let mut text = String::new(); - read_from_stdin(&mut stdout, &mut text)?; - request = request.with_text(text); - } - - let mut response = if request.text.is_some() { - let requests = request.split(cmd.max_length, cmd.split_pattern.as_str()); - server_client.check_multiple_and_join(requests).await? - } else { - server_client.check(&request).await? - }; - - if request.text.is_some() && !cmd.raw { - let text = request.text.unwrap(); - response = check::ResponseWithContext::new(text.clone(), response).into(); - writeln!( - &mut stdout, - "{}", - &response.annotate(text.as_str(), None, color) - )?; - } else { - writeln!(&mut stdout, "{}", serde_json::to_string_pretty(&response)?)?; - } - - return Ok(()); - } - - for filename in cmd.filenames.iter() { - let text = std::fs::read_to_string(filename)?; - let requests = request - .clone() - .with_text(text.clone()) - .split(cmd.max_length, cmd.split_pattern.as_str()); - let response = server_client.check_multiple_and_join(requests).await?; - - if !cmd.raw { - writeln!( - &mut stdout, - "{}", - &response.annotate(text.as_str(), filename.to_str(), color) - )?; - } else { - writeln!(&mut stdout, "{}", serde_json::to_string_pretty(&response)?)?; - } - } - }, - #[cfg(feature = "docker")] - Command::Docker(cmd) => { - cmd.execute(&mut stdout)?; - }, - Command::Languages => { - let languages_response = server_client.languages().await?; - let languages = serde_json::to_string_pretty(&languages_response)?; - - writeln!(&mut stdout, "{languages}")?; - }, - Command::Ping => { - let ping = server_client.ping().await?; - writeln!(&mut stdout, "PONG! Delay: {ping} ms")?; - }, - Command::Words(cmd) => { - let words = match &cmd.subcommand { - Some(WordsSubcommand::Add(request)) => { - let words_response = server_client.words_add(request).await?; - serde_json::to_string_pretty(&words_response)? - }, - Some(WordsSubcommand::Delete(request)) => { - let words_response = server_client.words_delete(request).await?; - serde_json::to_string_pretty(&words_response)? - }, - None => { - let words_response = server_client.words(&cmd.request.into()).await?; - serde_json::to_string_pretty(&words_response)? - }, - }; - - writeln!(&mut stdout, "{words}")?; - }, - #[cfg(feature = "cli-complete")] - Command::Completions(cmd) => { - cmd.execute(&mut stdout)?; - }, - } - Ok(()) - } -} - -/// Build a command from the top-level command line structure. -#[must_use] -pub fn build_cli() -> clap::Command { - Cli::command() -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn test_cli() { - Cli::command().debug_assert(); - } -} - -#[cfg(feature = "cli-complete")] -pub(crate) mod complete { - //! Completion scripts generation with [`clap_complete`]. - - use crate::error::Result; - use clap::{Command, Parser}; - use clap_complete::{generate, shells::Shell}; - use std::io::Write; - - /// Command structure to generate complete scripts. - #[derive(Debug, Parser)] - #[command( - about = "Generate tab-completion scripts for supported shells", - after_help = "Use --help for installation help.", - after_long_help = COMPLETIONS_HELP -)] - pub struct CompleteCommand { - /// Shell for which to completion script is generated. - #[arg(value_enum, ignore_case = true)] - shell: Shell, - } - - impl CompleteCommand { - /// Generate completion file for current shell and write to buffer. - pub fn generate_completion_file(&self, build_cli: F, buffer: &mut W) - where - F: FnOnce() -> Command, - W: Write, - { - generate(self.shell, &mut build_cli(), "ltrs", buffer); - } - - /// Execute command by writing completion script to stdout. - pub fn execute(&self, stdout: &mut W) -> Result<()> - where - W: Write, - { - self.generate_completion_file(super::build_cli, stdout); - Ok(()) - } - } - - pub(crate) static COMPLETIONS_HELP: &str = r"DISCUSSION: - Enable tab completion for Bash, Fish, Zsh, or PowerShell - Elvish shell completion is currently supported, but not documented below. - The script is output on `stdout`, allowing one to re-direct the - output to the file of their choosing. Where you place the file - will depend on which shell, and which operating system you are - using. Your particular configuration may also determine where - these scripts need to be placed. - Here are some common set ups for the three supported shells under - Unix and similar operating systems (such as GNU/Linux). - BASH: - Completion files are commonly stored in `/etc/bash_completion.d/` for - system-wide commands, but can be stored in - `~/.local/share/bash-completion/completions` for user-specific commands. - Run the command: - $ mkdir -p ~/.local/share/bash-completion/completions - $ ltrs completions bash >> ~/.local/share/bash-completion/completions/ltrs - This installs the completion script. You may have to log out and - log back in to your shell session for the changes to take effect. - BASH (macOS/Homebrew): - Homebrew stores bash completion files within the Homebrew directory. - With the `bash-completion` brew formula installed, run the command: - $ mkdir -p $(brew --prefix)/etc/bash_completion.d - $ ltrs completions bash > $(brew --prefix)/etc/bash_completion.d/ltrs.bash-completion - FISH: - Fish completion files are commonly stored in - `$HOME/.config/fish/completions`. Run the command: - $ mkdir -p ~/.config/fish/completions - $ ltrs completions fish > ~/.config/fish/completions/ltrs.fish - This installs the completion script. You may have to log out and - log back in to your shell session for the changes to take effect. - ZSH: - ZSH completions are commonly stored in any directory listed in - your `$fpath` variable. To use these completions, you must either - add the generated script to one of those directories, or add your - own to this list. - Adding a custom directory is often the safest bet if you are - unsure of which directory to use. First create the directory; for - this example we'll create a hidden directory inside our `$HOME` - directory: - $ mkdir ~/.zfunc - Then add the following lines to your `.zshrc` just before - `compinit`: - fpath+=~/.zfunc - Now you can install the completions script using the following - command: - $ ltrs completions zsh > ~/.zfunc/_ltrs - You must then either log out and log back in, or simply run - $ exec zsh - for the new completions to take effect. - CUSTOM LOCATIONS: - Alternatively, you could save these files to the place of your - choosing, such as a custom directory inside your $HOME. Doing so - will require you to add the proper directives, such as `source`ing - inside your login script. Consult your shells documentation for - how to add such directives. - POWERSHELL: - The powershell completion scripts require PowerShell v5.0+ (which - comes with Windows 10, but can be downloaded separately for windows 7 - or 8.1). - First, check if a profile has already been set - PS C:\> Test-Path $profile - If the above command returns `False` run the following - PS C:\> New-Item -path $profile -type file -force - Now open the file provided by `$profile` (if you used the - `New-Item` command it will be - `${env:USERPROFILE}\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1` - Next, we either save the completions file into our profile, or - into a separate file and source it inside our profile. To save the - completions into our profile simply use - PS C:\> ltrs completions powershell >> ${env:USERPROFILE}\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1 - SOURCE: - This documentation is directly taken from: https://github.com/rust-lang/rustup/blob/8f6b53628ad996ad86f9c6225fa500cddf860905/src/cli/help.rs#L157"; -} diff --git a/src/cli/check.rs b/src/cli/check.rs new file mode 100644 index 0000000..24d7d61 --- /dev/null +++ b/src/cli/check.rs @@ -0,0 +1,134 @@ +//! Check text using LanguageTool server. +//! +//! The input can be one of the following: +//! +//! - raw text, if `--text TEXT` is provided; +//! - annotated data, if `--data TEXT` is provided; +//! - text from file(s), if `[FILE(S)]...` are provided. +//! - raw text through `stdin`, if nothing else is provided. +use std::{borrow::Cow, io::Write, path::PathBuf}; + +use clap::{Parser, ValueEnum}; +use termcolor::{StandardStream, WriteColor}; + +use crate::{ + api::{check::Request, server::ServerClient}, + error::{Error, Result}, +}; + +use super::ExecuteSubcommand; + +/// Parse a string slice into a [`PathBuf`], and error if the file does not +/// exist. +fn parse_filename(s: &str) -> Result { + 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 { + /// If present, raw JSON output will be printed instead of annotated text. + /// This has no effect if `--data` is used, because it is never + /// annotated. + #[clap(short = 'r', long)] + pub raw: bool, + /// Sets the maximum number of characters before splitting. + #[clap(long, default_value_t = 1500)] + pub max_length: usize, + /// If text is too long, will split on this pattern. + #[clap(long, default_value = "\n\n")] + pub split_pattern: String, + /// Max. number of suggestions kept. If negative, all suggestions are kept. + #[clap(long, default_value_t = 5, allow_negative_numbers = true)] + pub max_suggestions: isize, + /// Specify the files type to use the correct parser. + /// + /// If set to auto, the type is guessed from the filename extension. + #[clap(long, value_enum, default_value_t = FileType::default(), ignore_case = true)] + pub r#type: FileType, + /// Optional filenames from which input is read. + #[arg(conflicts_with_all(["text", "data"]), value_parser = parse_filename)] + pub filenames: Vec, + /// Inner [`Request`]. + #[command(flatten, next_help_heading = "Request options")] + pub request: Request, +} + +/// Support file types. +#[derive(Clone, Debug, Default, ValueEnum)] +#[non_exhaustive] +pub enum FileType { + /// Auto. + #[default] + Auto, + /// Markdown. + Markdown, + /// Typst. + Typst, +} + +impl ExecuteSubcommand for Command { + /// Executes the `check` subcommand. + async fn execute(self, mut stdout: StandardStream, server_client: ServerClient) -> Result<()> { + let mut request = self.request; + #[cfg(feature = "annotate")] + let color = stdout.supports_color(); + + let server_client = server_client.with_max_suggestions(self.max_suggestions); + + // ANNOTATED DATA, RAW TEXT, STDIN + if self.filenames.is_empty() { + // Fallback to `stdin` if nothing else is provided + if request.text.is_none() && request.data.is_none() { + let mut text = String::new(); + super::read_from_stdin(&mut stdout, &mut text)?; + request = request.with_text(Cow::Owned(text)); + } + + if request.text.is_none() { + // Handle annotated data + let response = server_client.check(&request).await?; + writeln!(&mut stdout, "{}", serde_json::to_string_pretty(&response)?)?; + return Ok(()); + }; + + let requests = request.split(self.max_length, self.split_pattern.as_str()); + let response = server_client.check_multiple_and_join(requests).await?; + + writeln!( + &mut stdout, + "{}", + &response.annotate(response.text.as_ref(), None, color) + )?; + + return Ok(()); + } + + // FILES + for filename in self.filenames.iter() { + let text = std::fs::read_to_string(filename)?; + let requests = request + .clone() + .with_text(Cow::Owned(text)) + .split(self.max_length, self.split_pattern.as_str()); + let response = server_client.check_multiple_and_join(requests).await?; + + if !self.raw { + writeln!( + &mut stdout, + "{}", + &response.annotate(response.text.as_ref(), filename.to_str(), color) + )?; + } else { + writeln!(&mut stdout, "{}", serde_json::to_string_pretty(&*response)?)?; + } + } + + Ok(()) + } +} diff --git a/src/cli/completions.rs b/src/cli/completions.rs new file mode 100644 index 0000000..5f9d2ec --- /dev/null +++ b/src/cli/completions.rs @@ -0,0 +1,115 @@ +//! Completion scripts generation with [`clap_complete`]. + +use crate::{api::server::ServerClient, error::Result}; +use clap::Parser; +use clap_complete::{generate, shells::Shell}; +use std::io::Write; +use termcolor::StandardStream; + +use super::ExecuteSubcommand; + +/// Command structure to generate complete scripts. +#[derive(Debug, Parser)] +#[command( + about = "Generate tab-completion scripts for supported shells", + after_help = "Use --help for installation help.", + after_long_help = COMPLETIONS_HELP +)] +pub struct Command { + /// Shell for which to completion script is generated. + #[arg(value_enum, ignore_case = true)] + shell: Shell, +} + +impl Command { + /// Generate completion file for current shell and write to buffer. + pub fn generate_completion_file(&self, build_cli: F, buffer: &mut W) + where + F: FnOnce() -> clap::Command, + W: Write, + { + generate(self.shell, &mut build_cli(), "ltrs", buffer); + } +} + +impl ExecuteSubcommand for Command { + /// Executes the `completions` subcommand. + async fn execute(self, mut stdout: StandardStream, _: ServerClient) -> Result<()> { + self.generate_completion_file(super::build_cli, &mut stdout); + Ok(()) + } +} + +pub(crate) static COMPLETIONS_HELP: &str = r"DISCUSSION: + Enable tab completion for Bash, Fish, Zsh, or PowerShell + Elvish shell completion is currently supported, but not documented below. + The script is output on `stdout`, allowing one to re-direct the + output to the file of their choosing. Where you place the file + will depend on which shell, and which operating system you are + using. Your particular configuration may also determine where + these scripts need to be placed. + Here are some common set ups for the three supported shells under + Unix and similar operating systems (such as GNU/Linux). + BASH: + Completion files are commonly stored in `/etc/bash_completion.d/` for + system-wide commands, but can be stored in + `~/.local/share/bash-completion/completions` for user-specific commands. + Run the command: + $ mkdir -p ~/.local/share/bash-completion/completions + $ ltrs completions bash >> ~/.local/share/bash-completion/completions/ltrs + This installs the completion script. You may have to log out and + log back in to your shell session for the changes to take effect. + BASH (macOS/Homebrew): + Homebrew stores bash completion files within the Homebrew directory. + With the `bash-completion` brew formula installed, run the command: + $ mkdir -p $(brew --prefix)/etc/bash_completion.d + $ ltrs completions bash > $(brew --prefix)/etc/bash_completion.d/ltrs.bash-completion + FISH: + Fish completion files are commonly stored in + `$HOME/.config/fish/completions`. Run the command: + $ mkdir -p ~/.config/fish/completions + $ ltrs completions fish > ~/.config/fish/completions/ltrs.fish + This installs the completion script. You may have to log out and + log back in to your shell session for the changes to take effect. + ZSH: + ZSH completions are commonly stored in any directory listed in + your `$fpath` variable. To use these completions, you must either + add the generated script to one of those directories, or add your + own to this list. + Adding a custom directory is often the safest bet if you are + unsure of which directory to use. First create the directory; for + this example we'll create a hidden directory inside our `$HOME` + directory: + $ mkdir ~/.zfunc + Then add the following lines to your `.zshrc` just before + `compinit`: + fpath+=~/.zfunc + Now you can install the completions script using the following + command: + $ ltrs completions zsh > ~/.zfunc/_ltrs + You must then either log out and log back in, or simply run + $ exec zsh + for the new completions to take effect. + CUSTOM LOCATIONS: + Alternatively, you could save these files to the place of your + choosing, such as a custom directory inside your $HOME. Doing so + will require you to add the proper directives, such as `source`ing + inside your login script. Consult your shells documentation for + how to add such directives. + POWERSHELL: + The powershell completion scripts require PowerShell v5.0+ (which + comes with Windows 10, but can be downloaded separately for windows 7 + or 8.1). + First, check if a profile has already been set + PS C:\> Test-Path $profile + If the above command returns `False` run the following + PS C:\> New-Item -path $profile -type file -force + Now open the file provided by `$profile` (if you used the + `New-Item` command it will be + `${env:USERPROFILE}\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1` + Next, we either save the completions file into our profile, or + into a separate file and source it inside our profile. To save the + completions into our profile simply use + PS C:\> ltrs completions powershell >> ${env:USERPROFILE}\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1 + SOURCE: + This documentation is directly taken from: https://github.com/rust-lang/rustup/blob/8f6b53628ad996ad86f9c6225fa500cddf860905/src/cli/help.rs#L157"; diff --git a/src/docker.rs b/src/cli/docker.rs similarity index 70% rename from src/docker.rs rename to src/cli/docker.rs index a040d41..eaf3a47 100644 --- a/src/docker.rs +++ b/src/cli/docker.rs @@ -1,61 +1,52 @@ //! Structures and methods to easily manipulate Docker images, especially for //! LanguageTool applications. -use std::process::{Command, Output, Stdio}; +use std::process::{self, Output, Stdio}; -#[cfg(feature = "cli")] use clap::{Args, Parser}; +use termcolor::StandardStream; -use crate::error::{exit_status_error, Error, Result}; +use crate::{ + api::server::ServerClient, + error::{exit_status_error, Error, Result}, +}; + +use super::ExecuteSubcommand; /// Commands to pull, start and stop a `LanguageTool` container using Docker. -#[cfg_attr(feature = "cli", derive(Args))] -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Args)] pub struct Docker { /// Image or repository from a registry. - #[cfg_attr( - feature = "cli", - clap( - default_value = "erikvl87/languagetool", - env = "LANGUAGETOOL_DOCKER_IMAGE" - ) + #[clap( + default_value = "erikvl87/languagetool", + env = "LANGUAGETOOL_DOCKER_IMAGE" )] name: String, /// Path to Docker's binaries. - #[cfg_attr( - feature = "cli", - clap( - short = 'b', - long, - default_value = "docker", - env = "LANGUAGETOOL_DOCKER_BIN" - ) + #[clap( + short = 'b', + long, + default_value = "docker", + env = "LANGUAGETOOL_DOCKER_BIN" )] bin: String, /// Name assigned to the container. - #[cfg_attr( - feature = "cli", - clap(long, default_value = "languagetool", env = "LANGUAGETOOL_DOCKER_NAME") - )] + #[clap(long, default_value = "languagetool", env = "LANGUAGETOOL_DOCKER_NAME")] container_name: String, /// Publish a container's port(s) to the host. - #[cfg_attr( - feature = "cli", - clap( - short = 'p', - long, - default_value = "8010:8010", - env = "LANGUAGETOOL_DOCKER_PORT" - ) + #[clap( + short = 'p', + long, + default_value = "8010:8010", + env = "LANGUAGETOOL_DOCKER_PORT" )] port: String, /// Docker action. - #[cfg_attr(feature = "cli", clap(subcommand))] + #[clap(subcommand)] action: Action, } -#[cfg_attr(feature = "cli", derive(clap::Subcommand))] -#[derive(Clone, Debug)] +#[derive(clap::Subcommand, Clone, Debug)] /// Enumerate supported Docker actions. enum Action { /// Pull a docker docker image. @@ -76,7 +67,7 @@ enum Action { impl Docker { /// Pull a Docker image from the given repository/file/... pub fn pull(&self) -> Result { - let output = Command::new(&self.bin) + let output = process::Command::new(&self.bin) .args(["pull", &self.name]) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) @@ -90,7 +81,7 @@ impl Docker { /// Start a Docker container with given specifications. pub fn start(&self) -> Result { - let output = Command::new(&self.bin) + let output = process::Command::new(&self.bin) .args([ "run", "--rm", @@ -113,7 +104,7 @@ impl Docker { /// Stop the latest Docker container with the given name. pub fn stop(&self) -> Result { - let output = Command::new(&self.bin) + let output = process::Command::new(&self.bin) .args([ "ps", "-l", @@ -132,7 +123,7 @@ impl Docker { .filter(|c| c.is_alphanumeric()) // This avoids newlines .collect(); - let output = Command::new(&self.bin) + let output = process::Command::new(&self.bin) .args(["kill", &docker_id]) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) @@ -154,21 +145,16 @@ impl Docker { } /// Commands to easily run a LanguageTool server with Docker. -#[cfg(feature = "cli")] #[derive(Debug, Parser)] -pub struct DockerCommand { +pub struct Command { /// Actual command arguments. #[command(flatten)] pub docker: Docker, } -#[cfg(feature = "cli")] -impl DockerCommand { - /// Execute a Docker command and write output to stdout. - pub fn execute(&self, _stdout: &mut W) -> Result<()> - where - W: std::io::Write, - { +impl ExecuteSubcommand for Command { + /// Execute the `docker` subcommand. + async fn execute(self, _stdout: StandardStream, _: ServerClient) -> Result<()> { self.docker.run_action()?; Ok(()) } diff --git a/src/cli/languages.rs b/src/cli/languages.rs new file mode 100644 index 0000000..a8d2a13 --- /dev/null +++ b/src/cli/languages.rs @@ -0,0 +1,21 @@ +use clap::Parser; +use std::io::Write; +use termcolor::StandardStream; + +use crate::{api::server::ServerClient, error::Result}; + +use super::ExecuteSubcommand; + +#[derive(Debug, Parser)] +pub struct Command {} + +impl ExecuteSubcommand for Command { + /// Executes the `languages` subcommand. + async fn execute(self, mut stdout: StandardStream, server_client: ServerClient) -> Result<()> { + let languages_response = server_client.languages().await?; + let languages = serde_json::to_string_pretty(&languages_response)?; + + writeln!(&mut stdout, "{languages}")?; + Ok(()) + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..8d092b1 --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,149 @@ +//! Command line tools. +//! +//! This module is specifically designed to be used by LTRS's binary target. +//! It contains all the content needed to create LTRS's command line interface. + +mod check; +#[cfg(feature = "cli-complete")] +mod completions; +#[cfg(feature = "docker")] +mod docker; +mod languages; +mod ping; +mod words; + +use std::io; + +use clap::{CommandFactory, Parser, Subcommand}; +#[cfg(feature = "cli")] +use enum_dispatch::enum_dispatch; +use is_terminal::IsTerminal; +#[cfg(feature = "annotate")] +use termcolor::{ColorChoice, StandardStream}; + +#[cfg(feature = "docker")] +pub use docker::Docker; + +use crate::{ + api::server::{ServerCli, ServerClient}, + error::Result, +}; + +/// Read lines from standard input and write to buffer string. +/// +/// Standard output is used when waiting for user to input text. +fn read_from_stdin(stdout: &mut W, buffer: &mut String) -> Result<()> +where + W: io::Write, +{ + if io::stdin().is_terminal() { + #[cfg(windows)] + writeln!( + stdout, + "Reading from STDIN, press [CTRL+Z] when you're done." + )?; + + #[cfg(unix)] + writeln!( + stdout, + "Reading from STDIN, press [CTRL+D] when you're done." + )?; + } + let stdin = std::io::stdin(); + + while stdin.read_line(buffer)? > 0 {} + Ok(()) +} + +/// Main command line structure. Contains every subcommand. +#[derive(Parser, Debug)] +#[command( + author, + version, + about = "LanguageTool API bindings in Rust.", + propagate_version(true), + subcommand_required(true), + verbatim_doc_comment +)] +pub struct Cli { + /// Specify WHEN to colorize output. + #[arg(short, long, value_name = "WHEN", default_value = "auto", default_missing_value = "always", num_args(0..=1), require_equals(true))] + pub color: clap::ColorChoice, + /// [`ServerCli`] arguments. + #[command(flatten, next_help_heading = "Server options")] + pub server_cli: ServerCli, + /// Subcommand. + #[command(subcommand)] + #[allow(missing_docs)] + pub command: Command, +} + +/// All possible subcommands. +#[derive(Subcommand, Debug)] +#[enum_dispatch] +#[allow(missing_docs)] +pub enum Command { + /// Check text using LanguageTool server. + Check(check::Command), + /// Commands to easily run a LanguageTool server with Docker. + #[cfg(feature = "docker")] + Docker(docker::Command), + /// Return list of supported languages. + #[clap(visible_alias = "lang")] + Languages(languages::Command), + /// Ping the LanguageTool server and return time elapsed in ms if success. + Ping(ping::Command), + /// Retrieve some user's words list, or add / delete word from it. + Words(words::Command), + /// Generate tab-completion scripts for supported shells + #[cfg(feature = "cli-complete")] + Completions(completions::Command), +} + +/// Provides a common interface for executing the subcommands. +#[enum_dispatch(Command)] +trait ExecuteSubcommand { + /// Executes the subcommand. + async fn execute(self, stdout: StandardStream, server_client: ServerClient) -> Result<()>; +} + +impl Cli { + /// Return a standard output stream that optionally supports color. + #[must_use] + fn stdout(&self) -> StandardStream { + let mut choice: ColorChoice = match self.color { + clap::ColorChoice::Auto => ColorChoice::Auto, + clap::ColorChoice::Always => ColorChoice::Always, + clap::ColorChoice::Never => ColorChoice::Never, + }; + + if choice == ColorChoice::Auto && !io::stdout().is_terminal() { + choice = ColorChoice::Never; + } + + StandardStream::stdout(choice) + } + + /// Execute command, possibly returning an error. + pub async fn execute(self) -> Result<()> { + let stdout = self.stdout(); + let server_client: ServerClient = self.server_cli.into(); + + self.command.execute(stdout, server_client).await + } +} + +/// Build a command from the top-level command line structure. +#[must_use] +pub fn build_cli() -> clap::Command { + Cli::command() +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_cli() { + Cli::command().debug_assert(); + } +} diff --git a/src/cli/ping.rs b/src/cli/ping.rs new file mode 100644 index 0000000..d32d3a7 --- /dev/null +++ b/src/cli/ping.rs @@ -0,0 +1,20 @@ +use clap::Parser; +use std::io::Write; +use termcolor::StandardStream; + +use crate::{api::server::ServerClient, error::Result}; + +use super::ExecuteSubcommand; + +#[derive(Debug, Parser)] +pub struct Command {} + +impl ExecuteSubcommand for Command { + /// Execute the `languages` subcommand. + async fn execute(self, mut stdout: StandardStream, server_client: ServerClient) -> Result<()> { + let ping = server_client.ping().await?; + + writeln!(&mut stdout, "PONG! Delay: {ping} ms")?; + Ok(()) + } +} diff --git a/src/cli/words.rs b/src/cli/words.rs new file mode 100644 index 0000000..f87aa1b --- /dev/null +++ b/src/cli/words.rs @@ -0,0 +1,55 @@ +use clap::{Parser, Subcommand}; +use std::io::Write; +use termcolor::StandardStream; + +use crate::{ + api::{self, server::ServerClient, words::RequestArgs}, + error::Result, +}; + +use super::ExecuteSubcommand; + +/// Retrieve some user's words list. +#[derive(Debug, Parser)] +#[clap(args_conflicts_with_subcommands = true)] +#[clap(subcommand_negates_reqs = true)] +pub struct Command { + /// Actual GET request. + #[command(flatten)] + pub request: RequestArgs, + /// Optional subcommand. + #[command(subcommand)] + pub subcommand: Option, +} + +/// Words' optional subcommand. +#[derive(Clone, Debug, Subcommand)] +pub enum WordsSubcommand { + /// Add a word to some user's list. + Add(api::words::add::Request), + /// Remove a word from some user's list. + Delete(api::words::delete::Request), +} + +impl ExecuteSubcommand for Command { + /// Executes the `words` subcommand. + async fn execute(self, mut stdout: StandardStream, server_client: ServerClient) -> Result<()> { + let words = match self.subcommand { + Some(WordsSubcommand::Add(request)) => { + let words_response = server_client.words_add(&request).await?; + serde_json::to_string_pretty(&words_response)? + }, + Some(WordsSubcommand::Delete(request)) => { + let words_response = server_client.words_delete(&request).await?; + serde_json::to_string_pretty(&words_response)? + }, + None => { + let words_response = server_client.words(&self.request.into()).await?; + serde_json::to_string_pretty(&words_response)? + }, + }; + + writeln!(&mut stdout, "{words}")?; + Ok(()) + } +} diff --git a/src/lib.rs b/src/lib.rs index a28f029..4d8f386 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,9 +20,4 @@ pub mod api; #[cfg(feature = "cli")] pub mod cli; -#[cfg(feature = "docker")] -pub mod docker; pub mod error; - -#[cfg(feature = "docker")] -pub use crate::docker::Docker; diff --git a/tests/match_positions.rs b/tests/match_positions.rs index 944166c..2abf4c7 100644 --- a/tests/match_positions.rs +++ b/tests/match_positions.rs @@ -1,4 +1,5 @@ use languagetool_rust::api::{check, server::ServerClient}; +use std::borrow::Cow; #[macro_export] macro_rules! test_match_positions { @@ -7,7 +8,7 @@ macro_rules! test_match_positions { async fn $name() -> Result<(), Box> { let client = ServerClient::from_env_or_default(); - let req = check::Request::default().with_text($text.to_string()); + let req = check::Request::default().with_text(Cow::Borrowed($text)); let resp = client.check(&req).await.unwrap(); let resp = check::ResponseWithContext::new(req.get_text(), resp);