diff --git a/cli/assets/fig.ts b/cli/assets/fig.ts index a7de9bb4..a9f39499 100644 --- a/cli/assets/fig.ts +++ b/cli/assets/fig.ts @@ -365,6 +365,56 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: "json-schema", + description: + "Generate a JSON Schema for a CLI's config file from its usage spec", + options: [ + { + name: ["-f", "--file"], + description: + 'A usage spec taken in as a file, use "-" to read from stdin', + isRepeatable: false, + args: { + name: "file", + template: "filepaths", + }, + }, + { + name: "--out-file", + description: "Write the schema here instead of to stdout", + isRepeatable: false, + args: { + name: "out_file", + template: "filepaths", + }, + }, + { + name: "--spec", + description: "Raw string spec input", + isRepeatable: false, + args: { + name: "spec", + }, + }, + { + name: "--title", + description: "The schema's title, shown by editors", + isRepeatable: false, + args: { + name: "title", + }, + }, + { + name: "--url", + description: "Where the schema is published, for its `$id`", + isRepeatable: false, + args: { + name: "url", + }, + }, + ], + }, { name: ["manpage", "man"], options: [ diff --git a/cli/assets/usage.1 b/cli/assets/usage.1 index 52215452..cb0df43e 100644 --- a/cli/assets/usage.1 +++ b/cli/assets/usage.1 @@ -58,6 +58,9 @@ Generate Fig completion spec for Amazon Q / Fig \fBgenerate json\fR Outputs a usage spec in json format .TP +\fBgenerate json\-schema\fR +Generate a JSON Schema for a CLI's config file from its usage spec +.TP \fBgenerate manpage\fR .RS \fIAliases: \fRman @@ -275,6 +278,28 @@ A usage spec taken in as a file, use "\-" to read from stdin .TP \fB\-\-spec\fR \fI\fR raw string spec input +.SH "USAGE GENERATE JSON-SCHEMA" +Generate a JSON Schema for a CLI's config file from its usage spec +.PP +\fBUsage:\fR usage generate json\-schema [OPTIONS] +.PP +\fBOptions:\fR +.PP +.TP +\fB\-f, \-\-file\fR \fI\fR +A usage spec taken in as a file, use "\-" to read from stdin +.TP +\fB\-\-out\-file\fR \fI\fR +Write the schema here instead of to stdout +.TP +\fB\-\-spec\fR \fI\fR +raw string spec input +.TP +\fB\-\-title\fR \fI\fR +The schema's title, shown by editors +.TP +\fB\-\-url\fR \fI<URL>\fR +Where the schema is published, for its `$id` .SH "USAGE GENERATE MANPAGE" \fBUsage:\fR usage generate manpage [OPTIONS] .PP diff --git a/cli/src/cli/generate/json_schema.rs b/cli/src/cli/generate/json_schema.rs new file mode 100644 index 00000000..e6811f16 --- /dev/null +++ b/cli/src/cli/generate/json_schema.rs @@ -0,0 +1,64 @@ +use std::path::PathBuf; + +use miette::IntoDiagnostic; + +use crate::cli::generate; +use crate::schema::{config_schema, SchemaOptions}; +use crate::Result; + +/// Generate a JSON Schema for a CLI's config file from its usage spec +#[derive(clap::Args)] +#[clap()] +pub struct JsonSchema { + /// A usage spec taken in as a file, use "-" to read from stdin + #[clap(short, long)] + file: Option<PathBuf>, + + /// Write the schema here instead of to stdout + #[clap(long, value_hint = clap::ValueHint::FilePath)] + out_file: Option<PathBuf>, + + /// raw string spec input + #[clap(long, required_unless_present = "file", overrides_with = "file")] + spec: Option<String>, + + /// The schema's title, shown by editors + #[clap(long)] + title: Option<String>, + + /// Where the schema is published, for its `$id` + #[clap(long)] + url: Option<String>, +} + +impl JsonSchema { + pub fn run(&self) -> Result<()> { + let spec = generate::file_or_spec(&self.file, &self.spec)?; + let options = SchemaOptions { + title: self + .title + .clone() + .or_else(|| Some(format!("{} configuration", spec.name))), + url: self.url.clone(), + }; + let schema = config_schema(&spec.config, &options); + // A schema with no properties and `unevaluatedProperties: false` rejects every config + // file there is, which is worse than saying there is nothing to describe. Asked of + // the schema rather than of the spec, because a spec whose settings are *all* + // `scope="env"` declares props and still has nothing a file may hold. + if schema + .get("properties") + .and_then(serde_json::Value::as_object) + .is_none_or(serde_json::Map::is_empty) + { + miette::bail!( + "this spec declares nothing a config file can hold, so there is no schema to write" + ); + } + let json = serde_json::to_string_pretty(&schema).into_diagnostic()?; + // Through the shared writer, like every other generator: it takes `-` for stdout and + // reports a broken pipe instead of panicking on one. + generate::write_or_stdout(self.out_file.as_deref(), &format!("{json}\n"))?; + Ok(()) + } +} diff --git a/cli/src/cli/generate/mod.rs b/cli/src/cli/generate/mod.rs index 131750d2..3a365876 100644 --- a/cli/src/cli/generate/mod.rs +++ b/cli/src/cli/generate/mod.rs @@ -8,6 +8,7 @@ mod completion; mod completion_init; mod fig; mod json; +mod json_schema; mod manpage; mod markdown; mod sdk; @@ -26,6 +27,7 @@ pub enum Command { CompletionInit(completion_init::CompletionInit), Fig(fig::Fig), Json(json::Json), + JsonSchema(json_schema::JsonSchema), Manpage(manpage::Manpage), Markdown(markdown::Markdown), Sdk(sdk::Sdk), @@ -38,6 +40,7 @@ impl Generate { Command::CompletionInit(cmd) => cmd.run(), Command::Fig(cmd) => cmd.run(), Command::Json(cmd) => cmd.run(), + Command::JsonSchema(cmd) => cmd.run(), Command::Manpage(cmd) => cmd.run(), Command::Markdown(cmd) => cmd.run(), Command::Sdk(cmd) => cmd.run(), diff --git a/cli/src/command_effects.rs b/cli/src/command_effects.rs index 31665ecd..680b63b4 100644 --- a/cli/src/command_effects.rs +++ b/cli/src/command_effects.rs @@ -28,6 +28,7 @@ const EFFECTS: &[(&str, SpecCommandEffect)] = &[ ("generate completion-init", Read), ("generate fig", Read), ("generate json", Read), + ("generate json-schema", Read), ("generate manpage", Read), ("generate markdown", Read), // The only generator whose output flag is required: it cannot print an SDK @@ -47,6 +48,7 @@ const EFFECTS: &[(&str, SpecCommandEffect)] = &[ /// All of these redirect output that would otherwise go to stdout. const FLAG_EFFECTS: &[(&str, &str, SpecCommandEffect)] = &[ ("generate fig", "out-file", Write), + ("generate json-schema", "out-file", Write), ("generate manpage", "out-file", Write), ("generate markdown", "out-dir", Write), ("generate markdown", "out-file", Write), diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 7f380b67..687e8af4 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -10,6 +10,7 @@ pub use cli::Cli; mod cli; mod command_effects; pub mod env; +mod schema; mod usage_spec; #[cfg(test)] diff --git a/cli/src/schema/mod.rs b/cli/src/schema/mod.rs new file mode 100644 index 00000000..010950e7 --- /dev/null +++ b/cli/src/schema/mod.rs @@ -0,0 +1,565 @@ +//! A JSON Schema for a CLI's config file, from its `config` block. +//! +//! Every CLI in the fleet hand-writes one of these — mise generates `schema/mise.json` from +//! its own registry with a TypeScript script. Generating it from the spec means the schema, +//! the documentation and the resolver cannot disagree about what a setting is. +//! +//! Draft 2020-12, with `unevaluatedProperties: false` on every object so an unknown key in +//! a config file is reported by an editor rather than ignored. + +use serde_json::{json, Map, Value}; + +use usage::spec::config::{SpecConfig, SpecConfigProp, SpecConfigScope}; +use usage::spec::config_type::{Base, SpecConfigType}; + +/// What to call the schema, and where it claims to live. +#[derive(Debug, Default, Clone)] +pub struct SchemaOptions { + pub title: Option<String>, + /// The `$id` — where the schema is published, if anywhere. + pub url: Option<String>, +} + +/// The schema for the config file a spec describes. +pub fn config_schema(config: &SpecConfig, options: &SchemaOptions) -> Value { + let mut schema = Map::new(); + schema.insert( + "$schema".into(), + json!("https://json-schema.org/draft/2020-12/schema"), + ); + if let Some(url) = &options.url { + schema.insert("$id".into(), json!(url)); + } + if let Some(title) = &options.title { + schema.insert("title".into(), json!(title)); + } + schema.insert("type".into(), json!("object")); + schema.insert("unevaluatedProperties".into(), json!(false)); + schema.insert("properties".into(), Value::Object(nest(config))); + Value::Object(schema) +} + +/// Dotted keys become nested objects: `task.output` is `task: { output: … }`. +/// +/// This is what the dotted spelling buys — one canonical key in the spec, and the shape a +/// config file actually has reconstructed from it here. +fn nest(config: &SpecConfig) -> Map<String, Value> { + let mut root = Map::new(); + for (key, prop) in &config.props { + // Hidden settings are still settable, so they stay in the schema — with + // `unevaluatedProperties: false`, leaving one out turns an editor red on a config + // file that is perfectly legal. `hide` is a documentation and completion concern. + // + // `scope="env"` is different in kind: such a setting cannot come from a file at all, + // so listing it would advertise a key the CLI will ignore there. mise's own + // schema.ts does list its five `env_only` settings, and that is the drift this is + // generated from a spec to avoid — a deliberate divergence from the parity target. + if prop.scope == SpecConfigScope::Env { + continue; + } + let mut segments: Vec<&str> = key.split('.').collect(); + let leaf = segments.pop().unwrap_or(key.as_str()); + let mut table = &mut root; + for segment in segments { + // A key that is both a value and a group — `prop "a"` beside `prop "a.b"` — is + // a contradiction a schema cannot express. The group wins, because the keys + // under it would otherwise have nowhere to live; a scalar `type` left beside + // `properties` would make every write of `a` invalid instead of just that one. + // + // Replacing it wholesale was too blunt, though: a `map` or an `object` parent has + // no `properties` either, only `additionalProperties` or a bare `type: object` — + // so overwriting it dropped exactly the keyword that let the map hold anything, + // and `unevaluatedProperties: false` then rejected every key it was declared to + // accept. Only a parent that cannot hold keys at all is replaced. + let entry = table + .entry(segment.to_string()) + .or_insert_with(|| json!({"type": "object", "unevaluatedProperties": false})); + let holds_keys = entry.get("type").is_some_and(|ty| ty == "object"); + if !holds_keys { + *entry = json!({"type": "object", "unevaluatedProperties": false}); + } + table = entry + .as_object_mut() + .expect("just inserted an object") + .entry("properties") + .or_insert_with(|| json!({})) + .as_object_mut() + .expect("properties is an object"); + } + // No guard needed the other way round: props is sorted, and a key sorts before + // every key that extends it, so `a` is always written before `a.b` promotes it. + table.insert(leaf.to_string(), prop_schema(prop)); + } + root +} + +fn prop_schema(prop: &SpecConfigProp) -> Value { + let ty = prop.value_type.clone().unwrap_or_default(); + let mut schema = type_schema(&ty); + let object = schema.as_object_mut().expect("a schema is an object"); + + // The long form when there is one: an editor's hover is the one place a reader gets the + // whole story without leaving the file. + let description = prop + .long_help + .clone() + .or_else(|| prop.help.clone()) + .map(|text| match &prop.deprecated { + Some(why) => format!("{text}\n\nDeprecated: {why}"), + None => text, + }) + .or_else(|| { + prop.deprecated + .as_ref() + .map(|why| format!("Deprecated: {why}")) + }); + if let Some(description) = description { + object.insert("description".into(), json!(description)); + } + if let Some(default) = &prop.default { + object.insert("default".into(), value_json(default)); + } else if !prop.default_list.is_empty() { + object.insert("default".into(), json!(prop.default_list)); + } + if prop.deprecated.is_some() { + object.insert("deprecated".into(), json!(true)); + } + if !prop.choices.is_empty() { + // Onto every position a *value* can appear in, recursively: `choices` says what one + // value may be, and where that value sits depends on the type. A list keeps it on + // `items`, a map on `additionalProperties`, a union on each of its branches — and a + // `string|list<string>` needs both, which is why this recurses rather than picking one + // place. An `enum` beside `type: array`, or beside an `anyOf` it was not distributed + // into, is AND-combined with it and nothing can satisfy the pair. + let allowed = Value::Array(prop.choices.iter().map(|c| value_json(&c.value)).collect()); + constrain(&mut schema, &allowed); + } + schema +} + +/// Apply an `enum` to every position a single value can occupy in `schema`. +/// +/// Down through containers and across a union's branches, because that is where the values are. +/// `mise`'s `python.uv_venv_auto` is a `bool|string` listing all four of its values, and each +/// branch has to carry them or the branch that does not match rejects the value outright. +fn constrain(schema: &mut Value, allowed: &Value) { + let Some(object) = schema.as_object_mut() else { + return; + }; + if let Some(branches) = object.get_mut("anyOf").and_then(Value::as_array_mut) { + for branch in branches { + constrain(branch, allowed); + } + return; + } + for key in ["items", "additionalProperties"] { + if object.get(key).is_some_and(Value::is_object) { + let inner = object.get_mut(key).expect("just checked"); + constrain(inner, allowed); + return; + } + } + object.insert("enum".into(), allowed.clone()); +} + +fn type_schema(ty: &SpecConfigType) -> Value { + match ty { + SpecConfigType::Base(base) => base_schema(base), + SpecConfigType::List(inner) | SpecConfigType::Set(inner) => { + let mut schema = json!({"type": "array", "items": type_schema(inner)}); + // A set is an array that does not repeat, which JSON Schema can say. + if matches!(ty, SpecConfigType::Set(_)) { + schema + .as_object_mut() + .expect("an object") + .insert("uniqueItems".into(), json!(true)); + } + schema + } + SpecConfigType::Map(_, value) => json!({ + "type": "object", + "additionalProperties": type_schema(value), + }), + // Absent is legitimate, which in a schema is simply not being required — nothing + // here requires anything, so the inner type is the whole answer. + SpecConfigType::Option(inner) => type_schema(inner), + SpecConfigType::Union(members) => { + json!({"anyOf": members.iter().map(type_schema).collect::<Vec<_>>()}) + } + } +} + +fn base_schema(base: &Base) -> Value { + match base { + Base::Bool => json!({"type": "boolean"}), + Base::Int => json!({"type": "integer"}), + Base::Uint => json!({"type": "integer", "minimum": 0}), + Base::Float => json!({"type": "number"}), + Base::Path => json!({"type": "string"}), + Base::Url => json!({"type": "string", "format": "uri"}), + Base::Duration => json!({"type": "string"}), + Base::Object => json!({"type": "object"}), + // A type only the tool understands. A string accepts what it is written as in a + // config file, which is the most a schema can say without knowing more. + Base::String | Base::Custom(_) => json!({"type": "string"}), + } +} + +fn value_json(value: &usage::spec::config::SpecConfigValue) -> Value { + use usage::spec::config::SpecConfigValue as V; + match value { + V::Bool(b) => json!(b), + V::Int(i) => json!(i), + V::Float(f) => json!(f), + V::String(s) => json!(s), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use usage::Spec; + + fn schema_of(src: &str) -> Value { + let spec: Spec = src.parse().unwrap(); + config_schema( + &spec.config, + &SchemaOptions { + title: Some("Example config".into()), + url: Some("https://example.com/schema.json".into()), + }, + ) + } + + #[test] + fn every_type_becomes_something_a_validator_understands() { + let schema = schema_of( + r##" +name "ex" +bin "ex" +config { + prop "flag" type="bool" + prop "count" type="int" + prop "jobs" type="uint" + prop "rate" type="float" + prop "dir" type="path" + prop "site" type="url" + prop "wait" type="duration" + prop "loose" type="object" + prop "names" type="list<string>" + prop "uniq" type="set<string>" + prop "vars" type="map<string, string>" + prop "maybe" type="option<int>" + prop "either" type="bool|string" + prop "exotic" type="crate::Weird" +} +"##, + ); + let props = &schema["properties"]; + assert_eq!(props["flag"]["type"], "boolean"); + assert_eq!(props["count"]["type"], "integer"); + assert_eq!(props["jobs"]["minimum"], 0); + assert_eq!(props["rate"]["type"], "number"); + assert_eq!(props["dir"]["type"], "string"); + assert_eq!(props["site"]["format"], "uri"); + assert_eq!(props["wait"]["type"], "string"); + assert_eq!(props["loose"]["type"], "object"); + assert_eq!(props["names"]["items"]["type"], "string"); + assert_eq!(props["uniq"]["uniqueItems"], true); + assert_eq!(props["vars"]["additionalProperties"]["type"], "string"); + // Optional is "not required", and nothing here is required. + assert_eq!(props["maybe"]["type"], "integer"); + assert_eq!(props["either"]["anyOf"][0]["type"], "boolean"); + assert_eq!(props["either"]["anyOf"][1]["type"], "string"); + // A type only the tool knows still validates as what a file can hold. + assert_eq!(props["exotic"]["type"], "string"); + } + + #[test] + fn dotted_keys_become_the_shape_a_file_has() { + let schema = schema_of( + r##" +name "ex" +bin "ex" +config { + prop "task.output" type="string" help="How to print task output" + prop "task.cache.remote_mode" type="string" + prop "jobs" type="uint" +} +"##, + ); + let task = &schema["properties"]["task"]; + assert_eq!(task["type"], "object"); + assert_eq!(task["unevaluatedProperties"], false); + assert_eq!(task["properties"]["output"]["type"], "string"); + assert_eq!( + task["properties"]["cache"]["properties"]["remote_mode"]["type"], + "string" + ); + assert_eq!(schema["properties"]["jobs"]["type"], "integer"); + } + + #[test] + fn what_an_editor_shows_comes_from_the_spec() { + let schema = schema_of( + r##" +name "ex" +bin "ex" +config { + prop "shell" type="string" default="bash" help="Which shell" { + choices { + choice "bash" + choice "zsh" + } + } + prop "exclude" type="list<string>" { + default "target" "node_modules" + } + prop "old" type="bool" deprecated="Use new instead." help="Old setting" + prop "verbose" type="bool" default=#false help="Say more" \ + long_help="Say more.\n\nIncluding about things you did not ask about." +} +"##, + ); + let props = &schema["properties"]; + assert_eq!(props["shell"]["default"], "bash"); + assert_eq!(props["shell"]["enum"], json!(["bash", "zsh"])); + assert_eq!(props["shell"]["description"], "Which shell"); + assert_eq!( + props["exclude"]["default"], + json!(["target", "node_modules"]) + ); + assert_eq!(props["old"]["deprecated"], true); + assert_eq!( + props["old"]["description"], + "Old setting\n\nDeprecated: Use new instead." + ); + // The long form when there is one: hover is where a reader gets the whole story. + assert!(props["verbose"]["description"] + .as_str() + .unwrap() + .contains("did not ask about")); + assert_eq!(props["verbose"]["default"], false); + } + + #[test] + fn a_schema_lists_what_a_file_can_actually_hold() { + let schema = schema_of( + r##" +name "ex" +bin "ex" +config { + prop "internal" type="bool" hide=#true help="Not documented, still settable" + prop "config_file" type="path" scope="env" help="Read from the environment only" + prop "trusted" type="bool" scope="global" help="Not from a project file" + prop "normal" type="bool" +} +"##, + ); + let props = schema["properties"].as_object().expect("an object"); + // Hidden but writable: omitting it would make `unevaluatedProperties: false` reject + // a legal file. + assert!(props.contains_key("internal")); + // A file is still a file, whatever its scope, so a global-scoped setting belongs. + assert!(props.contains_key("trusted")); + assert!(props.contains_key("normal")); + // Never readable from a file, so advertising it would be a lie. + assert!( + !props.contains_key("config_file"), + "an env-only setting is not a config file key: {props:?}" + ); + } + + #[test] + fn a_group_that_is_also_a_map_keeps_what_makes_it_one() { + // A map or an object parent has no `properties` either — only `additionalProperties`, + // or a bare `type: object`. Replacing it because of that dropped the very keyword that + // let it hold anything, and `unevaluatedProperties: false` then rejected every key the + // map was declared to accept. + let schema = schema_of( + r##" +name "ex" +bin "ex" +config { + prop "vars" type="map<string, string>" help="Free-form" + prop "vars.known" type="bool" help="And one we know about" + prop "loose" type="object" + prop "loose.known" type="bool" +} +"##, + ); + let vars = &schema["properties"]["vars"]; + assert_eq!( + vars["additionalProperties"]["type"], "string", + "the map lost what makes it a map: {vars}" + ); + assert_eq!(vars["properties"]["known"]["type"], "boolean"); + // A free-form object keeps being one, and gains the key it declared. + let loose = &schema["properties"]["loose"]; + assert_eq!(loose["type"], "object"); + assert_eq!(loose["properties"]["known"]["type"], "boolean"); + assert!( + loose.get("unevaluatedProperties").is_none(), + "a free-form object should not have been made strict: {loose}" + ); + } + + #[test] + fn a_key_that_is_both_a_value_and_a_group_stays_valid_json_schema() { + // Contradictory to declare, but a schema that says `type: string` *and* lists + // properties rejects every possible value, which is worse than picking one. + let schema = schema_of( + r##" +name "ex" +bin "ex" +config { + prop "a" type="string" help="Also a group, which cannot be" + prop "a.b" type="bool" + prop "z.y" type="bool" + prop "z" type="string" +} +"##, + ); + // Both orders: the scalar declared before its group, and after it. + for (parent, child) in [("a", "b"), ("z", "y")] { + let group = &schema["properties"][parent]; + assert_eq!(group["type"], "object", "{parent}"); + assert_eq!(group["properties"][child]["type"], "boolean", "{parent}"); + assert!( + group.get("enum").is_none(), + "{parent} kept scalar facts beside its properties: {group}" + ); + } + } + + #[test] + fn choices_constrain_the_values_not_the_container() { + // `choices` says what one value may be. On a list that means the items: an `enum` at + // the top of an array schema is matched against the whole array, so no array can + // satisfy it and the schema rejects every config file that sets the setting at all. + let schema = schema_of( + r##" +name "ex" +bin "ex" +config { + prop "tools" type="list<string>" { + choices { + choice "node" + choice "python" + } + } + prop "levels" type="map<string, string>" { + choices { + choice "warn" + choice "error" + } + } + prop "shell" type="string" { + choices { + choice "bash" + } + } +} +"##, + ); + let props = &schema["properties"]; + assert_eq!(props["tools"]["type"], "array"); + assert!( + props["tools"].get("enum").is_none(), + "an array cannot equal one of its items: {}", + props["tools"] + ); + assert_eq!(props["tools"]["items"]["enum"], json!(["node", "python"])); + // Same for a map: the values are what is constrained. + assert_eq!( + props["levels"]["additionalProperties"]["enum"], + json!(["warn", "error"]) + ); + assert!(props["levels"].get("enum").is_none()); + // A scalar keeps it where it always was. + assert_eq!(props["shell"]["enum"], json!(["bash"])); + } + + #[test] + fn a_union_with_choices_is_described_by_its_choices() { + // `anyOf` and `enum` are combined with AND, so string-only choices on a `bool|string` + // setting rejected `true` — which the declared type plainly allows. A spec that lists + // its choices has said what the accepted values are, and each carries its own type. + let schema = schema_of( + r##" +name "ex" +bin "ex" +config { + prop "venv" type="bool|string" { + choices { + choice #false help="off" + choice "source" help="source an existing venv" + choice #true help="create and source" + } + } + prop "plain" type="bool|string" + prop "mixed" type="string|list<string>" { + choices { + choice "a" + choice "b" + } + } + prop "nested" type="map<string, list<string>>" { + choices { + choice "a" + choice "b" + } + } +} +"##, + ); + let nested = &schema["properties"]["nested"]; + // Down to the scalars: one level put the enum on the array under + // `additionalProperties`, where it was AND-combined with `type: array`. + assert_eq!( + nested["additionalProperties"]["items"]["enum"], + json!(["a", "b"]) + ); + assert!(nested["additionalProperties"].get("enum").is_none()); + + // Into each branch, not on top of the union: an `enum` beside an `anyOf` is + // AND-combined with it, so string-only choices on a `bool|string` rejected `true`. + // Distributed, each branch accepts the choices of its own type and the union still + // means "either". + let venv = &schema["properties"]["venv"]; + assert!( + venv.get("enum").is_none(), + "the enum should be inside the branches: {venv}" + ); + assert_eq!(venv["anyOf"][0]["type"], "boolean"); + assert_eq!(venv["anyOf"][0]["enum"], json!([false, "source", true])); + assert_eq!(venv["anyOf"][1]["type"], "string"); + assert_eq!(venv["anyOf"][1]["enum"], json!([false, "source", true])); + + // And a union with a container branch needs both positions at once, which is why the + // walk recurses rather than picking one place: dropping the `anyOf` here would have + // rejected every list form the declared type allows. + let mixed = &schema["properties"]["mixed"]; + assert_eq!(mixed["anyOf"][0]["enum"], json!(["a", "b"])); + assert_eq!(mixed["anyOf"][1]["items"]["enum"], json!(["a", "b"])); + // A union with no choices keeps describing itself as a union. + let plain = &schema["properties"]["plain"]; + assert_eq!(plain["anyOf"][0]["type"], "boolean"); + assert!(plain.get("enum").is_none()); + } + + #[test] + fn the_envelope_says_what_it_is() { + let schema = schema_of("name \"ex\"\nbin \"ex\"\nconfig {\n prop \"a\"\n}\n"); + assert_eq!( + schema["$schema"], + "https://json-schema.org/draft/2020-12/schema" + ); + assert_eq!(schema["$id"], "https://example.com/schema.json"); + assert_eq!(schema["title"], "Example config"); + // An unknown key in a config file should be reported, not ignored. + assert_eq!(schema["unevaluatedProperties"], false); + // No type declared means a string, which is what a bare `prop "a"` can hold. + assert_eq!(schema["properties"]["a"]["type"], "string"); + } +} diff --git a/cli/tests/json_schema.rs b/cli/tests/json_schema.rs new file mode 100644 index 00000000..d51a393a --- /dev/null +++ b/cli/tests/json_schema.rs @@ -0,0 +1,67 @@ +//! `usage g json-schema` as a command: where it writes, and when it refuses. + +use assert_cmd::prelude::*; +use predicates::str::contains; +use std::process::Command; + +fn usage_cmd() -> Command { + Command::new(assert_cmd::cargo::cargo_bin!("usage")) +} + +fn schema_of(spec: &str) -> assert_cmd::assert::Assert { + usage_cmd() + .args(["generate", "json-schema", "--spec", spec]) + .assert() +} + +#[test] +fn a_dash_means_stdout_rather_than_a_file_called_dash() { + // The convention every sibling generator follows, via the shared writer. Writing a file + // named `-` into whatever directory the user happened to be in is the kind of thing + // nobody notices until they find it in `git status`. + let dir = std::env::temp_dir().join(format!("usage_schema_dash_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + usage_cmd() + .current_dir(&dir) + .args([ + "generate", + "json-schema", + "--spec", + "name \"x\"\nbin \"x\"\nconfig {\n prop \"jobs\" type=\"uint\"\n}\n", + "--out-file", + "-", + ]) + .assert() + .success() + .stdout(contains("\"jobs\"")); + assert!( + !dir.join("-").exists(), + "a file named `-` was written instead of using stdout" + ); + + std::fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn a_spec_with_nothing_a_file_can_hold_says_so() { + // An empty `properties` next to `unevaluatedProperties: false` is a schema that rejects + // every config file in existence. Refusing to write one is the only honest answer. + schema_of("name \"x\"\nbin \"x\"\n") + .failure() + .stderr(contains("nothing a config file can hold")); + + // The case the props-only check missed: settings are declared, and every one of them is + // `scope="env"`, so none may appear in a file. + schema_of( + "name \"x\"\nbin \"x\"\nconfig {\n prop \"config_file\" type=\"path\" scope=\"env\"\n}\n", + ) + .failure() + .stderr(contains("nothing a config file can hold")); + + // And one settable key is enough to have something to describe. + schema_of("name \"x\"\nbin \"x\"\nconfig {\n prop \"jobs\" type=\"uint\"\n}\n") + .success() + .stdout(contains("\"jobs\"")); +} diff --git a/cli/usage.usage.kdl b/cli/usage.usage.kdl index 749ffcb1..aea6c6f2 100644 --- a/cli/usage.usage.kdl +++ b/cli/usage.usage.kdl @@ -139,6 +139,23 @@ You may need to set this if you have a different bin named "usage" arg <SPEC> } } + cmd json-schema help="Generate a JSON Schema for a CLI's config file from its usage spec" effect=read { + flag "-f --file" help="A usage spec taken in as a file, use \"-\" to read from stdin" { + arg <FILE> + } + flag --out-file help="Write the schema here instead of to stdout" effect=write { + arg <OUT_FILE> + } + flag --spec help="raw string spec input" { + arg <SPEC> + } + flag --title help="The schema's title, shown by editors" { + arg <TITLE> + } + flag --url help="Where the schema is published, for its `$id`" { + arg <URL> + } + } cmd manpage effect=read { alias man flag "-f --file" help="A usage spec taken in as a file, use \"-\" to read from stdin" required=#true { diff --git a/docs/cli/reference/commands.json b/docs/cli/reference/commands.json index 5d912cb5..b43033c7 100644 --- a/docs/cli/reference/commands.json +++ b/docs/cli/reference/commands.json @@ -573,6 +573,109 @@ "hidden_aliases": [], "examples": [] }, + "json-schema": { + "full_cmd": ["generate", "json-schema"], + "usage": "generate json-schema [FLAGS]", + "subcommands": {}, + "args": [], + "flags": [ + { + "name": "file", + "usage": "-f --file <FILE>", + "help": "A usage spec taken in as a file, use \"-\" to read from stdin", + "help_first_line": "A usage spec taken in as a file, use \"-\" to read from stdin", + "short": ["f"], + "long": ["file"], + "hide": false, + "global": false, + "arg": { + "name": "FILE", + "usage": "<FILE>", + "required": true, + "double_dash": "Optional", + "hide": false + } + }, + { + "name": "out-file", + "usage": "--out-file <OUT_FILE>", + "help": "Write the schema here instead of to stdout", + "help_first_line": "Write the schema here instead of to stdout", + "short": [], + "long": ["out-file"], + "hide": false, + "global": false, + "arg": { + "name": "OUT_FILE", + "usage": "<OUT_FILE>", + "required": true, + "double_dash": "Optional", + "hide": false + }, + "effect": "write" + }, + { + "name": "spec", + "usage": "--spec <SPEC>", + "help": "raw string spec input", + "help_first_line": "raw string spec input", + "short": [], + "long": ["spec"], + "hide": false, + "global": false, + "arg": { + "name": "SPEC", + "usage": "<SPEC>", + "required": true, + "double_dash": "Optional", + "hide": false + } + }, + { + "name": "title", + "usage": "--title <TITLE>", + "help": "The schema's title, shown by editors", + "help_first_line": "The schema's title, shown by editors", + "short": [], + "long": ["title"], + "hide": false, + "global": false, + "arg": { + "name": "TITLE", + "usage": "<TITLE>", + "required": true, + "double_dash": "Optional", + "hide": false + } + }, + { + "name": "url", + "usage": "--url <URL>", + "help": "Where the schema is published, for its `$id`", + "help_first_line": "Where the schema is published, for its `$id`", + "short": [], + "long": ["url"], + "hide": false, + "global": false, + "arg": { + "name": "URL", + "usage": "<URL>", + "required": true, + "double_dash": "Optional", + "hide": false + } + } + ], + "mounts": [], + "effect": "read", + "unknown_flags": null, + "hide": false, + "help": "Generate a JSON Schema for a CLI's config file from its usage spec", + "name": "json-schema", + "aliases": [], + "hidden_aliases": [], + "examples": [] + }, "manpage": { "full_cmd": ["generate", "manpage"], "usage": "generate manpage <FLAGS>", diff --git a/docs/cli/reference/generate.md b/docs/cli/reference/generate.md index b31d45cc..a69dc0cd 100644 --- a/docs/cli/reference/generate.md +++ b/docs/cli/reference/generate.md @@ -15,6 +15,7 @@ Generate completions, documentation, and other artifacts from usage specs - [`usage generate completion-init [--usage-bin <USAGE_BIN>] <SHELL>`](/cli/reference/generate/completion-init.md) - [`usage generate fig [FLAGS]`](/cli/reference/generate/fig.md) - [`usage generate json [-f --file <FILE>] [--spec <SPEC>]`](/cli/reference/generate/json.md) +- [`usage generate json-schema [FLAGS]`](/cli/reference/generate/json-schema.md) - [`usage generate manpage <FLAGS>`](/cli/reference/generate/manpage.md) - [`usage generate markdown <FLAGS>`](/cli/reference/generate/markdown.md) - [`usage generate sdk <FLAGS>`](/cli/reference/generate/sdk.md) diff --git a/docs/cli/reference/generate/json-schema.md b/docs/cli/reference/generate/json-schema.md new file mode 100644 index 00000000..ca2d1f50 --- /dev/null +++ b/docs/cli/reference/generate/json-schema.md @@ -0,0 +1,33 @@ +<!-- @generated by usage-cli from usage spec --> + +# `usage generate json-schema` + +- **Usage**: `usage generate json-schema [FLAGS]` +- **Effect**: read-only +- **Source code**: [`cli/src/cli/generate/json_schema.rs`](https://github.com/jdx/usage/blob/main/cli/src/cli/generate/json_schema.rs) + +Generate a JSON Schema for a CLI's config file from its usage spec + +## Flags + +### `-f --file <FILE>` + +A usage spec taken in as a file, use "-" to read from stdin + +### `--out-file <OUT_FILE>` + +**Effect**: modifies state + +Write the schema here instead of to stdout + +### `--spec <SPEC>` + +raw string spec input + +### `--title <TITLE>` + +The schema's title, shown by editors + +### `--url <URL>` + +Where the schema is published, for its `$id` diff --git a/docs/cli/reference/index.md b/docs/cli/reference/index.md index 73d9da5d..5ec3c63e 100644 --- a/docs/cli/reference/index.md +++ b/docs/cli/reference/index.md @@ -31,6 +31,7 @@ Outputs a `usage.kdl` spec for this CLI itself - [`usage generate completion-init [--usage-bin <USAGE_BIN>] <SHELL>`](/cli/reference/generate/completion-init.md) - [`usage generate fig [FLAGS]`](/cli/reference/generate/fig.md) - [`usage generate json [-f --file <FILE>] [--spec <SPEC>]`](/cli/reference/generate/json.md) +- [`usage generate json-schema [FLAGS]`](/cli/reference/generate/json-schema.md) - [`usage generate manpage <FLAGS>`](/cli/reference/generate/manpage.md) - [`usage generate markdown <FLAGS>`](/cli/reference/generate/markdown.md) - [`usage generate sdk <FLAGS>`](/cli/reference/generate/sdk.md)