@kubb/ast 5.0.0-beta.3 → 5.0.0-beta.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/utils.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { camelCase, isValidVarName } from '@internals/utils'
1
+ import { camelCase, isValidVarName, memoize } from '@internals/utils'
2
2
 
3
3
  import { createFunctionParameter, createFunctionParameters, createParameterGroup, createParamsType, createProperty, createSchema } from './factory.ts'
4
4
  import { narrowSchema } from './guards.ts'
@@ -17,7 +17,7 @@ import type {
17
17
  } from './nodes/index.ts'
18
18
  import type { SchemaType } from './nodes/schema.ts'
19
19
  import { extractRefName } from './refs.ts'
20
- import { collect } from './visitor.ts'
20
+ import { collect, collectLazy } from './visitor.ts'
21
21
 
22
22
  const plainStringTypes = new Set<SchemaType>(['string', 'uuid', 'email', 'url', 'datetime'] as const)
23
23
 
@@ -74,16 +74,18 @@ export function isStringType(node: SchemaNode): boolean {
74
74
  * the desired casing while preserving `OperationNode.parameters` for other consumers.
75
75
  * The input array is not mutated. When `casing` is not set, the original array is returned unchanged.
76
76
  */
77
- export function caseParams(params: Array<ParameterNode>, casing: 'camelcase' | undefined): Array<ParameterNode> {
78
- if (!casing) {
79
- return params
80
- }
77
+ const caseParamsMemo = memoize(new WeakMap<Array<ParameterNode>, (casing: string) => Array<ParameterNode>>(), (params) =>
78
+ memoize(new Map<string, Array<ParameterNode>>(), (casing: string) =>
79
+ params.map((param) => {
80
+ const transformed = casing === 'camelcase' || !isValidVarName(param.name) ? camelCase(param.name) : param.name
81
+ return { ...param, name: transformed }
82
+ }),
83
+ ),
84
+ )
81
85
 
82
- return params.map((param) => {
83
- const transformed = casing === 'camelcase' || !isValidVarName(param.name) ? camelCase(param.name) : param.name
84
-
85
- return { ...param, name: transformed }
86
- })
86
+ export function caseParams(params: Array<ParameterNode>, casing: 'camelcase' | undefined): Array<ParameterNode> {
87
+ if (!casing) return params
88
+ return caseParamsMemo(params)(casing)
87
89
  }
88
90
 
89
91
  /**
@@ -392,7 +394,7 @@ export function createOperationParams(node: OperationNode, options: CreateOperat
392
394
  } else {
393
395
  if (pathParams.length) {
394
396
  if (pathParamsType === 'inlineSpread') {
395
- const spreadType = resolver?.resolvePathParamsName(node, pathParams[0]!) ?? undefined
397
+ const spreadType = resolver?.resolvePathParamsName(node, pathParams[0]!)
396
398
  params.push(
397
399
  createFunctionParameter({
398
400
  name: pathName,
@@ -474,7 +476,7 @@ function buildGroupParam({
474
476
  name: string
475
477
  node: OperationNode
476
478
  params: Array<ParameterNode>
477
- groupType: ParamGroupType | undefined
479
+ groupType: ParamGroupType | null | undefined
478
480
  resolver: OperationParamsResolver | undefined
479
481
  wrapType: (type: string) => ParamsTypeNode
480
482
  }): Array<FunctionParameterNode> {
@@ -496,7 +498,7 @@ function buildGroupParam({
496
498
 
497
499
  /**
498
500
  * Derives a {@link ParamGroupType} from the resolver's group method.
499
- * Returns `undefined` when the group name equals the individual param name (no real group).
501
+ * Returns `null` when the group name equals the individual param name (no real group).
500
502
  */
501
503
  function resolveGroupType({
502
504
  node,
@@ -508,14 +510,14 @@ function resolveGroupType({
508
510
  params: Array<ParameterNode>
509
511
  groupMethod: (_node: OperationNode, _param: ParameterNode) => string
510
512
  resolver: OperationParamsResolver
511
- }): ParamGroupType | undefined {
513
+ }): ParamGroupType | null {
512
514
  if (!params.length) {
513
- return undefined
515
+ return null
514
516
  }
515
517
  const firstParam = params[0]!
516
518
  const groupName = groupMethod.call(resolver, node, firstParam)
517
519
  if (groupName === resolver.resolveParamName(node, firstParam)) {
518
- return undefined
520
+ return null
519
521
  }
520
522
  const allOptional = params.every((p) => !p.required)
521
523
  return {
@@ -554,15 +556,15 @@ function sourceKey(source: SourceNode): string {
554
556
  return `${nameKey}:${source.isExportable ?? false}:${source.isTypeOnly ?? false}`
555
557
  }
556
558
 
557
- function pathTypeKey(path: string, isTypeOnly: boolean | undefined): string {
559
+ function pathTypeKey(path: string, isTypeOnly: boolean | null | undefined): string {
558
560
  return `${path}:${isTypeOnly ?? false}`
559
561
  }
560
562
 
561
- function exportKey(path: string, name: string | undefined, isTypeOnly: boolean | undefined, asAlias: boolean | undefined): string {
563
+ function exportKey(path: string, name: string | null | undefined, isTypeOnly: boolean | null | undefined, asAlias: boolean | null | undefined): string {
562
564
  return `${path}:${name ?? ''}:${isTypeOnly ?? false}:${asAlias ?? ''}`
563
565
  }
564
566
 
565
- function importKey(path: string, name: string | undefined, isTypeOnly: boolean | undefined): string {
567
+ function importKey(path: string, name: string | null | undefined, isTypeOnly: boolean | null | undefined): string {
566
568
  return `${path}:${name ?? ''}:${isTypeOnly ?? false}`
567
569
  }
568
570
 
@@ -570,7 +572,7 @@ function importKey(path: string, name: string | undefined, isTypeOnly: boolean |
570
572
  * Computes a multi-level sort key for exports and imports:
571
573
  * non-array names first (wildcards/namespace aliases); type-only before value; alphabetical path; unnamed before named.
572
574
  */
573
- function sortKey(node: { name?: string | Array<unknown>; isTypeOnly?: boolean; path: string }): string {
575
+ function sortKey(node: { name?: string | Array<unknown> | null; isTypeOnly?: boolean | null; path: string }): string {
574
576
  const isArray = Array.isArray(node.name) ? '1' : '0'
575
577
  const typeOnly = node.isTypeOnly ? '0' : '1'
576
578
  const hasName = node.name != null ? '1' : '0'
@@ -592,6 +594,17 @@ export function combineSources(sources: Array<SourceNode>): Array<SourceNode> {
592
594
  return [...seen.values()]
593
595
  }
594
596
 
597
+ /**
598
+ * Merges `incoming` names into `existing`, preserving order and dropping duplicates.
599
+ *
600
+ * Shared by `combineExports` and `combineImports` for the same-path name-merge case.
601
+ */
602
+ function mergeNameArrays<TName>(existing: Array<TName>, incoming: Array<TName>): Array<TName> {
603
+ const merged = new Set(existing)
604
+ for (const name of incoming) merged.add(name)
605
+ return [...merged]
606
+ }
607
+
595
608
  /**
596
609
  * Deduplicates and merges `ExportNode` objects by path and type.
597
610
  *
@@ -619,9 +632,7 @@ export function combineExports(exports: Array<ExportNode>): Array<ExportNode> {
619
632
  const existing = namedByPath.get(key)
620
633
 
621
634
  if (existing && Array.isArray(existing.name)) {
622
- const merged = new Set(existing.name)
623
- for (const n of name) merged.add(n)
624
- existing.name = [...merged]
635
+ existing.name = mergeNameArrays(existing.name, name)
625
636
  } else {
626
637
  const newItem: ExportNode = { ...curr, name: [...new Set(name)] }
627
638
  result.push(newItem)
@@ -662,6 +673,17 @@ export function combineImports(imports: Array<ImportNode>, exports: Array<Export
662
673
  return importNameMemo.get(key)!
663
674
  }
664
675
 
676
+ // Paths that keep at least one used named import. A default import from such a path is retained
677
+ // even when its binding can't be found in `source` — e.g. a generated `client` default import
678
+ // alongside `import type { Client } from <same path>`, where merged grouped output omits the body.
679
+ const pathsWithUsedNamedImport = new Set<string>()
680
+ for (const node of imports) {
681
+ if (!Array.isArray(node.name)) continue
682
+ if (node.name.some((item) => (typeof item === 'string' ? isUsed(item) : isUsed(item.name ?? item.propertyName)))) {
683
+ pathsWithUsedNamedImport.add(node.path)
684
+ }
685
+ }
686
+
665
687
  const result: Array<ImportNode> = []
666
688
  // Accumulates array-named imports keyed by `path:isTypeOnly` for name-merging
667
689
  const namedByPath = new Map<string, ImportNode>()
@@ -686,16 +708,14 @@ export function combineImports(imports: Array<ImportNode>, exports: Array<Export
686
708
  const existing = namedByPath.get(key)
687
709
 
688
710
  if (existing && Array.isArray(existing.name)) {
689
- const merged = new Set(existing.name)
690
- for (const n of name) merged.add(n)
691
- existing.name = [...merged]
711
+ existing.name = mergeNameArrays(existing.name, name)
692
712
  } else {
693
713
  const newItem: ImportNode = { ...curr, name }
694
714
  result.push(newItem)
695
715
  namedByPath.set(key, newItem)
696
716
  }
697
717
  } else {
698
- if (name && !isUsed(name)) continue
718
+ if (name && !isUsed(name) && !pathsWithUsedNamedImport.has(path)) continue
699
719
 
700
720
  const key = importKey(path, name, isTypeOnly)
701
721
  if (!seen.has(key)) {
@@ -723,13 +743,18 @@ export function extractStringsFromNodes(nodes: Array<CodeNode> | undefined): str
723
743
  if (node.kind === 'Text') return node.value
724
744
  if (node.kind === 'Break') return ''
725
745
  if (node.kind === 'Jsx') return node.value
726
- const parts: string[] = []
746
+
747
+ const parts: Array<string> = []
748
+
727
749
  if ('params' in node && node.params) parts.push(node.params)
728
750
  if ('generics' in node && node.generics) parts.push(Array.isArray(node.generics) ? node.generics.join(', ') : node.generics)
729
751
  if ('returnType' in node && node.returnType) parts.push(node.returnType)
730
752
  if ('type' in node && typeof node.type === 'string') parts.push(node.type)
753
+
731
754
  const nested = extractStringsFromNodes(node.nodes)
755
+
732
756
  if (nested) parts.push(nested)
757
+
733
758
  return parts.join('\n')
734
759
  })
735
760
  .filter(Boolean)
@@ -739,7 +764,7 @@ export function extractStringsFromNodes(nodes: Array<CodeNode> | undefined): str
739
764
  /**
740
765
  * Resolves the schema name of a ref node, falling back through `ref` → `name` → nested `schema.name`.
741
766
  *
742
- * Returns `undefined` for non-ref nodes or when no name can be resolved. Use this to get a schema's
767
+ * Returns `null` for non-ref nodes or when no name can be resolved. Use this to get a schema's
743
768
  * identifier for type definitions or error messages.
744
769
  *
745
770
  * @example
@@ -748,11 +773,11 @@ export function extractStringsFromNodes(nodes: Array<CodeNode> | undefined): str
748
773
  * // => 'Pet'
749
774
  * ```
750
775
  */
751
- export function resolveRefName(node: SchemaNode | undefined): string | undefined {
752
- if (!node || node.type !== 'ref') return undefined
753
- if (node.ref) return extractRefName(node.ref) ?? node.name ?? node.schema?.name ?? undefined
776
+ export function resolveRefName(node: SchemaNode | undefined): string | null {
777
+ if (!node || node.type !== 'ref') return null
778
+ if (node.ref) return extractRefName(node.ref) ?? node.name ?? node.schema?.name ?? null
754
779
 
755
- return node.name ?? node.schema?.name ?? undefined
780
+ return node.name ?? node.schema?.name ?? null
756
781
  }
757
782
 
758
783
  /**
@@ -775,18 +800,22 @@ export function resolveRefName(node: SchemaNode | undefined): string | undefined
775
800
  * }
776
801
  * ```
777
802
  */
778
- export function collectReferencedSchemaNames(node: SchemaNode | undefined, out: Set<string> = new Set()): Set<string> {
779
- if (!node) return out
803
+ const collectSchemaRefs = memoize(new WeakMap<SchemaNode, ReadonlySet<string>>(), (node: SchemaNode): ReadonlySet<string> => {
804
+ const refs = new Set<string>()
780
805
  collect<void>(node, {
781
806
  schema(child) {
782
807
  if (child.type === 'ref') {
783
808
  const name = resolveRefName(child)
784
-
785
- if (name) out.add(name)
809
+ if (name) refs.add(name)
786
810
  }
787
- return undefined
788
811
  },
789
812
  })
813
+ return refs
814
+ })
815
+
816
+ export function collectReferencedSchemaNames(node: SchemaNode | undefined, out: Set<string> = new Set()): Set<string> {
817
+ if (!node) return out
818
+ for (const name of collectSchemaRefs(node)) out.add(name)
790
819
  return out
791
820
  }
792
821
 
@@ -803,10 +832,10 @@ export function collectReferencedSchemaNames(node: SchemaNode | undefined, out:
803
832
  *
804
833
  * @example Only generate schemas referenced by included operations
805
834
  * ```ts
806
- * const includedOps = inputNode.operations.filter(op => resolver.resolveOptions(op, { options, include }) !== null)
807
- * const allowed = collectUsedSchemaNames(includedOps, inputNode.schemas)
835
+ * const includedOps = operations.filter(op => resolver.resolveOptions(op, { options, include }) !== null)
836
+ * const allowed = collectUsedSchemaNames(includedOps, schemas)
808
837
  *
809
- * for (const schema of inputNode.schemas) {
838
+ * for (const schema of schemas) {
810
839
  * if (schema.name && !allowed.has(schema.name)) continue
811
840
  * // … generate schema
812
841
  * }
@@ -814,16 +843,18 @@ export function collectReferencedSchemaNames(node: SchemaNode | undefined, out:
814
843
  *
815
844
  * @example Check whether a specific schema is needed
816
845
  * ```ts
817
- * const allowed = collectUsedSchemaNames(includedOps, inputNode.schemas)
846
+ * const allowed = collectUsedSchemaNames(includedOps, schemas)
818
847
  * allowed.has('OrderStatus') // false when no included operation references OrderStatus
819
848
  * ```
820
849
  */
821
- export function collectUsedSchemaNames(operations: ReadonlyArray<OperationNode>, schemas: ReadonlyArray<SchemaNode>): Set<string> {
850
+ const collectUsedSchemaNamesMemo = memoize(new WeakMap<ReadonlyArray<OperationNode>, (schemas: ReadonlyArray<SchemaNode>) => Set<string>>(), (ops) =>
851
+ memoize(new WeakMap<ReadonlyArray<SchemaNode>, Set<string>>(), (schemas) => computeUsedSchemaNames(ops, schemas)),
852
+ )
853
+
854
+ function computeUsedSchemaNames(operations: ReadonlyArray<OperationNode>, schemas: ReadonlyArray<SchemaNode>): Set<string> {
822
855
  const schemaMap = new Map<string, SchemaNode>()
823
856
  for (const schema of schemas) {
824
- if (schema.name) {
825
- schemaMap.set(schema.name, schema)
826
- }
857
+ if (schema.name) schemaMap.set(schema.name, schema)
827
858
  }
828
859
 
829
860
  const result = new Set<string>()
@@ -834,15 +865,13 @@ export function collectUsedSchemaNames(operations: ReadonlyArray<OperationNode>,
834
865
  if (!result.has(name)) {
835
866
  result.add(name)
836
867
  const namedSchema = schemaMap.get(name)
837
- if (namedSchema) {
838
- visitSchema(namedSchema)
839
- }
868
+ if (namedSchema) visitSchema(namedSchema)
840
869
  }
841
870
  }
842
871
  }
843
872
 
844
873
  for (const op of operations) {
845
- for (const schema of collect<SchemaNode>(op, { depth: 'shallow', schema: (node) => node })) {
874
+ for (const schema of collectLazy<SchemaNode>(op, { depth: 'shallow', schema: (node) => node })) {
846
875
  visitSchema(schema)
847
876
  }
848
877
  }
@@ -850,16 +879,13 @@ export function collectUsedSchemaNames(operations: ReadonlyArray<OperationNode>,
850
879
  return result
851
880
  }
852
881
 
853
- /**
854
- * Identifies all schemas that participate in circular dependency chains, including direct self-loops.
855
- *
856
- * Returns a Set of schema names with circular dependencies. Use this to wrap recursive schema positions
857
- * in deferred constructs (lazy getter, `z.lazy(() => …)`) to prevent infinite recursion when generated code runs.
858
- * Refs are followed by name only, keeping the algorithm linear in the schema graph size.
859
- *
860
- * @note Call this once on the full schema graph, then use `containsCircularRef()` to check individual schemas.
861
- */
862
- export function findCircularSchemas(schemas: ReadonlyArray<SchemaNode>): Set<string> {
882
+ export function collectUsedSchemaNames(operations: ReadonlyArray<OperationNode>, schemas: ReadonlyArray<SchemaNode>): Set<string> {
883
+ return collectUsedSchemaNamesMemo(operations)(schemas)
884
+ }
885
+
886
+ const EMPTY_CIRCULAR_SET = new Set<string>()
887
+
888
+ const findCircularSchemasMemo = memoize(new WeakMap<ReadonlyArray<SchemaNode>, Set<string>>(), (schemas: ReadonlyArray<SchemaNode>): Set<string> => {
863
889
  const graph = new Map<string, Set<string>>()
864
890
 
865
891
  for (const schema of schemas) {
@@ -870,7 +896,7 @@ export function findCircularSchemas(schemas: ReadonlyArray<SchemaNode>): Set<str
870
896
  const circular = new Set<string>()
871
897
  for (const start of graph.keys()) {
872
898
  const visited = new Set<string>()
873
- const stack: string[] = [...(graph.get(start) ?? [])]
899
+ const stack: Array<string> = [...(graph.get(start) ?? [])]
874
900
  while (stack.length > 0) {
875
901
  const node = stack.pop()!
876
902
  if (node === start) {
@@ -886,6 +912,20 @@ export function findCircularSchemas(schemas: ReadonlyArray<SchemaNode>): Set<str
886
912
  }
887
913
 
888
914
  return circular
915
+ })
916
+
917
+ /**
918
+ * Identifies all schemas that participate in circular dependency chains, including direct self-loops.
919
+ *
920
+ * Returns a Set of schema names with circular dependencies. Use this to wrap recursive schema positions
921
+ * in deferred constructs (lazy getter, `z.lazy(() => …)`) to prevent infinite recursion when generated code runs.
922
+ * Refs are followed by name only, keeping the algorithm linear in the schema graph size.
923
+ *
924
+ * @note Call this once on the full schema graph, then use `containsCircularRef()` to check individual schemas.
925
+ */
926
+ export function findCircularSchemas(schemas: ReadonlyArray<SchemaNode>): Set<string> {
927
+ if (schemas.length === 0) return EMPTY_CIRCULAR_SET
928
+ return findCircularSchemasMemo(schemas)
889
929
  }
890
930
 
891
931
  /**
@@ -902,14 +942,15 @@ export function containsCircularRef(
902
942
  ): boolean {
903
943
  if (!node || circularSchemas.size === 0) return false
904
944
 
905
- const matches = collect<true>(node, {
945
+ for (const _ of collectLazy<true>(node, {
906
946
  schema(child) {
907
- if (child.type !== 'ref') return undefined
947
+ if (child.type !== 'ref') return null
908
948
  const name = resolveRefName(child)
909
-
910
- return name && name !== excludeName && circularSchemas.has(name) ? true : undefined
949
+ return name && name !== excludeName && circularSchemas.has(name) ? true : null
911
950
  },
912
- })
951
+ })) {
952
+ return true
953
+ }
913
954
 
914
- return matches.length > 0
955
+ return false
915
956
  }