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
70 changes: 55 additions & 15 deletions crates/jp_cli/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ impl SchemaType {
Self::Number => json!({"type": "number"}),
Self::Boolean => json!({"type": "boolean"}),
Self::Any => json!({}),
Self::Literal(val) => json!({"const": val}),
Self::Literal(value) => literal_schema(value),
Self::Array(items) => json!({
"type": "array",
"items": items.to_json(),
Expand Down Expand Up @@ -667,23 +667,63 @@ fn fields_to_json(fields: &[SchemaField]) -> serde_json::Value {
serde_json::Value::Object(schema)
}

/// Convert a union to JSON Schema, optimizing all-literal unions to `enum`.
/// Convert a union to JSON Schema, optimizing compatible literal unions to a
/// typed `enum`.
fn union_to_json(types: &[SchemaType]) -> serde_json::Value {
if types.iter().all(SchemaType::is_literal) {
// All literals: use the more widely supported `enum` form.
let values: Vec<&serde_json::Value> = types
.iter()
.map(|t| match t {
SchemaType::Literal(v) => v,
_ => unreachable!(),
})
.collect();
json!({ "enum": values })
} else {
// Mixed types and literals: use `anyOf`.
json!({
if !types.iter().all(SchemaType::is_literal) {
return json!({
"anyOf": types.iter().map(SchemaType::to_json).collect::<Vec<_>>(),
});
}

let values = types
.iter()
.map(|type_| match type_ {
SchemaType::Literal(value) => value,
_ => unreachable!(),
})
.collect::<Vec<_>>();

match common_literal_type(&values) {
Some(type_) => json!({ "type": type_, "enum": values }),
None => json!({
"anyOf": values.into_iter().map(literal_schema).collect::<Vec<_>>(),
}),
}
}

// The type is redundant next to `const` for validation: both forms accept the
// same documents. It is emitted anyway because a consumer that dispatches on
// `type` skips a node without one, and at least one provider rejects such a
// node outright.
fn literal_schema(value: &serde_json::Value) -> serde_json::Value {
json!({
"type": literal_type(value),
"const": value,
})
}

fn common_literal_type(values: &[&serde_json::Value]) -> Option<&'static str> {
let first = values.first().map(|value| literal_type(value))?;
if values.iter().all(|value| literal_type(value) == first) {
return Some(first);
}

values
.iter()
.all(|value| matches!(literal_type(value), "integer" | "number"))
.then_some("number")
}

fn literal_type(value: &serde_json::Value) -> &'static str {
match value {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "boolean",
serde_json::Value::Number(number) if number.is_i64() || number.is_u64() => "integer",
serde_json::Value::Number(_) => "number",
serde_json::Value::String(_) => "string",
serde_json::Value::Array(_) => "array",
serde_json::Value::Object(_) => "object",
}
}

Expand Down
90 changes: 72 additions & 18 deletions crates/jp_cli/src/schema_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -612,40 +612,76 @@ fn string_literal_enum() {
let schema = parse_schema_dsl(r#"status "active"|"inactive"|"archived""#).unwrap();
assert_eq!(
schema["properties"]["status"],
json!({"enum": ["active", "inactive", "archived"]})
json!({"type": "string", "enum": ["active", "inactive", "archived"]})
);
}

#[test]
fn single_string_literal() {
let schema = parse_schema_dsl(r#"kind "fixed""#).unwrap();
assert_eq!(schema["properties"]["kind"], json!({"const": "fixed"}));
assert_eq!(
schema["properties"]["kind"],
json!({"type": "string", "const": "fixed"})
);
}

#[test]
fn number_literal() {
let schema = parse_schema_dsl("version 1").unwrap();
assert_eq!(schema["properties"]["version"], json!({"const": 1}));
assert_eq!(
schema["properties"]["version"],
json!({"type": "integer", "const": 1})
);
}

#[test]
fn negative_number_literal() {
let schema = parse_schema_dsl("offset -1").unwrap();
assert_eq!(schema["properties"]["offset"], json!({"const": -1}));
assert_eq!(
schema["properties"]["offset"],
json!({"type": "integer", "const": -1})
);
}

#[test]
fn float_literal() {
let schema = parse_schema_dsl("ratio 0.5").unwrap();
assert_eq!(schema["properties"]["ratio"], json!({"const": 0.5}));
assert_eq!(
schema["properties"]["ratio"],
json!({"type": "number", "const": 0.5})
);
}

#[test]
fn integer_literal_enum_has_integer_type() {
let schema = parse_schema_dsl("value 1|2|3").unwrap();
assert_eq!(
schema["properties"]["value"],
json!({"type": "integer", "enum": [1, 2, 3]})
);
}

#[test]
fn numeric_literal_enum_has_number_type() {
let schema = parse_schema_dsl("value 1|2.5").unwrap();
assert_eq!(
schema["properties"]["value"],
json!({"type": "number", "enum": [1, 2.5]})
);
}

#[test]
fn mixed_literal_enum() {
let schema = parse_schema_dsl(r#"value "foo"|"bar"|42"#).unwrap();
assert_eq!(
schema["properties"]["value"],
json!({"enum": ["foo", "bar", 42]})
json!({
"anyOf": [
{"type": "string", "const": "foo"},
{"type": "string", "const": "bar"},
{"type": "integer", "const": 42}
]
})
);
}

Expand All @@ -654,34 +690,43 @@ fn literal_mixed_with_type() {
let schema = parse_schema_dsl(r#"value "special"|int"#).unwrap();
assert_eq!(
schema["properties"]["value"],
json!({"anyOf": [{"const": "special"}, {"type": "integer"}]})
json!({"anyOf": [{"type": "string", "const": "special"}, {"type": "integer"}]})
);
}

#[test]
fn boolean_literal_true() {
let schema = parse_schema_dsl("answer true").unwrap();
assert_eq!(schema["properties"]["answer"], json!({"const": true}));
assert_eq!(
schema["properties"]["answer"],
json!({"type": "boolean", "const": true})
);
}

#[test]
fn boolean_literal_false() {
let schema = parse_schema_dsl("answer false").unwrap();
assert_eq!(schema["properties"]["answer"], json!({"const": false}));
assert_eq!(
schema["properties"]["answer"],
json!({"type": "boolean", "const": false})
);
}

#[test]
fn null_literal() {
let schema = parse_schema_dsl("cleared null").unwrap();
assert_eq!(schema["properties"]["cleared"], json!({"const": null}));
assert_eq!(
schema["properties"]["cleared"],
json!({"type": "null", "const": null})
);
}

#[test]
fn nullable_string() {
let schema = parse_schema_dsl("value null|string").unwrap();
assert_eq!(
schema["properties"]["value"],
json!({"anyOf": [{"const": null}, {"type": "string"}]})
json!({"anyOf": [{"type": "null", "const": null}, {"type": "string"}]})
);
}

Expand All @@ -690,7 +735,10 @@ fn enum_in_array() {
let schema = parse_schema_dsl(r#"tags ["foo"|"bar"|"baz"]"#).unwrap();
assert_eq!(
schema["properties"]["tags"],
json!({"type": "array", "items": {"enum": ["foo", "bar", "baz"]}})
json!({
"type": "array",
"items": {"type": "string", "enum": ["foo", "bar", "baz"]}
})
);
}

Expand All @@ -700,7 +748,7 @@ fn enum_in_nested_object() {
let config = &schema["properties"]["config"];
assert_eq!(
config["properties"]["mode"],
json!({"enum": ["fast", "slow"]})
json!({"type": "string", "enum": ["fast", "slow"]})
);
assert_eq!(config["properties"]["count"], json!({"type": "integer"}));
}
Expand All @@ -710,15 +758,21 @@ fn enum_with_description() {
let schema = parse_schema_dsl(r#"status "active"|"inactive": current status"#).unwrap();
assert_eq!(
schema["properties"]["status"],
json!({"enum": ["active", "inactive"], "description": "current status"})
json!({
"type": "string",
"enum": ["active", "inactive"],
"description": "current status"
})
);
}

#[test]
fn true_false_enum_is_not_bool_type() {
// true|false produces enum, not {"type": "boolean"}
fn true_false_enum_has_boolean_type() {
let schema = parse_schema_dsl("flag true|false").unwrap();
assert_eq!(schema["properties"]["flag"], json!({"enum": [true, false]}));
assert_eq!(
schema["properties"]["flag"],
json!({"type": "boolean", "enum": [true, false]})
);
}

#[test]
Expand All @@ -728,7 +782,7 @@ fn field_level_union_with_literal_and_array() {
schema["properties"]["value"],
json!({
"anyOf": [
{"type": "array", "items": {"enum": ["a", "b"]}},
{"type": "array", "items": {"type": "string", "enum": ["a", "b"]}},
{"type": "integer"}
]
})
Expand Down
2 changes: 1 addition & 1 deletion docs/.vitepress/rfd-summaries.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@
"summary": "Make JP scriptable with concise schema DSL and inferred clean JSON output when piping or with --schema flag."
},
"030-schema-dsl.md": {
"hash": "d8f041473711fa0ec15010002e56e1ef8e8f65c1b55481601e6efdffc4d70f2e",
"hash": "97d9fe44330e60174d9045dfba09869ee1f005af585df97f79163a947cdd7ff6",
"summary": "JP's concise DSL syntax for defining JSON Schema objects via command-line flags with types, descriptions, and nested structures."
},
"031-durable-conversation-storage-with-workspace-projection.md": {
Expand Down
40 changes: 25 additions & 15 deletions docs/rfd/030-schema-dsl.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,32 +192,36 @@ Quoted strings, numbers, `true`, `false`, and `null` in the type position define
literal (constant) values:

```
kind "fixed" → {"const": "fixed"}
version 1 → {"const": 1}
ratio 0.5 → {"const": 0.5}
answer true → {"const": true}
cleared null → {"const": null}
kind "fixed" → {"type": "string", "const": "fixed"}
version 1 → {"type": "integer", "const": 1}
ratio 0.5 → {"type": "number", "const": 0.5}
answer true → {"type": "boolean", "const": true}
cleared null → {"type": "null", "const": null}
```

A literal carries both its type and its value.
`{"type": "integer", "const": 1}` and `{"const": 1}` accept the same documents;
the first also says what kind of value it is.

Strings must be quoted in the type position to distinguish them from type
keywords.
`string` is the type; `"string"` is the literal value `"string"`.

### Unions

The pipe `|` creates union types.
When all variants are literals, the output uses `enum` (widely supported by LLM
providers in strict mode).
When the union mixes literals and types, the output uses `anyOf`.
When every variant is a literal and the literals share a type, the output is a
typed `enum`; integer and float literals together widen to `number`.
Every other union is an `anyOf`.

**All literals `enum`:**
**All literals of one type — typed `enum`:**

```
status "active"|"inactive"|"archived"
```

```json
{"enum": ["active", "inactive", "archived"]}
{"type": "string", "enum": ["active", "inactive", "archived"]}
```

**Mixed types — `anyOf`:**
Expand All @@ -227,17 +231,23 @@ value "special"|int
```

```json
{"anyOf": [{"const": "special"}, {"type": "integer"}]}
{"anyOf": [{"type": "string", "const": "special"}, {"type": "integer"}]}
```

**Mixed literals (different JSON types) — `enum`:**
**Mixed literal types — `anyOf`:**

```
value "foo"|"bar"|42
```

```json
{"enum": ["foo", "bar", 42]}
{
"anyOf": [
{"type": "string", "const": "foo"},
{"type": "string", "const": "bar"},
{"type": "integer", "const": 42}
]
}
```

Inside arrays, `|` defines which item types the array accepts:
Expand All @@ -257,7 +267,7 @@ tags ["foo"|"bar"|"baz"]
```

```json
{"type": "array", "items": {"enum": ["foo", "bar", "baz"]}}
{"type": "array", "items": {"type": "string", "enum": ["foo", "bar", "baz"]}}
```

At the field level, `|` creates a union of the entire type:
Expand Down Expand Up @@ -392,7 +402,7 @@ This produces:
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
"role": {"enum": ["engineer", "manager", "designer"]},
"role": {"type": "string", "enum": ["engineer", "manager", "designer"]},
"misc": {"type": "array", "items": {}, "description": "whatever you want"},
"nested": {
"type": "object",
Expand Down
Loading