diff --git a/packages/quicktype-core/src/Inference.ts b/packages/quicktype-core/src/Inference.ts index 317c9c91a7..ae0b5520ef 100644 --- a/packages/quicktype-core/src/Inference.ts +++ b/packages/quicktype-core/src/Inference.ts @@ -77,7 +77,7 @@ export const inferenceFlagsObject = { }, }; export type InferenceFlagName = keyof typeof inferenceFlagsObject; -export const inferenceFlagNames = Object.getOwnPropertyNames( +export const inferenceFlagNames = Object.keys( inferenceFlagsObject, ) as InferenceFlagName[]; export const inferenceFlags: { [F in InferenceFlagName]: InferenceFlag } = diff --git a/packages/quicktype-core/src/support/Chance.ts b/packages/quicktype-core/src/support/Chance.ts index fabbbcc072..715fd952fe 100644 --- a/packages/quicktype-core/src/support/Chance.ts +++ b/packages/quicktype-core/src/support/Chance.ts @@ -190,7 +190,7 @@ export class Chance { return this.integer({ min: 0, max: options.max }); } - pick(arr: T[]): T { + pick(arr: readonly T[]): T { if (arr.length === 0) { throw new RangeError("Chance: Cannot pick() from an empty array"); } diff --git a/src/CLIOptions.types.ts b/src/CLIOptions.types.ts new file mode 100644 index 0000000000..3b5bf8c650 --- /dev/null +++ b/src/CLIOptions.types.ts @@ -0,0 +1,45 @@ +import type { + LanguageName, + RendererOptions, + InferenceFlagName, +} from "quicktype-core"; + +type CamelToPascal = + T extends `${infer FirstChar}${infer Rest}` + ? `${Capitalize}${Rest}` + : never; + +export type NegatedInferenceFlagName< + Input extends InferenceFlagName = InferenceFlagName, +> = `no${CamelToPascal}`; + +export interface CLIOptions + extends Partial< + Record + > { + additionalSchema: string[]; + allPropertiesOptional: boolean; + alphabetizeProperties: boolean; + buildMarkovChain?: string; + debug?: string; + graphqlIntrospect?: string; + graphqlSchema?: string; + help: boolean; + httpHeader?: string[]; + httpMethod?: string; + lang: Lang; + + noRender: boolean; + out?: string; + quiet: boolean; + + rendererOptions: RendererOptions; + + src: string[]; + srcLang: string; + srcUrls?: string; + telemetry?: "enable" | "disable"; + topLevel: string; + + version: boolean; +} diff --git a/src/cli.options.ts b/src/cli.options.ts new file mode 100644 index 0000000000..efdb9e56da --- /dev/null +++ b/src/cli.options.ts @@ -0,0 +1,122 @@ +import { exceptionToString } from "@glideapps/ts-necessities"; +import commandLineArgs from "command-line-args"; +import _ from "lodash"; + +import { + type OptionDefinition, + type RendererOptions, + type TargetLanguage, + defaultTargetLanguages, + getTargetLanguage, + isLanguageName, + messageError, +} from "quicktype-core"; + +import { inferCLIOptions } from "./inference"; +import { + makeOptionDefinitions, + transformDefinition, +} from "./optionDefinitions"; +import type { CLIOptions } from "./CLIOptions.types"; + +// Parse the options in argv and split them into global options and renderer options, +// according to each option definition's `renderer` field. If `partial` is false this +// will throw if it encounters an unknown option. +export function parseOptions( + definitions: OptionDefinition[], + argv: string[], + partial: boolean, +): Partial { + let opts: commandLineArgs.CommandLineOptions; + try { + opts = commandLineArgs(definitions.map(transformDefinition), { + argv, + partial, + }); + } catch (e) { + return messageError("DriverCLIOptionParsingFailed", { + message: exceptionToString(e), + }); + } + + for (const k of Object.keys(opts)) { + if (opts[k] === null) { + return messageError("DriverCLIOptionParsingFailed", { + message: `Missing value for command line option "${k}"`, + }); + } + } + + const options: { + [key: string]: unknown; + rendererOptions: RendererOptions; + } = { rendererOptions: {} }; + for (const optionDefinition of definitions) { + if (!(optionDefinition.name in opts)) { + continue; + } + + const optionValue = opts[optionDefinition.name] as string; + if (optionDefinition.kind !== "cli") { + ( + options.rendererOptions as Record< + typeof optionDefinition.name, + unknown + > + )[optionDefinition.name] = optionValue; + } + // Inference flags + else { + const k = _.lowerFirst( + optionDefinition.name.split("-").map(_.upperFirst).join(""), + ); + options[k] = optionValue; + } + } + + return options; +} + +export function parseCLIOptions( + argv: string[], + inputTargetLanguage?: TargetLanguage, +): CLIOptions { + if (argv.length === 0) { + return inferCLIOptions({ help: true }, inputTargetLanguage); + } + + const targetLanguages = inputTargetLanguage + ? [inputTargetLanguage] + : defaultTargetLanguages; + const optionDefinitions = makeOptionDefinitions(targetLanguages); + + // We can only fully parse the options once we know which renderer is selected, + // because there are renderer-specific options. But we only know which renderer + // is selected after we've parsed the options. Hence, we parse the options + // twice. This is the first parse to get the renderer: + const incompleteOptions = inferCLIOptions( + parseOptions(optionDefinitions, argv, true), + inputTargetLanguage, + ); + + let targetLanguage = inputTargetLanguage as TargetLanguage; + if (inputTargetLanguage === undefined) { + const languageName = isLanguageName(incompleteOptions.lang) + ? incompleteOptions.lang + : "typescript"; + targetLanguage = getTargetLanguage(languageName); + } + + const rendererOptionDefinitions = + targetLanguage.cliOptionDefinitions.actual; + // Use the global options as well as the renderer options from now on: + const allOptionDefinitions = _.concat( + optionDefinitions, + rendererOptionDefinitions, + ); + // This is the parse that counts: + return inferCLIOptions( + parseOptions(allOptionDefinitions, argv, false), + targetLanguage, + ); +} diff --git a/src/index.ts b/src/index.ts index 8919671a58..575dd13338 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,1197 +1,30 @@ #!/usr/bin/env node + import * as fs from "node:fs"; import * as path from "node:path"; -import { exceptionToString } from "@glideapps/ts-necessities"; import chalk from "chalk"; -import { - definedMap, - // biome-ignore lint/suspicious/noShadowRestrictedNames: collection-utils exports this name - hasOwnProperty, - mapFromObject, - mapMap, - withDefault, -} from "collection-utils"; -import commandLineArgs from "command-line-args"; -import getUsage from "command-line-usage"; -import * as _ from "lodash"; -import type { Readable } from "readable-stream"; -import stringToStream from "string-to-stream"; -import _wordwrap from "wordwrap"; import { - FetchingJSONSchemaStore, - InputData, IssueAnnotationData, - JSONInput, - JSONSchemaInput, - type JSONSourceData, - type LanguageName, - type OptionDefinition, - type Options, - type RendererOptions, type SerializedRenderResult, - type TargetLanguage, - assert, - assertNever, - capitalize, - defaultTargetLanguages, - defined, - getStream, - getTargetLanguage, - inferenceFlagNames, - inferenceFlags, - isLanguageName, - languageNamed, - messageAssert, - messageError, - panic, - parseJSON, quicktypeMultiFile, - readFromFileOrURL, - readableFromFileOrURL, - sourcesFromPostmanCollection, - splitIntoWords, - trainMarkovChain, } from "quicktype-core"; -import { GraphQLInput } from "quicktype-graphql-input"; -import { schemaForTypeScriptSources } from "quicktype-typescript-input"; - -import { CompressedJSONFromStream } from "./CompressedJSONFromStream"; -import { introspectServer } from "./GraphQLIntrospection"; -import type { - GraphQLTypeSource, - JSONTypeSource, - SchemaTypeSource, - TypeSource, -} from "./TypeSource"; -import { urlsFromURLGrammar } from "./URLGrammar"; - -// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires -const packageJSON = require("../package.json"); - -const wordWrap: (s: string) => string = _wordwrap(90); - -export interface CLIOptions { - // We use this to access the inference flags - // biome-ignore lint/suspicious/noExplicitAny: heterogeneous by design - [option: string]: any; - additionalSchema: string[]; - allPropertiesOptional: boolean; - alphabetizeProperties: boolean; - buildMarkovChain?: string; - debug?: string; - graphqlIntrospect?: string; - graphqlSchema?: string; - help: boolean; - httpHeader?: string[]; - httpMethod?: string; - lang: Lang; - - noRender: boolean; - out?: string; - quiet: boolean; - - rendererOptions: RendererOptions; - - src: string[]; - srcLang: string; - srcUrls?: string; - telemetry?: string; - topLevel: string; - - version: boolean; -} - -const defaultDefaultTargetLanguageName = "go"; - -async function sourceFromFileOrUrlArray( - name: string, - filesOrUrls: string[], - httpHeaders?: string[], -): Promise { - const samples = await Promise.all( - filesOrUrls.map( - async (file) => await readableFromFileOrURL(file, httpHeaders), - ), - ); - return { kind: "json", name, samples }; -} - -function typeNameFromFilename(filename: string): string { - const name = path.basename(filename); - const extIndex = name.lastIndexOf("."); - return extIndex === -1 ? "" : name.slice(0, extIndex); -} - -async function samplesFromDirectory( - dataDir: string, - httpHeaders?: string[], -): Promise { - async function readFilesOrURLsInDirectory( - d: string, - ): Promise { - const files = fs - .readdirSync(d) - .map((x) => path.join(d, x)) - .filter((x) => fs.lstatSync(x).isFile()); - // Each file is a (Name, JSON | URL) - const sourcesInDir: TypeSource[] = []; - const graphQLSources: GraphQLTypeSource[] = []; - let graphQLSchema: Readable | undefined; - let graphQLSchemaFileName: string | undefined; - for (let file of files) { - const name = typeNameFromFilename(file); - - let fileOrUrl = file; - file = file.toLowerCase(); - - // If file is a URL string, download it - if (file.endsWith(".url")) { - fileOrUrl = fs.readFileSync(file, "utf8").trim(); - } - - if (file.endsWith(".url") || file.endsWith(".json")) { - sourcesInDir.push({ - kind: "json", - name, - samples: [ - await readableFromFileOrURL(fileOrUrl, httpHeaders), - ], - }); - } else if (file.endsWith(".schema")) { - sourcesInDir.push({ - kind: "schema", - name, - uris: [fileOrUrl], - }); - } else if (file.endsWith(".gqlschema")) { - messageAssert( - graphQLSchema === undefined, - "DriverMoreThanOneGraphQLSchemaInDir", - { - dir: dataDir, - }, - ); - graphQLSchema = await readableFromFileOrURL( - fileOrUrl, - httpHeaders, - ); - graphQLSchemaFileName = fileOrUrl; - } else if (file.endsWith(".graphql")) { - graphQLSources.push({ - kind: "graphql", - name, - schema: undefined, - query: await getStream( - await readableFromFileOrURL(fileOrUrl, httpHeaders), - ), - }); - } - } - - if (graphQLSources.length > 0) { - if (graphQLSchema === undefined) { - return messageError("DriverNoGraphQLSchemaInDir", { - dir: dataDir, - }); - } - - const schema = parseJSON( - await getStream(graphQLSchema), - "GraphQL schema", - graphQLSchemaFileName, - ); - for (const source of graphQLSources) { - source.schema = schema; - sourcesInDir.push(source); - } - } - - return sourcesInDir; - } - - const contents = fs.readdirSync(dataDir).map((x) => path.join(dataDir, x)); - const directories = contents.filter((x) => fs.lstatSync(x).isDirectory()); - - let sources = await readFilesOrURLsInDirectory(dataDir); - - for (const dir of directories) { - let jsonSamples: Readable[] = []; - const schemaSources: SchemaTypeSource[] = []; - const graphQLSources: GraphQLTypeSource[] = []; - - for (const source of await readFilesOrURLsInDirectory(dir)) { - switch (source.kind) { - case "json": - jsonSamples = jsonSamples.concat(source.samples); - break; - case "schema": - schemaSources.push(source); - break; - case "graphql": - graphQLSources.push(source); - break; - default: - return assertNever(source); - } - } - - if ( - jsonSamples.length > 0 && - schemaSources.length + graphQLSources.length > 0 - ) { - return messageError("DriverCannotMixJSONWithOtherSamples", { - dir, - }); - } - - // FIXME: rewrite this to be clearer - const oneUnlessEmpty = (xs: TypeSource[]): 0 | 1 => - Math.sign(xs.length) as 0 | 1; - if ( - oneUnlessEmpty(schemaSources) + oneUnlessEmpty(graphQLSources) > - 1 - ) { - return messageError("DriverCannotMixNonJSONInputs", { dir }); - } - - if (jsonSamples.length > 0) { - sources.push({ - kind: "json", - name: path.basename(dir), - samples: jsonSamples, - }); - } - - sources = sources.concat(schemaSources); - sources = sources.concat(graphQLSources); - } - - return sources; -} - -function inferLang( - options: Partial, - defaultLanguage: LanguageName, -): string | LanguageName { - // Output file extension determines the language if language is undefined - if (options.out !== undefined) { - const extension = path.extname(options.out); - if (extension === "") { - return messageError("DriverNoLanguageOrExtension", {}); - } - - return extension.slice(1); - } - - return defaultLanguage; -} - -function inferTopLevel(options: Partial): string { - // Output file name determines the top-level if undefined - if (options.out !== undefined) { - const extension = path.extname(options.out); - const without = path.basename(options.out).replace(extension, ""); - return without; - } - - // Source determines the top-level if undefined - if (options.src !== undefined && options.src.length === 1) { - const src = options.src[0]; - const extension = path.extname(src); - const without = path.basename(src).replace(extension, ""); - return without; - } - - return "TopLevel"; -} - -function inferCLIOptions( - opts: Partial, - targetLanguage: TargetLanguage | undefined, -): CLIOptions { - let srcLang = opts.srcLang; - if ( - opts.graphqlSchema !== undefined || - opts.graphqlIntrospect !== undefined - ) { - messageAssert( - srcLang === undefined || srcLang === "graphql", - "DriverSourceLangMustBeGraphQL", - {}, - ); - srcLang = "graphql"; - } else if ( - opts.src !== undefined && - opts.src.length > 0 && - opts.src.every((file) => _.endsWith(file, ".ts")) - ) { - srcLang = "typescript"; - } else { - messageAssert(srcLang !== "graphql", "DriverGraphQLSchemaNeeded", {}); - srcLang = withDefault(srcLang, "json"); - } - - let language: TargetLanguage; - if (targetLanguage !== undefined) { - language = targetLanguage; - } else { - const languageName = - opts.lang ?? inferLang(opts, defaultDefaultTargetLanguageName); - - if (isLanguageName(languageName)) { - language = languageNamed(languageName); - } else { - return messageError("DriverUnknownOutputLanguage", { - lang: languageName, - }); - } - } - - const options: CLIOptions = { - src: opts.src ?? [], - srcUrls: opts.srcUrls, - srcLang, - lang: language.name as LanguageName, - topLevel: opts.topLevel ?? inferTopLevel(opts), - noRender: !!opts.noRender, - alphabetizeProperties: !!opts.alphabetizeProperties, - allPropertiesOptional: !!opts.allPropertiesOptional, - rendererOptions: opts.rendererOptions ?? {}, - help: opts.help ?? false, - quiet: opts.quiet ?? false, - version: opts.version ?? false, - out: opts.out, - buildMarkovChain: opts.buildMarkovChain, - additionalSchema: opts.additionalSchema ?? [], - graphqlSchema: opts.graphqlSchema, - graphqlIntrospect: opts.graphqlIntrospect, - httpMethod: opts.httpMethod, - httpHeader: opts.httpHeader, - debug: opts.debug, - telemetry: opts.telemetry, - }; - for (const flagName of inferenceFlagNames) { - const cliName = negatedInferenceFlagName(flagName); - options[cliName] = !!opts[cliName]; - } - - return options; -} - -function makeLangTypeLabel(targetLanguages: readonly TargetLanguage[]): string { - assert( - targetLanguages.length > 0, - "Must have at least one target language", - ); - return targetLanguages - .map((r) => _.minBy(r.names, (s) => s.length)) - .join("|"); -} - -function negatedInferenceFlagName(name: string): string { - const prefix = "infer"; - if (name.startsWith(prefix)) { - name = name.slice(prefix.length); - } - - return `no${capitalize(name)}`; -} - -function dashedFromCamelCase(name: string): string { - return splitIntoWords(name) - .map((w) => w.word.toLowerCase()) - .join("-"); -} - -function makeOptionDefinitions( - targetLanguages: readonly TargetLanguage[], -): OptionDefinition[] { - const beforeLang: OptionDefinition[] = [ - { - name: "out", - alias: "o", - optionType: "string", - typeLabel: "FILE", - description: "The output file. Determines --lang and --top-level.", - kind: "cli", - }, - { - name: "top-level", - alias: "t", - optionType: "string", - typeLabel: "NAME", - description: "The name for the top level type.", - kind: "cli", - }, - ]; - const lang: OptionDefinition[] = - targetLanguages.length < 2 - ? [] - : [ - { - name: "lang", - alias: "l", - optionType: "string", - typeLabel: "LANG", - description: "The target language.", - kind: "cli", - }, - ]; - const afterLang: OptionDefinition[] = [ - { - name: "src-lang", - alias: "s", - optionType: "string", - defaultValue: undefined, - typeLabel: "SRC_LANG", - description: "The source language (default is json).", - kind: "cli", - }, - { - name: "src", - optionType: "string", - multiple: true, - typeLabel: "FILE|URL|DIRECTORY", - description: "The file, url, or data directory to type.", - kind: "cli", - defaultOption: true, - }, - { - name: "src-urls", - optionType: "string", - typeLabel: "FILE", - description: "Tracery grammar describing URLs to crawl.", - kind: "cli", - }, - ]; - const inference: OptionDefinition[] = Array.from( - mapMap(mapFromObject(inferenceFlags), (flag, name) => { - return { - name: dashedFromCamelCase(negatedInferenceFlagName(name)), - optionType: "boolean" as const, - description: `${flag.negationDescription}.`, - kind: "cli" as const, - }; - }).values(), - ); - const afterInference: OptionDefinition[] = [ - { - name: "graphql-schema", - optionType: "string", - typeLabel: "FILE", - description: "GraphQL introspection file.", - kind: "cli", - }, - { - name: "graphql-introspect", - optionType: "string", - typeLabel: "URL", - description: "Introspect GraphQL schema from a server.", - kind: "cli", - }, - { - name: "http-method", - optionType: "string", - typeLabel: "METHOD", - description: - "HTTP method to use for the GraphQL introspection query.", - kind: "cli", - }, - { - name: "http-header", - optionType: "string", - multiple: true, - typeLabel: "HEADER", - description: - "Header(s) to attach to all HTTP requests, including the GraphQL introspection query.", - kind: "cli", - }, - { - name: "additional-schema", - alias: "S", - optionType: "string", - multiple: true, - typeLabel: "FILE", - description: "Register the $id's of additional JSON Schema files.", - kind: "cli", - }, - { - name: "no-render", - optionType: "boolean", - description: "Don't render output.", - kind: "cli", - }, - { - name: "alphabetize-properties", - optionType: "boolean", - description: "Alphabetize order of class properties.", - kind: "cli", - }, - { - name: "all-properties-optional", - optionType: "boolean", - description: "Make all class properties optional.", - kind: "cli", - }, - { - name: "build-markov-chain", - optionType: "string", - typeLabel: "FILE", - description: "Markov chain corpus filename.", - kind: "cli", - }, - { - name: "quiet", - optionType: "boolean", - description: "Don't show issues in the generated code.", - kind: "cli", - }, - { - name: "debug", - optionType: "string", - typeLabel: "OPTIONS or all", - description: - "Comma separated debug options: print-graph, print-reconstitution, print-gather-names, print-transformations, print-schema-resolving, print-times, provenance", - kind: "cli", - }, - { - name: "telemetry", - optionType: "string", - typeLabel: "enable|disable", - description: "Enable anonymous telemetry to help improve quicktype", - kind: "cli", - }, - { - name: "help", - alias: "h", - optionType: "boolean", - description: "Get some help.", - kind: "cli", - }, - { - name: "version", - alias: "v", - optionType: "boolean", - description: "Display the version of quicktype", - kind: "cli", - }, - ]; - return beforeLang.concat(lang, afterLang, inference, afterInference); -} - -// command-line-usage defaults to a "string" type placeholder for any option -// definition that has neither a `type` function nor a `typeLabel`. Map our -// `optionType` to a `type` function (like `parseOptions` does for -// command-line-args) so boolean flags are rendered without a bogus value -// placeholder, while options that take values keep their explicit `typeLabel`. -function optionDefinitionsForUsage( - definitions: OptionDefinition[], -): OptionDefinition[] { - return definitions.map((def) => ({ - ...def, - type: def.optionType === "boolean" ? Boolean : String, - })); -} - -interface ColumnDefinition { - name: string; - padding?: { left: string; right: string }; - width?: number; -} - -interface TableOptions { - columns: ColumnDefinition[]; -} - -interface UsageSection { - content?: string | string[]; - header?: string; - hide?: string[]; - optionList?: OptionDefinition[]; - tableOptions?: TableOptions; -} - -const tableOptionsForOptions: TableOptions = { - columns: [ - { - name: "option", - width: 60, - }, - { - name: "description", - width: 60, - }, - ], -}; - -function makeSectionsBeforeRenderers( - targetLanguages: readonly TargetLanguage[], -): UsageSection[] { - const langDisplayNames = targetLanguages - .map((r) => r.displayName) - .join(", "); - - return [ - { - header: "Synopsis", - content: [ - `$ quicktype [${chalk.bold("--lang")} LANG] [${chalk.bold("--src-lang")} SRC_LANG] [${chalk.bold( - "--out", - )} FILE] FILE|URL ...`, - "", - ` LANG ... ${makeLangTypeLabel(targetLanguages)}`, - "", - "SRC_LANG ... json|schema|graphql|postman|typescript", - ], - }, - { - header: "Description", - content: `Given JSON sample data, quicktype outputs code for working with that data in ${langDisplayNames}.`, - }, - { - header: "Options", - optionList: optionDefinitionsForUsage( - makeOptionDefinitions(targetLanguages), - ), - hide: ["no-render", "build-markov-chain"], - tableOptions: tableOptionsForOptions, - }, - ]; -} - -const sectionsAfterRenderers: UsageSection[] = [ - { - header: "Examples", - content: [ - chalk.dim("Generate C# to parse a Bitcoin API"), - "$ quicktype -o LatestBlock.cs https://blockchain.info/latestblock", - "", - chalk.dim( - "Generate Go code from a directory of samples containing:", - ), - chalk.dim( - ` - Foo.json - + Bar - - bar-sample-1.json - - bar-sample-2.json - - Baz.url`, - ), - "$ quicktype -l go samples", - "", - chalk.dim("Generate JSON Schema, then TypeScript"), - "$ quicktype -o schema.json https://blockchain.info/latestblock", - "$ quicktype -o bitcoin.ts --src-lang schema schema.json", - ], - }, - { - content: `Learn more at ${chalk.bold("quicktype.io")}`, - }, -]; - -export function parseCLIOptions( - argv: string[], - targetLanguage?: TargetLanguage, -): CLIOptions { - if (argv.length === 0) { - return inferCLIOptions({ help: true }, targetLanguage); - } - - const targetLanguages = - targetLanguage === undefined - ? defaultTargetLanguages - : [targetLanguage]; - const optionDefinitions = makeOptionDefinitions(targetLanguages); - - // We can only fully parse the options once we know which renderer is selected, - // because there are renderer-specific options. But we only know which renderer - // is selected after we've parsed the options. Hence, we parse the options - // twice. This is the first parse to get the renderer: - const incompleteOptions = inferCLIOptions( - parseOptions(optionDefinitions, argv, true), - targetLanguage, - ); - if (targetLanguage === undefined) { - const languageName = isLanguageName(incompleteOptions.lang) - ? incompleteOptions.lang - : "typescript"; - targetLanguage = getTargetLanguage(languageName); - } - - const rendererOptionDefinitions = - targetLanguage.cliOptionDefinitions.actual; - // Use the global options as well as the renderer options from now on: - const allOptionDefinitions = _.concat( - optionDefinitions, - rendererOptionDefinitions, - ); - // This is the parse that counts: - return inferCLIOptions( - parseOptions(allOptionDefinitions, argv, false), - targetLanguage, - ); -} - -// Parse the options in argv and split them into global options and renderer options, -// according to each option definition's `renderer` field. If `partial` is false this -// will throw if it encounters an unknown option. -function parseOptions( - definitions: OptionDefinition[], - argv: string[], - partial: boolean, -): Partial { - let opts: commandLineArgs.CommandLineOptions; - try { - opts = commandLineArgs( - definitions.map((def) => ({ - ...def, - type: def.optionType === "boolean" ? Boolean : String, - })), - { argv, partial }, - ); - } catch (e) { - return messageError("DriverCLIOptionParsingFailed", { - message: exceptionToString(e), - }); - } - - for (const k of Object.keys(opts)) { - if (opts[k] === null) { - return messageError("DriverCLIOptionParsingFailed", { - message: `Missing value for command line option "${k}"`, - }); - } - } - - const options: { - [key: string]: unknown; - rendererOptions: RendererOptions; - } = { rendererOptions: {} }; - for (const optionDefinition of definitions) { - if (!hasOwnProperty(opts, optionDefinition.name)) { - continue; - } - - const optionValue = opts[optionDefinition.name] as string; - if (optionDefinition.kind !== "cli") { - ( - options.rendererOptions as Record< - typeof optionDefinition.name, - unknown - > - )[optionDefinition.name] = optionValue; - } else { - const k = _.lowerFirst( - optionDefinition.name.split("-").map(_.upperFirst).join(""), - ); - options[k] = optionValue; - } - } - - return options; -} - -function usage(targetLanguages: readonly TargetLanguage[]): void { - const rendererSections: UsageSection[] = []; - - for (const language of targetLanguages) { - const definitions = language.cliOptionDefinitions.display; - if (definitions.length === 0) continue; - - rendererSections.push({ - header: `Options for ${language.displayName}`, - optionList: optionDefinitionsForUsage(definitions), - tableOptions: tableOptionsForOptions, - }); - } - - const sections = _.concat( - makeSectionsBeforeRenderers(targetLanguages), - rendererSections, - sectionsAfterRenderers, - ); - - console.log(getUsage(sections)); -} - -// Returns an array of [name, sourceURIs] pairs. -async function getSourceURIs( - options: CLIOptions, -): Promise> { - if (options.srcUrls !== undefined) { - const json = parseJSON( - await readFromFileOrURL(options.srcUrls, options.httpHeader), - "URL grammar", - options.srcUrls, - ); - const jsonMap = urlsFromURLGrammar(json); - const topLevels = Object.getOwnPropertyNames(jsonMap); - return topLevels.map( - (name) => [name, jsonMap[name]] as [string, string[]], - ); - } - if (options.src.length === 0) { - return [[options.topLevel, ["-"]]]; - } - - return []; -} - -async function typeSourcesForURIs( - name: string, - uris: string[], - options: CLIOptions, -): Promise { - switch (options.srcLang) { - case "json": - return [ - await sourceFromFileOrUrlArray(name, uris, options.httpHeader), - ]; - case "schema": - return uris.map( - (uri) => - ({ kind: "schema", name, uris: [uri] }) as SchemaTypeSource, - ); - default: - return panic( - `typeSourceForURIs must not be called for source language ${options.srcLang}`, - ); - } -} - -async function getSources(options: CLIOptions): Promise { - const sourceURIs = await getSourceURIs(options); - const sourceArrays = await Promise.all( - sourceURIs.map( - async ([name, uris]) => - await typeSourcesForURIs(name, uris, options), - ), - ); - let sources: TypeSource[] = ([] as TypeSource[]).concat(...sourceArrays); - - const exists = options.src.filter(fs.existsSync); - const directories = exists.filter((x) => fs.lstatSync(x).isDirectory()); - - for (const dataDir of directories) { - sources = sources.concat( - await samplesFromDirectory(dataDir, options.httpHeader), - ); - } - - // Every src that's not a directory is assumed to be a file or URL - const filesOrUrls = options.src.filter((x) => !_.includes(directories, x)); - if (!_.isEmpty(filesOrUrls)) { - sources.push( - ...(await typeSourcesForURIs( - options.topLevel, - filesOrUrls, - options, - )), - ); - } - - return sources; -} - -function makeTypeScriptSource(fileNames: string[]): SchemaTypeSource { - return { - kind: "schema", - ...schemaForTypeScriptSources(fileNames), - } as SchemaTypeSource; -} - -export function jsonInputForTargetLanguage( - targetLanguage: string | TargetLanguage, - languages?: TargetLanguage[], - handleJSONRefs = false, - rendererOptions: Record = {}, -): JSONInput { - if (typeof targetLanguage === "string") { - const languageName = isLanguageName(targetLanguage) - ? targetLanguage - : "typescript"; - targetLanguage = defined(languageNamed(languageName, languages)); - } - - const compressedJSON = new CompressedJSONFromStream( - targetLanguage.dateTimeRecognizer, - handleJSONRefs, - targetLanguage.getSupportedIntegerRange(rendererOptions), - ); - return new JSONInput(compressedJSON); -} - -async function makeInputData( - sources: TypeSource[], - targetLanguage: TargetLanguage, - additionalSchemaAddresses: readonly string[], - handleJSONRefs: boolean, - rendererOptions: Record, - httpHeaders?: string[], -): Promise { - const inputData = new InputData(); - - for (const source of sources) { - switch (source.kind) { - case "graphql": - await inputData.addSource( - "graphql", - source, - () => new GraphQLInput(), - ); - break; - case "json": - await inputData.addSource("json", source, () => - jsonInputForTargetLanguage( - targetLanguage, - undefined, - handleJSONRefs, - rendererOptions, - ), - ); - break; - case "schema": - await inputData.addSource( - "schema", - source, - () => - new JSONSchemaInput( - new FetchingJSONSchemaStore(httpHeaders), - [], - additionalSchemaAddresses, - ), - ); - break; - default: - return assertNever(source); - } - } - - return inputData; -} - -function stringSourceDataToStreamSourceData( - src: JSONSourceData, -): JSONSourceData { - return { - name: src.name, - description: src.description, - samples: src.samples.map( - (sample) => stringToStream(sample) as Readable, - ), - }; -} - -export async function makeQuicktypeOptions( - options: CLIOptions, - targetLanguages?: TargetLanguage[], -): Promise | undefined> { - if (options.help) { - usage(targetLanguages ?? defaultTargetLanguages); - return undefined; - } - - if (options.version) { - console.log(`quicktype version ${packageJSON.version}`); - console.log("Visit quicktype.io for more info."); - return undefined; - } - - if (options.buildMarkovChain !== undefined) { - const contents = fs.readFileSync(options.buildMarkovChain).toString(); - const lines = contents.split("\n"); - const mc = trainMarkovChain(lines, 3); - console.log(JSON.stringify(mc)); - return undefined; - } - - let sources: TypeSource[] = []; - let leadingComments: string[] | undefined; - let fixedTopLevels = false; - switch (options.srcLang) { - case "graphql": { - let schemaString: string | undefined; - let wroteSchemaToFile = false; - if (options.graphqlIntrospect !== undefined) { - schemaString = await introspectServer( - options.graphqlIntrospect, - withDefault(options.httpMethod, "POST"), - withDefault(options.httpHeader, []), - ); - if (options.graphqlSchema !== undefined) { - fs.writeFileSync(options.graphqlSchema, schemaString); - wroteSchemaToFile = true; - } - } - - const numSources = options.src.length; - if (numSources !== 1) { - if (wroteSchemaToFile) { - // We're done. - return undefined; - } - - if (numSources === 0) { - if (schemaString !== undefined) { - console.log(schemaString); - return undefined; - } - - return messageError("DriverNoGraphQLQueryGiven", {}); - } - } - - const gqlSources: GraphQLTypeSource[] = []; - for (const queryFile of options.src) { - let schemaFileName: string | undefined; - if (schemaString === undefined) { - schemaFileName = defined(options.graphqlSchema); - schemaString = fs.readFileSync(schemaFileName, "utf8"); - } - - const schema = parseJSON( - schemaString, - "GraphQL schema", - schemaFileName, - ); - const query = await getStream( - await readableFromFileOrURL(queryFile, options.httpHeader), - ); - const name = - numSources === 1 - ? options.topLevel - : typeNameFromFilename(queryFile); - gqlSources.push({ kind: "graphql", name, schema, query }); - } - - sources = gqlSources; - break; - } - case "json": - case "schema": - sources = await getSources(options); - break; - case "typescript": - sources = [makeTypeScriptSource(options.src)]; - break; - case "postman": - for (const collectionFile of options.src) { - const collectionJSON = fs.readFileSync(collectionFile, "utf8"); - const { sources: postmanSources, description } = - sourcesFromPostmanCollection( - collectionJSON, - collectionFile, - ); - for (const src of postmanSources) { - sources.push({ - kind: "json", - ...stringSourceDataToStreamSourceData(src), - } as JSONTypeSource); - } - - if (postmanSources.length > 1) { - fixedTopLevels = true; - } - - if (description !== undefined) { - leadingComments = wordWrap(description).split("\n"); - } - } - - break; - default: - return messageError("DriverUnknownSourceLanguage", { - lang: options.srcLang, - }); - } - - const components = definedMap(options.debug, (d) => d.split(",")); - const debugAll = components?.includes("all"); - let debugPrintGraph = debugAll; - let checkProvenance = debugAll; - let debugPrintReconstitution = debugAll; - let debugPrintGatherNames = debugAll; - let debugPrintTransformations = debugAll; - let debugPrintSchemaResolving = debugAll; - let debugPrintTimes = debugAll; - if (components !== undefined) { - for (let component of components) { - component = component.trim(); - if (component === "print-graph") { - debugPrintGraph = true; - } else if (component === "print-reconstitution") { - debugPrintReconstitution = true; - } else if (component === "print-gather-names") { - debugPrintGatherNames = true; - } else if (component === "print-transformations") { - debugPrintTransformations = true; - } else if (component === "print-times") { - debugPrintTimes = true; - } else if (component === "print-schema-resolving") { - debugPrintSchemaResolving = true; - } else if (component === "provenance") { - checkProvenance = true; - } else if (component !== "all") { - return messageError("DriverUnknownDebugOption", { - option: component, - }); - } - } - } - - if (!isLanguageName(options.lang)) { - return messageError("DriverUnknownOutputLanguage", { - lang: options.lang, - }); - } - const lang = languageNamed(options.lang, targetLanguages); - - const quicktypeOptions: Partial = { - lang, - alphabetizeProperties: options.alphabetizeProperties, - allPropertiesOptional: options.allPropertiesOptional, - fixedTopLevels, - noRender: options.noRender, - rendererOptions: options.rendererOptions, - leadingComments, - outputFilename: definedMap(options.out, path.basename), - debugPrintGraph, - checkProvenance, - debugPrintReconstitution, - debugPrintGatherNames, - debugPrintTransformations, - debugPrintSchemaResolving, - debugPrintTimes, - }; - for (const flagName of inferenceFlagNames) { - const cliName = negatedInferenceFlagName(flagName); - const v = options[cliName]; - if (typeof v === "boolean") { - quicktypeOptions[flagName] = !v; - } else { - quicktypeOptions[flagName] = true; - } - } - - quicktypeOptions.inputData = await makeInputData( - sources, - lang, - options.additionalSchema, - quicktypeOptions.ignoreJsonRefs !== true, - options.rendererOptions, - options.httpHeader, - ); - - return quicktypeOptions; -} +import { parseCLIOptions } from "./cli.options"; +import { inferCLIOptions } from "./inference"; +import { makeQuicktypeOptions } from "./quicktype.options"; +import type { CLIOptions } from "./CLIOptions.types"; +export { parseCLIOptions } from "./cli.options"; +export { jsonInputForTargetLanguage } from "./input"; +export { makeQuicktypeOptions } from "./quicktype.options"; +export type { CLIOptions }; export function writeOutput( cliOptions: CLIOptions, resultsByFilename: ReadonlyMap, ): void { - let onFirst = true; + let isFirstRun = true; for (const [filename, { lines, annotations }] of resultsByFilename) { const output = lines.join("\n"); @@ -1201,7 +34,7 @@ export function writeOutput( output, ); } else { - if (!onFirst) { + if (!isFirstRun) { process.stdout.write("\n"); } @@ -1216,10 +49,13 @@ export function writeOutput( continue; } - for (const sa of annotations) { - const annotation = sa.annotation; - if (!(annotation instanceof IssueAnnotationData)) continue; - const lineNumber = sa.span.start.line; + for (const sourceAnnotation of annotations) { + const annotation = sourceAnnotation.annotation; + if (!(annotation instanceof IssueAnnotationData)) { + continue; + } + + const lineNumber = sourceAnnotation.span.start.line; const humanLineNumber = lineNumber + 1; console.error( `\nIssue in line ${humanLineNumber}: ${annotation.message}`, @@ -1227,7 +63,7 @@ export function writeOutput( console.error(`${humanLineNumber}: ${lines[lineNumber]}`); } - onFirst = false; + isFirstRun = false; } } diff --git a/src/inference.ts b/src/inference.ts new file mode 100644 index 0000000000..67fff88f5c --- /dev/null +++ b/src/inference.ts @@ -0,0 +1,134 @@ +import * as path from "node:path"; + +import { withDefault } from "collection-utils"; + +import { + type LanguageName, + type TargetLanguage, + inferenceFlagNames, + isLanguageName, + languageNamed, + messageAssert, + messageError, +} from "quicktype-core"; + +import { negatedInferenceFlagName } from "./utils"; +import type { CLIOptions } from "./CLIOptions.types"; + +const defaultTargetLanguageName = "go"; + +function inferLang( + options: Partial, + defaultLanguage: LanguageName, +): string | LanguageName { + // Output file extension determines the language if language is undefined + if (options.out !== undefined) { + const extension = path.extname(options.out); + if (extension === "") { + return messageError("DriverNoLanguageOrExtension", {}); + } + + return extension.slice(1); + } + + return defaultLanguage; +} + +function inferTopLevel(options: Partial): string { + // Output file name determines the top-level if undefined + if (options.out !== undefined) { + const extension = path.extname(options.out); + const without = path.basename(options.out).replace(extension, ""); + return without; + } + + // Source determines the top-level if undefined + if (options.src !== undefined && options.src.length === 1) { + const src = options.src[0]; + const extension = path.extname(src); + const without = path.basename(src).replace(extension, ""); + return without; + } + + return "TopLevel"; +} + +export function inferCLIOptions( + opts: Partial, + targetLanguage: TargetLanguage | undefined, +): CLIOptions { + let srcLang = opts.srcLang; + if ( + opts.graphqlSchema !== undefined || + opts.graphqlIntrospect !== undefined + ) { + messageAssert( + srcLang === undefined || srcLang === "graphql", + "DriverSourceLangMustBeGraphQL", + {}, + ); + srcLang = "graphql"; + } else if ( + opts.src !== undefined && + opts.src.length > 0 && + opts.src.every((file) => file.endsWith(".ts")) + ) { + srcLang = "typescript"; + } else { + messageAssert(srcLang !== "graphql", "DriverGraphQLSchemaNeeded", {}); + srcLang = withDefault(srcLang, "json"); + } + + let language: TargetLanguage; + if (targetLanguage !== undefined) { + language = targetLanguage; + } else { + const languageName = + opts.lang ?? inferLang(opts, defaultTargetLanguageName); + + if (isLanguageName(languageName)) { + language = languageNamed(languageName); + } else { + return messageError("DriverUnknownOutputLanguage", { + lang: languageName, + }); + } + } + + const options: CLIOptions = { + src: opts.src ?? [], + srcUrls: opts.srcUrls, + srcLang, + lang: language.name as LanguageName, + topLevel: opts.topLevel ?? inferTopLevel(opts), + noRender: !!opts.noRender, + alphabetizeProperties: !!opts.alphabetizeProperties, + allPropertiesOptional: !!opts.allPropertiesOptional, + rendererOptions: opts.rendererOptions ?? {}, + help: opts.help ?? false, + quiet: opts.quiet ?? false, + version: opts.version ?? false, + out: opts.out, + buildMarkovChain: opts.buildMarkovChain, + additionalSchema: opts.additionalSchema ?? [], + graphqlSchema: opts.graphqlSchema, + graphqlIntrospect: opts.graphqlIntrospect, + httpMethod: opts.httpMethod, + httpHeader: opts.httpHeader, + debug: opts.debug, + telemetry: opts.telemetry, + }; + for (const flagName of inferenceFlagNames) { + const cliName = negatedInferenceFlagName(flagName); + const positiveValue = opts[flagName]; + if (typeof positiveValue === "boolean") { + options[flagName] = positiveValue; + options[cliName] = !positiveValue; + } else { + options[cliName] = !!opts[cliName]; + options[flagName] = !options[cliName]; + } + } + + return options; +} diff --git a/src/input.ts b/src/input.ts new file mode 100644 index 0000000000..8fd6debaf7 --- /dev/null +++ b/src/input.ts @@ -0,0 +1,90 @@ +import type { Readable } from "readable-stream"; + +import { + FetchingJSONSchemaStore, + InputData, + JSONInput, + JSONSchemaInput, + type TargetLanguage, + assertNever, + defined, + isLanguageName, + languageNamed, +} from "quicktype-core"; +import { GraphQLInput } from "quicktype-graphql-input"; + +import { CompressedJSONFromStream } from "./CompressedJSONFromStream"; +import type { TypeSource } from "./TypeSource"; + +export function jsonInputForTargetLanguage( + _targetLanguage: string | TargetLanguage, + languages?: TargetLanguage[], + handleJSONRefs = false, + rendererOptions: Record = {}, +): JSONInput { + let targetLanguage: TargetLanguage; + if (typeof _targetLanguage === "string") { + const languageName = isLanguageName(_targetLanguage) + ? _targetLanguage + : "typescript"; + targetLanguage = defined(languageNamed(languageName, languages)); + } else { + targetLanguage = _targetLanguage; + } + + const compressedJSON = new CompressedJSONFromStream( + targetLanguage.dateTimeRecognizer, + handleJSONRefs, + targetLanguage.getSupportedIntegerRange(rendererOptions), + ); + return new JSONInput(compressedJSON); +} + +export async function makeInputData( + sources: TypeSource[], + targetLanguage: TargetLanguage, + additionalSchemaAddresses: readonly string[], + handleJSONRefs: boolean, + rendererOptions: Record, + httpHeaders?: string[], +): Promise { + const inputData = new InputData(); + + for (const source of sources) { + switch (source.kind) { + case "graphql": + await inputData.addSource( + "graphql", + source, + () => new GraphQLInput(), + ); + break; + case "json": + await inputData.addSource("json", source, () => + jsonInputForTargetLanguage( + targetLanguage, + undefined, + handleJSONRefs, + rendererOptions, + ), + ); + break; + case "schema": + await inputData.addSource( + "schema", + source, + () => + new JSONSchemaInput( + new FetchingJSONSchemaStore(httpHeaders), + [], + additionalSchemaAddresses, + ), + ); + break; + default: + return assertNever(source); + } + } + + return inputData; +} diff --git a/src/optionDefinitions.ts b/src/optionDefinitions.ts new file mode 100644 index 0000000000..3f6590a75c --- /dev/null +++ b/src/optionDefinitions.ts @@ -0,0 +1,189 @@ +import { + type InferenceFlagName, + type OptionDefinition, + type TargetLanguage, + inferenceFlags, +} from "quicktype-core"; + +import { dashedFromCamelCase, negatedInferenceFlagName } from "./utils"; + +export function makeOptionDefinitions( + targetLanguages: readonly TargetLanguage[], +): OptionDefinition[] { + const beforeLang: OptionDefinition[] = [ + { + name: "out", + alias: "o", + optionType: "string", + typeLabel: "FILE", + description: "The output file. Determines --lang and --top-level.", + kind: "cli", + }, + { + name: "top-level", + alias: "t", + optionType: "string", + typeLabel: "NAME", + description: "The name for the top level type.", + kind: "cli", + }, + ]; + const lang: OptionDefinition[] = + targetLanguages.length < 2 + ? [] + : [ + { + name: "lang", + alias: "l", + optionType: "string", + typeLabel: "LANG", + description: "The target language.", + kind: "cli", + }, + ]; + const afterLang: OptionDefinition[] = [ + { + name: "src-lang", + alias: "s", + optionType: "string", + defaultValue: undefined, + typeLabel: "SRC_LANG", + description: "The source language (default is json).", + kind: "cli", + }, + { + name: "src", + optionType: "string", + multiple: true, + typeLabel: "FILE|URL|DIRECTORY", + description: "The file, url, or data directory to type.", + kind: "cli", + defaultOption: true, + }, + { + name: "src-urls", + optionType: "string", + typeLabel: "FILE", + description: "Tracery grammar describing URLs to crawl.", + kind: "cli", + }, + ]; + const inference: OptionDefinition[] = Object.entries(inferenceFlags).map( + ([name, flag]) => ({ + name: dashedFromCamelCase( + negatedInferenceFlagName(name as InferenceFlagName), + ), + optionType: "boolean" as const, + description: `${flag.negationDescription}.`, + kind: "cli" as const, + }), + ); + const afterInference: OptionDefinition[] = [ + { + name: "graphql-schema", + optionType: "string", + typeLabel: "FILE", + description: "GraphQL introspection file.", + kind: "cli", + }, + { + name: "graphql-introspect", + optionType: "string", + typeLabel: "URL", + description: "Introspect GraphQL schema from a server.", + kind: "cli", + }, + { + name: "http-method", + optionType: "string", + typeLabel: "METHOD", + description: + "HTTP method to use for the GraphQL introspection query.", + kind: "cli", + }, + { + name: "http-header", + optionType: "string", + multiple: true, + typeLabel: "HEADER", + description: + "Header(s) to attach to all HTTP requests, including the GraphQL introspection query.", + kind: "cli", + }, + { + name: "additional-schema", + alias: "S", + optionType: "string", + multiple: true, + typeLabel: "FILE", + description: "Register the $id's of additional JSON Schema files.", + kind: "cli", + }, + { + name: "no-render", + optionType: "boolean", + description: "Don't render output.", + kind: "cli", + }, + { + name: "alphabetize-properties", + optionType: "boolean", + description: "Alphabetize order of class properties.", + kind: "cli", + }, + { + name: "all-properties-optional", + optionType: "boolean", + description: "Make all class properties optional.", + kind: "cli", + }, + { + name: "build-markov-chain", + optionType: "string", + typeLabel: "FILE", + description: "Markov chain corpus filename.", + kind: "cli", + }, + { + name: "quiet", + optionType: "boolean", + description: "Don't show issues in the generated code.", + kind: "cli", + }, + { + name: "debug", + optionType: "string", + typeLabel: "OPTIONS or all", + description: + "Comma separated debug options: print-graph, print-reconstitution, print-gather-names, print-transformations, print-schema-resolving, print-times, provenance", + kind: "cli", + }, + { + name: "telemetry", + optionType: "string", + typeLabel: "enable|disable", + description: "Enable anonymous telemetry to help improve quicktype", + kind: "cli", + }, + { + name: "help", + alias: "h", + optionType: "boolean", + description: "Get some help.", + kind: "cli", + }, + { + name: "version", + alias: "v", + optionType: "boolean", + description: "Display the version of quicktype", + kind: "cli", + }, + ]; + return beforeLang.concat(lang, afterLang, inference, afterInference); +} + +export const transformDefinition = (def: OptionDefinition) => ({ + ...def, + type: def.optionType === "boolean" ? Boolean : String, +}); diff --git a/src/quicktype.options.ts b/src/quicktype.options.ts new file mode 100644 index 0000000000..ee6e60d8d3 --- /dev/null +++ b/src/quicktype.options.ts @@ -0,0 +1,250 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +import { definedMap, withDefault } from "collection-utils"; +import _wordwrap from "wordwrap"; + +import { + type Options, + type TargetLanguage, + defaultTargetLanguages, + defined, + getStream, + inferenceFlagNames, + isLanguageName, + languageNamed, + messageError, + parseJSON, + readableFromFileOrURL, + sourcesFromPostmanCollection, + trainMarkovChain, +} from "quicktype-core"; + +import { introspectServer } from "./GraphQLIntrospection"; +import type { + GraphQLTypeSource, + JSONTypeSource, + TypeSource, +} from "./TypeSource"; +import { makeInputData } from "./input"; +import { getSources, makeTypeScriptSource } from "./sources"; +import { displayUsage } from "./usage"; +import { + negatedInferenceFlagName, + stringSourceDataToStreamSourceData, + typeNameFromFilename, +} from "./utils"; +import type { CLIOptions } from "./CLIOptions.types"; + +const packageJSON = require("../package.json"); + +const wordWrap: (s: string) => string = _wordwrap(90); + +export async function makeQuicktypeOptions( + options: CLIOptions, + targetLanguages?: TargetLanguage[], +): Promise | undefined> { + if (options.help) { + displayUsage(targetLanguages ?? defaultTargetLanguages); + return; + } + + if (options.version) { + console.log(`quicktype version ${packageJSON.version}`); + console.log("Visit quicktype.io for more info."); + return; + } + + if (options.buildMarkovChain !== undefined) { + const contents = fs.readFileSync(options.buildMarkovChain).toString(); + const lines = contents.split("\n"); + const markovChain = trainMarkovChain(lines, 3); + console.log(JSON.stringify(markovChain)); + return; + } + + let sources: TypeSource[] = []; + let leadingComments: string[] | undefined; + let fixedTopLevels = false; + switch (options.srcLang) { + case "graphql": { + let schemaString: string | undefined; + let wroteSchemaToFile = false; + if (options.graphqlIntrospect !== undefined) { + schemaString = await introspectServer( + options.graphqlIntrospect, + withDefault(options.httpMethod, "POST"), + withDefault(options.httpHeader, []), + ); + if (options.graphqlSchema !== undefined) { + fs.writeFileSync(options.graphqlSchema, schemaString); + wroteSchemaToFile = true; + } + } + + const numSources = options.src.length; + if (numSources !== 1) { + if (wroteSchemaToFile) { + // We're done. + return; + } + + if (numSources === 0) { + if (schemaString !== undefined) { + console.log(schemaString); + return; + } + + return messageError("DriverNoGraphQLQueryGiven", {}); + } + } + + const graphqlSources: GraphQLTypeSource[] = []; + for (const queryFile of options.src) { + let schemaFileName: string | undefined; + if (schemaString === undefined) { + schemaFileName = defined(options.graphqlSchema); + schemaString = fs.readFileSync(schemaFileName, "utf8"); + } + + const schema = parseJSON( + schemaString, + "GraphQL schema", + schemaFileName, + ); + const query = await getStream( + await readableFromFileOrURL(queryFile, options.httpHeader), + ); + const name = + numSources === 1 + ? options.topLevel + : typeNameFromFilename(queryFile); + graphqlSources.push({ kind: "graphql", name, schema, query }); + } + + sources = graphqlSources; + break; + } + case "json": + case "schema": + sources = await getSources(options); + break; + case "typescript": + sources = [makeTypeScriptSource(options.src)]; + break; + case "postman": + for (const collectionFile of options.src) { + const collectionJson = fs.readFileSync(collectionFile, "utf8"); + const { sources: postmanSources, description } = + sourcesFromPostmanCollection( + collectionJson, + collectionFile, + ); + for (const src of postmanSources) { + sources.push({ + kind: "json", + ...stringSourceDataToStreamSourceData(src), + } as JSONTypeSource); + } + + if (postmanSources.length > 1) { + fixedTopLevels = true; + } + + if (description !== undefined) { + leadingComments = wordWrap(description).split("\n"); + } + } + + break; + default: + return messageError("DriverUnknownSourceLanguage", { + lang: options.srcLang, + }); + } + + const components = definedMap(options.debug, (d) => d.split(",")); + const debugAll = components?.includes("all"); + let debugPrintGraph = debugAll; + let checkProvenance = debugAll; + let debugPrintReconstitution = debugAll; + let debugPrintGatherNames = debugAll; + let debugPrintTransformations = debugAll; + let debugPrintSchemaResolving = debugAll; + let debugPrintTimes = debugAll; + if (components !== undefined) { + for (let component of components) { + component = component.trim(); + if (component === "print-graph") { + debugPrintGraph = true; + } else if (component === "print-reconstitution") { + debugPrintReconstitution = true; + } else if (component === "print-gather-names") { + debugPrintGatherNames = true; + } else if (component === "print-transformations") { + debugPrintTransformations = true; + } else if (component === "print-times") { + debugPrintTimes = true; + } else if (component === "print-schema-resolving") { + debugPrintSchemaResolving = true; + } else if (component === "provenance") { + checkProvenance = true; + } else if (component !== "all") { + return messageError("DriverUnknownDebugOption", { + option: component, + }); + } + } + } + + if (!isLanguageName(options.lang)) { + return messageError("DriverUnknownOutputLanguage", { + lang: options.lang, + }); + } + + const lang = languageNamed(options.lang, targetLanguages); + + const quicktypeOptions: Partial = { + lang, + alphabetizeProperties: options.alphabetizeProperties, + allPropertiesOptional: options.allPropertiesOptional, + fixedTopLevels, + noRender: options.noRender, + rendererOptions: options.rendererOptions, + leadingComments, + outputFilename: definedMap(options.out, path.basename), + debugPrintGraph, + checkProvenance, + debugPrintReconstitution, + debugPrintGatherNames, + debugPrintTransformations, + debugPrintSchemaResolving, + debugPrintTimes, + }; + + for (const flagName of inferenceFlagNames) { + const cliName = negatedInferenceFlagName(flagName); + const negatedValue = options[cliName]; + const positiveValue = options[flagName]; + + if (typeof positiveValue === "boolean") { + quicktypeOptions[flagName] = positiveValue; + } else if (typeof negatedValue === "boolean") { + quicktypeOptions[flagName] = !negatedValue; + } else { + quicktypeOptions[flagName] = true; + } + } + + quicktypeOptions.inputData = await makeInputData( + sources, + lang, + options.additionalSchema, + quicktypeOptions.ignoreJsonRefs !== true, + options.rendererOptions, + options.httpHeader, + ); + + return quicktypeOptions; +} diff --git a/src/sources.ts b/src/sources.ts new file mode 100644 index 0000000000..46b960f2fa --- /dev/null +++ b/src/sources.ts @@ -0,0 +1,279 @@ +#!/usr/bin/env node + +import * as fs from "node:fs"; +import * as path from "node:path"; + +import * as _ from "lodash"; +import type { Readable } from "readable-stream"; + +import { + assertNever, + getStream, + messageAssert, + messageError, + panic, + parseJSON, + readFromFileOrURL, + readableFromFileOrURL, +} from "quicktype-core"; +import { schemaForTypeScriptSources } from "quicktype-typescript-input"; + +import type { + GraphQLTypeSource, + JSONTypeSource, + SchemaTypeSource, + TypeSource, +} from "./TypeSource"; +import { urlsFromURLGrammar } from "./URLGrammar"; +import { typeNameFromFilename } from "./utils"; +import type { CLIOptions } from "./CLIOptions.types"; + +async function sourceFromFileOrUrlArray( + name: string, + filesOrUrls: string[], + httpHeaders?: string[], +): Promise { + const samples = await Promise.all( + filesOrUrls.map( + async (file) => await readableFromFileOrURL(file, httpHeaders), + ), + ); + return { kind: "json", name, samples }; +} + +async function samplesFromDirectory( + dataDir: string, + httpHeaders?: string[], +): Promise { + async function readFilesOrURLsInDirectory( + d: string, + ): Promise { + const files = fs + .readdirSync(d) + .map((x) => path.join(d, x)) + .filter((x) => fs.lstatSync(x).isFile()); + // Each file is a (Name, JSON | URL) + const sourcesInDir: TypeSource[] = []; + const graphQLSources: GraphQLTypeSource[] = []; + let graphQLSchema: Readable | undefined; + let graphQLSchemaFileName: string | undefined; + for (let file of files) { + const name = typeNameFromFilename(file); + + let fileOrUrl = file; + file = file.toLowerCase(); + + // If file is a URL string, download it + if (file.endsWith(".url")) { + fileOrUrl = fs.readFileSync(file, "utf8").trim(); + } + + if (file.endsWith(".url") || file.endsWith(".json")) { + sourcesInDir.push({ + kind: "json", + name, + samples: [ + await readableFromFileOrURL(fileOrUrl, httpHeaders), + ], + }); + } else if (file.endsWith(".schema")) { + sourcesInDir.push({ + kind: "schema", + name, + uris: [fileOrUrl], + }); + } else if (file.endsWith(".gqlschema")) { + messageAssert( + graphQLSchema === undefined, + "DriverMoreThanOneGraphQLSchemaInDir", + { + dir: dataDir, + }, + ); + graphQLSchema = await readableFromFileOrURL( + fileOrUrl, + httpHeaders, + ); + graphQLSchemaFileName = fileOrUrl; + } else if (file.endsWith(".graphql")) { + graphQLSources.push({ + kind: "graphql", + name, + schema: undefined, + query: await getStream( + await readableFromFileOrURL(fileOrUrl, httpHeaders), + ), + }); + } + } + + if (graphQLSources.length > 0) { + if (graphQLSchema === undefined) { + return messageError("DriverNoGraphQLSchemaInDir", { + dir: dataDir, + }); + } + + const schema = parseJSON( + await getStream(graphQLSchema), + "GraphQL schema", + graphQLSchemaFileName, + ); + for (const source of graphQLSources) { + source.schema = schema; + sourcesInDir.push(source); + } + } + + return sourcesInDir; + } + + const contents = fs.readdirSync(dataDir).map((x) => path.join(dataDir, x)); + const directories = contents.filter((x) => fs.lstatSync(x).isDirectory()); + + let sources = await readFilesOrURLsInDirectory(dataDir); + + for (const dir of directories) { + let jsonSamples: Readable[] = []; + const schemaSources: SchemaTypeSource[] = []; + const graphQLSources: GraphQLTypeSource[] = []; + + for (const source of await readFilesOrURLsInDirectory(dir)) { + switch (source.kind) { + case "json": + jsonSamples = jsonSamples.concat(source.samples); + break; + case "schema": + schemaSources.push(source); + break; + case "graphql": + graphQLSources.push(source); + break; + default: + return assertNever(source); + } + } + + if ( + jsonSamples.length > 0 && + schemaSources.length + graphQLSources.length > 0 + ) { + return messageError("DriverCannotMixJSONWithOtherSamples", { + dir, + }); + } + + // FIXME: rewrite this to be clearer + const oneUnlessEmpty = (xs: TypeSource[]): 0 | 1 => + Math.sign(xs.length) as 0 | 1; + if ( + oneUnlessEmpty(schemaSources) + oneUnlessEmpty(graphQLSources) > + 1 + ) { + return messageError("DriverCannotMixNonJSONInputs", { dir }); + } + + if (jsonSamples.length > 0) { + sources.push({ + kind: "json", + name: path.basename(dir), + samples: jsonSamples, + }); + } + + sources = sources.concat(schemaSources); + sources = sources.concat(graphQLSources); + } + + return sources; +} + +// Returns an array of [name, sourceURIs] pairs. +async function getSourceURIs( + options: CLIOptions, +): Promise> { + if (options.srcUrls !== undefined) { + const json = parseJSON( + await readFromFileOrURL(options.srcUrls, options.httpHeader), + "URL grammar", + options.srcUrls, + ); + const jsonMap = urlsFromURLGrammar(json); + const topLevels = Object.getOwnPropertyNames(jsonMap); + return topLevels.map( + (name) => [name, jsonMap[name]] as [string, string[]], + ); + } + if (options.src.length === 0) { + return [[options.topLevel, ["-"]]]; + } + + return []; +} + +async function typeSourcesForURIs( + name: string, + uris: string[], + options: CLIOptions, +): Promise { + switch (options.srcLang) { + case "json": + return [ + await sourceFromFileOrUrlArray(name, uris, options.httpHeader), + ]; + case "schema": + return uris.map( + (uri) => + ({ + kind: "schema", + name, + uris: [uri], + }) as SchemaTypeSource, + ); + default: + return panic( + `typeSourceForURIs must not be called for source language ${options.srcLang}`, + ); + } +} + +export async function getSources(options: CLIOptions): Promise { + const sourceURIs = await getSourceURIs(options); + const sourceArrays = await Promise.all( + sourceURIs.map( + async ([name, uris]) => + await typeSourcesForURIs(name, uris, options), + ), + ); + let sources: TypeSource[] = ([] as TypeSource[]).concat(...sourceArrays); + + const exists = options.src.filter(fs.existsSync); + const directories = exists.filter((x) => fs.lstatSync(x).isDirectory()); + + for (const dataDir of directories) { + sources = sources.concat( + await samplesFromDirectory(dataDir, options.httpHeader), + ); + } + + // Every src that's not a directory is assumed to be a file or URL + const filesOrUrls = options.src.filter((x) => !_.includes(directories, x)); + if (!_.isEmpty(filesOrUrls)) { + sources.push( + ...(await typeSourcesForURIs( + options.topLevel, + filesOrUrls, + options, + )), + ); + } + + return sources; +} + +export function makeTypeScriptSource(fileNames: string[]): SchemaTypeSource { + return { + kind: "schema", + ...schemaForTypeScriptSources(fileNames), + } as SchemaTypeSource; +} diff --git a/src/usage.ts b/src/usage.ts new file mode 100644 index 0000000000..f5bcbbac13 --- /dev/null +++ b/src/usage.ts @@ -0,0 +1,130 @@ +#!/usr/bin/env node + +import chalk from "chalk"; +import getUsage from "command-line-usage"; +import * as _ from "lodash"; + +import type { OptionDefinition, TargetLanguage } from "quicktype-core"; + +import { + makeOptionDefinitions, + transformDefinition, +} from "./optionDefinitions"; +import { makeLangTypeLabel } from "./utils"; + +interface ColumnDefinition { + name: string; + padding?: { left: string; right: string }; + width?: number; +} + +interface TableOptions { + columns: ColumnDefinition[]; +} + +interface UsageSection { + content?: string | string[]; + header?: string; + hide?: string[]; + optionList?: OptionDefinition[]; + tableOptions?: TableOptions; +} + +const tableOptionsForOptions: TableOptions = { + columns: [ + { + name: "option", + width: 60, + }, + { + name: "description", + width: 60, + }, + ], +}; + +function makeSectionsBeforeRenderers( + targetLanguages: readonly TargetLanguage[], +): UsageSection[] { + const langDisplayNames = targetLanguages + .map((r) => r.displayName) + .join(", "); + + return [ + { + header: "Synopsis", + content: [ + `$ quicktype [${chalk.bold("--lang")} LANG] [${chalk.bold("--src-lang")} SRC_LANG] [${chalk.bold( + "--out", + )} FILE] FILE|URL ...`, + "", + ` LANG ... ${makeLangTypeLabel(targetLanguages)}`, + "", + "SRC_LANG ... json|schema|graphql|postman|typescript", + ], + }, + { + header: "Description", + content: `Given JSON sample data, quicktype outputs code for working with that data in ${langDisplayNames}.`, + }, + { + header: "Options", + optionList: + makeOptionDefinitions(targetLanguages).map(transformDefinition), + hide: ["no-render", "build-markov-chain"], + tableOptions: tableOptionsForOptions, + }, + ]; +} + +const sectionsAfterRenderers: UsageSection[] = [ + { + header: "Examples", + content: [ + chalk.dim("Generate C# to parse a Bitcoin API"), + "$ quicktype -o LatestBlock.cs https://blockchain.info/latestblock", + "", + chalk.dim( + "Generate Go code from a directory of samples containing:", + ), + chalk.dim( + ` - Foo.json + + Bar + - bar-sample-1.json + - bar-sample-2.json + - Baz.url`, + ), + "$ quicktype -l go samples", + "", + chalk.dim("Generate JSON Schema, then TypeScript"), + "$ quicktype -o schema.json https://blockchain.info/latestblock", + "$ quicktype -o bitcoin.ts --src-lang schema schema.json", + ], + }, + { + content: `Learn more at ${chalk.bold("quicktype.io")}`, + }, +]; + +export function displayUsage(targetLanguages: readonly TargetLanguage[]): void { + const rendererSections: UsageSection[] = []; + + for (const language of targetLanguages) { + const definitions = language.cliOptionDefinitions.display; + if (definitions.length === 0) continue; + + rendererSections.push({ + header: `Options for ${language.displayName}`, + optionList: definitions.map(transformDefinition), + tableOptions: tableOptionsForOptions, + }); + } + + const sections = _.concat( + makeSectionsBeforeRenderers(targetLanguages), + rendererSections, + sectionsAfterRenderers, + ); + + console.log(getUsage(sections)); +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000000..beb69984f4 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,61 @@ +import * as path from "node:path"; + +import * as _ from "lodash"; +import type { Readable } from "readable-stream"; +import stringToStream from "string-to-stream"; + +import { + assert, + type InferenceFlagName, + splitIntoWords, + type JSONSourceData, + type TargetLanguage, +} from "quicktype-core"; + +import type { NegatedInferenceFlagName } from "./CLIOptions.types"; + +export function makeLangTypeLabel( + targetLanguages: readonly TargetLanguage[], +): string { + assert( + targetLanguages.length > 0, + "Must have at least one target language", + ); + return targetLanguages + .map((r) => _.minBy(r.names, (s) => s.length)) + .join("|"); +} + +export function negatedInferenceFlagName( + inputName: Input, +): NegatedInferenceFlagName { + const withoutLeadingInfer = inputName.replace(/^infer/, ""); + const asPascalCase = withoutLeadingInfer.replace(/^\w/, (x) => + x.toUpperCase(), + ); + return `no${asPascalCase}` as NegatedInferenceFlagName; +} + +export function dashedFromCamelCase(name: string): string { + return splitIntoWords(name) + .map((w) => w.word.toLowerCase()) + .join("-"); +} + +export function typeNameFromFilename(filename: string): string { + const name = path.basename(filename); + const extIndex = name.lastIndexOf("."); + return extIndex === -1 ? "" : name.slice(0, extIndex); +} + +export function stringSourceDataToStreamSourceData( + src: JSONSourceData, +): JSONSourceData { + return { + name: src.name, + description: src.description, + samples: src.samples.map( + (sample) => stringToStream(sample) as Readable, + ), + }; +} diff --git a/test/buildkite.ts b/test/buildkite.ts index e0d2ac89d3..5869329bf7 100644 --- a/test/buildkite.ts +++ b/test/buildkite.ts @@ -11,10 +11,8 @@ function getChangedFiles(base: string, commit: string): string[] { return diff.trim().split("\n"); } -export function affectedFixtures( - changedFiles: string[] | undefined = undefined, -): Fixture[] { - if (changedFiles === undefined) { +export function affectedFixtures(_changedFiles?: string[]): Fixture[] { + if (_changedFiles === undefined) { const { GITHUB_BASE_REF: base, GITHUB_SHA: commit } = process.env; return commit === undefined ? allFixtures @@ -22,7 +20,9 @@ export function affectedFixtures( } // We can ignore changes in Markdown files - changedFiles = _.reject(changedFiles, (file) => _.endsWith(file, ".md")); + const changedFiles = _.reject(_changedFiles, (file) => + _.endsWith(file, ".md"), + ); // All fixtures are dirty if any changed file is not included as a sourceFile of some fixture. const fileDependencies = _.flatMap( diff --git a/test/unit/cli-options-functions.test.ts b/test/unit/cli-options-functions.test.ts new file mode 100644 index 0000000000..d8cbf53ec8 --- /dev/null +++ b/test/unit/cli-options-functions.test.ts @@ -0,0 +1,176 @@ +import { expect, it, beforeEach } from "vitest"; + +import commandLineArgs from "command-line-args"; + +import { inferenceFlagNames, type Option } from "../../packages/quicktype-core"; +import type { EnumOption } from "../../packages/quicktype-core/dist/RendererOptions"; +import { Chance } from "../../packages/quicktype-core/src/support/Chance"; +import { all as sourceLanguages } from "../../packages/quicktype-core/dist/language/All"; + +import { parseOptions } from "../../src/cli.options"; +import { + makeOptionDefinitions, + transformDefinition, +} from "../../src/optionDefinitions"; +import { negatedInferenceFlagName } from "../../src/utils"; +import { inferCLIOptions } from "../../src/inference"; + +import * as fixtureLanguages from "../languages"; + +const optionsDefinitions = makeOptionDefinitions([]); +const chance = new Chance(0); + +let randomStrings: Record = {}; +let argv: string[] = []; + +beforeEach(() => { + randomStrings = {}; + argv = optionsDefinitions.reduce((strArr, def, i) => { + if (def.optionType === "string") { + const randomString = chance.animal(); + randomStrings[i] = randomString; + + return strArr.concat([`--${def.name}`, randomString]); + } + + return strArr.concat([`--${def.name}`]); + }, [] as string[]); +}); + +it("should call command-line-args for all generic cli options", () => { + const args = commandLineArgs(optionsDefinitions.map(transformDefinition), { + argv, + partial: true, + }); + + const expectedOutput = optionsDefinitions.reduce((acc, def, i) => { + if (def.optionType === "string") { + if (def.multiple) { + acc[def.name] = [randomStrings[i]]; + } else { + acc[def.name] = randomStrings[i]; + } + return acc; + } + + acc[def.name] = true; + return acc; + }, {}); + + expect(args).toStrictEqual(expectedOutput); +}); + +it("should process inference cli options correctly", () => { + const randomTargetLang = chance.pick(sourceLanguages); + + // throw for graphql check + const parsedOptions = parseOptions(optionsDefinitions, argv, true); + expect(() => inferCLIOptions(parsedOptions, randomTargetLang)).toThrow(); + + // pass graphql check + parsedOptions.srcLang = "graphql"; + expect(() => + inferCLIOptions(parsedOptions, randomTargetLang), + ).not.toThrow(); + + // infer ts input + parsedOptions.srcLang = undefined; + parsedOptions.graphqlIntrospect = undefined; + parsedOptions.graphqlSchema = undefined; + parsedOptions.src = parsedOptions.src?.map((str) => `${str}.ts`); + expect(inferCLIOptions(parsedOptions, randomTargetLang)).toMatchObject({ + srcLang: "typescript", + }); + + // throw on unknown target lang + expect(() => inferCLIOptions(parsedOptions, undefined)).toThrow(); + + // infer target lang + parsedOptions.lang = undefined; + parsedOptions.out = `${parsedOptions.out}.${randomTargetLang.extension}`; + + const inferredOptionsWithTargetLang = inferCLIOptions( + parsedOptions, + undefined, + ); + expect(inferredOptionsWithTargetLang).toMatchObject({ + lang: randomTargetLang.name, + }); + + // check inference flags + for (const flagName of inferenceFlagNames) { + const negatedFlagName = negatedInferenceFlagName(flagName); + + expect(inferredOptionsWithTargetLang[flagName]).toEqual( + !!parsedOptions[flagName], + ); + expect(inferredOptionsWithTargetLang[negatedFlagName]).toEqual( + !parsedOptions[flagName], + ); + } +}); + +for (const [name, fixtureLanguage] of Object.entries(fixtureLanguages)) { + const languageClass = sourceLanguages.find((lang) => + (lang.names as readonly string[]).includes(fixtureLanguage.name), + ); + + if (!languageClass) { + // it.skip(`does not have language fixture for ${fixtureLanguage.name} language`, () => {}); + continue; + } + + const fixtureRendererOptionsEntries = [ + fixtureLanguage.rendererOptions, + ...fixtureLanguage.quickTestRendererOptions, + ].flatMap((optionsObj) => Object.entries(optionsObj)); + + const dedupeKeys = {}; + const rendererOptionsArgv = fixtureRendererOptionsEntries.reduce( + (strArr, [k, v]) => { + if (k in dedupeKeys) { + return strArr; + } + + dedupeKeys[k] = true; + return strArr.concat([`--${k}`, v as string]); + }, + [] as string[], + ); + + it(`should parse rendererOptions for ${name} fixture`, () => { + const parsedOptions = parseOptions( + optionsDefinitions.concat( + languageClass.cliOptionDefinitions.actual, + ), + argv.concat(rendererOptionsArgv), + true, + ); + const options = languageClass.getOptions(); + + const isAllRendererOptionsValid = + parsedOptions.rendererOptions && + (Object.values(options) as Option[]).every( + (option) => { + const parsedValue = + parsedOptions.rendererOptions?.[option.name]; + + if (option.definition.optionType === "enum") { + try { + // biome-ignore lint/suspicious/noExplicitAny: renderer enum values are heterogeneous + (option as EnumOption).getEnumValue( + parsedValue, + ); + return true; + } catch { + return false; + } + } + + return option.definition.optionType === typeof parsedValue; + }, + ); + + expect(isAllRendererOptionsValid).toBeTruthy(); + }); +} diff --git a/test/unit/cli-quicktype-options.test.ts b/test/unit/cli-quicktype-options.test.ts new file mode 100644 index 0000000000..fdd854078a --- /dev/null +++ b/test/unit/cli-quicktype-options.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { inferenceFlagNames, type InferenceFlagName } from "quicktype-core"; + +import type { CLIOptions } from "../../src/CLIOptions.types"; +import { inferCLIOptions } from "../../src/inference"; +import { makeQuicktypeOptions } from "../../src/quicktype.options"; +import { negatedInferenceFlagName } from "../../src/utils"; +import * as sources from "../../src/sources"; +import * as usage from "../../src/usage"; + +vi.mock("../../src/input", () => ({ + makeInputData: vi.fn().mockResolvedValue("inputData"), +})); +vi.mock("../../src/sources", () => ({ + getSources: vi.fn().mockResolvedValue([]), + makeTypeScriptSource: vi.fn().mockReturnValue({ + kind: "schema", + name: "TypeScript", + uris: [], + }), +})); +vi.mock("../../src/usage", () => ({ + displayUsage: vi.fn(), +})); + +afterEach(() => { + vi.clearAllMocks(); +}); + +function inferredOptions(overrides: Partial = {}): CLIOptions { + return inferCLIOptions( + { + lang: "typescript", + src: ["input.json"], + ...overrides, + }, + undefined, + ); +} + +describe("makeQuicktypeOptions", () => { + it("displays usage and returns no options for --help", async () => { + const result = await makeQuicktypeOptions( + inferredOptions({ help: true }), + ); + + expect(usage.displayUsage).toHaveBeenCalledOnce(); + expect(result).toBeUndefined(); + }); + + it("builds options for JSON and TypeScript inputs", async () => { + const jsonResult = await makeQuicktypeOptions(inferredOptions()); + expect(sources.getSources).toHaveBeenCalledOnce(); + expect(jsonResult).toMatchObject({ inputData: "inputData" }); + + const typescriptResult = await makeQuicktypeOptions( + inferredOptions({ src: ["input.ts"] }), + ); + expect(sources.makeTypeScriptSource).toHaveBeenCalledWith(["input.ts"]); + expect(typescriptResult).toMatchObject({ inputData: "inputData" }); + }); + + it("sets debug options", async () => { + const result = await makeQuicktypeOptions( + inferredOptions({ debug: "print-graph,provenance" }), + ); + + expect(result?.debugPrintGraph).toBe(true); + expect(result?.checkProvenance).toBe(true); + }); + + it("keeps every inference flag enabled by default", async () => { + const result = await makeQuicktypeOptions(inferredOptions()); + + for (const flagName of inferenceFlagNames) { + expect(result?.[flagName], flagName).toBe(true); + } + }); + + it("honors negated inference flags from the CLI pipeline", async () => { + const flagName: InferenceFlagName = inferenceFlagNames[0]; + const negatedFlagName = negatedInferenceFlagName(flagName); + const result = await makeQuicktypeOptions( + inferredOptions({ [negatedFlagName]: true }), + ); + + expect(result?.[flagName]).toBe(false); + }); + + it("honors positive inference flags supplied through the API", async () => { + const flagName: InferenceFlagName = inferenceFlagNames[0]; + const result = await makeQuicktypeOptions( + inferredOptions({ [flagName]: false }), + ); + + expect(result?.[flagName]).toBe(false); + }); +}); diff --git a/test/utils.ts b/test/utils.ts index 3a6d1f4bc2..3e8248330d 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -52,19 +52,16 @@ export function callAndExpectFailure(message: string, f: () => T): void { } export function exec( - s: string, - env: NodeJS.ProcessEnv | undefined, + str: string, + env: NodeJS.ProcessEnv = process.env, printFailure = true, ): { stdout: string; code: number } { - debug(s); - if (env === undefined) { - env = process.env; - } - const result = shell.exec(s, { silent: !DEBUG, env }); + debug(str); + const result = shell.exec(str, { silent: !DEBUG, env }); if (result.code !== 0) { const failureObj = { - command: s, + command: str, code: result.code, }; if (!printFailure) {