mirror of
https://github.com/crate-ci/typos.git
synced 2024-12-23 08:02:15 -05:00
Merge pull request #219 from epage/types
feat(config): Per-file type settings
This commit is contained in:
commit
0656a62860
5 changed files with 250 additions and 320 deletions
|
@ -31,3 +31,4 @@ Configuration is read from the following (in precedence order)
|
||||||
| default.locale | --locale | en, en-us, en-gb, en-ca, en-au | English dialect to correct to. |
|
| default.locale | --locale | en, en-us, en-gb, en-ca, en-au | English dialect to correct to. |
|
||||||
| default.extend-identifiers | \- | table of strings | Corrections for identifiers. When the correction is blank, the word is never valid. When the correction is the key, the word is always valid. |
|
| default.extend-identifiers | \- | table of strings | Corrections for identifiers. When the correction is blank, the word is never valid. When the correction is the key, the word is always valid. |
|
||||||
| default.extend-words | \- | table of strings | Corrections for identifiers. When the correction is blank, the word is never valid. When the correction is the key, the word is always valid. |
|
| default.extend-words | \- | table of strings | Corrections for identifiers. When the correction is blank, the word is never valid. When the correction is the key, the word is always valid. |
|
||||||
|
| type.<name>.binary | <varied> | <varied> | See `default.` for child keys. Run with `--type-list` to see available `<name>`s |
|
||||||
|
|
55
src/args.rs
55
src/args.rs
|
@ -79,8 +79,9 @@ pub(crate) struct Args {
|
||||||
/// Write the current configuration to file with `-` for stdout
|
/// Write the current configuration to file with `-` for stdout
|
||||||
pub(crate) dump_config: Option<std::path::PathBuf>,
|
pub(crate) dump_config: Option<std::path::PathBuf>,
|
||||||
|
|
||||||
#[structopt(flatten)]
|
#[structopt(long, group = "mode")]
|
||||||
pub(crate) overrides: FileArgs,
|
/// Show all supported file types.
|
||||||
|
pub(crate) type_list: bool,
|
||||||
|
|
||||||
#[structopt(
|
#[structopt(
|
||||||
long,
|
long,
|
||||||
|
@ -129,7 +130,20 @@ pub(crate) struct FileArgs {
|
||||||
pub(crate) locale: Option<config::Locale>,
|
pub(crate) locale: Option<config::Locale>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl config::EngineSource for FileArgs {
|
impl FileArgs {
|
||||||
|
pub fn to_config(&self) -> config::EngineConfig {
|
||||||
|
config::EngineConfig {
|
||||||
|
binary: self.binary(),
|
||||||
|
check_filename: self.check_filename(),
|
||||||
|
check_file: self.check_file(),
|
||||||
|
tokenizer: None,
|
||||||
|
dict: Some(config::DictConfig {
|
||||||
|
locale: self.locale,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn binary(&self) -> Option<bool> {
|
fn binary(&self) -> Option<bool> {
|
||||||
match (self.binary, self.no_binary) {
|
match (self.binary, self.no_binary) {
|
||||||
(true, false) => Some(true),
|
(true, false) => Some(true),
|
||||||
|
@ -156,16 +170,6 @@ impl config::EngineSource for FileArgs {
|
||||||
(_, _) => unreachable!("StructOpt should make this impossible"),
|
(_, _) => unreachable!("StructOpt should make this impossible"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dict(&self) -> Option<&dyn config::DictSource> {
|
|
||||||
Some(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl config::DictSource for FileArgs {
|
|
||||||
fn locale(&self) -> Option<config::Locale> {
|
|
||||||
self.locale
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, StructOpt)]
|
#[derive(Debug, StructOpt)]
|
||||||
|
@ -173,11 +177,17 @@ impl config::DictSource for FileArgs {
|
||||||
pub(crate) struct ConfigArgs {
|
pub(crate) struct ConfigArgs {
|
||||||
#[structopt(flatten)]
|
#[structopt(flatten)]
|
||||||
walk: WalkArgs,
|
walk: WalkArgs,
|
||||||
|
#[structopt(flatten)]
|
||||||
|
overrides: FileArgs,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl config::ConfigSource for ConfigArgs {
|
impl ConfigArgs {
|
||||||
fn walk(&self) -> Option<&dyn config::WalkSource> {
|
pub fn to_config(&self) -> config::Config {
|
||||||
Some(&self.walk)
|
config::Config {
|
||||||
|
files: self.walk.to_config(),
|
||||||
|
overrides: self.overrides.to_config(),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -221,7 +231,18 @@ pub(crate) struct WalkArgs {
|
||||||
ignore_vcs: bool,
|
ignore_vcs: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl config::WalkSource for WalkArgs {
|
impl WalkArgs {
|
||||||
|
pub fn to_config(&self) -> config::Walk {
|
||||||
|
config::Walk {
|
||||||
|
ignore_hidden: self.ignore_hidden(),
|
||||||
|
ignore_files: self.ignore_files(),
|
||||||
|
ignore_dot: self.ignore_dot(),
|
||||||
|
ignore_vcs: self.ignore_vcs(),
|
||||||
|
ignore_global: self.ignore_global(),
|
||||||
|
ignore_parent: self.ignore_parent(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn ignore_hidden(&self) -> Option<bool> {
|
fn ignore_hidden(&self) -> Option<bool> {
|
||||||
match (self.hidden, self.no_hidden) {
|
match (self.hidden, self.no_hidden) {
|
||||||
(true, false) => Some(false),
|
(true, false) => Some(false),
|
||||||
|
|
296
src/config.rs
296
src/config.rs
|
@ -1,119 +1,15 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
pub trait ConfigSource {
|
|
||||||
fn walk(&self) -> Option<&dyn WalkSource> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default(&self) -> Option<&dyn EngineSource> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait WalkSource {
|
|
||||||
/// Skip hidden files and directories.
|
|
||||||
fn ignore_hidden(&self) -> Option<bool> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Respect ignore files.
|
|
||||||
fn ignore_files(&self) -> Option<bool> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Respect .ignore files.
|
|
||||||
fn ignore_dot(&self) -> Option<bool> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Respect ignore files in vcs directories.
|
|
||||||
fn ignore_vcs(&self) -> Option<bool> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Respect global ignore files.
|
|
||||||
fn ignore_global(&self) -> Option<bool> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Respect ignore files in parent directories.
|
|
||||||
fn ignore_parent(&self) -> Option<bool> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait EngineSource {
|
|
||||||
/// Check binary files.
|
|
||||||
fn binary(&self) -> Option<bool> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verifying spelling in file names.
|
|
||||||
fn check_filename(&self) -> Option<bool> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verifying spelling in files.
|
|
||||||
fn check_file(&self) -> Option<bool> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tokenizer(&self) -> Option<&dyn TokenizerSource> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dict(&self) -> Option<&dyn DictSource> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait TokenizerSource {
|
|
||||||
/// Do not check identifiers that appear to be hexadecimal values.
|
|
||||||
fn ignore_hex(&self) -> Option<bool> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Allow identifiers to start with digits, in addition to letters.
|
|
||||||
fn identifier_leading_digits(&self) -> Option<bool> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Allow identifiers to start with one of these characters.
|
|
||||||
fn identifier_leading_chars(&self) -> Option<&str> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Allow identifiers to include digits, in addition to letters.
|
|
||||||
fn identifier_include_digits(&self) -> Option<bool> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Allow identifiers to include these characters.
|
|
||||||
fn identifier_include_chars(&self) -> Option<&str> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait DictSource {
|
|
||||||
fn locale(&self) -> Option<Locale> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extend_identifiers(&self) -> Box<dyn Iterator<Item = (&str, &str)> + '_> {
|
|
||||||
Box::new(None.into_iter())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extend_words(&self) -> Box<dyn Iterator<Item = (&str, &str)> + '_> {
|
|
||||||
Box::new(None.into_iter())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||||
#[serde(deny_unknown_fields, default)]
|
#[serde(deny_unknown_fields, default)]
|
||||||
#[serde(rename_all = "kebab-case")]
|
#[serde(rename_all = "kebab-case")]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub files: Walk,
|
pub files: Walk,
|
||||||
pub default: EngineConfig,
|
pub default: EngineConfig,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub type_: std::collections::HashMap<kstring::KString, EngineConfig>,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub overrides: EngineConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
|
@ -142,26 +38,14 @@ impl Config {
|
||||||
Self {
|
Self {
|
||||||
files: Walk::from_defaults(),
|
files: Walk::from_defaults(),
|
||||||
default: EngineConfig::from_defaults(),
|
default: EngineConfig::from_defaults(),
|
||||||
|
type_: Default::default(),
|
||||||
|
overrides: EngineConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update(&mut self, source: &dyn ConfigSource) {
|
pub fn update(&mut self, source: &Config) {
|
||||||
if let Some(walk) = source.walk() {
|
self.files.update(&source.files);
|
||||||
self.files.update(walk);
|
self.default.update(&source.default);
|
||||||
}
|
|
||||||
if let Some(default) = source.default() {
|
|
||||||
self.default.update(default);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ConfigSource for Config {
|
|
||||||
fn walk(&self) -> Option<&dyn WalkSource> {
|
|
||||||
Some(&self.files)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default(&self) -> Option<&dyn EngineSource> {
|
|
||||||
Some(&self.default)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -169,11 +53,17 @@ impl ConfigSource for Config {
|
||||||
#[serde(deny_unknown_fields, default)]
|
#[serde(deny_unknown_fields, default)]
|
||||||
#[serde(rename_all = "kebab-case")]
|
#[serde(rename_all = "kebab-case")]
|
||||||
pub struct Walk {
|
pub struct Walk {
|
||||||
|
/// Skip hidden files and directories.
|
||||||
pub ignore_hidden: Option<bool>,
|
pub ignore_hidden: Option<bool>,
|
||||||
|
/// Respect ignore files.
|
||||||
pub ignore_files: Option<bool>,
|
pub ignore_files: Option<bool>,
|
||||||
|
/// Respect .ignore files.
|
||||||
pub ignore_dot: Option<bool>,
|
pub ignore_dot: Option<bool>,
|
||||||
|
/// Respect ignore files in vcs directories.
|
||||||
pub ignore_vcs: Option<bool>,
|
pub ignore_vcs: Option<bool>,
|
||||||
|
/// Respect global ignore files.
|
||||||
pub ignore_global: Option<bool>,
|
pub ignore_global: Option<bool>,
|
||||||
|
/// Respect ignore files in parent directories.
|
||||||
pub ignore_parent: Option<bool>,
|
pub ignore_parent: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -190,28 +80,28 @@ impl Walk {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update(&mut self, source: &dyn WalkSource) {
|
pub fn update(&mut self, source: &Walk) {
|
||||||
if let Some(source) = source.ignore_hidden() {
|
if let Some(source) = source.ignore_hidden {
|
||||||
self.ignore_hidden = Some(source);
|
self.ignore_hidden = Some(source);
|
||||||
}
|
}
|
||||||
if let Some(source) = source.ignore_files() {
|
if let Some(source) = source.ignore_files {
|
||||||
self.ignore_files = Some(source);
|
self.ignore_files = Some(source);
|
||||||
self.ignore_dot = None;
|
self.ignore_dot = None;
|
||||||
self.ignore_vcs = None;
|
self.ignore_vcs = None;
|
||||||
self.ignore_global = None;
|
self.ignore_global = None;
|
||||||
self.ignore_parent = None;
|
self.ignore_parent = None;
|
||||||
}
|
}
|
||||||
if let Some(source) = source.ignore_dot() {
|
if let Some(source) = source.ignore_dot {
|
||||||
self.ignore_dot = Some(source);
|
self.ignore_dot = Some(source);
|
||||||
}
|
}
|
||||||
if let Some(source) = source.ignore_vcs() {
|
if let Some(source) = source.ignore_vcs {
|
||||||
self.ignore_vcs = Some(source);
|
self.ignore_vcs = Some(source);
|
||||||
self.ignore_global = None;
|
self.ignore_global = None;
|
||||||
}
|
}
|
||||||
if let Some(source) = source.ignore_global() {
|
if let Some(source) = source.ignore_global {
|
||||||
self.ignore_global = Some(source);
|
self.ignore_global = Some(source);
|
||||||
}
|
}
|
||||||
if let Some(source) = source.ignore_parent() {
|
if let Some(source) = source.ignore_parent {
|
||||||
self.ignore_parent = Some(source);
|
self.ignore_parent = Some(source);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -240,38 +130,15 @@ impl Walk {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WalkSource for Walk {
|
|
||||||
fn ignore_hidden(&self) -> Option<bool> {
|
|
||||||
self.ignore_hidden
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ignore_files(&self) -> Option<bool> {
|
|
||||||
self.ignore_files
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ignore_dot(&self) -> Option<bool> {
|
|
||||||
self.ignore_dot
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ignore_vcs(&self) -> Option<bool> {
|
|
||||||
self.ignore_vcs
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ignore_global(&self) -> Option<bool> {
|
|
||||||
self.ignore_global
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ignore_parent(&self) -> Option<bool> {
|
|
||||||
self.ignore_parent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||||
#[serde(deny_unknown_fields, default)]
|
#[serde(deny_unknown_fields, default)]
|
||||||
#[serde(rename_all = "kebab-case")]
|
#[serde(rename_all = "kebab-case")]
|
||||||
pub struct EngineConfig {
|
pub struct EngineConfig {
|
||||||
|
/// Check binary files.
|
||||||
pub binary: Option<bool>,
|
pub binary: Option<bool>,
|
||||||
|
/// Verifying spelling in file names.
|
||||||
pub check_filename: Option<bool>,
|
pub check_filename: Option<bool>,
|
||||||
|
/// Verifying spelling in files.
|
||||||
pub check_file: Option<bool>,
|
pub check_file: Option<bool>,
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub tokenizer: Option<TokenizerConfig>,
|
pub tokenizer: Option<TokenizerConfig>,
|
||||||
|
@ -295,17 +162,17 @@ impl EngineConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update(&mut self, source: &dyn EngineSource) {
|
pub fn update(&mut self, source: &EngineConfig) {
|
||||||
if let Some(source) = source.binary() {
|
if let Some(source) = source.binary {
|
||||||
self.binary = Some(source);
|
self.binary = Some(source);
|
||||||
}
|
}
|
||||||
if let Some(source) = source.check_filename() {
|
if let Some(source) = source.check_filename {
|
||||||
self.check_filename = Some(source);
|
self.check_filename = Some(source);
|
||||||
}
|
}
|
||||||
if let Some(source) = source.check_file() {
|
if let Some(source) = source.check_file {
|
||||||
self.check_file = Some(source);
|
self.check_file = Some(source);
|
||||||
}
|
}
|
||||||
if let Some(source) = source.tokenizer() {
|
if let Some(source) = source.tokenizer.as_ref() {
|
||||||
let mut tokenizer = None;
|
let mut tokenizer = None;
|
||||||
std::mem::swap(&mut tokenizer, &mut self.tokenizer);
|
std::mem::swap(&mut tokenizer, &mut self.tokenizer);
|
||||||
let mut tokenizer = tokenizer.unwrap_or_default();
|
let mut tokenizer = tokenizer.unwrap_or_default();
|
||||||
|
@ -313,7 +180,7 @@ impl EngineConfig {
|
||||||
let mut tokenizer = Some(tokenizer);
|
let mut tokenizer = Some(tokenizer);
|
||||||
std::mem::swap(&mut tokenizer, &mut self.tokenizer);
|
std::mem::swap(&mut tokenizer, &mut self.tokenizer);
|
||||||
}
|
}
|
||||||
if let Some(source) = source.dict() {
|
if let Some(source) = source.dict.as_ref() {
|
||||||
let mut dict = None;
|
let mut dict = None;
|
||||||
std::mem::swap(&mut dict, &mut self.dict);
|
std::mem::swap(&mut dict, &mut self.dict);
|
||||||
let mut dict = dict.unwrap_or_default();
|
let mut dict = dict.unwrap_or_default();
|
||||||
|
@ -336,36 +203,19 @@ impl EngineConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EngineSource for EngineConfig {
|
|
||||||
fn binary(&self) -> Option<bool> {
|
|
||||||
self.binary
|
|
||||||
}
|
|
||||||
|
|
||||||
fn check_filename(&self) -> Option<bool> {
|
|
||||||
self.check_filename
|
|
||||||
}
|
|
||||||
|
|
||||||
fn check_file(&self) -> Option<bool> {
|
|
||||||
self.check_file
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tokenizer(&self) -> Option<&dyn TokenizerSource> {
|
|
||||||
self.tokenizer.as_ref().map(|t| t as &dyn TokenizerSource)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dict(&self) -> Option<&dyn DictSource> {
|
|
||||||
self.dict.as_ref().map(|d| d as &dyn DictSource)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||||
#[serde(deny_unknown_fields, default)]
|
#[serde(deny_unknown_fields, default)]
|
||||||
#[serde(rename_all = "kebab-case")]
|
#[serde(rename_all = "kebab-case")]
|
||||||
pub struct TokenizerConfig {
|
pub struct TokenizerConfig {
|
||||||
|
/// Do not check identifiers that appear to be hexadecimal values.
|
||||||
pub ignore_hex: Option<bool>,
|
pub ignore_hex: Option<bool>,
|
||||||
|
/// Allow identifiers to start with digits, in addition to letters.
|
||||||
pub identifier_leading_digits: Option<bool>,
|
pub identifier_leading_digits: Option<bool>,
|
||||||
|
/// Allow identifiers to start with one of these characters.
|
||||||
pub identifier_leading_chars: Option<kstring::KString>,
|
pub identifier_leading_chars: Option<kstring::KString>,
|
||||||
|
/// Allow identifiers to include digits, in addition to letters.
|
||||||
pub identifier_include_digits: Option<bool>,
|
pub identifier_include_digits: Option<bool>,
|
||||||
|
/// Allow identifiers to include these characters.
|
||||||
pub identifier_include_chars: Option<kstring::KString>,
|
pub identifier_include_chars: Option<kstring::KString>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -385,21 +235,21 @@ impl TokenizerConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update(&mut self, source: &dyn TokenizerSource) {
|
pub fn update(&mut self, source: &TokenizerConfig) {
|
||||||
if let Some(source) = source.ignore_hex() {
|
if let Some(source) = source.ignore_hex {
|
||||||
self.ignore_hex = Some(source);
|
self.ignore_hex = Some(source);
|
||||||
}
|
}
|
||||||
if let Some(source) = source.identifier_leading_digits() {
|
if let Some(source) = source.identifier_leading_digits {
|
||||||
self.identifier_leading_digits = Some(source);
|
self.identifier_leading_digits = Some(source);
|
||||||
}
|
}
|
||||||
if let Some(source) = source.identifier_leading_chars() {
|
if let Some(source) = source.identifier_leading_chars.as_ref() {
|
||||||
self.identifier_leading_chars = Some(kstring::KString::from_ref(source));
|
self.identifier_leading_chars = Some(source.clone());
|
||||||
}
|
}
|
||||||
if let Some(source) = source.identifier_include_digits() {
|
if let Some(source) = source.identifier_include_digits {
|
||||||
self.identifier_include_digits = Some(source);
|
self.identifier_include_digits = Some(source);
|
||||||
}
|
}
|
||||||
if let Some(source) = source.identifier_include_chars() {
|
if let Some(source) = source.identifier_include_chars.as_ref() {
|
||||||
self.identifier_include_chars = Some(kstring::KString::from_ref(source));
|
self.identifier_include_chars = Some(source.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -424,28 +274,6 @@ impl TokenizerConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TokenizerSource for TokenizerConfig {
|
|
||||||
fn ignore_hex(&self) -> Option<bool> {
|
|
||||||
self.ignore_hex
|
|
||||||
}
|
|
||||||
|
|
||||||
fn identifier_leading_digits(&self) -> Option<bool> {
|
|
||||||
self.identifier_leading_digits
|
|
||||||
}
|
|
||||||
|
|
||||||
fn identifier_leading_chars(&self) -> Option<&str> {
|
|
||||||
self.identifier_leading_chars.as_deref()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn identifier_include_digits(&self) -> Option<bool> {
|
|
||||||
self.identifier_include_digits
|
|
||||||
}
|
|
||||||
|
|
||||||
fn identifier_include_chars(&self) -> Option<&str> {
|
|
||||||
self.identifier_include_chars.as_deref()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||||
#[serde(deny_unknown_fields, default)]
|
#[serde(deny_unknown_fields, default)]
|
||||||
#[serde(rename_all = "kebab-case")]
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
@ -465,19 +293,21 @@ impl DictConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update(&mut self, source: &dyn DictSource) {
|
pub fn update(&mut self, source: &DictConfig) {
|
||||||
if let Some(source) = source.locale() {
|
if let Some(source) = source.locale {
|
||||||
self.locale = Some(source);
|
self.locale = Some(source);
|
||||||
}
|
}
|
||||||
self.extend_identifiers.extend(
|
self.extend_identifiers.extend(
|
||||||
source
|
source
|
||||||
.extend_identifiers()
|
.extend_identifiers
|
||||||
.map(|(k, v)| (kstring::KString::from_ref(k), kstring::KString::from_ref(v))),
|
.iter()
|
||||||
|
.map(|(key, value)| (key.clone(), value.clone())),
|
||||||
);
|
);
|
||||||
self.extend_words.extend(
|
self.extend_words.extend(
|
||||||
source
|
source
|
||||||
.extend_words()
|
.extend_words
|
||||||
.map(|(k, v)| (kstring::KString::from_ref(k), kstring::KString::from_ref(v))),
|
.iter()
|
||||||
|
.map(|(key, value)| (key.clone(), value.clone())),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -502,28 +332,6 @@ impl DictConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DictSource for DictConfig {
|
|
||||||
fn locale(&self) -> Option<Locale> {
|
|
||||||
self.locale
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extend_identifiers(&self) -> Box<dyn Iterator<Item = (&str, &str)> + '_> {
|
|
||||||
Box::new(
|
|
||||||
self.extend_identifiers
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| (k.as_str(), v.as_str())),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extend_words(&self) -> Box<dyn Iterator<Item = (&str, &str)> + '_> {
|
|
||||||
Box::new(
|
|
||||||
self.extend_words
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| (k.as_str(), v.as_str())),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_project_file(dir: &std::path::Path, names: &[&str]) -> Option<std::path::PathBuf> {
|
fn find_project_file(dir: &std::path::Path, names: &[&str]) -> Option<std::path::PathBuf> {
|
||||||
let mut file_path = dir.join("placeholder");
|
let mut file_path = dir.join("placeholder");
|
||||||
for name in names {
|
for name in names {
|
||||||
|
|
85
src/main.rs
85
src/main.rs
|
@ -35,6 +35,8 @@ fn run() -> proc_exit::ExitResult {
|
||||||
|
|
||||||
if let Some(output_path) = args.dump_config.as_ref() {
|
if let Some(output_path) = args.dump_config.as_ref() {
|
||||||
run_dump_config(&args, output_path)
|
run_dump_config(&args, output_path)
|
||||||
|
} else if args.type_list {
|
||||||
|
run_type_list(&args)
|
||||||
} else {
|
} else {
|
||||||
run_checks(&args)
|
run_checks(&args)
|
||||||
}
|
}
|
||||||
|
@ -58,14 +60,17 @@ fn run_dump_config(args: &args::Args, output_path: &std::path::Path) -> proc_exi
|
||||||
};
|
};
|
||||||
|
|
||||||
let storage = typos_cli::policy::ConfigStorage::new();
|
let storage = typos_cli::policy::ConfigStorage::new();
|
||||||
let mut overrides = config::EngineConfig::default();
|
|
||||||
overrides.update(&args.overrides);
|
|
||||||
let mut engine = typos_cli::policy::ConfigEngine::new(&storage);
|
let mut engine = typos_cli::policy::ConfigEngine::new(&storage);
|
||||||
engine.set_isolated(args.isolated).set_overrides(overrides);
|
engine.set_isolated(args.isolated);
|
||||||
|
|
||||||
|
let mut overrides = config::Config::default();
|
||||||
if let Some(path) = args.custom_config.as_ref() {
|
if let Some(path) = args.custom_config.as_ref() {
|
||||||
let custom = config::Config::from_file(path).with_code(proc_exit::Code::CONFIG_ERR)?;
|
let custom = config::Config::from_file(path).with_code(proc_exit::Code::CONFIG_ERR)?;
|
||||||
engine.set_custom_config(custom);
|
overrides.update(&custom);
|
||||||
}
|
}
|
||||||
|
overrides.update(&args.config.to_config());
|
||||||
|
engine.set_overrides(overrides);
|
||||||
|
|
||||||
let config = engine
|
let config = engine
|
||||||
.load_config(cwd)
|
.load_config(cwd)
|
||||||
.with_code(proc_exit::Code::CONFIG_ERR)?;
|
.with_code(proc_exit::Code::CONFIG_ERR)?;
|
||||||
|
@ -82,18 +87,68 @@ fn run_dump_config(args: &args::Args, output_path: &std::path::Path) -> proc_exi
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run_type_list(args: &args::Args) -> proc_exit::ExitResult {
|
||||||
|
let global_cwd = std::env::current_dir()?;
|
||||||
|
|
||||||
|
let path = &args.path[0];
|
||||||
|
let path = if path == std::path::Path::new("-") {
|
||||||
|
path.to_owned()
|
||||||
|
} else {
|
||||||
|
path.canonicalize().with_code(proc_exit::Code::USAGE_ERR)?
|
||||||
|
};
|
||||||
|
let cwd = if path == std::path::Path::new("-") {
|
||||||
|
global_cwd.as_path()
|
||||||
|
} else if path.is_file() {
|
||||||
|
path.parent().unwrap()
|
||||||
|
} else {
|
||||||
|
path.as_path()
|
||||||
|
};
|
||||||
|
|
||||||
|
let storage = typos_cli::policy::ConfigStorage::new();
|
||||||
|
let mut engine = typos_cli::policy::ConfigEngine::new(&storage);
|
||||||
|
engine.set_isolated(args.isolated);
|
||||||
|
|
||||||
|
let mut overrides = config::Config::default();
|
||||||
|
if let Some(path) = args.custom_config.as_ref() {
|
||||||
|
let custom = config::Config::from_file(path).with_code(proc_exit::Code::CONFIG_ERR)?;
|
||||||
|
overrides.update(&custom);
|
||||||
|
}
|
||||||
|
overrides.update(&args.config.to_config());
|
||||||
|
engine.set_overrides(overrides);
|
||||||
|
|
||||||
|
engine
|
||||||
|
.init_dir(cwd)
|
||||||
|
.with_code(proc_exit::Code::CONFIG_ERR)?;
|
||||||
|
let definitions = engine.file_types(cwd);
|
||||||
|
|
||||||
|
let stdout = std::io::stdout();
|
||||||
|
let mut handle = stdout.lock();
|
||||||
|
for def in definitions {
|
||||||
|
writeln!(
|
||||||
|
handle,
|
||||||
|
"{}: {}",
|
||||||
|
def.name(),
|
||||||
|
itertools::join(def.globs(), ", ")
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn run_checks(args: &args::Args) -> proc_exit::ExitResult {
|
fn run_checks(args: &args::Args) -> proc_exit::ExitResult {
|
||||||
let global_cwd = std::env::current_dir()?;
|
let global_cwd = std::env::current_dir()?;
|
||||||
|
|
||||||
let storage = typos_cli::policy::ConfigStorage::new();
|
let storage = typos_cli::policy::ConfigStorage::new();
|
||||||
let mut overrides = config::EngineConfig::default();
|
|
||||||
overrides.update(&args.overrides);
|
|
||||||
let mut engine = typos_cli::policy::ConfigEngine::new(&storage);
|
let mut engine = typos_cli::policy::ConfigEngine::new(&storage);
|
||||||
engine.set_isolated(args.isolated).set_overrides(overrides);
|
engine.set_isolated(args.isolated);
|
||||||
|
|
||||||
|
let mut overrides = config::Config::default();
|
||||||
if let Some(path) = args.custom_config.as_ref() {
|
if let Some(path) = args.custom_config.as_ref() {
|
||||||
let custom = config::Config::from_file(path).with_code(proc_exit::Code::CONFIG_ERR)?;
|
let custom = config::Config::from_file(path).with_code(proc_exit::Code::CONFIG_ERR)?;
|
||||||
engine.set_custom_config(custom);
|
overrides.update(&custom);
|
||||||
}
|
}
|
||||||
|
overrides.update(&args.config.to_config());
|
||||||
|
engine.set_overrides(overrides);
|
||||||
|
|
||||||
let mut typos_found = false;
|
let mut typos_found = false;
|
||||||
let mut errors_found = false;
|
let mut errors_found = false;
|
||||||
|
@ -114,19 +169,19 @@ fn run_checks(args: &args::Args) -> proc_exit::ExitResult {
|
||||||
engine
|
engine
|
||||||
.init_dir(cwd)
|
.init_dir(cwd)
|
||||||
.with_code(proc_exit::Code::CONFIG_ERR)?;
|
.with_code(proc_exit::Code::CONFIG_ERR)?;
|
||||||
let files = engine.files(cwd);
|
let walk_policy = engine.walk(cwd);
|
||||||
|
|
||||||
let threads = if path.is_file() { 1 } else { args.threads };
|
let threads = if path.is_file() { 1 } else { args.threads };
|
||||||
let single_threaded = threads == 1;
|
let single_threaded = threads == 1;
|
||||||
|
|
||||||
let mut walk = ignore::WalkBuilder::new(path);
|
let mut walk = ignore::WalkBuilder::new(path);
|
||||||
walk.threads(args.threads)
|
walk.threads(args.threads)
|
||||||
.hidden(files.ignore_hidden())
|
.hidden(walk_policy.ignore_hidden())
|
||||||
.ignore(files.ignore_dot())
|
.ignore(walk_policy.ignore_dot())
|
||||||
.git_global(files.ignore_global())
|
.git_global(walk_policy.ignore_global())
|
||||||
.git_ignore(files.ignore_vcs())
|
.git_ignore(walk_policy.ignore_vcs())
|
||||||
.git_exclude(files.ignore_vcs())
|
.git_exclude(walk_policy.ignore_vcs())
|
||||||
.parents(files.ignore_parent());
|
.parents(walk_policy.ignore_parent());
|
||||||
|
|
||||||
// HACK: Diff doesn't handle mixing content
|
// HACK: Diff doesn't handle mixing content
|
||||||
let output_reporter = if args.diff {
|
let output_reporter = if args.diff {
|
||||||
|
|
133
src/policy.rs
133
src/policy.rs
|
@ -35,12 +35,11 @@ impl Default for ConfigStorage {
|
||||||
pub struct ConfigEngine<'s> {
|
pub struct ConfigEngine<'s> {
|
||||||
storage: &'s ConfigStorage,
|
storage: &'s ConfigStorage,
|
||||||
|
|
||||||
overrides: Option<crate::config::EngineConfig>,
|
overrides: Option<crate::config::Config>,
|
||||||
custom: Option<crate::config::Config>,
|
|
||||||
isolated: bool,
|
isolated: bool,
|
||||||
|
|
||||||
configs: std::collections::HashMap<std::path::PathBuf, DirConfig>,
|
configs: std::collections::HashMap<std::path::PathBuf, DirConfig>,
|
||||||
files: Intern<crate::config::Walk>,
|
walk: Intern<crate::config::Walk>,
|
||||||
tokenizer: Intern<typos::tokens::Tokenizer>,
|
tokenizer: Intern<typos::tokens::Tokenizer>,
|
||||||
dict: Intern<crate::dict::Override<'s, 's, crate::dict::BuiltIn>>,
|
dict: Intern<crate::dict::Override<'s, 's, crate::dict::BuiltIn>>,
|
||||||
}
|
}
|
||||||
|
@ -50,61 +49,62 @@ impl<'s> ConfigEngine<'s> {
|
||||||
Self {
|
Self {
|
||||||
storage,
|
storage,
|
||||||
overrides: Default::default(),
|
overrides: Default::default(),
|
||||||
custom: Default::default(),
|
|
||||||
configs: Default::default(),
|
configs: Default::default(),
|
||||||
isolated: false,
|
isolated: false,
|
||||||
files: Default::default(),
|
walk: Default::default(),
|
||||||
tokenizer: Default::default(),
|
tokenizer: Default::default(),
|
||||||
dict: Default::default(),
|
dict: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_overrides(&mut self, overrides: crate::config::EngineConfig) -> &mut Self {
|
pub fn set_overrides(&mut self, overrides: crate::config::Config) -> &mut Self {
|
||||||
self.overrides = Some(overrides);
|
self.overrides = Some(overrides);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_custom_config(&mut self, custom: crate::config::Config) -> &mut Self {
|
|
||||||
self.custom = Some(custom);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_isolated(&mut self, isolated: bool) -> &mut Self {
|
pub fn set_isolated(&mut self, isolated: bool) -> &mut Self {
|
||||||
self.isolated = isolated;
|
self.isolated = isolated;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn files(&mut self, cwd: &std::path::Path) -> &crate::config::Walk {
|
pub fn walk(&self, cwd: &std::path::Path) -> &crate::config::Walk {
|
||||||
let dir = self
|
let dir = self
|
||||||
.configs
|
.configs
|
||||||
.get(cwd)
|
.get(cwd)
|
||||||
.expect("`init_dir` must be called first");
|
.expect("`init_dir` must be called first");
|
||||||
self.get_files(dir)
|
self.get_walk(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn file_types(&self, cwd: &std::path::Path) -> &[ignore::types::FileTypeDef] {
|
||||||
|
let dir = self
|
||||||
|
.configs
|
||||||
|
.get(cwd)
|
||||||
|
.expect("`init_dir` must be called first");
|
||||||
|
dir.type_matcher.definitions()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn policy(&self, path: &std::path::Path) -> Policy<'_, '_> {
|
pub fn policy(&self, path: &std::path::Path) -> Policy<'_, '_> {
|
||||||
let dir = self
|
let dir = self.get_dir(path).expect("`walk()` should be called first");
|
||||||
.get_dir(path)
|
let file_config = dir.get_file_config(path);
|
||||||
.expect("`files()` should be called first");
|
|
||||||
Policy {
|
Policy {
|
||||||
check_filenames: dir.check_filenames,
|
check_filenames: file_config.check_filenames,
|
||||||
check_files: dir.check_files,
|
check_files: file_config.check_files,
|
||||||
binary: dir.binary,
|
binary: file_config.binary,
|
||||||
tokenizer: self.get_tokenizer(dir),
|
tokenizer: self.get_tokenizer(&file_config),
|
||||||
dict: self.get_dict(dir),
|
dict: self.get_dict(&file_config),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_files(&self, dir: &DirConfig) -> &crate::config::Walk {
|
fn get_walk(&self, dir: &DirConfig) -> &crate::config::Walk {
|
||||||
self.files.get(dir.files)
|
self.walk.get(dir.walk)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_tokenizer(&self, dir: &DirConfig) -> &typos::tokens::Tokenizer {
|
fn get_tokenizer(&self, file: &FileConfig) -> &typos::tokens::Tokenizer {
|
||||||
self.tokenizer.get(dir.tokenizer)
|
self.tokenizer.get(file.tokenizer)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_dict(&self, dir: &DirConfig) -> &dyn typos::Dictionary {
|
fn get_dict(&self, file: &FileConfig) -> &dyn typos::Dictionary {
|
||||||
self.dict.get(dir.dict)
|
self.dict.get(file.dict)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_dir(&self, path: &std::path::Path) -> Option<&DirConfig> {
|
fn get_dir(&self, path: &std::path::Path) -> Option<&DirConfig> {
|
||||||
|
@ -127,11 +127,8 @@ impl<'s> ConfigEngine<'s> {
|
||||||
config.update(&derived);
|
config.update(&derived);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(custom) = self.custom.as_ref() {
|
|
||||||
config.update(custom);
|
|
||||||
}
|
|
||||||
if let Some(overrides) = self.overrides.as_ref() {
|
if let Some(overrides) = self.overrides.as_ref() {
|
||||||
config.default.update(overrides);
|
config.update(overrides);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(config)
|
Ok(config)
|
||||||
|
@ -143,14 +140,46 @@ impl<'s> ConfigEngine<'s> {
|
||||||
}
|
}
|
||||||
|
|
||||||
let config = self.load_config(cwd)?;
|
let config = self.load_config(cwd)?;
|
||||||
|
let crate::config::Config {
|
||||||
|
files,
|
||||||
|
mut default,
|
||||||
|
type_,
|
||||||
|
overrides,
|
||||||
|
} = config;
|
||||||
|
|
||||||
let crate::config::Config { files, default } = config;
|
let walk = self.walk.intern(files);
|
||||||
let binary = default.binary();
|
|
||||||
let check_filename = default.check_filename();
|
let types = type_
|
||||||
let check_file = default.check_file();
|
.into_iter()
|
||||||
|
.map(|(type_, type_engine)| {
|
||||||
|
let mut new_type_engine = default.clone();
|
||||||
|
new_type_engine.update(&type_engine);
|
||||||
|
new_type_engine.update(&overrides);
|
||||||
|
let type_config = self.init_file_config(new_type_engine);
|
||||||
|
(type_, type_config)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
default.update(&overrides);
|
||||||
|
let default = self.init_file_config(default);
|
||||||
|
|
||||||
|
let dir = DirConfig {
|
||||||
|
walk,
|
||||||
|
default,
|
||||||
|
types,
|
||||||
|
type_matcher: ignore::types::TypesBuilder::new().add_defaults().build()?,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.configs.insert(cwd.to_owned(), dir);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_file_config(&mut self, engine: crate::config::EngineConfig) -> FileConfig {
|
||||||
|
let binary = engine.binary();
|
||||||
|
let check_filename = engine.check_filename();
|
||||||
|
let check_file = engine.check_file();
|
||||||
let crate::config::EngineConfig {
|
let crate::config::EngineConfig {
|
||||||
tokenizer, dict, ..
|
tokenizer, dict, ..
|
||||||
} = default;
|
} = engine;
|
||||||
let tokenizer_config =
|
let tokenizer_config =
|
||||||
tokenizer.unwrap_or_else(crate::config::TokenizerConfig::from_defaults);
|
tokenizer.unwrap_or_else(crate::config::TokenizerConfig::from_defaults);
|
||||||
let dict_config = dict.unwrap_or_else(crate::config::DictConfig::from_defaults);
|
let dict_config = dict.unwrap_or_else(crate::config::DictConfig::from_defaults);
|
||||||
|
@ -177,20 +206,15 @@ impl<'s> ConfigEngine<'s> {
|
||||||
);
|
);
|
||||||
|
|
||||||
let dict = self.dict.intern(dict);
|
let dict = self.dict.intern(dict);
|
||||||
let files = self.files.intern(files);
|
|
||||||
let tokenizer = self.tokenizer.intern(tokenizer);
|
let tokenizer = self.tokenizer.intern(tokenizer);
|
||||||
|
|
||||||
let dir = DirConfig {
|
FileConfig {
|
||||||
files,
|
|
||||||
check_filenames: check_filename,
|
check_filenames: check_filename,
|
||||||
check_files: check_file,
|
check_files: check_file,
|
||||||
binary,
|
binary,
|
||||||
tokenizer,
|
tokenizer,
|
||||||
dict,
|
dict,
|
||||||
};
|
}
|
||||||
|
|
||||||
self.configs.insert(cwd.to_owned(), dir);
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -222,8 +246,29 @@ impl<T> Default for Intern<T> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
struct DirConfig {
|
struct DirConfig {
|
||||||
files: usize,
|
walk: usize,
|
||||||
|
default: FileConfig,
|
||||||
|
types: std::collections::HashMap<kstring::KString, FileConfig>,
|
||||||
|
type_matcher: ignore::types::Types,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DirConfig {
|
||||||
|
fn get_file_config(&self, path: &std::path::Path) -> FileConfig {
|
||||||
|
let match_ = self.type_matcher.matched(path, false);
|
||||||
|
let name = match_
|
||||||
|
.inner()
|
||||||
|
.and_then(|g| g.file_type_def())
|
||||||
|
.map(|f| f.name());
|
||||||
|
|
||||||
|
name.and_then(|name| self.types.get(name).copied())
|
||||||
|
.unwrap_or(self.default)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
struct FileConfig {
|
||||||
tokenizer: usize,
|
tokenizer: usize,
|
||||||
dict: usize,
|
dict: usize,
|
||||||
check_filenames: bool,
|
check_filenames: bool,
|
||||||
|
|
Loading…
Reference in a new issue