Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 16 additions & 16 deletions src/bin/forge/normalize.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
//! `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;
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, String> {
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)]
Expand All @@ -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<Sources>,
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)]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 }
Expand Down
157 changes: 157 additions & 0 deletions src/bin/forge/normalize/json.rs
Original file line number Diff line number Diff line change
@@ -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<String, usize>,
details: BTreeMap<Detail, usize>,
}

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<Item = (usize, &'static str)> + '_ {
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<Vec<u8>>,
/// 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<Normalized, serde_json::Error> {
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<u8> {
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"}"));
}
}
18 changes: 18 additions & 0 deletions src/bin/forge/normalize/vendor/cloudflare.rs
Original file line number Diff line number Diff line change
@@ -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,
}
}
}
36 changes: 36 additions & 0 deletions src/bin/forge/normalize/vendor/fern.rs
Original file line number Diff line number Diff line change
@@ -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,
}
}
}
Loading
Loading