diff --git a/Dockerfile b/Dockerfile index 918ca59b..e6f152bd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,7 +22,7 @@ ENV PATH="${workdir}/swift-4.1.3-RELEASE-ubuntu16.04/usr/bin:${PATH}" RUN curl -sL https://deb.nodesource.com/setup_8.x | bash - # Add .NET core package sources -RUN curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > microsoft.gpg +RUN curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > microsoft.gpg RUN mv microsoft.gpg /etc/apt/trusted.gpg.d/microsoft.gpg RUN sh -c 'echo "deb [arch=amd64] https://packages.microsoft.com/repos/microsoft-ubuntu-xenial-prod xenial main" > /etc/apt/sources.list.d/dotnetdev.list' @@ -65,6 +65,12 @@ RUN sh -c 'curl https://storage.googleapis.com/download.dartlang.org/linux/debia RUN apt-get update RUN apt-get install dart +# Crystal +RUN curl -sL "https://keybase.io/crystal/pgp_keys.asc" | apt-key add - +RUN echo "deb https://dist.crystal-lang.org/apt crystal main" | tee /etc/apt/sources.list.d/crystal.list +RUN apt-get update +RUN apt-get install crystal --assume-yes + ENV PATH="${workdir}/node_modules/.bin:${PATH}" COPY . . diff --git a/README.md b/README.md index e423c93e..17aaefe0 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,7 @@ files, URLs, or add other options. `quicktype` has many complex test dependencies: +- `crystal` compiler - `dotnetcore` SDK - Java, Maven - `elm` tools diff --git a/src/quicktype-core/index.ts b/src/quicktype-core/index.ts index 1d16a1f8..09547a96 100644 --- a/src/quicktype-core/index.ts +++ b/src/quicktype-core/index.ts @@ -71,3 +71,4 @@ export { ElmTargetLanguage, ElmRenderer } from "./language/Elm"; export { JSONSchemaTargetLanguage, JSONSchemaRenderer } from "./language/JSONSchema"; export { RustTargetLanguage, RustRenderer } from "./language/Rust"; export { RubyTargetLanguage, RubyRenderer } from "./language/ruby"; +export { CrystalTargetLanguage, CrystalRenderer } from "./language/Crystal"; diff --git a/src/quicktype-core/language/All.ts b/src/quicktype-core/language/All.ts index 12dc7649..94b05c4e 100644 --- a/src/quicktype-core/language/All.ts +++ b/src/quicktype-core/language/All.ts @@ -14,6 +14,7 @@ import { KotlinTargetLanguage } from "./Kotlin"; import { ElmTargetLanguage } from "./Elm"; import { JSONSchemaTargetLanguage } from "./JSONSchema"; import { RustTargetLanguage } from "./Rust"; +import { CrystalTargetLanguage } from "./Crystal"; import { RubyTargetLanguage } from "./ruby"; import { DartTargetLanguage } from "./Dart"; import { PythonTargetLanguage } from "./Python"; @@ -22,6 +23,7 @@ export const all: TargetLanguage[] = [ new NewtonsoftCSharpTargetLanguage(), new GoTargetLanguage(), new RustTargetLanguage(), + new CrystalTargetLanguage(), new CPlusPlusTargetLanguage(), new ObjectiveCTargetLanguage(), new JavaTargetLanguage(), diff --git a/src/quicktype-core/language/Crystal.ts b/src/quicktype-core/language/Crystal.ts new file mode 100644 index 00000000..6ad5d5d9 --- /dev/null +++ b/src/quicktype-core/language/Crystal.ts @@ -0,0 +1,417 @@ +import { mapFirst } from "collection-utils"; + +import { TargetLanguage } from "../TargetLanguage"; +import { ConvenienceRenderer, ForbiddenWordsInfo } from "../ConvenienceRenderer"; +import { + legalizeCharacters, + splitIntoWords, + isLetterOrUnderscoreOrDigit, + combineWords, + allLowerWordStyle, + firstUpperWordStyle, + intToHex, + utf32ConcatMap, + escapeNonPrintableMapper, + isPrintable, + isAscii, + isLetterOrUnderscore +} from "../support/Strings"; +import { Name, Namer, funPrefixNamer } from "../Naming"; +import { UnionType, Type, ClassType, EnumType } from "../Type"; +import { matchType, nullableFromUnion, removeNullFromUnion } from "../TypeUtils"; +import { Sourcelike, maybeAnnotated } from "../Source"; +import { anyTypeIssueAnnotation, nullTypeIssueAnnotation } from "../Annotation"; +import { Option } from "../RendererOptions"; +import { defined } from "../support/Support"; +import { RenderContext } from "../Renderer"; + +export class CrystalTargetLanguage extends TargetLanguage { + protected makeRenderer(renderContext: RenderContext): CrystalRenderer { + return new CrystalRenderer(this, renderContext); + } + + constructor() { + super("crystal", ["crystal", "cr", "crystallang"], "cr"); + } + + protected get defaultIndentation(): string { + return " "; + } + + protected getOptions(): Option[] { + return []; + } +} + +const keywords = [ + "Any", + "Array", + "Atomic", + "Bool", + "Channel", + "Char", + "Class", + "Enum", + "Enumerable", + "Event", + "Extern", + "Exception", + "File", + "Float", + "Float32", + "Float64", + "GC", + "GZip", + "Hash", + "HTML", + "HTTP", + "Int", + "Int128", + "Int16", + "Int32", + "Int64", + "Int8", + "Iterable", + "Link", + "Logger", + "Math", + "Mutex", + "Nil", + "Number", + "JSON", + "IO", + "Object", + "Pointer", + "Proc", + "Process", + "Range", + "Random", + "Regex", + "Reference", + "Set", + "Signal", + "Slice", + "Spec", + "StaticArray", + "String", + "Struct", + "Symbol", + "System", + "TCPServer", + "TCPSocket", + "Socket", + "Tempfile", + "Termios", + "Time", + "Tuple", + "ThreadLocal", + "UDPSocket", + "UInt128", + "UInt16", + "UInt32", + "UInt64", + "UInt8", + "Union", + "UNIXServer", + "UNIXSocket", + "UUID", + "URI", + "VaList", + "Value", + "Void", + "WeakRef", + "XML", + "YAML", + "Zip", + "Zlib", + "abstract", + "alias", + "as", + "as?", + "asm", + "begin", + "break", + "case", + "class", + "def", + "do", + "else", + "elsif", + "end", + "ensure", + "enum", + "extend", + "false", + "for", + "fun", + "if", + "in", + "include", + "instance_sizeof", + "is_a?", + "lib", + "macro", + "module", + "next", + "nil", + "nil?", + "of", + "out", + "pointerof", + "private", + "protected", + "require", + "rescue", + "return", + "select", + "self", + "sizeof", + "struct", + "super", + "then", + "true", + "type", + "typeof", + "uninitialized", + "union", + "unless", + "until", + "when", + "while", + "with", + "yield" +]; + +const isAsciiLetterOrUnderscoreOrDigit = (codePoint: number): boolean => { + if (!isAscii(codePoint)) { + return false; + } + + return isLetterOrUnderscoreOrDigit(codePoint); +}; + +const isAsciiLetterOrUnderscore = (codePoint: number): boolean => { + if (!isAscii(codePoint)) { + return false; + } + + return isLetterOrUnderscore(codePoint); +}; + +const legalizeName = legalizeCharacters(isAsciiLetterOrUnderscoreOrDigit); + +function crystalStyle(original: string, isSnakeCase: boolean): string { + const words = splitIntoWords(original); + + const wordStyle = isSnakeCase ? allLowerWordStyle : firstUpperWordStyle; + + const combined = combineWords( + words, + legalizeName, + wordStyle, + wordStyle, + wordStyle, + wordStyle, + isSnakeCase ? "_" : "", + isAsciiLetterOrUnderscore + ); + + return combined === "_" ? "_underscore" : combined; +} + +const snakeNamingFunction = funPrefixNamer("default", (original: string) => crystalStyle(original, true)); +const camelNamingFunction = funPrefixNamer("camel", (original: string) => crystalStyle(original, false)); + +const standardUnicodeCrystalEscape = (codePoint: number): string => { + if (codePoint <= 0xffff) { + return "\\u{" + intToHex(codePoint, 4) + "}"; + } else { + return "\\u{" + intToHex(codePoint, 6) + "}"; + } +}; + +const crystalStringEscape = utf32ConcatMap(escapeNonPrintableMapper(isPrintable, standardUnicodeCrystalEscape)); + +export class CrystalRenderer extends ConvenienceRenderer { + constructor(targetLanguage: TargetLanguage, renderContext: RenderContext) { + super(targetLanguage, renderContext); + } + + protected makeNamedTypeNamer(): Namer { + return camelNamingFunction; + } + + protected namerForObjectProperty(): Namer | null { + return snakeNamingFunction; + } + + protected makeUnionMemberNamer(): Namer | null { + return camelNamingFunction; + } + + protected makeEnumCaseNamer(): Namer | null { + return camelNamingFunction; + } + + protected forbiddenNamesForGlobalNamespace(): string[] { + return keywords; + } + + protected forbiddenForObjectProperties(_c: ClassType, _className: Name): ForbiddenWordsInfo { + return { names: [], includeGlobalForbidden: true }; + } + + protected forbiddenForUnionMembers(_u: UnionType, _unionName: Name): ForbiddenWordsInfo { + return { names: [], includeGlobalForbidden: true }; + } + + protected forbiddenForEnumCases(_e: EnumType, _enumName: Name): ForbiddenWordsInfo { + return { names: [], includeGlobalForbidden: true }; + } + + protected get commentLineStart(): string { + return "# "; + } + + private nullableCrystalType = (t: Type, withIssues: boolean): Sourcelike => { + return [this.crystalType(t, withIssues), "?"]; + }; + + protected isImplicitCycleBreaker(t: Type): boolean { + const kind = t.kind; + return kind === "array" || kind === "map"; + } + + private crystalType = (t: Type, withIssues: boolean = false): Sourcelike => { + return matchType( + t, + _anyType => maybeAnnotated(withIssues, anyTypeIssueAnnotation, "JSON::Any"), + _nullType => maybeAnnotated(withIssues, nullTypeIssueAnnotation, "Nil"), + _boolType => "Bool", + _integerType => "Int32", + _doubleType => "Float64", + _stringType => "String", + arrayType => ["Array(", this.crystalType(arrayType.items, withIssues), ")"], + classType => this.nameForNamedType(classType), + mapType => ["Hash(String, ", this.crystalType(mapType.values, withIssues), ")"], + _enumType => "String", + unionType => { + const nullable = nullableFromUnion(unionType); + + if (nullable !== null) return this.nullableCrystalType(nullable, withIssues); + + const [hasNull] = removeNullFromUnion(unionType); + + const name = this.nameForNamedType(unionType); + + return hasNull !== null ? ([name, "?"] as Sourcelike) : name; + } + ); + }; + + private breakCycle = (t: Type, withIssues: boolean): any => { + const crystalType = this.crystalType(t, withIssues); + return crystalType; + }; + + private emitRenameAttribute(propName: Name, jsonName: string) { + const escapedName = crystalStringEscape(jsonName); + const namesDiffer = this.sourcelikeToString(propName) !== escapedName; + if (namesDiffer) { + this.emitLine('@[JSON::Field(key: "', escapedName, '")]'); + } + } + + protected emitStructDefinition(c: ClassType, className: Name): void { + this.emitDescription(this.descriptionForType(c)); + + const structBody = () => + this.forEachClassProperty(c, "none", (name, jsonName, prop) => { + this.emitDescription(this.descriptionForClassProperty(c, jsonName)); + this.emitRenameAttribute(name, jsonName); + this.emitLine("property ", name, " : ", this.crystalType(prop.type, true)); + }); + + this.emitBlock(["class ", className], structBody); + } + + protected emitBlock(line: Sourcelike, f: () => void): void { + this.emitLine(line, ""); + this.indent(() => { + this.emitLine("include JSON::Serializable"); + }); + this.ensureBlankLine(); + this.indent(f); + this.emitLine("end"); + } + + protected emitEnum(line: Sourcelike, f: () => void): void { + this.emitLine(line); + this.indent(f); + this.emitLine("end"); + } + + protected emitUnion(u: UnionType, unionName: Name): void { + const isMaybeWithSingleType = nullableFromUnion(u); + + if (isMaybeWithSingleType !== null) { + return; + } + + this.emitDescription(this.descriptionForType(u)); + + const [, nonNulls] = removeNullFromUnion(u); + + let count = nonNulls.size; + + let types: Sourcelike[][] = []; + this.emitLine(["alias ", unionName, " = "]); + this.forEachUnionMember(u, nonNulls, "none", null, (_fieldName, t) => { + const last = --count === 0; + const blanksOrPipe = last ? "" : " |"; + const crystalType = this.breakCycle(t, true); + this.emitLine([crystalType, blanksOrPipe]); + types.push(crystalType); + }); + } + + protected emitTopLevelAlias(t: Type, name: Name): void { + this.emitLine("alias ", name, " = ", this.crystalType(t)); + } + + protected emitLeadingComments(): void { + if (this.leadingComments !== undefined) { + this.emitCommentLines(this.leadingComments); + return; + } + + const topLevelName = defined(mapFirst(this.topLevels)); + this.emitMultiline( + `# Example code that deserializes and serializes the model. +# class ${topLevelName} +# include JSON::Serializable +# +# @[JSON::Field(key: "answer")] +# property answer : Int32 +# end +# +# ${topLevelName}.from_json(%({"answer": 42})) +` + ); + } + + protected emitSourceStructure(): void { + this.emitLeadingComments(); + this.ensureBlankLine(); + this.emitLine('require "json"'); + + this.forEachTopLevel( + "leading", + (t, name) => this.emitTopLevelAlias(t, name), + t => this.namedTypeToNameForTopLevel(t) === undefined + ); + + this.forEachObject("leading-and-interposing", (c: ClassType, name: Name) => this.emitStructDefinition(c, name)); + this.forEachUnion("leading-and-interposing", (u, name) => this.emitUnion(u, name)); + } +} diff --git a/test/fixtures.ts b/test/fixtures.ts index 2cd76dfd..fdbc9d8d 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -683,6 +683,7 @@ class GraphQLFixture extends LanguageFixture { } export const allFixtures: Fixture[] = [ + new JSONFixture(languages.CrystalLanguage), new JSONFixture(languages.CSharpLanguage), new JSONFixture(languages.JavaLanguage), new JSONFixture(languages.GoLanguage), @@ -700,6 +701,7 @@ export const allFixtures: Fixture[] = [ new JSONFixture(languages.DartLanguage), new JSONSchemaJSONFixture(languages.CSharpLanguage), new JSONTypeScriptFixture(languages.CSharpLanguage), + new JSONSchemaFixture(languages.CrystalLanguage), new JSONSchemaFixture(languages.CSharpLanguage), new JSONSchemaFixture(languages.JavaLanguage), new JSONSchemaFixture(languages.GoLanguage), diff --git a/test/fixtures/crystal/main.cr b/test/fixtures/crystal/main.cr new file mode 100644 index 00000000..33a677fa --- /dev/null +++ b/test/fixtures/crystal/main.cr @@ -0,0 +1,7 @@ +require "json" +require "./TopLevel" + +json = File.read(ARGV[0].not_nil!) +top = TopLevel.from_json(json) + +puts top.to_json diff --git a/test/languages.ts b/test/languages.ts index e0fbaaea..48febe2b 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -181,6 +181,39 @@ export const RustLanguage: Language = { sourceFiles: ["src/language/Rust.ts"] }; +export const CrystalLanguage: Language = { + name: "crystal", + base: "test/fixtures/crystal", + compileCommand: "crystal build -o quicktype main.cr", + runCommand(sample: string) { + return `./quicktype "${sample}"`; + }, + diffViaSchema: false, + skipDiffViaSchema: [], + allowMissingNull: true, + features: ["enum", "union", "no-defaults"], + output: "TopLevel.cr", + topLevel: "TopLevel", + skipJSON: [ + "blns-object.json", + "identifiers.json", + "simple-identifiers.json", + "bug427.json", + "nst-test-suite.json", + "34702.json", + "34702.json", + "4961a.json", + "32431.json", + "68c30.json", + "e8b04.json" + ], + skipSchema: [], + skipMiscJSON: false, + rendererOptions: {}, + quickTestRendererOptions: [], + sourceFiles: ["src/language/Crystal.ts"] +}; + export const RubyLanguage: Language = { name: "ruby", base: "test/fixtures/ruby",