diff --git a/src/bin/forge/normalize.rs b/src/bin/forge/normalize.rs index 04b803e23..277f62477 100644 --- a/src/bin/forge/normalize.rs +++ b/src/bin/forge/normalize.rs @@ -1,5 +1,10 @@ //! `forge normalize`, which rewrites the extensions other tools' metadata is -//! written with into the native ones, in the files it is given. +//! written with into the native ones, in the files it is given. How one +//! document is rewritten is in [`json`]. + +mod json; +mod vendor; +mod walk; use std::collections::HashSet; use std::fs; @@ -7,12 +12,9 @@ use std::io::{self, Read, Write}; use std::path::{Path, PathBuf}; use forge::Err; -use forge::normalize::{Normalized, Options, Report, Sources, normalize}; -fn source(name: &str) -> Result { - Sources::named(name) - .ok_or_else(|| format!("unknown compatibility source {name:?}; expected cloudflare, fern, or stainless")) -} +use json::{Normalized, Options, Report, normalize}; +use vendor::Source; /// What `forge normalize` is given, which says what it does in `main.rs`. #[derive(clap::Args)] @@ -34,7 +36,7 @@ pub struct Args { /// Rewrite only keys from cloudflare, fern, or stainless. May be repeated. /// Without it, keys from every source are rewritten. #[arg(long = "from", value_parser = source, value_name = "SOURCE")] - from: Vec, + from: Vec<&'static dyn Source>, /// Say what would be rewritten, and write nothing. It fails if there is /// anything to rewrite, to check that documents are normalized. #[arg(long)] @@ -64,17 +66,9 @@ struct Say { } pub fn run(args: Args) -> Result<(), Err> { - let sources = if args.from.is_empty() { - Sources::all() - } else { - args.from - .into_iter() - .reduce(Sources::union) - .expect("at least one source was given") - }; let options = Options { leave_old_extensions: args.leave_old_extensions, - sources, + sources: if args.from.is_empty() { vendor::ALL } else { &args.from }, }; let say = Say { check: args.check, @@ -145,6 +139,12 @@ pub fn run(args: Args) -> Result<(), Err> { Ok(()) } +/// Returns the compatibility source called `name`. +fn source(name: &str) -> Result<&'static dyn Source, String> { + vendor::named(name) + .ok_or_else(|| format!("unknown compatibility source {name:?}; expected cloudflare, fern, or stainless")) +} + /// Returns `one` if `n` is 1, and `many` if it is not. fn plural<'a>(n: usize, one: &'a str, many: &'a str) -> &'a str { if n == 1 { one } else { many } diff --git a/src/bin/forge/normalize/json.rs b/src/bin/forge/normalize/json.rs new file mode 100644 index 000000000..cc65a0f35 --- /dev/null +++ b/src/bin/forge/normalize/json.rs @@ -0,0 +1,157 @@ +//! Normalizes one JSON document. +//! +//! The key a tool writes is looked up in the sources of [`super::vendor`], at +//! the kinds of OpenAPI object where its native replacement belongs. A key +//! elsewhere is left alone, since rewriting it would give it a meaning it did +//! not have. +//! +//! A native key is never replaced. When a document says the same thing with a +//! native key and another, the native one is kept. +//! +//! The document is read with `serde_json` and written again, in the order its +//! keys were in, with the indentation of its first indented line. That is +//! simple, but it is not a minimal edit. The package README lists what is lost +//! under "Known limitations". + +use std::collections::BTreeMap; + +use serde::Serialize; +use serde_json::Value; +use serde_json::ser::PrettyFormatter; + +use super::vendor::{Change, Detail, Source}; +use super::walk; + +/// What [`normalize`] does. +#[derive(Clone, Copy)] +pub(super) struct Options<'a> { + /// Keeps each key that is rewritten, beside the native key that replaces + /// it, for what still reads the old one. + pub(super) leave_old_extensions: bool, + /// The compatibility sources whose keys are rewritten. + pub(super) sources: &'a [&'static dyn Source], +} + +/// What [`normalize`] did. +#[derive(Debug, Default)] +pub(super) struct Report { + /// How many times each key was rewritten, or removed because a native key + /// already said the same thing. + pub(super) migrated: BTreeMap, + details: BTreeMap, +} + +impl Report { + /// Returns whether the document is different from what it was. + pub(super) fn changed(&self) -> bool { + !self.migrated.is_empty() + } + + /// Returns the additional descriptions of changes, and how many times + /// each happened. + pub(super) fn details(&self) -> impl Iterator + '_ { + self.details.iter().map(|(detail, count)| { + let description = if *count == 1 { detail.one } else { detail.many }; + (*count, description) + }) + } + + pub(super) fn record(&mut self, original: &str, change: Change) { + *self.migrated.entry(original.to_owned()).or_insert(0) += 1; + if let Some(detail) = change.detail { + *self.details.entry(detail).or_insert(0) += 1; + } + } +} + +/// A document after [`normalize`]. +#[derive(Debug)] +pub(super) struct Normalized { + /// The document, if it is different from what was read. + pub(super) json: Option>, + /// What was done to it. + pub(super) report: Report, +} + +/// Rewrites the keys of the document `json` that a native key replaces. +pub(super) fn normalize(json: &[u8], options: &Options) -> Result { + let mut document: Value = serde_json::from_slice(json)?; + let report = walk::walk(&mut document, options); + if !report.changed() { + return Ok(Normalized { json: None, report }); + } + Ok(Normalized { + json: Some(encode(&document, json)), + report, + }) +} + +/// Encodes `document` as `original` was written, as far as that is known: with +/// its indentation, which is that of the first line that is indented, on one +/// line if it was, and with a newline at the end if it had one. +fn encode(document: &Value, original: &[u8]) -> Vec { + let mut out = Vec::with_capacity(original.len() + original.len() / 16); + match indentation(original) { + Some(indent) => { + let mut serializer = serde_json::Serializer::with_formatter(&mut out, PrettyFormatter::with_indent(indent)); + document + .serialize(&mut serializer) + .expect("a document is written as JSON to the memory"); + } + None => serde_json::to_writer(&mut out, document).expect("a document is written as JSON to the memory"), + } + if original.ends_with(b"\n") { + out.push(b'\n'); + } + out +} + +/// Returns what `original` is indented with, or none if it is on one line. +fn indentation(original: &[u8]) -> Option<&[u8]> { + let first = original.iter().position(|&byte| byte == b'\n')?; + for line in original[first + 1..].split(|byte| *byte == b'\n') { + let blanks = line.iter().take_while(|byte| matches!(byte, b' ' | b'\t')).count(); + if blanks > 0 && blanks < line.len() { + return Some(&line[..blanks]); + } + } + Some(b"") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_indentation_is_that_of_the_first_line_that_is_indented() { + assert_eq!(indentation(b"{\n \"a\": {\n \"b\": 1\n }\n}"), Some(&b" "[..])); + assert_eq!(indentation(b"{\n\t\"a\": 1\n}"), Some(&b"\t"[..])); + assert_eq!(indentation(b"{\n \"a\": 1\n}"), Some(&b" "[..])); + } + + #[test] + fn a_document_on_one_line_is_written_on_one_line() { + assert_eq!(indentation(br#"{"a":1}"#), None); + assert_eq!( + encode(&serde_json::json!({"a": 1, "b": [1, 2]}), br#"{"a":1}"#), + br#"{"a":1,"b":[1,2]}"# + ); + } + + #[test] + fn a_document_with_no_indented_line_is_written_with_none() { + assert_eq!(indentation(b"{\n\"a\": 1\n}\n"), Some(&b""[..])); + } + + #[test] + fn a_blank_line_with_spaces_is_not_the_indentation() { + assert_eq!(indentation(b"{\n \n \"a\": 1\n}"), Some(&b" "[..])); + } + + #[test] + fn the_newline_at_the_end_is_kept_if_there_was_one() { + let document = serde_json::json!({"a": 1}); + assert!(encode(&document, b"{\n \"a\": 1\n}\n").ends_with(b"}\n")); + assert!(encode(&document, b"{\n \"a\": 1\n}").ends_with(b"}")); + } +} diff --git a/src/bin/forge/normalize/vendor/cloudflare.rs b/src/bin/forge/normalize/vendor/cloudflare.rs new file mode 100644 index 000000000..de7df8c9d --- /dev/null +++ b/src/bin/forge/normalize/vendor/cloudflare.rs @@ -0,0 +1,18 @@ +//! The adapter for Cloudflare's compatibility extensions. + +use super::{Normalize, Source}; + +pub(super) struct Cloudflare; + +impl Source for Cloudflare { + fn name(&self) -> &'static str { + "cloudflare" + } + + fn schema(&self, key: &str) -> Option<&dyn Normalize> { + match key { + "x-sensitive" => Some(&"x-forge-sensitive"), + _ => None, + } + } +} diff --git a/src/bin/forge/normalize/vendor/fern.rs b/src/bin/forge/normalize/vendor/fern.rs new file mode 100644 index 000000000..091e50620 --- /dev/null +++ b/src/bin/forge/normalize/vendor/fern.rs @@ -0,0 +1,36 @@ +//! The adapter for Fern's `x-fern-*` extensions. + +use super::{Group, Normalize, Source}; + +pub(super) struct Fern; + +impl Source for Fern { + fn name(&self) -> &'static str { + "fern" + } + + fn definition(&self, key: &str) -> Option<&dyn Normalize> { + match key { + "x-fern-sdk-method-name" => Some(&"x-forge-method-name"), + "x-fern-sdk-group-name" => Some(&Group("x-forge-group-name")), + "x-fern-ignore" => Some(&"x-forge-skip"), + "x-fern-availability" => Some(&"x-forge-availability"), + _ => None, + } + } + + fn parameter(&self, key: &str) -> Option<&dyn Normalize> { + match key { + "x-fern-parameter-name" => Some(&"x-forge-parameter-name"), + _ => None, + } + } + + fn schema(&self, key: &str) -> Option<&dyn Normalize> { + match key { + "x-fern-type-name" => Some(&"x-forge-model-name"), + "x-fern-property-name" => Some(&"x-forge-property-name"), + _ => None, + } + } +} diff --git a/src/bin/forge/normalize/vendor/mod.rs b/src/bin/forge/normalize/vendor/mod.rs new file mode 100644 index 000000000..c3710f58c --- /dev/null +++ b/src/bin/forge/normalize/vendor/mod.rs @@ -0,0 +1,155 @@ +//! Compatibility-source adapters for [`super::json::normalize`]. +//! +//! A [`Source`] names the normalizers that apply to operations, parameters, +//! and schemas. A key is listed only at the OpenAPI object kind where Forge +//! reads its native replacement; elsewhere it is left alone. A [`Normalize`] +//! gives the [`Native`] form of an extension: the key that replaces it and +//! its value; the walk renames the key. +//! +//! To add a compatibility source, implement [`Source`] in a module here and +//! add its value to [`ALL`]. To add a key to an existing source, return its +//! normalizer from the applicable method. Normalization introduces no new +//! Forge semantics: the corresponding native key must already exist. + +mod cloudflare; +mod fern; +mod stainless; +#[cfg(test)] +mod tests; + +use serde_json::Value; + +/// Every available compatibility source. +pub(super) static ALL: &[&dyn Source] = &[&cloudflare::Cloudflare, &fern::Fern, &stainless::Stainless]; + +/// Returns the compatibility source called `name`, if there is one. +pub(super) fn named(name: &str) -> Option<&'static dyn Source> { + ALL.iter().find(|source| source.name() == name).copied() +} + +/// A source of compatibility extensions. +pub(super) trait Source: Sync { + /// The name accepted by `--from`. + fn name(&self) -> &'static str; + + /// The normalizer for `key` on operations and aliases, if there is one. + fn definition(&self, _key: &str) -> Option<&dyn Normalize> { + None + } + + /// The normalizer for `key` on Parameter Objects, if there is one. + fn parameter(&self, _key: &str) -> Option<&dyn Normalize> { + None + } + + /// The normalizer for `key` on Schema Objects, if there is one. + fn schema(&self, _key: &str) -> Option<&dyn Normalize> { + None + } +} + +/// Normalizes one compatibility extension into its [`Native`] form. +pub(super) trait Normalize: Sync { + /// The native form of an extension whose value is `value`, or none when + /// it has no native form and the extension is left alone. + fn normalize(&self, value: &Value) -> Option; +} + +/// An extension in its native form. +pub(super) struct Native { + /// The native key that replaces the compatibility key. + pub(super) key: &'static str, + /// The value in its native form, or none when it is kept as it is. + pub(super) value: Option, + pub(super) change: Change, +} + +impl Native { + /// Only the key renamed to `key`, with the value kept as it is. + const fn renamed(key: &'static str) -> Native { + Native { + key, + value: None, + change: Change::plain(), + } + } +} + +/// A change made by one normalizer. +#[derive(Clone, Copy, Debug)] +pub(super) struct Change { + pub(super) detail: Option, +} + +impl Change { + pub(super) const fn plain() -> Change { + Change { detail: None } + } + + pub(super) const fn described(detail: Detail) -> Change { + Change { detail: Some(detail) } + } +} + +/// An optional explanation of a normalization change. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(super) struct Detail { + pub(super) one: &'static str, + pub(super) many: &'static str, +} + +impl Detail { + const fn new(one: &'static str, many: &'static str) -> Detail { + Detail { one, many } + } +} + +/// A plain rename: the native key, with the value kept as it is. +impl Normalize for &'static str { + fn normalize(&self, _: &Value) -> Option { + Some(Native::renamed(self)) + } +} + +/// A group path, with an array of names written as a dotted string when each +/// name reads back as it is. +pub(super) struct Group(pub(super) &'static str); + +impl Normalize for Group { + fn normalize(&self, value: &Value) -> Option { + let Some(path) = value.as_array().and_then(|names| dotted(names)) else { + return Some(Native::renamed(self.0)); + }; + Some(Native { + key: self.0, + value: Some(Value::String(path)), + change: Change::described(Detail::new( + "group path written as a dotted string", + "group paths written as dotted strings", + )), + }) + } +} + +/// A skip that is a boolean, or a list of targets, which has no native form. +pub(super) struct BoolOrTargets(pub(super) &'static str); + +impl Normalize for BoolOrTargets { + fn normalize(&self, value: &Value) -> Option { + (!value.is_array()).then(|| Native::renamed(self.0)) + } +} + +/// Returns the names of an array joined by dots, if each is a name that a +/// dotted string reads back as it is. +fn dotted(names: &[Value]) -> Option { + let mut path = String::new(); + for (i, name) in names.iter().enumerate() { + let name = name.as_str().filter(|name| !name.is_empty() && !name.contains('.'))?; + if i > 0 { + path.push('.'); + } + path.push_str(name); + } + Some(path) +} diff --git a/src/bin/forge/normalize/vendor/stainless.rs b/src/bin/forge/normalize/vendor/stainless.rs new file mode 100644 index 000000000..f41f12550 --- /dev/null +++ b/src/bin/forge/normalize/vendor/stainless.rs @@ -0,0 +1,18 @@ +//! The adapter for Stainless's `x-stainless-*` extensions. + +use super::{BoolOrTargets, Normalize, Source}; + +pub(super) struct Stainless; + +impl Source for Stainless { + fn name(&self) -> &'static str { + "stainless" + } + + fn schema(&self, key: &str) -> Option<&dyn Normalize> { + match key { + "x-stainless-skip" => Some(&BoolOrTargets("x-forge-skip")), + _ => None, + } + } +} diff --git a/src/bin/forge/normalize/vendor/tests.rs b/src/bin/forge/normalize/vendor/tests.rs new file mode 100644 index 000000000..560b42c49 --- /dev/null +++ b/src/bin/forge/normalize/vendor/tests.rs @@ -0,0 +1,115 @@ +//! Tests for the private adapter registry. + +use super::*; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Level { + Definition, + Parameter, + Schema, +} + +/// Every compatibility key, with the source it is from and the level it is +/// read at. +const KEYS: [(&str, Level, &str); 9] = [ + ("cloudflare", Level::Schema, "x-sensitive"), + ("fern", Level::Definition, "x-fern-sdk-method-name"), + ("fern", Level::Definition, "x-fern-sdk-group-name"), + ("fern", Level::Definition, "x-fern-ignore"), + ("fern", Level::Definition, "x-fern-availability"), + ("fern", Level::Parameter, "x-fern-parameter-name"), + ("fern", Level::Schema, "x-fern-type-name"), + ("fern", Level::Schema, "x-fern-property-name"), + ("stainless", Level::Schema, "x-stainless-skip"), +]; + +fn read<'a>(source: &'a dyn Source, level: Level, key: &str) -> Option<&'a dyn Normalize> { + match level { + Level::Definition => source.definition(key), + Level::Parameter => source.parameter(key), + Level::Schema => source.schema(key), + } +} + +/// Reads `key` from every source, as the walk does when all are selected. +fn read_all(level: Level, key: &str) -> Option<&'static dyn Normalize> { + ALL.iter().find_map(|source| read(*source, level, key)) +} + +#[test] +fn a_key_is_read_only_at_its_level() { + for (name, level, key) in KEYS { + for other in [Level::Definition, Level::Parameter, Level::Schema] { + assert_eq!( + read(named(name).unwrap(), other, key).is_some(), + other == level, + "{key} at {other:?}" + ); + } + } +} + +#[test] +fn a_key_is_read_only_when_its_source_is_selected() { + for (name, level, key) in KEYS { + for selected in ALL { + assert_eq!( + read(*selected, level, key).is_some(), + selected.name() == name, + "{key} from {name}, with {} selected", + selected.name() + ); + } + } +} + +#[test] +fn every_adapter_is_in_all_sources() { + for (name, level, key) in KEYS { + assert!(read_all(level, key).is_some(), "{name}: {key}"); + } +} + +#[test] +fn every_normalizer_writes_a_native_key() { + for (_, level, original) in KEYS { + let normalizer = read_all(level, original).unwrap(); + let native = normalizer.normalize(&Value::Bool(true)); + + assert!( + native.is_some_and(|native| native.key.starts_with("x-forge-")), + "{original}" + ); + } +} + +#[test] +fn only_the_stainless_skip_may_have_no_native_value() { + for (_, level, original) in KEYS { + let normalizer = read_all(level, original).unwrap(); + + assert_eq!( + normalizer.normalize(&serde_json::json!(["terraform"])).is_none(), + original == "x-stainless-skip", + "{original}" + ); + } +} + +#[test] +fn the_other_extensions_of_these_tools_are_not_read() { + for key in [ + "x-fern-enum", + "x-fern-audiences", + "x-fern-pagination", + "x-fern-sdk-variables", + "x-stainless-terraform-configurability", + "x-stainless-deprecation-message", + "x-forge-skip", + "x-forge-method-name", + ] { + for level in [Level::Definition, Level::Parameter, Level::Schema] { + assert!(read_all(level, key).is_none(), "{key} at {level:?}"); + } + } +} diff --git a/src/normalize/walk.rs b/src/bin/forge/normalize/walk.rs similarity index 73% rename from src/normalize/walk.rs rename to src/bin/forge/normalize/walk.rs index 8f5cda501..e09af99ba 100644 --- a/src/normalize/walk.rs +++ b/src/bin/forge/normalize/walk.rs @@ -9,7 +9,8 @@ use serde_json::{Map, Value}; -use super::{Options, Report}; +use super::json::{Options, Report}; +use super::vendor::{Change, Native}; /// A kind of object that is read. #[derive(Clone, Copy)] @@ -86,7 +87,7 @@ pub(super) fn walk(document: &mut Value, options: &Options) -> Report { } struct Walk<'o> { - options: &'o Options, + options: &'o Options<'o>, report: Report, } @@ -142,20 +143,51 @@ impl Walk<'_> { .keys() .filter(|key| key.starts_with("x-")) .filter_map(|key| { - let normalize = match kind { - Kind::Operation => self.options.sources.definition(key), - Kind::Parameter => self.options.sources.parameter(key), - Kind::Schema => self.options.sources.schema(key), + // The first source with a normalizer for the key wins. + let normalizer = self.options.sources.iter().find_map(|source| match kind { + Kind::Operation => source.definition(key), + Kind::Parameter => source.parameter(key), + Kind::Schema => source.schema(key), Kind::Document | Kind::Components | Kind::PathItem | Kind::Alias | Kind::Body | Kind::Media => None, - }; - normalize.map(|normalize| (key.clone(), normalize)) + }); + normalizer.map(|normalizer| (key.clone(), normalizer)) }) .collect(); - for (original, normalize) in normalizers { - if let Some(change) = normalize.normalize(members, &original, self.options) { - self.report.record(&original, change); + for (original, normalizer) in normalizers { + let Some(Native { + key: native, + value, + change, + }) = members.get(&original).and_then(|value| normalizer.normalize(value)) + else { + continue; + }; + + // The native value wins when both are present. Keeping + // compatibility keys makes this object already normalized; + // otherwise the old key is removed without touching the native one. + if members.contains_key(native) { + if !self.options.leave_old_extensions { + members.shift_remove(&original); + self.report.record(&original, Change::plain()); + } + continue; } + + let at = members + .keys() + .position(|key| *key == original) + .expect("the compatibility key was found in its container"); + // The value is only copied when the old key keeps it too. + let removed = if self.options.leave_old_extensions { + None + } else { + members.shift_remove(&original) + }; + let value = value.or(removed).unwrap_or_else(|| members[&original].clone()); + members.shift_insert(at, native.to_owned(), value); + self.report.record(&original, change); } } } diff --git a/src/lib.rs b/src/lib.rs index f9ba83e9f..b609d40cd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,8 +7,6 @@ mod json; mod link; mod method; mod name; -#[cfg(feature = "cli")] -pub mod normalize; mod plugin; mod types; pub use json::Json; diff --git a/src/normalize/mod.rs b/src/normalize/mod.rs deleted file mode 100644 index c16791c41..000000000 --- a/src/normalize/mod.rs +++ /dev/null @@ -1,275 +0,0 @@ -//! Rewrites the extensions other tools' metadata is written with into the -//! native `x-forge-*` ones, in a JSON document. -//! -//! The key a tool writes is found with the tables of [`vendor`], at the kinds -//! of OpenAPI object where its native replacement belongs. A key elsewhere is -//! left alone, since rewriting it would give it a meaning it did not have. -//! -//! A native key is never replaced. When a document says the same thing with a -//! native key and another, the native one is kept. -//! -//! The document is read with `serde_json` and written again, in the order its -//! keys were in, with the indentation of its first indented line. That is -//! simple, and it is not a minimal edit: what is lost is listed in the package -//! README under "Known limitations". - -mod vendor; -mod walk; - -use std::collections::BTreeMap; -use std::fmt; - -use serde::Serialize; -use serde_json::Value; -use serde_json::ser::PrettyFormatter; - -/// The sources of compatibility keys that [`normalize`] rewrites. -#[derive(Clone)] -pub struct Sources(Vec<&'static dyn vendor::Source>); - -impl Sources { - /// Every compatibility source. - pub fn all() -> Sources { - Sources(vendor::ALL.to_vec()) - } - - /// Returns the source called `name`, if it exists. - pub fn named(name: &str) -> Option { - vendor::ALL - .iter() - .find(|source| source.name() == name) - .map(|source| Sources(vec![*source])) - } - - /// Returns these sources and `other` together. - pub fn union(mut self, other: Sources) -> Sources { - for source in other.0 { - if !self.0.iter().any(|selected| selected.name() == source.name()) { - self.0.push(source); - } - } - self - } - - fn find( - &self, - key: &str, - rules: impl Fn(&dyn vendor::Source) -> &'static [vendor::Rule], - ) -> Option<&'static dyn vendor::Normalize> { - self.0 - .iter() - .flat_map(|source| rules(*source)) - .find(|(original, _)| *original == key) - .map(|(_, normalize)| *normalize) - } - - fn definition(&self, key: &str) -> Option<&'static dyn vendor::Normalize> { - self.find(key, |source| source.definition()) - } - - fn parameter(&self, key: &str) -> Option<&'static dyn vendor::Normalize> { - self.find(key, |source| source.parameter()) - } - - fn schema(&self, key: &str) -> Option<&'static dyn vendor::Normalize> { - self.find(key, |source| source.schema()) - } -} - -impl fmt::Debug for Sources { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_list() - .entries(self.0.iter().map(|source| source.name())) - .finish() - } -} - -/// What [`normalize`] does. -#[derive(Clone, Debug)] -pub struct Options { - /// Keeps each key that is rewritten, beside the native key that replaces - /// it, for what still reads the old one. - pub leave_old_extensions: bool, - /// The compatibility sources to rewrite. - pub sources: Sources, -} - -impl Default for Options { - fn default() -> Options { - Options { - leave_old_extensions: false, - sources: Sources::all(), - } - } -} - -/// An optional explanation of a normalization change. -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -struct Detail { - one: &'static str, - many: &'static str, -} - -impl Detail { - const fn new(one: &'static str, many: &'static str) -> Detail { - Detail { one, many } - } -} - -/// A change made by one normalizer. -#[derive(Clone, Copy, Debug)] -struct Change { - detail: Option, -} - -impl Change { - const fn plain() -> Change { - Change { detail: None } - } - - const fn described(detail: Detail) -> Change { - Change { detail: Some(detail) } - } -} - -/// What [`normalize`] did. -#[derive(Debug, Default)] -pub struct Report { - /// How many keys were rewritten, or removed for a native key that - /// already said it, by the name of the key. - pub migrated: BTreeMap, - details: BTreeMap, -} - -impl Report { - /// Returns whether the document is different from what it was. - pub fn changed(&self) -> bool { - !self.migrated.is_empty() - } - - /// Returns the additional descriptions of changes, and how many times - /// each happened. - pub fn details(&self) -> impl Iterator + '_ { - self.details.iter().map(|(detail, count)| { - let description = if *count == 1 { detail.one } else { detail.many }; - (*count, description) - }) - } - - fn record(&mut self, original: &str, change: Change) { - *self.migrated.entry(original.to_owned()).or_insert(0) += 1; - if let Some(detail) = change.detail { - *self.details.entry(detail).or_insert(0) += 1; - } - } -} - -/// A document after [`normalize`]. -#[derive(Debug)] -pub struct Normalized { - /// The document, if it is different from what was read. - pub json: Option>, - /// What was done to it. - pub report: Report, -} - -/// Why a document could not be normalized. -#[derive(Debug)] -pub struct Error(String); - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} - -impl std::error::Error for Error {} - -impl From for Error { - fn from(err: serde_json::Error) -> Error { - Error(err.to_string()) - } -} - -/// Rewrites the keys of the document `json` that a native key replaces. -pub fn normalize(json: &[u8], options: &Options) -> Result { - let mut document: Value = serde_json::from_slice(json)?; - let report = walk::walk(&mut document, options); - if !report.changed() { - return Ok(Normalized { json: None, report }); - } - Ok(Normalized { - json: Some(write(&document, json)), - report, - }) -} - -/// Writes `document` as `original` was written, as far as it is known: with -/// its indentation, which is that of the first line that is indented, on one -/// line if it was, and with a newline at the end if it had one. -fn write(document: &Value, original: &[u8]) -> Vec { - let mut out = Vec::with_capacity(original.len() + original.len() / 16); - match indentation(original) { - Some(indent) => { - let mut serializer = serde_json::Serializer::with_formatter(&mut out, PrettyFormatter::with_indent(indent)); - document - .serialize(&mut serializer) - .expect("a document is written as JSON to the memory"); - } - None => serde_json::to_writer(&mut out, document).expect("a document is written as JSON to the memory"), - } - if original.ends_with(b"\n") { - out.push(b'\n'); - } - out -} - -/// Returns what `original` is indented with, or none if it is on one line. -fn indentation(original: &[u8]) -> Option<&[u8]> { - let first = original.iter().position(|&byte| byte == b'\n')?; - for line in original[first + 1..].split(|byte| *byte == b'\n') { - let blanks = line.iter().take_while(|byte| matches!(byte, b' ' | b'\t')).count(); - if blanks > 0 && blanks < line.len() { - return Some(&line[..blanks]); - } - } - Some(b"") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn the_indentation_is_that_of_the_first_line_that_is_indented() { - assert_eq!(indentation(b"{\n \"a\": {\n \"b\": 1\n }\n}"), Some(&b" "[..])); - assert_eq!(indentation(b"{\n\t\"a\": 1\n}"), Some(&b"\t"[..])); - assert_eq!(indentation(b"{\n \"a\": 1\n}"), Some(&b" "[..])); - } - - #[test] - fn a_document_on_one_line_is_written_on_one_line() { - assert_eq!(indentation(br#"{"a":1}"#), None); - assert_eq!( - write(&serde_json::json!({"a": 1, "b": [1, 2]}), br#"{"a":1}"#), - br#"{"a":1,"b":[1,2]}"# - ); - } - - #[test] - fn a_document_with_no_indented_line_is_written_with_none() { - assert_eq!(indentation(b"{\n\"a\": 1\n}\n"), Some(&b""[..])); - } - - #[test] - fn a_blank_line_with_spaces_is_not_the_indentation() { - assert_eq!(indentation(b"{\n \n \"a\": 1\n}"), Some(&b" "[..])); - } - - #[test] - fn the_newline_at_the_end_is_kept_if_there_was_one() { - let document = serde_json::json!({"a": 1}); - assert!(write(&document, b"{\n \"a\": 1\n}\n").ends_with(b"}\n")); - assert!(write(&document, b"{\n \"a\": 1\n}").ends_with(b"}")); - } -} diff --git a/src/normalize/vendor/cloudflare.rs b/src/normalize/vendor/cloudflare.rs deleted file mode 100644 index 0f50ef4aa..000000000 --- a/src/normalize/vendor/cloudflare.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! The adapter for Cloudflare's compatibility extensions. - -use super::{Rename, Rule, Source}; - -pub(super) struct Cloudflare; - -static SENSITIVE: Rename = Rename::same("x-forge-sensitive"); -static SCHEMA: [Rule; 1] = [("x-sensitive", &SENSITIVE)]; - -impl Source for Cloudflare { - fn name(&self) -> &'static str { - "cloudflare" - } - - fn schema(&self) -> &'static [Rule] { - &SCHEMA - } -} diff --git a/src/normalize/vendor/fern.rs b/src/normalize/vendor/fern.rs deleted file mode 100644 index 0176b6d18..000000000 --- a/src/normalize/vendor/fern.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! The adapter for Fern's `x-fern-*` extensions. - -use super::{Rename, Rule, Source}; - -pub(super) struct Fern; - -static METHOD_NAME: Rename = Rename::same("x-forge-method-name"); -static GROUP_NAME: Rename = Rename::group("x-forge-group-name"); -static SKIP: Rename = Rename::same("x-forge-skip"); -static AVAILABILITY: Rename = Rename::same("x-forge-availability"); -static PARAMETER_NAME: Rename = Rename::same("x-forge-parameter-name"); -static MODEL_NAME: Rename = Rename::same("x-forge-model-name"); -static PROPERTY_NAME: Rename = Rename::same("x-forge-property-name"); - -static DEFINITION: [Rule; 4] = [ - ("x-fern-sdk-method-name", &METHOD_NAME), - ("x-fern-sdk-group-name", &GROUP_NAME), - ("x-fern-ignore", &SKIP), - ("x-fern-availability", &AVAILABILITY), -]; -static PARAMETER: [Rule; 1] = [("x-fern-parameter-name", &PARAMETER_NAME)]; -static SCHEMA: [Rule; 2] = [ - ("x-fern-type-name", &MODEL_NAME), - ("x-fern-property-name", &PROPERTY_NAME), -]; - -impl Source for Fern { - fn name(&self) -> &'static str { - "fern" - } - - fn definition(&self) -> &'static [Rule] { - &DEFINITION - } - - fn parameter(&self) -> &'static [Rule] { - &PARAMETER - } - - fn schema(&self) -> &'static [Rule] { - &SCHEMA - } -} diff --git a/src/normalize/vendor/mod.rs b/src/normalize/vendor/mod.rs deleted file mode 100644 index 34000422e..000000000 --- a/src/normalize/vendor/mod.rs +++ /dev/null @@ -1,171 +0,0 @@ -//! Compatibility-source adapters for [`super::normalize`]. -//! -//! A [`Source`] names the normalizers that apply to operations, parameters, -//! and schemas. A key is listed only at the OpenAPI object kind where Forge -//! reads its native replacement; elsewhere it is left alone. [`Normalize`] -//! owns the complete mutation for one compatibility key, so a future -//! normalizer is not required to be a one-to-one rename. -//! -//! To add a compatibility source, implement [`Source`] in a module here and -//! add its value to [`ALL`]. To add a key to an existing source, add its -//! [`Rule`] to the applicable table. Normalization introduces no new Forge -//! semantics: the corresponding native key must already exist. - -mod cloudflare; -mod fern; -mod stainless; -#[cfg(test)] -mod tests; - -use serde_json::{Map, Value}; - -use super::{Change, Detail, Options}; - -/// One compatibility key and the normalizer that owns it. -pub(super) type Rule = (&'static str, &'static dyn Normalize); - -/// A source of compatibility extensions. -pub(super) trait Source: Sync { - /// The name accepted by `--from`. - fn name(&self) -> &'static str; - - /// Normalizers for operations and aliases. - fn definition(&self) -> &'static [Rule] { - &[] - } - - /// Normalizers for Parameter Objects. - fn parameter(&self) -> &'static [Rule] { - &[] - } - - /// Normalizers for Schema Objects. - fn schema(&self) -> &'static [Rule] { - &[] - } -} - -/// Normalizes one compatibility extension in its containing object. -pub(super) trait Normalize: Sync { - /// Returns the change that was made, or none when this value has no native - /// representation and the object is left alone. - fn normalize(&self, container: &mut Map, original: &str, options: &Options) -> Option; -} - -/// Renames one extension to its native equivalent. -/// -/// Every current compatibility rule is a rename, with one of the value forms -/// below. A future rule that restructures an object can implement [`Normalize`] -/// directly instead. -pub(super) struct Rename { - native: &'static str, - form: Form, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum Form { - Same, - Group, - BoolOrTargets, -} - -impl Rename { - pub(super) const fn same(native: &'static str) -> Rename { - Rename { - native, - form: Form::Same, - } - } - - pub(super) const fn group(native: &'static str) -> Rename { - Rename { - native, - form: Form::Group, - } - } - - pub(super) const fn bool_or_targets(native: &'static str) -> Rename { - Rename { - native, - form: Form::BoolOrTargets, - } - } - - fn supports(self: &Rename, value: &Value) -> bool { - self.form != Form::BoolOrTargets || !value.is_array() - } - - fn value(self: &Rename, value: Value) -> (Value, Option) { - let Form::Group = self.form else { - return (value, None); - }; - let Value::Array(names) = &value else { - return (value, None); - }; - let Some(path) = dotted(names) else { - return (value, None); - }; - ( - Value::String(path), - Some(Detail::new( - "group path written as a dotted string", - "group paths written as dotted strings", - )), - ) - } -} - -impl Normalize for Rename { - fn normalize(&self, container: &mut Map, original: &str, options: &Options) -> Option { - let value = container.get(original)?; - if !self.supports(value) { - return None; - } - - // The native value wins when both are present. Keeping compatibility - // keys makes this object already normalized; otherwise the old key is - // removed without touching the native one. - if container.contains_key(self.native) { - if options.leave_old_extensions { - return None; - } - container.shift_remove(original); - return Some(Change::plain()); - } - - let at = container - .keys() - .position(|key| key == original) - .expect("the compatibility key was found in its container"); - let original_value = if options.leave_old_extensions { - value.clone() - } else { - container - .shift_remove(original) - .expect("the compatibility key was found in its container") - }; - let (native_value, detail) = self.value(original_value); - container.shift_insert(at, self.native.to_owned(), native_value); - Some(match detail { - Some(detail) => Change::described(detail), - None => Change::plain(), - }) - } -} - -/// Every available compatibility source. -pub(super) static ALL: [&dyn Source; 3] = [&cloudflare::Cloudflare, &fern::Fern, &stainless::Stainless]; - -/// Returns the names of an array joined by dots, if each is a name that a -/// dotted string reads back as it is. -fn dotted(names: &[Value]) -> Option { - let mut path = String::new(); - for (i, name) in names.iter().enumerate() { - let name = name.as_str().filter(|name| !name.is_empty() && !name.contains('.'))?; - if i > 0 { - path.push('.'); - } - path.push_str(name); - } - Some(path) -} diff --git a/src/normalize/vendor/stainless.rs b/src/normalize/vendor/stainless.rs deleted file mode 100644 index 5b0f77fcb..000000000 --- a/src/normalize/vendor/stainless.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! The adapter for Stainless's `x-stainless-*` extensions. - -use super::{Rename, Rule, Source}; - -pub(super) struct Stainless; - -static SKIP: Rename = Rename::bool_or_targets("x-forge-skip"); -static SCHEMA: [Rule; 1] = [("x-stainless-skip", &SKIP)]; - -impl Source for Stainless { - fn name(&self) -> &'static str { - "stainless" - } - - fn schema(&self) -> &'static [Rule] { - &SCHEMA - } -} diff --git a/src/normalize/vendor/tests.rs b/src/normalize/vendor/tests.rs deleted file mode 100644 index 0d10b60bc..000000000 --- a/src/normalize/vendor/tests.rs +++ /dev/null @@ -1,128 +0,0 @@ -//! Tests for the private adapter registry. - -use super::*; -use crate::normalize::{Options, Sources}; - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -enum Level { - Definition, - Parameter, - Schema, -} - -fn tables() -> Vec<(&'static dyn Source, Level, &'static str, &'static dyn Normalize)> { - let table = |source: &'static dyn Source, level: Level, table: &'static [Rule]| -> Vec<_> { - table - .iter() - .map(|&(name, normalize)| (source, level, name, normalize)) - .collect() - }; - ALL.iter() - .flat_map(|source| { - [ - table(*source, Level::Definition, source.definition()), - table(*source, Level::Parameter, source.parameter()), - table(*source, Level::Schema, source.schema()), - ] - .concat() - }) - .collect() -} - -fn read(sources: &Sources, level: Level, key: &str) -> Option<&'static dyn Normalize> { - match level { - Level::Definition => sources.definition(key), - Level::Parameter => sources.parameter(key), - Level::Schema => sources.schema(key), - } -} - -#[test] -fn a_key_is_read_only_at_its_level() { - for (source, level, name, _) in tables() { - let sources = Sources::named(source.name()).unwrap(); - for other in [Level::Definition, Level::Parameter, Level::Schema] { - assert_eq!( - read(&sources, other, name).is_some(), - other == level, - "{name} at {other:?}" - ); - } - } -} - -#[test] -fn a_key_is_read_only_when_its_source_is_selected() { - for (source, level, name, _) in tables() { - for selected in ALL { - let sources = Sources::named(selected.name()).unwrap(); - assert_eq!( - read(&sources, level, name).is_some(), - selected.name() == source.name(), - "{name} from {}, with {} selected", - source.name(), - selected.name() - ); - } - } -} - -#[test] -fn every_adapter_is_in_all_sources() { - let sources = Sources::all(); - for (source, level, name, _) in tables() { - assert!(read(&sources, level, name).is_some(), "{}: {name}", source.name()); - } -} - -#[test] -fn every_rule_writes_a_native_key() { - for (_, _, original, normalize) in tables() { - let mut container = serde_json::Map::from_iter([(original.to_owned(), Value::Bool(true))]); - - assert!( - normalize - .normalize(&mut container, original, &Options::default()) - .is_some() - ); - assert!(!container.contains_key(original), "{original}"); - assert!( - container.keys().all(|key| key.starts_with("x-forge-")), - "{original}: {container:?}" - ); - } -} - -#[test] -fn only_the_stainless_skip_may_have_no_native_value() { - for (_, _, original, normalize) in tables() { - let mut container = serde_json::Map::from_iter([(original.to_owned(), serde_json::json!(["terraform"]))]); - - assert_eq!( - normalize - .normalize(&mut container, original, &Options::default()) - .is_none(), - original == "x-stainless-skip", - "{original}" - ); - } -} - -#[test] -fn the_other_extensions_of_these_tools_are_not_read() { - let sources = Sources::all(); - for key in [ - "x-fern-enum", - "x-fern-audiences", - "x-fern-pagination", - "x-fern-sdk-variables", - "x-stainless-terraform-configurability", - "x-stainless-deprecation-message", - "x-forge-skip", - "x-forge-method-name", - ] { - assert!(sources.definition(key).is_none(), "{key}"); - assert!(sources.parameter(key).is_none(), "{key}"); - assert!(sources.schema(key).is_none(), "{key}"); - } -}