From e612633692ad35bb17053796021dc711d679e93c Mon Sep 17 00:00:00 2001 From: Krzysztof Skorupka Date: Fri, 30 Jul 2021 10:17:14 +0200 Subject: [PATCH] refactor(schematic-utils): remove external deps Get rid of external deps and use their internal counterparts --- packages/schematic-utils/.eslintrc.json | 8 +- packages/schematic-utils/package-lock.json | 47 + packages/schematic-utils/package.json | 4 +- packages/schematic-utils/src/analytics/api.ts | 62 + .../src/analytics/forwarder.ts | 153 +++ .../schematic-utils/src/analytics/index.ts | 86 ++ .../schematic-utils/src/analytics/logging.ts | 56 + .../schematic-utils/src/analytics/multi.ts | 54 + .../schematic-utils/src/analytics/noop.ts | 22 + .../schematic-utils/src/engine/interface.ts | 3 +- packages/schematic-utils/src/json/index.ts | 13 + .../schematic-utils/src/json/interface.ts | 9 + .../schematic-utils/src/json/parser.spec.ts | 461 ++++++++ packages/schematic-utils/src/json/parser.ts | 1038 +++++++++++++++++ .../schematic-utils/src/json/schema/index.ts | 18 + .../src/json/schema/interface.ts | 127 ++ .../src/json/schema/pointer.ts | 41 + .../src/json/schema/prompt.spec.ts | 473 ++++++++ .../src/json/schema/registry.spec.ts | 431 +++++++ .../src/json/schema/registry.ts | 742 ++++++++++++ .../schematic-utils/src/json/schema/schema.ts | 54 + .../src/json/schema/transforms.spec.ts | 229 ++++ .../src/json/schema/transforms.ts | 112 ++ .../src/json/schema/utility.ts | 99 ++ .../src/json/schema/visitor.spec.ts | 160 +++ .../src/json/schema/visitor.ts | 271 +++++ .../schematic-utils/src/logger/indent.spec.ts | 65 ++ packages/schematic-utils/src/logger/indent.ts | 46 + packages/schematic-utils/src/logger/index.ts | 13 + packages/schematic-utils/src/logger/level.ts | 78 ++ .../schematic-utils/src/logger/logger.spec.ts | 101 ++ packages/schematic-utils/src/logger/logger.ts | 46 +- .../src/logger/null-logger.spec.ts | 51 + .../schematic-utils/src/logger/null-logger.ts | 29 + .../src/logger/transform-logger.spec.ts | 43 + .../src/logger/transform-logger.ts | 21 + .../schematic-utils/src/rules/call.spec.ts | 3 +- packages/schematic-utils/src/rules/call.ts | 3 +- .../schematic-utils/src/tree/action.spec.ts | 2 +- packages/schematic-utils/src/tree/action.ts | 3 +- packages/schematic-utils/src/tree/entry.ts | 2 +- .../src/tree/host-tree.spec.ts | 2 +- .../schematic-utils/src/tree/host-tree.ts | 29 +- .../schematic-utils/src/tree/interface.ts | 2 +- .../schematic-utils/src/tree/recorder.spec.ts | 2 +- packages/schematic-utils/src/tree/scoped.ts | 16 +- .../src/utility/update-buffer.ts | 2 +- .../src/utils/ast-utils.spec.ts | 2 +- .../schematic-utils/src/utils/ast-utils.ts | 2 +- .../src/utils/create-app-module.ts | 2 +- .../src/utils/find-module.spec.ts | 2 +- .../schematic-utils/src/utils/find-module.ts | 9 +- .../schematic-utils/src/utils/ng-ast-utils.ts | 4 +- .../src/utils/runtime/base.spec.ts | 4 +- .../src/utils/runtime/call.spec.ts | 3 +- .../schematic-utils/src/utils/runtime/call.ts | 5 +- .../schematic-utils/src/utils/runtime/move.ts | 2 +- .../src/utils/runtime/parse-name.ts | 8 +- .../src/utils/runtime/paths.ts | 2 +- .../src/utils/runtime/rename.ts | 2 +- .../src/utils/runtime/template.spec.ts | 12 +- .../src/utils/runtime/template.ts | 8 +- .../src/utils/runtime/validation.ts | 2 +- .../schematic-utils/src/utils/workspace.ts | 4 +- .../src/virtual-fs/host/alias.ts | 88 ++ .../src/virtual-fs/host/alias_spec.ts | 65 ++ .../src/virtual-fs/host/buffer.ts | 82 ++ .../src/virtual-fs/host/create.ts | 89 ++ .../src/virtual-fs/host/empty.ts | 48 + .../src/virtual-fs/host/index.ts | 21 + .../src/virtual-fs/host/interface.ts | 74 ++ .../src/virtual-fs/host/memory.ts | 412 +++++++ .../src/virtual-fs/host/memory_spec.ts | 158 +++ .../src/virtual-fs/host/pattern.ts | 56 + .../src/virtual-fs/host/pattern_spec.ts | 46 + .../src/virtual-fs/host/record.ts | 427 +++++++ .../src/virtual-fs/host/record_spec.ts | 584 ++++++++++ .../src/virtual-fs/host/resolver.ts | 72 ++ .../src/virtual-fs/host/safe.ts | 50 + .../src/virtual-fs/host/scoped.ts | 21 + .../src/virtual-fs/host/sync.ts | 117 ++ .../src/virtual-fs/host/test.ts | 174 +++ .../src/virtual-fs/host/test_spec.ts | 24 + .../schematic-utils/src/virtual-fs/index.ts | 12 + .../schematic-utils/src/virtual-fs/path.ts | 319 +++++ .../src/virtual-fs/path_spec.ts | 174 +++ .../schematic-utils/src/workflow/interface.ts | 2 +- .../schematic-utils/src/workspace/core.ts | 151 +++ .../src/workspace/core_spec.ts | 384 ++++++ .../src/workspace/definitions.ts | 270 +++++ .../src/workspace/definitions_spec.ts | 350 ++++++ .../schematic-utils/src/workspace/host.ts | 53 + .../src/workspace/host_spec.ts | 57 + .../schematic-utils/src/workspace/index.ts | 11 + .../src/workspace/json/metadata.ts | 104 ++ .../src/workspace/json/reader.spec.ts | 892 ++++++++++++++ .../src/workspace/json/reader.ts | 363 ++++++ .../src/workspace/json/test/angular.json | 121 ++ .../json/test/cases/AddArrayEmpty.json | 15 + .../json/test/cases/AddArrayPush.json | 15 + .../json/test/cases/AddProject1.json | 19 + .../json/test/cases/AddProject2.json | 124 ++ .../test/cases/AddProjectWithTargets.json | 33 + .../json/test/cases/ArrayDeleteInner.json | 14 + .../json/test/cases/ArrayDeleteInnerAdd.json | 14 + .../json/test/cases/ArrayDeleteLast.json | 14 + .../json/test/cases/ArrayDeleteLastAdd.json | 14 + .../json/test/cases/ArrayDeleteZero.json | 14 + .../json/test/cases/ArrayIndexInner.json | 14 + .../json/test/cases/ArrayIndexLast.json | 14 + .../json/test/cases/ArrayIndexZero.json | 14 + .../workspace/json/test/cases/ArrayPop.json | 14 + .../workspace/json/test/cases/ArrayPush.json | 14 + .../json/test/cases/ArrayReplace1.json | 14 + .../json/test/cases/ArrayReplace2.json | 14 + .../workspace/json/test/cases/ArrayShift.json | 14 + .../workspace/json/test/cases/ArraySort.json | 14 + .../json/test/cases/ArraySplice1.json | 14 + .../json/test/cases/ArraySplice2.json | 14 + .../json/test/cases/ArrayUnshift.json | 14 + .../json/test/cases/ArrayValues.json | 15 + .../src/workspace/json/test/cases/Empty.json | 5 + .../json/test/cases/Extensions1.json | 6 + .../json/test/cases/Extensions2.json | 11 + .../json/test/cases/ObjectRemove.json | 13 + .../json/test/cases/ObjectRemoveMultiple.json | 8 + .../json/test/cases/ObjectReplace1.json | 14 + .../json/test/cases/ObjectReplace2.json | 12 + .../json/test/cases/ObjectReplace3.json | 12 + .../json/test/cases/ProjectAddTarget.json | 124 ++ .../json/test/cases/ProjectDeleteTarget.json | 115 ++ .../json/test/cases/ProjectEmpty.json | 9 + .../json/test/cases/ProjectFull.json | 65 ++ .../test/cases/ProjectModifyProperties.json | 121 ++ .../json/test/cases/ProjectSetProperties.json | 122 ++ .../src/workspace/json/test/cases/Retain.json | 14 + .../src/workspace/json/utilities.ts | 352 ++++++ .../src/workspace/json/writer.spec.ts | 668 +++++++++++ .../src/workspace/json/writer.ts | 410 +++++++ packages/schematic-utils/tsconfig.json | 2 +- 140 files changed, 13733 insertions(+), 85 deletions(-) create mode 100644 packages/schematic-utils/src/analytics/api.ts create mode 100644 packages/schematic-utils/src/analytics/forwarder.ts create mode 100644 packages/schematic-utils/src/analytics/index.ts create mode 100644 packages/schematic-utils/src/analytics/logging.ts create mode 100644 packages/schematic-utils/src/analytics/multi.ts create mode 100644 packages/schematic-utils/src/analytics/noop.ts create mode 100644 packages/schematic-utils/src/json/index.ts create mode 100644 packages/schematic-utils/src/json/parser.spec.ts create mode 100644 packages/schematic-utils/src/json/parser.ts create mode 100644 packages/schematic-utils/src/json/schema/index.ts create mode 100644 packages/schematic-utils/src/json/schema/interface.ts create mode 100644 packages/schematic-utils/src/json/schema/pointer.ts create mode 100644 packages/schematic-utils/src/json/schema/prompt.spec.ts create mode 100644 packages/schematic-utils/src/json/schema/registry.spec.ts create mode 100644 packages/schematic-utils/src/json/schema/registry.ts create mode 100644 packages/schematic-utils/src/json/schema/schema.ts create mode 100644 packages/schematic-utils/src/json/schema/transforms.spec.ts create mode 100644 packages/schematic-utils/src/json/schema/transforms.ts create mode 100644 packages/schematic-utils/src/json/schema/utility.ts create mode 100644 packages/schematic-utils/src/json/schema/visitor.spec.ts create mode 100644 packages/schematic-utils/src/json/schema/visitor.ts create mode 100644 packages/schematic-utils/src/logger/indent.spec.ts create mode 100644 packages/schematic-utils/src/logger/indent.ts create mode 100644 packages/schematic-utils/src/logger/index.ts create mode 100644 packages/schematic-utils/src/logger/level.ts create mode 100644 packages/schematic-utils/src/logger/logger.spec.ts create mode 100644 packages/schematic-utils/src/logger/null-logger.spec.ts create mode 100644 packages/schematic-utils/src/logger/null-logger.ts create mode 100644 packages/schematic-utils/src/logger/transform-logger.spec.ts create mode 100644 packages/schematic-utils/src/logger/transform-logger.ts create mode 100644 packages/schematic-utils/src/virtual-fs/host/alias.ts create mode 100644 packages/schematic-utils/src/virtual-fs/host/alias_spec.ts create mode 100644 packages/schematic-utils/src/virtual-fs/host/buffer.ts create mode 100644 packages/schematic-utils/src/virtual-fs/host/create.ts create mode 100644 packages/schematic-utils/src/virtual-fs/host/empty.ts create mode 100644 packages/schematic-utils/src/virtual-fs/host/index.ts create mode 100644 packages/schematic-utils/src/virtual-fs/host/interface.ts create mode 100644 packages/schematic-utils/src/virtual-fs/host/memory.ts create mode 100644 packages/schematic-utils/src/virtual-fs/host/memory_spec.ts create mode 100644 packages/schematic-utils/src/virtual-fs/host/pattern.ts create mode 100644 packages/schematic-utils/src/virtual-fs/host/pattern_spec.ts create mode 100644 packages/schematic-utils/src/virtual-fs/host/record.ts create mode 100644 packages/schematic-utils/src/virtual-fs/host/record_spec.ts create mode 100644 packages/schematic-utils/src/virtual-fs/host/resolver.ts create mode 100644 packages/schematic-utils/src/virtual-fs/host/safe.ts create mode 100644 packages/schematic-utils/src/virtual-fs/host/scoped.ts create mode 100644 packages/schematic-utils/src/virtual-fs/host/sync.ts create mode 100644 packages/schematic-utils/src/virtual-fs/host/test.ts create mode 100644 packages/schematic-utils/src/virtual-fs/host/test_spec.ts create mode 100644 packages/schematic-utils/src/virtual-fs/index.ts create mode 100644 packages/schematic-utils/src/virtual-fs/path.ts create mode 100644 packages/schematic-utils/src/virtual-fs/path_spec.ts create mode 100644 packages/schematic-utils/src/workspace/core.ts create mode 100644 packages/schematic-utils/src/workspace/core_spec.ts create mode 100644 packages/schematic-utils/src/workspace/definitions.ts create mode 100644 packages/schematic-utils/src/workspace/definitions_spec.ts create mode 100644 packages/schematic-utils/src/workspace/host.ts create mode 100644 packages/schematic-utils/src/workspace/host_spec.ts create mode 100644 packages/schematic-utils/src/workspace/index.ts create mode 100644 packages/schematic-utils/src/workspace/json/metadata.ts create mode 100644 packages/schematic-utils/src/workspace/json/reader.spec.ts create mode 100644 packages/schematic-utils/src/workspace/json/reader.ts create mode 100644 packages/schematic-utils/src/workspace/json/test/angular.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/AddArrayEmpty.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/AddArrayPush.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/AddProject1.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/AddProject2.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/AddProjectWithTargets.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/ArrayDeleteInner.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/ArrayDeleteInnerAdd.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/ArrayDeleteLast.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/ArrayDeleteLastAdd.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/ArrayDeleteZero.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/ArrayIndexInner.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/ArrayIndexLast.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/ArrayIndexZero.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/ArrayPop.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/ArrayPush.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/ArrayReplace1.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/ArrayReplace2.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/ArrayShift.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/ArraySort.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/ArraySplice1.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/ArraySplice2.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/ArrayUnshift.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/ArrayValues.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/Empty.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/Extensions1.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/Extensions2.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/ObjectRemove.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/ObjectRemoveMultiple.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/ObjectReplace1.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/ObjectReplace2.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/ObjectReplace3.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/ProjectAddTarget.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/ProjectDeleteTarget.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/ProjectEmpty.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/ProjectFull.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/ProjectModifyProperties.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/ProjectSetProperties.json create mode 100644 packages/schematic-utils/src/workspace/json/test/cases/Retain.json create mode 100644 packages/schematic-utils/src/workspace/json/utilities.ts create mode 100644 packages/schematic-utils/src/workspace/json/writer.spec.ts create mode 100644 packages/schematic-utils/src/workspace/json/writer.ts diff --git a/packages/schematic-utils/.eslintrc.json b/packages/schematic-utils/.eslintrc.json index 9afd5d6..f9a0ee2 100644 --- a/packages/schematic-utils/.eslintrc.json +++ b/packages/schematic-utils/.eslintrc.json @@ -1,5 +1,11 @@ { "extends": "../../.eslintrc", "rules": {}, - "ignorePatterns": ["!**/*"] + "ignorePatterns": [ + "*.spec.ts", + "**/virtual-fs/*", + "**/workspace/*", + "**/logger/*", + "**/analytics/*" + ] } diff --git a/packages/schematic-utils/package-lock.json b/packages/schematic-utils/package-lock.json index 14c0352..1601889 100644 --- a/packages/schematic-utils/package-lock.json +++ b/packages/schematic-utils/package-lock.json @@ -10,10 +10,57 @@ "integrity": "sha512-F6S4Chv4JicJmyrwlDkxUdGNSplsQdGwp1A0AJloEVDirWdZOAiRHhovDlsFkKUrquUXhz1imJhXHsf59auyAg==", "dev": true }, + "ajv": { + "version": "8.6.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.6.2.tgz", + "integrity": "sha512-9807RlWAgT564wT+DjeyU5OFMPjmzxVobvDFmNAhY+5zD6A2ly3jDp6sgnfyDtlIQ+7H97oc/DGCzzfu9rjw9w==", + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + }, + "ajv-formats": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.0.tgz", + "integrity": "sha512-USH2jBb+C/hIpwD2iRjp0pe0k+MvzG0mlSn/FIdCgQhUb9ALPRjt2KIQdfZDS9r0ZIeUAg7gOu9KL0PFqGqr5Q==", + "requires": { + "ajv": "^8.0.0" + } + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" + }, + "require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" + }, "source-map": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==" + }, + "uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "requires": { + "punycode": "^2.1.0" + } } } } diff --git a/packages/schematic-utils/package.json b/packages/schematic-utils/package.json index 81f7f17..c58ad3a 100644 --- a/packages/schematic-utils/package.json +++ b/packages/schematic-utils/package.json @@ -26,10 +26,12 @@ "scripts": { "test": "jest", "build": "tsc -p tsconfig.json", - "lint": "eslint ./src/**/** --ignore-pattern '*.spec.ts'", + "lint": "eslint \"./src/**/**\"", "preversion": "npm run build" }, "dependencies": { + "ajv": "^8.6.2", + "ajv-formats": "^2.1.0", "source-map": "^0.7.3" }, "devDependencies": { diff --git a/packages/schematic-utils/src/analytics/api.ts b/packages/schematic-utils/src/analytics/api.ts new file mode 100644 index 0000000..af3869c --- /dev/null +++ b/packages/schematic-utils/src/analytics/api.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export interface CustomDimensionsAndMetricsOptions { + dimensions?: (boolean | number | string)[]; + metrics?: (boolean | number | string)[]; +} + +export interface EventOptions extends CustomDimensionsAndMetricsOptions { + label?: string; + value?: string; +} + +export interface ScreenviewOptions extends CustomDimensionsAndMetricsOptions { + appVersion?: string; + appId?: string; + appInstallerId?: string; +} + +export interface PageviewOptions extends CustomDimensionsAndMetricsOptions { + hostname?: string; + title?: string; +} + +export interface TimingOptions extends CustomDimensionsAndMetricsOptions { + label?: string; +} + +/** + * Interface for managing analytics. This is highly platform dependent, and mostly matches + * Google Analytics. The reason the interface is here is to remove the dependency to an + * implementation from most other places. + * + * The methods exported from this interface more or less match those needed by us in the + * universal analytics package, see https://unpkg.com/@types/universal-analytics@0.4.2/index.d.ts + * for typings. We mostly named arguments to make it easier to follow, but didn't change or + * add any semantics to those methods. They're mapping GA and u-a one for one. + * + * The Angular CLI (or any other kind of backend) should forward it to some compatible backend. + */ +export interface Analytics { + event(category: string, action: string, options?: EventOptions): void; + screenview( + screenName: string, + appName: string, + options?: ScreenviewOptions + ): void; + pageview(path: string, options?: PageviewOptions): void; + timing( + category: string, + variable: string, + time: string | number, + options?: TimingOptions + ): void; + + flush(): Promise; +} diff --git a/packages/schematic-utils/src/analytics/forwarder.ts b/packages/schematic-utils/src/analytics/forwarder.ts new file mode 100644 index 0000000..c9a75e1 --- /dev/null +++ b/packages/schematic-utils/src/analytics/forwarder.ts @@ -0,0 +1,153 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + Analytics, + EventOptions, + PageviewOptions, + ScreenviewOptions, + TimingOptions, +} from "./api"; +import { JsonObject } from "../json/interface"; + +export enum AnalyticsReportKind { + Event = "event", + Screenview = "screenview", + Pageview = "pageview", + Timing = "timing", +} + +export interface AnalyticsReportBase extends JsonObject { + kind: AnalyticsReportKind; +} + +export interface AnalyticsReportEvent extends AnalyticsReportBase { + kind: AnalyticsReportKind.Event; + options: JsonObject & EventOptions; + category: string; + action: string; +} +export interface AnalyticsReportScreenview extends AnalyticsReportBase { + kind: AnalyticsReportKind.Screenview; + options: JsonObject & ScreenviewOptions; + screenName: string; + appName: string; +} +export interface AnalyticsReportPageview extends AnalyticsReportBase { + kind: AnalyticsReportKind.Pageview; + options: JsonObject & PageviewOptions; + path: string; +} +export interface AnalyticsReportTiming extends AnalyticsReportBase { + kind: AnalyticsReportKind.Timing; + options: JsonObject & TimingOptions; + category: string; + variable: string; + time: string | number; +} + +export type AnalyticsReport = + | AnalyticsReportEvent + | AnalyticsReportScreenview + | AnalyticsReportPageview + | AnalyticsReportTiming; + +/** + * A function that can forward analytics along some stream. AnalyticsReport is already a + * JsonObject descendant, but we force it here so the user knows it's safe to serialize. + */ +export type AnalyticsForwarderFn = ( + report: JsonObject & AnalyticsReport +) => void; + +/** + * A class that follows the Analytics interface and forwards analytic reports (JavaScript objects). + * AnalyticsReporter is the counterpart which takes analytic reports and report them to another + * Analytics interface. + */ +export class ForwardingAnalytics implements Analytics { + constructor(protected _fn: AnalyticsForwarderFn) {} + + event(category: string, action: string, options?: EventOptions) { + this._fn({ + kind: AnalyticsReportKind.Event, + category, + action, + options: { ...options } as JsonObject, + }); + } + screenview(screenName: string, appName: string, options?: ScreenviewOptions) { + this._fn({ + kind: AnalyticsReportKind.Screenview, + screenName, + appName, + options: { ...options } as JsonObject, + }); + } + pageview(path: string, options?: PageviewOptions) { + this._fn({ + kind: AnalyticsReportKind.Pageview, + path, + options: { ...options } as JsonObject, + }); + } + timing( + category: string, + variable: string, + time: string | number, + options?: TimingOptions + ): void { + this._fn({ + kind: AnalyticsReportKind.Timing, + category, + variable, + time, + options: { ...options } as JsonObject, + }); + } + + // We do not support flushing. + flush() { + return Promise.resolve(); + } +} + +export class AnalyticsReporter { + constructor(protected _analytics: Analytics) {} + + report(report: AnalyticsReport) { + switch (report.kind) { + case AnalyticsReportKind.Event: + this._analytics.event(report.category, report.action, report.options); + break; + case AnalyticsReportKind.Screenview: + this._analytics.screenview( + report.screenName, + report.appName, + report.options + ); + break; + case AnalyticsReportKind.Pageview: + this._analytics.pageview(report.path, report.options); + break; + case AnalyticsReportKind.Timing: + this._analytics.timing( + report.category, + report.variable, + report.time, + report.options + ); + break; + + default: + throw new Error( + "Unexpected analytics report: " + JSON.stringify(report) + ); + } + } +} diff --git a/packages/schematic-utils/src/analytics/index.ts b/packages/schematic-utils/src/analytics/index.ts new file mode 100644 index 0000000..ecc1d1a --- /dev/null +++ b/packages/schematic-utils/src/analytics/index.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from "./api"; +export * from "./forwarder"; +export * from "./logging"; +export * from "./multi"; +export * from "./noop"; + +/** + * MAKE SURE TO KEEP THIS IN SYNC WITH THE TABLE AND CONTENT IN `/docs/design/analytics.md`. + * WE LIST THOSE DIMENSIONS (AND MORE). + * + * These cannot be in their respective schema.json file because we either change the type + * (e.g. --buildEventLog is string, but we want to know the usage of it, not its value), or + * some validation needs to be done (we cannot record ng add --collection if it's not marked as + * allowed). + */ +export enum NgCliAnalyticsDimensions { + CpuCount = 1, + CpuSpeed = 2, + RamInGigabytes = 3, + NodeVersion = 4, + NgAddCollection = 6, + NgIvyEnabled = 8, + BuildErrors = 20, +} + +export enum NgCliAnalyticsMetrics { + NgComponentCount = 1, + UNUSED_2 = 2, + UNUSED_3 = 3, + UNUSED_4 = 4, + BuildTime = 5, + NgOnInitCount = 6, + InitialChunkSize = 7, + TotalChunkCount = 8, + TotalChunkSize = 9, + LazyChunkCount = 10, + LazyChunkSize = 11, + AssetCount = 12, + AssetSize = 13, + PolyfillSize = 14, + CssSize = 15, +} + +// This table is used when generating the analytics.md file. It should match the enum above +// or the validate-user-analytics script will fail. +export const NgCliAnalyticsDimensionsFlagInfo: { + [name: string]: [string, string]; +} = { + CpuCount: ["CPU Count", "number"], + CpuSpeed: ["CPU Speed", "number"], + RamInGigabytes: ["RAM (In GB)", "number"], + NodeVersion: ["Node Version", "number"], + NgAddCollection: ["--collection", "string"], + NgIvyEnabled: ["Ivy Enabled", "boolean"], + BuildErrors: ["Build Errors (comma separated)", "string"], +}; + +// This table is used when generating the analytics.md file. It should match the enum above +// or the validate-user-analytics script will fail. +export const NgCliAnalyticsMetricsFlagInfo: { + [name: string]: [string, string]; +} = { + NgComponentCount: ["NgComponentCount", "number"], + UNUSED_2: ["UNUSED_2", "none"], + UNUSED_3: ["UNUSED_3", "none"], + UNUSED_4: ["UNUSED_4", "none"], + BuildTime: ["Build Time", "number"], + NgOnInitCount: ["NgOnInit Count", "number"], + InitialChunkSize: ["Initial Chunk Size", "number"], + TotalChunkCount: ["Total Chunk Count", "number"], + TotalChunkSize: ["Total Chunk Size", "number"], + LazyChunkCount: ["Lazy Chunk Count", "number"], + LazyChunkSize: ["Lazy Chunk Size", "number"], + AssetCount: ["Asset Count", "number"], + AssetSize: ["Asset Size", "number"], + PolyfillSize: [" Polyfill Size", "number"], + CssSize: [" Css Size", "number"], +}; diff --git a/packages/schematic-utils/src/analytics/logging.ts b/packages/schematic-utils/src/analytics/logging.ts new file mode 100644 index 0000000..7905ebf --- /dev/null +++ b/packages/schematic-utils/src/analytics/logging.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { Logger } from "../logger"; +import { + Analytics, + EventOptions, + PageviewOptions, + ScreenviewOptions, + TimingOptions, +} from "./api"; + +/** + * Analytics implementation that logs analytics events to a logger. This should be used for + * debugging mainly. + */ +export class LoggingAnalytics implements Analytics { + constructor(protected _logger: Logger) {} + + event(category: string, action: string, options?: EventOptions): void { + this._logger.info( + "event " + JSON.stringify({ category, action, ...options }) + ); + } + screenview( + screenName: string, + appName: string, + options?: ScreenviewOptions + ): void { + this._logger.info( + "screenview " + JSON.stringify({ screenName, appName, ...options }) + ); + } + pageview(path: string, options?: PageviewOptions): void { + this._logger.info("pageview " + JSON.stringify({ path, ...options })); + } + timing( + category: string, + variable: string, + time: string | number, + options?: TimingOptions + ): void { + this._logger.info( + "timing " + JSON.stringify({ category, variable, time, ...options }) + ); + } + + flush(): Promise { + return Promise.resolve(); + } +} diff --git a/packages/schematic-utils/src/analytics/multi.ts b/packages/schematic-utils/src/analytics/multi.ts new file mode 100644 index 0000000..63e866b --- /dev/null +++ b/packages/schematic-utils/src/analytics/multi.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + Analytics, + EventOptions, + PageviewOptions, + ScreenviewOptions, + TimingOptions, +} from "./api"; + +/** + * Analytics implementation that reports to multiple analytics backend. + */ +export class MultiAnalytics implements Analytics { + constructor(protected _backends: Analytics[] = []) {} + + push(...backend: Analytics[]) { + this._backends.push(...backend); + } + + event(category: string, action: string, options?: EventOptions): void { + this._backends.forEach((be) => be.event(category, action, options)); + } + screenview( + screenName: string, + appName: string, + options?: ScreenviewOptions + ): void { + this._backends.forEach((be) => be.screenview(screenName, appName, options)); + } + pageview(path: string, options?: PageviewOptions): void { + this._backends.forEach((be) => be.pageview(path, options)); + } + timing( + category: string, + variable: string, + time: string | number, + options?: TimingOptions + ): void { + this._backends.forEach((be) => + be.timing(category, variable, time, options) + ); + } + + flush(): Promise { + return Promise.all(this._backends.map((x) => x.flush())).then(() => {}); + } +} diff --git a/packages/schematic-utils/src/analytics/noop.ts b/packages/schematic-utils/src/analytics/noop.ts new file mode 100644 index 0000000..afde67b --- /dev/null +++ b/packages/schematic-utils/src/analytics/noop.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { Analytics } from "./api"; + +/** + * Analytics implementation that does nothing. + */ +export class NoopAnalytics implements Analytics { + event() {} + screenview() {} + pageview() {} + timing() {} + flush(): Promise { + return Promise.resolve(); + } +} diff --git a/packages/schematic-utils/src/engine/interface.ts b/packages/schematic-utils/src/engine/interface.ts index a6510f8..4453c4b 100644 --- a/packages/schematic-utils/src/engine/interface.ts +++ b/packages/schematic-utils/src/engine/interface.ts @@ -1,4 +1,5 @@ -import { analytics, logging } from "@angular-devkit/core"; +import * as logging from "../logger"; +import * as analytics from "../analytics"; import { Observable } from "rxjs"; import { Url } from "url"; import { FileEntry, MergeStrategy, Tree } from "../tree/interface"; diff --git a/packages/schematic-utils/src/json/index.ts b/packages/schematic-utils/src/json/index.ts new file mode 100644 index 0000000..9cda1df --- /dev/null +++ b/packages/schematic-utils/src/json/index.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import * as schema from "./schema/index"; + +export * from "./interface"; +export * from "./parser"; +export { schema }; diff --git a/packages/schematic-utils/src/json/interface.ts b/packages/schematic-utils/src/json/interface.ts index 47dff02..3241a90 100644 --- a/packages/schematic-utils/src/json/interface.ts +++ b/packages/schematic-utils/src/json/interface.ts @@ -1,3 +1,11 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + export interface Position { readonly offset: number; @@ -38,6 +46,7 @@ export interface JsonAstIdentifier extends JsonAstNodeBase { readonly value: string; } +// eslint-disable-next-line @typescript-eslint/no-empty-interface export interface JsonArray extends Array {} export interface JsonAstArray extends JsonAstNodeBase { diff --git a/packages/schematic-utils/src/json/parser.spec.ts b/packages/schematic-utils/src/json/parser.spec.ts new file mode 100644 index 0000000..9c51e4e --- /dev/null +++ b/packages/schematic-utils/src/json/parser.spec.ts @@ -0,0 +1,461 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { JsonParseMode, parseJson, parseJsonAst } from "./parser"; + +// Node 6 compatibility. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function entries(x: { [key: string]: any }): any { + return Object.keys(x).map((k) => [k, x[k]]); +} + +describe("parseJson and parseJsonAst", () => { + describe("generic", () => { + const numbers = {}; + const errors = ["", "-abcdefghijklmnopqrstuvwxyz"]; + + for (const [n, [start, end, text]] of entries(numbers)) { + it(`works for ${JSON.stringify(n)}`, () => { + const ast = parseJsonAst(n); + expect(ast.start).toEqual({ + offset: start[0], + line: start[1], + character: start[2], + }); + expect(ast.end).toEqual({ + offset: end[0], + line: end[1], + character: end[2], + }); + expect(ast.value).toEqual(JSON.parse(n)); + expect(parseJson(n)).toEqual(JSON.parse(n)); + expect(ast.text).toBe(text === undefined ? n : text); + }); + } + + for (const n of errors) { + it(`errors for ${JSON.stringify(n)}`, () => { + expect(() => parseJsonAst(n)).toThrow(); + expect(() => parseJson(n)).toThrow(); + expect(() => JSON.parse(n)).toThrow(); + }); + } + }); + + describe("numbers", () => { + const numbers = { + "1234": [ + [0, 0, 0], + [4, 0, 4], + ], + "12E34": [ + [0, 0, 0], + [5, 0, 5], + ], + "12E+4": [ + [0, 0, 0], + [5, 0, 5], + ], + "12E-4": [ + [0, 0, 0], + [5, 0, 5], + ], + "12E-0004": [ + [0, 0, 0], + [8, 0, 8], + ], + " 1234 ": [[3, 0, 3], [7, 0, 7], "1234"], + "\r1234\t": [[1, 0, 1], [5, 0, 5], "1234"], + "\n1234\n": [[1, 1, 0], [5, 1, 4], "1234"], + "0.123": [ + [0, 0, 0], + [5, 0, 5], + ], + "0": [ + [0, 0, 0], + [1, 0, 1], + ], + "\n\n\n\n\n0": [[5, 5, 0], [6, 5, 1], "0"], + }; + const errors = [ + "000", + "01", + "1E1+1", + "--", + "0-0", + "-0-0", + "0.0.0", + "0\n.0\n.0", + "0.", + "+1", + "Infinity", + "NaN", + "-Infinity", + "+Infinity", + ]; + + for (const [n, [start, end, text]] of entries(numbers)) { + it(`works for ${JSON.stringify(n)}`, () => { + const ast = parseJsonAst(n); + expect(ast.kind).toBe("number"); + expect(ast.start).toEqual({ + offset: start[0], + line: start[1], + character: start[2], + }); + expect(ast.end).toEqual({ + offset: end[0], + line: end[1], + character: end[2], + }); + expect(ast.value).toEqual(JSON.parse(n)); + expect(parseJson(n)).toEqual(JSON.parse(n)); + expect(ast.text).toBe(text === undefined ? n : text); + }); + } + + for (const n of errors) { + it(`errors for ${JSON.stringify(n)}`, () => { + expect(() => parseJsonAst(n)).toThrow(); + expect(() => parseJson(n)).toThrow(); + expect(() => JSON.parse(n)).toThrow(); + }); + } + }); + + describe("strings", () => { + const strings = { + '""': [ + [0, 0, 0], + [2, 0, 2], + ], + '"hello"': [ + [0, 0, 0], + [7, 0, 7], + ], + '"a\\nb"': [ + [0, 0, 0], + [6, 0, 6], + ], + '"a\\nb\\tc\\rd\\\\e\\/f\\"g\\bh\\fi"': [ + [0, 0, 0], + [27, 0, 27], + ], + '"a\\u1234b"': [ + [0, 0, 0], + [10, 0, 10], + ], + }; + const errors = [ + '"\\z"', + "'hello'", + '"\\', + '"a\\zb"', + '"a', + '"a\nb"', + '"\\\n "', + ]; + + for (const [n, [start, end, text]] of entries(strings)) { + it(`works for ${JSON.stringify(n)}`, () => { + const ast = parseJsonAst(n); + expect(ast.kind).toBe("string"); + expect(ast.start).toEqual({ + offset: start[0], + line: start[1], + character: start[2], + }); + expect(ast.end).toEqual({ + offset: end[0], + line: end[1], + character: end[2], + }); + expect(ast.value).toEqual(JSON.parse(n)); + expect(parseJson(n)).toEqual(JSON.parse(n)); + expect(ast.text).toBe(text === undefined ? n : text); + }); + } + + for (const n of errors) { + it(`errors for ${JSON.stringify(n)}`, () => { + expect(() => parseJsonAst(n)).toThrow(); + expect(() => parseJson(n)).toThrow(); + expect(() => JSON.parse(n)).toThrow(); + }); + } + }); + + describe("constants", () => { + const strings = { + true: ["true", [0, 0, 0], [4, 0, 4], true], + false: ["false", [0, 0, 0], [5, 0, 5], false], + null: ["null", [0, 0, 0], [4, 0, 4], null], + }; + const errors = ["undefined"]; + + for (const [n, [kind, start, end, value, text]] of entries(strings)) { + it(`works for ${JSON.stringify(n)}`, () => { + const ast = parseJsonAst(n); + expect(ast.kind).toBe(kind); + expect(ast.start).toEqual({ + offset: start[0], + line: start[1], + character: start[2], + }); + expect(ast.end).toEqual({ + offset: end[0], + line: end[1], + character: end[2], + }); + expect(ast.value).toEqual(value); + expect(ast.text).toBe(text === undefined ? n : text); + }); + } + + for (const n of errors) { + it(`errors for ${JSON.stringify(n)}`, () => { + expect(() => parseJsonAst(n)).toThrow(); + expect(() => parseJson(n)).toThrow(); + expect(() => JSON.parse(n)).toThrow(); + }); + } + }); + + describe("arrays", () => { + const strings = { + "[0,1,2,3]": [ + [0, 0, 0], + [9, 0, 9], + ], + "[[0],1,2,3]": [ + [0, 0, 0], + [11, 0, 11], + ], + "[0\n,\n1,2,3]": [ + [0, 0, 0], + [11, 2, 6], + ], + "[]": [ + [0, 0, 0], + [2, 0, 2], + ], + "\n[\n]\n": [[1, 1, 0], [4, 2, 1], "[\n]"], + "[\n]": [ + [0, 0, 0], + [3, 1, 1], + ], + "[\n\n]": [ + [0, 0, 0], + [4, 2, 1], + ], + }; + const errors = ["[", "[,]", "[0,]", "[,0]"]; + + for (const [n, [start, end, text]] of entries(strings)) { + it(`works for ${JSON.stringify(n)}`, () => { + const ast = parseJsonAst(n); + expect(ast.kind).toBe("array"); + expect(ast.start).toEqual({ + offset: start[0], + line: start[1], + character: start[2], + }); + expect(ast.end).toEqual({ + offset: end[0], + line: end[1], + character: end[2], + }); + expect(ast.value).toEqual(JSON.parse(n)); + expect(parseJson(n)).toEqual(JSON.parse(n)); + expect(ast.text).toBe(text === undefined ? n : text); + }); + } + + for (const n of errors) { + it(`errors for ${JSON.stringify(n)}`, () => { + expect(() => parseJsonAst(n)).toThrow(); + expect(() => parseJson(n)).toThrow(); + expect(() => JSON.parse(n)).toThrow(); + }); + } + }); + + describe("objects", () => { + const strings = { + "{}": [ + [0, 0, 0], + [2, 0, 2], + ], + "{\n}": [ + [0, 0, 0], + [3, 1, 1], + ], + '{"hello": "world"}': [ + [0, 0, 0], + [18, 0, 18], + ], + '{"hello": 0, "world": 1}': [ + [0, 0, 0], + [24, 0, 24], + ], + '{"hello": {"hello": {"hello": "world"}}}': [ + [0, 0, 0], + [40, 0, 40], + ], + }; + const errors = ["{", "{,}", '{"hello": 0']; + + for (const [n, [start, end, text]] of entries(strings)) { + it(`works for ${JSON.stringify(n)}`, () => { + const ast = parseJsonAst(n); + expect(ast.kind).toBe("object"); + expect(ast.start).toEqual({ + offset: start[0], + line: start[1], + character: start[2], + }); + expect(ast.end).toEqual({ + offset: end[0], + line: end[1], + character: end[2], + }); + expect(ast.value).toEqual(JSON.parse(n)); + expect(parseJson(n)).toEqual(JSON.parse(n)); + expect(ast.text).toBe(text === undefined ? n : text); + }); + } + + for (const n of errors) { + it(`errors for ${JSON.stringify(n)}`, () => { + expect(() => parseJsonAst(n)).toThrow(); + expect(() => parseJson(n)).toThrow(); + expect(() => JSON.parse(n)).toThrow(); + }); + } + }); + + describe("loose", () => { + const strings = { + "{'hello': 0}": [[0, 0, 0], [12, 0, 12], { hello: 0 }], + "{hello: 0}": [[0, 0, 0], [10, 0, 10], { hello: 0 }], + "{1: 0}": [[0, 0, 0], [6, 0, 6], { 1: 0 }], + "{hello\n:/**/ 0}": [[0, 0, 0], [15, 1, 8], { hello: 0 }], + "{\n// hello\n}": [[0, 0, 0], [12, 2, 1], {}], + "{\n/* hello\n*/ }": [[0, 0, 0], [15, 2, 4], {}], + "{}// ": [[0, 0, 0], [2, 0, 2], {}, "{}"], + "{}//": [[0, 0, 0], [2, 0, 2], {}, "{}"], + "{hello:0,}": [[0, 0, 0], [10, 0, 10], { hello: 0 }], + "{hello:0/**/,}": [[0, 0, 0], [14, 0, 14], { hello: 0 }], + "{hello:0,/**/}": [[0, 0, 0], [14, 0, 14], { hello: 0 }], + '{hi:["hello",]}': [[0, 0, 0], [15, 0, 15], { hi: ["hello"] }], + '{hi:["hello",/* */]}': [[0, 0, 0], [20, 0, 20], { hi: ["hello"] }], + '{hi:["hello"/* */,]}': [[0, 0, 0], [20, 0, 20], { hi: ["hello"] }], + '{hi:["hello" , ] , }': [[0, 0, 0], [20, 0, 20], { hi: ["hello"] }], + '{hi:"\\\n "}': [[0, 0, 0], [10, 1, 3], { hi: "\n " }], + "{d: -0xdecaf, e: Infinity, f: -Infinity, g: +Infinity, h: NaN,}": [ + [0, 0, 0], + [63, 0, 63], + { + d: -0xdecaf, + e: Infinity, + f: -Infinity, + g: Infinity, + h: NaN, + }, + ], + }; + const errors = ["{1b: 0}", " /*", "", ".Infinity"]; + + for (const [n, [start, end, value, text]] of entries(strings)) { + it(`works for ${JSON.stringify(n)}`, () => { + const ast = parseJsonAst(n, JsonParseMode.Loose); + expect(ast.kind).toBe("object"); + expect(ast.start).toEqual({ + offset: start[0], + line: start[1], + character: start[2], + }); + expect(ast.end).toEqual({ + offset: end[0], + line: end[1], + character: end[2], + }); + expect(ast.value).toEqual(value); + expect(parseJson(n, JsonParseMode.Loose)).toEqual(value); + expect(ast.text).toBe(text === undefined ? n : text); + }); + } + + for (const n of errors) { + it(`errors for ${JSON.stringify(n)}`, () => { + expect(() => parseJsonAst(n, JsonParseMode.Loose)).toThrow(); + expect(() => parseJson(n, JsonParseMode.Loose)).toThrow(); + expect(() => JSON.parse(n)).toThrow(); + }); + } + }); + + describe("complex", () => { + it("strips comments", () => { + expect( + parseJson( + ` + // THIS IS A COMMENT + { + /* THIS IS ALSO A COMMENT */ // IGNORED BECAUSE COMMENT + // AGAIN, COMMENT /* THIS SHOULD NOT BE WEIRD + "a": "this // should not be a comment", + "a2": "this /* should also not be a comment", + /* MULTIPLE + LINE + COMMENT + \o/ */ + "b" /* COMMENT */: /* YOU GUESSED IT */ 1 // COMMENT + , /* STILL VALID */ + "c": 2 + } + `, + JsonParseMode.Loose + ) + ).toEqual({ + a: "this // should not be a comment", + a2: "this /* should also not be a comment", + b: 1, + c: 2, + }); + }); + + it("works with json5.org example", () => { + const input = `{ + // comments + unquoted: 'and you can quote me on that', + 'singleQuotes': 'I can use "double quotes" here', + lineBreaks: "Look, Mom! \\ +No \\\\n's!", + hexadecimal: 0xdecaf, + leadingDecimalPoint: .8675309, andTrailing: 8675309., + positiveSign: +1, + trailingComma: 'in objects', andIn: ['arrays',], + "backwardsCompatible": "with JSON", + }`; + + expect(parseJson(input, JsonParseMode.Json5)).toEqual({ + unquoted: "and you can quote me on that", + singleQuotes: 'I can use "double quotes" here', + lineBreaks: "Look, Mom! \nNo \\n's!", + hexadecimal: 0xdecaf, + leadingDecimalPoint: 0.8675309, + andTrailing: 8675309, + positiveSign: +1, + trailingComma: "in objects", + andIn: ["arrays"], + backwardsCompatible: "with JSON", + }); + }); + }); +}); diff --git a/packages/schematic-utils/src/json/parser.ts b/packages/schematic-utils/src/json/parser.ts new file mode 100644 index 0000000..735b496 --- /dev/null +++ b/packages/schematic-utils/src/json/parser.ts @@ -0,0 +1,1038 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/* eslint-disable no-constant-condition */ +import { BaseException } from "../exceptions/exception"; +import { + JsonArray, + JsonAstArray, + JsonAstComment, + JsonAstConstantFalse, + JsonAstConstantNull, + JsonAstConstantTrue, + JsonAstIdentifier, + JsonAstKeyValue, + JsonAstMultilineComment, + JsonAstNode, + JsonAstNumber, + JsonAstObject, + JsonAstString, + JsonObject, + JsonValue, + Position, +} from "./interface"; + +export class JsonException extends BaseException {} + +/** + * A character was invalid in this context. + * @deprecated Deprecated since version 11. Use 3rd party JSON parsers such as `jsonc-parser` instead. + */ +export class InvalidJsonCharacterException extends JsonException { + invalidChar: string; + line: number; + character: number; + offset: number; + + constructor(context: JsonParserContext) { + const pos = context.previous; + const invalidChar = JSON.stringify(_peek(context)); + super( + `Invalid JSON character: ${invalidChar} at ${pos.line}:${pos.character}.` + ); + + this.invalidChar = invalidChar; + this.line = pos.line; + this.offset = pos.offset; + this.character = pos.character; + } +} + +/** + * More input was expected, but we reached the end of the stream. + * @deprecated Deprecated since version 11. Use 3rd party JSON parsers such as `jsonc-parser` instead. + */ +export class UnexpectedEndOfInputException extends JsonException { + // eslint-disable-next-line + constructor(_context: JsonParserContext) { + super(`Unexpected end of file.`); + } +} + +/** + * An error happened within a file. + * @deprecated Deprecated since version 11. Use 3rd party JSON parsers such as `jsonc-parser` instead. + */ +export class PathSpecificJsonException extends JsonException { + constructor(public path: string, public exception: JsonException) { + super( + `An error happened at file path ${JSON.stringify(path)}: ${ + exception.message + }` + ); + } +} + +/** + * Context passed around the parser with information about where we currently are in the parse. + * @deprecated Deprecated since version 11. Use 3rd party JSON parsers such as `jsonc-parser` instead. + */ +export interface JsonParserContext { + position: Position; + previous: Position; + readonly original: string; + readonly mode: JsonParseMode; +} + +/** + * Peek and return the next character from the context. + * @private + */ +function _peek(context: JsonParserContext): string | undefined { + return context.original[context.position.offset]; +} + +/** + * Move the context to the next character, including incrementing the line if necessary. + * @private + */ +// eslint-disable-next-line +function _next(context: JsonParserContext) { + context.previous = context.position; + + let { offset, line, character } = context.position; + const char = context.original[offset]; + offset++; + if (char == "\n") { + line++; + character = 0; + } else { + character++; + } + context.position = { offset, line, character }; +} + +/** + * Read a single character from the input. If a `valid` string is passed, validate that the + * character is included in the valid string. + * @private + */ +function _token(context: JsonParserContext, valid: string): string; +function _token(context: JsonParserContext): string | undefined; +function _token( + context: JsonParserContext, + valid?: string +): string | undefined { + const char = _peek(context); + if (valid) { + if (!char) { + throw new UnexpectedEndOfInputException(context); + } + if (valid.indexOf(char) == -1) { + throw new InvalidJsonCharacterException(context); + } + } + + // Move the position of the context to the next character. + _next(context); + + return char; +} + +/** + * Read the exponent part of a number. The exponent part is looser for JSON than the number + * part. `str` is the string of the number itself found so far, and start the position + * where the full number started. Returns the node found. + * @private + */ +function _readExpNumber( + context: JsonParserContext, + start: Position, + str: string, + comments: (JsonAstComment | JsonAstMultilineComment)[] +): JsonAstNumber { + let char; + let signed = false; + + while (true) { + char = _token(context); + if (char == "+" || char == "-") { + if (signed) { + break; + } + signed = true; + str += char; + } else if ( + char == "0" || + char == "1" || + char == "2" || + char == "3" || + char == "4" || + char == "5" || + char == "6" || + char == "7" || + char == "8" || + char == "9" + ) { + signed = true; + str += char; + } else { + break; + } + } + + // We're done reading this number. + context.position = context.previous; + + return { + kind: "number", + start, + end: context.position, + text: context.original.substring(start.offset, context.position.offset), + value: Number.parseFloat(str), + comments: comments, + }; +} + +/** + * Read the hexa part of a 0xBADCAFE hexadecimal number. + * @private + */ +function _readHexaNumber( + context: JsonParserContext, + isNegative: boolean, + start: Position, + comments: (JsonAstComment | JsonAstMultilineComment)[] +): JsonAstNumber { + // Read an hexadecimal number, until it's not hexadecimal. + let hexa = ""; + const valid = "0123456789abcdefABCDEF"; + + for (let ch = _peek(context); ch && valid.includes(ch); ch = _peek(context)) { + // Add it to the hexa string. + hexa += ch; + // Move the position of the context to the next character. + _next(context); + } + + const value = Number.parseInt(hexa, 16); + + // We're done reading this number. + return { + kind: "number", + start, + end: context.position, + text: context.original.substring(start.offset, context.position.offset), + value: isNegative ? -value : value, + comments, + }; +} + +/** + * Read a number from the context. + * @private + */ +function _readNumber( + context: JsonParserContext, + comments = _readBlanks(context) +): JsonAstNumber { + let str = ""; + let dotted = false; + const start = context.position; + + // read until `e` or end of line. + while (true) { + const char = _token(context); + + // Read tokens, one by one. + if (char == "-") { + if (str != "") { + throw new InvalidJsonCharacterException(context); + } + } else if ( + char == "I" && + (str == "-" || str == "" || str == "+") && + (context.mode & JsonParseMode.NumberConstantsAllowed) != 0 + ) { + // Infinity? + // _token(context, 'I'); Already read. + _token(context, "n"); + _token(context, "f"); + _token(context, "i"); + _token(context, "n"); + _token(context, "i"); + _token(context, "t"); + _token(context, "y"); + + str += "Infinity"; + break; + } else if (char == "0") { + if (str == "0" || str == "-0") { + throw new InvalidJsonCharacterException(context); + } + } else if ( + char == "1" || + char == "2" || + char == "3" || + char == "4" || + char == "5" || + char == "6" || + char == "7" || + char == "8" || + char == "9" + ) { + if (str == "0" || str == "-0") { + throw new InvalidJsonCharacterException(context); + } + } else if (char == "+" && str == "") { + // Pass over. + } else if (char == ".") { + if (dotted) { + throw new InvalidJsonCharacterException(context); + } + dotted = true; + } else if (char == "e" || char == "E") { + return _readExpNumber(context, start, str + char, comments); + } else if ( + char == "x" && + (str == "0" || str == "-0") && + (context.mode & JsonParseMode.HexadecimalNumberAllowed) != 0 + ) { + return _readHexaNumber(context, str == "-0", start, comments); + } else { + // We read one too many characters, so rollback the last character. + context.position = context.previous; + break; + } + + str += char; + } + + // We're done reading this number. + if ( + str.endsWith(".") && + (context.mode & JsonParseMode.HexadecimalNumberAllowed) == 0 + ) { + throw new InvalidJsonCharacterException(context); + } + + return { + kind: "number", + start, + end: context.position, + text: context.original.substring(start.offset, context.position.offset), + value: Number.parseFloat(str), + comments, + }; +} + +/** + * Read a string from the context. Takes the comments of the string or read the blanks before the + * string. + * @private + */ +function _readString( + context: JsonParserContext, + comments = _readBlanks(context) +): JsonAstString { + const start = context.position; + + // Consume the first string delimiter. + const delim = _token(context); + if ((context.mode & JsonParseMode.SingleQuotesAllowed) == 0) { + if (delim == "'") { + throw new InvalidJsonCharacterException(context); + } + } + + let str = ""; + while (true) { + let char = _token(context); + if (char == delim) { + return { + kind: "string", + start, + end: context.position, + text: context.original.substring(start.offset, context.position.offset), + value: str, + comments: comments, + }; + } else if (char == "\\") { + char = _token(context); + switch (char) { + case "\\": + case "/": + case '"': + case delim: + str += char; + break; + + case "b": + str += "\b"; + break; + case "f": + str += "\f"; + break; + case "n": + str += "\n"; + break; + case "r": + str += "\r"; + break; + case "t": + str += "\t"; + break; + case "u": + /* eslint-disable */ + const [c0] = _token(context, "0123456789abcdefABCDEF"); + const [c1] = _token(context, "0123456789abcdefABCDEF"); + const [c2] = _token(context, "0123456789abcdefABCDEF"); + const [c3] = _token(context, "0123456789abcdefABCDEF"); + /* eslint-enable */ + str += String.fromCharCode(parseInt(c0 + c1 + c2 + c3, 16)); + break; + + case undefined: + throw new UnexpectedEndOfInputException(context); + + case "\n": + // Only valid when multiline strings are allowed. + if ((context.mode & JsonParseMode.MultiLineStringAllowed) == 0) { + throw new InvalidJsonCharacterException(context); + } + str += char; + break; + + default: + throw new InvalidJsonCharacterException(context); + } + } else if (char === undefined) { + throw new UnexpectedEndOfInputException(context); + } else if ( + char == "\b" || + char == "\f" || + char == "\n" || + char == "\r" || + char == "\t" + ) { + throw new InvalidJsonCharacterException(context); + } else { + str += char; + } + } +} + +/** + * Read the constant `true` from the context. + * @private + */ +function _readTrue( + context: JsonParserContext, + comments = _readBlanks(context) +): JsonAstConstantTrue { + const start = context.position; + _token(context, "t"); + _token(context, "r"); + _token(context, "u"); + _token(context, "e"); + + const end = context.position; + + return { + kind: "true", + start, + end, + text: context.original.substring(start.offset, end.offset), + value: true, + comments, + }; +} + +/** + * Read the constant `false` from the context. + * @private + */ +function _readFalse( + context: JsonParserContext, + comments = _readBlanks(context) +): JsonAstConstantFalse { + const start = context.position; + _token(context, "f"); + _token(context, "a"); + _token(context, "l"); + _token(context, "s"); + _token(context, "e"); + + const end = context.position; + + return { + kind: "false", + start, + end, + text: context.original.substring(start.offset, end.offset), + value: false, + comments, + }; +} + +/** + * Read the constant `null` from the context. + * @private + */ +function _readNull( + context: JsonParserContext, + comments = _readBlanks(context) +): JsonAstConstantNull { + const start = context.position; + + _token(context, "n"); + _token(context, "u"); + _token(context, "l"); + _token(context, "l"); + + const end = context.position; + + return { + kind: "null", + start, + end, + text: context.original.substring(start.offset, end.offset), + value: null, + comments: comments, + }; +} + +/** + * Read the constant `NaN` from the context. + * @private + */ +function _readNaN( + context: JsonParserContext, + comments = _readBlanks(context) +): JsonAstNumber { + const start = context.position; + + _token(context, "N"); + _token(context, "a"); + _token(context, "N"); + + const end = context.position; + + return { + kind: "number", + start, + end, + text: context.original.substring(start.offset, end.offset), + value: NaN, + comments: comments, + }; +} + +/** + * Read an array of JSON values from the context. + * @private + */ +function _readArray( + context: JsonParserContext, + comments = _readBlanks(context) +): JsonAstArray { + const start = context.position; + + // Consume the first delimiter. + _token(context, "["); + const value: JsonArray = []; + const elements: JsonAstNode[] = []; + + _readBlanks(context); + if (_peek(context) != "]") { + const node = _readValue(context); + elements.push(node); + value.push(node.value); + } + + while (_peek(context) != "]") { + _token(context, ","); + + const valueComments = _readBlanks(context); + if ( + (context.mode & JsonParseMode.TrailingCommasAllowed) !== 0 && + _peek(context) === "]" + ) { + break; + } + const node = _readValue(context, valueComments); + elements.push(node); + value.push(node.value); + } + + _token(context, "]"); + + return { + kind: "array", + start, + end: context.position, + text: context.original.substring(start.offset, context.position.offset), + value, + elements, + comments, + }; +} + +/** + * Read an identifier from the context. An identifier is a valid JavaScript identifier, and this + * function is only used in Loose mode. + * @private + */ +function _readIdentifier( + context: JsonParserContext, + comments = _readBlanks(context) +): JsonAstIdentifier { + const start = context.position; + + let char = _peek(context); + if (char && "0123456789".indexOf(char) != -1) { + const identifierNode = _readNumber(context); + + return { + kind: "identifier", + start, + end: identifierNode.end, + text: identifierNode.text, + value: identifierNode.value.toString(), + }; + } + + const identValidFirstChar = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMOPQRSTUVWXYZ"; + const identValidChar = + "_$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMOPQRSTUVWXYZ0123456789"; + let first = true; + let value = ""; + + // eslint-disable-next-line + while (true) { + char = _token(context); + if ( + char == undefined || + (first + ? identValidFirstChar.indexOf(char) + : identValidChar.indexOf(char)) == -1 + ) { + context.position = context.previous; + + return { + kind: "identifier", + start, + end: context.position, + text: context.original.substr(start.offset, context.position.offset), + value, + comments, + }; + } + + value += char; + first = false; + } +} + +/** + * Read a property from the context. A property is a string or (in Loose mode only) a number or + * an identifier, followed by a colon `:`. + * @private + */ +function _readProperty( + context: JsonParserContext, + comments = _readBlanks(context) +): JsonAstKeyValue { + const start = context.position; + + let key; + if ((context.mode & JsonParseMode.IdentifierKeyNamesAllowed) != 0) { + const top = _peek(context); + if (top == '"' || top == "'") { + key = _readString(context); + } else { + key = _readIdentifier(context); + } + } else { + key = _readString(context); + } + + _readBlanks(context); + _token(context, ":"); + const value = _readValue(context); + const end = context.position; + + return { + kind: "keyvalue", + key, + value, + start, + end, + text: context.original.substring(start.offset, end.offset), + comments, + }; +} + +/** + * Read an object of properties -> JSON values from the context. + * @private + */ +function _readObject( + context: JsonParserContext, + comments = _readBlanks(context) +): JsonAstObject { + const start = context.position; + // Consume the first delimiter. + _token(context, "{"); + const value: JsonObject = {}; + const properties: JsonAstKeyValue[] = []; + + _readBlanks(context); + if (_peek(context) != "}") { + const property = _readProperty(context); + value[property.key.value] = property.value.value; + properties.push(property); + + while (_peek(context) != "}") { + _token(context, ","); + + const propertyComments = _readBlanks(context); + if ( + (context.mode & JsonParseMode.TrailingCommasAllowed) !== 0 && + _peek(context) === "}" + ) { + break; + } + const property = _readProperty(context, propertyComments); + value[property.key.value] = property.value.value; + properties.push(property); + } + } + + _token(context, "}"); + + return { + kind: "object", + properties, + start, + end: context.position, + value, + text: context.original.substring(start.offset, context.position.offset), + comments, + }; +} + +/** + * Remove any blank character or comments (in Loose mode) from the context, returning an array + * of comments if any are found. + * @private + */ +function _readBlanks( + context: JsonParserContext +): (JsonAstComment | JsonAstMultilineComment)[] { + if ((context.mode & JsonParseMode.CommentsAllowed) != 0) { + const comments: (JsonAstComment | JsonAstMultilineComment)[] = []; + // eslint-disable-next-line + while (true) { + const char = context.original[context.position.offset]; + if (char == "/" && context.original[context.position.offset + 1] == "*") { + const start = context.position; + // Multi line comment. + _next(context); + _next(context); + + while ( + context.original[context.position.offset] != "*" || + context.original[context.position.offset + 1] != "/" + ) { + _next(context); + if (context.position.offset >= context.original.length) { + throw new UnexpectedEndOfInputException(context); + } + } + // Remove "*/". + _next(context); + _next(context); + + comments.push({ + kind: "multicomment", + start, + end: context.position, + text: context.original.substring( + start.offset, + context.position.offset + ), + content: context.original.substring( + start.offset + 2, + context.position.offset - 2 + ), + }); + } else if ( + char == "/" && + context.original[context.position.offset + 1] == "/" + ) { + const start = context.position; + // Multi line comment. + _next(context); + _next(context); + + while (context.original[context.position.offset] != "\n") { + _next(context); + if (context.position.offset >= context.original.length) { + break; + } + } + + // Remove "\n". + if (context.position.offset < context.original.length) { + _next(context); + } + comments.push({ + kind: "comment", + start, + end: context.position, + text: context.original.substring( + start.offset, + context.position.offset + ), + content: context.original.substring( + start.offset + 2, + context.position.offset - 1 + ), + }); + } else if ( + char == " " || + char == "\t" || + char == "\n" || + char == "\r" || + char == "\f" + ) { + _next(context); + } else { + break; + } + } + + return comments; + } else { + let char = context.original[context.position.offset]; + while ( + char == " " || + char == "\t" || + char == "\n" || + char == "\r" || + char == "\f" + ) { + _next(context); + char = context.original[context.position.offset]; + } + + return []; + } +} + +/** + * Read a JSON value from the context, which can be any form of JSON value. + * @private + */ +function _readValue( + context: JsonParserContext, + comments = _readBlanks(context) +): JsonAstNode { + let result: JsonAstNode; + + // Clean up before. + const char = _peek(context); + switch (char) { + case undefined: + throw new UnexpectedEndOfInputException(context); + + case "-": + case "0": + case "1": + case "2": + case "3": + case "4": + case "5": + case "6": + case "7": + case "8": + case "9": + result = _readNumber(context, comments); + break; + + case ".": + case "+": + if ((context.mode & JsonParseMode.LaxNumberParsingAllowed) == 0) { + throw new InvalidJsonCharacterException(context); + } + result = _readNumber(context, comments); + break; + + case "'": + case '"': + result = _readString(context, comments); + break; + + case "I": + if ((context.mode & JsonParseMode.NumberConstantsAllowed) == 0) { + throw new InvalidJsonCharacterException(context); + } + result = _readNumber(context, comments); + break; + + case "N": + if ((context.mode & JsonParseMode.NumberConstantsAllowed) == 0) { + throw new InvalidJsonCharacterException(context); + } + result = _readNaN(context, comments); + break; + + case "t": + result = _readTrue(context, comments); + break; + case "f": + result = _readFalse(context, comments); + break; + case "n": + result = _readNull(context, comments); + break; + + case "[": + result = _readArray(context, comments); + break; + + case "{": + result = _readObject(context, comments); + break; + + default: + throw new InvalidJsonCharacterException(context); + } + + // Clean up after. + _readBlanks(context); + + return result; +} + +/** + * The Parse mode used for parsing the JSON string. + */ +export enum JsonParseMode { + Strict = 0, // Standard JSON. + CommentsAllowed = 1 << 0, // Allows comments, both single or multi lines. + SingleQuotesAllowed = 1 << 1, // Allow single quoted strings. + IdentifierKeyNamesAllowed = 1 << 2, // Allow identifiers as objectp properties. + TrailingCommasAllowed = 1 << 3, + HexadecimalNumberAllowed = 1 << 4, + MultiLineStringAllowed = 1 << 5, + LaxNumberParsingAllowed = 1 << 6, // Allow `.` or `+` as the first character of a number. + NumberConstantsAllowed = 1 << 7, // Allow -Infinity, Infinity and NaN. + + Default = Strict, + Loose = CommentsAllowed | + SingleQuotesAllowed | + IdentifierKeyNamesAllowed | + TrailingCommasAllowed | + HexadecimalNumberAllowed | + MultiLineStringAllowed | + LaxNumberParsingAllowed | + NumberConstantsAllowed, + + Json = Strict, + Json5 = Loose, +} + +/** + * Parse the JSON string and return its AST. The AST may be losing data (end comments are + * discarded for example, and space characters are not represented in the AST), but all values + * will have a single node in the AST (a 1-to-1 mapping). + * + * @deprecated Deprecated since version 11. Use 3rd party JSON parsers such as `jsonc-parser` instead. + * @param input The string to use. + * @param mode The mode to parse the input with. {@see JsonParseMode}. + * @returns {JsonAstNode} The root node of the value of the AST. + */ +export function parseJsonAst( + input: string, + mode = JsonParseMode.Default +): JsonAstNode { + if (mode == JsonParseMode.Default) { + mode = JsonParseMode.Strict; + } + + const context = { + position: { offset: 0, line: 0, character: 0 }, + previous: { offset: 0, line: 0, character: 0 }, + original: input, + comments: undefined, + mode, + }; + + const ast = _readValue(context); + if (context.position.offset < input.length) { + const rest = input.substr(context.position.offset); + const i = rest.length > 20 ? rest.substr(0, 20) + "..." : rest; + throw new Error( + `Expected end of file, got "${i}" at ` + + `${context.position.line}:${context.position.character}.` + ); + } + + return ast; +} + +/** + * Options for the parseJson() function. + * @deprecated Deprecated since version 11. Use 3rd party JSON parsers such as `jsonc-parser` instead. + */ +export interface ParseJsonOptions { + /** + * If omitted, will only emit errors related to the content of the JSON. If specified, any + * JSON errors will also include the path of the file that caused the error. + */ + path?: string; +} + +/** + * Parse a JSON string into its value. This discards the AST and only returns the value itself. + * + * If a path option is pass, it also absorbs JSON parsing errors and return a new error with the + * path in it. Useful for showing errors when parsing from a file. + * + * @deprecated Deprecated since version 11. Use 3rd party JSON parsers such as `jsonc-parser` instead. + * @param input The string to parse. + * @param mode The mode to parse the input with. {@see JsonParseMode}. + * @param options Additional optinos for parsing. + * @returns {JsonValue} The value represented by the JSON string. + */ +export function parseJson( + input: string, + mode = JsonParseMode.Default, + options?: ParseJsonOptions +): JsonValue { + try { + // Try parsing for the fastest path available, if error, uses our own parser for better errors. + if (mode == JsonParseMode.Strict) { + try { + return JSON.parse(input); + } catch (err) { + return parseJsonAst(input, mode).value; + } + } + + return parseJsonAst(input, mode).value; + } catch (e) { + if (options && options.path && e instanceof JsonException) { + throw new PathSpecificJsonException(options.path, e); + } + throw e; + } +} diff --git a/packages/schematic-utils/src/json/schema/index.ts b/packages/schematic-utils/src/json/schema/index.ts new file mode 100644 index 0000000..fb21153 --- /dev/null +++ b/packages/schematic-utils/src/json/schema/index.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import * as transforms from "./transforms"; + +export * from "./interface"; +export * from "./pointer"; +export * from "./registry"; +export * from "./schema"; +export * from "./visitor"; +export * from "./utility"; + +export { transforms }; diff --git a/packages/schematic-utils/src/json/schema/interface.ts b/packages/schematic-utils/src/json/schema/interface.ts new file mode 100644 index 0000000..e8bb578 --- /dev/null +++ b/packages/schematic-utils/src/json/schema/interface.ts @@ -0,0 +1,127 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { ErrorObject, Format } from "ajv"; +import { Observable, SubscribableOrPromise } from "rxjs"; +import { JsonArray, JsonObject, JsonValue } from "../interface"; + +export type JsonPointer = string & { + __PRIVATE_DEVKIT_JSON_POINTER: void; +}; +export interface SchemaValidatorResult { + data: JsonValue; + success: boolean; + errors?: SchemaValidatorError[]; +} + +export type SchemaValidatorError = Partial; + +export interface SchemaValidatorOptions { + applyPreTransforms?: boolean; + applyPostTransforms?: boolean; + withPrompts?: boolean; +} + +export interface SchemaValidator { + (data: JsonValue, options?: SchemaValidatorOptions): Observable< + SchemaValidatorResult + >; +} + +export type SchemaFormatter = Format; + +export interface SchemaFormat { + name: string; + formatter: SchemaFormatter; +} + +export interface SmartDefaultProvider { + (schema: JsonObject): T | Observable; +} + +export interface SchemaKeywordValidator { + ( + data: JsonValue, + schema: JsonValue, + parent: JsonObject | JsonArray | undefined, + parentProperty: string | number | undefined, + pointer: JsonPointer, + rootData: JsonValue + ): boolean | Observable; +} + +export interface PromptDefinition { + id: string; + type: string; + message: string; + default?: string | string[] | number | boolean | null; + validator?: ( + value: JsonValue + ) => boolean | string | Promise; + + items?: Array; + + raw?: string | JsonObject; + multiselect?: boolean; + propertyTypes: Set; +} + +export type PromptProvider = ( + definitions: Array +) => SubscribableOrPromise<{ [id: string]: JsonValue }>; + +export interface SchemaRegistry { + compile(schema: Object): Observable; // eslint-disable-line + /** + * @deprecated since 11.2 without replacement. + * Producing a flatten schema document does not in all cases produce a schema with identical behavior to the original. + * See: https://json-schema.org/draft/2019-09/json-schema-core.html#rfc.appendix.B.2 + */ + flatten(schema: JsonObject | string): Observable; + addFormat(format: SchemaFormat): void; + addSmartDefaultProvider( + source: string, + provider: SmartDefaultProvider + ): void; + usePromptProvider(provider: PromptProvider): void; + useXDeprecatedProvider(onUsage: (message: string) => void): void; + + /** + * Add a transformation step before the validation of any Json. + * @param {JsonVisitor} visitor The visitor to transform every value. + * @param {JsonVisitor[]} deps A list of other visitors to run before. + */ + addPreTransform(visitor: JsonVisitor, deps?: JsonVisitor[]): void; + + /** + * Add a transformation step after the validation of any Json. The JSON will not be validated + * after the POST, so if transformations are not compatible with the Schema it will not result + * in an error. + * @param {JsonVisitor} visitor The visitor to transform every value. + * @param {JsonVisitor[]} deps A list of other visitors to run before. + */ + addPostTransform(visitor: JsonVisitor, deps?: JsonVisitor[]): void; +} + +export interface JsonSchemaVisitor { + ( + current: JsonObject | JsonArray, + pointer: JsonPointer, + parentSchema?: JsonObject | JsonArray, + index?: string + ): void; +} + +export interface JsonVisitor { + ( + value: JsonValue, + pointer: JsonPointer, + schema?: JsonObject, + root?: JsonObject | JsonArray + ): Observable | JsonValue; +} diff --git a/packages/schematic-utils/src/json/schema/pointer.ts b/packages/schematic-utils/src/json/schema/pointer.ts new file mode 100644 index 0000000..2591238 --- /dev/null +++ b/packages/schematic-utils/src/json/schema/pointer.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { JsonPointer } from "./interface"; + +export function buildJsonPointer(fragments: string[]): JsonPointer { + return ("/" + + fragments + .map((f) => { + return f.replace(/~/g, "~0").replace(/\//g, "~1"); + }) + .join("/")) as JsonPointer; +} +export function joinJsonPointer( + root: JsonPointer, + ...others: string[] +): JsonPointer { + if (root == "/") { + return buildJsonPointer(others); + } + + return (root + buildJsonPointer(others)) as JsonPointer; +} +export function parseJsonPointer(pointer: JsonPointer): string[] { + if (pointer === "") { + return []; + } + if (pointer.charAt(0) !== "/") { + throw new Error("Relative pointer: " + pointer); + } + + return pointer + .substring(1) + .split(/\//) + .map((str) => str.replace(/~1/g, "/").replace(/~0/g, "~")); +} diff --git a/packages/schematic-utils/src/json/schema/prompt.spec.ts b/packages/schematic-utils/src/json/schema/prompt.spec.ts new file mode 100644 index 0000000..dbd999c --- /dev/null +++ b/packages/schematic-utils/src/json/schema/prompt.spec.ts @@ -0,0 +1,473 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { map, mergeMap } from "rxjs/operators"; +import { CoreSchemaRegistry } from "./registry"; + +// @TODO tests don't pass when using jest +describe.skip("Prompt Provider", () => { + it("sets properties with answer", (done) => { + const registry = new CoreSchemaRegistry(); + const data: any = {}; + + registry.usePromptProvider(async (definitions) => { + return { [definitions[0].id]: true }; + }); + + registry + .compile({ + properties: { + test: { + type: "boolean", + "x-prompt": "test-message", + }, + }, + }) + .pipe( + mergeMap((validator) => validator(data)), + map(() => { + expect(data.test).toBe(true); + }) + ) + .toPromise() + .then(done, done.fail); + }); + + it("supports mixed schema references", (done) => { + const registry = new CoreSchemaRegistry(); + const data: any = {}; + + // @ts-ignore + registry.usePromptProvider(async (definitions) => { + return { + "/bool": true, + "/test": "two", + "/obj/deep/three": "test3-answer", + }; + }); + + registry + .compile({ + properties: { + bool: { + $ref: "#/definitions/example", + }, + test: { + type: "string", + enum: ["one", "two", "three"], + "x-prompt": { + type: "list", + message: "other-message", + }, + }, + obj: { + properties: { + deep: { + properties: { + three: { + $ref: "#/definitions/test3", + }, + }, + }, + }, + }, + }, + definitions: { + example: { + type: "boolean", + "x-prompt": "example-message", + }, + test3: { + type: "string", + "x-prompt": "test3-message", + }, + }, + }) + .pipe( + mergeMap((validator) => validator(data)), + map((result) => { + expect(result.success).toBe(true); + expect(data.bool).toBe(true); + expect(data.test).toBe("two"); + expect(data.obj.deep.three).toEqual("test3-answer"); + }) + ) + .toPromise() + .then(done, done.fail); + }); + + describe("with shorthand", () => { + it("supports message value", (done) => { + const registry = new CoreSchemaRegistry(); + const data: any = {}; + + registry.usePromptProvider(async (definitions) => { + expect(definitions.length).toBe(1); + expect(definitions[0].message).toBe("test-message"); + + return {}; + }); + + registry + .compile({ + properties: { + test: { + type: "string", + "x-prompt": "test-message", + }, + }, + }) + .pipe(mergeMap((validator) => validator(data))) + .toPromise() + .then(done, done.fail); + }); + + it("analyzes enums", (done) => { + const registry = new CoreSchemaRegistry(); + const data: any = {}; + + registry.usePromptProvider(async (definitions) => { + expect(definitions.length).toBe(1); + expect(definitions[0].type).toBe("list"); + expect(definitions[0].items).toEqual(["one", "two", "three"]); + + return {}; + }); + + registry + .compile({ + properties: { + test: { + type: "string", + enum: ["one", "two", "three"], + "x-prompt": "test-message", + }, + }, + }) + .pipe(mergeMap((validator) => validator(data))) + .toPromise() + .then(done, done.fail); + }); + + it("analyzes boolean properties", (done) => { + const registry = new CoreSchemaRegistry(); + const data: any = {}; + + registry.usePromptProvider(async (definitions) => { + expect(definitions.length).toBe(1); + expect(definitions[0].type).toBe("confirmation"); + expect(definitions[0].items).toBeUndefined(); + + return {}; + }); + + registry + .compile({ + properties: { + test: { + type: "boolean", + "x-prompt": "test-message", + }, + }, + }) + .pipe(mergeMap((validator) => validator(data))) + .toPromise() + .then(done, done.fail); + }); + }); + + describe("with longhand", () => { + it("supports message option", (done) => { + const registry = new CoreSchemaRegistry(); + const data: any = {}; + + registry.usePromptProvider(async (definitions) => { + expect(definitions.length).toBe(1); + expect(definitions[0].message).toBe("test-message"); + + return {}; + }); + + registry + .compile({ + properties: { + test: { + type: "string", + "x-prompt": { + message: "test-message", + }, + }, + }, + }) + .pipe(mergeMap((validator) => validator(data))) + .toPromise() + .then(done, done.fail); + }); + + it("analyzes enums WITH explicit list type", (done) => { + const registry = new CoreSchemaRegistry(); + const data: any = {}; + + registry.usePromptProvider(async (definitions) => { + expect(definitions.length).toBe(1); + expect(definitions[0].type).toBe("list"); + expect(definitions[0].items).toEqual(["one", "two", "three"]); + + return { [definitions[0].id]: "one" }; + }); + + registry + .compile({ + properties: { + test: { + type: "string", + enum: ["one", "two", "three"], + "x-prompt": { + type: "list", + message: "test-message", + }, + }, + }, + }) + .pipe(mergeMap((validator) => validator(data))) + .toPromise() + .then(done, done.fail); + }); + + it("analyzes list with true multiselect option and object items", (done) => { + const registry = new CoreSchemaRegistry(); + const data: any = {}; + + registry.usePromptProvider(async (definitions) => { + expect(definitions.length).toBe(1); + expect(definitions[0].type).toBe("list"); + expect(definitions[0].multiselect).toBe(true); + expect(definitions[0].items).toEqual([ + { value: "one", label: "one" }, + { value: "two", label: "two" }, + ]); + + return { [definitions[0].id]: { value: "one", label: "one" } }; + }); + + registry + .compile({ + properties: { + test: { + type: "array", + "x-prompt": { + type: "list", + multiselect: true, + items: [ + { value: "one", label: "one" }, + { value: "two", label: "two" }, + ], + message: "test-message", + }, + }, + }, + }) + .pipe(mergeMap((validator) => validator(data))) + .toPromise() + .then(done, done.fail); + }); + + it("analyzes list with false multiselect option and object items", (done) => { + const registry = new CoreSchemaRegistry(); + const data: any = {}; + + registry.usePromptProvider(async (definitions) => { + expect(definitions.length).toBe(1); + expect(definitions[0].type).toBe("list"); + expect(definitions[0].multiselect).toBe(false); + expect(definitions[0].items).toEqual([ + { value: "one", label: "one" }, + { value: "two", label: "two" }, + ]); + + return { [definitions[0].id]: { value: "one", label: "one" } }; + }); + + registry + .compile({ + properties: { + test: { + type: "array", + "x-prompt": { + type: "list", + multiselect: false, + items: [ + { value: "one", label: "one" }, + { value: "two", label: "two" }, + ], + message: "test-message", + }, + }, + }, + }) + .pipe(mergeMap((validator) => validator(data))) + .toPromise() + .then(done, done.fail); + }); + + it("analyzes list without multiselect option and object items", (done) => { + const registry = new CoreSchemaRegistry(); + const data: any = {}; + + registry.usePromptProvider(async (definitions) => { + expect(definitions.length).toBe(1); + expect(definitions[0].type).toBe("list"); + expect(definitions[0].multiselect).toBe(true); + expect(definitions[0].items).toEqual([ + { value: "one", label: "one" }, + { value: "two", label: "two" }, + ]); + + return { [definitions[0].id]: { value: "two", label: "two" } }; + }); + + registry + .compile({ + properties: { + test: { + type: "array", + "x-prompt": { + type: "list", + items: [ + { value: "one", label: "one" }, + { value: "two", label: "two" }, + ], + message: "test-message", + }, + }, + }, + }) + .pipe(mergeMap((validator) => validator(data))) + .toPromise() + .then(done, done.fail); + }); + + it("analyzes enums WITHOUT explicit list type", (done) => { + const registry = new CoreSchemaRegistry(); + const data: any = {}; + + registry.usePromptProvider(async (definitions) => { + expect(definitions.length).toBe(1); + expect(definitions[0].type).toBe("list"); + expect(definitions[0].multiselect).toBeFalsy(); + expect(definitions[0].items).toEqual(["one", "two", "three"]); + + return {}; + }); + + registry + .compile({ + properties: { + test: { + type: "string", + enum: ["one", "two", "three"], + "x-prompt": { + message: "test-message", + }, + }, + }, + }) + .pipe(mergeMap((validator) => validator(data))) + .toPromise() + .then(done, done.fail); + }); + + it("analyzes enums WITHOUT explicit list type and multiselect", (done) => { + const registry = new CoreSchemaRegistry(); + const data: any = {}; + + registry.usePromptProvider(async (definitions) => { + expect(definitions.length).toBe(1); + expect(definitions[0].type).toBe("list"); + expect(definitions[0].multiselect).toBe(true); + expect(definitions[0].items).toEqual(["one", "two", "three"]); + + return {}; + }); + + registry + .compile({ + properties: { + test: { + type: "array", + items: { + enum: ["one", "two", "three"], + }, + "x-prompt": "test-message", + }, + }, + }) + .pipe(mergeMap((validator) => validator(data))) + .toPromise() + .then(done, done.fail); + }); + + it("analyzes boolean properties", (done) => { + const registry = new CoreSchemaRegistry(); + const data: any = {}; + + registry.usePromptProvider(async (definitions) => { + expect(definitions.length).toBe(1); + expect(definitions[0].type).toBe("confirmation"); + expect(definitions[0].items).toBeUndefined(); + + return {}; + }); + + registry + .compile({ + properties: { + test: { + type: "boolean", + "x-prompt": { + message: "test-message", + }, + }, + }, + }) + .pipe(mergeMap((validator) => validator(data))) + .toPromise() + .then(done, done.fail); + }); + + it("allows prompt type override", (done) => { + const registry = new CoreSchemaRegistry(); + const data: any = {}; + + registry.usePromptProvider(async (definitions) => { + expect(definitions.length).toBe(1); + expect(definitions[0].type).toBe("input"); + expect(definitions[0].items).toBeUndefined(); + + return {}; + }); + + registry + .compile({ + properties: { + test: { + type: "boolean", + "x-prompt": { + type: "input", + message: "test-message", + }, + }, + }, + }) + .pipe(mergeMap((validator) => validator(data))) + .toPromise() + .then(done, done.fail); + }); + }); +}); diff --git a/packages/schematic-utils/src/json/schema/registry.spec.ts b/packages/schematic-utils/src/json/schema/registry.spec.ts new file mode 100644 index 0000000..8b27229 --- /dev/null +++ b/packages/schematic-utils/src/json/schema/registry.spec.ts @@ -0,0 +1,431 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { map, mergeMap } from "rxjs/operators"; +import { SchemaFormat } from "./interface"; +import { CoreSchemaRegistry, SchemaValidationException } from "./registry"; +import { addUndefinedDefaults } from "./transforms"; + +describe("CoreSchemaRegistry", () => { + it("works asynchronously", (done) => { + const registry = new CoreSchemaRegistry(); + registry.addPostTransform(addUndefinedDefaults); + const data: any = {}; + + registry + .compile({ + properties: { + bool: { type: "boolean" }, + str: { type: "string", default: "someString" }, + obj: { + properties: { + num: { type: "number" }, + other: { type: "number", default: 0 }, + }, + }, + tslint: { + $ref: "https://json.schemastore.org/npm-link-up#", + }, + }, + }) + .pipe( + mergeMap((validator) => validator(data)), + map((result) => { + expect(result.success).toBe(true); + expect(data.obj.num).toBeUndefined(); + expect(data.tslint).not.toBeUndefined(); + }) + ) + .toPromise() + .then(done, done.fail); + }); + + it("supports pre transforms", (done) => { + const registry = new CoreSchemaRegistry(); + registry.addPostTransform(addUndefinedDefaults); + const data = {}; + + registry.addPreTransform((data, ptr) => { + if (ptr == "/") { + return { str: "string" }; + } + + return data; + }); + + registry + .compile({ + properties: { + bool: { type: "boolean" }, + str: { type: "string", default: "someString" }, + obj: { + properties: { + num: { type: "number" }, + other: { type: "number", default: 0 }, + }, + }, + }, + }) + .pipe( + mergeMap((validator) => validator(data)), + map((result) => { + const data = result.data as any; + expect(result.success).toBe(true); + expect(data.str).toBe("string"); + expect(data.obj.num).toBeUndefined(); + }) + ) + .toPromise() + .then(done, done.fail); + }); + + it("supports local references", (done) => { + const registry = new CoreSchemaRegistry(); + registry.addPostTransform(addUndefinedDefaults); + const data = { numbers: { one: 1 } }; + + registry + .compile({ + properties: { + numbers: { + type: "object", + additionalProperties: { $ref: "#/definitions/myRef" }, + }, + }, + definitions: { + myRef: { type: "integer" }, + }, + }) + .pipe( + mergeMap((validator) => validator(data)), + map((result) => { + expect(result.success).toBe(true); + expect(data.numbers.one).not.toBeUndefined(); + }) + ) + .toPromise() + .then(done, done.fail); + }); + + it("fails on invalid additionalProperties", (done) => { + const registry = new CoreSchemaRegistry(); + registry.addPostTransform(addUndefinedDefaults); + const data = { notNum: "foo" }; + + registry + .compile({ + properties: { + num: { type: "number" }, + }, + additionalProperties: false, + }) + .pipe( + mergeMap((validator) => validator(data)), + map((result) => { + expect(result.success).toBe(false); + expect(result.errors && result.errors[0].message).toContain( + "must NOT have additional properties" + ); + }) + ) + .toPromise() + .then(done, done.fail); + }); + + it("fails on invalid enum value", (done) => { + const registry = new CoreSchemaRegistry(); + registry.addPostTransform(addUndefinedDefaults); + const data = { packageManager: "foo" }; + + registry + .compile({ + properties: { + packageManager: { + type: "string", + enum: ["npm", "yarn", "pnpm", "cnpm"], + }, + }, + additionalProperties: false, + }) + .pipe( + mergeMap((validator) => validator(data)), + map((result) => { + expect(result.success).toBe(false); + expect( + new SchemaValidationException(result.errors).message + ).toContain( + `Data path "/packageManager" must be equal to one of the allowed values. Allowed values are: "npm", "yarn", "pnpm", "cnpm".` + ); + }) + ) + .toPromise() + .then(done, done.fail); + }); + + it("fails on invalid additionalProperties async", (done) => { + const registry = new CoreSchemaRegistry(); + registry.addPostTransform(addUndefinedDefaults); + const data = { notNum: "foo" }; + + registry + .compile({ + $async: true, + properties: { + num: { type: "number" }, + }, + additionalProperties: false, + }) + .pipe( + mergeMap((validator) => validator(data)), + map((result) => { + expect(result.success).toBe(false); + expect(result.errors?.[0].message).toContain( + "must NOT have additional properties" + ); + expect(result.errors?.[0].keyword).toBe("additionalProperties"); + }) + ) + .toPromise() + .then(done, done.fail); + }); + + it("supports sync format", (done) => { + const registry = new CoreSchemaRegistry(); + const data = { str: "hotdog" }; + const format = { + name: "is-hotdog", + formatter: { + validate: (str: string) => str === "hotdog", + }, + }; + + registry.addFormat(format); + + registry + .compile({ + properties: { + str: { type: "string", format: "is-hotdog" }, + }, + }) + .pipe( + mergeMap((validator) => validator(data)), + map((result) => { + expect(result.success).toBe(true); + }) + ) + .toPromise() + .then(done, done.fail); + }); + + it("supports async format", (done) => { + const registry = new CoreSchemaRegistry(); + const data = { str: "hotdog" }; + + const format: SchemaFormat = { + name: "is-hotdog", + formatter: { + async: true, + validate: async (str: string) => str === "hotdog", + }, + }; + + registry.addFormat(format); + + registry + .compile({ + $async: true, + properties: { + str: { type: "string", format: "is-hotdog" }, + }, + }) + .pipe( + mergeMap((validator) => validator(data)), + map((result) => { + expect(result.success).toBe(true); + }) + ) + .toPromise() + .then(done, done.fail); + }); + + it("shows dataPath and message on error", async () => { + const registry = new CoreSchemaRegistry(); + const data = { hotdot: "hotdog", banana: "banana" }; + const format: SchemaFormat = { + name: "is-hotdog", + formatter: { + async: false, + validate: (str: string) => str === "hotdog", + }, + }; + + registry.addFormat(format); + + await registry + .compile({ + properties: { + hotdot: { type: "string", format: "is-hotdog" }, + banana: { type: "string", format: "is-hotdog" }, + }, + }) + .pipe( + mergeMap((validator) => validator(data)), + map((result) => { + expect(result.success).toBe(false); + expect(result.errors && result.errors[0]).toBeTruthy(); + expect(result.errors && result.errors[0].keyword).toBe("format"); + expect(result.errors && result.errors[0].instancePath).toBe( + "/banana" + ); + expect(result.errors && (result.errors[0].params as any).format).toBe( + "is-hotdog" + ); + }) + ) + .toPromise(); + }); + + it("supports smart defaults", (done) => { + const registry = new CoreSchemaRegistry(); + const data: any = { + arr: [{}], + }; + + registry.addSmartDefaultProvider("test", (schema) => { + expect(schema).toEqual({ + $source: "test", + }); + + return true; + }); + registry.addSmartDefaultProvider("test2", (schema) => { + expect(schema).toEqual({ + $source: "test2", + blue: "yep", + }); + + return schema["blue"]; + }); + registry.addSmartDefaultProvider("test3", () => { + return [1, 2, 3]; + }); + + registry + .compile({ + properties: { + bool: { + $ref: "#/definitions/example", + }, + arr: { + items: { + properties: { + test: { + $ref: "#/definitions/other", + }, + }, + }, + }, + arr2: { + $ref: "#/definitions/test3", + }, + obj: { + properties: { + deep: { + properties: { + arr: { + $ref: "#/definitions/test3", + }, + }, + }, + }, + }, + }, + definitions: { + example: { + type: "boolean", + $default: { + $source: "test", + }, + }, + other: { + type: "string", + $default: { + $source: "test2", + blue: "yep", + }, + }, + test3: { + type: "array", + $default: { + $source: "test3", + }, + }, + }, + }) + .pipe( + mergeMap((validator) => validator(data)), + map((result) => { + expect(result.success).toBe(true); + expect(data.bool).toBe(true); + expect(data.arr[0].test).toBe("yep"); + expect(data.arr2).toEqual([1, 2, 3]); + expect(data.obj.deep.arr).toEqual([1, 2, 3]); + }) + ) + .toPromise() + .then(done, done.fail); + }); + + it("works with true as a schema and post-transforms", async () => { + const registry = new CoreSchemaRegistry(); + registry.addPostTransform(addUndefinedDefaults); + const data = { a: 1, b: 2 }; + + const validate = await registry.compile(true).toPromise(); + const result = await validate(data).toPromise(); + + expect(result.success).toBe(true); + expect(result.data).toBe(data); + }); + + it("adds deprecated options usage", (done) => { + const registry = new CoreSchemaRegistry(); + const deprecatedMessages: string[] = []; + registry.useXDeprecatedProvider((m) => deprecatedMessages.push(m)); + + const data = { + foo: true, + bar: true, + bat: true, + }; + + registry + .compile({ + properties: { + foo: { type: "boolean", "x-deprecated": "Use bar instead." }, + bar: { type: "boolean", "x-deprecated": true }, + buz: { type: "boolean", "x-deprecated": true }, + bat: { type: "boolean", "x-deprecated": false }, + }, + }) + .pipe( + mergeMap((validator) => validator(data)), + map((result) => { + expect(deprecatedMessages.length).toBe(2); + expect(deprecatedMessages[0]).toBe( + 'Option "foo" is deprecated: Use bar instead.' + ); + expect(deprecatedMessages[1]).toBe('Option "bar" is deprecated.'); + expect(result.success).toBe(true); + }) + ) + .toPromise() + .then(done, done.fail); + }); +}); diff --git a/packages/schematic-utils/src/json/schema/registry.ts b/packages/schematic-utils/src/json/schema/registry.ts new file mode 100644 index 0000000..7bb731e --- /dev/null +++ b/packages/schematic-utils/src/json/schema/registry.ts @@ -0,0 +1,742 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +/* eslint-disable */ +import Ajv, { SchemaObjCxt, ValidateFunction } from "ajv"; +import ajvAddFormats from "ajv-formats"; +import * as http from "http"; +import * as https from "https"; +import { Observable, from, isObservable } from "rxjs"; +import { map } from "rxjs/operators"; +import * as Url from "url"; +import { JsonArray, JsonObject, JsonValue, isJsonObject } from "../interface"; +import { + JsonPointer, + JsonVisitor, + PromptDefinition, + PromptProvider, + SchemaFormat, + SchemaRegistry, + SchemaValidator, + SchemaValidatorError, + SchemaValidatorOptions, + SchemaValidatorResult, + SmartDefaultProvider, +} from "./interface"; +import { JsonSchema } from "./schema"; +import { getTypesOfSchema } from "./utility"; +import { visitJson, visitJsonSchema } from "./visitor"; +import { BaseException } from "../../exceptions/exception"; +import { deepCopy, PartiallyOrderedSet } from "../../utils/runtime"; + +export type UriHandler = ( + uri: string +) => Observable | Promise | null | undefined; + +export class SchemaValidationException extends BaseException { + public readonly errors: SchemaValidatorError[]; + + constructor( + errors?: SchemaValidatorError[], + baseMessage = "Schema validation failed with the following errors:" + ) { + if (!errors || errors.length === 0) { + super("Schema validation failed."); + this.errors = []; + + return; + } + + const messages = SchemaValidationException.createMessages(errors); + super(`${baseMessage}\n ${messages.join("\n ")}`); + this.errors = errors; + } + + public static createMessages(errors?: SchemaValidatorError[]): string[] { + if (!errors || errors.length === 0) { + return []; + } + + const messages = errors.map((err) => { + let message = `Data path ${JSON.stringify(err.instancePath)} ${ + err.message + }`; + if (err.params) { + switch (err.keyword) { + case "additionalProperties": + message += `(${err.params.additionalProperty})`; + break; + + case "enum": + message += `. Allowed values are: ${(err.params.allowedValues as + | string[] + | undefined) + ?.map((v) => `"${v}"`) + .join(", ")}`; + break; + } + } + + return message + "."; + }); + + return messages; + } +} + +interface SchemaInfo { + smartDefaultRecord: Map; + promptDefinitions: Array; +} + +export class CoreSchemaRegistry implements SchemaRegistry { + private _ajv: Ajv; + private _uriCache = new Map(); + private _uriHandlers = new Set(); + private _pre = new PartiallyOrderedSet(); + private _post = new PartiallyOrderedSet(); + + private _currentCompilationSchemaInfo?: SchemaInfo; + + private _smartDefaultKeyword = false; + private _promptProvider?: PromptProvider; + private _sourceMap = new Map>(); + + constructor(formats: SchemaFormat[] = []) { + this._ajv = new Ajv({ + strict: false, + loadSchema: (uri: string) => this._fetch(uri), + passContext: true, + }); + + ajvAddFormats(this._ajv); + + for (const format of formats) { + this.addFormat(format); + } + } + + private async _fetch(uri: string): Promise { + const maybeSchema = this._uriCache.get(uri); + + if (maybeSchema) { + return maybeSchema; + } + + // Try all handlers, one after the other. + for (const handler of this._uriHandlers) { + let handlerResult = handler(uri); + if (handlerResult === null || handlerResult === undefined) { + continue; + } + + if (isObservable(handlerResult)) { + handlerResult = handlerResult.toPromise(); + } + + const value = await handlerResult; + this._uriCache.set(uri, value); + + return value; + } + + // If none are found, handle using http client. + return new Promise((resolve, reject) => { + const url = new Url.URL(uri); + const client = url.protocol === "https:" ? https : http; + client.get(url, (res) => { + if (!res.statusCode || res.statusCode >= 300) { + // Consume the rest of the data to free memory. + res.resume(); + reject(new Error(`Request failed. Status Code: ${res.statusCode}`)); + } else { + res.setEncoding("utf8"); + let data = ""; + res.on("data", (chunk) => { + data += chunk; + }); + res.on("end", () => { + try { + const json = JSON.parse(data); + this._uriCache.set(uri, json); + resolve(json); + } catch (err) { + reject(err); + } + }); + } + }); + }); + } + + /** + * Add a transformation step before the validation of any Json. + * @param {JsonVisitor} visitor The visitor to transform every value. + * @param {JsonVisitor[]} deps A list of other visitors to run before. + */ + addPreTransform(visitor: JsonVisitor, deps?: JsonVisitor[]) { + this._pre.add(visitor, deps); + } + + /** + * Add a transformation step after the validation of any Json. The JSON will not be validated + * after the POST, so if transformations are not compatible with the Schema it will not result + * in an error. + * @param {JsonVisitor} visitor The visitor to transform every value. + * @param {JsonVisitor[]} deps A list of other visitors to run before. + */ + addPostTransform(visitor: JsonVisitor, deps?: JsonVisitor[]) { + this._post.add(visitor, deps); + } + + protected _resolver( + ref: string, + validate?: ValidateFunction + ): { context?: ValidateFunction; schema?: JsonObject } { + if (!validate || !ref) { + return {}; + } + + const schema = validate.schemaEnv.root.schema; + const id = typeof schema === "object" ? schema.$id : null; + + let fullReference = ref; + if (typeof id === "string") { + fullReference = Url.resolve(id, ref); + + if (ref.startsWith("#")) { + fullReference = id + fullReference; + } + } + + if (fullReference.startsWith("#")) { + fullReference = fullReference.slice(0, -1); + } + const resolvedSchema = this._ajv.getSchema(fullReference); + + return { + context: resolvedSchema?.schemaEnv.validate, + schema: resolvedSchema?.schema as JsonObject, + }; + } + + /** + * Flatten the Schema, resolving and replacing all the refs. Makes it into a synchronous schema + * that is also easier to traverse. Does not cache the result. + * + * @param schema The schema or URI to flatten. + * @returns An Observable of the flattened schema object. + * @deprecated since 11.2 without replacement. + * Producing a flatten schema document does not in all cases produce a schema with identical behavior to the original. + * See: https://json-schema.org/draft/2019-09/json-schema-core.html#rfc.appendix.B.2 + */ + flatten(schema: JsonObject): Observable { + return from(this._flatten(schema)); + } + + private async _flatten(schema: JsonObject): Promise { + this._replaceDeprecatedSchemaIdKeyword(schema); + this._ajv.removeSchema(schema); + + this._currentCompilationSchemaInfo = undefined; + const validate = await this._ajv.compileAsync(schema); + + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + + function visitor( + current: JsonObject | JsonArray, + // @ts-ignore + pointer: JsonPointer, + parentSchema?: JsonObject | JsonArray, + index?: string + ) { + if ( + current && + parentSchema && + index && + isJsonObject(current) && + Object.prototype.hasOwnProperty.call(current, "$ref") && + typeof current["$ref"] == "string" + ) { + const resolved = self._resolver(current["$ref"], validate); + + if (resolved.schema) { + (parentSchema as JsonObject)[index] = resolved.schema; + } + } + } + + const schemaCopy = deepCopy(validate.schema as JsonObject); + visitJsonSchema(schemaCopy, visitor); + + return schemaCopy; + } + + /** + * Compile and return a validation function for the Schema. + * + * @param schema The schema to validate. If a string, will fetch the schema before compiling it + * (using schema as a URI). + * @returns An Observable of the Validation function. + */ + compile(schema: JsonSchema): Observable { + return from(this._compile(schema)).pipe( + map((validate) => (value, options) => from(validate(value, options))) + ); + } + + private async _compile( + schema: JsonSchema + ): Promise< + ( + data: JsonValue, + options?: SchemaValidatorOptions + ) => Promise + > { + if (typeof schema === "boolean") { + return async (data) => ({ success: schema, data }); + } + + this._replaceDeprecatedSchemaIdKeyword(schema); + + const schemaInfo: SchemaInfo = { + smartDefaultRecord: new Map(), + promptDefinitions: [], + }; + + this._ajv.removeSchema(schema); + let validator: ValidateFunction; + + try { + this._currentCompilationSchemaInfo = schemaInfo; + validator = this._ajv.compile(schema); + } catch (e) { + // This should eventually be refactored so that we we handle race condition where the same schema is validated at the same time. + if (!(e instanceof Ajv.MissingRefError)) { + throw e; + } + + validator = await this._ajv.compileAsync(schema); + } finally { + this._currentCompilationSchemaInfo = undefined; + } + + return async (data: JsonValue, options?: SchemaValidatorOptions) => { + const validationOptions: SchemaValidatorOptions = { + withPrompts: true, + applyPostTransforms: true, + applyPreTransforms: true, + ...options, + }; + const validationContext = { + promptFieldsWithValue: new Set(), + }; + + // Apply pre-validation transforms + if (validationOptions.applyPreTransforms) { + for (const visitor of this._pre.values()) { + data = await visitJson( + data, + visitor, + schema, + this._resolver.bind(this), + validator + ).toPromise(); + } + } + + // Apply smart defaults + await this._applySmartDefaults(data, schemaInfo.smartDefaultRecord); + + // Apply prompts + if (validationOptions.withPrompts) { + const visitor: JsonVisitor = (value, pointer) => { + if (value !== undefined) { + validationContext.promptFieldsWithValue.add(pointer); + } + + return value; + }; + if (typeof schema === "object") { + await visitJson( + data, + visitor, + schema, + this._resolver.bind(this), + validator + ).toPromise(); + } + + const definitions = schemaInfo.promptDefinitions.filter( + (def) => !validationContext.promptFieldsWithValue.has(def.id) + ); + + if (definitions.length > 0) { + await this._applyPrompts(data, definitions); + } + } + + // Validate using ajv + try { + const success = await validator.call(validationContext, data); + + if (!success) { + return { data, success, errors: validator.errors ?? [] }; + } + } catch (error) { + if (error instanceof Ajv.ValidationError) { + return { data, success: false, errors: error.errors }; + } + + throw error; + } + + // Apply post-validation transforms + if (validationOptions.applyPostTransforms) { + for (const visitor of this._post.values()) { + data = await visitJson( + data, + visitor, + schema, + this._resolver.bind(this), + validator + ).toPromise(); + } + } + + return { data, success: true }; + }; + } + + addFormat(format: SchemaFormat): void { + this._ajv.addFormat(format.name, format.formatter); + } + + addSmartDefaultProvider( + source: string, + provider: SmartDefaultProvider + ) { + if (this._sourceMap.has(source)) { + throw new Error(source); + } + + this._sourceMap.set(source, provider); + + if (!this._smartDefaultKeyword) { + this._smartDefaultKeyword = true; + + this._ajv.addKeyword({ + keyword: "$default", + errors: false, + valid: true, + compile: (schema, _parentSchema, it) => { + const compilationSchemInfo = this._currentCompilationSchemaInfo; + if (compilationSchemInfo === undefined) { + return () => true; + } + + // We cheat, heavily. + const pathArray = this.normalizeDataPathArr(it); + compilationSchemInfo.smartDefaultRecord.set( + JSON.stringify(pathArray), + schema + ); + + return () => true; + }, + metaSchema: { + type: "object", + properties: { + $source: { type: "string" }, + }, + additionalProperties: true, + required: ["$source"], + }, + }); + } + } + + registerUriHandler(handler: UriHandler) { + this._uriHandlers.add(handler); + } + + usePromptProvider(provider: PromptProvider) { + const isSetup = !!this._promptProvider; + + this._promptProvider = provider; + + if (isSetup) { + return; + } + + this._ajv.addKeyword({ + keyword: "x-prompt", + errors: false, + valid: true, + compile: (schema, parentSchema, it) => { + const compilationSchemInfo = this._currentCompilationSchemaInfo; + if (!compilationSchemInfo) { + return () => true; + } + + const path = "/" + this.normalizeDataPathArr(it).join("/"); + + let type: string | undefined; + let items: + | Array + | undefined; + let message: string; + if (typeof schema == "string") { + message = schema; + } else { + message = schema.message; + type = schema.type; + items = schema.items; + } + + const propertyTypes = getTypesOfSchema(parentSchema as JsonObject); + if (!type) { + if (propertyTypes.size === 1 && propertyTypes.has("boolean")) { + type = "confirmation"; + } else if (Array.isArray((parentSchema as JsonObject).enum)) { + type = "list"; + } else if ( + propertyTypes.size === 1 && + propertyTypes.has("array") && + (parentSchema as JsonObject).items && + Array.isArray( + ((parentSchema as JsonObject).items as JsonObject).enum + ) + ) { + type = "list"; + } else { + type = "input"; + } + } + + let multiselect; + if (type === "list") { + multiselect = + schema.multiselect === undefined + ? propertyTypes.size === 1 && propertyTypes.has("array") + : schema.multiselect; + + const enumValues = multiselect + ? (parentSchema as JsonObject).items && + ((parentSchema as JsonObject).items as JsonObject).enum + : (parentSchema as JsonObject).enum; + if (!items && Array.isArray(enumValues)) { + items = []; + for (const value of enumValues) { + if (typeof value == "string") { + items.push(value); + } else if (typeof value == "object") { + // Invalid + } else { + items.push({ label: value.toString(), value }); + } + } + } + } + + const definition: PromptDefinition = { + id: path, + type, + message, + raw: schema, + items, + multiselect, + propertyTypes, + default: + typeof (parentSchema as JsonObject).default == "object" && + (parentSchema as JsonObject).default !== null && + !Array.isArray((parentSchema as JsonObject).default) + ? undefined + : ((parentSchema as JsonObject).default as string[]), + async validator(data: JsonValue) { + try { + const result = await it.self.validate(parentSchema, data); + // If the schema is sync then false will be returned on validation failure + if (result) { + return result; + } else if (it.self.errors?.length) { + // Validation errors will be present on the Ajv instance when sync + return it.self.errors[0].message; + } + } catch (e) { + // If the schema is async then an error will be thrown on validation failure + if (Array.isArray(e.errors) && e.errors.length) { + return e.errors[0].message; + } + } + + return false; + }, + }; + + compilationSchemInfo.promptDefinitions.push(definition); + + return function (this: { promptFieldsWithValue: Set }) { + // If 'this' is undefined in the call, then it defaults to the global + // 'this'. + if (this && this.promptFieldsWithValue) { + this.promptFieldsWithValue.add(path); + } + + return true; + }; + }, + metaSchema: { + oneOf: [ + { type: "string" }, + { + type: "object", + properties: { + type: { type: "string" }, + message: { type: "string" }, + }, + additionalProperties: true, + required: ["message"], + }, + ], + }, + }); + } + + private async _applyPrompts( + data: JsonValue, + prompts: Array + ): Promise { + const provider = this._promptProvider; + if (!provider) { + return; + } + + const answers = await from(provider(prompts)).toPromise(); + for (const path in answers) { + const pathFragments = path.split("/").slice(1); + + CoreSchemaRegistry._set( + data, + pathFragments, + answers[path], + null, + undefined, + true + ); + } + } + + private static _set( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: any, + fragments: string[], + value: unknown, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parent: any = null, + parentProperty?: string, + force?: boolean + ): void { + for (let index = 0; index < fragments.length; index++) { + const fragment = fragments[index]; + if (/^i\d+$/.test(fragment)) { + if (!Array.isArray(data)) { + return; + } + + for (let dataIndex = 0; dataIndex < data.length; dataIndex++) { + CoreSchemaRegistry._set( + data[dataIndex], + fragments.slice(index + 1), + value, + data, + `${dataIndex}` + ); + } + + return; + } + + if (!data && parent !== null && parentProperty) { + data = parent[parentProperty] = {}; + } + + parent = data; + parentProperty = fragment; + data = data[fragment]; + } + + if ( + parent && + parentProperty && + (force || parent[parentProperty] === undefined) + ) { + parent[parentProperty] = value; + } + } + + private async _applySmartDefaults( + data: T, + smartDefaults: Map + ): Promise { + for (const [pointer, schema] of smartDefaults.entries()) { + const fragments = JSON.parse(pointer); + const source = this._sourceMap.get(schema.$source as string); + if (!source) { + continue; + } + + let value = source(schema); + if (isObservable(value)) { + value = await value.toPromise(); + } + + CoreSchemaRegistry._set(data, fragments, value); + } + } + + useXDeprecatedProvider(onUsage: (message: string) => void): void { + this._ajv.addKeyword({ + keyword: "x-deprecated", + validate: (schema, _data, _parentSchema, dataCxt) => { + if (schema) { + onUsage( + `Option "${dataCxt?.parentDataProperty}" is deprecated${ + typeof schema == "string" ? ": " + schema : "." + }` + ); + } + + return true; + }, + errors: false, + }); + } + + /** + * Workaround to avoid a breaking change in downstream schematics. + * @deprecated will be removed in version 13. + */ + private _replaceDeprecatedSchemaIdKeyword(schema: JsonObject): void { + if (typeof schema.id === "string") { + schema.$id = schema.id; + delete schema.id; + + // eslint-disable-next-line no-console + console.warn( + `"${schema.$id}" schema is using the keyword "id" which its support is deprecated. Use "$id" for schema ID.` + ); + } + } + + private normalizeDataPathArr(it: SchemaObjCxt): (number | string)[] { + return it.dataPathArr + .slice(1, it.dataLevel + 1) + .map((p) => (typeof p === "number" ? p : p.str.replace(/\"/g, ""))); + } +} diff --git a/packages/schematic-utils/src/json/schema/schema.ts b/packages/schematic-utils/src/json/schema/schema.ts new file mode 100644 index 0000000..31cfd81 --- /dev/null +++ b/packages/schematic-utils/src/json/schema/schema.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { JsonObject, JsonValue, isJsonObject } from "../interface"; + +/** + * A specialized interface for JsonSchema (to come). JsonSchemas are also JsonObject. + * + * @public + */ +export type JsonSchema = JsonObject | boolean; + +export function isJsonSchema(value: unknown): value is JsonSchema { + return isJsonObject(value as JsonValue) || value === false || value === true; +} + +/** + * Return a schema that is the merge of all subschemas, ie. it should validate all the schemas + * that were passed in. It is possible to make an invalid schema this way, e.g. by using + * `mergeSchemas({ type: 'number' }, { type: 'string' })`, which will never validate. + * @param schemas All schemas to be merged. + */ +export function mergeSchemas( + ...schemas: (JsonSchema | undefined)[] +): JsonSchema { + return schemas.reduce((prev, curr) => { + if (curr === undefined) { + return prev; + } + + if (prev === false || curr === false) { + return false; + } else if (prev === true) { + return curr; + } else if (curr === true) { + return prev; + } else if (Array.isArray(prev.allOf)) { + if (Array.isArray(curr.allOf)) { + return { ...prev, allOf: [...prev.allOf, ...curr.allOf] }; + } else { + return { ...prev, allOf: [...prev.allOf, curr] }; + } + } else if (Array.isArray(curr.allOf)) { + return { ...prev, allOf: [prev, ...curr.allOf] }; + } else { + return { ...prev, allOf: [prev, curr] }; + } + }, true); +} diff --git a/packages/schematic-utils/src/json/schema/transforms.spec.ts b/packages/schematic-utils/src/json/schema/transforms.spec.ts new file mode 100644 index 0000000..d1a1aba --- /dev/null +++ b/packages/schematic-utils/src/json/schema/transforms.spec.ts @@ -0,0 +1,229 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { mergeMap } from "rxjs/operators"; +import { CoreSchemaRegistry } from "./registry"; +import { addUndefinedDefaults } from "./transforms"; + +describe("addUndefinedDefaults", () => { + it("should add defaults to undefined properties (1)", async () => { + const registry = new CoreSchemaRegistry(); + registry.addPreTransform(addUndefinedDefaults); + const data: any = {}; // eslint-disable-line @typescript-eslint/no-explicit-any + + const result = await registry + .compile({ + properties: { + bool: { type: "boolean" }, + str: { type: "string", default: "someString" }, + obj: { + properties: { + num: { type: "number" }, + other: { type: "number", default: 0 }, + }, + }, + objAllOk: { + allOf: [{ type: "object" }], + }, + objAllBad: { + allOf: [{ type: "object" }, { type: "number" }], + }, + objOne: { + oneOf: [{ type: "object" }], + }, + objNotOk: { + not: { not: { type: "object" } }, + }, + objNotBad: { + type: "object", + not: { type: "object" }, + }, + }, + }) + .pipe(mergeMap((validator) => validator(data))) + .toPromise(); + + expect(result.success).toBeTruthy(); + expect(data.bool).toBeUndefined(); + expect(data.str).toBe("someString"); + expect(data.obj.num).toBeUndefined(); + expect(data.obj.other).toBe(0); + expect(data.objAllOk).toEqual({}); + expect(data.objOne).toEqual({}); + expect(data.objAllBad).toBeUndefined(); + expect(data.objNotOk).toEqual({}); + expect(data.objNotBad).toBeUndefined(); + }); + + it("should add defaults to undefined properties (2)", async () => { + const registry = new CoreSchemaRegistry(); + registry.addPreTransform(addUndefinedDefaults); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data: any = { + bool: undefined, + str: undefined, + obj: { + num: undefined, + }, + }; + + const result = await registry + .compile({ + properties: { + bool: { type: "boolean", default: true }, + str: { type: "string", default: "someString" }, + obj: { + properties: { + num: { type: "number", default: 0 }, + }, + }, + }, + }) + .pipe(mergeMap((validator) => validator(data))) + .toPromise(); + + expect(result.success).toBeTruthy(); + expect(data.bool).toBeTruthy(); + expect(data.str).toBe("someString"); + expect(data.obj.num).toBe(0); + }); + + it("should add defaults to undefined properties when using oneOf", async () => { + const registry = new CoreSchemaRegistry(); + registry.addPreTransform(addUndefinedDefaults); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const dataNoObj: any = { + bool: undefined, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const dataObj: any = { + bool: undefined, + obj: { + a: false, + }, + }; + + const validator = registry.compile({ + properties: { + bool: { type: "boolean", default: true }, + obj: { + default: true, + oneOf: [ + { + type: "object", + properties: { + a: { type: "boolean", default: true }, + b: { type: "boolean", default: true }, + c: { type: "boolean", default: false }, + }, + }, + { + type: "boolean", + }, + ], + }, + noDefaultOneOf: { + oneOf: [ + { + type: "object", + properties: { + a: { type: "boolean", default: true }, + b: { type: "boolean", default: true }, + c: { type: "boolean", default: false }, + }, + }, + { + type: "boolean", + }, + ], + }, + }, + }); + + const result1 = await validator + .pipe(mergeMap((validator) => validator(dataNoObj))) + .toPromise(); + + expect(result1.success).toBeTruthy(); + expect(dataNoObj.bool).toBeTruthy(); + expect(dataNoObj.obj).toBeTruthy(); + expect(dataNoObj.noDefaultOneOf).toBeUndefined(); + + const result2 = await validator + .pipe(mergeMap((validator) => validator(dataObj))) + .toPromise(); + + expect(result2.success).toBeTruthy(); + expect(dataObj.bool).toBeTruthy(); + expect(dataObj.obj.a).toBeFalsy(); + expect(dataObj.obj.b).toBeTruthy(); + expect(dataObj.obj.c).toBeFalsy(); + }); + + it("should add defaults to undefined properties when using anyOf", async () => { + const registry = new CoreSchemaRegistry(); + registry.addPreTransform(addUndefinedDefaults); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const dataNoObj: any = { + bool: undefined, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const dataObj: any = { + bool: undefined, + obj: { + a: false, + }, + }; + + const validator = registry.compile({ + properties: { + bool: { type: "boolean", default: true }, + obj: { + default: true, + anyOf: [ + { + type: "object", + properties: { + d: { type: "boolean", default: false }, + }, + }, + { + type: "object", + properties: { + a: { type: "boolean", default: true }, + b: { type: "boolean", default: true }, + c: { type: "boolean", default: false }, + }, + }, + { + type: "boolean", + }, + ], + }, + }, + }); + + const result1 = await validator + .pipe(mergeMap((validator) => validator(dataNoObj))) + .toPromise(); + + expect(result1.success).toBeTruthy(); + expect(dataNoObj.bool).toBeTruthy(); + expect(dataNoObj.obj).toBeTruthy(); + + const result2 = await validator + .pipe(mergeMap((validator) => validator(dataObj))) + .toPromise(); + + expect(result2.success).toBeTruthy(); + expect(dataObj.bool).toBeTruthy(); + expect(dataObj.obj.a).toBeFalsy(); + expect(dataObj.obj.b).toBeTruthy(); + expect(dataObj.obj.c).toBeFalsy(); + }); +}); diff --git a/packages/schematic-utils/src/json/schema/transforms.ts b/packages/schematic-utils/src/json/schema/transforms.ts new file mode 100644 index 0000000..ffdbb5a --- /dev/null +++ b/packages/schematic-utils/src/json/schema/transforms.ts @@ -0,0 +1,112 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { JsonObject, JsonValue, isJsonArray, isJsonObject } from "../interface"; +import { JsonPointer } from "./interface"; +import { JsonSchema } from "./schema"; +import { getTypesOfSchema } from "./utility"; + +export function addUndefinedDefaults( + value: JsonValue, + _pointer: JsonPointer, + schema?: JsonSchema +): JsonValue { + if (typeof schema === "boolean" || schema === undefined) { + return value; + } + + const types = getTypesOfSchema(schema); + if (types.size === 0) { + return value; + } + + let type; + if (types.size === 1) { + // only one potential type + type = Array.from(types)[0]; + } else if (types.size === 2 && types.has("array") && types.has("object")) { + // need to create one of them and array is simpler + type = "array"; + } else if (schema.properties && types.has("object")) { + // assume object + type = "object"; + } else if (schema.items && types.has("array")) { + // assume array + type = "array"; + } else { + // anything else needs to be checked by the consumer anyway + return value; + } + + if (type === "array") { + return value == undefined ? [] : value; + } + + if (type === "object") { + let newValue; + if (value == undefined) { + newValue = {} as JsonObject; + } else if (isJsonObject(value)) { + newValue = value; + } else { + return value; + } + + if (!isJsonObject(schema.properties)) { + return newValue; + } + + for (const [propName, schemaObject] of Object.entries(schema.properties)) { + if (propName === "$schema" || !isJsonObject(schemaObject)) { + continue; + } + + const value = newValue[propName]; + if (value === undefined) { + newValue[propName] = schemaObject.default; + } else if (isJsonObject(value)) { + // Basic support for oneOf and anyOf. + const propertySchemas = schemaObject.oneOf || schemaObject.anyOf; + const allProperties = Object.keys(value); + // Locate a schema which declares all the properties that the object contains. + const adjustedSchema = + isJsonArray(propertySchemas) && + propertySchemas.find((s) => { + if (!isJsonObject(s)) { + return false; + } + + const schemaType = getTypesOfSchema(s); + if ( + schemaType.size === 1 && + schemaType.has("object") && + isJsonObject(s.properties) + ) { + const properties = Object.keys(s.properties); + + return allProperties.every((key) => properties.includes(key)); + } + + return false; + }); + + if (adjustedSchema && isJsonObject(adjustedSchema)) { + newValue[propName] = addUndefinedDefaults( + value, + _pointer, + adjustedSchema + ); + } + } + } + + return newValue; + } + + return value; +} diff --git a/packages/schematic-utils/src/json/schema/utility.ts b/packages/schematic-utils/src/json/schema/utility.ts new file mode 100644 index 0000000..3aa6352 --- /dev/null +++ b/packages/schematic-utils/src/json/schema/utility.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { JsonObject, isJsonArray, isJsonObject } from "../interface"; +import { JsonSchema } from "./schema"; + +const allTypes = [ + "string", + "integer", + "number", + "object", + "array", + "boolean", + "null", +]; + +export function getTypesOfSchema(schema: JsonSchema): Set { + if (!schema) { + return new Set(); + } + if (schema === true) { + return new Set(allTypes); + } + + let potentials: Set; + if (typeof schema.type === "string") { + potentials = new Set([schema.type]); + } else if (Array.isArray(schema.type)) { + potentials = new Set(schema.type as string[]); + } else if (isJsonArray(schema.enum)) { + potentials = new Set(); + + // Gather the type of each enum values, and use that as a starter for potential types. + for (const v of schema.enum) { + switch (typeof v) { + case "string": + case "number": + case "boolean": + potentials.add(typeof v); + break; + + case "object": + if (Array.isArray(v)) { + potentials.add("array"); + } else if (v === null) { + potentials.add("null"); + } else { + potentials.add("object"); + } + break; + } + } + } else { + potentials = new Set(allTypes); + } + + if (isJsonObject(schema.not)) { + const notTypes = getTypesOfSchema(schema.not); + potentials = new Set([...potentials].filter((p) => !notTypes.has(p))); + } + + if (Array.isArray(schema.allOf)) { + for (const sub of schema.allOf) { + const types = getTypesOfSchema(sub as JsonObject); + potentials = new Set([...types].filter((t) => potentials.has(t))); + } + } + + if (Array.isArray(schema.oneOf)) { + let options = new Set(); + for (const sub of schema.oneOf) { + const types = getTypesOfSchema(sub as JsonObject); + options = new Set([...options, ...types]); + } + potentials = new Set([...options].filter((o) => potentials.has(o))); + } + + if (Array.isArray(schema.anyOf)) { + let options = new Set(); + for (const sub of schema.anyOf) { + const types = getTypesOfSchema(sub as JsonObject); + options = new Set([...options, ...types]); + } + potentials = new Set([...options].filter((o) => potentials.has(o))); + } + + if (schema.properties) { + potentials.add("object"); + } else if (schema.items) { + potentials.add("array"); + } + + return potentials; +} diff --git a/packages/schematic-utils/src/json/schema/visitor.spec.ts b/packages/schematic-utils/src/json/schema/visitor.spec.ts new file mode 100644 index 0000000..932f3e8 --- /dev/null +++ b/packages/schematic-utils/src/json/schema/visitor.spec.ts @@ -0,0 +1,160 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { Observable, from } from "rxjs"; +import { JsonObject, JsonValue } from ".."; +import { visitJson } from "./visitor"; + +function syncObs(obs: Observable): T { + let value: T; + let set = false; + + obs + .forEach((x) => { + if (set) { + throw new Error("Multiple value."); + } + value = x; + set = true; + }) + .catch((err) => fail(err)); + + if (!set) { + throw new Error("Async observable."); + } + + return value!; // eslint-disable-line @typescript-eslint/no-non-null-assertion +} + +describe("visitJson", () => { + it("works to replace the root", () => { + const json = { a: 1 }; + const newJson = {}; + + const result = syncObs(visitJson(json, () => newJson)); + expect(result).toBe(newJson); + }); + + it("goes through recursively if replacing root", () => { + const json = { a: 1 }; + const newJson = { b: "hello " }; + + const result = syncObs( + visitJson(json, (value) => { + if (typeof value == "object") { + return newJson; + } else { + return value + "world"; + } + }) + ); + expect(result).toEqual({ b: "hello world" }); + expect(newJson).toEqual({ b: "hello world" }); + }); + + it("goes through all replacements recursively", () => { + const json = { a: 1 }; + const newJson = { b: "" }; + const newJson2 = { c: [] }; + const newJson3 = [1, 2, 3]; + + const result = syncObs( + visitJson(json, (value, ptr) => { + if (ptr.endsWith("a")) { + return newJson; + } else if (ptr.endsWith("b")) { + return newJson2; + } else if (ptr.endsWith("c")) { + return newJson3; + } else if (typeof value == "number") { + return "_" + value; + } else if (ptr == "/") { + return value; + } else { + return "abc"; + } + }) + ); + + expect(result).toEqual({ a: { b: { c: ["_1", "_2", "_3"] } } }); + }); + + it("goes through all replacements recursively (async)", (done) => { + const json = { a: 1 }; + const newJson = { b: "" }; + const newJson2 = { c: [] }; + const newJson3 = [1, 2, 3]; + + visitJson(json, (value, ptr) => { + if (ptr.endsWith("a")) { + return from(Promise.resolve(newJson)); + } else if (ptr.endsWith("b")) { + return from(Promise.resolve(newJson2)); + } else if (ptr.endsWith("c")) { + return from(Promise.resolve(newJson3)); + } else if (typeof value == "number") { + return from(Promise.resolve("_" + value)); + } else if (ptr == "/") { + return from(Promise.resolve(value)); + } else { + return from(Promise.resolve("abc")); + } + }) + .toPromise() + .then((result) => { + expect(result).toEqual({ a: { b: { c: ["_1", "_2", "_3"] } } }); + done(); + }, done.fail); + }); + + it("works with schema", () => { + const schema = { + properties: { + bool: { type: "boolean" }, + str: { type: "string", default: "someString" }, + obj: { + properties: { + num: { type: "number" }, + other: { type: "number", default: 0 }, + }, + }, + }, + }; + + const allPointers: { [ptr: string]: JsonObject | undefined } = {}; + function visitor(value: JsonValue, ptr: string, schema?: JsonObject) { + expect(allPointers[ptr]).toBeUndefined(); + allPointers[ptr] = schema; + + return value; + } + + const json = { + bool: true, + str: "hello", + obj: { + num: 1, + }, + }; + + const result = syncObs(visitJson(json, visitor, schema)); + + expect(result).toEqual({ + bool: true, + str: "hello", + obj: { num: 1 }, + }); + expect(allPointers).toEqual({ + "/": schema, + "/bool": schema.properties.bool, + "/str": schema.properties.str, + "/obj": schema.properties.obj, + "/obj/num": schema.properties.obj.properties.num, + }); + }); +}); diff --git a/packages/schematic-utils/src/json/schema/visitor.ts b/packages/schematic-utils/src/json/schema/visitor.ts new file mode 100644 index 0000000..0a46b0f --- /dev/null +++ b/packages/schematic-utils/src/json/schema/visitor.ts @@ -0,0 +1,271 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + Observable, + concat, + from, + isObservable, + of as observableOf, +} from "rxjs"; +import { concatMap, ignoreElements, mergeMap, tap } from "rxjs/operators"; +import { JsonArray, JsonObject, JsonValue } from "../interface"; +import { JsonPointer, JsonSchemaVisitor, JsonVisitor } from "./interface"; +import { buildJsonPointer, joinJsonPointer } from "./pointer"; +import { JsonSchema } from "./schema"; + +export interface ReferenceResolver { + (ref: string, context?: ContextT): { + context?: ContextT; + schema?: JsonObject; + }; +} + +function _getObjectSubSchema( + schema: JsonSchema | undefined, + key: string +): JsonObject | undefined { + if (typeof schema !== "object" || schema === null) { + return undefined; + } + + // Is it an object schema? + if (typeof schema.properties == "object" || schema.type == "object") { + if ( + typeof schema.properties == "object" && + typeof (schema.properties as JsonObject)[key] == "object" + ) { + return (schema.properties as JsonObject)[key] as JsonObject; + } + if (typeof schema.additionalProperties == "object") { + return schema.additionalProperties as JsonObject; + } + + return undefined; + } + + // Is it an array schema? + if (typeof schema.items == "object" || schema.type == "array") { + return typeof schema.items == "object" + ? (schema.items as JsonObject) + : undefined; + } + + return undefined; +} + +function _visitJsonRecursive( + json: JsonValue, + visitor: JsonVisitor, + ptr: JsonPointer, + schema?: JsonSchema, + refResolver?: ReferenceResolver, + context?: ContextT, + root?: JsonObject | JsonArray +): Observable { + if (schema === true || schema === false) { + // There's no schema definition, so just visit the JSON recursively. + schema = undefined; + } + // eslint-disable-next-line no-prototype-builtins + if ( + schema && + schema.hasOwnProperty("$ref") && // eslint-disable-line + typeof schema["$ref"] == "string" + ) { + if (refResolver) { + const resolved = refResolver(schema["$ref"], context); + schema = resolved.schema; + context = resolved.context; + } + } + + const value = visitor(json, ptr, schema as JsonObject, root); + + return (isObservable(value) ? value : observableOf(value)).pipe( + concatMap((value) => { + if (Array.isArray(value)) { + return concat( + from(value).pipe( + mergeMap((item, i) => { + return _visitJsonRecursive( + item, + visitor, + joinJsonPointer(ptr, "" + i), + _getObjectSubSchema(schema, "" + i), + refResolver, + context, + root || value + ).pipe(tap((x) => (value[i] = x))); + }), + ignoreElements() + ), + observableOf(value) + ); + } else if (typeof value == "object" && value !== null) { + return concat( + from(Object.getOwnPropertyNames(value)).pipe( + mergeMap((key) => { + return _visitJsonRecursive( + value[key], + visitor, + joinJsonPointer(ptr, key), + _getObjectSubSchema(schema, key), + refResolver, + context, + root || value + ).pipe( + tap((x) => { + const descriptor = Object.getOwnPropertyDescriptor( + value, + key + ); + if (descriptor && descriptor.writable && value[key] !== x) { + value[key] = x; + } + }) + ); + }), + ignoreElements() + ), + observableOf(value) + ); + } else { + return observableOf(value); + } + }) + ); +} + +/** + * Visit all the properties in a JSON object, allowing to transform them. It supports calling + * properties synchronously or asynchronously (through Observables). + * The original object can be mutated or replaced entirely. In case where it's replaced, the new + * value is returned. When it's mutated though the original object will be changed. + * + * Please note it is possible to have an infinite loop here (which will result in a stack overflow) + * if you return 2 objects that references each others (or the same object all the time). + * + * @param {JsonValue} json The Json value to visit. + * @param {JsonVisitor} visitor A function that will be called on every items. + * @param {JsonObject} schema A JSON schema to pass through to the visitor (where possible). + * @param refResolver a function to resolve references in the schema. + * @returns {Observable< | undefined>} The observable of the new root, if the root changed. + */ +export function visitJson( + json: JsonValue, + visitor: JsonVisitor, + schema?: JsonSchema, + refResolver?: ReferenceResolver, + context?: ContextT +): Observable { + return _visitJsonRecursive( + json, + visitor, + buildJsonPointer([]), + schema, + refResolver, + context + ); +} + +export function visitJsonSchema( // eslint-disable-line + schema: JsonSchema, + visitor: JsonSchemaVisitor +) { + if (schema === false || schema === true) { + // Nothing to visit. + return; + } + + const keywords = { + additionalItems: true, + items: true, + contains: true, + additionalProperties: true, + propertyNames: true, + not: true, + }; + + const arrayKeywords = { + items: true, + allOf: true, + anyOf: true, + oneOf: true, + }; + + const propsKeywords = { + definitions: true, + properties: true, + patternProperties: true, + additionalProperties: true, + dependencies: true, + items: true, + }; + + function _traverse( // eslint-disable-line + schema: JsonObject | JsonArray, + jsonPtr: JsonPointer, + rootSchema: JsonObject, + parentSchema?: JsonObject | JsonArray, + keyIndex?: string + ) { + if (schema && typeof schema == "object" && !Array.isArray(schema)) { + visitor(schema, jsonPtr, parentSchema, keyIndex); + + for (const key of Object.keys(schema)) { + const sch = schema[key]; + if (key in propsKeywords) { + if (sch && typeof sch == "object") { + for (const prop of Object.keys(sch)) { + _traverse( + (sch as JsonObject)[prop] as JsonObject, + joinJsonPointer(jsonPtr, key, prop), + rootSchema, + schema, + prop + ); + } + } + } else if (key in keywords) { + _traverse( + sch as JsonObject, + joinJsonPointer(jsonPtr, key), + rootSchema, + schema, + key + ); + } else if (key in arrayKeywords) { + if (Array.isArray(sch)) { + for (let i = 0; i < sch.length; i++) { + _traverse( + sch[i] as JsonArray, + joinJsonPointer(jsonPtr, key, "" + i), + rootSchema, + sch, + "" + i + ); + } + } + } else if (Array.isArray(sch)) { + for (let i = 0; i < sch.length; i++) { + _traverse( + sch[i] as JsonArray, + joinJsonPointer(jsonPtr, key, "" + i), + rootSchema, + sch, + "" + i + ); + } + } + } + } + } + + _traverse(schema, buildJsonPointer([]), schema); +} diff --git a/packages/schematic-utils/src/logger/indent.spec.ts b/packages/schematic-utils/src/logger/indent.spec.ts new file mode 100644 index 0000000..583b6cd --- /dev/null +++ b/packages/schematic-utils/src/logger/indent.spec.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { toArray } from "rxjs/operators"; +import { IndentLogger } from "./indent"; +import { LogEntry, Logger } from "./logger"; + +describe("IndentSpec", () => { + it("works", (done) => { + const logger = new IndentLogger("test"); + logger + .pipe(toArray()) + .toPromise() + .then((observed: LogEntry[]) => { + expect(observed).toEqual([ + jasmine.objectContaining({ + message: "test", + level: "info", + name: "test", + }) as any, + jasmine.objectContaining({ + message: " test2", + level: "info", + name: "test2", + }) as any, + jasmine.objectContaining({ + message: " test3", + level: "info", + name: "test3", + }) as any, + jasmine.objectContaining({ + message: " test4", + level: "info", + name: "test4", + }) as any, + jasmine.objectContaining({ + message: "test5", + level: "info", + name: "test", + }) as any, + ]); + }) + .then( + () => done(), + (err) => done.fail(err) + ); + const logger2 = new Logger("test2", logger); + const logger3 = new Logger("test3", logger2); + const logger4 = new Logger("test4", logger); + + logger.info("test"); + logger2.info("test2"); + logger3.info("test3"); + logger4.info("test4"); + logger.info("test5"); + + logger.complete(); + }); +}); diff --git a/packages/schematic-utils/src/logger/indent.ts b/packages/schematic-utils/src/logger/indent.ts new file mode 100644 index 0000000..75b95c6 --- /dev/null +++ b/packages/schematic-utils/src/logger/indent.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { map } from "rxjs/operators"; +import { Logger } from "./logger"; + +/** + * Keep an map of indentation => array of indentations based on the level. + * This is to optimize calculating the prefix based on the indentation itself. Since most logs + * come from similar levels, and with similar indentation strings, this will be shared by all + * loggers. Also, string concatenation is expensive so performing concats for every log entries + * is expensive; this alleviates it. + */ +const indentationMap: { [indentationType: string]: string[] } = {}; + +export class IndentLogger extends Logger { + constructor(name: string, parent: Logger | null = null, indentation = " ") { + super(name, parent); + + indentationMap[indentation] = indentationMap[indentation] || [""]; + const indentMap = indentationMap[indentation]; + + this._observable = this._observable.pipe( + map((entry) => { + const l = entry.path.filter((x) => !!x).length; + if (l >= indentMap.length) { + let current = indentMap[indentMap.length - 1]; + while (l >= indentMap.length) { + current += indentation; + indentMap.push(current); + } + } + + entry.message = + indentMap[l] + entry.message.split(/\n/).join("\n" + indentMap[l]); + + return entry; + }) + ); + } +} diff --git a/packages/schematic-utils/src/logger/index.ts b/packages/schematic-utils/src/logger/index.ts new file mode 100644 index 0000000..17eb5a5 --- /dev/null +++ b/packages/schematic-utils/src/logger/index.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from "./indent"; +export * from "./level"; +export * from "./logger"; +export * from "./null-logger"; +export * from "./transform-logger"; diff --git a/packages/schematic-utils/src/logger/level.ts b/packages/schematic-utils/src/logger/level.ts new file mode 100644 index 0000000..76e9d41 --- /dev/null +++ b/packages/schematic-utils/src/logger/level.ts @@ -0,0 +1,78 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { JsonObject } from "../json/interface"; +import { LogLevel, Logger } from "./logger"; + +export class LevelTransformLogger extends Logger { + constructor( + public readonly name: string, + public readonly parent: Logger | null = null, + public readonly levelTransform: (level: LogLevel) => LogLevel + ) { + super(name, parent); + } + + log(level: LogLevel, message: string, metadata: JsonObject = {}): void { + return super.log(this.levelTransform(level), message, metadata); + } + + createChild(name: string): Logger { + return new LevelTransformLogger(name, this, this.levelTransform); + } +} + +export class LevelCapLogger extends LevelTransformLogger { + static levelMap: { [cap: string]: { [level: string]: string } } = { + debug: { + debug: "debug", + info: "debug", + warn: "debug", + error: "debug", + fatal: "debug", + }, + info: { + debug: "debug", + info: "info", + warn: "info", + error: "info", + fatal: "info", + }, + warn: { + debug: "debug", + info: "info", + warn: "warn", + error: "warn", + fatal: "warn", + }, + error: { + debug: "debug", + info: "info", + warn: "warn", + error: "error", + fatal: "error", + }, + fatal: { + debug: "debug", + info: "info", + warn: "warn", + error: "error", + fatal: "fatal", + }, + }; + + constructor( + public readonly name: string, + public readonly parent: Logger | null = null, + public readonly levelCap: LogLevel + ) { + super(name, parent, (level: LogLevel) => { + return (LevelCapLogger.levelMap[levelCap][level] || level) as LogLevel; + }); + } +} diff --git a/packages/schematic-utils/src/logger/logger.spec.ts b/packages/schematic-utils/src/logger/logger.spec.ts new file mode 100644 index 0000000..46aef9e --- /dev/null +++ b/packages/schematic-utils/src/logger/logger.spec.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { toArray } from "rxjs/operators"; +import { JsonValue } from "../json/interface"; +import { Logger } from "./logger"; + +describe("Logger", () => { + it("works", (done) => { + const logger = new Logger("test"); + logger + .pipe(toArray()) + .toPromise() + .then((observed: JsonValue[]) => { + expect(observed).toEqual([ + jasmine.objectContaining({ + message: "hello", + level: "debug", + name: "test", + }) as any, + jasmine.objectContaining({ + message: "world", + level: "info", + name: "test", + }) as any, + ]); + }) + .then( + () => done(), + (err) => done.fail(err) + ); + + logger.debug("hello"); + logger.info("world"); + logger.complete(); + }); + + it("works with children", (done) => { + const logger = new Logger("test"); + let hasCompleted = false; + logger + .pipe(toArray()) + .toPromise() + .then((observed: JsonValue[]) => { + expect(observed).toEqual([ + jasmine.objectContaining({ + message: "hello", + level: "debug", + name: "child", + }) as any, + jasmine.objectContaining({ + message: "world", + level: "info", + name: "child", + }) as any, + ]); + expect(hasCompleted).toBe(true); + }) + .then( + () => done(), + (err) => done.fail(err) + ); + + const childLogger = new Logger("child", logger); + childLogger.subscribe(undefined, undefined, () => (hasCompleted = true)); + childLogger.debug("hello"); + childLogger.info("world"); + logger.complete(); + }); + + it("misses messages if not subscribed", (done) => { + const logger = new Logger("test"); + logger.debug("woah"); + + logger + .pipe(toArray()) + .toPromise() + .then((observed: JsonValue[]) => { + expect(observed).toEqual([ + jasmine.objectContaining({ + message: "hello", + level: "debug", + name: "test", + }) as any, + ]); + }) + .then( + () => done(), + (err) => done.fail(err) + ); + + logger.debug("hello"); + logger.complete(); + }); +}); diff --git a/packages/schematic-utils/src/logger/logger.ts b/packages/schematic-utils/src/logger/logger.ts index 6f1ea5e..aa5d801 100644 --- a/packages/schematic-utils/src/logger/logger.ts +++ b/packages/schematic-utils/src/logger/logger.ts @@ -1,3 +1,11 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + import { Observable, Operator, @@ -36,7 +44,7 @@ export class Logger extends Observable implements LoggerApi { private _obs: Observable = empty(); private _subscription: Subscription | null = null; - protected get _observable(): Observable { + protected get _observable() { return this._obs; } protected set _observable(v: Observable) { @@ -90,28 +98,28 @@ export class Logger extends Observable implements LoggerApi { asApi(): LoggerApi { return { - createChild: (name: string): Logger => this.createChild(name), - log: (level: LogLevel, message: string, metadata?: JsonObject): void => { + createChild: (name: string) => this.createChild(name), + log: (level: LogLevel, message: string, metadata?: JsonObject) => { return this.log(level, message, metadata); }, - debug: (message: string, metadata?: JsonObject): void => + debug: (message: string, metadata?: JsonObject) => this.debug(message, metadata), - info: (message: string, metadata?: JsonObject): void => + info: (message: string, metadata?: JsonObject) => this.info(message, metadata), - warn: (message: string, metadata?: JsonObject): void => + warn: (message: string, metadata?: JsonObject) => this.warn(message, metadata), - error: (message: string, metadata?: JsonObject): void => + error: (message: string, metadata?: JsonObject) => this.error(message, metadata), - fatal: (message: string, metadata?: JsonObject): void => + fatal: (message: string, metadata?: JsonObject) => this.fatal(message, metadata), }; } - createChild(name: string): Logger { + createChild(name: string) { return new (this.constructor as typeof Logger)(name, this); } - complete(): void { + complete() { this._subject.complete(); } @@ -127,23 +135,23 @@ export class Logger extends Observable implements LoggerApi { this._subject.next(entry); } - debug(message: string, metadata: JsonObject = {}): void { + debug(message: string, metadata: JsonObject = {}) { return this.log("debug", message, metadata); } - info(message: string, metadata: JsonObject = {}): void { + info(message: string, metadata: JsonObject = {}) { return this.log("info", message, metadata); } - warn(message: string, metadata: JsonObject = {}): void { + warn(message: string, metadata: JsonObject = {}) { return this.log("warn", message, metadata); } - error(message: string, metadata: JsonObject = {}): void { + error(message: string, metadata: JsonObject = {}) { return this.log("error", message, metadata); } - fatal(message: string, metadata: JsonObject = {}): void { + fatal(message: string, metadata: JsonObject = {}) { return this.log("fatal", message, metadata); } - toString(): string { + toString() { return ``; } @@ -158,7 +166,11 @@ export class Logger extends Observable implements LoggerApi { error?: (error: Error) => void, complete?: () => void ): Subscription; - subscribe(): Subscription { + subscribe( + _observerOrNext?: PartialObserver | ((value: LogEntry) => void), + _error?: (error: Error) => void, + _complete?: () => void + ): Subscription { // eslint-disable-next-line prefer-spread return this._observable.subscribe.apply( this._observable, diff --git a/packages/schematic-utils/src/logger/null-logger.spec.ts b/packages/schematic-utils/src/logger/null-logger.spec.ts new file mode 100644 index 0000000..de13259 --- /dev/null +++ b/packages/schematic-utils/src/logger/null-logger.spec.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { toArray } from "rxjs/operators"; +import { LogEntry, Logger } from "./logger"; +import { NullLogger } from "./null-logger"; + +describe("NullLogger", () => { + it("works", (done) => { + const logger = new NullLogger(); + logger + .pipe(toArray()) + .toPromise() + .then((observed: LogEntry[]) => { + expect(observed).toEqual([]); + }) + .then( + () => done(), + (err) => done.fail(err) + ); + + logger.debug("hello"); + logger.info("world"); + logger.complete(); + }); + + it("nullifies children", (done) => { + const logger = new Logger("test"); + logger + .pipe(toArray()) + .toPromise() + .then((observed: LogEntry[]) => { + expect(observed).toEqual([]); + }) + .then( + () => done(), + (err) => done.fail(err) + ); + + const nullLogger = new NullLogger(logger); + const child = new Logger("test", nullLogger); + child.debug("hello"); + child.info("world"); + logger.complete(); + }); +}); diff --git a/packages/schematic-utils/src/logger/null-logger.ts b/packages/schematic-utils/src/logger/null-logger.ts new file mode 100644 index 0000000..d89d703 --- /dev/null +++ b/packages/schematic-utils/src/logger/null-logger.ts @@ -0,0 +1,29 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { EMPTY } from "rxjs"; +import { Logger, LoggerApi } from "./logger"; + +export class NullLogger extends Logger { + constructor(parent: Logger | null = null) { + super("", parent); + this._observable = EMPTY; + } + + asApi(): LoggerApi { + return { + createChild: () => new NullLogger(this), + log() {}, + debug() {}, + info() {}, + warn() {}, + error() {}, + fatal() {}, + } as LoggerApi; + } +} diff --git a/packages/schematic-utils/src/logger/transform-logger.spec.ts b/packages/schematic-utils/src/logger/transform-logger.spec.ts new file mode 100644 index 0000000..6919f2e --- /dev/null +++ b/packages/schematic-utils/src/logger/transform-logger.spec.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { filter, map, toArray } from "rxjs/operators"; +import { LogEntry } from "./logger"; +import { TransformLogger } from "./transform-logger"; + +describe("TransformLogger", () => { + it("works", (done) => { + const logger = new TransformLogger("test", (stream) => { + return stream.pipe( + filter((entry) => entry.message != "hello"), + map((entry) => ((entry.message += "1"), entry)) + ); + }); + logger + .pipe(toArray()) + .toPromise() + .then((observed: LogEntry[]) => { + expect(observed).toEqual([ + jasmine.objectContaining({ + message: "world1", + level: "info", + name: "test", + }) as any, + ]); + }) + .then( + () => done(), + (err) => done.fail(err) + ); + + logger.debug("hello"); + logger.info("world"); + logger.complete(); + }); +}); diff --git a/packages/schematic-utils/src/logger/transform-logger.ts b/packages/schematic-utils/src/logger/transform-logger.ts new file mode 100644 index 0000000..f233935 --- /dev/null +++ b/packages/schematic-utils/src/logger/transform-logger.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { Observable } from "rxjs"; +import { LogEntry, Logger } from "./logger"; + +export class TransformLogger extends Logger { + constructor( + name: string, + transform: (stream: Observable) => Observable, + parent: Logger | null = null + ) { + super(name, parent); + this._observable = transform(this._observable); + } +} diff --git a/packages/schematic-utils/src/rules/call.spec.ts b/packages/schematic-utils/src/rules/call.spec.ts index 3e77b80..3ef388f 100644 --- a/packages/schematic-utils/src/rules/call.spec.ts +++ b/packages/schematic-utils/src/rules/call.spec.ts @@ -1,7 +1,6 @@ -import { MergeStrategy } from "@angular-devkit/schematics"; import { of as observableOf } from "rxjs"; import { Rule, SchematicContext, Source } from "../engine/interface"; -import { Tree } from "../tree/interface"; +import { MergeStrategy, Tree } from "../tree/interface"; import { empty } from "../tree/static"; import { InvalidRuleResultException, diff --git a/packages/schematic-utils/src/rules/call.ts b/packages/schematic-utils/src/rules/call.ts index ad9a21b..b207440 100644 --- a/packages/schematic-utils/src/rules/call.ts +++ b/packages/schematic-utils/src/rules/call.ts @@ -1,4 +1,3 @@ -import { BaseException, isPromise } from "@angular-devkit/core"; import { Observable, from, @@ -9,6 +8,8 @@ import { import { defaultIfEmpty, last, mergeMap, tap } from "rxjs/operators"; import { Rule, SchematicContext, Source } from "../engine/interface"; import { Tree, TreeSymbol } from "../tree/interface"; +import { isPromise } from "../utils/runtime"; +import { BaseException } from "../exceptions/exception"; function _getTypeOfResult(value?: any): string { if (value === undefined) { diff --git a/packages/schematic-utils/src/tree/action.spec.ts b/packages/schematic-utils/src/tree/action.spec.ts index ce72aec..e16ab6b 100644 --- a/packages/schematic-utils/src/tree/action.spec.ts +++ b/packages/schematic-utils/src/tree/action.spec.ts @@ -1,5 +1,5 @@ -import { normalize } from "@angular-devkit/core"; import { ActionList } from "./action"; +import { normalize } from "../virtual-fs"; describe("Action", () => { describe("optimize", () => { diff --git a/packages/schematic-utils/src/tree/action.ts b/packages/schematic-utils/src/tree/action.ts index 3dbe2fc..0da53f4 100644 --- a/packages/schematic-utils/src/tree/action.ts +++ b/packages/schematic-utils/src/tree/action.ts @@ -1,4 +1,5 @@ -import { BaseException, Path } from "@angular-devkit/core"; +import { BaseException } from "../exceptions/exception"; +import { Path } from "../virtual-fs"; export class UnknownActionException extends BaseException { constructor(action: Action) { diff --git a/packages/schematic-utils/src/tree/entry.ts b/packages/schematic-utils/src/tree/entry.ts index 210764b..f7a7a48 100644 --- a/packages/schematic-utils/src/tree/entry.ts +++ b/packages/schematic-utils/src/tree/entry.ts @@ -1,5 +1,5 @@ -import { Path } from "@angular-devkit/core"; import { FileEntry } from "./interface"; +import { Path } from "../virtual-fs"; export class SimpleFileEntry implements FileEntry { constructor(private _path: Path, private _content: Buffer) {} diff --git a/packages/schematic-utils/src/tree/host-tree.spec.ts b/packages/schematic-utils/src/tree/host-tree.spec.ts index 23f8cfe..ba548d5 100644 --- a/packages/schematic-utils/src/tree/host-tree.spec.ts +++ b/packages/schematic-utils/src/tree/host-tree.spec.ts @@ -1,4 +1,4 @@ -import { normalize, virtualFs } from "@angular-devkit/core"; +import { normalize, virtualFs } from "../virtual-fs"; import { FilterHostTree, HostTree } from "./host-tree"; import { MergeStrategy } from "./interface"; diff --git a/packages/schematic-utils/src/tree/host-tree.ts b/packages/schematic-utils/src/tree/host-tree.ts index caaf46e..3becece 100644 --- a/packages/schematic-utils/src/tree/host-tree.ts +++ b/packages/schematic-utils/src/tree/host-tree.ts @@ -5,17 +5,6 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ - -import { - Path, - PathFragment, - PathIsDirectoryException, - PathIsFileException, - dirname, - join, - normalize, - virtualFs, -} from "@angular-devkit/core"; import { EMPTY, Observable } from "rxjs"; import { concatMap, map, mergeMap } from "rxjs/operators"; import { @@ -45,9 +34,19 @@ import { FileDoesNotExistException, InvalidUpdateRecordException, MergeConflictException, + PathIsDirectoryException, + PathIsFileException, SchematicsException, } from "../exceptions/exception"; import { LazyFileEntry } from "./entry"; +import { + dirname, + join, + normalize, + Path, + PathFragment, + virtualFs, +} from "../virtual-fs"; let _uniqueId = 0; @@ -64,6 +63,7 @@ export class HostDirEntry implements DirEntry { .list(this.path) .filter((fragment) => this._host.isDirectory(join(this.path, fragment))); } + get subfiles(): PathFragment[] { return this._host .list(this.path) @@ -73,6 +73,7 @@ export class HostDirEntry implements DirEntry { dir(name: PathFragment): DirEntry { return this._tree.getDir(join(this.path, name)); } + file(name: PathFragment): FileEntry | null { return this._tree.get(join(this.path, name)); } @@ -311,6 +312,7 @@ export class HostTree implements Tree { return entry ? entry.content : null; } + exists(path: string): boolean { return this._recordSync.isFile(this._normalizePath(path)); } @@ -351,6 +353,7 @@ export class HostTree implements Tree { return maybeCache; } + visit(visitor: FileVisitor): void { this.root.visit((path, entry) => { visitor(path, entry); @@ -366,6 +369,7 @@ export class HostTree implements Tree { const c = typeof content == "string" ? Buffer.from(content) : content; this._record.overwrite(p, (c as any) as virtualFs.FileBuffer).subscribe(); } + beginUpdate(path: string): UpdateRecorder { const entry = this.get(path); if (!entry) { @@ -374,6 +378,7 @@ export class HostTree implements Tree { return UpdateRecorderBase.createFromFileEntry(entry); } + commitUpdate(record: UpdateRecorder): void { if (record instanceof UpdateRecorderBase) { const path = record.path; @@ -400,9 +405,11 @@ export class HostTree implements Tree { const c = typeof content == "string" ? Buffer.from(content) : content; this._record.create(p, (c as any) as virtualFs.FileBuffer).subscribe(); } + delete(path: string): void { this._recordSync.delete(this._normalizePath(path)); } + rename(from: string, to: string): void { this._recordSync.rename(this._normalizePath(from), this._normalizePath(to)); } diff --git a/packages/schematic-utils/src/tree/interface.ts b/packages/schematic-utils/src/tree/interface.ts index 5bbf45f..1b245b3 100644 --- a/packages/schematic-utils/src/tree/interface.ts +++ b/packages/schematic-utils/src/tree/interface.ts @@ -1,4 +1,4 @@ -import { Path, PathFragment } from "@angular-devkit/core"; +import { Path, PathFragment } from "../virtual-fs"; import { Action } from "./action"; export enum MergeStrategy { diff --git a/packages/schematic-utils/src/tree/recorder.spec.ts b/packages/schematic-utils/src/tree/recorder.spec.ts index 4a9bed8..4cbe43d 100644 --- a/packages/schematic-utils/src/tree/recorder.spec.ts +++ b/packages/schematic-utils/src/tree/recorder.spec.ts @@ -1,6 +1,6 @@ -import { normalize } from "@angular-devkit/core"; import { SimpleFileEntry } from "./entry"; import { UpdateRecorderBase, UpdateRecorderBom } from "./recorder"; +import { normalize } from "../virtual-fs"; describe("UpdateRecorderBase", () => { it("works for simple files", () => { diff --git a/packages/schematic-utils/src/tree/scoped.ts b/packages/schematic-utils/src/tree/scoped.ts index 0180331..85d34ee 100644 --- a/packages/schematic-utils/src/tree/scoped.ts +++ b/packages/schematic-utils/src/tree/scoped.ts @@ -1,11 +1,3 @@ -import { - NormalizedRoot, - Path, - PathFragment, - join, - normalize, - relative, -} from "@angular-devkit/core"; import { Action } from "./action"; import { DirEntry, @@ -17,6 +9,14 @@ import { UpdateRecorder, } from "./interface"; import { DelegateTree } from "./delegate"; +import { + join, + normalize, + NormalizedRoot, + Path, + PathFragment, + relative, +} from "../virtual-fs"; class ScopedFileEntry implements FileEntry { constructor(private _base: FileEntry, private scope: Path) {} diff --git a/packages/schematic-utils/src/utility/update-buffer.ts b/packages/schematic-utils/src/utility/update-buffer.ts index 4d1682f..62615f4 100644 --- a/packages/schematic-utils/src/utility/update-buffer.ts +++ b/packages/schematic-utils/src/utility/update-buffer.ts @@ -1,5 +1,5 @@ -import { BaseException } from "@angular-devkit/core"; import { LinkedList } from "./linked-list"; +import { BaseException } from "../exceptions/exception"; export class IndexOutOfBoundException extends BaseException { constructor(index: number, min: number, max = Infinity) { diff --git a/packages/schematic-utils/src/utils/ast-utils.spec.ts b/packages/schematic-utils/src/utils/ast-utils.spec.ts index 9ea1095..3c153d2 100644 --- a/packages/schematic-utils/src/utils/ast-utils.spec.ts +++ b/packages/schematic-utils/src/utils/ast-utils.spec.ts @@ -1,4 +1,4 @@ -import { tags } from "@angular-devkit/core"; +import * as tags from "./runtime/literals"; import * as ts from "typescript"; import { Change, InsertChange } from "./change"; import { getFileContent } from "./get-file-content"; diff --git a/packages/schematic-utils/src/utils/ast-utils.ts b/packages/schematic-utils/src/utils/ast-utils.ts index 6f80494..8d87c50 100644 --- a/packages/schematic-utils/src/utils/ast-utils.ts +++ b/packages/schematic-utils/src/utils/ast-utils.ts @@ -1,4 +1,4 @@ -import { tags } from "@angular-devkit/core"; +import * as tags from "./runtime/literals"; import * as ts from "typescript"; import { Change, InsertChange, NoopChange } from "./change"; diff --git a/packages/schematic-utils/src/utils/create-app-module.ts b/packages/schematic-utils/src/utils/create-app-module.ts index 0e7d783..9c072e5 100644 --- a/packages/schematic-utils/src/utils/create-app-module.ts +++ b/packages/schematic-utils/src/utils/create-app-module.ts @@ -1,4 +1,4 @@ -import { UnitTestTree } from "@angular-devkit/schematics/testing"; +import { UnitTestTree } from "../tree/unit-test-tree"; export function createAppModule( tree: UnitTestTree, diff --git a/packages/schematic-utils/src/utils/find-module.spec.ts b/packages/schematic-utils/src/utils/find-module.spec.ts index 4486382..5ed0f57 100644 --- a/packages/schematic-utils/src/utils/find-module.spec.ts +++ b/packages/schematic-utils/src/utils/find-module.spec.ts @@ -1,4 +1,3 @@ -import { Path } from "@angular-devkit/core"; import { ModuleOptions, buildRelativePath, @@ -7,6 +6,7 @@ import { } from "./find-module"; import { Tree } from "../tree/interface"; import { EmptyTree } from "../tree/empty"; +import { Path } from "../virtual-fs/path"; describe("find-module", () => { describe("findModule", () => { diff --git a/packages/schematic-utils/src/utils/find-module.ts b/packages/schematic-utils/src/utils/find-module.ts index 7f0ee02..98dc13a 100644 --- a/packages/schematic-utils/src/utils/find-module.ts +++ b/packages/schematic-utils/src/utils/find-module.ts @@ -1,13 +1,12 @@ +import { DirEntry, Tree } from "../tree/interface"; import { - NormalizedRoot, - Path, dirname, join, normalize, + NormalizedRoot, + Path, relative, -} from "@angular-devkit/core"; -import { DirEntry } from "@angular-devkit/schematics"; -import { Tree } from "../tree/interface"; +} from "../virtual-fs/path"; export interface ModuleOptions { module?: string; diff --git a/packages/schematic-utils/src/utils/ng-ast-utils.ts b/packages/schematic-utils/src/utils/ng-ast-utils.ts index 1b9b50e..b428b7f 100644 --- a/packages/schematic-utils/src/utils/ng-ast-utils.ts +++ b/packages/schematic-utils/src/utils/ng-ast-utils.ts @@ -1,9 +1,9 @@ -import { normalize } from "@angular-devkit/core"; -import { SchematicsException } from "@angular-devkit/schematics"; import { dirname } from "path"; import * as ts from "typescript"; import { findNode, getSourceNodes } from "./ast-utils"; import { Tree } from "../tree/interface"; +import { SchematicsException } from "../exceptions/exception"; +import { normalize } from "../virtual-fs/path"; export function findBootstrapModuleCall( host: Tree, diff --git a/packages/schematic-utils/src/utils/runtime/base.spec.ts b/packages/schematic-utils/src/utils/runtime/base.spec.ts index 85a9487..80b23f7 100644 --- a/packages/schematic-utils/src/utils/runtime/base.spec.ts +++ b/packages/schematic-utils/src/utils/runtime/base.spec.ts @@ -1,12 +1,12 @@ -import { HostTree, MergeStrategy } from "@angular-devkit/schematics"; import { of as observableOf } from "rxjs"; import { apply, applyToSubtree, chain } from "./base"; import { Rule, SchematicContext, Source } from "../../engine/interface"; -import { Tree } from "../../tree/interface"; +import { MergeStrategy, Tree } from "../../tree/interface"; import { callSource } from "../../rules/call"; import { callRule } from "./call"; import { empty } from "../../tree/static"; import { move } from "./move"; +import { HostTree } from "../../tree/host-tree"; const context: SchematicContext = ({ engine: null, diff --git a/packages/schematic-utils/src/utils/runtime/call.spec.ts b/packages/schematic-utils/src/utils/runtime/call.spec.ts index 72dbdd4..a6476c0 100644 --- a/packages/schematic-utils/src/utils/runtime/call.spec.ts +++ b/packages/schematic-utils/src/utils/runtime/call.spec.ts @@ -1,4 +1,3 @@ -import { MergeStrategy } from "@angular-devkit/schematics"; import { of as observableOf } from "rxjs"; import { InvalidRuleResultException, @@ -6,7 +5,7 @@ import { callRule, callSource, } from "./call"; -import { Tree } from "../../tree/interface"; +import { MergeStrategy, Tree } from "../../tree/interface"; import { Rule, SchematicContext, Source } from "../../engine/interface"; import { empty } from "../../tree/static"; diff --git a/packages/schematic-utils/src/utils/runtime/call.ts b/packages/schematic-utils/src/utils/runtime/call.ts index 5d0e449..2bcdc06 100644 --- a/packages/schematic-utils/src/utils/runtime/call.ts +++ b/packages/schematic-utils/src/utils/runtime/call.ts @@ -1,14 +1,15 @@ -import { BaseException, isPromise } from "@angular-devkit/core"; import { - Observable, from, isObservable, + Observable, of as observableOf, throwError, } from "rxjs"; import { defaultIfEmpty, last, mergeMap, tap } from "rxjs/operators"; import { Tree, TreeSymbol } from "../../tree/interface"; import { Rule, SchematicContext, Source } from "../../engine/interface"; +import { BaseException } from "../../exceptions/exception"; +import { isPromise } from "./lang"; function _getTypeOfResult(value?: any): string { if (value === undefined) { diff --git a/packages/schematic-utils/src/utils/runtime/move.ts b/packages/schematic-utils/src/utils/runtime/move.ts index d33fbaf..7e20770 100644 --- a/packages/schematic-utils/src/utils/runtime/move.ts +++ b/packages/schematic-utils/src/utils/runtime/move.ts @@ -1,7 +1,7 @@ -import { join, normalize } from "@angular-devkit/core"; import { noop } from "./base"; import { Rule } from "../../engine/interface"; import { Tree } from "../../tree/interface"; +import { join, normalize } from "../../virtual-fs/path"; export function move(from: string, to?: string): Rule { if (to === undefined) { diff --git a/packages/schematic-utils/src/utils/runtime/parse-name.ts b/packages/schematic-utils/src/utils/runtime/parse-name.ts index 5784604..ebd5d11 100644 --- a/packages/schematic-utils/src/utils/runtime/parse-name.ts +++ b/packages/schematic-utils/src/utils/runtime/parse-name.ts @@ -1,4 +1,10 @@ -import { basename, dirname, join, normalize, Path } from "@angular-devkit/core"; +import { + basename, + dirname, + join, + normalize, + Path, +} from "../../virtual-fs/path"; export interface Location { name: string; diff --git a/packages/schematic-utils/src/utils/runtime/paths.ts b/packages/schematic-utils/src/utils/runtime/paths.ts index b9382b2..e938eaf 100644 --- a/packages/schematic-utils/src/utils/runtime/paths.ts +++ b/packages/schematic-utils/src/utils/runtime/paths.ts @@ -1,4 +1,4 @@ -import { normalize, split } from "@angular-devkit/core"; +import { normalize, split } from "../../virtual-fs"; export function relativePathToWorkspaceRoot( projectRoot: string | undefined diff --git a/packages/schematic-utils/src/utils/runtime/rename.ts b/packages/schematic-utils/src/utils/runtime/rename.ts index 70e3728..7aa5cd4 100644 --- a/packages/schematic-utils/src/utils/runtime/rename.ts +++ b/packages/schematic-utils/src/utils/runtime/rename.ts @@ -1,7 +1,7 @@ -import { normalize } from "@angular-devkit/core"; import { forEach } from "./base"; import { FilePredicate } from "../../tree/interface"; import { Rule } from "../../engine/interface"; +import { normalize } from "../../virtual-fs/path"; export function rename( match: FilePredicate, diff --git a/packages/schematic-utils/src/utils/runtime/template.spec.ts b/packages/schematic-utils/src/utils/runtime/template.spec.ts index 29759ec..0c3bf7c 100644 --- a/packages/schematic-utils/src/utils/runtime/template.spec.ts +++ b/packages/schematic-utils/src/utils/runtime/template.spec.ts @@ -1,19 +1,19 @@ -import { normalize } from "@angular-devkit/core"; -import { UnitTestTree } from "@angular-devkit/schematics/testing"; import { of as observableOf } from "rxjs"; import { - InvalidPipeException, - OptionIsNotDefinedException, - UnknownPipeException, applyContentTemplate, applyPathTemplate, applyTemplates, + InvalidPipeException, + OptionIsNotDefinedException, + UnknownPipeException, } from "./template"; import { FileEntry, MergeStrategy, Tree } from "../../tree/interface"; import { SchematicContext } from "../../engine/interface"; -import { HostTree } from "@angular-devkit/schematics"; import { callRule } from "./call"; +import { normalize } from "../../virtual-fs/path"; +import { UnitTestTree } from "../../tree/unit-test-tree"; +import { HostTree } from "../../tree/host-tree"; function _entry(path?: string, content?: string): FileEntry { if (!path) { diff --git a/packages/schematic-utils/src/utils/runtime/template.ts b/packages/schematic-utils/src/utils/runtime/template.ts index 07c6bb1..2c92a82 100644 --- a/packages/schematic-utils/src/utils/runtime/template.ts +++ b/packages/schematic-utils/src/utils/runtime/template.ts @@ -1,13 +1,11 @@ -import { - BaseException, - normalize, - template as templateImpl, -} from "@angular-devkit/core"; +import { template as templateImpl } from "../template"; import { TextDecoder } from "util"; import { chain, composeFileOperators, forEach, when } from "./base"; import { rename } from "./rename"; import { FileOperator, Rule } from "../../engine/interface"; import { FileEntry } from "../../tree/interface"; +import { BaseException } from "../../exceptions/exception"; +import { normalize } from "../../virtual-fs/path"; export const TEMPLATE_FILENAME_RE = /\.template$/; diff --git a/packages/schematic-utils/src/utils/runtime/validation.ts b/packages/schematic-utils/src/utils/runtime/validation.ts index 3401545..7331675 100644 --- a/packages/schematic-utils/src/utils/runtime/validation.ts +++ b/packages/schematic-utils/src/utils/runtime/validation.ts @@ -1,4 +1,4 @@ -import { tags } from "@angular-devkit/core"; +import * as tags from "./literals"; import { SchematicsException } from "../../exceptions/exception"; export function validateName(name: string): void { diff --git a/packages/schematic-utils/src/utils/workspace.ts b/packages/schematic-utils/src/utils/workspace.ts index 5171f5d..51e2bb1 100644 --- a/packages/schematic-utils/src/utils/workspace.ts +++ b/packages/schematic-utils/src/utils/workspace.ts @@ -1,8 +1,10 @@ -import { json, virtualFs, workspaces } from "@angular-devkit/core"; +import * as workspaces from "../workspace"; +import * as json from "../json/interface"; import { ProjectType } from "./workspace-models"; import { Tree } from "../tree/interface"; import { Rule } from "../engine/interface"; import { noop } from "./runtime"; +import { virtualFs } from "../virtual-fs"; function createHost(tree: Tree): workspaces.WorkspaceHost { return { diff --git a/packages/schematic-utils/src/virtual-fs/host/alias.ts b/packages/schematic-utils/src/virtual-fs/host/alias.ts new file mode 100644 index 0000000..72b03d0 --- /dev/null +++ b/packages/schematic-utils/src/virtual-fs/host/alias.ts @@ -0,0 +1,88 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { NormalizedRoot, Path, PathFragment, join, split } from "../path"; +import { ResolverHost } from "./resolver"; + +/** + * A Virtual Host that allow to alias some paths to other paths. + * + * This does not verify, when setting an alias, that the target or source exist. Neither does it + * check whether it's a file or a directory. Please not that directories are also renamed/replaced. + * + * No recursion is done on the resolution, which means the following is perfectly valid then: + * + * ``` + * host.aliases.set(normalize('/file/a'), normalize('/file/b')); + * host.aliases.set(normalize('/file/b'), normalize('/file/a')); + * ``` + * + * This will result in a proper swap of two files for each others. + * + * @example + * const host = new SimpleMemoryHost(); + * host.write(normalize('/some/file'), content).subscribe(); + * + * const aHost = new AliasHost(host); + * aHost.read(normalize('/some/file')) + * .subscribe(x => expect(x).toBe(content)); + * aHost.aliases.set(normalize('/some/file'), normalize('/other/path'); + * + * // This file will not exist because /other/path does not exist. + * aHost.read(normalize('/some/file')) + * .subscribe(undefined, err => expect(err.message).toMatch(/does not exist/)); + * + * @example + * const host = new SimpleMemoryHost(); + * host.write(normalize('/some/folder/file'), content).subscribe(); + * + * const aHost = new AliasHost(host); + * aHost.read(normalize('/some/folder/file')) + * .subscribe(x => expect(x).toBe(content)); + * aHost.aliases.set(normalize('/some'), normalize('/other'); + * + * // This file will not exist because /other/path does not exist. + * aHost.read(normalize('/some/folder/file')) + * .subscribe(undefined, err => expect(err.message).toMatch(/does not exist/)); + * + * // Create the file with new content and verify that this has the new content. + * aHost.write(normalize('/other/folder/file'), content2).subscribe(); + * aHost.read(normalize('/some/folder/file')) + * .subscribe(x => expect(x).toBe(content2)); + */ +export class AliasHost extends ResolverHost< + StatsT +> { + protected _aliases = new Map(); + + protected _resolve(path: Path) { + let maybeAlias = this._aliases.get(path); + const sp = split(path); + const remaining: PathFragment[] = []; + + // Also resolve all parents of the requested files, only picking the first one that matches. + // This can have surprising behaviour when aliases are inside another alias. It will always + // use the closest one to the file. + while (!maybeAlias && sp.length > 0) { + const p = join(NormalizedRoot, ...sp); + maybeAlias = this._aliases.get(p); + + if (maybeAlias) { + maybeAlias = join(maybeAlias, ...remaining); + } + // Allow non-null-operator because we know sp.length > 0 (condition on while). + remaining.unshift(sp.pop()!); // eslint-disable-line @typescript-eslint/no-non-null-assertion + } + + return maybeAlias || path; + } + + get aliases(): Map { + return this._aliases; + } +} diff --git a/packages/schematic-utils/src/virtual-fs/host/alias_spec.ts b/packages/schematic-utils/src/virtual-fs/host/alias_spec.ts new file mode 100644 index 0000000..68001e5 --- /dev/null +++ b/packages/schematic-utils/src/virtual-fs/host/alias_spec.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { normalize } from "../path"; +import { AliasHost } from "./alias"; +import { stringToFileBuffer } from "./buffer"; +import { SimpleMemoryHost } from "./memory"; + +describe("AliasHost", () => { + it("works as in the example", () => { + const content = stringToFileBuffer("hello world"); + + const host = new SimpleMemoryHost(); + host.write(normalize("/some/file"), content).subscribe(); + + const aHost = new AliasHost(host); + aHost + .read(normalize("/some/file")) + .subscribe((x) => expect(x).toBe(content)); + aHost.aliases.set(normalize("/some/file"), normalize("/other/path")); + + // This file will not exist because /other/path does not exist. + try { + aHost.read(normalize("/some/file")).subscribe(undefined, (err) => { + expect(err.message).toMatch(/does not exist/); + }); + } catch { + // Ignore it. RxJS <6 still throw errors when they happen synchronously. + } + }); + + it("works as in the example (2)", () => { + const content = stringToFileBuffer("hello world"); + const content2 = stringToFileBuffer("hello world 2"); + + const host = new SimpleMemoryHost(); + host.write(normalize("/some/folder/file"), content).subscribe(); + + const aHost = new AliasHost(host); + aHost + .read(normalize("/some/folder/file")) + .subscribe((x) => expect(x).toBe(content)); + aHost.aliases.set(normalize("/some"), normalize("/other")); + + // This file will not exist because /other/path does not exist. + try { + aHost + .read(normalize("/some/folder/file")) + .subscribe(undefined, (err) => + expect(err.message).toMatch(/does not exist/) + ); + } catch {} + + // Create the file with new content and verify that this has the new content. + aHost.write(normalize("/other/folder/file"), content2).subscribe(); + aHost + .read(normalize("/some/folder/file")) + .subscribe((x) => expect(x).toBe(content2)); + }); +}); diff --git a/packages/schematic-utils/src/virtual-fs/host/buffer.ts b/packages/schematic-utils/src/virtual-fs/host/buffer.ts new file mode 100644 index 0000000..0888ec9 --- /dev/null +++ b/packages/schematic-utils/src/virtual-fs/host/buffer.ts @@ -0,0 +1,82 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { TemplateTag } from "../../utils/runtime"; +import { FileBuffer } from "./interface"; + +declare const TextEncoder: { + new (encoding: string): { + encode(str: string): Uint8Array; + }; +}; + +declare const TextDecoder: { + new (encoding: string): { + decode(bytes: Uint8Array): string; + }; +}; + +export function stringToFileBuffer(str: string): FileBuffer { + // If we're in Node... + if (typeof Buffer !== "undefined" && typeof Buffer.from === "function") { + const buf = Buffer.from(str); + const ab = new ArrayBuffer(buf.length); + const view = new Uint8Array(ab); + for (let i = 0; i < buf.length; ++i) { + view[i] = buf[i]; + } + + return ab; + } else if (typeof TextEncoder !== "undefined") { + // Modern browsers implement TextEncode. + return new TextEncoder("utf-8").encode(str).buffer as ArrayBuffer; + } else { + // Slowest method but sure to be compatible with every platform. + const buf = new ArrayBuffer(str.length * 2); // 2 bytes for each char + const bufView = new Uint16Array(buf); + for (let i = 0, strLen = str.length; i < strLen; i++) { + bufView[i] = str.charCodeAt(i); + } + + return buf; + } +} + +export const fileBuffer: TemplateTag = (strings, ...values) => { + return stringToFileBuffer(String.raw(strings, ...values)); +}; + +export function fileBufferToString(fileBuffer: FileBuffer): string { + if (fileBuffer.toString.length == 1) { + return (fileBuffer.toString as (enc: string) => string)("utf-8"); + } else if (typeof Buffer !== "undefined") { + return Buffer.from(fileBuffer).toString("utf-8"); + } else if (typeof TextDecoder !== "undefined") { + // Modern browsers implement TextEncode. + return new TextDecoder("utf-8").decode(new Uint8Array(fileBuffer)); + } else { + // Slowest method but sure to be compatible with every platform. + const bufView = new Uint8Array(fileBuffer); + const bufLength = bufView.length; + let result = ""; + let chunkLength = Math.pow(2, 16) - 1; + + // We have to chunk it because String.fromCharCode.apply will throw + // `Maximum call stack size exceeded` on big inputs. + for (let i = 0; i < bufLength; i += chunkLength) { + if (i + chunkLength > bufLength) { + chunkLength = bufLength - i; + } + result += String.fromCharCode.apply(null, [ + ...bufView.subarray(i, i + chunkLength), + ]); + } + + return result; + } +} diff --git a/packages/schematic-utils/src/virtual-fs/host/create.ts b/packages/schematic-utils/src/virtual-fs/host/create.ts new file mode 100644 index 0000000..c5d12d8 --- /dev/null +++ b/packages/schematic-utils/src/virtual-fs/host/create.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { Observable } from "rxjs"; +import { Path, PathFragment } from "../path"; +import { + FileBuffer, + FileBufferLike, + Host, + HostCapabilities, + Stats, +} from "./interface"; + +export interface SyncHostHandler { + read(path: Path): FileBuffer; + list(path: Path): PathFragment[]; + + exists(path: Path): boolean; + isDirectory(path: Path): boolean; + isFile(path: Path): boolean; + + stat(path: Path): Stats | null; + + write(path: Path, content: FileBufferLike): void; + delete(path: Path): void; + rename(from: Path, to: Path): void; +} + +function wrapAction(action: () => T): Observable { + return new Observable((subscriber) => { + subscriber.next(action()); + subscriber.complete(); + }); +} + +export function createSyncHost( + handler: SyncHostHandler +): Host { + return new (class { + get capabilities(): HostCapabilities { + return { synchronous: true }; + } + + read(path: Path): Observable { + return wrapAction(() => handler.read(path)); + } + + list(path: Path): Observable { + return wrapAction(() => handler.list(path)); + } + + exists(path: Path): Observable { + return wrapAction(() => handler.exists(path)); + } + + isDirectory(path: Path): Observable { + return wrapAction(() => handler.isDirectory(path)); + } + + isFile(path: Path): Observable { + return wrapAction(() => handler.isFile(path)); + } + + stat(path: Path): Observable | null> { + return wrapAction(() => handler.stat(path)); + } + + write(path: Path, content: FileBufferLike): Observable { + return wrapAction(() => handler.write(path, content)); + } + + delete(path: Path): Observable { + return wrapAction(() => handler.delete(path)); + } + + rename(from: Path, to: Path): Observable { + return wrapAction(() => handler.rename(from, to)); + } + + watch(): null { + return null; + } + })(); +} diff --git a/packages/schematic-utils/src/virtual-fs/host/empty.ts b/packages/schematic-utils/src/virtual-fs/host/empty.ts new file mode 100644 index 0000000..3e7cc10 --- /dev/null +++ b/packages/schematic-utils/src/virtual-fs/host/empty.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { Observable, of, throwError } from "rxjs"; +import { Path, PathFragment } from "../path"; +import { FileBuffer, HostCapabilities, ReadonlyHost, Stats } from "./interface"; +import { FileDoesNotExistException } from "../../exceptions/exception"; + +export class Empty implements ReadonlyHost { + readonly capabilities: HostCapabilities = { + synchronous: true, + }; + + read(path: Path): Observable { + return throwError(new FileDoesNotExistException(path)); + } + + // @ts-ignore + list(path: Path): Observable { + return of([]); + } + + // @ts-ignore + exists(path: Path): Observable { + return of(false); + } + + // @ts-ignore + isDirectory(path: Path): Observable { + return of(false); + } + + // @ts-ignore + isFile(path: Path): Observable { + return of(false); + } + + // @ts-ignore + stat(path: Path): Observable | null> { + // We support stat() but have no file. + return of(null); + } +} diff --git a/packages/schematic-utils/src/virtual-fs/host/index.ts b/packages/schematic-utils/src/virtual-fs/host/index.ts new file mode 100644 index 0000000..d5dafe0 --- /dev/null +++ b/packages/schematic-utils/src/virtual-fs/host/index.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from "./alias"; +export * from "./buffer"; +export * from "./create"; +export * from "./empty"; +export * from "./interface"; +export * from "./memory"; +export * from "./pattern"; +export * from "./record"; +export * from "./safe"; +export * from "./scoped"; +export * from "./sync"; +export * from "./resolver"; +export * from "./test"; diff --git a/packages/schematic-utils/src/virtual-fs/host/interface.ts b/packages/schematic-utils/src/virtual-fs/host/interface.ts new file mode 100644 index 0000000..161ce34 --- /dev/null +++ b/packages/schematic-utils/src/virtual-fs/host/interface.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { Observable } from "rxjs"; +import { Path, PathFragment } from "../path"; + +export type FileBuffer = ArrayBuffer; +export type FileBufferLike = ArrayBufferLike; + +export interface HostWatchOptions { + readonly persistent?: boolean; + readonly recursive?: boolean; +} + +export const enum HostWatchEventType { + Changed = 0, + Created = 1, + Deleted = 2, + Renamed = 3, // Applied to the original file path. +} + +export type Stats = T & { + isFile(): boolean; + isDirectory(): boolean; + + readonly size: number; + + readonly atime: Date; + readonly mtime: Date; + readonly ctime: Date; + readonly birthtime: Date; +}; + +export interface HostWatchEvent { + readonly time: Date; + readonly type: HostWatchEventType; + readonly path: Path; +} + +export interface HostCapabilities { + synchronous: boolean; +} + +export interface ReadonlyHost { + readonly capabilities: HostCapabilities; + + read(path: Path): Observable; + + list(path: Path): Observable; + + exists(path: Path): Observable; + isDirectory(path: Path): Observable; + isFile(path: Path): Observable; + + // Some hosts may not support stats. + stat(path: Path): Observable | null> | null; +} + +export interface Host extends ReadonlyHost { + write(path: Path, content: FileBufferLike): Observable; + delete(path: Path): Observable; + rename(from: Path, to: Path): Observable; + + // Some hosts may not support watching. + watch( + path: Path, + options?: HostWatchOptions + ): Observable | null; +} diff --git a/packages/schematic-utils/src/virtual-fs/host/memory.ts b/packages/schematic-utils/src/virtual-fs/host/memory.ts new file mode 100644 index 0000000..26015a2 --- /dev/null +++ b/packages/schematic-utils/src/virtual-fs/host/memory.ts @@ -0,0 +1,412 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { Observable, Subject } from "rxjs"; +import { + FileAlreadyExistException, + FileDoesNotExistException, + PathIsDirectoryException, + PathIsFileException, +} from "../../exceptions/exception"; +import { + NormalizedRoot, + NormalizedSep, + Path, + PathFragment, + dirname, + isAbsolute, + join, + normalize, + split, +} from "../path"; +import { + FileBuffer, + Host, + HostCapabilities, + HostWatchEvent, + HostWatchEventType, + HostWatchOptions, + Stats, +} from "./interface"; + +export interface SimpleMemoryHostStats { + readonly content: FileBuffer | null; +} + +export class SimpleMemoryHost implements Host<{}> { + protected _cache = new Map>(); + private _watchers = new Map< + Path, + [HostWatchOptions, Subject][] + >(); + + protected _newDirStats() { + return { + inspect() { + return ""; + }, + + isFile() { + return false; + }, + isDirectory() { + return true; + }, + size: 0, + + atime: new Date(), + ctime: new Date(), + mtime: new Date(), + birthtime: new Date(), + + content: null, + }; + } + protected _newFileStats( + content: FileBuffer, + oldStats?: Stats + ) { + return { + inspect() { + return ``; + }, + + isFile() { + return true; + }, + isDirectory() { + return false; + }, + size: content.byteLength, + + atime: oldStats ? oldStats.atime : new Date(), + ctime: new Date(), + mtime: new Date(), + birthtime: oldStats ? oldStats.birthtime : new Date(), + + content, + }; + } + + constructor() { + this._cache.set(normalize("/"), this._newDirStats()); + } + + protected _toAbsolute(path: Path) { + return isAbsolute(path) ? path : normalize("/" + path); + } + + protected _updateWatchers(path: Path, type: HostWatchEventType) { + const time = new Date(); + let currentPath = path; + let parent: Path | null = null; + + if (this._watchers.size == 0) { + // Nothing to do if there's no watchers. + return; + } + + const maybeWatcher = this._watchers.get(currentPath); + if (maybeWatcher) { + maybeWatcher.forEach((watcher) => { + const [options, subject] = watcher; + subject.next({ path, time, type }); + + if (!options.persistent && type == HostWatchEventType.Deleted) { + subject.complete(); + this._watchers.delete(currentPath); + } + }); + } + + do { + currentPath = parent !== null ? parent : currentPath; + parent = dirname(currentPath); + + const maybeWatcher = this._watchers.get(currentPath); + if (maybeWatcher) { + maybeWatcher.forEach((watcher) => { + const [options, subject] = watcher; + if (!options.recursive) { + return; + } + subject.next({ path, time, type }); + + if (!options.persistent && type == HostWatchEventType.Deleted) { + subject.complete(); + this._watchers.delete(currentPath); + } + }); + } + } while (parent != currentPath); + } + + get capabilities(): HostCapabilities { + return { synchronous: true }; + } + + /** + * List of protected methods that give direct access outside the observables to the cache + * and internal states. + */ + protected _write(path: Path, content: FileBuffer): void { + path = this._toAbsolute(path); + const old = this._cache.get(path); + if (old && old.isDirectory()) { + throw new PathIsDirectoryException(path); + } + + // Update all directories. If we find a file we know it's an invalid write. + const fragments = split(path); + let curr: Path = normalize("/"); + for (const fr of fragments) { + curr = join(curr, fr); + const maybeStats = this._cache.get(fr); + if (maybeStats) { + if (maybeStats.isFile()) { + throw new PathIsFileException(curr); + } + } else { + this._cache.set(curr, this._newDirStats()); + } + } + + // Create the stats. + const stats: Stats = this._newFileStats( + content, + old + ); + this._cache.set(path, stats); + this._updateWatchers( + path, + old ? HostWatchEventType.Changed : HostWatchEventType.Created + ); + } + protected _read(path: Path): FileBuffer { + path = this._toAbsolute(path); + const maybeStats = this._cache.get(path); + if (!maybeStats) { + throw new FileDoesNotExistException(path); + } else if (maybeStats.isDirectory()) { + throw new PathIsDirectoryException(path); + } else if (!maybeStats.content) { + throw new PathIsDirectoryException(path); + } else { + return maybeStats.content; + } + } + protected _delete(path: Path): void { + path = this._toAbsolute(path); + if (this._isDirectory(path)) { + for (const [cachePath] of this._cache.entries()) { + if (cachePath.startsWith(path + NormalizedSep) || cachePath === path) { + this._cache.delete(cachePath); + } + } + } else { + this._cache.delete(path); + } + this._updateWatchers(path, HostWatchEventType.Deleted); + } + protected _rename(from: Path, to: Path): void { + from = this._toAbsolute(from); + to = this._toAbsolute(to); + if (!this._cache.has(from)) { + throw new FileDoesNotExistException(from); + } else if (this._cache.has(to)) { + throw new FileAlreadyExistException(to); + } + + if (this._isDirectory(from)) { + for (const path of this._cache.keys()) { + if (path.startsWith(from + NormalizedSep)) { + const content = this._cache.get(path); + if (content) { + // We don't need to clone or extract the content, since we're moving files. + this._cache.set( + join(to, NormalizedSep, path.slice(from.length)), + content + ); + } + } + } + } else { + const content = this._cache.get(from); + if (content) { + const fragments = split(to); + const newDirectories = []; + let curr: Path = normalize("/"); + for (const fr of fragments) { + curr = join(curr, fr); + const maybeStats = this._cache.get(fr); + if (maybeStats) { + if (maybeStats.isFile()) { + throw new PathIsFileException(curr); + } + } else { + newDirectories.push(curr); + } + } + for (const newDirectory of newDirectories) { + this._cache.set(newDirectory, this._newDirStats()); + } + this._cache.delete(from); + this._cache.set(to, content); + } + } + + this._updateWatchers(from, HostWatchEventType.Renamed); + } + + protected _list(path: Path): PathFragment[] { + path = this._toAbsolute(path); + if (this._isFile(path)) { + throw new PathIsFileException(path); + } + + const fragments = split(path); + const result = new Set(); + if (path !== NormalizedRoot) { + for (const p of this._cache.keys()) { + if (p.startsWith(path + NormalizedSep)) { + result.add(split(p)[fragments.length]); + } + } + } else { + for (const p of this._cache.keys()) { + if (p.startsWith(NormalizedSep) && p !== NormalizedRoot) { + result.add(split(p)[1]); + } + } + } + + return [...result]; + } + + protected _exists(path: Path): boolean { + return !!this._cache.get(this._toAbsolute(path)); + } + protected _isDirectory(path: Path): boolean { + const maybeStats = this._cache.get(this._toAbsolute(path)); + + return maybeStats ? maybeStats.isDirectory() : false; + } + protected _isFile(path: Path): boolean { + const maybeStats = this._cache.get(this._toAbsolute(path)); + + return maybeStats ? maybeStats.isFile() : false; + } + + protected _stat(path: Path): Stats | null { + const maybeStats = this._cache.get(this._toAbsolute(path)); + + if (!maybeStats) { + return null; + } else { + return maybeStats; + } + } + + protected _watch( + path: Path, + options?: HostWatchOptions + ): Observable { + path = this._toAbsolute(path); + + const subject = new Subject(); + let maybeWatcherArray = this._watchers.get(path); + if (!maybeWatcherArray) { + maybeWatcherArray = []; + this._watchers.set(path, maybeWatcherArray); + } + + maybeWatcherArray.push([options || {}, subject]); + + return subject.asObservable(); + } + + write(path: Path, content: FileBuffer): Observable { + return new Observable((obs) => { + this._write(path, content); + obs.next(); + obs.complete(); + }); + } + + read(path: Path): Observable { + return new Observable((obs) => { + const content = this._read(path); + obs.next(content); + obs.complete(); + }); + } + + delete(path: Path): Observable { + return new Observable((obs) => { + this._delete(path); + obs.next(); + obs.complete(); + }); + } + + rename(from: Path, to: Path): Observable { + return new Observable((obs) => { + this._rename(from, to); + obs.next(); + obs.complete(); + }); + } + + list(path: Path): Observable { + return new Observable((obs) => { + obs.next(this._list(path)); + obs.complete(); + }); + } + + exists(path: Path): Observable { + return new Observable((obs) => { + obs.next(this._exists(path)); + obs.complete(); + }); + } + + isDirectory(path: Path): Observable { + return new Observable((obs) => { + obs.next(this._isDirectory(path)); + obs.complete(); + }); + } + + isFile(path: Path): Observable { + return new Observable((obs) => { + obs.next(this._isFile(path)); + obs.complete(); + }); + } + + // Some hosts may not support stat. + stat(path: Path): Observable | null> | null { + return new Observable | null>((obs) => { + obs.next(this._stat(path)); + obs.complete(); + }); + } + + watch( + path: Path, + options?: HostWatchOptions + ): Observable | null { + return this._watch(path, options); + } + + reset(): void { + this._cache.clear(); + this._watchers.clear(); + } +} diff --git a/packages/schematic-utils/src/virtual-fs/host/memory_spec.ts b/packages/schematic-utils/src/virtual-fs/host/memory_spec.ts new file mode 100644 index 0000000..b3f8de9 --- /dev/null +++ b/packages/schematic-utils/src/virtual-fs/host/memory_spec.ts @@ -0,0 +1,158 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { fragment, normalize } from "../path"; +import { stringToFileBuffer } from "./buffer"; +import { SimpleMemoryHost } from "./memory"; +import { SyncDelegateHost } from "./sync"; + +describe("SimpleMemoryHost", () => { + it("can watch", () => { + const host = new SyncDelegateHost(new SimpleMemoryHost()); + + host.write(normalize("/sub/file1"), stringToFileBuffer("")); + + let recursiveCalled = 0; + let noRecursiveCalled = 0; + let noRecursiveFileCalled = 0; + let diffFile = 0; + + host + .watch(normalize("/sub"), { recursive: true })! + .subscribe(() => recursiveCalled++); + host.watch(normalize("/sub"))!.subscribe(() => noRecursiveCalled++); + host + .watch(normalize("/sub/file2"))! + .subscribe(() => noRecursiveFileCalled++); + host.watch(normalize("/sub/file3"))!.subscribe(() => diffFile++); + + host.write(normalize("/sub/file2"), stringToFileBuffer("")); + + expect(recursiveCalled).toBe(1); + expect(noRecursiveCalled).toBe(0); + expect(noRecursiveFileCalled).toBe(1); + expect(diffFile).toBe(0); + + host.write(normalize("/sub/file3"), stringToFileBuffer("")); + + expect(recursiveCalled).toBe(2); + expect(noRecursiveCalled).toBe(0); + expect(noRecursiveFileCalled).toBe(1); + expect(diffFile).toBe(1); + }); + + it("can read", () => { + const host = new SyncDelegateHost(new SimpleMemoryHost()); + + const buffer = stringToFileBuffer("hello"); + + host.write(normalize("/hello"), buffer); + expect(host.read(normalize("/hello"))).toBe(buffer); + }); + + it("can delete", () => { + const host = new SyncDelegateHost(new SimpleMemoryHost()); + + const buffer = stringToFileBuffer("hello"); + + expect(host.exists(normalize("/sub/file1"))).toBe(false); + host.write(normalize("/sub/file1"), buffer); + expect(host.exists(normalize("/sub/file1"))).toBe(true); + host.delete(normalize("/sub/file1")); + expect(host.exists(normalize("/sub/file1"))).toBe(false); + }); + + it("can delete directory", () => { + const host = new SyncDelegateHost(new SimpleMemoryHost()); + + const buffer = stringToFileBuffer("hello"); + + expect(host.exists(normalize("/sub/file1"))).toBe(false); + host.write(normalize("/sub/file1"), buffer); + host.write(normalize("/subfile.2"), buffer); + expect(host.exists(normalize("/sub/file1"))).toBe(true); + expect(host.exists(normalize("/subfile.2"))).toBe(true); + host.delete(normalize("/sub")); + expect(host.exists(normalize("/sub/file1"))).toBe(false); + expect(host.exists(normalize("/sub"))).toBe(false); + expect(host.exists(normalize("/subfile.2"))).toBe(true); + }); + + it("can rename", () => { + const host = new SyncDelegateHost(new SimpleMemoryHost()); + + const buffer = stringToFileBuffer("hello"); + + expect(host.exists(normalize("/sub/file1"))).toBe(false); + host.write(normalize("/sub/file1"), buffer); + expect(host.exists(normalize("/sub/file1"))).toBe(true); + host.rename(normalize("/sub/file1"), normalize("/sub/file2")); + expect(host.exists(normalize("/sub/file1"))).toBe(false); + expect(host.exists(normalize("/sub/file2"))).toBe(true); + expect(host.read(normalize("/sub/file2"))).toBe(buffer); + }); + + it("can list", () => { + const host = new SyncDelegateHost(new SimpleMemoryHost()); + + const buffer = stringToFileBuffer("hello"); + + host.write(normalize("/sub/file1"), buffer); + host.write(normalize("/sub/file2"), buffer); + host.write(normalize("/sub/sub1/file3"), buffer); + host.write(normalize("/file4"), buffer); + + expect(host.list(normalize("/sub"))).toEqual([ + fragment("file1"), + fragment("file2"), + fragment("sub1"), + ]); + expect(host.list(normalize("/"))).toEqual([ + fragment("sub"), + fragment("file4"), + ]); + expect(host.list(normalize("/inexistent"))).toEqual([]); + }); + + it("supports isFile / isDirectory", () => { + const host = new SyncDelegateHost(new SimpleMemoryHost()); + + const buffer = stringToFileBuffer("hello"); + + host.write(normalize("/sub/file1"), buffer); + host.write(normalize("/sub/file2"), buffer); + host.write(normalize("/sub/sub1/file3"), buffer); + host.write(normalize("/file4"), buffer); + + expect(host.isFile(normalize("/sub"))).toBe(false); + expect(host.isFile(normalize("/sub1"))).toBe(false); + expect(host.isDirectory(normalize("/"))).toBe(true); + expect(host.isDirectory(normalize("/sub"))).toBe(true); + expect(host.isDirectory(normalize("/sub/sub1"))).toBe(true); + expect(host.isDirectory(normalize("/sub/file1"))).toBe(false); + expect(host.isDirectory(normalize("/sub/sub1/file3"))).toBe(false); + }); + + it("makes every path absolute", () => { + const host = new SyncDelegateHost(new SimpleMemoryHost()); + + const buffer = stringToFileBuffer("hello"); + const buffer2 = stringToFileBuffer("hello 2"); + + host.write(normalize("file1"), buffer); + host.write(normalize("/sub/file2"), buffer); + host.write(normalize("sub/file2"), buffer2); + expect(host.isFile(normalize("file1"))).toBe(true); + expect(host.isFile(normalize("/file1"))).toBe(true); + expect(host.isFile(normalize("/sub/file2"))).toBe(true); + expect(host.read(normalize("sub/file2"))).toBe(buffer2); + expect(host.isDirectory(normalize("/sub"))).toBe(true); + expect(host.isDirectory(normalize("sub"))).toBe(true); + }); +}); diff --git a/packages/schematic-utils/src/virtual-fs/host/pattern.ts b/packages/schematic-utils/src/virtual-fs/host/pattern.ts new file mode 100644 index 0000000..883525d --- /dev/null +++ b/packages/schematic-utils/src/virtual-fs/host/pattern.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { Path } from "../path"; +import { ResolverHost } from "./resolver"; + +export type ReplacementFunction = (path: Path) => Path; + +/** + */ +export class PatternMatchingHost< + StatsT extends object = {} +> extends ResolverHost { + protected _patterns = new Map(); + + addPattern(pattern: string | string[], replacementFn: ReplacementFunction) { + // Simple GLOB pattern replacement. + const reString = + "^(" + + (Array.isArray(pattern) ? pattern : [pattern]) + .map( + (ex) => + "(" + + ex + .split(/[\/\\]/g) + .map((f) => + f + .replace(/[\-\[\]{}()+?.^$|]/g, "\\$&") + .replace(/^\*\*/g, "(.+?)?") + .replace(/\*/g, "[^/\\\\]*") + ) + .join("[/\\\\]") + + ")" + ) + .join("|") + + ")($|/|\\\\)"; + + this._patterns.set(new RegExp(reString), replacementFn); + } + + protected _resolve(path: Path) { + let newPath = path; + this._patterns.forEach((fn, re) => { + if (re.test(path)) { + newPath = fn(newPath); + } + }); + + return newPath; + } +} diff --git a/packages/schematic-utils/src/virtual-fs/host/pattern_spec.ts b/packages/schematic-utils/src/virtual-fs/host/pattern_spec.ts new file mode 100644 index 0000000..35591f3 --- /dev/null +++ b/packages/schematic-utils/src/virtual-fs/host/pattern_spec.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { normalize } from "../path"; +import { stringToFileBuffer } from "./buffer"; +import { SimpleMemoryHost } from "./memory"; +import { PatternMatchingHost } from "./pattern"; + +describe("PatternMatchingHost", () => { + it("works for NativeScript", () => { + const content = stringToFileBuffer("hello world"); + const content2 = stringToFileBuffer("hello world 2"); + + const host = new SimpleMemoryHost(); + host.write(normalize("/some/file.tns.ts"), content).subscribe(); + + const pHost = new PatternMatchingHost(host); + pHost + .read(normalize("/some/file.tns.ts")) + .subscribe((x) => expect(x).toBe(content)); + + pHost.addPattern("**/*.tns.ts", (path) => { + return normalize(path.replace(/\.tns\.ts$/, ".ts")); + }); + + // This file will not exist because /some/file.ts does not exist. + try { + pHost.read(normalize("/some/file.tns.ts")).subscribe(undefined, (err) => { + expect(err.message).toMatch(/does not exist/); + }); + } catch { + // Ignore it. RxJS <6 still throw errors when they happen synchronously. + } + + // Create the file, it should exist now. + pHost.write(normalize("/some/file.ts"), content2).subscribe(); + pHost + .read(normalize("/some/file.tns.ts")) + .subscribe((x) => expect(x).toBe(content2)); + }); +}); diff --git a/packages/schematic-utils/src/virtual-fs/host/record.ts b/packages/schematic-utils/src/virtual-fs/host/record.ts new file mode 100644 index 0000000..ab607b1 --- /dev/null +++ b/packages/schematic-utils/src/virtual-fs/host/record.ts @@ -0,0 +1,427 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + EMPTY, + Observable, + concat, + from as observableFrom, + of, + throwError, +} from "rxjs"; +import { concatMap, map, reduce, switchMap, toArray } from "rxjs/operators"; +import { + FileAlreadyExistException, + FileDoesNotExistException, + PathIsDirectoryException, + UnknownException, +} from "../../exceptions/exception"; +import { Path, PathFragment } from "../path"; +import { + FileBuffer, + Host, + HostCapabilities, + HostWatchOptions, + ReadonlyHost, + Stats, +} from "./interface"; +import { SimpleMemoryHost } from "./memory"; + +export interface CordHostCreate { + kind: "create"; + path: Path; + content: FileBuffer; +} +export interface CordHostOverwrite { + kind: "overwrite"; + path: Path; + content: FileBuffer; +} +export interface CordHostRename { + kind: "rename"; + from: Path; + to: Path; +} +export interface CordHostDelete { + kind: "delete"; + path: Path; +} +export type CordHostRecord = + | CordHostCreate + | CordHostOverwrite + | CordHostRename + | CordHostDelete; + +/** + * A Host that records changes to the underlying Host, while keeping a record of Create, Overwrite, + * Rename and Delete of files. + * + * This is fully compatible with Host, but will keep a staging of every changes asked. That staging + * follows the principle of the Tree (e.g. can create a file that already exists). + * + * Using `create()` and `overwrite()` will force those operations, but using `write` will add + * the create/overwrite records IIF the files does/doesn't already exist. + */ +export class CordHost extends SimpleMemoryHost { + protected _filesToCreate = new Set(); + protected _filesToRename = new Map(); + protected _filesToRenameRevert = new Map(); + protected _filesToDelete = new Set(); + protected _filesToOverwrite = new Set(); + + constructor(protected _back: ReadonlyHost) { + super(); + } + + get backend(): ReadonlyHost { + return this._back; + } + get capabilities(): HostCapabilities { + // Our own host is always Synchronous, but the backend might not be. + return { + synchronous: this._back.capabilities.synchronous, + }; + } + + /** + * Create a copy of this host, including all actions made. + * @returns {CordHost} The carbon copy. + */ + clone(): CordHost { + const dolly = new CordHost(this._back); + + dolly._cache = new Map(this._cache); + dolly._filesToCreate = new Set(this._filesToCreate); + dolly._filesToRename = new Map(this._filesToRename); + dolly._filesToRenameRevert = new Map(this._filesToRenameRevert); + dolly._filesToDelete = new Set(this._filesToDelete); + dolly._filesToOverwrite = new Set(this._filesToOverwrite); + + return dolly; + } + + /** + * Commit the changes recorded to a Host. It is assumed that the host does have the same structure + * as the host that was used for backend (could be the same host). + * @param host The host to create/delete/rename/overwrite files to. + * @param force Whether to skip existence checks when creating/overwriting. This is + * faster but might lead to incorrect states. Because Hosts natively don't support creation + * versus overwriting (it's only writing), we check for existence before completing a request. + * @returns An observable that completes when done, or error if an error occured. + */ + commit(host: Host, force = false): Observable { + // Really commit everything to the actual host. + return observableFrom(this.records()).pipe( + concatMap((record) => { + switch (record.kind) { + case "delete": + return host.delete(record.path); + case "rename": + return host.rename(record.from, record.to); + case "create": + return host.exists(record.path).pipe( + switchMap((exists) => { + if (exists && !force) { + return throwError(new FileAlreadyExistException(record.path)); + } else { + return host.write(record.path, record.content); + } + }) + ); + case "overwrite": + return host.exists(record.path).pipe( + switchMap((exists) => { + if (!exists && !force) { + return throwError(new FileDoesNotExistException(record.path)); + } else { + return host.write(record.path, record.content); + } + }) + ); + } + }), + reduce(() => {}) + ); + } + + records(): CordHostRecord[] { + return [ + ...[...this._filesToDelete.values()].map( + (path) => + ({ + kind: "delete", + path, + } as CordHostRecord) + ), + ...[...this._filesToRename.entries()].map( + ([from, to]) => + ({ + kind: "rename", + from, + to, + } as CordHostRecord) + ), + ...[...this._filesToCreate.values()].map( + (path) => + ({ + kind: "create", + path, + content: this._read(path), + } as CordHostRecord) + ), + ...[...this._filesToOverwrite.values()].map( + (path) => + ({ + kind: "overwrite", + path, + content: this._read(path), + } as CordHostRecord) + ), + ]; + } + + /** + * Specialized version of {@link CordHost#write} which forces the creation of a file whether it + * exists or not. + * @param {} path + * @param {FileBuffer} content + * @returns {Observable} + */ + create(path: Path, content: FileBuffer): Observable { + if (super._exists(path)) { + throw new FileAlreadyExistException(path); + } + + if (this._filesToDelete.has(path)) { + this._filesToDelete.delete(path); + this._filesToOverwrite.add(path); + } else { + this._filesToCreate.add(path); + } + + return super.write(path, content); + } + + overwrite(path: Path, content: FileBuffer): Observable { + return this.isDirectory(path).pipe( + switchMap((isDir) => { + if (isDir) { + return throwError(new PathIsDirectoryException(path)); + } + + return this.exists(path); + }), + switchMap((exists) => { + if (!exists) { + return throwError(new FileDoesNotExistException(path)); + } + + if (!this._filesToCreate.has(path)) { + this._filesToOverwrite.add(path); + } + + return super.write(path, content); + }) + ); + } + + write(path: Path, content: FileBuffer): Observable { + return this.exists(path).pipe( + switchMap((exists) => { + if (exists) { + // It exists, but might be being renamed or deleted. In that case we want to create it. + if (this.willRename(path) || this.willDelete(path)) { + return this.create(path, content); + } else { + return this.overwrite(path, content); + } + } else { + return this.create(path, content); + } + }) + ); + } + + read(path: Path): Observable { + if (this._exists(path)) { + return super.read(path); + } + + return this._back.read(path); + } + + delete(path: Path): Observable { + if (this._exists(path)) { + if (this._filesToCreate.has(path)) { + this._filesToCreate.delete(path); + } else if (this._filesToOverwrite.has(path)) { + this._filesToOverwrite.delete(path); + this._filesToDelete.add(path); + } else { + const maybeOrigin = this._filesToRenameRevert.get(path); + if (maybeOrigin) { + this._filesToRenameRevert.delete(path); + this._filesToRename.delete(maybeOrigin); + this._filesToDelete.add(maybeOrigin); + } else { + return throwError( + new UnknownException( + `This should never happen. Path: ${JSON.stringify(path)}.` + ) + ); + } + } + + return super.delete(path); + } else { + return this._back.exists(path).pipe( + switchMap((exists) => { + if (exists) { + this._filesToDelete.add(path); + + return of(); + } else { + return throwError(new FileDoesNotExistException(path)); + } + }) + ); + } + } + + rename(from: Path, to: Path): Observable { + return concat(this.exists(to), this.exists(from)).pipe( + toArray(), + switchMap(([existTo, existFrom]) => { + if (!existFrom) { + return throwError(new FileDoesNotExistException(from)); + } + if (from === to) { + return EMPTY; + } + + if (existTo) { + return throwError(new FileAlreadyExistException(to)); + } + + // If we're renaming a file that's been created, shortcircuit to creating the `to` path. + if (this._filesToCreate.has(from)) { + this._filesToCreate.delete(from); + this._filesToCreate.add(to); + + return super.rename(from, to); + } + if (this._filesToOverwrite.has(from)) { + this._filesToOverwrite.delete(from); + + // Recursively call this function. This is so we don't repeat the bottom logic. This + // if will be by-passed because we just deleted the `from` path from files to overwrite. + return concat( + this.rename(from, to), + new Observable((x) => { + this._filesToOverwrite.add(to); + x.complete(); + }) + ); + } + if (this._filesToDelete.has(to)) { + this._filesToDelete.delete(to); + this._filesToDelete.add(from); + this._filesToOverwrite.add(to); + + // We need to delete the original and write the new one. + return this.read(from).pipe( + map((content) => this._write(to, content)) + ); + } + + const maybeTo1 = this._filesToRenameRevert.get(from); + if (maybeTo1) { + // We already renamed to this file (A => from), let's rename the former to the new + // path (A => to). + this._filesToRename.delete(maybeTo1); + this._filesToRenameRevert.delete(from); + from = maybeTo1; + } + + this._filesToRename.set(from, to); + this._filesToRenameRevert.set(to, from); + + // If the file is part of our data, just rename it internally. + if (this._exists(from)) { + return super.rename(from, to); + } else { + // Create a file with the same content. + return this._back + .read(from) + .pipe(switchMap((content) => super.write(to, content))); + } + }) + ); + } + + list(path: Path): Observable { + return concat(super.list(path), this._back.list(path)).pipe( + reduce((list: Set, curr: PathFragment[]) => { + curr.forEach((elem) => list.add(elem)); + + return list; + }, new Set()), + map((set) => [...set]) + ); + } + + exists(path: Path): Observable { + return this._exists(path) + ? of(true) + : this.willDelete(path) || this.willRename(path) + ? of(false) + : this._back.exists(path); + } + isDirectory(path: Path): Observable { + return this._exists(path) + ? super.isDirectory(path) + : this._back.isDirectory(path); + } + isFile(path: Path): Observable { + return this._exists(path) + ? super.isFile(path) + : this.willDelete(path) || this.willRename(path) + ? of(false) + : this._back.isFile(path); + } + + stat(path: Path): Observable | null { + return this._exists(path) + ? super.stat(path) + : this.willDelete(path) || this.willRename(path) + ? of(null) + : this._back.stat(path); + } + + // @ts-ignore + watch(path: Path, options?: HostWatchOptions) { + // Watching not supported. + return null; + } + + willCreate(path: Path) { + return this._filesToCreate.has(path); + } + willOverwrite(path: Path) { + return this._filesToOverwrite.has(path); + } + willDelete(path: Path) { + return this._filesToDelete.has(path); + } + willRename(path: Path) { + return this._filesToRename.has(path); + } + willRenameTo(path: Path, to: Path) { + return this._filesToRename.get(path) === to; + } +} diff --git a/packages/schematic-utils/src/virtual-fs/host/record_spec.ts b/packages/schematic-utils/src/virtual-fs/host/record_spec.ts new file mode 100644 index 0000000..655b85d --- /dev/null +++ b/packages/schematic-utils/src/virtual-fs/host/record_spec.ts @@ -0,0 +1,584 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { path } from "../path"; +import { fileBuffer } from "./buffer"; +import { CordHost } from "./record"; +import { test } from "./test"; + +describe("CordHost", () => { + const TestHost = test.TestHost; + const mutatingTestRecord = ["write", "delete", "rename"]; + + it("works (create)", (done) => { + const base = new TestHost({ + "/hello": "world", + }); + + const host = new CordHost(base); + host.write(path`/blue`, fileBuffer`hi`).subscribe(undefined, done.fail); + + const target = new TestHost(); + host.commit(target).subscribe(undefined, done.fail); + + expect( + target.records.filter((x) => mutatingTestRecord.includes(x.kind)) + ).toEqual([{ kind: "write", path: path`/blue` }]); + + expect(target.$exists("/hello")).toBe(false); + expect(target.$exists("/blue")).toBe(true); + done(); + }); + + it("works (create -> create)", (done) => { + const base = new TestHost({ + "/hello": "world", + }); + + const host = new CordHost(base); + host.write(path`/blue`, fileBuffer`hi`).subscribe(undefined, done.fail); + host + .write(path`/blue`, fileBuffer`hi again`) + .subscribe(undefined, done.fail); + + const target = new TestHost(); + host.commit(target).subscribe(undefined, done.fail); + + expect( + target.records.filter((x) => mutatingTestRecord.includes(x.kind)) + ).toEqual([{ kind: "write", path: path`/blue` }]); + + expect(target.$exists("/hello")).toBe(false); + expect(target.$exists("/blue")).toBe(true); + expect(target.$read("/blue")).toBe("hi again"); + done(); + }); + + it("works (create -> delete)", (done) => { + const base = new TestHost({ + "/hello": "world", + }); + + const host = new CordHost(base); + host.write(path`/blue`, fileBuffer`hi`).subscribe(undefined, done.fail); + host.delete(path`/blue`).subscribe(undefined, done.fail); + + const target = new TestHost(); + host.commit(target).subscribe(undefined, done.fail); + + expect( + target.records.filter((x) => mutatingTestRecord.includes(x.kind)) + ).toEqual([]); + + expect(target.$exists("/hello")).toBe(false); + expect(target.$exists("/blue")).toBe(false); + done(); + }); + + it("works (create -> rename)", (done) => { + const base = new TestHost({ + "/hello": "world", + }); + + const host = new CordHost(base); + host.write(path`/blue`, fileBuffer`hi`).subscribe(undefined, done.fail); + host.rename(path`/blue`, path`/red`).subscribe(undefined, done.fail); + + const target = new TestHost(); + host.commit(target).subscribe(undefined, done.fail); + + // Check that there's only 1 write done. + expect( + target.records.filter((x) => mutatingTestRecord.includes(x.kind)) + ).toEqual([{ kind: "write", path: path`/red` }]); + + expect(target.$exists("/hello")).toBe(false); + expect(target.$exists("/blue")).toBe(false); + expect(target.$exists("/red")).toBe(true); + + done(); + }); + + it("works (create -> rename (identity))", (done) => { + const base = new TestHost({ + "/hello": "world", + }); + + const host = new CordHost(base); + host.write(path`/blue`, fileBuffer`hi`).subscribe(undefined, done.fail); + host.rename(path`/blue`, path`/blue`).subscribe(undefined, done.fail); + + const target = new TestHost(); + host.commit(target).subscribe(undefined, done.fail); + + // Check that there's only 1 write done. + expect( + target.records.filter((x) => mutatingTestRecord.includes(x.kind)) + ).toEqual([{ kind: "write", path: path`/blue` }]); + + expect(target.$exists("/hello")).toBe(false); + expect(target.$exists("/blue")).toBe(true); + + done(); + }); + + it("works (create -> rename -> rename)", (done) => { + const base = new TestHost({ + "/hello": "world", + }); + + const host = new CordHost(base); + host.write(path`/blue`, fileBuffer`hi`).subscribe(undefined, done.fail); + host.rename(path`/blue`, path`/red`).subscribe(undefined, done.fail); + host.rename(path`/red`, path`/yellow`).subscribe(undefined, done.fail); + + const target = new TestHost(); + host.commit(target).subscribe(undefined, done.fail); + + // Check that there's only 1 write done. + expect( + target.records.filter((x) => mutatingTestRecord.includes(x.kind)) + ).toEqual([{ kind: "write", path: path`/yellow` }]); + + expect(target.$exists("/hello")).toBe(false); + expect(target.$exists("/blue")).toBe(false); + expect(target.$exists("/red")).toBe(false); + expect(target.$exists("/yellow")).toBe(true); + + done(); + }); + + it("works (rename)", (done) => { + const base = new TestHost({ + "/hello": "world", + }); + + const host = new CordHost(base); + host.rename(path`/hello`, path`/blue`).subscribe(undefined, done.fail); + + const target = base.clone(); + host.commit(target).subscribe(undefined, done.fail); + + // Check that there's only 1 write done. + expect( + target.records.filter((x) => mutatingTestRecord.includes(x.kind)) + ).toEqual([{ kind: "rename", from: path`/hello`, to: path`/blue` }]); + + expect(target.$exists("/hello")).toBe(false); + expect(target.$exists("/blue")).toBe(true); + + done(); + }); + + it("works (rename -> rename)", (done) => { + const base = new TestHost({ + "/hello": "world", + }); + + const host = new CordHost(base); + host.rename(path`/hello`, path`/blue`).subscribe(undefined, done.fail); + host.rename(path`/blue`, path`/red`).subscribe(undefined, done.fail); + + const target = base.clone(); + host.commit(target).subscribe(undefined, done.fail); + + // Check that there's only 1 write done. + expect( + target.records.filter((x) => mutatingTestRecord.includes(x.kind)) + ).toEqual([{ kind: "rename", from: path`/hello`, to: path`/red` }]); + + expect(target.$exists("/hello")).toBe(false); + expect(target.$exists("/blue")).toBe(false); + expect(target.$exists("/red")).toBe(true); + + done(); + }); + + it("works (rename -> create)", (done) => { + const base = new TestHost({ + "/hello": "world", + }); + + const host = new CordHost(base); + host.rename(path`/hello`, path`/blue`).subscribe(undefined, done.fail); + host + .write(path`/hello`, fileBuffer`beautiful world`) + .subscribe(undefined, done.fail); + + const target = base.clone(); + host.commit(target).subscribe(undefined, done.fail); + + // Check that there's only 1 write done. + expect( + target.records.filter((x) => mutatingTestRecord.includes(x.kind)) + ).toEqual([ + { kind: "rename", from: path`/hello`, to: path`/blue` }, + { kind: "write", path: path`/hello` }, + ]); + + expect(target.$exists("/hello")).toBe(true); + expect(target.$exists("/blue")).toBe(true); + + done(); + }); + + it("works (overwrite)", (done) => { + const base = new TestHost({ + "/hello": "world", + }); + + const host = new CordHost(base); + host + .write(path`/hello`, fileBuffer`beautiful world`) + .subscribe(undefined, done.fail); + + const target = base.clone(); + host.commit(target).subscribe(undefined, done.fail); + + // Check that there's only 1 write done. + expect( + target.records.filter((x) => mutatingTestRecord.includes(x.kind)) + ).toEqual([{ kind: "write", path: path`/hello` }]); + + expect(target.$exists("/hello")).toBe(true); + expect(target.$read("/hello")).toBe("beautiful world"); + + done(); + }); + + it("works (overwrite -> overwrite)", (done) => { + const base = new TestHost({ + "/hello": "world", + }); + + const host = new CordHost(base); + host + .write(path`/hello`, fileBuffer`beautiful world`) + .subscribe(undefined, done.fail); + host.write(path`/hello`, fileBuffer`again`).subscribe(undefined, done.fail); + + const target = base.clone(); + host.commit(target).subscribe(undefined, done.fail); + + // Check that there's only 1 write done. + expect( + target.records.filter((x) => mutatingTestRecord.includes(x.kind)) + ).toEqual([{ kind: "write", path: path`/hello` }]); + + expect(target.$exists("/hello")).toBe(true); + expect(target.$read("/hello")).toBe("again"); + + done(); + }); + + it("works (overwrite -> rename)", (done) => { + const base = new TestHost({ + "/hello": "world", + }); + + const host = new CordHost(base); + host + .write(path`/hello`, fileBuffer`beautiful world`) + .subscribe(undefined, done.fail); + host.rename(path`/hello`, path`/blue`).subscribe(undefined, done.fail); + + const target = base.clone(); + host.commit(target).subscribe(undefined, done.fail); + + expect( + target.records.filter((x) => mutatingTestRecord.includes(x.kind)) + ).toEqual([ + { kind: "rename", from: path`/hello`, to: path`/blue` }, + { kind: "write", path: path`/blue` }, + ]); + + expect(target.$exists("/hello")).toBe(false); + expect(target.$exists("/blue")).toBe(true); + expect(target.$read("/blue")).toBe("beautiful world"); + + done(); + }); + + it("works (overwrite -> delete)", (done) => { + const base = new TestHost({ + "/hello": "world", + }); + + const host = new CordHost(base); + host + .write(path`/hello`, fileBuffer`beautiful world`) + .subscribe(undefined, done.fail); + host.delete(path`/hello`).subscribe(undefined, done.fail); + + const target = base.clone(); + host.commit(target).subscribe(undefined, done.fail); + + expect( + target.records.filter((x) => mutatingTestRecord.includes(x.kind)) + ).toEqual([{ kind: "delete", path: path`/hello` }]); + + expect(target.$exists("/hello")).toBe(false); + done(); + }); + + it("works (rename -> overwrite)", (done) => { + const base = new TestHost({ + "/hello": "world", + }); + + const host = new CordHost(base); + host.rename(path`/hello`, path`/blue`).subscribe(undefined, done.fail); + host + .write(path`/blue`, fileBuffer`beautiful world`) + .subscribe(undefined, done.fail); + + const target = base.clone(); + host.commit(target).subscribe(undefined, done.fail); + + expect( + target.records.filter((x) => mutatingTestRecord.includes(x.kind)) + ).toEqual([ + { kind: "rename", from: path`/hello`, to: path`/blue` }, + { kind: "write", path: path`/blue` }, + ]); + + expect(target.$exists("/hello")).toBe(false); + expect(target.$exists("/blue")).toBe(true); + expect(target.$read("/blue")).toBe("beautiful world"); + + done(); + }); + + it("works (delete)", (done) => { + const base = new TestHost({ + "/hello": "world", + }); + + const host = new CordHost(base); + host.delete(path`/hello`).subscribe(undefined, done.fail); + + const target = new TestHost(); + host.commit(target).subscribe(undefined, done.fail); + + expect( + target.records.filter((x) => mutatingTestRecord.includes(x.kind)) + ).toEqual([{ kind: "delete", path: path`/hello` }]); + + expect(target.$exists("/hello")).toBe(false); + done(); + }); + + it("works (delete -> create)", (done) => { + const base = new TestHost({ + "/hello": "world", + }); + + const host = new CordHost(base); + host.delete(path`/hello`).subscribe(undefined, done.fail); + host + .write(path`/hello`, fileBuffer`beautiful world`) + .subscribe(undefined, done.fail); + + const target = base.clone(); + host.commit(target).subscribe(undefined, done.fail); + + expect( + target.records.filter((x) => mutatingTestRecord.includes(x.kind)) + ).toEqual([{ kind: "write", path: path`/hello` }]); + + expect(target.$exists("/hello")).toBe(true); + expect(target.$read("/hello")).toBe("beautiful world"); + done(); + }); + + it("works (rename -> delete)", (done) => { + const base = new TestHost({ + "/hello": "world", + }); + + const host = new CordHost(base); + host.rename(path`/hello`, path`/blue`).subscribe(undefined, done.fail); + host.delete(path`/blue`).subscribe(undefined, done.fail); + + const target = base.clone(); + host.commit(target).subscribe(undefined, done.fail); + + expect( + target.records.filter((x) => mutatingTestRecord.includes(x.kind)) + ).toEqual([{ kind: "delete", path: path`/hello` }]); + + expect(target.$exists("/hello")).toBe(false); + done(); + }); + + it("works (delete -> rename)", (done) => { + const base = new TestHost({ + "/hello": "world", + "/blue": "foo", + }); + + const host = new CordHost(base); + host.delete(path`/blue`).subscribe(undefined, done.fail); + host.rename(path`/hello`, path`/blue`).subscribe(undefined, done.fail); + + const target = base.clone(); + host.commit(target).subscribe(undefined, done.fail); + expect( + target.records.filter((x) => mutatingTestRecord.includes(x.kind)) + ).toEqual([ + { kind: "delete", path: path`/hello` }, + { kind: "write", path: path`/blue` }, + ]); + + expect(target.$exists("/hello")).toBe(false); + expect(target.$exists("/blue")).toBe(true); + done(); + }); + + it("errors: commit (create: exists)", () => { + const base = new TestHost({ + "/hello": "world", + }); + + const host = new CordHost(base); + host.write(path`/blue`, fileBuffer`hi`).subscribe(); + + const target = new TestHost({ + "/blue": "test", + }); + + let error = false; + host.commit(target).subscribe( + undefined, + () => (error = true), + () => (error = false) + ); + expect(error).toBe(true); + }); + + it("errors: commit (overwrite: not exist)", () => { + const base = new TestHost({ + "/hello": "world", + }); + + const host = new CordHost(base); + host.write(path`/hello`, fileBuffer`hi`).subscribe(); + + const target = new TestHost({}); + + let error = false; + host.commit(target).subscribe( + undefined, + () => (error = true), + () => (error = false) + ); + expect(error).toBe(true); + }); + + it("errors: commit (rename: not exist)", () => { + const base = new TestHost({ + "/hello": "world", + }); + + const host = new CordHost(base); + host.rename(path`/hello`, path`/blue`).subscribe(); + + const target = new TestHost({}); + + let error = false; + host.commit(target).subscribe( + undefined, + () => (error = true), + () => (error = false) + ); + expect(error).toBe(true); + }); + + it("errors: commit (rename: exist)", () => { + const base = new TestHost({ + "/hello": "world", + }); + + const host = new CordHost(base); + host.rename(path`/hello`, path`/blue`).subscribe(); + + const target = new TestHost({ + "/blue": "foo", + }); + + let error = false; + host.commit(target).subscribe( + undefined, + () => (error = true), + () => (error = false) + ); + expect(error).toBe(true); + }); + + it("errors (write directory)", () => { + const base = new TestHost({ + "/dir/hello": "world", + }); + + const host = new CordHost(base); + let error = false; + host.write(path`/dir`, fileBuffer`beautiful world`).subscribe( + undefined, + () => (error = true), + () => (error = false) + ); + + expect(error).toBe(true); + }); + + it("errors (delete: not exist)", () => { + const base = new TestHost({}); + + const host = new CordHost(base); + let error = false; + host.delete(path`/hello`).subscribe( + undefined, + () => (error = true), + () => (error = false) + ); + + expect(error).toBe(true); + }); + + it("errors (rename: exist)", () => { + const base = new TestHost({ + "/hello": "world", + "/blue": "foo", + }); + + const host = new CordHost(base); + let error = false; + host.rename(path`/hello`, path`/blue`).subscribe( + undefined, + () => (error = true), + () => (error = false) + ); + + expect(error).toBe(true); + }); + + it("errors (rename: not exist)", () => { + const base = new TestHost({}); + + const host = new CordHost(base); + let error = false; + host.rename(path`/hello`, path`/blue`).subscribe( + undefined, + () => (error = true), + () => (error = false) + ); + + expect(error).toBe(true); + }); +}); diff --git a/packages/schematic-utils/src/virtual-fs/host/resolver.ts b/packages/schematic-utils/src/virtual-fs/host/resolver.ts new file mode 100644 index 0000000..79ab618 --- /dev/null +++ b/packages/schematic-utils/src/virtual-fs/host/resolver.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { Observable } from "rxjs"; +import { Path, PathFragment } from "../path"; +import { + FileBuffer, + Host, + HostCapabilities, + HostWatchEvent, + HostWatchOptions, + Stats, +} from "./interface"; + +/** + * A Host that runs a method before calling its delegate. This is an abstract class and its actual + * behaviour is entirely dependant of the subclass. + */ +export abstract class ResolverHost implements Host { + protected abstract _resolve(path: Path): Path; + + constructor(protected _delegate: Host) {} + + get capabilities(): HostCapabilities { + return this._delegate.capabilities; + } + + write(path: Path, content: FileBuffer): Observable { + return this._delegate.write(this._resolve(path), content); + } + read(path: Path): Observable { + return this._delegate.read(this._resolve(path)); + } + delete(path: Path): Observable { + return this._delegate.delete(this._resolve(path)); + } + rename(from: Path, to: Path): Observable { + return this._delegate.rename(this._resolve(from), this._resolve(to)); + } + + list(path: Path): Observable { + return this._delegate.list(this._resolve(path)); + } + + exists(path: Path): Observable { + return this._delegate.exists(this._resolve(path)); + } + isDirectory(path: Path): Observable { + return this._delegate.isDirectory(this._resolve(path)); + } + isFile(path: Path): Observable { + return this._delegate.isFile(this._resolve(path)); + } + + // Some hosts may not support stat. + stat(path: Path): Observable | null> | null { + return this._delegate.stat(this._resolve(path)); + } + + // Some hosts may not support watching. + watch( + path: Path, + options?: HostWatchOptions + ): Observable | null { + return this._delegate.watch(this._resolve(path), options); + } +} diff --git a/packages/schematic-utils/src/virtual-fs/host/safe.ts b/packages/schematic-utils/src/virtual-fs/host/safe.ts new file mode 100644 index 0000000..02327f9 --- /dev/null +++ b/packages/schematic-utils/src/virtual-fs/host/safe.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { Observable, of } from "rxjs"; +import { catchError } from "rxjs/operators"; +import { Path, PathFragment } from "../path"; +import { FileBuffer, HostCapabilities, ReadonlyHost, Stats } from "./interface"; + +/** + * A Host that filters out errors. The only exception is `read()` which will still error out if + * the delegate returned an error (e.g. NodeJS will error out if the file doesn't exist). + */ +export class SafeReadonlyHost + implements ReadonlyHost { + constructor(private _delegate: ReadonlyHost) {} + + get capabilities(): HostCapabilities { + return this._delegate.capabilities; + } + + read(path: Path): Observable { + return this._delegate.read(path); + } + + list(path: Path): Observable { + return this._delegate.list(path).pipe(catchError(() => of([]))); + } + + exists(path: Path): Observable { + return this._delegate.exists(path); + } + isDirectory(path: Path): Observable { + return this._delegate.isDirectory(path).pipe(catchError(() => of(false))); + } + isFile(path: Path): Observable { + return this._delegate.isFile(path).pipe(catchError(() => of(false))); + } + + // Some hosts may not support stats. + stat(path: Path): Observable | null> | null { + const maybeStat = this._delegate.stat(path); + + return maybeStat && maybeStat.pipe(catchError(() => of(null))); + } +} diff --git a/packages/schematic-utils/src/virtual-fs/host/scoped.ts b/packages/schematic-utils/src/virtual-fs/host/scoped.ts new file mode 100644 index 0000000..35e077b --- /dev/null +++ b/packages/schematic-utils/src/virtual-fs/host/scoped.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { NormalizedRoot, Path, join } from "../path"; +import { Host } from "./interface"; +import { ResolverHost } from "./resolver"; + +export class ScopedHost extends ResolverHost { + constructor(delegate: Host, protected _root: Path = NormalizedRoot) { + super(delegate); + } + + protected _resolve(path: Path): Path { + return join(this._root, path); + } +} diff --git a/packages/schematic-utils/src/virtual-fs/host/sync.ts b/packages/schematic-utils/src/virtual-fs/host/sync.ts new file mode 100644 index 0000000..08690c3 --- /dev/null +++ b/packages/schematic-utils/src/virtual-fs/host/sync.ts @@ -0,0 +1,117 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { Observable } from "rxjs"; +import { BaseException } from "../../exceptions/exception"; +import { Path, PathFragment } from "../path"; +import { + FileBuffer, + FileBufferLike, + Host, + HostCapabilities, + HostWatchEvent, + HostWatchOptions, + Stats, +} from "./interface"; + +export class SynchronousDelegateExpectedException extends BaseException { + constructor() { + super(`Expected a synchronous delegate but got an asynchronous one.`); + } +} + +/** + * Implement a synchronous-only host interface (remove the Observable parts). + */ +export class SyncDelegateHost { + constructor(protected _delegate: Host) { + if (!_delegate.capabilities.synchronous) { + throw new SynchronousDelegateExpectedException(); + } + } + + protected _doSyncCall(observable: Observable): ResultT { + let completed = false; + let result: ResultT | undefined = undefined; + let errorResult: Error | undefined = undefined; + // Perf note: this is not using an observer object to avoid a performance penalty in RxJS. + // See https://github.com/ReactiveX/rxjs/pull/5646 for details. + observable.subscribe( + (x: ResultT) => (result = x), + (err: Error) => (errorResult = err), + () => (completed = true) + ); + + if (errorResult !== undefined) { + throw errorResult; + } + if (!completed) { + throw new SynchronousDelegateExpectedException(); + } + + // The non-null operation is to work around `void` type. We don't allow to return undefined + // but ResultT could be void, which is undefined in JavaScript, so this doesn't change the + // behaviour. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return result!; + } + + get capabilities(): HostCapabilities { + return this._delegate.capabilities; + } + get delegate() { + return this._delegate; + } + + write(path: Path, content: FileBufferLike): void { + return this._doSyncCall(this._delegate.write(path, content)); + } + read(path: Path): FileBuffer { + return this._doSyncCall(this._delegate.read(path)); + } + delete(path: Path): void { + return this._doSyncCall(this._delegate.delete(path)); + } + rename(from: Path, to: Path): void { + return this._doSyncCall(this._delegate.rename(from, to)); + } + + list(path: Path): PathFragment[] { + return this._doSyncCall(this._delegate.list(path)); + } + + exists(path: Path): boolean { + return this._doSyncCall(this._delegate.exists(path)); + } + isDirectory(path: Path): boolean { + return this._doSyncCall(this._delegate.isDirectory(path)); + } + isFile(path: Path): boolean { + return this._doSyncCall(this._delegate.isFile(path)); + } + + // Some hosts may not support stat. + stat(path: Path): Stats | null { + const result: Observable | null> | null = this._delegate.stat( + path + ); + + if (result) { + return this._doSyncCall(result); + } else { + return null; + } + } + + watch( + path: Path, + options?: HostWatchOptions + ): Observable | null { + return this._delegate.watch(path, options); + } +} diff --git a/packages/schematic-utils/src/virtual-fs/host/test.ts b/packages/schematic-utils/src/virtual-fs/host/test.ts new file mode 100644 index 0000000..398e620 --- /dev/null +++ b/packages/schematic-utils/src/virtual-fs/host/test.ts @@ -0,0 +1,174 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { Observable } from "rxjs"; +import { Path, PathFragment, join, normalize } from "../path"; +import { fileBufferToString, stringToFileBuffer } from "./buffer"; +import { + FileBuffer, + HostWatchEvent, + HostWatchOptions, + Stats, +} from "./interface"; +import { SimpleMemoryHost, SimpleMemoryHostStats } from "./memory"; +import { SyncDelegateHost } from "./sync"; + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace test { + export type TestLogRecord = + | { + kind: + | "write" + | "read" + | "delete" + | "list" + | "exists" + | "isDirectory" + | "isFile" + | "stat" + | "watch"; + path: Path; + } + | { + kind: "rename"; + from: Path; + to: Path; + }; + + export class TestHost extends SimpleMemoryHost { + protected _records: TestLogRecord[] = []; + protected _sync: SyncDelegateHost<{}> | null = null; + + constructor(map: { [path: string]: string } = {}) { + super(); + + for (const filePath of Object.getOwnPropertyNames(map)) { + this._write(normalize(filePath), stringToFileBuffer(map[filePath])); + } + } + + get records(): TestLogRecord[] { + return [...this._records]; + } + clearRecords() { + this._records = []; + } + + get files(): Path[] { + const sync = this.sync; + function _visit(p: Path): Path[] { + return sync + .list(p) + .map((fragment) => join(p, fragment)) + .reduce((files, path) => { + if (sync.isDirectory(path)) { + return files.concat(_visit(path)); + } else { + return files.concat(path); + } + }, [] as Path[]); + } + + return _visit(normalize("/")); + } + + get sync() { + if (!this._sync) { + this._sync = new SyncDelegateHost<{}>(this); + } + + return this._sync; + } + + clone() { + const newHost = new TestHost(); + newHost._cache = new Map(this._cache); + + return newHost; + } + + // Override parents functions to keep a record of all operators that were done. + protected _write(path: Path, content: FileBuffer) { + this._records.push({ kind: "write", path }); + + return super._write(path, content); + } + protected _read(path: Path) { + this._records.push({ kind: "read", path }); + + return super._read(path); + } + protected _delete(path: Path) { + this._records.push({ kind: "delete", path }); + + return super._delete(path); + } + protected _rename(from: Path, to: Path) { + this._records.push({ kind: "rename", from, to }); + + return super._rename(from, to); + } + protected _list(path: Path): PathFragment[] { + this._records.push({ kind: "list", path }); + + return super._list(path); + } + protected _exists(path: Path) { + this._records.push({ kind: "exists", path }); + + return super._exists(path); + } + protected _isDirectory(path: Path) { + this._records.push({ kind: "isDirectory", path }); + + return super._isDirectory(path); + } + protected _isFile(path: Path) { + this._records.push({ kind: "isFile", path }); + + return super._isFile(path); + } + protected _stat(path: Path): Stats | null { + this._records.push({ kind: "stat", path }); + + return super._stat(path); + } + protected _watch( + path: Path, + options?: HostWatchOptions + ): Observable { + this._records.push({ kind: "watch", path }); + + return super._watch(path, options); + } + + $write(path: string, content: string) { + return super._write(normalize(path), stringToFileBuffer(content)); + } + + $read(path: string): string { + return fileBufferToString(super._read(normalize(path))); + } + + $list(path: string): PathFragment[] { + return super._list(normalize(path)); + } + + $exists(path: string) { + return super._exists(normalize(path)); + } + + $isDirectory(path: string) { + return super._isDirectory(normalize(path)); + } + + $isFile(path: string) { + return super._isFile(normalize(path)); + } + } +} diff --git a/packages/schematic-utils/src/virtual-fs/host/test_spec.ts b/packages/schematic-utils/src/virtual-fs/host/test_spec.ts new file mode 100644 index 0000000..2117101 --- /dev/null +++ b/packages/schematic-utils/src/virtual-fs/host/test_spec.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { test } from "./test"; + +// Yes, we realize the irony of testing a test host. +describe("TestHost", () => { + it("can list files", () => { + const files = { + "/x/y/z": "", + "/a": "", + "/h": "", + "/x/y/b": "", + }; + + const host = new test.TestHost(files); + expect(host.files.sort() as string[]).toEqual(Object.keys(files).sort()); + }); +}); diff --git a/packages/schematic-utils/src/virtual-fs/index.ts b/packages/schematic-utils/src/virtual-fs/index.ts new file mode 100644 index 0000000..74b4a20 --- /dev/null +++ b/packages/schematic-utils/src/virtual-fs/index.ts @@ -0,0 +1,12 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import * as virtualFs from "./host/index"; + +export * from "./path"; +export { virtualFs }; diff --git a/packages/schematic-utils/src/virtual-fs/path.ts b/packages/schematic-utils/src/virtual-fs/path.ts new file mode 100644 index 0000000..ab6cc0e --- /dev/null +++ b/packages/schematic-utils/src/virtual-fs/path.ts @@ -0,0 +1,319 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { BaseException } from "../exceptions/exception"; +import { TemplateTag } from "../utils/runtime"; + +export class InvalidPathException extends BaseException { + constructor(path: string) { + super(`Path ${JSON.stringify(path)} is invalid.`); + } +} +export class PathMustBeAbsoluteException extends BaseException { + constructor(path: string) { + super(`Path ${JSON.stringify(path)} must be absolute.`); + } +} +export class PathCannotBeFragmentException extends BaseException { + constructor(path: string) { + super(`Path ${JSON.stringify(path)} cannot be made a fragment.`); + } +} + +/** + * A Path recognized by most methods in the DevKit. + */ +export type Path = string & { + __PRIVATE_DEVKIT_PATH: void; +}; + +/** + * A Path fragment (file or directory name) recognized by most methods in the DevKit. + */ +export type PathFragment = Path & { + __PRIVATE_DEVKIT_PATH_FRAGMENT: void; +}; + +/** + * The Separator for normalized path. + * @type {Path} + */ +export const NormalizedSep = "/" as Path; + +/** + * The root of a normalized path. + * @type {Path} + */ +export const NormalizedRoot = NormalizedSep; + +/** + * Split a path into multiple path fragments. Each fragments except the last one will end with + * a path separator. + * @param {Path} path The path to split. + * @returns {Path[]} An array of path fragments. + */ +export function split(path: Path): PathFragment[] { + const fragments = path.split(NormalizedSep).map((x) => fragment(x)); + if (fragments[fragments.length - 1].length === 0) { + fragments.pop(); + } + + return fragments; +} + +/** + * + */ +export function extname(path: Path): string { + const base = basename(path); + const i = base.lastIndexOf("."); + if (i < 1) { + return ""; + } else { + return base.substr(i); + } +} + +/** + * Return the basename of the path, as a Path. See path.basename + */ +export function basename(path: Path): PathFragment { + const i = path.lastIndexOf(NormalizedSep); + if (i == -1) { + return fragment(path); + } else { + return fragment(path.substr(path.lastIndexOf(NormalizedSep) + 1)); + } +} + +/** + * Return the dirname of the path, as a Path. See path.dirname + */ +export function dirname(path: Path): Path { + const index = path.lastIndexOf(NormalizedSep); + if (index === -1) { + return "" as Path; + } + + const endIndex = index === 0 ? 1 : index; // case of file under root: '/file' + + return normalize(path.substr(0, endIndex)); +} + +/** + * Join multiple paths together, and normalize the result. Accepts strings that will be + * normalized as well (but the original must be a path). + */ +export function join(p1: Path, ...others: string[]): Path { + if (others.length > 0) { + return normalize( + (p1 ? p1 + NormalizedSep : "") + others.join(NormalizedSep) + ); + } else { + return p1; + } +} + +/** + * Returns true if a path is absolute. + */ +export function isAbsolute(p: Path) { + return p.startsWith(NormalizedSep); +} + +/** + * Returns a path such that `join(from, relative(from, to)) == to`. + * Both paths must be absolute, otherwise it does not make much sense. + */ +export function relative(from: Path, to: Path): Path { + if (!isAbsolute(from)) { + throw new PathMustBeAbsoluteException(from); + } + if (!isAbsolute(to)) { + throw new PathMustBeAbsoluteException(to); + } + + let p: string; + + if (from == to) { + p = ""; + } else { + const splitFrom = split(from); + const splitTo = split(to); + + while ( + splitFrom.length > 0 && + splitTo.length > 0 && + splitFrom[0] == splitTo[0] + ) { + splitFrom.shift(); + splitTo.shift(); + } + + if (splitFrom.length == 0) { + p = splitTo.join(NormalizedSep); + } else { + p = splitFrom + .map(() => "..") + .concat(splitTo) + .join(NormalizedSep); + } + } + + return normalize(p); +} + +/** + * Returns a Path that is the resolution of p2, from p1. If p2 is absolute, it will return p2, + * otherwise will join both p1 and p2. + */ +export function resolve(p1: Path, p2: Path) { + if (isAbsolute(p2)) { + return p2; + } else { + return join(p1, p2); + } +} + +export function fragment(path: string): PathFragment { + if (path.indexOf(NormalizedSep) != -1) { + throw new PathCannotBeFragmentException(path); + } + + return path as PathFragment; +} + +/** + * normalize() cache to reduce computation. For now this grows and we never flush it, but in the + * future we might want to add a few cache flush to prevent this from growing too large. + */ +let normalizedCache = new Map(); + +/** + * Reset the cache. This is only useful for testing. + * @private + */ +export function resetNormalizeCache() { + normalizedCache = new Map(); +} + +/** + * Normalize a string into a Path. This is the only mean to get a Path type from a string that + * represents a system path. This method cache the results as real world paths tend to be + * duplicated often. + * Normalization includes: + * - Windows backslashes `\\` are replaced with `/`. + * - Windows drivers are replaced with `/X/`, where X is the drive letter. + * - Absolute paths starts with `/`. + * - Multiple `/` are replaced by a single one. + * - Path segments `.` are removed. + * - Path segments `..` are resolved. + * - If a path is absolute, having a `..` at the start is invalid (and will throw). + * @param path The path to be normalized. + */ +export function normalize(path: string): Path { + let maybePath = normalizedCache.get(path); + if (!maybePath) { + maybePath = noCacheNormalize(path); + normalizedCache.set(path, maybePath); + } + + return maybePath; +} + +/** + * The no cache version of the normalize() function. Used for benchmarking and testing. + */ +export function noCacheNormalize(path: string): Path { + if (path == "" || path == ".") { + return "" as Path; + } else if (path == NormalizedRoot) { + return NormalizedRoot; + } + + // Match absolute windows path. + const original = path; + if (path.match(/^[A-Z]:[\/\\]/i)) { + path = "\\" + path[0] + "\\" + path.substr(3); + } + + // We convert Windows paths as well here. + const p = path.split(/[\/\\]/g); + let relative = false; + let i = 1; + + // Special case the first one. + if (p[0] != "") { + p.unshift("."); + relative = true; + } + + while (i < p.length) { + if (p[i] == ".") { + p.splice(i, 1); + } else if (p[i] == "..") { + if (i < 2 && !relative) { + throw new InvalidPathException(original); + } else if (i >= 2 && p[i - 1] != "..") { + p.splice(i - 1, 2); + i--; + } else { + i++; + } + } else if (p[i] == "") { + p.splice(i, 1); + } else { + i++; + } + } + + if (p.length == 1) { + return p[0] == "" ? NormalizedSep : ("" as Path); + } else { + if (p[0] == ".") { + p.shift(); + } + + return p.join(NormalizedSep) as Path; + } +} + +export const path: TemplateTag = (strings, ...values) => { + return normalize(String.raw(strings, ...values)); +}; + +// Platform-specific paths. +export type WindowsPath = string & { + __PRIVATE_DEVKIT_WINDOWS_PATH: void; +}; +export type PosixPath = string & { + __PRIVATE_DEVKIT_POSIX_PATH: void; +}; + +export function asWindowsPath(path: Path): WindowsPath { + const drive = path.match(/^\/(\w)(?:\/(.*))?$/); + if (drive) { + const subPath = drive[2] ? drive[2].replace(/\//g, "\\") : ""; + + return `${drive[1]}:\\${subPath}` as WindowsPath; + } + + return path.replace(/\//g, "\\") as WindowsPath; +} + +export function asPosixPath(path: Path): PosixPath { + return (path as string) as PosixPath; +} + +export function getSystemPath(path: Path): string { + if (process.platform.startsWith("win32")) { + return asWindowsPath(path); + } else { + return asPosixPath(path); + } +} diff --git a/packages/schematic-utils/src/virtual-fs/path_spec.ts b/packages/schematic-utils/src/virtual-fs/path_spec.ts new file mode 100644 index 0000000..fc796e6 --- /dev/null +++ b/packages/schematic-utils/src/virtual-fs/path_spec.ts @@ -0,0 +1,174 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + InvalidPathException, + Path, + PathFragment, + asWindowsPath, + basename, + dirname, + join, + normalize, + relative, + split, +} from "./path"; + +describe("path", () => { + it("normalize", () => { + expect(normalize("////")).toBe("/"); + expect(normalize("././././.")).toBe(""); + expect(normalize("/./././.")).toBe("/"); + + // Regular use cases. + expect(normalize("a")).toBe("a"); + expect(normalize("a/b/c")).toBe("a/b/c"); + expect(normalize("/a/b/c")).toBe("/a/b/c"); + expect(normalize("./a/b/c")).toBe("a/b/c"); + expect(normalize("/./a/b/c")).toBe("/a/b/c"); + expect(normalize("/./a/b/c/")).toBe("/a/b/c"); + expect(normalize("/./a/b/./c")).toBe("/a/b/c"); + expect(normalize("./a/b/./c")).toBe("a/b/c"); + expect(normalize("/./a/b/d/../c")).toBe("/a/b/c"); + expect(normalize("/./a/b/./d/../c")).toBe("/a/b/c"); + expect(normalize("././a/b/./d/../c")).toBe("a/b/c"); + expect(normalize("a/")).toBe("a"); + expect(normalize("a/./..")).toBe(""); + + // Reducing to nothing use cases. + expect(normalize("")).toBe(""); + expect(normalize(".")).toBe(""); + expect(normalize("/.")).toBe("/"); + expect(normalize("/./.")).toBe("/"); + expect(normalize("/././.")).toBe("/"); + expect(normalize("/c/..")).toBe("/"); + + // Out of directory. + expect(normalize("..")).toBe(".."); + expect(normalize("../..")).toBe("../.."); + expect(normalize("../../a")).toBe("../../a"); + expect(normalize("b/../../a")).toBe("../a"); + expect(normalize("./..")).toBe(".."); + expect(normalize("../a/b/c")).toBe("../a/b/c"); + expect(normalize("./a/../../a/b/c")).toBe("../a/b/c"); + + // Invalid use cases. + expect(() => normalize("/./././../././/")).toThrow( + new InvalidPathException("/./././../././/") + ); + expect(() => normalize("/./././../././/../")).toThrow( + new InvalidPathException("/./././../././/../") + ); + expect(() => normalize("/./././../././a/.")).toThrow( + new InvalidPathException("/./././../././a/.") + ); + + expect(() => normalize("/c/../../")).toThrow( + new InvalidPathException("/c/../../") + ); + + // Windows use cases. + expect(normalize("a\\b\\c")).toBe("a/b/c"); + expect(normalize("\\a\\b\\c")).toBe("/a/b/c"); + expect(normalize(".\\a\\b\\c")).toBe("a/b/c"); + expect(normalize("C:\\a\\b\\c")).toBe("/C/a/b/c"); + expect(normalize("c:\\a\\b\\c")).toBe("/c/a/b/c"); + expect(normalize("A:\\a\\b\\c")).toBe("/A/a/b/c"); + expect(() => normalize("A:\\..\\..")).toThrow( + new InvalidPathException("A:\\..\\..") + ); + expect(normalize("\\.\\a\\b\\c")).toBe("/a/b/c"); + expect(normalize("\\.\\a\\b\\.\\c")).toBe("/a/b/c"); + expect(normalize("\\.\\a\\b\\d\\..\\c")).toBe("/a/b/c"); + expect(normalize("\\.\\a\\b\\.\\d\\..\\c")).toBe("/a/b/c"); + expect(normalize("a\\")).toBe("a"); + }); + + describe("split", () => { + const tests: [string, string[]][] = [ + ["a", ["a"]], + ["/a/b", ["", "a", "b"]], + ["a/b", ["a", "b"]], + ["a/b/", ["a", "b"]], + ["", []], + ["/", [""]], + ]; + + for (const [input, result] of tests) { + const normalizedInput = normalize(input); + + it(`(${JSON.stringify(normalizedInput)}) == "${result}"`, () => { + expect(split(normalizedInput)).toEqual(result as PathFragment[]); + }); + } + }); + + describe("join", () => { + const tests: [string[], string][] = [ + [["a"], "a"], + [["/a", "/b"], "/a/b"], + [["/a", "/b", "/c"], "/a/b/c"], + [["/a", "b", "c"], "/a/b/c"], + [["a", "b", "c"], "a/b/c"], + ]; + + for (const [input, result] of tests) { + const args = input.map((x) => normalize(x)) as [Path, ...Path[]]; + + it(`(${JSON.stringify(args)}) == "${result}"`, () => { + expect(join(...args)).toBe(result); + }); + } + }); + + describe("relative", () => { + const tests = [ + ["/a/b/c", "/a/b/c", ""], + ["/a/b", "/a/b/c", "c"], + ["/a/b", "/a/b/c/d", "c/d"], + ["/a/b/c", "/a/b", ".."], + ["/a/b/c", "/a/b/d", "../d"], + ["/a/b/c/d/e", "/a/f/g", "../../../../f/g"], + ["/src/app/sub1/test1", "/src/app/sub2/test2", "../../sub2/test2"], + ["/", "/a/b/c", "a/b/c"], + ["/a/b/c", "/d", "../../../d"], + ]; + + for (const [from, to, result] of tests) { + it(`("${from}", "${to}") == "${result}"`, () => { + const f = normalize(from); + const t = normalize(to); + + expect(relative(f, t)).toBe(result); + expect(join(f, relative(f, t))).toBe(t); + }); + } + }); + + it("dirname", () => { + expect(dirname(normalize("a"))).toBe(""); + expect(dirname(normalize("/a"))).toBe("/"); + expect(dirname(normalize("/a/b/c"))).toBe("/a/b"); + expect(dirname(normalize("./c"))).toBe(""); + expect(dirname(normalize("./a/b/c"))).toBe("a/b"); + }); + + it("basename", () => { + expect(basename(normalize("a"))).toBe("a"); + expect(basename(normalize("/a/b/c"))).toBe("c"); + expect(basename(normalize("./c"))).toBe("c"); + expect(basename(normalize("."))).toBe(""); + expect(basename(normalize("./a/b/c"))).toBe("c"); + }); + + it("asWindowsPath", () => { + expect(asWindowsPath(normalize("c:/"))).toBe("c:\\"); + expect(asWindowsPath(normalize("c:/b/"))).toBe("c:\\b"); + expect(asWindowsPath(normalize("c:/b/c"))).toBe("c:\\b\\c"); + }); +}); diff --git a/packages/schematic-utils/src/workflow/interface.ts b/packages/schematic-utils/src/workflow/interface.ts index 1b30929..81bc76c 100644 --- a/packages/schematic-utils/src/workflow/interface.ts +++ b/packages/schematic-utils/src/workflow/interface.ts @@ -1,5 +1,5 @@ import { Observable } from "rxjs"; -import { logging } from "@angular-devkit/core"; +import * as logging from "../logger"; export interface RequiredWorkflowExecutionContext { collection: string; diff --git a/packages/schematic-utils/src/workspace/core.ts b/packages/schematic-utils/src/workspace/core.ts new file mode 100644 index 0000000..820f4d5 --- /dev/null +++ b/packages/schematic-utils/src/workspace/core.ts @@ -0,0 +1,151 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { basename, getSystemPath, join, normalize } from "../virtual-fs"; +import { WorkspaceDefinition } from "./definitions"; +import { WorkspaceHost } from "./host"; +import { readJsonWorkspace } from "./json/reader"; +import { writeJsonWorkspace } from "./json/writer"; + +const formatLookup = new WeakMap(); + +/** + * Supported workspace formats + */ +export enum WorkspaceFormat { + JSON, +} + +/** + * @private + */ +export function _test_addWorkspaceFile( + name: string, + format: WorkspaceFormat +): void { + workspaceFiles[name] = format; +} + +/** + * @private + */ +export function _test_removeWorkspaceFile(name: string): void { + delete workspaceFiles[name]; +} + +// NOTE: future additions could also perform content analysis to determine format/version +const workspaceFiles: Record = { + "angular.json": WorkspaceFormat.JSON, + ".angular.json": WorkspaceFormat.JSON, +}; + +/** + * Reads and constructs a `WorkspaceDefinition`. If the function is provided with a path to a + * directory instead of a file, a search of the directory's files will commence to attempt to + * locate a known workspace file. Currently the following are considered known workspace files: + * - `angular.json` + * - `.angular.json` + * + * @param path The path to either a workspace file or a directory containing a workspace file. + * @param host The `WorkspaceHost` to use to access the file and directory data. + * @param format An optional `WorkspaceFormat` value. Used if the path specifies a non-standard + * file name that would prevent automatically discovering the format. + * + * + * @return An `Promise` of the read result object with the `WorkspaceDefinition` contained within + * the `workspace` property. + */ +export async function readWorkspace( + path: string, + host: WorkspaceHost, + format?: WorkspaceFormat + // return type will eventually have a `diagnostics` property as well +): Promise<{ workspace: WorkspaceDefinition }> { + if (await host.isDirectory(path)) { + // TODO: Warn if multiple found (requires diagnostics support) + const directory = normalize(path); + let found = false; + for (const [name, nameFormat] of Object.entries(workspaceFiles)) { + if (format !== undefined && format !== nameFormat) { + continue; + } + + const potential = getSystemPath(join(directory, name)); + if (await host.isFile(potential)) { + path = potential; + format = nameFormat; + found = true; + break; + } + } + if (!found) { + throw new Error("Unable to locate a workspace file for workspace path."); + } + } else if (format === undefined) { + const filename = basename(normalize(path)); + if (filename in workspaceFiles) { + format = workspaceFiles[filename]; + } + } + + if (format === undefined) { + throw new Error("Unable to determine format for workspace path."); + } + + let workspace; + switch (format) { + case WorkspaceFormat.JSON: + workspace = await readJsonWorkspace(path, host); + break; + default: + throw new Error("Unsupported workspace format."); + } + + formatLookup.set(workspace, WorkspaceFormat.JSON); + + return { workspace }; +} + +/** + * Writes a `WorkspaceDefinition` to the underlying storage via the provided `WorkspaceHost`. + * If the `WorkspaceDefinition` was created via the `readWorkspace` function, metadata will be + * used to determine the path and format of the Workspace. In all other cases, the `path` and + * `format` options must be specified as they would be otherwise unknown. + * + * @param workspace The `WorkspaceDefinition` that will be written. + * @param host The `WorkspaceHost` to use to access/write the file and directory data. + * @param path The path to a file location for the output. Required if `readWorkspace` was not + * used to create the `WorkspaceDefinition`. Optional otherwise; will override the + * `WorkspaceDefinition` metadata if provided. + * @param format The `WorkspaceFormat` to use for output. Required if `readWorkspace` was not + * used to create the `WorkspaceDefinition`. Optional otherwise; will override the + * `WorkspaceDefinition` metadata if provided. + * + * + * @return An `Promise` of type `void`. + */ +export async function writeWorkspace( + workspace: WorkspaceDefinition, + host: WorkspaceHost, + path?: string, + format?: WorkspaceFormat +): Promise { + if (format === undefined) { + format = formatLookup.get(workspace); + if (format === undefined) { + throw new Error("A format is required for custom workspace objects."); + } + } + + switch (format) { + case WorkspaceFormat.JSON: + return writeJsonWorkspace(workspace, host, path); + default: + throw new Error("Unsupported workspace format."); + } +} diff --git a/packages/schematic-utils/src/workspace/core_spec.ts b/packages/schematic-utils/src/workspace/core_spec.ts new file mode 100644 index 0000000..ed070fa --- /dev/null +++ b/packages/schematic-utils/src/workspace/core_spec.ts @@ -0,0 +1,384 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/* eslint-disable @typescript-eslint/no-empty-function */ +import { getSystemPath, join, normalize } from "../virtual-fs"; +import { + WorkspaceFormat, + _test_addWorkspaceFile, + _test_removeWorkspaceFile, + readWorkspace, + writeWorkspace, +} from "./core"; +import { WorkspaceDefinition } from "./definitions"; +import { WorkspaceHost } from "./host"; + +describe("readWorkspace", () => { + it("attempts to read from specified file path [angular.json]", async () => { + const requestedPath = "/path/to/workspace/angular.json"; + + const host: WorkspaceHost = { + async readFile(path) { + expect(path).toBe(requestedPath); + + return '{ "version": 1 }'; + }, + async writeFile() {}, + async isFile(path) { + return path === requestedPath; + }, + async isDirectory(path) { + if (path !== requestedPath) { + fail(); + } + + return false; + }, + }; + + await readWorkspace(requestedPath, host); + }); + + it("attempts to read from specified file path [.angular.json]", async () => { + const requestedPath = "/path/to/workspace/.angular.json"; + + const host: WorkspaceHost = { + async readFile(path) { + expect(path).toBe(requestedPath); + + return '{ "version": 1 }'; + }, + async writeFile() {}, + async isFile(path) { + return path === requestedPath; + }, + async isDirectory(path) { + if (path !== requestedPath) { + fail(); + } + + return false; + }, + }; + + await readWorkspace(requestedPath, host); + }); + + it("attempts to read from specified non-standard file path with format", async () => { + const requestedPath = "/path/to/workspace/abc.json"; + + const host: WorkspaceHost = { + async readFile(path) { + expect(path).toBe(requestedPath); + + return '{ "version": 1 }'; + }, + async writeFile() {}, + async isFile(path) { + return path === requestedPath; + }, + async isDirectory(path) { + if (path !== requestedPath) { + fail(); + } + + return false; + }, + }; + + await readWorkspace(requestedPath, host, WorkspaceFormat.JSON); + }); + + it("errors when reading from specified non-standard file path without format", async () => { + const requestedPath = "/path/to/workspace/abc.json"; + + const host: WorkspaceHost = { + async readFile(path) { + expect(path).toBe(requestedPath); + + return '{ "version": 1 }'; + }, + async writeFile() {}, + async isFile(path) { + return path === requestedPath; + }, + async isDirectory(path) { + if (path !== requestedPath) { + fail(); + } + + return false; + }, + }; + + try { + await readWorkspace(requestedPath, host); + fail(); + } catch (e) { + expect(e.message).toContain( + "Unable to determine format for workspace path" + ); + } + }); + + it("errors when reading from specified file path with invalid specified format", async () => { + const requestedPath = "/path/to/workspace/angular.json"; + + const host: WorkspaceHost = { + async readFile(path) { + expect(path).toBe(requestedPath); + + return '{ "version": 1 }'; + }, + async writeFile() {}, + async isFile(path) { + return path === requestedPath; + }, + async isDirectory(path) { + if (path !== requestedPath) { + fail(); + } + + return false; + }, + }; + + try { + await readWorkspace(requestedPath, host, 12 as WorkspaceFormat); + fail(); + } catch (e) { + expect(e.message).toContain("Unsupported workspace format"); + } + }); + + it("attempts to find/read from directory path", async () => { + const requestedPath = getSystemPath(normalize("/path/to/workspace")); + const expectedFile = getSystemPath( + join(normalize(requestedPath), ".angular.json") + ); + + const isFileChecks: string[] = []; + const host: WorkspaceHost = { + async readFile(path) { + expect(path).not.toBe(requestedPath); + expect(path).toBe(expectedFile); + + return '{ "version": 1 }'; + }, + async writeFile() {}, + async isFile(path) { + isFileChecks.push(path); + + return path === expectedFile; + }, + async isDirectory(path) { + if (path === requestedPath) { + return true; + } + + fail(); + + return false; + }, + }; + + await readWorkspace(requestedPath, host); + isFileChecks.sort(); + expect(isFileChecks).toEqual( + [ + getSystemPath(join(normalize(requestedPath), "angular.json")), + getSystemPath(join(normalize(requestedPath), ".angular.json")), + ].sort() + ); + }); + + it("attempts to find/read only files for specified format from directory path", async () => { + const requestedPath = "/path/to/workspace"; + + const isFileChecks: string[] = []; + const readFileChecks: string[] = []; + const host: WorkspaceHost = { + async readFile(path) { + expect(path).not.toBe(requestedPath); + readFileChecks.push(path); + + return '{ "version": 1 }'; + }, + async writeFile() {}, + async isFile(path) { + isFileChecks.push(path); + + return true; + }, + async isDirectory(path) { + if (path === requestedPath) { + return true; + } + + fail(); + + return false; + }, + }; + + _test_addWorkspaceFile("wrong.format", 99); + try { + await readWorkspace(requestedPath, host, WorkspaceFormat.JSON); + } finally { + _test_removeWorkspaceFile("wrong.format"); + } + + isFileChecks.sort(); + expect(isFileChecks).toEqual([ + getSystemPath(join(normalize(requestedPath), "angular.json")), + ]); + + readFileChecks.sort(); + expect(readFileChecks).toEqual([ + getSystemPath(join(normalize(requestedPath), "angular.json")), + ]); + }); + + it("errors when no file found from specified directory path", async () => { + const requestedPath = "/path/to/workspace"; + + const host: WorkspaceHost = { + async readFile(path) { + expect(path).not.toBe(requestedPath); + + return '{ "version": 1 }'; + }, + async writeFile() {}, + async isFile() { + return false; + }, + async isDirectory(path) { + if (path === requestedPath) { + return true; + } + + fail(); + + return false; + }, + }; + + try { + await readWorkspace(requestedPath, host); + fail(); + } catch (e) { + expect(e.message).toContain("Unable to locate a workspace file"); + } + }); +}); + +describe("writeWorkspace", () => { + it("attempts to write to specified file path", async () => { + const requestedPath = "/path/to/workspace/angular.json"; + + let writtenPath: string | undefined; + const host: WorkspaceHost = { + async readFile() { + fail(); + + return ""; + }, + async writeFile(path) { + expect(writtenPath).toBeUndefined(); + writtenPath = path; + }, + async isFile() { + fail(); + + return false; + }, + async isDirectory() { + fail(); + + return false; + }, + }; + + await writeWorkspace( + {} as WorkspaceDefinition, + host, + requestedPath, + WorkspaceFormat.JSON + ); + expect(writtenPath).toBe(requestedPath); + }); + + it("errors when writing to specified file path with invalid specified format", async () => { + const requestedPath = "/path/to/workspace/angular.json"; + + const host: WorkspaceHost = { + async readFile() { + fail(); + + return ""; + }, + async writeFile() { + fail(); + }, + async isFile() { + fail(); + + return false; + }, + async isDirectory() { + fail(); + + return false; + }, + }; + + try { + await writeWorkspace( + {} as WorkspaceDefinition, + host, + requestedPath, + 12 as WorkspaceFormat + ); + fail(); + } catch (e) { + expect(e.message).toContain("Unsupported workspace format"); + } + }); + + it("errors when writing custom workspace without specified format", async () => { + const requestedPath = "/path/to/workspace/angular.json"; + + const host: WorkspaceHost = { + async readFile() { + fail(); + + return ""; + }, + async writeFile() { + fail(); + }, + async isFile() { + fail(); + + return false; + }, + async isDirectory() { + fail(); + + return false; + }, + }; + + try { + await writeWorkspace({} as WorkspaceDefinition, host, requestedPath); + fail(); + } catch (e) { + expect(e.message).toContain("A format is required"); + } + }); +}); diff --git a/packages/schematic-utils/src/workspace/definitions.ts b/packages/schematic-utils/src/workspace/definitions.ts new file mode 100644 index 0000000..72f2dc6 --- /dev/null +++ b/packages/schematic-utils/src/workspace/definitions.ts @@ -0,0 +1,270 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { JsonValue } from "../json"; + +export interface WorkspaceDefinition { + readonly extensions: Record; + + readonly projects: ProjectDefinitionCollection; +} + +export interface ProjectDefinition { + readonly extensions: Record; + readonly targets: TargetDefinitionCollection; + + root: string; + prefix?: string; + sourceRoot?: string; +} + +export interface TargetDefinition { + options?: Record; + configurations?: Record< + string, + Record | undefined + >; + defaultConfiguration?: string; + builder: string; +} + +export type DefinitionCollectionListener = ( + name: string, + action: "add" | "remove" | "replace", + newValue: V | undefined, + oldValue: V | undefined, + collection: DefinitionCollection +) => void; + +class DefinitionCollection implements ReadonlyMap { + private _map: Map; + + constructor( + initial?: Record, + private _listener?: DefinitionCollectionListener + ) { + this._map = new Map(initial && Object.entries(initial)); + } + + delete(key: string): boolean { + const value = this._map.get(key); + const result = this._map.delete(key); + if (result && value !== undefined && this._listener) { + this._listener(key, "remove", undefined, value, this); + } + + return result; + } + + set(key: string, value: V): this { + const existing = this.get(key); + this._map.set(key, value); + + if (this._listener) { + this._listener( + key, + existing !== undefined ? "replace" : "add", + value, + existing, + this + ); + } + + return this; + } + + forEach( + callbackfn: (value: V, key: string, map: DefinitionCollection) => void, + thisArg?: T + ): void { + this._map.forEach((value, key) => callbackfn(value, key, this), thisArg); + } + + get(key: string): V | undefined { + return this._map.get(key); + } + + has(key: string): boolean { + return this._map.has(key); + } + + get size(): number { + return this._map.size; + } + + [Symbol.iterator](): IterableIterator<[string, V]> { + return this._map[Symbol.iterator](); + } + + entries(): IterableIterator<[string, V]> { + return this._map.entries(); + } + + keys(): IterableIterator { + return this._map.keys(); + } + + values(): IterableIterator { + return this._map.values(); + } +} + +function isJsonValue(value: unknown): value is JsonValue { + const visited = new Set(); + + switch (typeof value) { + case "boolean": + case "number": + case "string": + return true; + case "object": + if (value === null) { + return true; + } + visited.add(value); + for (const property of Object.values(value)) { + if (typeof value === "object" && visited.has(property)) { + continue; + } + if (!isJsonValue(property)) { + return false; + } + } + + return true; + default: + return false; + } +} + +export class ProjectDefinitionCollection extends DefinitionCollection< + ProjectDefinition +> { + constructor( + initial?: Record, + listener?: DefinitionCollectionListener + ) { + super(initial, listener); + } + + add(definition: { + name: string; + root: string; + sourceRoot?: string; + prefix?: string; + targets?: Record; + [key: string]: unknown; + }): ProjectDefinition { + if (this.has(definition.name)) { + throw new Error("Project name already exists."); + } + this._validateName(definition.name); + + const project: ProjectDefinition = { + root: definition.root, + prefix: definition.prefix, + sourceRoot: definition.sourceRoot, + targets: new TargetDefinitionCollection(), + extensions: {}, + }; + + if (definition.targets) { + for (const [name, target] of Object.entries(definition.targets)) { + if (target) { + project.targets.set(name, target); + } + } + } + + for (const [name, value] of Object.entries(definition)) { + switch (name) { + case "name": + case "root": + case "sourceRoot": + case "prefix": + case "targets": + break; + default: + if (isJsonValue(value)) { + project.extensions[name] = value; + } else { + throw new TypeError(`"${name}" must be a JSON value.`); + } + break; + } + } + + super.set(definition.name, project); + + return project; + } + + set(name: string, value: ProjectDefinition): this { + this._validateName(name); + + super.set(name, value); + + return this; + } + + private _validateName(name: string): void { + if ( + typeof name !== "string" || + !/^(?:@\w[\w\.-]*\/)?\w[\w\.-]*$/.test(name) + ) { + throw new Error("Project name must be a valid npm package name."); + } + } +} + +export class TargetDefinitionCollection extends DefinitionCollection< + TargetDefinition +> { + constructor( + initial?: Record, + listener?: DefinitionCollectionListener + ) { + super(initial, listener); + } + + add( + definition: { + name: string; + } & TargetDefinition + ): TargetDefinition { + if (this.has(definition.name)) { + throw new Error("Target name already exists."); + } + this._validateName(definition.name); + + const target = { + builder: definition.builder, + options: definition.options, + configurations: definition.configurations, + defaultConfiguration: definition.defaultConfiguration, + }; + + super.set(definition.name, target); + + return target; + } + + set(name: string, value: TargetDefinition): this { + this._validateName(name); + + super.set(name, value); + + return this; + } + + private _validateName(name: string): void { + if (typeof name !== "string") { + throw new TypeError("Target name must be a string."); + } + } +} diff --git a/packages/schematic-utils/src/workspace/definitions_spec.ts b/packages/schematic-utils/src/workspace/definitions_spec.ts new file mode 100644 index 0000000..0ace5a9 --- /dev/null +++ b/packages/schematic-utils/src/workspace/definitions_spec.ts @@ -0,0 +1,350 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + ProjectDefinition, + ProjectDefinitionCollection, + TargetDefinition, + TargetDefinitionCollection, +} from "./definitions"; + +describe("ProjectDefinitionCollection", () => { + it("can be created without initial values or a listener", () => { + const collection = new ProjectDefinitionCollection(); + + expect(collection.size).toBe(0); + }); + + it("can be created with initial values", () => { + const initial = { + "my-app": { + root: "my-app", + extensions: {}, + targets: new TargetDefinitionCollection(), + }, + "my-lib": { + root: "my-lib", + extensions: {}, + targets: new TargetDefinitionCollection(), + }, + }; + + initial["my-app"].targets.add({ + name: "build", + builder: "build-builder", + }); + + const collection = new ProjectDefinitionCollection(initial); + + expect(collection.size).toBe(2); + + const app = collection.get("my-app"); + expect(app).not.toBeUndefined(); + if (app) { + expect(app.root).toBe("my-app"); + expect(app.extensions).toEqual({}); + expect(app.targets.size).toBe(1); + expect(app.targets.get("build")).not.toBeUndefined(); + } + const lib = collection.get("my-lib"); + expect(lib).not.toBeUndefined(); + if (lib) { + expect(lib.root).toBe("my-lib"); + expect(lib.extensions).toEqual({}); + expect(lib.targets).toBeTruthy(); + } + }); + + it("can be created with a listener", () => { + const listener = () => { + fail("listener should not execute on initialization"); + }; + + const collection = new ProjectDefinitionCollection(undefined, listener); + + expect(collection.size).toBe(0); + }); + + it("can be created with initial values and a listener", () => { + const initial = { + "my-app": { + root: "src/my-app", + extensions: {}, + targets: new TargetDefinitionCollection(), + }, + "my-lib": { + root: "src/my-lib", + extensions: {}, + targets: new TargetDefinitionCollection(), + }, + }; + + initial["my-app"].targets.add({ + name: "build", + builder: "build-builder", + }); + + const listener = () => { + fail("listener should not execute on initialization"); + }; + + const collection = new ProjectDefinitionCollection(initial, listener); + + expect(collection.size).toBe(2); + + const app = collection.get("my-app"); + expect(app).not.toBeUndefined(); + if (app) { + expect(app.root).toBe("src/my-app"); + expect(app.extensions).toEqual({}); + expect(app.targets.size).toBe(1); + expect(app.targets.get("build")).not.toBeUndefined(); + } + const lib = collection.get("my-lib"); + expect(lib).not.toBeUndefined(); + if (lib) { + expect(lib.root).toBe("src/my-lib"); + expect(lib.extensions).toEqual({}); + expect(lib.targets).toBeTruthy(); + } + }); + + it("listens to an addition via set", () => { + const listener = (name: string, action: string) => { + expect(name).toBe("my-app"); + expect(action).toBe("add"); + }; + + const collection = new ProjectDefinitionCollection(undefined, listener); + + collection.set("my-app", { + root: "src/my-app", + extensions: {}, + targets: new TargetDefinitionCollection(), + }); + }); + + it("listens to an addition via add", () => { + const listener = ( + name: string, + action: string, + value?: ProjectDefinition + ) => { + expect(name).toBe("my-app"); + expect(action).toBe("add"); + expect(value).not.toBeUndefined(); + if (value) { + expect(value.root).toBe("src/my-app"); + } + }; + + const collection = new ProjectDefinitionCollection(undefined, listener); + + collection.add({ + name: "my-app", + root: "src/my-app", + }); + }); + + it("listens to a removal", () => { + const initial = { + "my-app": { + root: "src/my-app", + extensions: {}, + targets: new TargetDefinitionCollection(), + }, + }; + + const listener = (name: string, action: string) => { + expect(name).toBe("my-app"); + expect(action).toBe("remove"); + }; + + const collection = new ProjectDefinitionCollection(initial, listener); + + collection.delete("my-app"); + }); + + it("listens to a replacement", () => { + const initial = { + "my-app": { + root: "src/my-app", + extensions: {}, + targets: new TargetDefinitionCollection(), + }, + }; + + const listener = ( + name: string, + action: string, + newValue?: ProjectDefinition, + oldValue?: ProjectDefinition + ) => { + expect(name).toBe("my-app"); + expect(action).toBe("replace"); + expect(newValue).not.toBeUndefined(); + if (newValue) { + expect(newValue.root).toBe("src/my-app2"); + } + expect(oldValue).not.toBeUndefined(); + if (oldValue) { + expect(oldValue.root).toBe("src/my-app"); + } + }; + + const collection = new ProjectDefinitionCollection(initial, listener); + + collection.set("my-app", { + root: "src/my-app2", + extensions: {}, + targets: new TargetDefinitionCollection(), + }); + }); +}); + +describe("TargetDefinitionCollection", () => { + it("can be created without initial values or a listener", () => { + const collection = new TargetDefinitionCollection(); + + expect(collection.size).toBe(0); + }); + + it("can be created with initial values", () => { + const initial = { + build: { builder: "builder:build" }, + test: { builder: "builder:test" }, + }; + + const collection = new TargetDefinitionCollection(initial); + + expect(collection.size).toBe(2); + + const build = collection.get("build"); + expect(build).not.toBeUndefined(); + if (build) { + expect(build.builder).toBe("builder:build"); + } + const test = collection.get("test"); + expect(test).not.toBeUndefined(); + if (test) { + expect(test.builder).toBe("builder:test"); + } + }); + + it("can be created with a listener", () => { + const listener = () => { + fail("listener should not execute on initialization"); + }; + + const collection = new TargetDefinitionCollection(undefined, listener); + + expect(collection.size).toBe(0); + }); + + it("can be created with initial values and a listener", () => { + const initial = { + build: { builder: "builder:build" }, + test: { builder: "builder:test" }, + }; + + const listener = () => { + fail("listener should not execute on initialization"); + }; + + const collection = new TargetDefinitionCollection(initial, listener); + + expect(collection.size).toBe(2); + + const build = collection.get("build"); + expect(build).not.toBeUndefined(); + if (build) { + expect(build.builder).toBe("builder:build"); + } + const test = collection.get("test"); + expect(test).not.toBeUndefined(); + if (test) { + expect(test.builder).toBe("builder:test"); + } + }); + + it("listens to an addition via set", () => { + const listener = (name: string, action: string) => { + expect(name).toBe("build"); + expect(action).toBe("add"); + }; + + const collection = new TargetDefinitionCollection(undefined, listener); + + collection.set("build", { builder: "builder:build" }); + }); + + it("listens to an addition via add", () => { + const listener = ( + name: string, + action: string, + value?: TargetDefinition + ) => { + expect(name).toBe("build"); + expect(action).toBe("add"); + expect(value).not.toBeUndefined(); + if (value) { + expect(value.builder).toBe("builder:build"); + } + }; + + const collection = new TargetDefinitionCollection(undefined, listener); + + collection.add({ + name: "build", + builder: "builder:build", + }); + }); + + it("listens to a removal", () => { + const initial = { + build: { builder: "builder:build" }, + }; + + const listener = (name: string, action: string) => { + expect(name).toBe("build"); + expect(action).toBe("remove"); + }; + + const collection = new TargetDefinitionCollection(initial, listener); + + collection.delete("build"); + }); + + it("listens to a replacement", () => { + const initial = { + build: { builder: "builder:build" }, + }; + + const listener = ( + name: string, + action: string, + newValue?: TargetDefinition, + oldValue?: TargetDefinition + ) => { + expect(name).toBe("build"); + expect(action).toBe("replace"); + expect(newValue).not.toBeUndefined(); + if (newValue) { + expect(newValue.builder).toBe("builder:test"); + } + expect(oldValue).not.toBeUndefined(); + if (oldValue) { + expect(oldValue.builder).toBe("builder:build"); + } + }; + + const collection = new TargetDefinitionCollection(initial, listener); + + collection.set("build", { builder: "builder:test" }); + }); +}); diff --git a/packages/schematic-utils/src/workspace/host.ts b/packages/schematic-utils/src/workspace/host.ts new file mode 100644 index 0000000..ca08b55 --- /dev/null +++ b/packages/schematic-utils/src/workspace/host.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { normalize, virtualFs } from "../virtual-fs"; + +export interface WorkspaceHost { + readFile(path: string): Promise; + writeFile(path: string, data: string): Promise; + + isDirectory(path: string): Promise; + isFile(path: string): Promise; + + // Potential future additions + // readDirectory?(path: string): Promise; +} + +export function createWorkspaceHost(host: virtualFs.Host): WorkspaceHost { + const workspaceHost: WorkspaceHost = { + async readFile(path: string): Promise { + const data = await host.read(normalize(path)).toPromise(); + + return virtualFs.fileBufferToString(data); + }, + async writeFile(path: string, data: string): Promise { + return host + .write(normalize(path), virtualFs.stringToFileBuffer(data)) + .toPromise(); + }, + async isDirectory(path: string): Promise { + try { + return await host.isDirectory(normalize(path)).toPromise(); + } catch { + // some hosts throw if path does not exist + return false; + } + }, + async isFile(path: string): Promise { + try { + return await host.isFile(normalize(path)).toPromise(); + } catch { + // some hosts throw if path does not exist + return false; + } + }, + }; + + return workspaceHost; +} diff --git a/packages/schematic-utils/src/workspace/host_spec.ts b/packages/schematic-utils/src/workspace/host_spec.ts new file mode 100644 index 0000000..5ad38b4 --- /dev/null +++ b/packages/schematic-utils/src/workspace/host_spec.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { test } from "../virtual-fs/host"; +import { WorkspaceHost, createWorkspaceHost } from "./host"; + +describe("createWorkspaceHost", () => { + let testHost: test.TestHost; + let workspaceHost: WorkspaceHost; + + beforeEach(() => { + testHost = new test.TestHost({ + "abc.txt": "abcdefg", + "foo/bar.json": "{}", + }); + workspaceHost = createWorkspaceHost(testHost); + }); + + it("supports isFile", async () => { + expect(await workspaceHost.isFile("abc.txt")).toBeTruthy(); + expect(await workspaceHost.isFile("foo/bar.json")).toBeTruthy(); + expect(await workspaceHost.isFile("foo\\bar.json")).toBeTruthy(); + + expect(await workspaceHost.isFile("foo")).toBeFalsy(); + expect(await workspaceHost.isFile("not.there")).toBeFalsy(); + }); + + it("supports isDirectory", async () => { + expect(await workspaceHost.isDirectory("foo")).toBeTruthy(); + expect(await workspaceHost.isDirectory("foo/")).toBeTruthy(); + expect(await workspaceHost.isDirectory("foo\\")).toBeTruthy(); + + expect(await workspaceHost.isDirectory("abc.txt")).toBeFalsy(); + expect(await workspaceHost.isDirectory("foo/bar.json")).toBeFalsy(); + expect(await workspaceHost.isDirectory("not.there")).toBeFalsy(); + }); + + it("supports readFile", async () => { + expect(await workspaceHost.readFile("abc.txt")).toBe("abcdefg"); + }); + + it("supports writeFile", async () => { + await workspaceHost.writeFile("newfile", "baz"); + expect(testHost.files.sort() as string[]).toEqual([ + "/abc.txt", + "/foo/bar.json", + "/newfile", + ]); + + expect(testHost.$read("newfile")).toBe("baz"); + }); +}); diff --git a/packages/schematic-utils/src/workspace/index.ts b/packages/schematic-utils/src/workspace/index.ts new file mode 100644 index 0000000..479d32c --- /dev/null +++ b/packages/schematic-utils/src/workspace/index.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from "./definitions"; +export { WorkspaceHost, createWorkspaceHost } from "./host"; +export { WorkspaceFormat, readWorkspace, writeWorkspace } from "./core"; diff --git a/packages/schematic-utils/src/workspace/json/metadata.ts b/packages/schematic-utils/src/workspace/json/metadata.ts new file mode 100644 index 0000000..b10ef47 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/metadata.ts @@ -0,0 +1,104 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + JsonAstArray, + JsonAstKeyValue, + JsonAstNode, + JsonAstObject, + JsonValue, +} from "../../json"; +import { + ProjectDefinition, + TargetDefinition, + WorkspaceDefinition, +} from "../definitions"; + +export const JsonWorkspaceSymbol = Symbol.for("@angular/core:workspace-json"); + +export interface JsonWorkspaceDefinition extends WorkspaceDefinition { + [JsonWorkspaceSymbol]: JsonWorkspaceMetadata; +} + +interface ChangeValues { + json: JsonValue; + project: ProjectDefinition; + target: TargetDefinition; + projectcollection: Iterable<[string, ProjectDefinition]>; + targetcollection: Iterable<[string, TargetDefinition]>; +} + +export interface JsonChange { + // core collections can only be added as they are managed directly by _Collection_ objects + op: T extends "json" | "project" | "target" + ? "add" | "remove" | "replace" + : "add"; + path: string; + node: JsonAstNode | JsonAstKeyValue; + value?: ChangeValues[T]; + type: T; +} + +export class JsonWorkspaceMetadata { + readonly changes: JsonChange[] = []; + + constructor( + readonly filePath: string, + readonly ast: JsonAstObject, + readonly raw: string + ) {} + + get hasChanges(): boolean { + return this.changes.length > 0; + } + + get changeCount(): number { + return this.changes.length; + } + + findChangesForPath(path: string): JsonChange[] { + return this.changes.filter((c) => c.path === path); + } + + addChange( + op: "add" | "remove" | "replace", + path: string, + node: JsonAstArray | JsonAstObject | JsonAstKeyValue, + value?: ChangeValues[T], + type?: T + ): void { + // Remove redundant operations + if (op === "remove" || op === "replace") { + for (let i = this.changes.length - 1; i >= 0; --i) { + const currentPath = this.changes[i].path; + if (currentPath === path || currentPath.startsWith(path + "/")) { + if ( + op === "replace" && + currentPath === path && + this.changes[i].op === "add" + ) { + op = "add"; + } + this.changes.splice(i, 1); + } + } + } + + this.changes.push({ + op, + path, + node, + value, + type: op === "remove" || !type ? "json" : type, + }); + } + + reset(): void { + this.changes.length = 0; + } +} diff --git a/packages/schematic-utils/src/workspace/json/reader.spec.ts b/packages/schematic-utils/src/workspace/json/reader.spec.ts new file mode 100644 index 0000000..00ac6ff --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/reader.spec.ts @@ -0,0 +1,892 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { readFileSync } from "fs"; +import { JsonObject } from "../../json"; +import { + TargetDefinitionCollection, + WorkspaceDefinition, +} from "../definitions"; +import { + JsonWorkspaceDefinition, + JsonWorkspaceMetadata, + JsonWorkspaceSymbol, +} from "./metadata"; +import { readJsonWorkspace } from "./reader"; +import { stripIndent } from "../../utils/runtime"; + +const basicFile = stripIndent` +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-foo": { + "is": ["good", "great", "awesome"] + }, + "x-bar": 5, +}`; + +const representativeFile = readFileSync( + require.resolve(__dirname + "/test/angular.json"), + "utf8" +); + +function createTestHost( + content: string, + onWrite?: (path: string, data: string) => void +) { + return { + async readFile() { + return content; + }, + async writeFile(path: string, data: string) { + if (onWrite) { + onWrite(path, data); + } + }, + async isFile() { + return true; + }, + async isDirectory() { + return true; + }, + }; +} + +function getMetadata(workspace: WorkspaceDefinition): JsonWorkspaceMetadata { + const metadata = (workspace as JsonWorkspaceDefinition)[JsonWorkspaceSymbol]; + expect(metadata).toBeDefined(); + + return metadata; +} + +// @TODO tests don't pass when using jest +describe.skip("readJsonWorkpace Parsing", () => { + it("parses a basic file", async () => { + const host = createTestHost(basicFile); + + const workspace = await readJsonWorkspace("basic", host); + + expect(workspace.projects.size).toBe(0); + expect(workspace.extensions["x-bar"]).toBe(5); + expect(workspace.extensions["schematics"]).toEqual({ + "@angular/schematics:component": { prefix: "abc" }, + }); + }); + + it("parses a representative file", async () => { + const host = createTestHost(representativeFile); + + const workspace = await readJsonWorkspace("", host); + + expect(Array.from(workspace.projects.keys())).toEqual([ + "my-app", + "my-app-e2e", + ]); + expect(workspace.extensions["newProjectRoot"]).toBe("projects"); + expect(workspace.extensions["defaultProject"]).toBe("my-app"); + expect(workspace.projects.get("my-app")!.extensions["schematics"]).toEqual({ + "@schematics/angular:component": { styleext: "scss" }, + }); + expect(workspace.projects.get("my-app")!.root).toBe(""); + expect( + workspace.projects.get("my-app")!.targets.get("build")!.builder + ).toBe("@angular-devkit/build-angular:browser"); + }); + + it(`doesn't remove falsy values when using the spread operator`, async () => { + const host = createTestHost(representativeFile); + const workspace = await readJsonWorkspace("", host); + const prodConfig = workspace.projects.get("my-app")!.targets.get("build")! + .configurations!.production!; + expect({ ...prodConfig }).toEqual(prodConfig); + }); + + it("parses extensions only into extensions object", async () => { + const host = createTestHost(representativeFile); + + const workspace = await readJsonWorkspace("", host); + + expect(workspace.extensions["newProjectRoot"]).toBe("projects"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((workspace as any)["newProjectRoot"]).toBeUndefined(); + + expect(workspace.projects.get("my-app")!.extensions["schematics"]).toEqual({ + "@schematics/angular:component": { styleext: "scss" }, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect( + (workspace.projects.get("my-app") as any)["schematics"] + ).toBeUndefined(); + }); + + it("errors on invalid version", async () => { + const host = createTestHost(stripIndent` + { + "version": 99, + // Comment + "x-bar": 5, + } + `); + + try { + await readJsonWorkspace("", host); + fail(); + } catch (e) { + expect(e.message).toContain("Invalid format version detected"); + } + }); + + it("errors on missing version", async () => { + const host = createTestHost(stripIndent` + { + // Comment + "x-bar": 5, + } + `); + + try { + await readJsonWorkspace("", host); + fail(); + } catch (e) { + expect(e.message).toContain("version specifier not found"); + } + }); +}); + +describe("JSON WorkspaceDefinition Tracks Workspace Changes", () => { + it("tracks basic extension additions", async () => { + const host = createTestHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + workspace.extensions["x-baz"] = 101; + expect(workspace.extensions["x-baz"]).toBe(101); + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(1); + + const change = metadata.findChangesForPath("/x-baz")[0]; + expect(change).not.toBeUndefined(); + if (change) { + expect(change.op).toBe("add"); + expect(change.value).toBe(101); + } + }); + + it("tracks complex extension additions", async () => { + const host = createTestHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + const value = { a: 1, b: 2, c: { d: "abc" } }; + workspace.extensions["x-baz"] = value; + expect(workspace.extensions["x-baz"]).toEqual({ + a: 1, + b: 2, + c: { d: "abc" }, + }); + + value.b = 3; + expect(workspace.extensions["x-baz"]).toEqual({ + a: 1, + b: 3, + c: { d: "abc" }, + }); + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(1); + + const change = metadata.findChangesForPath("/x-baz")[0]; + expect(change).not.toBeUndefined(); + if (change) { + expect(change.op).toBe("add"); + expect(change.value).toEqual({ a: 1, b: 3, c: { d: "abc" } }); + } + }); + + it("tracks complex extension additions with Object.assign target", async () => { + const host = createTestHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + const value = { a: 1, b: 2, c: { d: "abc" } }; + workspace.extensions["x-baz"] = value; + expect(workspace.extensions["x-baz"]).toEqual({ + a: 1, + b: 2, + c: { d: "abc" }, + }); + + Object.assign(value, { x: 9, y: 8, z: 7 }); + expect(workspace.extensions["x-baz"]).toEqual({ + a: 1, + b: 2, + c: { d: "abc" }, + x: 9, + y: 8, + z: 7, + }); + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(1); + + const change = metadata.findChangesForPath("/x-baz")[0]; + expect(change).not.toBeUndefined(); + if (change) { + expect(change.op).toBe("add"); + expect(change.value).toEqual({ + a: 1, + b: 2, + c: { d: "abc" }, + x: 9, + y: 8, + z: 7, + }); + } + }); + + it("tracks complex extension additions with Object.assign return", async () => { + const host = createTestHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + const value = { a: 1, b: 2, c: { d: "abc" } }; + workspace.extensions["x-baz"] = value; + expect(workspace.extensions["x-baz"]).toEqual({ + a: 1, + b: 2, + c: { d: "abc" }, + }); + + workspace.extensions["x-baz"] = Object.assign(value, { x: 9, y: 8, z: 7 }); + expect(workspace.extensions["x-baz"]).toEqual({ + a: 1, + b: 2, + c: { d: "abc" }, + x: 9, + y: 8, + z: 7, + }); + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(1); + + const change = metadata.findChangesForPath("/x-baz")[0]; + expect(change).not.toBeUndefined(); + if (change) { + expect(change.op).toBe("add"); + expect(change.value).toEqual({ + a: 1, + b: 2, + c: { d: "abc" }, + x: 9, + y: 8, + z: 7, + }); + } + }); + + it("tracks complex extension additions with spread operator", async () => { + const host = createTestHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + const value = { a: 1, b: 2, c: { d: "abc" } }; + workspace.extensions["x-baz"] = value; + expect(workspace.extensions["x-baz"]).toEqual({ + a: 1, + b: 2, + c: { d: "abc" }, + }); + + workspace.extensions["x-baz"] = { ...value, ...{ x: 9, y: 8 }, z: 7 }; + expect(workspace.extensions["x-baz"]).toEqual({ + a: 1, + b: 2, + c: { d: "abc" }, + x: 9, + y: 8, + z: 7, + }); + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(1); + + const change = metadata.findChangesForPath("/x-baz")[0]; + expect(change).not.toBeUndefined(); + if (change) { + expect(change.op).toBe("add"); + expect(change.value).toEqual({ + a: 1, + b: 2, + c: { d: "abc" }, + x: 9, + y: 8, + z: 7, + }); + } + }); + + it("tracks modifying an existing extension object with spread operator", async () => { + const host = createTestHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + workspace.extensions["x-foo"] = { + ...(workspace.extensions["x-foo"] as JsonObject), + ...{ x: 9, y: 8 }, + z: 7, + }; + expect(workspace.extensions["x-foo"]).toEqual({ + is: ["good", "great", "awesome"], + x: 9, + y: 8, + z: 7, + }); + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(1); + + const change = metadata.findChangesForPath("/x-foo")[0]; + expect(change).not.toBeUndefined(); + if (change) { + expect(change.op).toBe("replace"); + expect(change.value).toEqual({ + is: ["good", "great", "awesome"], + x: 9, + y: 8, + z: 7, + }); + } + }); + + it("tracks modifying an existing extension object with Object.assign target", async () => { + const host = createTestHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + Object.assign(workspace.extensions["x-foo"], { x: 9, y: 8 }, { z: 7 }); + expect(workspace.extensions["x-foo"]).toEqual({ + is: ["good", "great", "awesome"], + x: 9, + y: 8, + z: 7, + }); + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(3); + + let change = metadata.findChangesForPath("/x-foo/x")[0]; + expect(change).not.toBeUndefined(); + if (change) { + expect(change.op).toBe("add"); + expect(change.value).toEqual(9); + } + + change = metadata.findChangesForPath("/x-foo/y")[0]; + expect(change).not.toBeUndefined(); + if (change) { + expect(change.op).toBe("add"); + expect(change.value).toEqual(8); + } + + change = metadata.findChangesForPath("/x-foo/z")[0]; + expect(change).not.toBeUndefined(); + if (change) { + expect(change.op).toBe("add"); + expect(change.value).toEqual(7); + } + }); + + it("tracks modifying an existing extension object with Object.assign return", async () => { + const host = createTestHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + workspace.extensions["x-foo"] = Object.assign( + workspace.extensions["x-foo"], + { x: 9, y: 8 }, + { z: 7 } + ); + expect(workspace.extensions["x-foo"]).toEqual({ + is: ["good", "great", "awesome"], + x: 9, + y: 8, + z: 7, + }); + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(3); + + let change = metadata.findChangesForPath("/x-foo/x")[0]; + expect(change).not.toBeUndefined(); + if (change) { + expect(change.op).toBe("add"); + expect(change.value).toEqual(9); + } + + change = metadata.findChangesForPath("/x-foo/y")[0]; + expect(change).not.toBeUndefined(); + if (change) { + expect(change.op).toBe("add"); + expect(change.value).toEqual(8); + } + + change = metadata.findChangesForPath("/x-foo/z")[0]; + expect(change).not.toBeUndefined(); + if (change) { + expect(change.op).toBe("add"); + expect(change.value).toEqual(7); + } + }); + + it("tracks add and remove of an existing extension object", async () => { + const host = createTestHost(basicFile); + + const workspace = await readJsonWorkspace("basic", host); + + workspace.extensions["schematics2"] = workspace.extensions["schematics"]; + + expect(workspace.extensions["schematics"]).toEqual({ + "@angular/schematics:component": { prefix: "abc" }, + }); + expect(workspace.extensions["schematics2"]).toEqual({ + "@angular/schematics:component": { prefix: "abc" }, + }); + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(1); + + let change = metadata.findChangesForPath("/schematics2")[0]; + expect(change).not.toBeUndefined(); + if (change) { + expect(change.op).toBe("add"); + expect(change.value).toEqual({ + "@angular/schematics:component": { prefix: "abc" }, + }); + } + + workspace.extensions["schematics"] = undefined; + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(2); + + change = metadata.findChangesForPath("/schematics")[0]; + expect(change).not.toBeUndefined(); + if (change) { + expect(change.op).toBe("remove"); + } + }); + + it("tracks moving and modifying an existing extension object", async () => { + const host = createTestHost(basicFile); + + const workspace = await readJsonWorkspace("basic", host); + + workspace.extensions["schematics2"] = workspace.extensions["schematics"]; + + expect(workspace.extensions["schematics"]).toEqual({ + "@angular/schematics:component": { prefix: "abc" }, + }); + expect(workspace.extensions["schematics2"]).toEqual({ + "@angular/schematics:component": { prefix: "abc" }, + }); + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(1); + + let change = metadata.findChangesForPath("/schematics2")[0]; + expect(change).not.toBeUndefined(); + if (change) { + expect(change.op).toBe("add"); + expect(change.value).toEqual({ + "@angular/schematics:component": { prefix: "abc" }, + }); + } + + workspace.extensions["schematics"] = undefined; + (workspace.extensions["schematics2"] as JsonObject)[ + "@angular/schematics:component" + ] = { + prefix: "xyz", + }; + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(2); + + change = metadata.findChangesForPath("/schematics")[0]; + expect(change).not.toBeUndefined(); + if (change) { + expect(change.op).toBe("remove"); + } + + change = metadata.findChangesForPath("/schematics2")[0]; + expect(change).not.toBeUndefined(); + if (change) { + expect(change.op).toBe("add"); + expect(change.value).toEqual({ + "@angular/schematics:component": { prefix: "xyz" }, + }); + } + }); + + it("tracks copying and modifying an existing extension object", async () => { + const host = createTestHost(basicFile); + + const workspace = await readJsonWorkspace("basic", host); + + workspace.extensions["schematics2"] = workspace.extensions["schematics"]; + + expect(workspace.extensions["schematics"]).toEqual({ + "@angular/schematics:component": { prefix: "abc" }, + }); + expect(workspace.extensions["schematics2"]).toEqual({ + "@angular/schematics:component": { prefix: "abc" }, + }); + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(1); + + let change = metadata.findChangesForPath("/schematics2")[0]; + expect(change).not.toBeUndefined(); + if (change) { + expect(change.op).toBe("add"); + expect(change.value).toEqual({ + "@angular/schematics:component": { prefix: "abc" }, + }); + } + + (workspace.extensions["schematics2"] as JsonObject)[ + "@angular/schematics:component" + ] = { + prefix: "xyz", + }; + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(2); + + change = metadata.findChangesForPath( + "/schematics/@angular~1schematics:component" + )[0]; + expect(change).not.toBeUndefined(); + if (change) { + expect(change.op).toBe("replace"); + expect(change.value).toEqual({ prefix: "xyz" }); + } + + change = metadata.findChangesForPath("/schematics2")[0]; + expect(change).not.toBeUndefined(); + if (change) { + expect(change.op).toBe("add"); + expect(change.value).toEqual({ + "@angular/schematics:component": { prefix: "xyz" }, + }); + } + }); + + it("tracks copying, modifying, and removing an existing extension object", async () => { + const host = createTestHost(basicFile); + + const workspace = await readJsonWorkspace("basic", host); + + workspace.extensions["schematics2"] = workspace.extensions["schematics"]; + + expect(workspace.extensions["schematics"]).toEqual({ + "@angular/schematics:component": { prefix: "abc" }, + }); + expect(workspace.extensions["schematics2"]).toEqual({ + "@angular/schematics:component": { prefix: "abc" }, + }); + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(1); + + let change = metadata.findChangesForPath("/schematics2")[0]; + expect(change).not.toBeUndefined(); + if (change) { + expect(change.op).toBe("add"); + expect(change.value).toEqual({ + "@angular/schematics:component": { prefix: "abc" }, + }); + } + + (workspace.extensions["schematics2"] as JsonObject)[ + "@angular/schematics:component" + ] = { + prefix: "xyz", + }; + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(2); + + change = metadata.findChangesForPath( + "/schematics/@angular~1schematics:component" + )[0]; + expect(change).not.toBeUndefined(); + if (change) { + expect(change.op).toBe("replace"); + expect(change.value).toEqual({ prefix: "xyz" }); + } + + change = metadata.findChangesForPath("/schematics2")[0]; + expect(change).not.toBeUndefined(); + if (change) { + expect(change.op).toBe("add"); + expect(change.value).toEqual({ + "@angular/schematics:component": { prefix: "xyz" }, + }); + } + + workspace.extensions["schematics"] = undefined; + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(2); + + change = metadata.findChangesForPath("/schematics")[0]; + expect(change).not.toBeUndefined(); + if (change) { + expect(change.op).toBe("remove"); + } + + const orderedChanges = Array.from(metadata.changes.values()); + change = orderedChanges[1]; + expect(change).not.toBeUndefined(); + if (change) { + expect(change.op).toBe("remove"); + } + }); + + it("tracks project additions with set", async () => { + const host = createTestHost(representativeFile); + + const workspace = await readJsonWorkspace("", host); + + workspace.projects.set("new-app", { + root: "src", + extensions: {}, + targets: new TargetDefinitionCollection(), + }); + + const app = workspace.projects.get("new-app"); + expect(app).not.toBeUndefined(); + if (app) { + expect(app.extensions).toEqual({}); + expect(app.root).toBe("src"); + } + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(1); + + const change = metadata.findChangesForPath("/projects/new-app")[0]; + expect(change).not.toBeUndefined(); + if (change) { + expect(change.op).toBe("add"); + expect(change.value).toEqual(jasmine.objectContaining({ root: "src" })); + } + }); + + it("tracks project additions with add", async () => { + const host = createTestHost(representativeFile); + + const workspace = await readJsonWorkspace("", host); + + workspace.projects.add({ + name: "new-app", + root: "src", + }); + + const app = workspace.projects.get("new-app"); + expect(app).not.toBeUndefined(); + if (app) { + expect(app.extensions).toEqual({}); + expect(app.root).toBe("src"); + } + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(1); + + const change = metadata.findChangesForPath("/projects/new-app")[0]; + expect(change).not.toBeUndefined(); + if (change) { + expect(change.op).toBe("add"); + expect(change.value).toEqual(jasmine.objectContaining({ root: "src" })); + } + }); +}); + +describe("JSON ProjectDefinition Tracks Project Changes", () => { + it("tracks property changes", async () => { + const host = createTestHost(representativeFile); + + const workspace = await readJsonWorkspace("", host); + + const project = workspace.projects.get("my-app"); + expect(project).not.toBeUndefined(); + if (!project) { + return; + } + + project.prefix = "bar"; + expect(project.prefix).toBe("bar"); + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(1); + + const change = metadata.findChangesForPath("/projects/my-app/prefix")[0]; + expect(change).not.toBeUndefined(); + if (change) { + expect(change.op).toBe("replace"); + expect(change.value).toBe("bar"); + } + }); + + it("tracks property additions", async () => { + const host = createTestHost(representativeFile); + + const workspace = await readJsonWorkspace("", host); + + const project = workspace.projects.get("my-app"); + expect(project).not.toBeUndefined(); + if (!project) { + return; + } + + expect(project.sourceRoot).toBeUndefined(); + project.sourceRoot = "xyz"; + expect(project.sourceRoot).toBe("xyz"); + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(1); + + const change = metadata.findChangesForPath( + "/projects/my-app/sourceRoot" + )[0]; + expect(change).not.toBeUndefined(); + if (change) { + expect(change.op).toBe("add"); + expect(change.value).toBe("xyz"); + } + }); + + it("tracks extension additions", async () => { + const host = createTestHost(representativeFile); + + const workspace = await readJsonWorkspace("", host); + + const project = workspace.projects.get("my-app"); + expect(project).not.toBeUndefined(); + if (!project) { + return; + } + + expect(project.extensions["abc-option"]).toBeUndefined(); + project.extensions["abc-option"] = "valueA"; + expect(project.extensions["abc-option"]).toBe("valueA"); + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(1); + + const change = metadata.findChangesForPath( + "/projects/my-app/abc-option" + )[0]; + expect(change).not.toBeUndefined(); + if (change) { + expect(change.op).toBe("add"); + expect(change.value).toBe("valueA"); + } + }); + + it("tracks target additions with no original target collection", async () => { + const original = stripIndent` + { + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "projects": { + "p1": { + "root": "p1-root" + } + }, + "x-foo": { + "is": ["good", "great", "awesome"] + }, + "x-bar": 5, + } + `; + const host = createTestHost(original); + + const workspace = await readJsonWorkspace("", host); + + const project = workspace.projects.get("p1"); + expect(project).not.toBeUndefined(); + if (!project) { + return; + } + + project.targets.add({ + name: "t1", + builder: "t1-builder", + }); + + const metadata = getMetadata(workspace); + + expect(metadata.hasChanges).toBeTruthy(); + expect(metadata.changeCount).toBe(1); + + const change = metadata.findChangesForPath("/projects/p1/targets")[0]; + expect(change).not.toBeUndefined(); + if (change) { + expect(change.op).toBe("add"); + expect(change.type).toBe("targetcollection"); + } + }); +}); diff --git a/packages/schematic-utils/src/workspace/json/reader.ts b/packages/schematic-utils/src/workspace/json/reader.ts new file mode 100644 index 0000000..a593d56 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/reader.ts @@ -0,0 +1,363 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + JsonAstKeyValue, + JsonAstNode, + JsonAstObject, + JsonParseMode, + JsonValue, + parseJsonAst, +} from "../../json"; +import { + DefinitionCollectionListener, + ProjectDefinition, + ProjectDefinitionCollection, + TargetDefinition, + TargetDefinitionCollection, + WorkspaceDefinition, +} from "../definitions"; +import { WorkspaceHost } from "../host"; +import { JsonWorkspaceMetadata, JsonWorkspaceSymbol } from "./metadata"; +import { createVirtualAstObject, escapeKey } from "./utilities"; + +interface ParserContext { + readonly host: WorkspaceHost; + readonly metadata: JsonWorkspaceMetadata; + readonly trackChanges: boolean; + error(message: string, node: JsonAstNode | JsonAstKeyValue): void; + warn(message: string, node: JsonAstNode | JsonAstKeyValue): void; +} + +export async function readJsonWorkspace( + path: string, + host: WorkspaceHost +): Promise { + const raw = await host.readFile(path); + + if (raw === undefined) { + throw new Error("Unable to read workspace file."); + } + + const ast = parseJsonAst(raw, JsonParseMode.Loose); + if (ast.kind !== "object") { + throw new Error("Invalid workspace file - expected JSON object."); + } + + // Version check + const versionNode = ast.properties.find( + (pair) => pair.key.value === "version" + ); + if (!versionNode) { + throw new Error("Unknown format - version specifier not found."); + } + const formatVersion = versionNode.value.value; + if (formatVersion !== 1) { + throw new Error( + `Invalid format version detected - Expected:[ 1 ] Found: [ ${formatVersion} ]` + ); + } + + const context: ParserContext = { + host, + metadata: new JsonWorkspaceMetadata(path, ast, raw), + trackChanges: true, + error(message, _node) { + // TODO: Diagnostic reporting support + throw new Error(message); + }, + warn(_message, _node) { + // TODO: Diagnostic reporting support + }, + }; + + const workspace = parseWorkspace(ast, context); + + return workspace; +} + +const specialWorkspaceExtensions = [ + "cli", + "defaultProject", + "newProjectRoot", + "schematics", +]; + +const specialProjectExtensions = ["cli", "schematics", "projectType"]; + +function parseWorkspace( + workspaceNode: JsonAstObject, + context: ParserContext +): WorkspaceDefinition { + const jsonMetadata = context.metadata; + let projects; + let projectsNode: JsonAstObject | undefined; + let extensions: Record | undefined; + if (!context.trackChanges) { + extensions = Object.create(null); + } + + for (const { key, value } of workspaceNode.properties) { + const name = key.value; + + if (name === "$schema" || name === "version") { + // skip + } else if (name === "projects") { + if (value.kind !== "object") { + context.error( + 'Invalid "projects" field found; expected an object.', + value + ); + continue; + } + + projectsNode = value; + projects = parseProjectsObject(value, context); + } else { + if ( + !specialWorkspaceExtensions.includes(name) && + !/^[a-z]{1,3}-.*/.test(name) + ) { + context.warn(`Project extension with invalid name found.`, key); + } + if (extensions) { + extensions[name] = value.value; + } + } + } + + let collectionListener: + | DefinitionCollectionListener + | undefined; + if (context.trackChanges && projectsNode) { + const parentNode = projectsNode; + collectionListener = (name, action, newValue) => { + jsonMetadata.addChange( + action, + `/projects/${escapeKey(name)}`, + parentNode, + newValue, + "project" + ); + }; + } + + const projectCollection = new ProjectDefinitionCollection( + projects, + collectionListener + ); + + return { + [JsonWorkspaceSymbol]: jsonMetadata, + projects: projectCollection, + // If not tracking changes the `extensions` variable will contain the parsed + // values. Otherwise the extensions are tracked via a virtual AST object. + extensions: + extensions || + createVirtualAstObject(workspaceNode, { + exclude: ["$schema", "version", "projects"], + listener(op, path, node, value) { + jsonMetadata.addChange(op, path, node, value); + }, + }), + } as WorkspaceDefinition; +} + +function parseProjectsObject( + projectsNode: JsonAstObject, + context: ParserContext +): Record { + const projects: Record = Object.create(null); + + for (const { key, value } of projectsNode.properties) { + if (value.kind !== "object") { + context.warn( + "Skipping invalid project value; expected an object.", + value + ); + continue; + } + + const name = key.value; + projects[name] = parseProject(name, value, context); + } + + return projects; +} + +function parseProject( + projectName: string, + projectNode: JsonAstObject, + context: ParserContext +): ProjectDefinition { + const jsonMetadata = context.metadata; + let targets; + let targetsNode: JsonAstObject | undefined; + let extensions: Record | undefined; + let properties: Record<"root" | "sourceRoot" | "prefix", string> | undefined; + if (!context.trackChanges) { + // If not tracking changes, the parser will store the values directly in standard objects + extensions = Object.create(null); + properties = Object.create(null); + } + + for (const { key, value } of projectNode.properties) { + const name = key.value; + switch (name) { + case "targets": + case "architect": + if (value.kind !== "object") { + context.error( + `Invalid "${name}" field found; expected an object.`, + value + ); + break; + } + targetsNode = value; + targets = parseTargetsObject(projectName, value, context); + break; + case "prefix": + case "root": + case "sourceRoot": + if (value.kind !== "string") { + context.warn(`Project property "${name}" should be a string.`, value); + } + if (properties) { + properties[name] = value.value as string; + } + break; + default: + if ( + !specialProjectExtensions.includes(name) && + !/^[a-z]{1,3}-.*/.test(name) + ) { + context.warn(`Project extension with invalid name found.`, key); + } + if (extensions) { + extensions[name] = value.value; + } + break; + } + } + + let collectionListener: + | DefinitionCollectionListener + | undefined; + if (context.trackChanges) { + if (targetsNode) { + const parentNode = targetsNode; + collectionListener = (name, action, newValue) => { + jsonMetadata.addChange( + action, + `/projects/${projectName}/targets/${escapeKey(name)}`, + parentNode, + newValue, + "target" + ); + }; + } else { + let added = false; + collectionListener = (_name, action, _new, _old, collection) => { + if (added || action !== "add") { + return; + } + + jsonMetadata.addChange( + "add", + `/projects/${projectName}/targets`, + projectNode, + collection, + "targetcollection" + ); + added = true; + }; + } + } + + const base = { + targets: new TargetDefinitionCollection(targets, collectionListener), + // If not tracking changes the `extensions` variable will contain the parsed + // values. Otherwise the extensions are tracked via a virtual AST object. + extensions: + extensions || + createVirtualAstObject(projectNode, { + exclude: ["architect", "prefix", "root", "sourceRoot", "targets"], + listener(op, path, node, value) { + jsonMetadata.addChange( + op, + `/projects/${projectName}${path}`, + node, + value + ); + }, + }), + }; + + let project: ProjectDefinition; + if (context.trackChanges) { + project = createVirtualAstObject(projectNode, { + base, + include: ["prefix", "root", "sourceRoot"], + listener(op, path, node, value) { + jsonMetadata.addChange( + op, + `/projects/${projectName}${path}`, + node, + value + ); + }, + }); + } else { + project = { + ...base, + ...properties, + } as ProjectDefinition; + } + + return project; +} + +function parseTargetsObject( + projectName: string, + targetsNode: JsonAstObject, + context: ParserContext +): Record { + const jsonMetadata = context.metadata; + const targets: Record = Object.create(null); + + for (const { key, value } of targetsNode.properties) { + if (value.kind !== "object") { + context.warn("Skipping invalid target value; expected an object.", value); + continue; + } + + const name = key.value; + if (context.trackChanges) { + targets[name] = createVirtualAstObject(value, { + include: [ + "builder", + "options", + "configurations", + "defaultConfiguration", + ], + listener(op, path, node, value) { + jsonMetadata.addChange( + op, + `/projects/${projectName}/targets/${name}${path}`, + node, + value + ); + }, + }); + } else { + targets[name] = (value.value as unknown) as TargetDefinition; + } + } + + return targets; +} diff --git a/packages/schematic-utils/src/workspace/json/test/angular.json b/packages/schematic-utils/src/workspace/json/test/angular.json new file mode 100644 index 0000000..52f194d --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/angular.json @@ -0,0 +1,121 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "my-app": { + "root": "", + "projectType": "application", + "prefix": "app", + "schematics": { + "@schematics/angular:component": { + "styleext": "scss" + } + }, + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist/my-app", + "index": "src/index.html", + "main": "src/main.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.app.json", + "assets": ["src/favicon.ico", "src/assets"], + "styles": ["src/styles.scss"], + "scripts": [] + }, + "configurations": { + "production": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ], + "optimization": true, + "outputHashing": "all", + "sourceMap": false, + "extractCss": true, + "namedChunks": false, + "aot": true, + "extractLicenses": true, + "vendorChunk": false, + "buildOptimizer": true, + "budgets": [ + { + "type": "initial", + "maximumWarning": "2mb", + "maximumError": "5mb" + } + ] + } + } + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "options": { + "browserTarget": "my-app:build" + }, + "configurations": { + "production": { + "browserTarget": "my-app:build:production" + } + } + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "browserTarget": "my-app:build" + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "main": "src/test.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.spec.json", + "karmaConfig": "src/karma.conf.js", + "styles": ["src/styles.scss"], + "scripts": [], + "assets": ["src/favicon.ico", "src/assets"] + } + }, + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": ["src/tsconfig.app.json", "src/tsconfig.spec.json"], + "exclude": ["**/node_modules/**"] + } + } + } + }, + "my-app-e2e": { + "root": "e2e/", + "projectType": "application", + "prefix": "", + "architect": { + "e2e": { + "builder": "@angular-devkit/build-angular:protractor", + "options": { + "protractorConfig": "e2e/protractor.conf.js", + "devServerTarget": "my-app:serve" + }, + "configurations": { + "production": { + "devServerTarget": "my-app:serve:production" + } + } + }, + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": "e2e/tsconfig.e2e.json", + "exclude": ["**/node_modules/**"] + } + } + } + } + }, + "defaultProject": "my-app" +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/AddArrayEmpty.json b/packages/schematic-utils/src/workspace/json/test/cases/AddArrayEmpty.json new file mode 100644 index 0000000..8cbf558 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/AddArrayEmpty.json @@ -0,0 +1,15 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": ["good", "great", "awesome"] + }, + "x-bar": 5, + "x-array": [] +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/AddArrayPush.json b/packages/schematic-utils/src/workspace/json/test/cases/AddArrayPush.json new file mode 100644 index 0000000..fff7acc --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/AddArrayPush.json @@ -0,0 +1,15 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": ["good", "great", "awesome"] + }, + "x-bar": 5, + "x-array": ["value"] +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/AddProject1.json b/packages/schematic-utils/src/workspace/json/test/cases/AddProject1.json new file mode 100644 index 0000000..da8ea50 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/AddProject1.json @@ -0,0 +1,19 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 10, + "x-foo": { + "is": ["good", "great", "awesome"] + }, + "x-bar": 5, + "projects": { + "new": { + "root": "src" + } + } +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/AddProject2.json b/packages/schematic-utils/src/workspace/json/test/cases/AddProject2.json new file mode 100644 index 0000000..4c32a6c --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/AddProject2.json @@ -0,0 +1,124 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "my-app": { + "root": "", + "projectType": "application", + "prefix": "app", + "schematics": { + "@schematics/angular:component": { + "styleext": "scss" + } + }, + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist/my-app", + "index": "src/index.html", + "main": "src/main.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.app.json", + "assets": ["src/favicon.ico", "src/assets"], + "styles": ["src/styles.scss"], + "scripts": [] + }, + "configurations": { + "production": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ], + "optimization": true, + "outputHashing": "all", + "sourceMap": false, + "extractCss": true, + "namedChunks": false, + "aot": true, + "extractLicenses": true, + "vendorChunk": false, + "buildOptimizer": true, + "budgets": [ + { + "type": "initial", + "maximumWarning": "2mb", + "maximumError": "5mb" + } + ] + } + } + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "options": { + "browserTarget": "my-app:build" + }, + "configurations": { + "production": { + "browserTarget": "my-app:build:production" + } + } + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "browserTarget": "my-app:build" + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "main": "src/test.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.spec.json", + "karmaConfig": "src/karma.conf.js", + "styles": ["src/styles.scss"], + "scripts": [], + "assets": ["src/favicon.ico", "src/assets"] + } + }, + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": ["src/tsconfig.app.json", "src/tsconfig.spec.json"], + "exclude": ["**/node_modules/**"] + } + } + } + }, + "my-app-e2e": { + "root": "e2e/", + "projectType": "application", + "prefix": "", + "architect": { + "e2e": { + "builder": "@angular-devkit/build-angular:protractor", + "options": { + "protractorConfig": "e2e/protractor.conf.js", + "devServerTarget": "my-app:serve" + }, + "configurations": { + "production": { + "devServerTarget": "my-app:serve:production" + } + } + }, + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": "e2e/tsconfig.e2e.json", + "exclude": ["**/node_modules/**"] + } + } + } + }, + "new": { + "root": "src" + } + }, + "defaultProject": "my-app" +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/AddProjectWithTargets.json b/packages/schematic-utils/src/workspace/json/test/cases/AddProjectWithTargets.json new file mode 100644 index 0000000..65d0e04 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/AddProjectWithTargets.json @@ -0,0 +1,33 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 10, + "x-foo": { + "is": ["good", "great", "awesome"] + }, + "x-bar": 5, + "projects": { + "new": { + "root": "src", + "targets": { + "build": { + "builder": "build-builder", + "options": { + "one": 1, + "two": false + }, + "configurations": { + "staging": { + "two": true + } + } + } + } + } + } +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/ArrayDeleteInner.json b/packages/schematic-utils/src/workspace/json/test/cases/ArrayDeleteInner.json new file mode 100644 index 0000000..fdbbdd8 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/ArrayDeleteInner.json @@ -0,0 +1,14 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": ["good", "awesome"] + }, + "x-bar": 5 +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/ArrayDeleteInnerAdd.json b/packages/schematic-utils/src/workspace/json/test/cases/ArrayDeleteInnerAdd.json new file mode 100644 index 0000000..7f8099f --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/ArrayDeleteInnerAdd.json @@ -0,0 +1,14 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": ["good", "new", "awesome"] + }, + "x-bar": 5 +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/ArrayDeleteLast.json b/packages/schematic-utils/src/workspace/json/test/cases/ArrayDeleteLast.json new file mode 100644 index 0000000..9405c58 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/ArrayDeleteLast.json @@ -0,0 +1,14 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": ["good", "great"] + }, + "x-bar": 5 +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/ArrayDeleteLastAdd.json b/packages/schematic-utils/src/workspace/json/test/cases/ArrayDeleteLastAdd.json new file mode 100644 index 0000000..bea837f --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/ArrayDeleteLastAdd.json @@ -0,0 +1,14 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": ["good", "great", "new"] + }, + "x-bar": 5 +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/ArrayDeleteZero.json b/packages/schematic-utils/src/workspace/json/test/cases/ArrayDeleteZero.json new file mode 100644 index 0000000..fe17183 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/ArrayDeleteZero.json @@ -0,0 +1,14 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": ["great", "awesome"] + }, + "x-bar": 5 +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/ArrayIndexInner.json b/packages/schematic-utils/src/workspace/json/test/cases/ArrayIndexInner.json new file mode 100644 index 0000000..5e3fb65 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/ArrayIndexInner.json @@ -0,0 +1,14 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": ["good", "value", "awesome"] + }, + "x-bar": 5 +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/ArrayIndexLast.json b/packages/schematic-utils/src/workspace/json/test/cases/ArrayIndexLast.json new file mode 100644 index 0000000..672d2a6 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/ArrayIndexLast.json @@ -0,0 +1,14 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": ["good", "great", "value"] + }, + "x-bar": 5 +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/ArrayIndexZero.json b/packages/schematic-utils/src/workspace/json/test/cases/ArrayIndexZero.json new file mode 100644 index 0000000..bb520cd --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/ArrayIndexZero.json @@ -0,0 +1,14 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": ["value", "great", "awesome"] + }, + "x-bar": 5 +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/ArrayPop.json b/packages/schematic-utils/src/workspace/json/test/cases/ArrayPop.json new file mode 100644 index 0000000..9405c58 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/ArrayPop.json @@ -0,0 +1,14 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": ["good", "great"] + }, + "x-bar": 5 +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/ArrayPush.json b/packages/schematic-utils/src/workspace/json/test/cases/ArrayPush.json new file mode 100644 index 0000000..b1b4137 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/ArrayPush.json @@ -0,0 +1,14 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": ["good", "great", "awesome", "value"] + }, + "x-bar": 5 +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/ArrayReplace1.json b/packages/schematic-utils/src/workspace/json/test/cases/ArrayReplace1.json new file mode 100644 index 0000000..b81c8c0 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/ArrayReplace1.json @@ -0,0 +1,14 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": ["value"] + }, + "x-bar": 5 +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/ArrayReplace2.json b/packages/schematic-utils/src/workspace/json/test/cases/ArrayReplace2.json new file mode 100644 index 0000000..899b455 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/ArrayReplace2.json @@ -0,0 +1,14 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": [] + }, + "x-bar": 5 +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/ArrayShift.json b/packages/schematic-utils/src/workspace/json/test/cases/ArrayShift.json new file mode 100644 index 0000000..fe17183 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/ArrayShift.json @@ -0,0 +1,14 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": ["great", "awesome"] + }, + "x-bar": 5 +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/ArraySort.json b/packages/schematic-utils/src/workspace/json/test/cases/ArraySort.json new file mode 100644 index 0000000..730c761 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/ArraySort.json @@ -0,0 +1,14 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": ["awesome", "good", "great"] + }, + "x-bar": 5 +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/ArraySplice1.json b/packages/schematic-utils/src/workspace/json/test/cases/ArraySplice1.json new file mode 100644 index 0000000..9405c58 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/ArraySplice1.json @@ -0,0 +1,14 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": ["good", "great"] + }, + "x-bar": 5 +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/ArraySplice2.json b/packages/schematic-utils/src/workspace/json/test/cases/ArraySplice2.json new file mode 100644 index 0000000..9f048f9 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/ArraySplice2.json @@ -0,0 +1,14 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": ["good", "great", "value1", "value2", "awesome"] + }, + "x-bar": 5 +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/ArrayUnshift.json b/packages/schematic-utils/src/workspace/json/test/cases/ArrayUnshift.json new file mode 100644 index 0000000..9c6f230 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/ArrayUnshift.json @@ -0,0 +1,14 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": ["value", "good", "great", "awesome"] + }, + "x-bar": 5 +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/ArrayValues.json b/packages/schematic-utils/src/workspace/json/test/cases/ArrayValues.json new file mode 100644 index 0000000..0a26765 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/ArrayValues.json @@ -0,0 +1,15 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": ["good", "great", "awesome"] + }, + "x-bar": 5, + "x-array": [5, "a", false, null, true, 9.9] +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/Empty.json b/packages/schematic-utils/src/workspace/json/test/cases/Empty.json new file mode 100644 index 0000000..80e11d5 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/Empty.json @@ -0,0 +1,5 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "projects": {} +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/Extensions1.json b/packages/schematic-utils/src/workspace/json/test/cases/Extensions1.json new file mode 100644 index 0000000..b17dd02 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/Extensions1.json @@ -0,0 +1,6 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": {} +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/Extensions2.json b/packages/schematic-utils/src/workspace/json/test/cases/Extensions2.json new file mode 100644 index 0000000..aba5306 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/Extensions2.json @@ -0,0 +1,11 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "schematics": { + "@schematics/angular:component": { + "prefix": "app" + } + }, + "projects": {} +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/ObjectRemove.json b/packages/schematic-utils/src/workspace/json/test/cases/ObjectRemove.json new file mode 100644 index 0000000..f0a93a7 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/ObjectRemove.json @@ -0,0 +1,13 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-foo": { + "is": ["good", "great", "awesome"] + }, + "x-bar": 5 +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/ObjectRemoveMultiple.json b/packages/schematic-utils/src/workspace/json/test/cases/ObjectRemoveMultiple.json new file mode 100644 index 0000000..0a221ea --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/ObjectRemoveMultiple.json @@ -0,0 +1,8 @@ +{ + "version": 1, + // Comment + "x-foo": { + "is": ["good", "great", "awesome"] + }, + "x-bar": 5 +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/ObjectReplace1.json b/packages/schematic-utils/src/workspace/json/test/cases/ObjectReplace1.json new file mode 100644 index 0000000..d396b27 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/ObjectReplace1.json @@ -0,0 +1,14 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "replacement": true + }, + "x-bar": 5 +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/ObjectReplace2.json b/packages/schematic-utils/src/workspace/json/test/cases/ObjectReplace2.json new file mode 100644 index 0000000..01959d5 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/ObjectReplace2.json @@ -0,0 +1,12 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": {}, + "x-bar": 5 +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/ObjectReplace3.json b/packages/schematic-utils/src/workspace/json/test/cases/ObjectReplace3.json new file mode 100644 index 0000000..62945b5 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/ObjectReplace3.json @@ -0,0 +1,12 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": null, + "x-bar": 5 +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/ProjectAddTarget.json b/packages/schematic-utils/src/workspace/json/test/cases/ProjectAddTarget.json new file mode 100644 index 0000000..a920d17 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/ProjectAddTarget.json @@ -0,0 +1,124 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "my-app": { + "root": "", + "projectType": "application", + "prefix": "app", + "schematics": { + "@schematics/angular:component": { + "styleext": "scss" + } + }, + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist/my-app", + "index": "src/index.html", + "main": "src/main.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.app.json", + "assets": ["src/favicon.ico", "src/assets"], + "styles": ["src/styles.scss"], + "scripts": [] + }, + "configurations": { + "production": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ], + "optimization": true, + "outputHashing": "all", + "sourceMap": false, + "extractCss": true, + "namedChunks": false, + "aot": true, + "extractLicenses": true, + "vendorChunk": false, + "buildOptimizer": true, + "budgets": [ + { + "type": "initial", + "maximumWarning": "2mb", + "maximumError": "5mb" + } + ] + } + } + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "options": { + "browserTarget": "my-app:build" + }, + "configurations": { + "production": { + "browserTarget": "my-app:build:production" + } + } + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "browserTarget": "my-app:build" + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "main": "src/test.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.spec.json", + "karmaConfig": "src/karma.conf.js", + "styles": ["src/styles.scss"], + "scripts": [], + "assets": ["src/favicon.ico", "src/assets"] + } + }, + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": ["src/tsconfig.app.json", "src/tsconfig.spec.json"], + "exclude": ["**/node_modules/**"] + } + }, + "new": { + "builder": "new-builder" + } + } + }, + "my-app-e2e": { + "root": "e2e/", + "projectType": "application", + "prefix": "", + "architect": { + "e2e": { + "builder": "@angular-devkit/build-angular:protractor", + "options": { + "protractorConfig": "e2e/protractor.conf.js", + "devServerTarget": "my-app:serve" + }, + "configurations": { + "production": { + "devServerTarget": "my-app:serve:production" + } + } + }, + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": "e2e/tsconfig.e2e.json", + "exclude": ["**/node_modules/**"] + } + } + } + } + }, + "defaultProject": "my-app" +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/ProjectDeleteTarget.json b/packages/schematic-utils/src/workspace/json/test/cases/ProjectDeleteTarget.json new file mode 100644 index 0000000..674eb4a --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/ProjectDeleteTarget.json @@ -0,0 +1,115 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "my-app": { + "root": "", + "projectType": "application", + "prefix": "app", + "schematics": { + "@schematics/angular:component": { + "styleext": "scss" + } + }, + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist/my-app", + "index": "src/index.html", + "main": "src/main.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.app.json", + "assets": ["src/favicon.ico", "src/assets"], + "styles": ["src/styles.scss"], + "scripts": [] + }, + "configurations": { + "production": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ], + "optimization": true, + "outputHashing": "all", + "sourceMap": false, + "extractCss": true, + "namedChunks": false, + "aot": true, + "extractLicenses": true, + "vendorChunk": false, + "buildOptimizer": true, + "budgets": [ + { + "type": "initial", + "maximumWarning": "2mb", + "maximumError": "5mb" + } + ] + } + } + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "options": { + "browserTarget": "my-app:build" + }, + "configurations": { + "production": { + "browserTarget": "my-app:build:production" + } + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "main": "src/test.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.spec.json", + "karmaConfig": "src/karma.conf.js", + "styles": ["src/styles.scss"], + "scripts": [], + "assets": ["src/favicon.ico", "src/assets"] + } + }, + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": ["src/tsconfig.app.json", "src/tsconfig.spec.json"], + "exclude": ["**/node_modules/**"] + } + } + } + }, + "my-app-e2e": { + "root": "e2e/", + "projectType": "application", + "prefix": "", + "architect": { + "e2e": { + "builder": "@angular-devkit/build-angular:protractor", + "options": { + "protractorConfig": "e2e/protractor.conf.js", + "devServerTarget": "my-app:serve" + }, + "configurations": { + "production": { + "devServerTarget": "my-app:serve:production" + } + } + }, + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": "e2e/tsconfig.e2e.json", + "exclude": ["**/node_modules/**"] + } + } + } + } + }, + "defaultProject": "my-app" +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/ProjectEmpty.json b/packages/schematic-utils/src/workspace/json/test/cases/ProjectEmpty.json new file mode 100644 index 0000000..99498b8 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/ProjectEmpty.json @@ -0,0 +1,9 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "projects": { + "my-app": { + "root": "projects/my-app" + } + } +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/ProjectFull.json b/packages/schematic-utils/src/workspace/json/test/cases/ProjectFull.json new file mode 100644 index 0000000..3be50da --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/ProjectFull.json @@ -0,0 +1,65 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "projects": { + "my-app": { + "root": "projects/my-app", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist/my-app", + "index": "projects/my-app/src/index.html", + "main": "projects/my-app/src/main.ts", + "polyfills": "projects/my-app/src/polyfills.ts", + "tsConfig": "projects/my-app/tsconfig.app.json", + "assets": [ + "projects/my-app/src/favicon.ico", + "projects/my-app/src/assets" + ], + "styles": ["projects/my-app/src/styles.scss"], + "scripts": [], + "es5BrowserSupport": true + }, + "configurations": { + "production": { + "fileReplacements": [ + { + "replace": "projects/my-app/src/environments/environment.ts", + "with": "projects/my-app/src/environments/environment.prod.ts" + } + ], + "optimization": true, + "outputHashing": "all", + "sourceMap": false, + "extractCss": true, + "namedChunks": false, + "aot": true, + "extractLicenses": true, + "vendorChunk": false, + "buildOptimizer": true, + "budgets": [ + { + "type": "initial", + "maximumWarning": "2mb", + "maximumError": "5mb" + } + ] + } + } + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "options": { + "browserTarget": "my-app:build" + }, + "configurations": { + "production": { + "browserTarget": "my-app:build:production" + } + } + } + } + } + } +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/ProjectModifyProperties.json b/packages/schematic-utils/src/workspace/json/test/cases/ProjectModifyProperties.json new file mode 100644 index 0000000..80f9d02 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/ProjectModifyProperties.json @@ -0,0 +1,121 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "my-app": { + "root": "src", + "projectType": "application", + "prefix": "app", + "schematics": { + "@schematics/angular:component": { + "styleext": "scss" + } + }, + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist/my-app", + "index": "src/index.html", + "main": "src/main.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.app.json", + "assets": ["src/favicon.ico", "src/assets"], + "styles": ["src/styles.scss"], + "scripts": [] + }, + "configurations": { + "production": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ], + "optimization": true, + "outputHashing": "all", + "sourceMap": false, + "extractCss": true, + "namedChunks": false, + "aot": true, + "extractLicenses": true, + "vendorChunk": false, + "buildOptimizer": true, + "budgets": [ + { + "type": "initial", + "maximumWarning": "2mb", + "maximumError": "5mb" + } + ] + } + } + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "options": { + "browserTarget": "my-app:build" + }, + "configurations": { + "production": { + "browserTarget": "my-app:build:production" + } + } + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "browserTarget": "my-app:build" + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "main": "src/test.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.spec.json", + "karmaConfig": "src/karma.conf.js", + "styles": ["src/styles.scss"], + "scripts": [], + "assets": ["src/favicon.ico", "src/assets"] + } + }, + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": ["src/tsconfig.app.json", "src/tsconfig.spec.json"], + "exclude": ["**/node_modules/**"] + } + } + } + }, + "my-app-e2e": { + "root": "e2e/", + "projectType": "application", + "prefix": "", + "architect": { + "e2e": { + "builder": "@angular-devkit/build-angular:protractor", + "options": { + "protractorConfig": "e2e/protractor.conf.js", + "devServerTarget": "my-app:serve" + }, + "configurations": { + "production": { + "devServerTarget": "my-app:serve:production" + } + } + }, + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": "e2e/tsconfig.e2e.json", + "exclude": ["**/node_modules/**"] + } + } + } + } + }, + "defaultProject": "my-app" +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/ProjectSetProperties.json b/packages/schematic-utils/src/workspace/json/test/cases/ProjectSetProperties.json new file mode 100644 index 0000000..9551408 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/ProjectSetProperties.json @@ -0,0 +1,122 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "my-app": { + "root": "", + "projectType": "application", + "prefix": "app", + "schematics": { + "@schematics/angular:component": { + "styleext": "scss" + } + }, + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist/my-app", + "index": "src/index.html", + "main": "src/main.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.app.json", + "assets": ["src/favicon.ico", "src/assets"], + "styles": ["src/styles.scss"], + "scripts": [] + }, + "configurations": { + "production": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ], + "optimization": true, + "outputHashing": "all", + "sourceMap": false, + "extractCss": true, + "namedChunks": false, + "aot": true, + "extractLicenses": true, + "vendorChunk": false, + "buildOptimizer": true, + "budgets": [ + { + "type": "initial", + "maximumWarning": "2mb", + "maximumError": "5mb" + } + ] + } + } + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "options": { + "browserTarget": "my-app:build" + }, + "configurations": { + "production": { + "browserTarget": "my-app:build:production" + } + } + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "browserTarget": "my-app:build" + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "main": "src/test.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.spec.json", + "karmaConfig": "src/karma.conf.js", + "styles": ["src/styles.scss"], + "scripts": [], + "assets": ["src/favicon.ico", "src/assets"] + } + }, + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": ["src/tsconfig.app.json", "src/tsconfig.spec.json"], + "exclude": ["**/node_modules/**"] + } + } + }, + "sourceRoot": "src" + }, + "my-app-e2e": { + "root": "e2e/", + "projectType": "application", + "prefix": "", + "architect": { + "e2e": { + "builder": "@angular-devkit/build-angular:protractor", + "options": { + "protractorConfig": "e2e/protractor.conf.js", + "devServerTarget": "my-app:serve" + }, + "configurations": { + "production": { + "devServerTarget": "my-app:serve:production" + } + } + }, + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": "e2e/tsconfig.e2e.json", + "exclude": ["**/node_modules/**"] + } + } + } + } + }, + "defaultProject": "my-app" +} diff --git a/packages/schematic-utils/src/workspace/json/test/cases/Retain.json b/packages/schematic-utils/src/workspace/json/test/cases/Retain.json new file mode 100644 index 0000000..11c15c4 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/test/cases/Retain.json @@ -0,0 +1,14 @@ +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 10, + "x-foo": { + "is": ["good", "great", "awesome"] + }, + "x-bar": 5 +} diff --git a/packages/schematic-utils/src/workspace/json/utilities.ts b/packages/schematic-utils/src/workspace/json/utilities.ts new file mode 100644 index 0000000..77b0c1c --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/utilities.ts @@ -0,0 +1,352 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + JsonAstArray, + JsonAstKeyValue, + JsonAstNode, + JsonAstObject, + JsonObject, + JsonValue, +} from "../../json"; + +const stableStringify = require("fast-json-stable-stringify"); + +interface CacheEntry { + value?: JsonValue; + node?: JsonAstNode; + parent: JsonAstArray | JsonAstKeyValue | JsonAstObject; +} + +export type ChangeListener = ( + op: "add" | "remove" | "replace", + path: string, + node: JsonAstArray | JsonAstObject | JsonAstKeyValue, + value?: JsonValue +) => void; + +type ChangeReporter = ( + path: string, + parent: JsonAstArray | JsonAstKeyValue | JsonAstObject, + node?: JsonAstNode, + old?: JsonValue, + current?: JsonValue +) => void; + +// lib.es5 PropertyKey is string | number | symbol which doesn't overlap ProxyHandler PropertyKey which is string | symbol. +// See https://github.com/microsoft/TypeScript/issues/42894 +type ProxyPropertyKey = string | symbol; + +function findNode( + parent: JsonAstArray | JsonAstObject, + p: ProxyPropertyKey +): { + node?: JsonAstNode; + parent: JsonAstArray | JsonAstKeyValue | JsonAstObject; +} { + if (parent.kind === "object") { + const entry = parent.properties.find((entry) => entry.key.value === p); + if (entry) { + return { node: entry.value, parent: entry }; + } + } else { + const index = Number(p); + if (!isNaN(index)) { + return { node: parent.elements[index], parent }; + } + } + + return { parent }; +} + +function createPropertyDescriptor( + value: JsonValue | undefined +): PropertyDescriptor { + return { + configurable: true, + enumerable: true, + writable: true, + value, + }; +} + +export function escapeKey(key: string | number): string | number { + if (typeof key === "number") { + return key; + } + + return key.replace("~", "~0").replace("/", "~1"); +} + +export function unescapeKey(key: string | number): string | number { + if (typeof key === "number") { + return key; + } + + return key.replace("~1", "/").replace("~0", "~"); +} + +export function createVirtualAstObject( + root: JsonAstObject, + options: { + exclude?: string[]; + include?: string[]; + listener?: ChangeListener; + base?: object; + } = {} +): T { + // @ts-ignore + const reporter: ChangeReporter = (path, parent, node, old, current) => { + if (options.listener) { + if ( + old === current || + stableStringify(old) === stableStringify(current) + ) { + return; + } + + const op = + old === undefined + ? "add" + : current === undefined + ? "remove" + : "replace"; + options.listener(op, path, parent, current); + } + }; + + return create( + root, + "", + reporter, + new Set(options.exclude), + options.include && options.include.length > 0 + ? new Set(options.include) + : undefined, + options.base + ) as T; +} + +function create( + ast: JsonAstObject | JsonAstArray, + path: string, + reporter: ChangeReporter, + excluded = new Set(), + included?: Set, + base?: object +) { + const cache = new Map(); + const alteredNodes = new Set(); + + if (!base) { + if (ast.kind === "object") { + base = Object.create(null) as object; + } else { + base = []; + (base as Array).length = ast.elements.length; + } + } + + return new Proxy(base, { + getOwnPropertyDescriptor( + target: {}, + p: ProxyPropertyKey + ): PropertyDescriptor | undefined { + const descriptor = Reflect.getOwnPropertyDescriptor(target, p); + if (descriptor || typeof p === "symbol") { + return descriptor; + } else if (excluded.has(p) || (included && !included.has(p))) { + return undefined; + } + + const propertyPath = path + "/" + escapeKey(p); + const cacheEntry = cache.get(propertyPath); + if (cacheEntry) { + if (cacheEntry.value !== undefined) { + return createPropertyDescriptor(cacheEntry.value); + } + + return undefined; + } + + const { node } = findNode(ast, p); + if (node) { + return createPropertyDescriptor(node.value); + } + + return undefined; + }, + has(target: {}, p: ProxyPropertyKey): boolean { + if (Reflect.has(target, p)) { + return true; + } else if (typeof p === "symbol" || excluded.has(p)) { + return false; + } + + return ( + cache.has(path + "/" + escapeKey(p)) || findNode(ast, p) !== undefined + ); + }, + get(target: {}, p: ProxyPropertyKey): unknown { + if (typeof p === "symbol" || Reflect.has(target, p)) { + return Reflect.get(target, p); + } else if (excluded.has(p) || (included && !included.has(p))) { + return undefined; + } + + const propertyPath = path + "/" + escapeKey(p); + const cacheEntry = cache.get(propertyPath); + if (cacheEntry) { + return cacheEntry.value; + } + + const { node, parent } = findNode(ast, p); + let value; + if (node) { + if (node.kind === "object" || node.kind === "array") { + value = create( + node, + propertyPath, + (path, parent, vnode, old, current) => { + if (!alteredNodes.has(node)) { + reporter(path, parent, vnode, old, current); + } + } + ); + } else { + value = node.value; + } + + cache.set(propertyPath, { node, parent, value }); + } + + return value; + }, + set(target: {}, p: ProxyPropertyKey, value: unknown): boolean { + if (value === undefined) { + // setting to undefined is equivalent to a delete + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this.deleteProperty!(target, p); + } + + if (typeof p === "symbol" || Reflect.has(target, p)) { + return Reflect.set(target, p, value); + } else if (excluded.has(p) || (included && !included.has(p))) { + return false; + } + + // TODO: Check if is JSON value + const jsonValue = value as JsonValue; + + const propertyPath = path + "/" + escapeKey(p); + const cacheEntry = cache.get(propertyPath); + if (cacheEntry) { + const oldValue = cacheEntry.value; + cacheEntry.value = value as JsonValue; + if (cacheEntry.node && oldValue !== value) { + alteredNodes.add(cacheEntry.node); + } + reporter( + propertyPath, + cacheEntry.parent, + cacheEntry.node, + oldValue, + jsonValue + ); + } else { + const { node, parent } = findNode(ast, p); + cache.set(propertyPath, { node, parent, value: value as JsonValue }); + if (node && node.value !== value) { + alteredNodes.add(node); + } + reporter( + propertyPath, + parent, + node, + node && node.value, + value as JsonValue + ); + } + + return true; + }, + deleteProperty(target: {}, p: ProxyPropertyKey): boolean { + if (typeof p === "symbol" || Reflect.has(target, p)) { + return Reflect.deleteProperty(target, p); + } else if (excluded.has(p) || (included && !included.has(p))) { + return false; + } + + const propertyPath = path + "/" + escapeKey(p); + const cacheEntry = cache.get(propertyPath); + if (cacheEntry) { + const oldValue = cacheEntry.value; + cacheEntry.value = undefined; + if (cacheEntry.node) { + alteredNodes.add(cacheEntry.node); + } + if (cacheEntry.parent.kind === "keyvalue") { + // Remove the entire key/value pair from this JSON object + reporter(propertyPath, ast, cacheEntry.node, oldValue, undefined); + } else { + reporter( + propertyPath, + cacheEntry.parent, + cacheEntry.node, + oldValue, + undefined + ); + } + } else { + const { node, parent } = findNode(ast, p); + if (node) { + cache.set(propertyPath, { node, parent, value: undefined }); + alteredNodes.add(node); + if (parent.kind === "keyvalue") { + // Remove the entire key/value pair from this JSON object + reporter(propertyPath, ast, node, node && node.value, undefined); + } else { + reporter(propertyPath, parent, node, node && node.value, undefined); + } + } + } + + return true; + }, + defineProperty( + target: {}, + p: ProxyPropertyKey, + attributes: PropertyDescriptor + ): boolean { + if (typeof p === "symbol") { + return Reflect.defineProperty(target, p, attributes); + } + + return false; + }, + ownKeys(target: {}): ProxyPropertyKey[] { + let keys: ProxyPropertyKey[]; + if (ast.kind === "object") { + keys = ast.properties + .map((entry) => entry.key.value) + .filter((p) => !excluded.has(p) && (!included || included.has(p))); + } else { + keys = []; + } + + for (const key of cache.keys()) { + const relativeKey = key.substr(path.length + 1); + if (relativeKey.length > 0 && !relativeKey.includes("/")) { + keys.push(`${unescapeKey(relativeKey)}`); + } + } + + return [...new Set([...keys, ...Reflect.ownKeys(target)])]; + }, + }); +} diff --git a/packages/schematic-utils/src/workspace/json/writer.spec.ts b/packages/schematic-utils/src/workspace/json/writer.spec.ts new file mode 100644 index 0000000..d0c1617 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/writer.spec.ts @@ -0,0 +1,668 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { readFileSync } from "fs"; +import { join } from "path"; +import { JsonArray, JsonObject } from "../../json"; +import { + ProjectDefinitionCollection, + WorkspaceDefinition, +} from "../definitions"; +import { readJsonWorkspace } from "./reader"; +import { writeJsonWorkspace } from "./writer"; +import { stripIndent } from "../../utils/runtime"; + +const basicFile = stripIndent` +{ + "version": 1, + // Comment + "schematics": { + "@angular/schematics:component": { + "prefix": "abc" + } + }, + "x-baz": 1, + "x-foo": { + "is": ["good", "great", "awesome"] + }, + "x-bar": 5, +}`; + +const representativeFile = readFileSync( + require.resolve(__dirname + "/test/angular.json"), + "utf8" +); + +function createTestCaseHost(inputData = "") { + const host = { + async readFile() { + return inputData; + }, + async writeFile(path: string, data: string) { + try { + const testCase = readFileSync( + require.resolve(join(__dirname, "test", "cases", path) + ".json"), + "utf8" + ); + expect(data).toEqual(testCase); + } catch (e) { + fail(`Unable to load test case '${path}': ${e.message || e}`); + } + }, + async isFile() { + return true; + }, + async isDirectory() { + return true; + }, + }; + + return host; +} + +// @TODO tests don't pass when using jest +describe.skip("writeJsonWorkpaceFile", () => { + it("does not modify a file without changes", async () => { + const host = { + async readFile() { + return representativeFile; + }, + async writeFile() { + fail(); + }, + async isFile() { + return true; + }, + async isDirectory() { + return true; + }, + }; + + const workspace = await readJsonWorkspace("angular.json", host); + await writeJsonWorkspace(workspace, host); + }); + + it("writes an empty workspace", async () => { + const workspace: WorkspaceDefinition = { + extensions: {}, + projects: new ProjectDefinitionCollection(), + }; + await writeJsonWorkspace(workspace, createTestCaseHost(), "Empty"); + }); + + it("writes new workspace with extensions", async () => { + const workspace: WorkspaceDefinition = { + extensions: { + newProjectRoot: "projects", + }, + projects: new ProjectDefinitionCollection(), + }; + await writeJsonWorkspace(workspace, createTestCaseHost(), "Extensions1"); + + workspace.extensions["schematics"] = { + "@schematics/angular:component": { prefix: "app" }, + }; + await writeJsonWorkspace(workspace, createTestCaseHost(), "Extensions2"); + }); + + it("writes new workspace with an empty project", async () => { + const workspace: WorkspaceDefinition = { + extensions: {}, + projects: new ProjectDefinitionCollection(), + }; + + workspace.projects.add({ + name: "my-app", + root: "projects/my-app", + }); + + await writeJsonWorkspace(workspace, createTestCaseHost(), "ProjectEmpty"); + }); + + it("writes new workspace with a full project", async () => { + const workspace: WorkspaceDefinition = { + extensions: {}, + projects: new ProjectDefinitionCollection(), + }; + + workspace.projects.add({ + name: "my-app", + root: "projects/my-app", + targets: { + build: { + builder: "@angular-devkit/build-angular:browser", + options: { + outputPath: `dist/my-app`, + index: `projects/my-app/src/index.html`, + main: `projects/my-app/src/main.ts`, + polyfills: `projects/my-app/src/polyfills.ts`, + tsConfig: `projects/my-app/tsconfig.app.json`, + assets: [ + "projects/my-app/src/favicon.ico", + "projects/my-app/src/assets", + ], + styles: [`projects/my-app/src/styles.scss`], + scripts: [], + es5BrowserSupport: true, + }, + configurations: { + production: { + fileReplacements: [ + { + replace: `projects/my-app/src/environments/environment.ts`, + with: `projects/my-app/src/environments/environment.prod.ts`, + }, + ], + optimization: true, + outputHashing: "all", + sourceMap: false, + extractCss: true, + namedChunks: false, + aot: true, + extractLicenses: true, + vendorChunk: false, + buildOptimizer: true, + budgets: [ + { + type: "initial", + maximumWarning: "2mb", + maximumError: "5mb", + }, + ], + }, + }, + }, + serve: { + builder: "@angular-devkit/build-angular:dev-server", + options: { + browserTarget: `my-app:build`, + }, + configurations: { + production: { + browserTarget: `my-app:build:production`, + }, + }, + }, + }, + }); + + await writeJsonWorkspace(workspace, createTestCaseHost(), "ProjectFull"); + }); + + it("retains comments and formatting when modifying the workspace", async () => { + const host = createTestCaseHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + workspace.extensions["x-baz"] = 10; + + await writeJsonWorkspace(workspace, host, "Retain"); + }); + + it("adds a project to workspace without any projects", async () => { + const host = createTestCaseHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + workspace.projects.add({ + name: "new", + root: "src7", + }); + + await writeJsonWorkspace(workspace, host, "AddProject1"); + }); + + it("adds a project to workspace with existing projects", async () => { + const host = createTestCaseHost(representativeFile); + + const workspace = await readJsonWorkspace("", host); + + workspace.projects.add({ + name: "new", + root: "src", + }); + + await writeJsonWorkspace(workspace, host, "AddProject2"); + }); + + it("adds a project with targets", async () => { + const host = createTestCaseHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + workspace.projects.add({ + name: "new", + root: "src", + targets: { + build: { + builder: "build-builder", + options: { one: 1, two: false }, + configurations: { + staging: { + two: true, + }, + }, + }, + }, + }); + + await writeJsonWorkspace(workspace, host, "AddProjectWithTargets"); + }); + + it("adds a project with targets using reference to workspace", async () => { + const host = createTestCaseHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + workspace.projects.add({ + name: "new", + root: "src", + }); + + const project = workspace.projects.get("new"); + if (!project) { + fail("project is missing"); + + return; + } + + project.targets.add({ + name: "build", + builder: "build-builder", + options: { one: 1, two: false }, + configurations: { + staging: { + two: true, + }, + }, + }); + + // This should be the same as adding them in the project add call + await writeJsonWorkspace(workspace, host, "AddProjectWithTargets"); + }); + + it("modifies a project's properties", async () => { + const host = createTestCaseHost(representativeFile); + + const workspace = await readJsonWorkspace("", host); + + const project = workspace.projects.get("my-app"); + if (!project) { + fail("project is missing"); + + return; + } + + project.root = "src"; + + await writeJsonWorkspace(workspace, host, "ProjectModifyProperties"); + }); + + it("sets a project's properties", async () => { + const host = createTestCaseHost(representativeFile); + + const workspace = await readJsonWorkspace("", host); + + const project = workspace.projects.get("my-app"); + if (!project) { + fail("project is missing"); + + return; + } + + project.sourceRoot = "src"; + + await writeJsonWorkspace(workspace, host, "ProjectSetProperties"); + }); + + it("adds a target to an existing project", async () => { + const host = createTestCaseHost(representativeFile); + + const workspace = await readJsonWorkspace("", host); + + const project = workspace.projects.get("my-app"); + if (!project) { + fail("project is missing"); + + return; + } + + project.targets.add({ + name: "new", + builder: "new-builder", + }); + + await writeJsonWorkspace(workspace, host, "ProjectAddTarget"); + }); + + it("deletes a target from an existing project", async () => { + const host = createTestCaseHost(representativeFile); + + const workspace = await readJsonWorkspace("", host); + + const project = workspace.projects.get("my-app"); + if (!project) { + fail("project is missing"); + + return; + } + + project.targets.delete("extract-i18n"); + + await writeJsonWorkspace(workspace, host, "ProjectDeleteTarget"); + }); + + it("supports adding an empty array", async () => { + const host = createTestCaseHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + workspace.extensions["x-array"] = []; + + await writeJsonWorkspace(workspace, host, "AddArrayEmpty"); + }); + + it("supports adding an array with values", async () => { + const host = createTestCaseHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + workspace.extensions["x-array"] = [5, "a", false, null, true, 9.9]; + + await writeJsonWorkspace(workspace, host, "ArrayValues"); + }); + + it("supports adding an empty array then pushing as an extension", async () => { + const host = createTestCaseHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + workspace.extensions["x-array"] = []; + (workspace.extensions["x-array"] as string[]).push("value"); + + await writeJsonWorkspace(workspace, host, "AddArrayPush"); + }); + + it("supports pushing to an existing array", async () => { + const host = createTestCaseHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + const array = (workspace.extensions["x-foo"] as JsonObject)[ + "is" + ] as JsonArray; + array.push("value"); + + await writeJsonWorkspace(workspace, host, "ArrayPush"); + }); + + it("supports unshifting to an existing array", async () => { + const host = createTestCaseHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + const array = (workspace.extensions["x-foo"] as JsonObject)[ + "is" + ] as JsonArray; + array.unshift("value"); + + await writeJsonWorkspace(workspace, host, "ArrayUnshift"); + }); + + it("supports shifting from an existing array", async () => { + const host = createTestCaseHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + const array = (workspace.extensions["x-foo"] as JsonObject)[ + "is" + ] as JsonArray; + array.shift(); + + await writeJsonWorkspace(workspace, host, "ArrayShift"); + }); + + it("supports splicing an existing array without new values", async () => { + const host = createTestCaseHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + const array = (workspace.extensions["x-foo"] as JsonObject)[ + "is" + ] as JsonArray; + array.splice(2, 1); + + await writeJsonWorkspace(workspace, host, "ArraySplice1"); + }); + + it("supports splicing an existing array with new values", async () => { + const host = createTestCaseHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + const array = (workspace.extensions["x-foo"] as JsonObject)[ + "is" + ] as JsonArray; + array.splice(2, 0, "value1", "value2"); + + await writeJsonWorkspace(workspace, host, "ArraySplice2"); + }); + + it("supports popping from an existing array", async () => { + const host = createTestCaseHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + const array = (workspace.extensions["x-foo"] as JsonObject)[ + "is" + ] as JsonArray; + array.pop(); + + await writeJsonWorkspace(workspace, host, "ArrayPop"); + }); + + it("supports sorting from an existing array", async () => { + const host = createTestCaseHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + const array = (workspace.extensions["x-foo"] as JsonObject)[ + "is" + ] as JsonArray; + array.sort(); + + await writeJsonWorkspace(workspace, host, "ArraySort"); + }); + + it("replaces a value at zero index from an existing array", async () => { + const host = createTestCaseHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + const array = (workspace.extensions["x-foo"] as JsonObject)[ + "is" + ] as JsonArray; + array[0] = "value"; + + await writeJsonWorkspace(workspace, host, "ArrayIndexZero"); + }); + + it("replaces a value at inner index from an existing array", async () => { + const host = createTestCaseHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + const array = (workspace.extensions["x-foo"] as JsonObject)[ + "is" + ] as JsonArray; + array[1] = "value"; + + await writeJsonWorkspace(workspace, host, "ArrayIndexInner"); + }); + + it("replaces a value at last index from an existing array", async () => { + const host = createTestCaseHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + const array = (workspace.extensions["x-foo"] as JsonObject)[ + "is" + ] as JsonArray; + array[array.length - 1] = "value"; + + await writeJsonWorkspace(workspace, host, "ArrayIndexLast"); + }); + + it("deletes a value at zero index from an existing array", async () => { + const host = createTestCaseHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + const array = (workspace.extensions["x-foo"] as JsonObject)[ + "is" + ] as JsonArray; + array.splice(0, 1); + + await writeJsonWorkspace(workspace, host, "ArrayDeleteZero"); + }); + + it("deletes a value at inner index from an existing array", async () => { + const host = createTestCaseHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + const array = (workspace.extensions["x-foo"] as JsonObject)[ + "is" + ] as JsonArray; + array.splice(1, 1); + + await writeJsonWorkspace(workspace, host, "ArrayDeleteInner"); + }); + + it("deletes and then adds a value at inner index from an existing array", async () => { + const host = createTestCaseHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + const array = (workspace.extensions["x-foo"] as JsonObject)[ + "is" + ] as JsonArray; + array.splice(1, 1); + array.splice(1, 0, "new"); + + await writeJsonWorkspace(workspace, host, "ArrayDeleteInnerAdd"); + }); + + it("deletes a value at last index from an existing array", async () => { + const host = createTestCaseHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + const array = (workspace.extensions["x-foo"] as JsonObject)[ + "is" + ] as JsonArray; + array.splice(array.length - 1, 1); + + await writeJsonWorkspace(workspace, host, "ArrayDeleteLast"); + }); + + it("deletes and then adds a value at last index from an existing array", async () => { + const host = createTestCaseHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + const array = (workspace.extensions["x-foo"] as JsonObject)[ + "is" + ] as JsonArray; + array.splice(array.length - 1, 1); + array.push("new"); + + await writeJsonWorkspace(workspace, host, "ArrayDeleteLastAdd"); + }); + + it("replaces an existing array", async () => { + const host = createTestCaseHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + (workspace.extensions["x-foo"] as JsonObject)["is"] = ["value"]; + + await writeJsonWorkspace(workspace, host, "ArrayReplace1"); + }); + + it("replaces an existing array with an empty array", async () => { + const host = createTestCaseHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + (workspace.extensions["x-foo"] as JsonObject)["is"] = []; + + await writeJsonWorkspace(workspace, host, "ArrayReplace2"); + }); + + it("replaces an existing object with a new object", async () => { + const host = createTestCaseHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + workspace.extensions["x-foo"] = { replacement: true }; + + await writeJsonWorkspace(workspace, host, "ObjectReplace1"); + }); + + it("replaces an existing object with an empty object", async () => { + const host = createTestCaseHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + workspace.extensions["x-foo"] = {}; + + await writeJsonWorkspace(workspace, host, "ObjectReplace2"); + }); + + it("replaces an existing object with a different value type", async () => { + const host = createTestCaseHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + workspace.extensions["x-foo"] = null; + + await writeJsonWorkspace(workspace, host, "ObjectReplace3"); + }); + + it("removes a property when property value is set to undefined", async () => { + const host = createTestCaseHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + workspace.extensions["x-baz"] = undefined; + + await writeJsonWorkspace(workspace, host, "ObjectRemove"); + }); + + it("removes a property when using delete operator", async () => { + const host = createTestCaseHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + delete workspace.extensions["x-baz"]; + + await writeJsonWorkspace(workspace, host, "ObjectRemove"); + }); + + it("removes multiple properties when using delete operator", async () => { + const host = createTestCaseHost(basicFile); + + const workspace = await readJsonWorkspace("", host); + + delete workspace.extensions["x-baz"]; + delete workspace.extensions.schematics; + + await writeJsonWorkspace(workspace, host, "ObjectRemoveMultiple"); + }); +}); diff --git a/packages/schematic-utils/src/workspace/json/writer.ts b/packages/schematic-utils/src/workspace/json/writer.ts new file mode 100644 index 0000000..9ac14f4 --- /dev/null +++ b/packages/schematic-utils/src/workspace/json/writer.ts @@ -0,0 +1,410 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import MagicString from "magic-string"; +import { + JsonAstKeyValue, + JsonAstNode, + JsonObject, + JsonValue, +} from "../../json"; +import { + ProjectDefinition, + TargetDefinition, + WorkspaceDefinition, +} from "../definitions"; +import { WorkspaceHost } from "../host"; +import { + JsonChange, + JsonWorkspaceDefinition, + JsonWorkspaceMetadata, + JsonWorkspaceSymbol, +} from "./metadata"; +import { unescapeKey } from "./utilities"; + +export async function writeJsonWorkspace( + workspace: WorkspaceDefinition, + host: WorkspaceHost, + path?: string, + options: { + schema?: string; + } = {} +): Promise { + const metadata = (workspace as JsonWorkspaceDefinition)[JsonWorkspaceSymbol]; + + if (metadata) { + if (!metadata.hasChanges) { + // nothing to do + return; + } + + // update existing JSON workspace + const data = updateJsonWorkspace(metadata); + + return host.writeFile(path || metadata.filePath, data); + } else { + // serialize directly + if (!path) { + throw new Error("path option is required"); + } + + const obj = convertJsonWorkspace(workspace, options.schema); + const data = JSON.stringify(obj, null, 2); + + return host.writeFile(path, data); + } +} + +function convertJsonWorkspace( + workspace: WorkspaceDefinition, + schema?: string +): JsonObject { + const obj = { + $schema: schema || "./node_modules/@angular/cli/lib/config/schema.json", + version: 1, + ...workspace.extensions, + projects: workspace.projects + ? convertJsonProjectCollection(workspace.projects) + : {}, + }; + + return obj; +} + +function convertJsonProjectCollection( + collection: Iterable<[string, ProjectDefinition]> +): JsonObject { + const projects = Object.create(null) as JsonObject; + + for (const [projectName, project] of collection) { + projects[projectName] = convertJsonProject(project); + } + + return projects; +} + +function convertJsonProject(project: ProjectDefinition): JsonObject { + let targets: JsonObject | undefined; + if (project.targets.size > 0) { + targets = Object.create(null) as JsonObject; + for (const [targetName, target] of project.targets) { + targets[targetName] = convertJsonTarget(target); + } + } + + const obj = { + ...project.extensions, + root: project.root, + ...(project.sourceRoot === undefined + ? {} + : { sourceRoot: project.sourceRoot }), + ...(project.prefix === undefined ? {} : { prefix: project.prefix }), + ...(targets === undefined ? {} : { architect: targets }), + }; + + return obj; +} + +function isEmpty(obj?: object): boolean { + return obj === undefined || Object.keys(obj).length === 0; +} + +function convertJsonTarget(target: TargetDefinition): JsonObject { + return { + builder: target.builder, + ...(isEmpty(target.options) + ? {} + : { options: target.options as JsonObject }), + ...(isEmpty(target.configurations) + ? {} + : { configurations: target.configurations as JsonObject }), + ...(target.defaultConfiguration === undefined + ? {} + : { defaultConfiguration: target.defaultConfiguration }), + }; +} + +function convertJsonTargetCollection( + collection: Iterable<[string, TargetDefinition]> +): JsonObject { + const targets = Object.create(null) as JsonObject; + + for (const [projectName, target] of collection) { + targets[projectName] = convertJsonTarget(target); + } + + return targets; +} + +function findFullStart( + node: JsonAstNode | JsonAstKeyValue, + raw: string +): number { + let i = node.start.offset; + while (i > 0 && /\s/.test(raw[i - 1])) { + --i; + } + + return i; +} + +function findFullEnd(node: JsonAstNode | JsonAstKeyValue, raw: string): number { + let i = node.end.offset; + if (i >= raw.length) { + return raw.length; + } else if (raw[i] === ",") { + return i + 1; + } + + while (i > node.start.offset && /\s/.test(raw[i - 1])) { + --i; + } + + return i; +} + +function findPrecedingComma( + node: JsonAstNode | JsonAstKeyValue, + raw: string +): number { + let i = node.start.offset; + if (node.comments && node.comments.length > 0) { + i = node.comments[0].start.offset; + } + while (i > 0 && /\s/.test(raw[i - 1])) { + --i; + } + + if (raw[i - 1] === ",") { + return i - 1; + } + + return -1; +} + +function stringify( + value: JsonValue | undefined, + multiline: boolean, + depth: number, + indent: string +): string { + if (value === undefined) { + return ""; + } + + if (multiline) { + const content = JSON.stringify(value, null, indent); + const spacing = "\n" + indent.repeat(depth); + + return content.replace(/\n/g, spacing); + } else { + return JSON.stringify(value); + } +} + +function normalizeValue( + value: JsonChange["value"] | undefined, + type: JsonChange["type"] +): JsonValue | undefined { + switch (type) { + case "project": + return convertJsonProject(value as ProjectDefinition); + case "projectcollection": + const projects = convertJsonProjectCollection( + value as Iterable<[string, ProjectDefinition]> + ); + + return Object.keys(projects).length === 0 ? undefined : projects; + case "target": + return convertJsonTarget(value as TargetDefinition); + case "targetcollection": + const targets = convertJsonTargetCollection( + value as Iterable<[string, TargetDefinition]> + ); + + return Object.keys(targets).length === 0 ? undefined : targets; + default: + return value as JsonValue; + } +} + +function updateJsonWorkspace(metadata: JsonWorkspaceMetadata): string { + const data = new MagicString(metadata.raw); + const indent = data.getIndentString(); + const removedCommas = new Set(); + const nodeChanges = new Map< + JsonAstNode | JsonAstKeyValue, + (JsonAstNode | JsonAstKeyValue | string)[] + >(); + + for (const { op, path, node, value, type } of metadata.changes) { + // targets/projects are typically large objects so always use multiline + const multiline = node.start.line !== node.end.line || type !== "json"; + const pathSegments = path.split("/"); + const depth = pathSegments.length - 1; // TODO: more complete analysis + const propertyOrIndex = unescapeKey(pathSegments[depth]); + const jsonValue = normalizeValue(value, type); + if (op === "add" && jsonValue === undefined) { + continue; + } + + // Track changes to the order/size of any modified objects/arrays + let elements = nodeChanges.get(node); + if (!elements) { + if (node.kind === "array") { + elements = node.elements.slice(); + nodeChanges.set(node, elements); + } else if (node.kind === "object") { + elements = node.properties.slice(); + nodeChanges.set(node, elements); + } else { + // keyvalue + elements = []; + } + } + + switch (op) { + case "add": + let contentPrefix = ""; + if (node.kind === "object") { + contentPrefix = `"${propertyOrIndex}": `; + } + + const spacing = multiline ? "\n" + indent.repeat(depth) : " "; + const content = + spacing + + contentPrefix + + stringify(jsonValue, multiline, depth, indent); + + // Additions are handled after analyzing all operations + // This is mainly to support array operations which can occur at arbitrary indices + if (node.kind === "object") { + // Object property additions are always added at the end for simplicity + elements.push(content); + } else { + // Add place holders if adding an index past the length + // An empty string is an impossible real value + for (let i = elements.length; i < +propertyOrIndex; ++i) { + elements[i] = ""; + } + if (elements[+propertyOrIndex] === "") { + elements[+propertyOrIndex] = content; + } else { + elements.splice(+propertyOrIndex, 0, content); + } + } + break; + case "remove": + let removalIndex = -1; + if (node.kind === "object") { + removalIndex = elements.findIndex((e) => { + return ( + typeof e != "string" && + e.kind === "keyvalue" && + e.key.value === propertyOrIndex + ); + }); + } else if (node.kind === "array") { + removalIndex = +propertyOrIndex; + } + if (removalIndex === -1) { + continue; + } + + const nodeToRemove = elements[removalIndex]; + if (typeof nodeToRemove === "string") { + // synthetic + elements.splice(removalIndex, 1); + continue; + } + + if (elements.length - 1 === removalIndex) { + // If the element is a terminal element remove the otherwise trailing comma + const commaIndex = findPrecedingComma(nodeToRemove, data.original); + if (commaIndex !== -1) { + data.remove(commaIndex, commaIndex + 1); + removedCommas.add(commaIndex); + } + } + data.remove( + findFullStart(nodeToRemove, data.original), + findFullEnd(nodeToRemove, data.original) + ); + elements.splice(removalIndex, 1); + break; + case "replace": + let nodeToReplace; + if (node.kind === "keyvalue") { + nodeToReplace = node.value; + } else if (node.kind === "array") { + nodeToReplace = elements[+propertyOrIndex]; + if (typeof nodeToReplace === "string") { + // Was already modified. This is already handled. + continue; + } + } else { + continue; + } + + nodeChanges.delete(nodeToReplace); + + data.overwrite( + nodeToReplace.start.offset, + nodeToReplace.end.offset, + stringify(jsonValue, multiline, depth, indent) + ); + break; + } + } + + for (const [node, elements] of nodeChanges.entries()) { + let parentPoint = + 1 + + data.original.indexOf( + node.kind === "array" ? "[" : "{", + node.start.offset + ); + + // Short-circuit for simple case + if (elements.length === 1 && typeof elements[0] === "string") { + data.appendRight(parentPoint, elements[0]); + continue; + } + + // Combine adjecent element additions to minimize/simplify insertions + const optimizedElements: typeof elements = []; + for (let i = 0; i < elements.length; ++i) { + const element = elements[i]; + if ( + typeof element === "string" && + i > 0 && + typeof elements[i - 1] === "string" + ) { + optimizedElements[optimizedElements.length - 1] += "," + element; + } else { + optimizedElements.push(element); + } + } + + let prefixComma = false; + for (const element of optimizedElements) { + if (typeof element === "string") { + data.appendRight(parentPoint, (prefixComma ? "," : "") + element); + } else { + parentPoint = findFullEnd(element, data.original); + prefixComma = + data.original[parentPoint - 1] !== "," || + removedCommas.has(parentPoint - 1); + } + } + } + + const result = data.toString(); + + return result; +} diff --git a/packages/schematic-utils/tsconfig.json b/packages/schematic-utils/tsconfig.json index a2ace8b..94bd495 100644 --- a/packages/schematic-utils/tsconfig.json +++ b/packages/schematic-utils/tsconfig.json @@ -17,5 +17,5 @@ "types": ["jest", "node"] }, "include": ["src/**/*"], - "exclude": ["src/*/files/**/*"] + "exclude": ["src/*/files/**/*", "**/*.spec.ts"] }