pub mod settings;
pub mod v1;
pub use settings::{Settings, SettingsLayer};
#[cfg(feature = "python")]
pub use globetrotter_python as python;
#[cfg(feature = "rust")]
pub use globetrotter_rust as rust;
#[cfg(feature = "typescript")]
pub use globetrotter_typescript as typescript;
#[cfg(feature = "golang")]
pub use globetrotter_golang as golang;
use codespan_reporting::diagnostic::{Diagnostic, Label};
use globetrotter_model::diagnostics::{DiagnosticExt, Span, ToDiagnostics};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use yaml_spanned::Value;
#[derive(
Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize, Default,
)]
pub enum Version {
#[serde(rename = "1", alias = "v1", alias = "V1")]
V1,
#[serde(rename = "latest")]
#[default]
Latest,
}
pub fn config_file_names() -> impl Iterator<Item = &'static str> {
[".globetrotter.yaml", "globetrotter.yaml"].into_iter()
}
pub async fn find_config_file(dir: &Path) -> std::io::Result<Option<PathBuf>> {
use futures::{StreamExt, TryStreamExt, stream};
let mut found = stream::iter(config_file_names().map(|path| dir.join(path)))
.map(|path| async move {
match tokio::fs::canonicalize(&path).await {
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err),
Ok(path) => Ok(Some(path)),
}
})
.buffered(8)
.into_stream();
while let Some(path) = found.try_next().await? {
if let Some(path) = path {
return Ok(Some(path));
}
}
Ok(None)
}
pub fn find_config_file_sync(dir: &Path) -> std::io::Result<Option<PathBuf>> {
for path in config_file_names().map(|path| dir.join(path)) {
match std::fs::exists(&path) {
Err(err) => return Err(err),
Ok(true) => return Ok(Some(path)),
Ok(false) => {
}
}
}
Ok(None)
}
pub fn from_str<F: Copy + PartialEq>(
raw_config: &str,
config_dir: &Path,
file_id: F,
strict: Option<bool>,
diagnostics: &mut Vec<Diagnostic<F>>,
) -> Result<v1::Configs<F>, ConfigError> {
let value = yaml_spanned::from_str(raw_config).map_err(ConfigError::YAML)?;
let version = parse_version(&value, file_id, strict, diagnostics)?;
match version {
Version::Latest | Version::V1 => {
v1::parse_configs(&value, config_dir, file_id, strict, diagnostics)
}
}
}
#[derive(thiserror::Error, Debug)]
pub enum ConfigError {
#[error("{message}")]
MissingKey {
key: String,
message: String,
span: Span,
},
#[error("{message}")]
UnexpectedType {
message: String,
expected: Vec<yaml_spanned::value::Kind>,
found: yaml_spanned::value::Kind,
span: Span,
},
#[error("invalid `allow` entry `{entry}`: {source}")]
InvalidAllowEntry {
entry: String,
#[source]
source: globetrotter_model::lint::ParseAllowEntryError,
span: Span,
},
#[error("{source}")]
Serde {
#[source]
source: yaml_spanned::error::SerdeError,
span: Span,
},
#[error(transparent)]
YAML(#[from] yaml_spanned::Error),
}
impl ToDiagnostics for ConfigError {
fn to_diagnostics<F: Copy + PartialEq>(&self, file_id: F) -> Vec<Diagnostic<F>> {
match self {
Self::MissingKey {
message, key, span, ..
} => vec![
Diagnostic::error()
.with_message(format!("missing required key `{key}`"))
.with_labels(vec![
Label::secondary(file_id, span.clone()).with_message(message),
]),
],
Self::UnexpectedType {
expected,
found,
span,
..
} => {
let expected = expected
.iter()
.map(|ty| format!("`{ty:?}`"))
.collect::<Vec<_>>()
.join(", or ");
let diagnostic = Diagnostic::error()
.with_message(self.to_string())
.with_labels(vec![
Label::primary(file_id, span.clone())
.with_message(format!("expected {expected}")),
])
.with_notes(vec![indoc::formatdoc!(
"
expected type {expected}
found type `{found:?}`
"
)]);
vec![diagnostic]
}
Self::InvalidAllowEntry {
entry,
source,
span,
} => vec![
Diagnostic::error()
.with_message(self.to_string())
.with_labels(vec![
Label::primary(file_id, span.clone()).with_message(source.to_string()),
])
.with_notes(vec![source.note(entry)]),
],
Self::Serde { source, span } => vec![
Diagnostic::error()
.with_message(self.to_string())
.with_labels(vec![
Label::primary(file_id, span.clone()).with_message(source.to_string()),
]),
],
Self::YAML(source) => {
use yaml_spanned::error::ToDiagnostics;
source.to_diagnostics(file_id)
}
}
}
}
pub fn parse_version<F>(
value: &yaml_spanned::Spanned<Value>,
file_id: F,
strict: Option<bool>,
diagnostics: &mut Vec<Diagnostic<F>>,
) -> Result<Version, ConfigError> {
match value.get("version") {
None => {
let diagnostic = Diagnostic::warning_or_error(strict.unwrap_or(false))
.with_message("missing version")
.with_labels(vec![
Label::primary(file_id, value.span)
.with_message("no version is specified - assuming version 1"),
]);
diagnostics.push(diagnostic);
Ok(Version::Latest)
}
Some(yaml_spanned::Spanned {
inner: Value::Number(n),
..
}) if n.as_f64() == Some(1.0) => Ok(Version::V1),
Some(value) => {
let version = v1::parse::<Version>(value)?;
Ok(version.into_inner())
}
}
}
#[cfg(test)]
mod tests {
use super::ConfigError;
use color_eyre::eyre;
use similar_asserts::assert_eq as sim_assert_eq;
use yaml_spanned::{Spanned, Value};
#[test_util::test]
fn parses_settings_keys_and_aliases() -> eyre::Result<()> {
use globetrotter_model::{TemplateEngine, diagnostics::Spanned};
let raw = indoc::indoc! {r#"
version: 1
config:
languages: ["en"]
engine: handlebars
strict: true
check_templates: false
dry_run: true
absolute: true
inputs:
- ./translations/a.toml
outputs:
json:
- ./out/{{language}}.json
"#};
let mut diagnostics = vec![];
let configs = super::from_str(raw, std::path::Path::new("."), (), None, &mut diagnostics)?;
sim_assert_eq!(
have: configs[0].config.settings,
want: super::SettingsLayer {
strict: Some(true),
check_templates: Some(false),
dry_run: Some(true),
print_absolute_paths: Some(true),
template_engine: Some(Spanned::dummy(TemplateEngine::Handlebars)),
}
);
Ok(())
}
#[test_util::test]
fn parses_config_allow_list() -> eyre::Result<()> {
use globetrotter_model::lint::{AllowEntry, LintCode};
let raw = indoc::indoc! {r#"
version: 1
config:
languages: ["en"]
allow: ["lint:duplicate", "lint:llm-drift"]
inputs:
- ./translations/a.toml
outputs:
json:
- ./out/{{language}}.json
"#};
let mut diagnostics = vec![];
let configs = super::from_str(raw, std::path::Path::new("."), (), None, &mut diagnostics)?;
sim_assert_eq!(
have: configs[0].config.allow,
want: [
AllowEntry::Code(LintCode::Duplicate),
AllowEntry::Code(LintCode::LlmDrift),
]
.into_iter()
.collect()
);
Ok(())
}
#[test_util::test]
fn rejects_unprefixed_config_allow_entry() {
use globetrotter_model::lint::ParseAllowEntryError;
let raw = indoc::indoc! {r#"
version: 1
config:
languages: ["en"]
allow: ["duplicate"]
"#};
let mut diagnostics = vec![];
let result = super::from_str(raw, std::path::Path::new("."), (), None, &mut diagnostics);
assert!(
matches!(
&result,
Err(ConfigError::InvalidAllowEntry {
entry,
source: ParseAllowEntryError::MissingPrefix,
..
}) if entry == "duplicate"
),
"{result:?}"
);
}
#[test_util::test]
fn test_parse_version() -> eyre::Result<()> {
fn parse_version_wrapper(
value: impl Into<Spanned<Value>>,
strict: bool,
) -> Result<super::Version, ConfigError> {
let mut diagnostics = vec![];
super::parse_version(&value.into(), (), Some(strict), &mut diagnostics)
}
let have = parse_version_wrapper(
Value::Mapping([("version".into(), 1.into())].into_iter().collect()),
true,
)?;
sim_assert_eq!(
have: have,
want: super::Version::V1
);
let have = parse_version_wrapper(
Value::Mapping([("version".into(), "1".into())].into_iter().collect()),
true,
)?;
sim_assert_eq!(
have: have,
want: super::Version::V1
);
let have = parse_version_wrapper(
Value::Mapping([("version".into(), "v1".into())].into_iter().collect()),
true,
)?;
sim_assert_eq!(
have: have,
want: super::Version::V1
);
Ok(())
}
}