Skip to content
Open
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
49 changes: 29 additions & 20 deletions compilers/compile/naming.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,21 @@ func NamingFor(source string) ir.Naming {
// NamingHint builds the Naming of an entity nothing declared a name for, from
// the context-derived hint an emitter should synthesize one from.
//
// It is the second half of the same invariant NamingFor holds: a compiler that
// derives a hint from a position — the property a schema was inlined at, the
// method and path of an operation with no operationId — derives an empty one
// wherever that position is itself unnamed, and passing it through leaves the
// node with no name in any channel. Minting one here means no caller has to
// remember the case.
// It is the second half of the same invariant NamingFor holds, and holds it the
// same way. A hint is the only name an anonymous entity carries, so it is what
// an emitter renders that entity's identifier from — the job Canonical does for
// a declared name — and it is therefore neutral words here too (invariant 4).
// That matters because a hint is nearly always derived from something a source
// *did* spell: a component key, an operationId, a header name, a $ref target.
// Passing those through carried their casing and their punctuation into the one
// channel no rule was holding (GitHub #54).
//
// A position that carries no name of its own derives an empty hint, and a
// spelling with no word rune in it ("***") derives no words; both leave the node
// with no name in any channel, so both are minted one here rather than at every
// caller.
func NamingHint(hint string) ir.Naming {
return ir.Naming{Hint: hintOr(hint)}
return ir.Naming{Hint: neutralHint(hint)}
}

// SubHint composes the hint of a node named after its position inside another —
Expand All @@ -59,22 +66,24 @@ func NamingHint(hint string) ir.Naming {
//
// Composing by hand is what NamingHint cannot protect: "" + "_item" is "_item",
// which is non-empty, so the presence rule passes it, and which is a leading
// separator no grammar produces, so nothing else reports it either — Naming.Hint
// is held to none of the content rules (GitHub #54). Minting the enclosing hint
// first makes the child agree with the node it hangs off, "empty_item" under
// "empty", rather than leaking the emptiness one level down.
// separator no grammar produces. Neutralizing each half first makes the child
// agree with the node it hangs off, "empty_item" under "empty", rather than
// leaking the emptiness one level down.
//
// suffix is the caller's own role or index and is never empty; a caller with
// neither has no child to distinguish and no reason to be here.
// Both halves go through the same minting because either can arrive from a
// source spelling: the enclosing position's name, and the $ref target a
// composition branch takes its role from. Two neutral words joined by a single
// "_" are a neutral word sequence again, which is what lets a composed hint be
// fed back in as the parent of the next one.
func SubHint(parent, suffix string) string {
return hintOr(parent) + "_" + suffix
return neutralHint(parent) + "_" + neutralHint(suffix)
}

// hintOr returns hint, or the minted name when the position it was derived from
// carries none.
func hintOr(hint string) string {
if hint == "" {
return emptyNameHint
// neutralHint returns the neutral word sequence of hint, or the minted name when
// the position it was derived from names nothing a word can be read out of.
func neutralHint(hint string) string {
if words := ir.CanonicalWords(hint); words != "" {
return words
}
return hint
return emptyNameHint
}
49 changes: 49 additions & 0 deletions compilers/compile/naming_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,26 @@ func TestNamingHint_KeepsADerivedHint(t *testing.T) {
assert.Equal(t, ir.Naming{Hint: "connection_domain"}, compile.NamingHint("connection_domain"))
}

// TestNamingHint_NeutralizesTheContextItWasDerivedFrom is what makes the hint
// channel a name rather than a transcription. A hint is derived from a position
// the source named — a component key, an operationId, a header name, a $ref
// target — so it arrives carrying whatever casing and punctuation that source
// used, and it is the only name an anonymous type has for an emitter to render
// (GitHub #54).
func TestNamingHint_NeutralizesTheContextItWasDerivedFrom(t *testing.T) {
t.Parallel()
for _, tc := range []struct{ hint, want string }{
{"connectionDomain", "connection_domain"},
{"OrderBody", "order_body"},
{"X-Report-List", "x_report_list"},
{"rollout.state", "rollout_state"},
{"get /pets/{petId}", "get_pets_pet_id"},
{"***", "empty"}, // no words to render, so the same minting an empty hint gets
} {
assert.Equal(t, ir.Naming{Hint: tc.want}, compile.NamingHint(tc.hint), "hint %q", tc.hint)
}
}

// TestNamingHint_EmptyHintIsMintedAName is the same defect reached through the
// other channel: a hint derived from a position the source left unnamed comes
// out empty, and passing it through leaves the node nameless just as an empty
Expand Down Expand Up @@ -69,3 +89,32 @@ func TestSubHint_MintsTheEnclosingHint(t *testing.T) {
assert.Equal(t, "widget_item", compile.SubHint("widget", "item"),
"an enclosing hint that is really there is untouched")
}

// TestSubHint_NeutralizesBothHalves pins that a composed hint is neutral however
// its two halves were spelled. Either can arrive from a source name — the
// enclosing position's, and the $ref target a union branch takes its role from —
// so neutralizing only the whole would still be a word sequence whichever half
// carried the casing, and neutralizing only the parent would not.
func TestSubHint_NeutralizesBothHalves(t *testing.T) {
t.Parallel()
for _, tc := range []struct{ parent, suffix, want string }{
{"Combo", "Alt", "combo_alt"},
{"X-Report", "item", "x_report_item"},
{"widget", "0", "widget_0"},
{"widget", "***", "widget_empty"},
} {
assert.Equal(t, tc.want, compile.SubHint(tc.parent, tc.suffix), "%q + %q", tc.parent, tc.suffix)
}
}

// TestSubHint_IsItselfANeutralHint is the composition property the callers rely
// on: a composed hint is fed back in as the parent of the next one, so joining
// two neutral halves has to produce something the grammar leaves alone. A join
// that introduced a boundary — a doubled or trailing separator, a letter run
// against a digit — would compound one level down.
func TestSubHint_IsItselfANeutralHint(t *testing.T) {
t.Parallel()
nested := compile.SubHint(compile.SubHint("Combo_A", "2"), "item")
assert.Equal(t, "combo_a_2_item", nested)
assert.Equal(t, nested, ir.CanonicalWords(nested), "the grammar leaves a composed hint alone")
}
49 changes: 44 additions & 5 deletions compilers/openapi/conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -325,9 +325,11 @@ func assertNamedTypes(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) {
// things in plain identifiers, so the compiler and the goldens shared one blind
// spot and the segmentation could not be wrong in a way any of them saw.
//
// Naming.Hint is deliberately not covered: it is built from context strings
// rather than through the grammar, and the golden shows one ("rollout.state")
// still carrying the source punctuation. That is GitHub #54, left open.
// Naming.Hint is covered by the same spec and for the same reason: a hint is
// built from a context string the source spelled, so this is the fixture whose
// context strings carry the punctuation. The enum property's hoisted node used
// to be hinted "rollout.state" verbatim, which is a name no emitter can render
// (GitHub #54).
func assertNeutralNaming(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) {
require.Len(t, doc.Services, 1)
svc := doc.Services[0]
Expand Down Expand Up @@ -371,6 +373,8 @@ func assertNeutralNamingLeaves(t *testing.T, doc *ir.Document, widget *ir.Model,
require.True(t, ok, "the enum property hoists an Enum node")
require.NotEmpty(t, rollout.Members)
assert.Equal(t, "in_progress", rollout.Members[0].Name.Canonical, "enum member")
assert.Equal(t, "rollout_state", rollout.Name.Hint,
"the hoisted node's hint is words too, not the property key verbatim")

require.Len(t, op.Responses, 1)
require.Len(t, op.Responses[0].Headers, 1)
Expand Down Expand Up @@ -469,7 +473,7 @@ var composedHints = []struct {
{"t/anon/components/schemas/Tuple/properties//prefixItems/0", "empty_0"},
{"t/anon/components/schemas/Mixed/properties//enum/0", "empty_0"},
{"t/anon/components/schemas/Mixed/properties//enum/1", "empty_1"},
{"t/composed/components/schemas/Host/properties//oneOf/0", "empty_Alt"},
{"t/composed/components/schemas/Host/properties//oneOf/0", "empty_alt"},
}

// assertEmptyDerivedHints is the case minting at the node alone does not reach.
Expand Down Expand Up @@ -562,7 +566,7 @@ func assertComponentReuse(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) {
bodyID := ir.TypeID("t/anon/components/requestBodies/OrderBody/content/application~1json/schema")
require.NotNil(t, order.Request)
assert.Equal(t, bodyID, order.Request.Contents[0].Type.Target)
assert.Equal(t, "OrderBody", doc.Types[bodyID].Common().Name.Hint,
assert.Equal(t, "order_body", doc.Types[bodyID].Common().Name.Hint,
"a shared body is named after its component, not the operation that reached it first")

require.Len(t, widgets.Responses[0].Headers, 1)
Expand Down Expand Up @@ -929,6 +933,41 @@ func assertNullable31Ref(t *testing.T, doc *ir.Document, _ []ir.Diagnostic) {
u, ok := doc.Types[namedID("UnionTarget")].(*ir.Union)
require.True(t, ok)
assert.Len(t, u.Variants, 2, "the null branch lifts to the ref rather than becoming a variant")

assertCollapsedBranchHint(t, doc)
}

// assertCollapsedBranchHint covers the {X, null} collapse's naming of the branch
// it keeps. The branch pointer is nameable from outside — BranchRef names it —
// and only the first lowering to reach it interns the node, so the collapse and
// an outside $ref must derive the same hint or the document depends on which
// component is declared first. The collapse used to hand the branch the
// *enclosing* schema's hint, which is neither what its composition would give it
// nor what the pointer walk derives (GitHub #281).
//
// The spec declares Collapsed before BranchRef on purpose: that is the order in
// which the collapse reaches the pointer first, and so the order that carried
// the enclosing name. The permutation half is the corpus-wide two-order oracle's
// (internal/harness), which compares hints with nothing excluded.
func assertCollapsedBranchHint(t *testing.T, doc *ir.Document) {
t.Helper()
const branchID = ir.TypeID("t/anon/components/schemas/Collapsed/oneOf/0")

collapsed, ok := doc.Types[namedID("Collapsed")].(*ir.Scalar)
require.True(t, ok, "a {X, null} set resolves to its one branch rather than to a union node")
require.NotNil(t, collapsed.Base)
assert.True(t, collapsed.Base.Nullable, "the null branch lifts onto the reference")
assert.Equal(t, branchID, collapsed.Base.Target)

branch, ok := doc.Types[branchID]
require.True(t, ok, "the branch declares a description, so it owns a node of its own")
assert.Equal(t, "variant_0", branch.Common().Name.Hint,
"the branch is named by its position in the composition, which is what a $ref to it derives too")

ref, ok := doc.Types[namedID("BranchRef")].(*ir.Scalar)
require.True(t, ok)
require.NotNil(t, ref.Base)
assert.Equal(t, branchID, ref.Base.Target, "the outside reference reaches that same node")
}

// assertNullableEnum31 covers 3.1's spelling of a nullable enum: `null` listed
Expand Down
9 changes: 5 additions & 4 deletions compilers/openapi/internal/operation/content_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -829,7 +829,8 @@ func TestContent_HeaderMapEntriesSharingComponentGetDistinctIDs(t *testing.T) {
// a body, the map key for a header — would name the one shared node after
// whichever reference happened to lower first. Naming.Hint is what emitters
// render from, so "postA_request" on a body two operations share is a wrong
// name, not a cosmetic one.
// name, not a cosmetic one. The hint is the component name in neutral words,
// which is what every name channel in the IR carries (invariant 4).
func TestContent_SharedComponentSchemaTakesItsDeclarationHint(t *testing.T) {
t.Parallel()
doc, diags := parseFull(t, componentBodyRefSpec)
Expand All @@ -838,14 +839,14 @@ func TestContent_SharedComponentSchemaTakesItsDeclarationHint(t *testing.T) {
body, ok := doc.Types[bodyID]
require.True(t, ok)
assert.True(t, body.Common().Anonymous, "a requestBody component is not a named type")
assert.Equal(t, "Body", body.Common().Name.Hint,
assert.Equal(t, "body", body.Common().Name.Hint,
"the shared body schema is hinted from its component, not from postA or postB")

hdrDoc, hdrDiags := parseFull(t, headerIdentitySpec)
requireNoErrorDiags(t, hdrDiags)
hdr, ok := hdrDoc.Types[ir.TypeID("t/anon/components/headers/Rate/schema")]
require.True(t, ok)
assert.Equal(t, "Rate", hdr.Common().Name.Hint,
assert.Equal(t, "rate", hdr.Common().Name.Hint,
"the shared header schema is hinted from its component, not from X-Rate or X-Limit")
}

Expand All @@ -868,7 +869,7 @@ func TestContent_InlineSchemaKeepsItsUseSiteHint(t *testing.T) {
requireNoErrorDiags(t, diags)
td, ok := doc.Types[ir.TypeID("t/anon/paths/~1a/post/requestBody/content/application~1json/schema")]
require.True(t, ok)
assert.Equal(t, "postA_request", td.Common().Name.Hint)
assert.Equal(t, "post_a_request", td.Common().Name.Hint)
}

const refdEncodingHeaderSpec = `openapi: 3.1.0
Expand Down
4 changes: 2 additions & 2 deletions compilers/openapi/internal/operation/operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ func operationName(src *soa.Operation, method, uriTemplate string) ir.Naming {
if id := src.GetOperationID(); id != "" {
return compile.NamingFor(id)
}
return compile.NamingHint(ir.CanonicalWords(method + " " + uriTemplate))
return compile.NamingHint(method + " " + uriTemplate)
}

// fillOperationDocs maps an operation's summary, description, and externalDocs
Expand Down Expand Up @@ -485,7 +485,7 @@ func lowerResponse(c lowering.Ctx, ts *compile.Types, anchors *schema.AnchorInde
// Naming at all and so has no counterpart here, and would be held by the
// presence rule at once if it gained one, since irverify does not exempt it.
func responseName(code string) ir.Naming {
return compile.NamingHint(ir.CanonicalWords(code))
return compile.NamingHint(code)
}

// lowerErrorCase lowers one error response into an ErrorCase, classifying its
Expand Down
2 changes: 1 addition & 1 deletion compilers/openapi/internal/schema/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ func baseBranchDiscriminator(branches []*oas3.JSONSchema[oas3.Referenceable]) *o
// with one Variant per branch (oneOf exclusive, anyOf not), never collapsing a
// union into optional fields.
func lowerOneOfAnyOf(c lowering.Ctx, ts *compile.Types, anchors *AnchorIndex, depth int, s *oas3.Schema, pointer, hint string) (ir.TypeRef, []ir.Diagnostic) {
if inner, ip, ih, ok := nullUnionCollapse(s, pointer, hint); ok {
if inner, ip, ih, ok := nullUnionCollapse(s, pointer); ok {
ref, diags := Ref(c, ts, anchors, depth, inner, ip, ih)
ref.Nullable = true
return ref, diags
Expand Down
51 changes: 48 additions & 3 deletions compilers/openapi/internal/schema/compose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1357,7 +1357,7 @@ func TestUnion_VariantHints(t *testing.T) {
u := typeByName(doc, "U").(*ir.Union)
require.Len(t, u.Variants, 2)
hints := []string{u.Variants[0].Name.Hint, u.Variants[1].Name.Hint}
assert.Contains(t, hints, "Named", "ref-with-siblings hint from target name")
assert.Contains(t, hints, "named", "ref-with-siblings hint from target name")
assert.Contains(t, hints, "variant_1", "inline branch positional hint")
}

Expand Down Expand Up @@ -1565,7 +1565,8 @@ func TestOneOf_CoDeclaredCompositionDistributes(t *testing.T) {
assert.Equal(t, componentID("Base"), v.Base.Target)
require.Len(t, v.Mixins, 1, "the branch joins as a mixin, the composition already having a base")
assert.Equal(t, componentID(branch), v.Mixins[0].Target)
assert.Equal(t, branch, u.Variants[i].Name.Hint)
assert.Equal(t, ir.CanonicalWords(branch), u.Variants[i].Name.Hint,
"the variant is named after its branch, in the neutral words every name channel carries")
}
assert.Equal(t, 1, countDiagsAt(diags, diag.CompositionLowering, ir.SeverityInfo),
"the reshaping is reported once; got %+v", diags)
Expand Down Expand Up @@ -1780,6 +1781,50 @@ func TestComposition_BranchAliasIsOrderIndependent(t *testing.T) {
}
}

// nullCollapseSpec writes the ordinary single-combinator `{X, null}` collapse
// with an outside component naming its surviving branch's pointer. The branch
// declares a description on purpose: a bare `{type: string}` branch resolves to
// the shared primitive and owns no node, so nothing would compete for the
// pointer and no hint would ever have to agree with another. hostFirst permutes
// which of the two components is declared first, and components lower in source
// order, so the two spellings are the two orders the pointer can be reached in.
func nullCollapseSpec(hostFirst bool) string {
host := " S:\n oneOf:\n - {type: string, description: the branch}\n" +
" - {type: \"null\"}\n"
outside := " Outsider: {$ref: '#/components/schemas/S/oneOf/0'}\n"
if hostFirst {
return componentSpec(host + outside)
}
return componentSpec(outside + host)
}

// TestNullCollapse_BranchHintIsOrderIndependent is TestComposition_Branch-
// AliasIsOrderIndependent's rule at the site the collapse takes instead. The
// collapse lowers the surviving branch at the branch pointer but used to hand it
// the *enclosing* schema's hint, while an outside $ref naming that same pointer
// derives variant_<index> through subSchemaHint — so whichever lowering arrived
// first decided the name and the two declaration orders produced two different
// documents, with no diagnostic on either side (GitHub #281).
//
// The single-order assertion is written against the order that was wrong: with S
// declared first the collapse reaches the pointer first, which is where the
// enclosing schema's hint used to land.
func TestNullCollapse_BranchHintIsOrderIndependent(t *testing.T) {
t.Parallel()
first, diags := parseFull(t, nullCollapseSpec(true))
requireNoErrorDiags(t, diags)
last, diags := parseFull(t, nullCollapseSpec(false))
requireNoErrorDiags(t, diags)

branch := ir.TypeID("t/anon/components/schemas/S/oneOf/0")
require.Contains(t, first.Types, branch, "the surviving branch owns a node of its own")
assert.Equal(t, "variant_0", first.Types[branch].Common().Name.Hint,
"the branch takes the hint its composition gives it, not the enclosing schema's")
assert.Empty(t, cmp.Diff(first.Types, last.Types),
"declaring the collapse before or after the outside $ref must not change the registry")
assert.Empty(t, cmp.Diff(first, last, orderInvariantIR()...), "nor the rest of the document")
}

// TestOneOf_CoDeclaredVariantCarriesDiscriminatorValue pins the tag the variants
// inherit. The enclosing schema is an allOf subtype of a discriminated base, so
// every variant is written on the wire with that subtype's tag — and the tag
Expand Down Expand Up @@ -1822,7 +1867,7 @@ func TestOneOf_CoDeclaredAdditionalPropsHintNamesTheBody(t *testing.T) {
v, ok := doc.Types[u.Variants[0].Type.Target].(*ir.Model)
require.True(t, ok)
require.NotNil(t, v.AdditionalProps)
assert.Equal(t, "Combo_value", doc.Types[v.AdditionalProps.Value.Target].Common().Name.Hint)
assert.Equal(t, "combo_value", doc.Types[v.AdditionalProps.Value.Target].Common().Name.Hint)
}

// TestOneOf_CoDeclaredNonModelBranchIsCarriedAsWritten pins what §4.3 says
Expand Down
Loading
Loading