From 0b12164a05c754d2fc452c120ad2636c9a376b0a Mon Sep 17 00:00:00 2001 From: Simon Corsin Date: Thu, 23 Jul 2026 13:31:40 -0500 Subject: [PATCH] Add native API versioning and compatibility guards --- bzl/valdi/BUILD.bazel | 21 +- bzl/valdi/common.bzl | 1 + bzl/valdi/empty_android_manifest.xml | 2 + bzl/valdi/valdi_android_application.bzl | 8 +- bzl/valdi/valdi_android_resource_deps.bzl | 19 + bzl/valdi/valdi_application.bzl | 5 + bzl/valdi/valdi_compilation_metadata.bzl | 31 + bzl/valdi/valdi_compiled.bzl | 11 +- bzl/valdi/valdi_config.yaml.tpl | 1 + bzl/valdi/valdi_exported_library.bzl | 13 +- bzl/valdi/valdi_ios_application.bzl | 2 + bzl/valdi/valdi_macos_application.bzl | 2 + bzl/valdi/valdi_projectsync.bzl | 3 + bzl/valdi/valdi_run_compiler.bzl | 7 + bzl/valdi/valdi_test.bzl | 12 +- compiler/companion/src/AST.ts | 31 +- compiler/companion/src/CompilerCompanion.ts | 4 +- .../companion/src/VersioningValidator.spec.ts | 854 ++++++++++++++++++ compiler/companion/src/VersioningValidator.ts | 481 ++++++++++ compiler/companion/src/Workspace.ts | 23 + compiler/companion/src/WorkspaceStore.spec.ts | 42 + compiler/companion/src/WorkspaceStore.ts | 19 +- .../src/cache/CachingWorkspaceFactory.ts | 13 +- .../src/native/NativeCompiler.spec.ts | 32 +- compiler/companion/src/protocol.ts | 4 +- .../Sources/Config/ValdiProjectConfig.swift | 16 +- .../Generation/Cpp/CppFunctionGenerator.swift | 3 +- .../ExportedFunctionGenerator.swift | 1 + .../GeneratedTypesDiagnostics.swift | 326 ++++++- .../ObjC/ObjCFunctionGenerator.swift | 3 +- .../Parser/Models/ValdiRawDocument.swift | 6 +- .../Sources/Pipeline/CompilationItem.swift | 2 +- .../ApplyTypeScriptAnnotationsProcessor.swift | 31 +- .../Processors/DiagnosticsProcessor.swift | 10 +- .../DumpCompilationMetadataProcessor.swift | 28 +- .../Processors/GenerateModelsProcessor.swift | 109 ++- .../GenerateViewClassesProcessor.swift | 17 +- .../GeneratedTypesVerificationProcessor.swift | 2 +- .../NativeCodeGenerationManager.swift | 44 +- .../TypeScriptAnnotationsManager.swift | 4 +- .../Template/CompilationMetadata.swift | 37 + .../TypeScript/CompanionExecutable.swift | 5 +- .../TypeScript/TypeScriptAnnotation.swift | 34 +- .../TypeScript/TypeScriptCommentedFile.swift | 16 + .../TypeScript/TypeScriptCompiler.swift | 6 +- .../TypeScriptCompilerCompanionDriver.swift | 6 +- .../TypeScriptNativeTypeExporter.swift | 96 +- .../TypeScriptNativeTypeResolver.swift | 1 - .../Utils/GeneratedSourceFilename.swift | 1 + .../Sources/ValdiCompilerRunner.swift | 23 +- .../ViewModels/ExportedEnumGenerator.swift | 1 + .../NativeApiMetadataTests.swift | 148 +++ .../CompilerTests/ValdiAnnotationTests.swift | 45 + .../valdi_core/src/CompilerIntrinsics.ts | 20 + .../valdi/valdi_core/src/ValdiRuntime.d.ts | 2 + .../test/CompilerIntrinsics.spec.ts | 18 + .../runtime/JavaScript/JavaScriptRuntime.cpp | 7 + .../runtime/Resources/ResourceManager.cpp | 35 + .../runtime/Resources/ResourceManager.hpp | 5 + valdi/test/integration/Runtime_tests.cpp | 10 + 60 files changed, 2573 insertions(+), 186 deletions(-) create mode 100644 bzl/valdi/empty_android_manifest.xml create mode 100644 bzl/valdi/valdi_android_resource_deps.bzl create mode 100644 bzl/valdi/valdi_compilation_metadata.bzl create mode 100644 compiler/companion/src/VersioningValidator.spec.ts create mode 100644 compiler/companion/src/VersioningValidator.ts create mode 100644 compiler/companion/src/WorkspaceStore.spec.ts create mode 100644 compiler/compiler/Compiler/Tests/CompilerTests/NativeApiMetadataTests.swift create mode 100644 src/valdi_modules/src/valdi/valdi_core/test/CompilerIntrinsics.spec.ts diff --git a/bzl/valdi/BUILD.bazel b/bzl/valdi/BUILD.bazel index 16aaf65b..492922d1 100644 --- a/bzl/valdi/BUILD.bazel +++ b/bzl/valdi/BUILD.bazel @@ -1,4 +1,4 @@ -load("@bazel_skylib//rules:common_settings.bzl", "bool_flag", "string_flag") +load("@bazel_skylib//rules:common_settings.bzl", "bool_flag", "int_flag", "string_flag") load("@valdi//bzl/conditions:selects.bzl", "selects") load("@valdi//bzl/valdi:valdi_toolchain.bzl", "valdi_toolchain") load("@valdi//bzl/valdi:valdi_toolchain_binary.bzl", "valdi_toolchain_binary") @@ -13,6 +13,7 @@ exports_files([ "empty.swift", "empty.kt", "empty.js", + "empty_android_manifest.xml", "Empty.bundle/Info.plist", "Info.plist", "package.json.tmpl", @@ -49,6 +50,24 @@ string_flag( visibility = ["//visibility:public"], ) +int_flag( + name = "native_api_min_version", + build_setting_default = -1, + visibility = ["//visibility:public"], +) + +filegroup( + name = "empty_api_version_file", + srcs = [], + visibility = ["//visibility:public"], +) + +label_flag( + name = "api_version_file", + build_setting_default = ":empty_api_version_file", + visibility = ["//visibility:public"], +) + string_flag( name = "assets_mode", build_setting_default = "bundle", diff --git a/bzl/valdi/common.bzl b/bzl/valdi/common.bzl index 56e89a52..140e1bfa 100644 --- a/bzl/valdi/common.bzl +++ b/bzl/valdi/common.bzl @@ -73,6 +73,7 @@ BUILD_DIR = ".valdi_build/compile" TYPESCRIPT_OUTPUT_DIR = paths.join(BUILD_DIR, "typescript/output") TYPESCRIPT_GENERATED_TS_DIR = paths.join(BUILD_DIR, "generated_ts") TYPESCRIPT_DUMPED_SYMBOLS_DIR = paths.join(BUILD_DIR, "typescript/dumped_symbols") +COMPILATION_METADATA_FILENAME = "compilation-metadata.json" def base_relative_dir(platform, output_target, relative_dir): """Helper function for constructing paths relative to the _BASE_DIR. diff --git a/bzl/valdi/empty_android_manifest.xml b/bzl/valdi/empty_android_manifest.xml new file mode 100644 index 00000000..47185687 --- /dev/null +++ b/bzl/valdi/empty_android_manifest.xml @@ -0,0 +1,2 @@ + + diff --git a/bzl/valdi/valdi_android_application.bzl b/bzl/valdi/valdi_android_application.bzl index e9f24dbe..112b2c43 100644 --- a/bzl/valdi/valdi_android_application.bzl +++ b/bzl/valdi/valdi_android_application.bzl @@ -7,6 +7,7 @@ load( "generate_valdi_android_application_icons", _valdi_android_application_icons = "valdi_android_application_icons", ) +load("//bzl/valdi:valdi_android_resource_deps.bzl", "valdi_android_resource_deps") load("//bzl/valdi/source_set:utils.bzl", "source_set_select") def valdi_android_application_icons(src, round_src = None): @@ -34,10 +35,15 @@ def valdi_android_application( round_icon_name = None, activity_theme_name = None, deps = [], + resources = [], native_deps = []): src_target = "{}_src".format(name) src_activity_target = "{}_activitygen".format(name) aar_target = "{}_aar".format(name) + resource_deps = valdi_android_resource_deps( + name = "{}_resources".format(name), + resources = resources, + ) generated_app_icons = generate_valdi_android_application_icons( name, @@ -123,5 +129,5 @@ def valdi_android_application( deps = [ ":{}".format(src_target), ":{}_import".format(aar_target), - ], + ] + resource_deps, ) diff --git a/bzl/valdi/valdi_android_resource_deps.bzl b/bzl/valdi/valdi_android_resource_deps.bzl new file mode 100644 index 00000000..1390606e --- /dev/null +++ b/bzl/valdi/valdi_android_resource_deps.bzl @@ -0,0 +1,19 @@ +"""Helpers for packaging cross-platform Valdi resources.""" + +load("@rules_android//rules:rules.bzl", "android_library") + +def valdi_android_resource_deps(name, resources): + """Creates an Android asset dependency for cross-platform resources.""" + if not resources: + return [] + + android_library( + name = name, + assets = resources, + # Keep these resources independent of the application's assets_dir. + # The empty root packages source resources relative to their owning + # Bazel package and generated resources relative to their output root. + assets_dir = "", + manifest = "@valdi//bzl/valdi:empty_android_manifest.xml", + ) + return [":{}".format(name)] diff --git a/bzl/valdi/valdi_application.bzl b/bzl/valdi/valdi_application.bzl index 8268bf81..cdd696b2 100644 --- a/bzl/valdi/valdi_application.bzl +++ b/bzl/valdi/valdi_application.bzl @@ -58,8 +58,10 @@ def valdi_application( desktop_window_width = 600, desktop_window_height = 800, desktop_window_resizable = True, + resources = [], version = None, deps = []): + resources = resources + [Label("//bzl/valdi:api_version_file")] resolved_ios_bundle_id = ios_bundle_id if ios_bundle_id else "com.snap.valdi.{}".format(name) resolved_android_package = android_package if android_package else "com.snap.valdi.{}".format(name) resolved_app_icons = icons if icons != None else app_icons @@ -91,6 +93,7 @@ def valdi_application( minimum_os_version = ios_minimum_os_version, provisioning_profile = ios_provisioning_profile, app_icons = ios_app_icons, + resources = resources, version = version, deps = get_suffixed_deps(deps, "_objc"), ) @@ -109,6 +112,7 @@ def valdi_application( round_icon_name = android_round_app_icon_name, activity_theme_name = android_activity_theme_name, deps = get_suffixed_deps(deps, "_kt"), + resources = resources, native_deps = get_suffixed_deps(deps, "_native"), ) @@ -121,6 +125,7 @@ def valdi_application( window_height = desktop_window_height, window_resizable = desktop_window_resizable, app_icons = macos_app_icons, + resources = resources, deps = get_suffixed_deps(deps, "_native"), ) diff --git a/bzl/valdi/valdi_compilation_metadata.bzl b/bzl/valdi/valdi_compilation_metadata.bzl new file mode 100644 index 00000000..96faad94 --- /dev/null +++ b/bzl/valdi/valdi_compilation_metadata.bzl @@ -0,0 +1,31 @@ +"""Collects compilation metadata from a transitive graph of Valdi modules.""" + +load(":common.bzl", "COMPILATION_METADATA_FILENAME") +load(":valdi_compiled.bzl", "ValdiModuleInfo") + +def _valdi_compilation_metadata_impl(ctx): + intermediates = depset( + transitive = [ + dep[ValdiModuleInfo].intermediates + for dep in ctx.attr.deps + ], + ) + metadata = [ + file + for file in intermediates.to_list() + if file.basename == COMPILATION_METADATA_FILENAME + ] + return [DefaultInfo(files = depset(metadata))] + +valdi_compilation_metadata = rule( + implementation = _valdi_compilation_metadata_impl, + doc = "Returns compilation-metadata.json artifacts for the transitive closure of Valdi module dependencies.", + attrs = { + "deps": attr.label_list( + mandatory = True, + cfg = "exec", + providers = [ValdiModuleInfo], + doc = "Valdi modules whose transitive compilation metadata should be collected.", + ), + }, +) diff --git a/bzl/valdi/valdi_compiled.bzl b/bzl/valdi/valdi_compiled.bzl index aa27b952..a804419d 100644 --- a/bzl/valdi/valdi_compiled.bzl +++ b/bzl/valdi/valdi_compiled.bzl @@ -12,6 +12,7 @@ load( "common.bzl", "ANDROID_RESOURCE_VARIANT_DIRECTORIES", "BUILD_DIR", + "COMPILATION_METADATA_FILENAME", "IOS_API_NAME_SUFFIX", "IOS_DEFAULT_MODULE_NAME_PREFIX", "IOS_OUTPUT_BASE", @@ -374,6 +375,9 @@ valdi_compiled = rule( allow_single_file = True, default = "valdi_config.yaml.tpl", ), + "_native_api_min_version": attr.label( + default = "@valdi//bzl/valdi:native_api_min_version", + ), }, ) @@ -1438,7 +1442,7 @@ def _get_ios_image_resources_paths(module_name, resources_basenames): return [debug_images, release_images] def _get_dumped_compilation_metadata(module_name): - return paths.join(TYPESCRIPT_DUMPED_SYMBOLS_DIR, module_name, "compilation-metadata.json") + return paths.join(TYPESCRIPT_DUMPED_SYMBOLS_DIR, module_name, COMPILATION_METADATA_FILENAME) def _get_dependency_data_path(module_name): return base_relative_dir("ios", "metadata", "dependencyData.json") @@ -1897,7 +1901,7 @@ def _extract_dts_files(srcs): return [f for f in srcs if f.basename.endswith(".d.ts")] def _extract_dumped_compilation_metadata(files): - return [f for f in files if f.basename.endswith("compilation-metadata.json")] + return [f for f in files if f.basename == COMPILATION_METADATA_FILENAME] def _extract_valdi_module_android(output_target, module_name, outputs): debug_path, release_path = _get_android_valdi_module_paths(module_name) @@ -2319,5 +2323,8 @@ valdi_hotreload = rule( allow_single_file = True, default = "valdi_config.yaml.tpl", ), + "_native_api_min_version": attr.label( + default = "@valdi//bzl/valdi:native_api_min_version", + ), }, ) diff --git a/bzl/valdi/valdi_config.yaml.tpl b/bzl/valdi/valdi_config.yaml.tpl index 7f127c8b..c491c010 100644 --- a/bzl/valdi/valdi_config.yaml.tpl +++ b/bzl/valdi/valdi_config.yaml.tpl @@ -58,3 +58,4 @@ node_modules_target: {NODE_MODULES_TARGET} node_modules_workspace: {NODE_MODULES_WORKSPACE} external_modules_target: {EXTERNAL_MODULES_TARGET} external_modules_workspace: {EXTERNAL_MODULES_WORKSPACE} +{NATIVE_API_MIN_VERSION_CONFIG} diff --git a/bzl/valdi/valdi_exported_library.bzl b/bzl/valdi/valdi_exported_library.bzl index 30f8dfda..d8c4a10a 100644 --- a/bzl/valdi/valdi_exported_library.bzl +++ b/bzl/valdi/valdi_exported_library.bzl @@ -4,6 +4,7 @@ load("//bzl:expand_template.bzl", "expand_template") load("//bzl/android:collect_android_assets.bzl", "collect_android_assets") load("//bzl/valdi:rewrite_hdrs.bzl", "rewrite_hdrs") load("//bzl/valdi:suffixed_deps.bzl", "get_suffixed_deps") +load("//bzl/valdi:valdi_android_resource_deps.bzl", "valdi_android_resource_deps") load("//bzl/valdi:valdi_collapse_web_paths.bzl", "collapse_native_paths", "collapse_web_paths", "generate_native_module_map", "generate_register_native_modules") load("//bzl/valdi:valdi_protodecl_to_js.bzl", "collapse_protodecl_paths", "protodecl_to_js_dir") load("//bzl/valdi/source_set:utils.bzl", "source_set_select") @@ -37,7 +38,8 @@ def valdi_exported_library( web_package_name = None, npm_scope = "", npm_version = "1.0.0", - web_exclude_jsx_global_declaration = False): + web_exclude_jsx_global_declaration = False, + resources = []): """Exports Valdi modules as platform-specific libraries (xcframework, aar, npm). Args: @@ -48,9 +50,15 @@ def valdi_exported_library( Only needed for multi-module exports with cross-module Swift imports. ValdiCoreSwift is always included automatically. """ + resources = resources + [Label("//bzl/valdi:api_version_file")] if not web_package_name: web_package_name = "{}_npm".format(name) + android_resource_deps = valdi_android_resource_deps( + name = "{}_resources_android".format(name), + resources = resources, + ) + ios_public_hdrs_name = "{}_ios_hdrs".format(name) rewrite_hdrs( name = ios_public_hdrs_name, @@ -113,6 +121,7 @@ done | sed '/^import ValdiCoreSwift$$/d' > $@ apple_xcframework( name = "{}_ios".format(name), bundle_name = ios_bundle_name, + data = resources, deps = xcframework_deps, infoplists = [ "@valdi//bzl/valdi:Info.plist", @@ -135,7 +144,7 @@ done | sed '/^import ValdiCoreSwift$$/d' > $@ public_hdrs = [":{}".format(ios_public_hdrs_name)], ) - java_deps = java_deps + get_suffixed_deps(deps, "_kt") + java_deps = java_deps + get_suffixed_deps(deps, "_kt") + android_resource_deps collect_android_assets( name = "{}_android_assets".format(name), diff --git a/bzl/valdi/valdi_ios_application.bzl b/bzl/valdi/valdi_ios_application.bzl index e0a35252..f21e7813 100644 --- a/bzl/valdi/valdi_ios_application.bzl +++ b/bzl/valdi/valdi_ios_application.bzl @@ -28,6 +28,7 @@ def valdi_ios_application( minimum_os_version = None, provisioning_profile = None, app_icons = None, + resources = [], version = None, deps = []): main_target = "{}_maingen".format(name) @@ -96,6 +97,7 @@ def valdi_ios_application( minimum_os_version = minimum_os_version, provisioning_profile = provisioning_profile, app_icons = generate_valdi_ios_application_icons(name, app_icons), + resources = resources, version = resolved_version, tags = ["valdi_ios_application"], visibility = ["//visibility:public"], diff --git a/bzl/valdi/valdi_macos_application.bzl b/bzl/valdi/valdi_macos_application.bzl index 3211ff09..56a2b26c 100644 --- a/bzl/valdi/valdi_macos_application.bzl +++ b/bzl/valdi/valdi_macos_application.bzl @@ -18,6 +18,7 @@ def valdi_macos_application( window_height, window_resizable, app_icons = None, + resources = [], deps = []): main_target = "{}_maingen".format(name) plist_target = "{}_plist".format(name) @@ -61,6 +62,7 @@ def valdi_macos_application( deps = [":{}".format(src_target)], minimum_os_version = "15.0", app_icons = generate_valdi_macos_application_icons(name, app_icons), + resources = resources, tags = ["valdi_macos_application"], visibility = ["//visibility:public"], ) diff --git a/bzl/valdi/valdi_projectsync.bzl b/bzl/valdi/valdi_projectsync.bzl index 685b7f8b..0fa0f9ab 100644 --- a/bzl/valdi/valdi_projectsync.bzl +++ b/bzl/valdi/valdi_projectsync.bzl @@ -230,5 +230,8 @@ valdi_projectsync = rule( allow_single_file = True, default = "valdi_config.yaml.tpl", ), + "_native_api_min_version": attr.label( + default = "@valdi//bzl/valdi:native_api_min_version", + ), }, ) diff --git a/bzl/valdi/valdi_run_compiler.bzl b/bzl/valdi/valdi_run_compiler.bzl index 0d65c65f..b33e6f0d 100644 --- a/bzl/valdi/valdi_run_compiler.bzl +++ b/bzl/valdi/valdi_run_compiler.bzl @@ -2,6 +2,7 @@ load( "common.bzl", "NODE_MODULES_BASE", ) +load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo") load(":valdi_toolchain_type.bzl", "VALDI_TOOLCHAIN_TYPE") # TODO: modify the compiler so that we don't need to pass in the config file and instead can just pass in all of these options as arguments @@ -18,6 +19,11 @@ def generate_config(ctx): companion_path = toolchain.companion.files.to_list()[0].path minify_config_path = toolchain.minify_config.files.to_list()[0].path compiler_toolbox_path = toolchain.compiler_toolbox.files.to_list()[0].path + native_api_min_version = ctx.attr._native_api_min_version[BuildSettingInfo].value + + native_api_min_version_config = "" + if native_api_min_version >= 0: + native_api_min_version_config = "native_api_min_version: {}".format(native_api_min_version) ctx.actions.expand_template( output = out, @@ -29,6 +35,7 @@ def generate_config(ctx): "{MINIFY_CONFIG_PATH}": "$PWD/" + minify_config_path, "{COMPILER_TOOLBOX_PATH}": "$PWD/" + compiler_toolbox_path, "{NODE_MODULES_DIR}": NODE_MODULES_BASE, + "{NATIVE_API_MIN_VERSION_CONFIG}": native_api_min_version_config, }, ) return out diff --git a/bzl/valdi/valdi_test.bzl b/bzl/valdi/valdi_test.bzl index 49dd6495..18855761 100644 --- a/bzl/valdi/valdi_test.bzl +++ b/bzl/valdi/valdi_test.bzl @@ -32,7 +32,8 @@ def _valdi_test_impl(ctx): # Always include standalone module runfiles valdimodules = _collect_target_runfiles(ctx.attr.target) valdimodules += _collect_target_runfiles(ctx.attr._valdi_standalone) - module_paths = ["--module_path {}".format(f.short_path) for f in valdimodules] + runtime_files = valdimodules + ctx.files._api_version_file + module_paths = ["--module_path {}".format(f.short_path) for f in runtime_files] test_script = ctx.actions.declare_file("test_wrapper.sh") if has_tests: @@ -64,10 +65,10 @@ def _valdi_test_impl(ctx): ) runfiles = ctx.runfiles( - files = [standalone_binary] + valdimodules, + files = [standalone_binary] + runtime_files, ) - return [DefaultInfo(executable = test_script, files = depset([standalone_binary] + valdimodules), runfiles = runfiles)] + return [DefaultInfo(executable = test_script, files = depset([standalone_binary] + runtime_files), runfiles = runfiles)] valdi_test = rule( implementation = _valdi_test_impl, @@ -99,6 +100,11 @@ valdi_test = rule( default = Label("@valdi//bzl/valdi:code_coverage_enabled"), doc = "Enable code coverage collection during tests", ), + "_api_version_file": attr.label( + default = Label("//bzl/valdi:api_version_file"), + allow_files = True, + doc = "Optional repository API-version resource exposed to the standalone test runtime", + ), "_valdi_standalone": attr.label( default = Label("@valdi//src/valdi_modules/src/valdi/valdi_standalone"), doc = "The Valdi Standalone target to be added to all Valdi targets", diff --git a/compiler/companion/src/AST.ts b/compiler/companion/src/AST.ts index 8c1ccc39..6200a1eb 100644 --- a/compiler/companion/src/AST.ts +++ b/compiler/companion/src/AST.ts @@ -455,6 +455,22 @@ export interface DumpedRootNode { exportedTypeAlias?: string; } +const GENERATE_OR_EXPORT_ANNOTATION_REGEX = /@Generate|@Export/; +const NATIVE_ANNOTATION_REGEX = /@Native/; +const EXPORT_MODULE_ANNOTATION_REGEX = /@ExportModule/; + +export function hasGenerateOrExportAnnotation(comments: string): boolean { + return GENERATE_OR_EXPORT_ANNOTATION_REGEX.test(comments); +} + +export function hasNativeExportAnnotation(comments: string): boolean { + return hasGenerateOrExportAnnotation(comments) || NATIVE_ANNOTATION_REGEX.test(comments); +} + +export function hasExportModuleAnnotation(comments: string): boolean { + return EXPORT_MODULE_ANNOTATION_REGEX.test(comments); +} + function isExportedSymbolOrHasAnnotation(nodeToDump: NodeToDump, shouldDumpAllExportedSymbols: boolean): boolean { if (shouldDumpAllExportedSymbols && isNodeExported(nodeToDump.node)) { return true; @@ -465,19 +481,11 @@ function isExportedSymbolOrHasAnnotation(nodeToDump: NodeToDump, shouldDumpAllEx return false; } const comments = nodeToDump.leadingComments.text; - const hasMatch = !!comments.match(/@Generate|@Export|@Component|@ViewModel|@Context|@Native/g); + const hasMatch = hasNativeExportAnnotation(comments) || !!comments.match(/@Component|@ViewModel|@Context/g); return hasMatch; } -function hasExportModuleAnnotation(nodeToDump: NodeToDump): boolean { - if (!nodeToDump.leadingComments) { - return false; - } - - return !!nodeToDump.leadingComments.text.match(/@ExportModule/g); -} - function shouldDumpNodeMember(nodeToDump: NodeToDump, memberNode: ts.TypeElement | ts.ClassElement): boolean { const nodeComments = nodeToDump.leadingComments?.text; if (!nodeComments) { @@ -488,7 +496,7 @@ function shouldDumpNodeMember(nodeToDump: NodeToDump, memberNode: ts.TypeElement return false; } else if (nodeComments.match(/@Component/)) { return !!(getNodeComments(memberNode)?.text ?? '').match(/@Action|@ConstructorOmitted/g); - } else if (nodeComments.match(/@Generate|@Export/)) { + } else if (hasGenerateOrExportAnnotation(nodeComments)) { return true; } else { return false; @@ -602,7 +610,8 @@ export function dumpRootNodes(sourceFile: ts.SourceFile, astReferences: AST.Type // Need to dump all exported symbols for .vue user scripts, or for modules // annotated with @ExportModule const shouldDumpAllExportedSymbols = - !!sourceFile.fileName.match(/\.vue\.ts(x)?$/g) || hasExportModuleAnnotation(rootNodes[0]); + !!sourceFile.fileName.match(/\.vue\.ts(x)?$/g) || + hasExportModuleAnnotation(rootNodes[0].leadingComments?.text ?? ''); for (const rootNode of rootNodes) { if (isExportedSymbolOrHasAnnotation(rootNode, shouldDumpAllExportedSymbols)) { nodesToDump.push(rootNode); diff --git a/compiler/companion/src/CompilerCompanion.ts b/compiler/companion/src/CompilerCompanion.ts index adaf4e4e..9469f939 100644 --- a/compiler/companion/src/CompilerCompanion.ts +++ b/compiler/companion/src/CompilerCompanion.ts @@ -102,7 +102,7 @@ export class CompilerCompanion extends CompanionServiceBase { }); this.addEndpoint(Command.createWorkspace, async (body) => { - const workspaceId = this.workspaceStore.createWorkspace().workspaceId; + const workspaceId = this.workspaceStore.createWorkspace(body.nativeApiMinVersion).workspaceId; return { workspaceId }; }); @@ -270,7 +270,7 @@ export class CompilerCompanion extends CompanionServiceBase { this.addEndpoint(Command.compileNative, async (body) => { let workspaceId: number; if (!body.workspaceId && body.registerInputFiles) { - workspaceId = this.workspaceStore.createWorkspace().workspaceId; + workspaceId = this.workspaceStore.createWorkspace(undefined).workspaceId; } else { workspaceId = body.workspaceId; } diff --git a/compiler/companion/src/VersioningValidator.spec.ts b/compiler/companion/src/VersioningValidator.spec.ts new file mode 100644 index 00000000..c0d37ede --- /dev/null +++ b/compiler/companion/src/VersioningValidator.spec.ts @@ -0,0 +1,854 @@ +import 'ts-jest'; +import * as ts from 'typescript'; +import { Workspace } from './Workspace'; + +function createWorkspaceWithFile(contents: string, nativeApiMinVersion: number | undefined): Workspace { + const workspace = new Workspace( + '/', + false, + undefined, + { + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.CommonJS, + lib: ['lib.es2015.d.ts'], + strict: true, + }, + nativeApiMinVersion, + ); + + workspace.registerInMemoryFile('/file.ts', contents); + workspace.addSourceFileAtPath('/file.ts'); + return workspace; +} + +function getDiagnosticTexts(contents: string, nativeApiMinVersion?: number): string[] { + const workspace = createWorkspaceWithFile(contents, nativeApiMinVersion); + const diagnostics = workspace.getDiagnosticsSync('/file.ts').diagnostics; + workspace.destroy(); + return diagnostics.map((diagnostic) => diagnostic.text); +} + +describe('VersioningValidator', () => { + it('allows versioned properties inside a sufficient version guard', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + title: string; + // @Version(43) + subtitle?: string; + } + + function render(model: MyModel) { + if (isVersionAtLeast(43)) { + model.subtitle; + } + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('rejects versioned properties inside an insufficient version guard', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + title: string; + // @Version(43) + subtitle?: string; + } + + function render(model: MyModel) { + if (isVersionAtLeast(42)) { + model.subtitle; + } + } + `); + + expect(diagnostics).toEqual([ + "Property 'subtitle' requires @Version(43) or an enclosing isVersionAtLeast(43) block", + ]); + }); + + it('rejects versioned properties outside a version guard', () => { + const diagnostics = getDiagnosticTexts(` + interface MyModel { + title: string; + // @Version(43) + subtitle?: string; + } + + function render(model: MyModel) { + model.subtitle; + } + `); + + expect(diagnostics).toEqual([ + "Property 'subtitle' requires @Version(43) or an enclosing isVersionAtLeast(43) block", + ]); + }); + + it('rejects placeholder-versioned properties outside a version guard', () => { + const diagnostics = getDiagnosticTexts(` + interface MyModel { + title: string; + // @Version(__PLACEHOLDER__) + subtitle?: string; + } + + function render(model: MyModel) { + model.subtitle; + } + `); + + expect(diagnostics).toEqual([ + "Property 'subtitle' requires @Version(__PLACEHOLDER__) or an enclosing isVersionAtLeast(__PLACEHOLDER__) block", + ]); + }); + + it('allows placeholder-versioned properties inside a placeholder version guard', () => { + const diagnostics = getDiagnosticTexts(` + declare const __PLACEHOLDER__: number; + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + // @Version(__PLACEHOLDER__) + subtitle?: string; + } + + function render(model: MyModel) { + if (isVersionAtLeast(__PLACEHOLDER__)) { + model.subtitle; + } + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('allows placeholder-versioned properties inside a max safe integer version guard', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + // @Version(__PLACEHOLDER__) + subtitle?: string; + } + + function render(model: MyModel) { + if (isVersionAtLeast(9007199254740991)) { + model.subtitle; + } + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('applies the highest nested version guard to child blocks only', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + // @Version(42) + subtitle?: string; + // @Version(43) + detail?: string; + } + + function render(model: MyModel) { + if (isVersionAtLeast(42)) { + model.subtitle; + if (isVersionAtLeast(43)) { + model.detail; + } + model.detail; + } + } + `); + + expect(diagnostics).toEqual(["Property 'detail' requires @Version(43) or an enclosing isVersionAtLeast(43) block"]); + }); + + it('does not apply the then branch version guard to the else branch', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + // @Version(42) + subtitle?: string; + } + + function render(model: MyModel) { + if (isVersionAtLeast(42)) { + model.subtitle; + } else { + model.subtitle; + } + } + `); + + expect(diagnostics).toEqual([ + "Property 'subtitle' requires @Version(42) or an enclosing isVersionAtLeast(42) block", + ]); + }); + + it('allows versioned properties in the right side and body of a version-guarded && condition', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + // @Version(42) + subtitle?: string; + } + + function render(model: MyModel): string | undefined { + if (isVersionAtLeast(42) && model.subtitle) { + return model.subtitle; + } + return undefined; + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('keeps && condition guards order-sensitive for short-circuit evaluation', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + // @Version(42) + subtitle?: string; + } + + function render(model: MyModel): string | undefined { + if (model.subtitle && isVersionAtLeast(42)) { + return model.subtitle; + } + return undefined; + } + `); + + expect(diagnostics).toEqual([ + "Property 'subtitle' requires @Version(42) or an enclosing isVersionAtLeast(42) block", + ]); + }); + + it('rejects versioned properties in && conditions guarded by an insufficient version', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + // @Version(43) + subtitle?: string; + } + + function render(model: MyModel): string | undefined { + if (isVersionAtLeast(42) && model.subtitle) { + return model.subtitle; + } + return undefined; + } + `); + + expect(diagnostics).toEqual([ + "Property 'subtitle' requires @Version(43) or an enclosing isVersionAtLeast(43) block", + "Property 'subtitle' requires @Version(43) or an enclosing isVersionAtLeast(43) block", + ]); + }); + + it('combines parenthesized and nested && version guards', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + interface MyModel { + // @Version(42) + subtitle?: string; + // @Version(43) + detail?: string; + } + + declare function isReady(): boolean; + + function render(model: MyModel): string | undefined { + if ((isReady() && isVersionAtLeast(42)) && (isVersionAtLeast(43) && model.detail)) { + model.subtitle; + return model.detail; + } + return undefined; + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('allows versioned properties inside a sufficiently versioned function body', () => { + const diagnostics = getDiagnosticTexts(` + interface MyModel { + // @Version(42) + subtitle?: string; + } + + // @Version(42) + function render(model: MyModel) { + model.subtitle; + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('rejects versioned properties inside an insufficiently versioned function body', () => { + const diagnostics = getDiagnosticTexts(` + interface MyModel { + // @Version(43) + subtitle?: string; + } + + // @Version(42) + function render(model: MyModel) { + model.subtitle; + } + `); + + expect(diagnostics).toEqual([ + "Property 'subtitle' requires @Version(43) or an enclosing isVersionAtLeast(43) block", + ]); + }); + + it('allows nested lambdas created inside a far outer version guard to use versioned properties', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + declare function run(callback: () => string | undefined): string | undefined; + + interface MyModel { + // @Version(42) + subtitle?: string; + } + + function render(model: MyModel): string | undefined { + if (isVersionAtLeast(42)) { + const renderLater = () => { + const renderNested = () => { + return model.subtitle; + }; + return renderNested(); + }; + + return run(() => renderLater()); + } + + return undefined; + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('allows nested lambdas created inside a far outer && version guard to use versioned properties', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + declare function run(callback: () => string | undefined): string | undefined; + + interface MyModel { + // @Version(42) + subtitle?: string; + } + + declare function isReady(): boolean; + + function render(model: MyModel): string | undefined { + if (isReady() && isVersionAtLeast(42)) { + return run(() => { + const renderNested = () => model.subtitle; + return renderNested(); + }); + } + + return undefined; + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('allows calls to versioned functions inside a sufficient version guard', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + // @Version(42) + function renderLabelNew() {} + + function render() { + if (isVersionAtLeast(42)) { + renderLabelNew(); + } + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('rejects calls to versioned functions outside a sufficient version guard', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(42) + function renderLabelNew() {} + + function render() { + renderLabelNew(); + } + `); + + expect(diagnostics).toEqual(['Function call requires @Version(42) or an enclosing isVersionAtLeast(42) block']); + }); + + it('rejects calls to versioned functions inside an insufficient version guard', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + // @Version(43) + function renderLabelNew() {} + + function render() { + if (isVersionAtLeast(42)) { + renderLabelNew(); + } + } + `); + + expect(diagnostics).toEqual(['Function call requires @Version(43) or an enclosing isVersionAtLeast(43) block']); + }); + + it('allows calls to versioned methods inside a sufficient version guard', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + class Renderer { + // @Version(42) + renderLabelNew() {} + } + + function render(renderer: Renderer) { + if (isVersionAtLeast(42)) { + renderer.renderLabelNew(); + } + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('rejects calls to versioned methods outside a sufficient version guard', () => { + const diagnostics = getDiagnosticTexts(` + class Renderer { + // @Version(42) + renderLabelNew() {} + } + + function render(renderer: Renderer) { + renderer.renderLabelNew(); + } + `); + + expect(diagnostics).toEqual(['Function call requires @Version(42) or an enclosing isVersionAtLeast(42) block']); + }); + + it('rejects calls to placeholder-versioned functions outside a version guard', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(__PLACEHOLDER__) + function renderLabelNew() {} + + function render() { + renderLabelNew(); + } + `); + + expect(diagnostics).toEqual([ + 'Function call requires @Version(__PLACEHOLDER__) or an enclosing isVersionAtLeast(__PLACEHOLDER__) block', + ]); + }); + + it('allows calls to placeholder-versioned functions inside a placeholder version guard', () => { + const diagnostics = getDiagnosticTexts(` + declare const __PLACEHOLDER__: number; + declare function isVersionAtLeast(version: number): boolean; + + // @Version(__PLACEHOLDER__) + function renderLabelNew() {} + + function render() { + if (isVersionAtLeast(__PLACEHOLDER__)) { + renderLabelNew(); + } + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('requires declarations exposing versioned types to be versioned', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(42) + interface NewModel { + title: string; + } + + function render(model: NewModel) {} + `); + + expect(diagnostics).toEqual(["Type 'NewModel' requires @Version(42) on the containing declaration"]); + }); + + it('requires declarations exposing placeholder-versioned types to be placeholder-versioned', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(__PLACEHOLDER__) + interface NewModel { + title: string; + } + + function render(model: NewModel) {} + `); + + expect(diagnostics).toEqual(["Type 'NewModel' requires @Version(__PLACEHOLDER__) on the containing declaration"]); + }); + + it('allows placeholder-versioned declarations exposing placeholder-versioned types', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(__PLACEHOLDER__) + interface NewModel { + title: string; + } + + // @Version(__PLACEHOLDER__) + function render(model: NewModel): NewModel { + return model; + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('rejects versioned declarations that expose newer types', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(43) + interface NewModel { + title: string; + } + + // @Version(42) + function render(model: NewModel) {} + `); + + expect(diagnostics).toEqual(["Type 'NewModel' requires @Version(43) on the containing declaration"]); + }); + + it('allows declarations exposing older versioned types', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(42) + interface NewModel { + title: string; + } + + // @Version(43) + function render(model: NewModel): NewModel { + return model; + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('requires return types exposing versioned types to be versioned', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(42) + interface NewModel { + title: string; + } + + function makeModel(): NewModel { + return { title: 'title' }; + } + `); + + expect(diagnostics).toEqual(["Type 'NewModel' requires @Version(42) on the containing declaration"]); + }); + + it('requires interfaces extending versioned types to be versioned', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(42) + interface NewModel { + title: string; + } + + interface ExtendedModel extends NewModel { + subtitle: string; + } + `); + + expect(diagnostics).toEqual(["Type 'NewModel' requires @Version(42) on the containing declaration"]); + }); + + it('allows interfaces extending older versioned types', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(42) + interface NewModel { + title: string; + } + + // @Version(43) + interface ExtendedModel extends NewModel { + subtitle: string; + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('requires interface methods exposing versioned types to be versioned', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(42) + interface NewModel { + title: string; + } + + interface Renderer { + render(model: NewModel): void; + } + `); + + expect(diagnostics).toEqual(["Type 'NewModel' requires @Version(42) on the containing declaration"]); + }); + + it('allows versioned interface methods exposing compatible versioned types', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(42) + interface NewModel { + title: string; + } + + interface Renderer { + // @Version(42) + render(model: NewModel): void; + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('allows versioned properties exposing compatible versioned types', () => { + const diagnostics = getDiagnosticTexts(` + // @Version(42) + interface NewModel { + title: string; + } + + interface Renderer { + // @Version(42) + model: NewModel; + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('validates isVersionAtLeast rejects non-literal arguments', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(version: number): boolean; + + const version = 42; + if (isVersionAtLeast(version)) { + } + `); + + expect(diagnostics).toEqual(['isVersionAtLeast expects exactly one numeric literal or __PLACEHOLDER__ argument']); + }); + + it('validates isVersionAtLeast rejects missing arguments', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(...versions: number[]): boolean; + + if (isVersionAtLeast()) { + } + `); + + expect(diagnostics).toEqual(['isVersionAtLeast expects exactly one numeric literal or __PLACEHOLDER__ argument']); + }); + + it('validates isVersionAtLeast rejects extra arguments', () => { + const diagnostics = getDiagnosticTexts(` + declare function isVersionAtLeast(...versions: number[]): boolean; + + if (isVersionAtLeast(42, 43)) { + } + `); + + expect(diagnostics).toEqual(['isVersionAtLeast expects exactly one numeric literal or __PLACEHOLDER__ argument']); + }); + + it('preserves unchecked native contracts when the workspace minimum is disabled', () => { + const diagnostics = getDiagnosticTexts(` + // @ExportModel + export interface NativeModel { + futureValue?: string; + } + + function render(model: NativeModel) { + model.futureValue; + } + `); + + expect(diagnostics).toEqual([]); + }); + + it('allows implicitly versioned native members at the workspace minimum', () => { + const diagnostics = getDiagnosticTexts( + ` + // @ExportModel + export interface NativeModel { + value: string; + } + + function render(model: NativeModel) { + model.value; + } + `, + 2, + ); + + expect(diagnostics).toEqual([]); + }); + + it('lets an explicit member version override native-contract inheritance', () => { + const diagnostics = getDiagnosticTexts( + ` + // @ExportModel + export interface NativeModel { + // @Version(1) + existingValue: string; + // @Version(3) + futureValue?: string; + } + + function render(model: NativeModel) { + model.existingValue; + model.futureValue; + } + `, + 2, + ); + + expect(diagnostics).toEqual([ + "Property 'futureValue' requires @Version(3) or an enclosing isVersionAtLeast(3) block", + ]); + }); + + it('lets a placeholder member version override native-contract inheritance', () => { + const diagnostics = getDiagnosticTexts( + ` + // @ExportModel + export interface NativeModel { + // @Version(__PLACEHOLDER__) + futureValue?: string; + } + + function render(model: NativeModel) { + model.futureValue; + } + `, + 2, + ); + + expect(diagnostics).toEqual([ + "Property 'futureValue' requires @Version(__PLACEHOLDER__) or an enclosing isVersionAtLeast(__PLACEHOLDER__) block", + ]); + }); + + it('allows native members above the workspace minimum inside a sufficient guard', () => { + const diagnostics = getDiagnosticTexts( + ` + declare function isVersionAtLeast(version: number): boolean; + + // @NativeInterface + export interface NativeModel { + // @Version(3) + futureValue?: string; + } + + function render(model: NativeModel) { + if (isVersionAtLeast(3)) { + model.futureValue; + } + } + `, + 2, + ); + + expect(diagnostics).toEqual([]); + }); + + it('applies explicit versions to members inherited from native contracts', () => { + const diagnostics = getDiagnosticTexts( + ` + // @ExportProxy + export interface NativeBase { + // @Version(3) + futureValue?: string; + } + + export interface NativeDerived extends NativeBase {} + + function render(model: NativeDerived) { + model.futureValue; + } + `, + 2, + ); + + expect(diagnostics).toEqual([ + "Property 'futureValue' requires @Version(3) or an enclosing isVersionAtLeast(3) block", + ]); + }); + + it('applies the workspace minimum to exports in an ExportModule file', () => { + const diagnostics = getDiagnosticTexts( + ` + /** @ExportModule */ + export function existingFunction(): void {} + + // @Version(3) + export function futureFunction(): void {} + + function render() { + existingFunction(); + futureFunction(); + } + `, + 2, + ); + + expect(diagnostics).toEqual(['Function call requires @Version(3) or an enclosing isVersionAtLeast(3) block']); + }); + + it('does not implicitly version ordinary TypeScript declarations', () => { + const diagnostics = getDiagnosticTexts( + ` + interface Model { + value: string; + } + + function makeModel(): Model { + return { value: 'value' }; + } + + function render() { + makeModel().value; + } + `, + 2, + ); + + expect(diagnostics).toEqual([]); + }); +}); diff --git a/compiler/companion/src/VersioningValidator.ts b/compiler/companion/src/VersioningValidator.ts new file mode 100644 index 00000000..018a2423 --- /dev/null +++ b/compiler/companion/src/VersioningValidator.ts @@ -0,0 +1,481 @@ +import * as ts from 'typescript'; +import { hasExportModuleAnnotation, hasNativeExportAnnotation } from './AST'; +import { Diagnostic } from './protocol'; +import { getNodeComments, isNodeExported } from './TSUtils'; + +const PLACEHOLDER_VERSION = Number.MAX_SAFE_INTEGER; +const PLACEHOLDER_VERSION_TEXT = '__PLACEHOLDER__'; +const VERSION_ANNOTATION_REGEX = /@Version\s*\(\s*(\d+|__PLACEHOLDER__)\s*\)/; +const VERSION_INTRINSIC_NAME = 'isVersionAtLeast'; + +export class VersioningValidator { + private readonly versionCache = new WeakMap(); + private readonly nativeContractCache = new WeakMap(); + private readonly exportModuleCache = new WeakMap(); + private readonly diagnostics: Diagnostic[] = []; + + constructor( + private readonly sourceFile: ts.SourceFile, + private readonly typeChecker: ts.TypeChecker, + private readonly makeDiagnostic: (sourceFile: ts.SourceFile, node: ts.Node, text: string) => Diagnostic, + private readonly nativeApiMinVersion: number | undefined, + ) {} + + validate(): Diagnostic[] { + this.visit(this.sourceFile, this.nativeApiMinVersion); + return this.diagnostics; + } + + private getVersion(node: ts.Node | undefined): number | undefined { + if (!node) { + return undefined; + } + + if (this.versionCache.has(node)) { + return this.versionCache.get(node); + } + + let version = this.parseVersion(node); + if (version === undefined && ts.isVariableDeclaration(node) && ts.isVariableStatement(node.parent.parent)) { + version = this.parseVersion(node.parent.parent); + } + if ( + version === undefined && + this.nativeApiMinVersion !== undefined && + this.isImplicitlyVersionedNativeDeclaration(node) + ) { + version = this.nativeApiMinVersion; + } + + this.versionCache.set(node, version); + return version; + } + + private isImplicitlyVersionedNativeDeclaration(node: ts.Node): boolean { + if (this.nativeContractCache.has(node)) { + return this.nativeContractCache.get(node) ?? false; + } + + const annotationNode = this.getAnnotationNode(node); + let isNativeContract = hasNativeExportAnnotation(getNodeComments(annotationNode)?.text ?? ''); + + if (!isNativeContract) { + const containingContract = this.getContainingContractDeclaration(annotationNode); + if (containingContract) { + isNativeContract = this.isImplicitlyVersionedNativeDeclaration(containingContract); + } else if (this.sourceFileHasExportModuleAnnotation(annotationNode.getSourceFile())) { + const topLevelDeclaration = this.getTopLevelDeclaration(annotationNode); + isNativeContract = topLevelDeclaration !== undefined && isNodeExported(topLevelDeclaration); + } + } + + this.nativeContractCache.set(node, isNativeContract); + return isNativeContract; + } + + private sourceFileHasExportModuleAnnotation(sourceFile: ts.SourceFile): boolean { + const cached = this.exportModuleCache.get(sourceFile); + if (cached !== undefined) { + return cached; + } + + const hasAnnotation = sourceFile.statements.some((statement) => + hasExportModuleAnnotation(getNodeComments(statement)?.text ?? ''), + ); + this.exportModuleCache.set(sourceFile, hasAnnotation); + return hasAnnotation; + } + + private getAnnotationNode(node: ts.Node): ts.Node { + if (ts.isVariableDeclaration(node) && ts.isVariableStatement(node.parent.parent)) { + return node.parent.parent; + } + + return node; + } + + private getContainingContractDeclaration( + node: ts.Node, + ): ts.ClassDeclaration | ts.InterfaceDeclaration | ts.EnumDeclaration | undefined { + const parent = node.parent; + if ( + parent && + (ts.isClassDeclaration(parent) || ts.isInterfaceDeclaration(parent) || ts.isEnumDeclaration(parent)) + ) { + return parent; + } + + return undefined; + } + + private getTopLevelDeclaration(node: ts.Node): ts.Statement | undefined { + let current = node; + while (current.parent && !ts.isSourceFile(current.parent)) { + current = current.parent; + } + + return ts.isStatement(current) ? current : undefined; + } + + private parseVersion(node: ts.Node): number | undefined { + const comments = getNodeComments(node); + if (!comments) { + return undefined; + } + + const match = comments.text.match(VERSION_ANNOTATION_REGEX); + if (!match) { + return undefined; + } + + if (match[1] === PLACEHOLDER_VERSION_TEXT) { + return PLACEHOLDER_VERSION; + } + + return Number(match[1]); + } + + private formatVersion(version: number): string { + if (version === PLACEHOLDER_VERSION) { + return PLACEHOLDER_VERSION_TEXT; + } + + return String(version); + } + + private getVersionFromSymbol(symbol: ts.Symbol | undefined): number | undefined { + if (!symbol) { + return undefined; + } + + const declarations = symbol.getDeclarations(); + if (!declarations) { + return undefined; + } + + for (const declaration of declarations) { + const version = this.getVersion(declaration); + if (version !== undefined) { + return version; + } + } + + return undefined; + } + + private isVersionSatisfied(currentVersion: number | undefined, requiredVersion: number): boolean { + return currentVersion !== undefined && currentVersion >= requiredVersion; + } + + private validateVersionedUse( + node: ts.Node, + currentVersion: number | undefined, + requiredVersion: number, + label: string, + ) { + if (this.isVersionSatisfied(currentVersion, requiredVersion)) { + return; + } + + this.diagnostics.push( + this.makeDiagnostic( + this.sourceFile, + node, + `${label} requires @Version(${this.formatVersion( + requiredVersion, + )}) or an enclosing isVersionAtLeast(${this.formatVersion(requiredVersion)}) block`, + ), + ); + } + + private visit(node: ts.Node, currentVersion: number | undefined): void { + if (this.isVersionIntrinsicCall(node) && ts.isCallExpression(node)) { + this.validateVersionIntrinsicCall(node); + } + + if (ts.isIfStatement(node)) { + this.visitIfStatement(node, currentVersion); + return; + } + + if (this.isFunctionLikeDeclaration(node)) { + this.visitFunctionLikeDeclaration(node, currentVersion); + return; + } + + if (ts.isInterfaceDeclaration(node) || ts.isClassDeclaration(node)) { + this.validateContainerDeclaration(node); + } + + if (ts.isPropertyAccessExpression(node) && !this.isCalleePropertyAccess(node)) { + this.validatePropertyAccess(node, currentVersion); + } + + if (ts.isCallExpression(node)) { + this.validateCallExpression(node, currentVersion); + } + + ts.forEachChild(node, (child) => this.visit(child, currentVersion)); + } + + private visitIfStatement(node: ts.IfStatement, currentVersion: number | undefined): void { + const conditionVersion = this.visitVersionCondition(node.expression, currentVersion); + + const thenVersion = this.mergeVersions(currentVersion, conditionVersion); + this.visit(node.thenStatement, thenVersion); + + if (node.elseStatement) { + this.visit(node.elseStatement, currentVersion); + } + } + + private visitVersionCondition(node: ts.Expression, currentVersion: number | undefined): number | undefined { + if (ts.isParenthesizedExpression(node)) { + return this.visitVersionCondition(node.expression, currentVersion); + } + + if (this.isVersionIntrinsicCall(node) && ts.isCallExpression(node)) { + this.visit(node, currentVersion); + return this.getVersionIntrinsicArgument(node); + } + + if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) { + const leftVersion = this.visitVersionCondition(node.left, currentVersion); + const rightVersion = this.visitVersionCondition(node.right, this.mergeVersions(currentVersion, leftVersion)); + return this.mergeVersions(leftVersion, rightVersion); + } + + this.visit(node, currentVersion); + return undefined; + } + + private mergeVersions(left: number | undefined, right: number | undefined): number | undefined { + if (left === undefined) { + return right; + } + + if (right === undefined) { + return left; + } + + return Math.max(left, right); + } + + private visitFunctionLikeDeclaration(node: ts.FunctionLikeDeclaration, currentVersion: number | undefined): void { + const declaredVersion = this.getDeclarationVersion(node); + const effectiveDeclarationVersion = this.mergeVersions(this.nativeApiMinVersion, declaredVersion); + this.validateSignature(node, effectiveDeclarationVersion); + + if (node.body) { + const bodyVersion = + this.nativeApiMinVersion === undefined + ? declaredVersion ?? currentVersion + : this.mergeVersions(currentVersion, declaredVersion); + this.visit(node.body, bodyVersion); + } + } + + private getDeclarationVersion(node: ts.Node | undefined): number | undefined { + if (!node) { + return undefined; + } + + if (ts.isMethodDeclaration(node) || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node)) { + return this.getVersion(node); + } + + if (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node) || ts.isInterfaceDeclaration(node)) { + return this.getVersion(node); + } + + if ((ts.isFunctionExpression(node) || ts.isArrowFunction(node)) && ts.isVariableDeclaration(node.parent)) { + return this.getVersion(node.parent); + } + + return this.getVersion(node); + } + + private isFunctionLikeDeclaration(node: ts.Node): node is ts.FunctionLikeDeclaration { + return ( + ts.isFunctionDeclaration(node) || + ts.isMethodDeclaration(node) || + ts.isGetAccessorDeclaration(node) || + ts.isSetAccessorDeclaration(node) || + ts.isFunctionExpression(node) || + ts.isArrowFunction(node) + ); + } + + private validateContainerDeclaration(node: ts.InterfaceDeclaration | ts.ClassDeclaration): void { + const containerVersion = this.mergeVersions(this.nativeApiMinVersion, this.getVersion(node)); + + if (node.heritageClauses) { + for (const heritageClause of node.heritageClauses) { + for (const type of heritageClause.types) { + this.validateTypeNode(type, containerVersion, type); + } + } + } + + for (const member of node.members) { + if (this.isFunctionLikeDeclaration(member)) { + continue; + } + + const declaredMemberVersion = this.getVersion(member); + const memberVersion = + this.nativeApiMinVersion === undefined + ? declaredMemberVersion ?? containerVersion + : this.mergeVersions(containerVersion, declaredMemberVersion); + if (this.isSignatureMember(member)) { + this.validateSignature(member, memberVersion); + continue; + } + + if (ts.isPropertySignature(member) || ts.isPropertyDeclaration(member)) { + this.validateTypeNode(member.type, memberVersion, member.type ?? member); + } + } + } + + private isSignatureMember(node: ts.Node): node is ts.SignatureDeclaration { + return ( + ts.isMethodSignature(node) || + ts.isCallSignatureDeclaration(node) || + ts.isConstructSignatureDeclaration(node) || + ts.isIndexSignatureDeclaration(node) + ); + } + + private validateSignature(node: ts.SignatureDeclaration, declarationVersion: number | undefined): void { + for (const parameter of node.parameters) { + this.validateTypeNode(parameter.type, declarationVersion, parameter.type ?? parameter); + } + + this.validateTypeNode(node.type, declarationVersion, node.type ?? node); + } + + private validateTypeNode( + typeNode: ts.TypeNode | undefined, + declarationVersion: number | undefined, + diagnosticNode: ts.Node, + ): void { + if (!typeNode) { + return; + } + + const requiredVersion = this.getRequiredVersionForTypeNode(typeNode); + if (requiredVersion !== undefined && !this.isVersionSatisfied(declarationVersion, requiredVersion)) { + this.diagnostics.push( + this.makeDiagnostic( + this.sourceFile, + diagnosticNode, + `Type '${typeNode.getText(this.sourceFile)}' requires @Version(${this.formatVersion( + requiredVersion, + )}) on the containing declaration`, + ), + ); + } + } + + private getRequiredVersionForTypeNode(typeNode: ts.TypeNode): number | undefined { + let requiredVersion: number | undefined; + + const recordVersion = (version: number | undefined) => { + if (version === undefined) { + return; + } + requiredVersion = Math.max(requiredVersion ?? version, version); + }; + + const visitType = (node: ts.Node) => { + if (ts.isTypeReferenceNode(node)) { + recordVersion(this.getVersionFromSymbol(this.typeChecker.getSymbolAtLocation(node.typeName))); + } else if (ts.isExpressionWithTypeArguments(node)) { + recordVersion(this.getVersionFromSymbol(this.typeChecker.getSymbolAtLocation(node.expression))); + } else if (ts.isTypeQueryNode(node)) { + recordVersion(this.getVersionFromSymbol(this.typeChecker.getSymbolAtLocation(node.exprName))); + } + + ts.forEachChild(node, visitType); + }; + + visitType(typeNode); + return requiredVersion; + } + + private validatePropertyAccess(node: ts.PropertyAccessExpression, currentVersion: number | undefined): void { + const requiredVersion = this.getVersionFromSymbol(this.typeChecker.getSymbolAtLocation(node.name)); + if (requiredVersion !== undefined) { + this.validateVersionedUse(node.name, currentVersion, requiredVersion, `Property '${node.name.text}'`); + } + } + + private validateCallExpression(node: ts.CallExpression, currentVersion: number | undefined): void { + if (this.isVersionIntrinsicCall(node)) { + return; + } + + const requiredVersion = this.getRequiredVersionForCall(node); + if (requiredVersion !== undefined) { + this.validateVersionedUse(node.expression, currentVersion, requiredVersion, 'Function call'); + } + } + + private isCalleePropertyAccess(node: ts.PropertyAccessExpression): boolean { + return ts.isCallExpression(node.parent) && node.parent.expression === node; + } + + private getRequiredVersionForCall(node: ts.CallExpression): number | undefined { + let requiredVersion = this.getVersionFromSymbol(this.typeChecker.getSymbolAtLocation(node.expression)); + + const signature = this.typeChecker.getResolvedSignature(node); + if (!signature) { + return requiredVersion; + } + + const declarationVersion = this.getDeclarationVersion(signature.declaration); + if (declarationVersion !== undefined) { + requiredVersion = Math.max(requiredVersion ?? declarationVersion, declarationVersion); + } + + return requiredVersion; + } + + private isVersionIntrinsicCall(node: ts.Node): boolean { + return ( + ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === VERSION_INTRINSIC_NAME + ); + } + + private getVersionIntrinsicArgument(node: ts.Expression): number | undefined { + if (!this.isVersionIntrinsicCall(node) || !ts.isCallExpression(node)) { + return undefined; + } + + if (node.arguments.length !== 1) { + return undefined; + } + + const argument = node.arguments[0]; + if (ts.isNumericLiteral(argument)) { + return Number(argument.text); + } + if (ts.isIdentifier(argument) && argument.text === PLACEHOLDER_VERSION_TEXT) { + return PLACEHOLDER_VERSION; + } + return undefined; + } + + private validateVersionIntrinsicCall(node: ts.CallExpression): void { + if (this.getVersionIntrinsicArgument(node) === undefined) { + this.diagnostics.push( + this.makeDiagnostic( + this.sourceFile, + node, + 'isVersionAtLeast expects exactly one numeric literal or __PLACEHOLDER__ argument', + ), + ); + } + } +} diff --git a/compiler/companion/src/Workspace.ts b/compiler/companion/src/Workspace.ts index a59d0715..cdf28038 100644 --- a/compiler/companion/src/Workspace.ts +++ b/compiler/companion/src/Workspace.ts @@ -28,6 +28,7 @@ import { IWorkspace, OpenFileImportPath, OpenFileResult } from './IWorkspace'; import * as _path from 'path'; import { ImportPathResolver } from './utils/ImportPathResolver'; import { debounce } from 'lodash'; +import { VersioningValidator } from './VersioningValidator'; export interface OpenedFile { sourceFile: ts.SourceFile; @@ -120,6 +121,7 @@ export class Workspace implements IWorkspace { shouldDebounceOpenFile: boolean, readonly logger: ILogger | undefined, readonly compilerOptions: ts.CompilerOptions | undefined, + readonly nativeApiMinVersion: number | undefined, ) { this.workspaceRoot = workspaceRoot; const project = new Project(workspaceRoot, compilerOptions, logger ? new ProjectListener(logger) : undefined); @@ -373,6 +375,18 @@ export class Workspace implements IWorkspace { return success; } + private validateVersioning(openedFile: OpenedFile, output: Diagnostic[]): boolean { + const validator = new VersioningValidator( + openedFile.sourceFile, + openedFile.workspaceProject.typeChecker, + (sourceFile, node, text) => this.makeDiagnostic(sourceFile, node, text), + this.nativeApiMinVersion, + ); + const diagnostics = validator.validate(); + output.push(...diagnostics); + return diagnostics.length === 0; + } + async getDiagnostics(fileName: string): Promise { return this.getDiagnosticsSync(fileName); } @@ -409,6 +423,15 @@ export class Workspace implements IWorkspace { timeTakenMs: sw.elapsedMilliseconds, }; } + + if (!this.validateVersioning(openedFile, diagnostics)) { + return { + diagnostics, + fileContent: openedFile.sourceFile.text, + hasError: true, + timeTakenMs: sw.elapsedMilliseconds, + }; + } } return { diff --git a/compiler/companion/src/WorkspaceStore.spec.ts b/compiler/companion/src/WorkspaceStore.spec.ts new file mode 100644 index 00000000..bb08d67b --- /dev/null +++ b/compiler/companion/src/WorkspaceStore.spec.ts @@ -0,0 +1,42 @@ +import { getCompilationCacheVersion } from './cache/CachingWorkspaceFactory'; +import { ILogger } from './logger/ILogger'; +import { WorkspaceStore } from './WorkspaceStore'; + +const logger: ILogger = { + debug: undefined, + info: undefined, + warn: undefined, + error: undefined, +}; + +describe('WorkspaceStore', () => { + it('keeps the native API minimum scoped to each workspace', () => { + const store = new WorkspaceStore(logger, undefined, false); + + const unchecked = store.createWorkspace(undefined); + const baselineZero = store.createWorkspace(0); + const higherBaseline = store.createWorkspace(7); + + expect(store.getUncachedWorkspace(unchecked.workspaceId).nativeApiMinVersion).toBeUndefined(); + expect(store.getUncachedWorkspace(baselineZero.workspaceId).nativeApiMinVersion).toBe(0); + expect(store.getUncachedWorkspace(higherBaseline.workspaceId).nativeApiMinVersion).toBe(7); + + store.destroyAllWorkspaces(); + }); + + it('rejects invalid native API minimums', () => { + const store = new WorkspaceStore(logger, undefined, false); + + expect(() => store.createWorkspace(-1)).toThrow('nativeApiMinVersion must be an integer between 0 and 2147483647'); + expect(() => store.createWorkspace(1.5)).toThrow('nativeApiMinVersion must be an integer between 0 and 2147483647'); + expect(() => store.createWorkspace(2147483648)).toThrow( + 'nativeApiMinVersion must be an integer between 0 and 2147483647', + ); + }); + + it('versions compilation caches by native API minimum', () => { + expect(getCompilationCacheVersion(undefined)).toBe('3'); + expect(getCompilationCacheVersion(0)).toBe('3/native-api-min-version-0'); + expect(getCompilationCacheVersion(7)).toBe('3/native-api-min-version-7'); + }); +}); diff --git a/compiler/companion/src/WorkspaceStore.ts b/compiler/companion/src/WorkspaceStore.ts index ddaf3944..b0b2c90b 100644 --- a/compiler/companion/src/WorkspaceStore.ts +++ b/compiler/companion/src/WorkspaceStore.ts @@ -23,11 +23,24 @@ export class WorkspaceStore { readonly shouldDebounceOpenFile: boolean, ) {} - createWorkspace(): CreateWorkspaceResult { - const uncachedWorkspace = new Workspace('/', this.shouldDebounceOpenFile, this.logger, undefined); + createWorkspace(nativeApiMinVersion: number | undefined): CreateWorkspaceResult { + if ( + nativeApiMinVersion !== undefined && + (!Number.isInteger(nativeApiMinVersion) || nativeApiMinVersion < 0 || nativeApiMinVersion > 2147483647) + ) { + throw new Error('nativeApiMinVersion must be an integer between 0 and 2147483647'); + } + + const uncachedWorkspace = new Workspace( + '/', + this.shouldDebounceOpenFile, + this.logger, + undefined, + nativeApiMinVersion, + ); let workspace: IWorkspace; if (this.cacheDir) { - workspace = createCachingWorkspace(this.cacheDir, uncachedWorkspace, this.logger); + workspace = createCachingWorkspace(this.cacheDir, uncachedWorkspace, this.logger, nativeApiMinVersion); } else { workspace = uncachedWorkspace; } diff --git a/compiler/companion/src/cache/CachingWorkspaceFactory.ts b/compiler/companion/src/cache/CachingWorkspaceFactory.ts index 9c6b29a7..88243080 100644 --- a/compiler/companion/src/cache/CachingWorkspaceFactory.ts +++ b/compiler/companion/src/cache/CachingWorkspaceFactory.ts @@ -7,17 +7,26 @@ import { ILogger } from '../logger/ILogger'; /** * Should be incremented every time the workspace implementation changes. */ -const CACHE_VERSION = '2'; +const CACHE_VERSION = '3'; + +export function getCompilationCacheVersion(nativeApiMinVersion: number | undefined): string { + if (nativeApiMinVersion === undefined) { + return CACHE_VERSION; + } + + return `${CACHE_VERSION}/native-api-min-version-${nativeApiMinVersion}`; +} export function createCachingWorkspace( cacheDir: string, sourceWorkspace: IWorkspace, logger: ILogger | undefined, + nativeApiMinVersion: number | undefined, ): IWorkspace { const dbPath = path.resolve(cacheDir, 'compilecache.db'); const compilationCache = new SQLiteCompilationCache( dbPath, - CACHE_VERSION, + getCompilationCacheVersion(nativeApiMinVersion), { getCurrentTimestamp() { return Date.now(); diff --git a/compiler/companion/src/native/NativeCompiler.spec.ts b/compiler/companion/src/native/NativeCompiler.spec.ts index 1585e49f..0bf64825 100644 --- a/compiler/companion/src/native/NativeCompiler.spec.ts +++ b/compiler/companion/src/native/NativeCompiler.spec.ts @@ -19,11 +19,17 @@ function compileAsIRString( setupWorkspace: ((workpace: Workspace) => void) | undefined, filterIR: (ir: NativeCompilerIR.Base) => boolean, ): string { - const workspace = new Workspace('/', false, undefined, { - target: ts.ScriptTarget.ESNext, - module: ts.ModuleKind.CommonJS, - lib: ['lib.es2015.d.ts'], - }); + const workspace = new Workspace( + '/', + false, + undefined, + { + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.CommonJS, + lib: ['lib.es2015.d.ts'], + }, + undefined, + ); const filePath = 'file.ts'; workspace.registerInMemoryFile(filePath, text); @@ -43,11 +49,17 @@ function compileAsC( selectFunction: string | undefined, setupWorkspace: ((workpace: Workspace) => void) | undefined, ): string { - const workspace = new Workspace('/', false, undefined, { - target: ts.ScriptTarget.ESNext, - module: ts.ModuleKind.CommonJS, - lib: ['lib.es2015.d.ts'], - }); + const workspace = new Workspace( + '/', + false, + undefined, + { + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.CommonJS, + lib: ['lib.es2015.d.ts'], + }, + undefined, + ); const filePath = 'file.ts'; workspace.registerInMemoryFile(filePath, text); diff --git a/compiler/companion/src/protocol.ts b/compiler/companion/src/protocol.ts index cba18356..a66a0d0e 100644 --- a/compiler/companion/src/protocol.ts +++ b/compiler/companion/src/protocol.ts @@ -61,7 +61,9 @@ export interface BatchMinifyJSRequestBody { options: string; } -export interface CreateWorkspaceRequestBody {} +export interface CreateWorkspaceRequestBody { + nativeApiMinVersion?: number; +} export interface DestroyWorkspaceRequestBody { workspaceId: number; diff --git a/compiler/compiler/Compiler/Sources/Config/ValdiProjectConfig.swift b/compiler/compiler/Compiler/Sources/Config/ValdiProjectConfig.swift index 614f62eb..48645c16 100644 --- a/compiler/compiler/Compiler/Sources/Config/ValdiProjectConfig.swift +++ b/compiler/compiler/Compiler/Sources/Config/ValdiProjectConfig.swift @@ -96,6 +96,7 @@ struct ValdiProjectConfig { let nodeModulesWorkspace: String? let externalModulesTarget: String? let externalModulesWorkspace: String? + let nativeApiMinVersion: Int? private static func parseOutputConfig(inputConfig: Yams.Node.Mapping?, ignoredFiles: [NSRegularExpression]?, @@ -317,6 +318,18 @@ struct ValdiProjectConfig { let nodeModulesWorkspace = config["node_modules_workspace"]?.string let externalModulesTarget = config["external_modules_target"]?.string let externalModulesWorkspace = config["external_modules_workspace"]?.string + let nativeApiMinVersion: Int? + if let configuredNativeApiMinVersion = config["native_api_min_version"] { + guard let parsedNativeApiMinVersion = configuredNativeApiMinVersion.int else { + throw CompilerError("native_api_min_version must be an integer") + } + guard parsedNativeApiMinVersion >= 0 && parsedNativeApiMinVersion <= Int(Int32.max) else { + throw CompilerError("native_api_min_version must be between 0 and \(Int32.max)") + } + nativeApiMinVersion = parsedNativeApiMinVersion + } else { + nativeApiMinVersion = nil + } var projectConfig = ValdiProjectConfig(configDirectoryUrl: configDirectoryUrl, projectName: projectName, @@ -357,7 +370,8 @@ struct ValdiProjectConfig { nodeModulesTarget: nodeModulesTarget, nodeModulesWorkspace: nodeModulesWorkspace, externalModulesTarget: externalModulesTarget, - externalModulesWorkspace: externalModulesWorkspace) + externalModulesWorkspace: externalModulesWorkspace, + nativeApiMinVersion: nativeApiMinVersion) projectConfig.shouldEmitDiagnostics = args.emitDiagnostics projectConfig.shouldDebugCompilerCompanion = args.debugCompanion diff --git a/compiler/compiler/Compiler/Sources/Generation/Cpp/CppFunctionGenerator.swift b/compiler/compiler/Compiler/Sources/Generation/Cpp/CppFunctionGenerator.swift index 0413c189..99d1ebc3 100644 --- a/compiler/compiler/Compiler/Sources/Generation/Cpp/CppFunctionGenerator.swift +++ b/compiler/compiler/Compiler/Sources/Generation/Cpp/CppFunctionGenerator.swift @@ -56,7 +56,8 @@ final class CppFunctionGenerator { type: .function(parameters: exportedFunction.parameters, returnType: exportedFunction.returnType, isSingleCall: false, shouldCallOnWorkerThread: false, allowSyncCall: exportedFunction.allowSyncCall), comments: exportedFunction.comments, omitConstructor: nil, - injectableParams: .empty) + injectableParams: .empty, + declaredVersion: nil) let nameAllocator = PropertyNameAllocator.forCpp() diff --git a/compiler/compiler/Compiler/Sources/Generation/ExportedFunctionGenerator.swift b/compiler/compiler/Compiler/Sources/Generation/ExportedFunctionGenerator.swift index 6f0ef2b8..3be15b8c 100644 --- a/compiler/compiler/Compiler/Sources/Generation/ExportedFunctionGenerator.swift +++ b/compiler/compiler/Compiler/Sources/Generation/ExportedFunctionGenerator.swift @@ -17,6 +17,7 @@ struct ExportedFunction { let returnType: ValdiModelPropertyType let allowSyncCall: Bool let comments: String? + let declaredVersion: String? } final class ExportedFunctionGenerator: NativeSourceGenerator { diff --git a/compiler/compiler/Compiler/Sources/Generation/GeneratedTypesDiagnostics.swift b/compiler/compiler/Compiler/Sources/Generation/GeneratedTypesDiagnostics.swift index b8c7f61c..0b5099bb 100644 --- a/compiler/compiler/Compiler/Sources/Generation/GeneratedTypesDiagnostics.swift +++ b/compiler/compiler/Compiler/Sources/Generation/GeneratedTypesDiagnostics.swift @@ -7,6 +7,29 @@ import Foundation +func nativeApiDeclaredVersion(annotations: [ValdiTypeScriptAnnotation]) -> String? { + return annotations.first(where: { $0.name == ValdiAnnotationType.version.rawValue })?.positionalPayload +} + +func nativeApiEffectiveMemberVersion(declared: String?, container: String?) -> String? { + guard let declared else { + return container + } + guard let container else { + return declared + } + if declared == "__PLACEHOLDER__" || container == "__PLACEHOLDER__" { + return "__PLACEHOLDER__" + } + guard let declaredVersion = Int(declared) else { + return declared + } + guard let containerVersion = Int(container) else { + return container + } + return String(max(declaredVersion, containerVersion)) +} + ////////// // This file defines set of easily-encodable structs describing the classes, // interfaces, enums etc. generated by the compiler. @@ -29,6 +52,7 @@ enum GeneratedTypeDescription: Encodable { case interface(GeneratedNativeInterfaceDescription) case `enum`(GeneratedEnumDescription) case function(GeneratedFunctionDescription) + case module(GeneratedNativeInterfaceDescription) case viewClass(GeneratedViewClassDescription) enum CodingKeys: CodingKey { @@ -36,6 +60,7 @@ enum GeneratedTypeDescription: Encodable { case interface case `enum` case function + case module case viewClass } @@ -50,6 +75,8 @@ enum GeneratedTypeDescription: Encodable { try container.encode(value, forKey: .enum) case let .function(value): try container.encode(value, forKey: .function) + case let .module(value): + try container.encode(value, forKey: .module) case let .viewClass(value): try container.encode(value, forKey: .viewClass) } @@ -78,6 +105,8 @@ enum GeneratedTypeDescription: Encodable { return this.iosTypeName case let .function(this): return this.containingIosTypeName + case let .module(this): + return this.iosTypeName case let .viewClass(this): return this.iosTypeName } @@ -93,6 +122,8 @@ enum GeneratedTypeDescription: Encodable { return this.androidTypeName case let .function(this): return this.containingAndroidTypeName + case let .module(this): + return this.androidTypeName case let .viewClass(this): return this.androidClassName } @@ -106,9 +137,10 @@ enum GeneratedTypeDescription: Encodable { return this.cppTypeName case let .enum(this): return this.cppTypeName - case .function: - // TODO: not supported yet - return nil + case let .function(this): + return this.containingCppTypeName + case let .module(this): + return this.cppTypeName case .viewClass: return nil } @@ -129,10 +161,25 @@ enum GeneratedTypeDescription: Encodable { return this.iosTypeName ?? this.androidTypeName ?? "" case let .function(this): return this.functionName + case let .module(this): + return this.iosTypeName ?? this.androidTypeName ?? "" case let .viewClass(this): return this.iosTypeName ?? this.androidClassName ?? "" } } + + var isNativeApi: Bool { + switch self { + case let .class(description), + let .interface(description), + let .module(description): + return description.isNativeApi + case .enum, .function: + return true + case .viewClass: + return false + } + } } struct GeneratedNativeClassDescription: Encodable { @@ -140,26 +187,84 @@ struct GeneratedNativeClassDescription: Encodable { let androidTypeName: String? let cppTypeName: String? let properties: [PropertyDescription] + let declaredVersion: String? + let effectiveVersion: String? + let isNativeApi: Bool - init(model: ValdiModel) { + enum CodingKeys: CodingKey { + case iosTypeName + case androidTypeName + case cppTypeName + case properties + case declaredVersion + case effectiveVersion + } + + init(model: ValdiModel, baseline: String?) { iosTypeName = model.iosType?.name androidTypeName = model.androidClassName cppTypeName = model.cppType?.declaration.fullTypeName - properties = model.properties.map(PropertyDescription.init) + declaredVersion = model.declaredVersion + isNativeApi = model.isNativeApi + let effectiveVersion = model.declaredVersion ?? (model.isNativeApi ? baseline : nil) + self.effectiveVersion = effectiveVersion + properties = model.properties.map { property in + if model.isNativeApi { + return PropertyDescription( + prop: property, + declaredVersion: property.declaredVersion, + containerVersion: effectiveVersion + ) + } else { + return PropertyDescription(prop: property) + } + } + } + + init(nativeClass: TypeScriptNativeClass, + annotations: [ValdiTypeScriptAnnotation], + baseline: String?) { + iosTypeName = nativeClass.iosType?.name + androidTypeName = nativeClass.androidClass + cppTypeName = nativeClass.cppType?.declaration.fullTypeName + properties = [] + declaredVersion = nativeApiDeclaredVersion(annotations: annotations) + effectiveVersion = declaredVersion ?? baseline + isNativeApi = true + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(iosTypeName, forKey: .iosTypeName) + try container.encodeIfPresent(androidTypeName, forKey: .androidTypeName) + try container.encodeIfPresent(cppTypeName, forKey: .cppTypeName) + try container.encode(properties, forKey: .properties) + try container.encode(declaredVersion, forKey: .declaredVersion) + try container.encode(effectiveVersion, forKey: .effectiveVersion) } } -struct GeneratedNativeInterfaceDescription: Encodable { - let iosTypeName: String? - let androidTypeName: String? - let cppTypeName: String? - let properties: [PropertyDescription] +typealias GeneratedNativeInterfaceDescription = GeneratedNativeClassDescription - init(model: ValdiModel) { - iosTypeName = model.iosType?.name - androidTypeName = model.androidClassName - cppTypeName = model.cppType?.declaration.fullTypeName - properties = model.properties.map(PropertyDescription.init) +struct GeneratedEnumCaseDescription: Encodable { + let name: String + let value: T + let declaredVersion: String? + let effectiveVersion: String? + + enum CodingKeys: CodingKey { + case name + case value + case declaredVersion + case effectiveVersion + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + try container.encode(value, forKey: .value) + try container.encode(declaredVersion, forKey: .declaredVersion) + try container.encode(effectiveVersion, forKey: .effectiveVersion) } } @@ -168,41 +273,124 @@ struct GeneratedEnumDescription: Encodable { let androidTypeName: String? let cppTypeName: String? - let intCases: [EnumCase]? - let stringCases: [EnumCase]? + let intCases: [GeneratedEnumCaseDescription]? + let stringCases: [GeneratedEnumCaseDescription]? + let declaredVersion: String? + let effectiveVersion: String? - static func from(exportedEnum: ExportedEnum) -> GeneratedEnumDescription { - let intCases: [EnumCase]? - let stringCases: [EnumCase]? + enum CodingKeys: CodingKey { + case iosTypeName + case androidTypeName + case cppTypeName + case intCases + case stringCases + case declaredVersion + case effectiveVersion + } + + static func from(exportedEnum: ExportedEnum, baseline: String?) -> GeneratedEnumDescription { + let declaredVersion = exportedEnum.declaredVersion + let effectiveVersion = declaredVersion ?? baseline + + let intCases: [GeneratedEnumCaseDescription]? + let stringCases: [GeneratedEnumCaseDescription]? switch exportedEnum.cases { case let .enum(cases): - intCases = cases + intCases = cases.map { enumCase in + return GeneratedEnumCaseDescription( + name: enumCase.name, + value: enumCase.value, + declaredVersion: enumCase.declaredVersion, + effectiveVersion: nativeApiEffectiveMemberVersion( + declared: enumCase.declaredVersion, + container: effectiveVersion + ) + ) + } stringCases = nil case let .stringEnum(cases): intCases = nil - stringCases = cases + stringCases = cases.map { enumCase in + return GeneratedEnumCaseDescription( + name: enumCase.name, + value: enumCase.value, + declaredVersion: enumCase.declaredVersion, + effectiveVersion: nativeApiEffectiveMemberVersion( + declared: enumCase.declaredVersion, + container: effectiveVersion + ) + ) + } } - return GeneratedEnumDescription(iosTypeName: exportedEnum.iosType?.name, - androidTypeName: exportedEnum.androidTypeName, - cppTypeName: exportedEnum.cppType?.declaration.fullTypeName, - intCases: intCases, - stringCases: stringCases) + + return GeneratedEnumDescription( + iosTypeName: exportedEnum.iosType?.name, + androidTypeName: exportedEnum.androidTypeName, + cppTypeName: exportedEnum.cppType?.declaration.fullTypeName, + intCases: intCases, + stringCases: stringCases, + declaredVersion: declaredVersion, + effectiveVersion: effectiveVersion + ) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(iosTypeName, forKey: .iosTypeName) + try container.encodeIfPresent(androidTypeName, forKey: .androidTypeName) + try container.encodeIfPresent(cppTypeName, forKey: .cppTypeName) + try container.encodeIfPresent(intCases, forKey: .intCases) + try container.encodeIfPresent(stringCases, forKey: .stringCases) + try container.encode(declaredVersion, forKey: .declaredVersion) + try container.encode(effectiveVersion, forKey: .effectiveVersion) } } struct GeneratedFunctionDescription: Encodable { let containingIosTypeName: String? let containingAndroidTypeName: String? + let containingCppTypeName: String? let functionName: String let parameters: [PropertyDescription] let returnType: PropertyTypeDescription + let declaredVersion: String? + let effectiveVersion: String? + + enum CodingKeys: CodingKey { + case containingIosTypeName + case containingAndroidTypeName + case containingCppTypeName + case functionName + case parameters + case returnType + case declaredVersion + case effectiveVersion + } - static func from(exportedFunction: ExportedFunction) -> GeneratedFunctionDescription { - return GeneratedFunctionDescription(containingIosTypeName: exportedFunction.containingIosType?.name, - containingAndroidTypeName: exportedFunction.containingAndroidTypeName, - functionName: exportedFunction.functionName, - parameters: exportedFunction.parameters.map(PropertyDescription.init), - returnType: PropertyTypeDescription(propType: exportedFunction.returnType)) + static func from(exportedFunction: ExportedFunction, baseline: String?) -> GeneratedFunctionDescription { + let declaredVersion = exportedFunction.declaredVersion + return GeneratedFunctionDescription( + containingIosTypeName: exportedFunction.containingIosType?.name, + containingAndroidTypeName: exportedFunction.containingAndroidTypeName, + containingCppTypeName: exportedFunction.containingCppType?.declaration.fullTypeName, + functionName: exportedFunction.functionName, + parameters: exportedFunction.parameters.map(PropertyDescription.init), + returnType: PropertyTypeDescription(propType: exportedFunction.returnType), + declaredVersion: declaredVersion, + effectiveVersion: declaredVersion ?? baseline + ) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(containingIosTypeName, forKey: .containingIosTypeName) + try container.encodeIfPresent(containingAndroidTypeName, forKey: .containingAndroidTypeName) + try container.encodeIfPresent(containingCppTypeName, forKey: .containingCppTypeName) + try container.encode(functionName, forKey: .functionName) + try container.encode(parameters, forKey: .parameters) + try container.encode(returnType, forKey: .returnType) + try container.encode(declaredVersion, forKey: .declaredVersion) + try container.encode(effectiveVersion, forKey: .effectiveVersion) } } @@ -246,6 +434,7 @@ class PropertyTypeDescription: Encodable { struct CustomTypeMetadata: Encodable { let iosTypeName: String? let androidClassName: String? + let cppTypeName: String? } private(set) var typeParameterMetadata: TypeParameterMetadata? @@ -269,8 +458,12 @@ class PropertyTypeDescription: Encodable { genericTypeMetadata = GenericTypeMetadata(types: [description]) case .bytes: typeStr = "bytes" - case .map: + case let .map(keyType, valueType): typeStr = "map" + genericTypeMetadata = GenericTypeMetadata(types: [ + PropertyTypeDescription(propType: keyType), + PropertyTypeDescription(propType: valueType), + ]) case .any: typeStr = "any" case .void: @@ -279,27 +472,38 @@ class PropertyTypeDescription: Encodable { typeStr = "function" let parameters = parameters.map(PropertyDescription.init) let returnType = PropertyTypeDescription(propType: returnType) - functionTypeMetadata = FunctionTypeMetadata(parameters: parameters, returnType: returnType) + functionTypeMetadata = FunctionTypeMetadata(parameters: parameters, + returnType: returnType) case let .object(nodeClassMapping): typeStr = "object" - customTypeMetadata = CustomTypeMetadata(iosTypeName: nodeClassMapping.iosType?.name, androidClassName: nodeClassMapping.androidClassName) + customTypeMetadata = CustomTypeMetadata(nodeClassMapping: nodeClassMapping) case let .genericTypeParameter(name): typeStr = "genericTypeParameter" typeParameterMetadata = .init(typeParameterName: name) - case let .genericObject(nodeClassMapping, _): + case let .genericObject(nodeClassMapping, typeArguments): typeStr = "genericObject" - customTypeMetadata = CustomTypeMetadata(iosTypeName: nodeClassMapping.iosType?.name, androidClassName: nodeClassMapping.androidClassName) + customTypeMetadata = CustomTypeMetadata(nodeClassMapping: nodeClassMapping) + genericTypeMetadata = GenericTypeMetadata(types: typeArguments.map(PropertyTypeDescription.init)) case let .enum(nodeClassMapping): typeStr = "enum" - customTypeMetadata = CustomTypeMetadata(iosTypeName: nodeClassMapping.iosType?.name, androidClassName: nodeClassMapping.androidClassName) + customTypeMetadata = CustomTypeMetadata(nodeClassMapping: nodeClassMapping) case .promise(let typeArgument): typeStr = "promise" genericTypeMetadata = GenericTypeMetadata(types: [PropertyTypeDescription(propType: typeArgument)]) case let .nullable(innerType): - let innerDescription = PropertyTypeDescription(propType: innerType) - typeStr = "\(innerDescription.typeStr)?" + typeStr = "nullable" + genericTypeMetadata = GenericTypeMetadata(types: [PropertyTypeDescription(propType: innerType)]) } } + +} + +private extension PropertyTypeDescription.CustomTypeMetadata { + init(nodeClassMapping: ValdiNodeClassMapping) { + iosTypeName = nodeClassMapping.iosType?.name + androidClassName = nodeClassMapping.androidClassName + cppTypeName = nodeClassMapping.cppType?.declaration.fullTypeName + } } struct PropertyDescription: Encodable { @@ -307,14 +511,54 @@ struct PropertyDescription: Encodable { let isOptional: Bool let type: PropertyTypeDescription + let declaredVersion: String? + let effectiveVersion: String? + private let emitsVersionMetadata: Bool // not for interfaces let omitConstructor: OmitConstructorParams? + enum CodingKeys: CodingKey { + case name + case isOptional + case type + case declaredVersion + case effectiveVersion + case omitConstructor + } + init(prop: ValdiModelProperty) { name = prop.name isOptional = prop.type.isOptional type = PropertyTypeDescription(propType: prop.type.unwrappingOptional) omitConstructor = prop.omitConstructor + declaredVersion = nil + effectiveVersion = nil + emitsVersionMetadata = false + } + + init(prop: ValdiModelProperty, declaredVersion: String?, containerVersion: String?) { + name = prop.name + isOptional = prop.type.isOptional + type = PropertyTypeDescription(propType: prop.type.unwrappingOptional) + omitConstructor = prop.omitConstructor + self.declaredVersion = declaredVersion + effectiveVersion = nativeApiEffectiveMemberVersion( + declared: declaredVersion, + container: containerVersion + ) + emitsVersionMetadata = true + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + try container.encode(isOptional, forKey: .isOptional) + try container.encode(type, forKey: .type) + if emitsVersionMetadata { + try container.encode(declaredVersion, forKey: .declaredVersion) + try container.encode(effectiveVersion, forKey: .effectiveVersion) + } + try container.encodeIfPresent(omitConstructor, forKey: .omitConstructor) } } diff --git a/compiler/compiler/Compiler/Sources/Generation/ObjC/ObjCFunctionGenerator.swift b/compiler/compiler/Compiler/Sources/Generation/ObjC/ObjCFunctionGenerator.swift index fbabbe4b..0e9885e9 100644 --- a/compiler/compiler/Compiler/Sources/Generation/ObjC/ObjCFunctionGenerator.swift +++ b/compiler/compiler/Compiler/Sources/Generation/ObjC/ObjCFunctionGenerator.swift @@ -93,7 +93,8 @@ final class ObjCFunctionGenerator { type: .function(parameters: exportedFunction.parameters, returnType: exportedFunction.returnType, isSingleCall: false, shouldCallOnWorkerThread: false, allowSyncCall: exportedFunction.allowSyncCall), comments: nil, omitConstructor: nil, - injectableParams: .empty)) + injectableParams: .empty, + declaredVersion: nil)) let objectDescriptor = try classGenerator.writeObjectDescriptorGetter(resolvedProperties: [objcProperty], objcSelectors: [nil], typeParameters: nil, diff --git a/compiler/compiler/Compiler/Sources/Parser/Models/ValdiRawDocument.swift b/compiler/compiler/Compiler/Sources/Parser/Models/ValdiRawDocument.swift index 1cd7ba95..d53e2fb4 100644 --- a/compiler/compiler/Compiler/Sources/Parser/Models/ValdiRawDocument.swift +++ b/compiler/compiler/Compiler/Sources/Parser/Models/ValdiRawDocument.swift @@ -189,6 +189,7 @@ struct ValdiModelProperty { let comments: String? let omitConstructor: OmitConstructorParams? let injectableParams: InjectableParams + let declaredVersion: String? } struct ValdiTypeParameter { @@ -206,6 +207,8 @@ struct ValdiModel { var usePublicFields = false var comments: String? var properties = [ValdiModelProperty]() + var declaredVersion: String? + var isNativeApi = false } struct ExportedModule { @@ -221,6 +224,7 @@ struct EnumCase { let name: String let value: T let comments: String? + let declaredVersion: String? } extension EnumCase: Encodable where T: Encodable { @@ -549,4 +553,4 @@ struct ValdiNodeClassMapping { // TODO: rename to ValdiTypeMapping? struct ValdiClassMapping { var nodeMappingByClass = [String: ValdiNodeClassMapping]() -} \ No newline at end of file +} diff --git a/compiler/compiler/Compiler/Sources/Pipeline/CompilationItem.swift b/compiler/compiler/Compiler/Sources/Pipeline/CompilationItem.swift index 0c53d566..c259547b 100644 --- a/compiler/compiler/Compiler/Sources/Pipeline/CompilationItem.swift +++ b/compiler/compiler/Compiler/Sources/Pipeline/CompilationItem.swift @@ -81,7 +81,7 @@ struct CompilationItem { /** Diagnostics structure describing an exported type (class, interface, enum, function, view class). */ - case generatedTypeDescription(GeneratedTypeDescription) + case generatedTypeDescription(GeneratedTypeDescription, src: TypeScriptItemSrc) /** A diagnostics file to be emitted when we finish processing diff --git a/compiler/compiler/Compiler/Sources/Processors/ApplyTypeScriptAnnotationsProcessor.swift b/compiler/compiler/Compiler/Sources/Processors/ApplyTypeScriptAnnotationsProcessor.swift index ba13e615..27669488 100644 --- a/compiler/compiler/Compiler/Sources/Processors/ApplyTypeScriptAnnotationsProcessor.swift +++ b/compiler/compiler/Compiler/Sources/Processors/ApplyTypeScriptAnnotationsProcessor.swift @@ -137,9 +137,33 @@ final class ApplyTypeScriptAnnotationsProcessor: CompilationProcessor { case .nativeTypeConverter: try nativeCodeGenerationManager.addNativeTypeConverter(commentedFile: commentedFile, annotation: annotation, sourceURL: sourceURL, annotatedSymbol: annotatedSymbol, compilationItem: compilationItem, linesIndexer: linesIndexer) case .nativeClass: - try nativeCodeGenerationManager.registerNativeClass(commentedFile: commentedFile, annotation: annotation, symbol: symbol, shouldGenerateIOS: compilationItem.shouldOutputToIOS, shouldGenerateAndroid: compilationItem.shouldOutputToAndroid, kind: .class, bundleInfo: compilationItem.bundleInfo, isGenerated: false) + let nativeClass = try nativeCodeGenerationManager.registerNativeClass(commentedFile: commentedFile, annotation: annotation, symbol: symbol, shouldGenerateIOS: compilationItem.shouldOutputToIOS, shouldGenerateAndroid: compilationItem.shouldOutputToAndroid, kind: .class, bundleInfo: compilationItem.bundleInfo, isGenerated: false) + let description = nativeCodeGenerationManager.nativeTypeDescription( + annotatedSymbol: annotatedSymbol, + nativeClass: nativeClass, + compilationItem: compilationItem + ) + out.append(item: compilationItem.with( + newKind: .generatedTypeDescription( + description, + src: commentedFile.src + ), + newPlatform: .none + )) case .nativeInterface: - try nativeCodeGenerationManager.registerNativeClass(commentedFile: commentedFile, annotation: annotation, symbol: symbol, shouldGenerateIOS: compilationItem.shouldOutputToIOS, shouldGenerateAndroid: compilationItem.shouldOutputToAndroid, kind: .interface, bundleInfo: compilationItem.bundleInfo, isGenerated: false) + let nativeClass = try nativeCodeGenerationManager.registerNativeClass(commentedFile: commentedFile, annotation: annotation, symbol: symbol, shouldGenerateIOS: compilationItem.shouldOutputToIOS, shouldGenerateAndroid: compilationItem.shouldOutputToAndroid, kind: .interface, bundleInfo: compilationItem.bundleInfo, isGenerated: false) + let description = nativeCodeGenerationManager.nativeTypeDescription( + annotatedSymbol: annotatedSymbol, + nativeClass: nativeClass, + compilationItem: compilationItem + ) + out.append(item: compilationItem.with( + newKind: .generatedTypeDescription( + description, + src: commentedFile.src + ), + newPlatform: .none + )) case .component: if isTSorTSX { guard let symbolName = symbol.text.nonEmpty else { @@ -220,6 +244,9 @@ final class ApplyTypeScriptAnnotationsProcessor: CompilationProcessor { case .untypedMap: // UntypedMap annotations get processed inside TypeScriptNativeTypeExporter break + case .version: + // Version annotations are consumed by the companion's version validator. + break } // Replace the CompilationItem with possibly-updated document diff --git a/compiler/compiler/Compiler/Sources/Processors/DiagnosticsProcessor.swift b/compiler/compiler/Compiler/Sources/Processors/DiagnosticsProcessor.swift index c2aae44c..f123ebf4 100644 --- a/compiler/compiler/Compiler/Sources/Processors/DiagnosticsProcessor.swift +++ b/compiler/compiler/Compiler/Sources/Processors/DiagnosticsProcessor.swift @@ -30,16 +30,16 @@ class DiagnosticsProcessor: CompilationProcessor { return items } - return items.select { (item) -> GeneratedTypeDescription? in - if case let .generatedTypeDescription(generatedTypeDescription) = item.kind { - return generatedTypeDescription + return items.select { (item) -> (GeneratedTypeDescription, TypeScriptItemSrc)? in + if case let .generatedTypeDescription(generatedTypeDescription, src) = item.kind { + return (generatedTypeDescription, src) } return nil }.groupBy { selectedItem -> String in - selectedItem.item.relativeProjectPath + selectedItem.data.1.compilationPath }.transformEachConcurrently { groupedItems -> CompilationItem in let relativeSourceFilePath = groupedItems.key - let descriptions = groupedItems.items.map { $0.data }.sorted { $0.valueToSortBy < $1.valueToSortBy } + let descriptions = groupedItems.items.map { $0.data.0 }.sorted { $0.valueToSortBy < $1.valueToSortBy } let summary = GeneratedTypesSummary(sourceFilePath: relativeSourceFilePath, generatedTypes: descriptions) let anyItem = groupedItems.items[0] diff --git a/compiler/compiler/Compiler/Sources/Processors/DumpCompilationMetadataProcessor.swift b/compiler/compiler/Compiler/Sources/Processors/DumpCompilationMetadataProcessor.swift index 968c6792..92f8cad5 100644 --- a/compiler/compiler/Compiler/Sources/Processors/DumpCompilationMetadataProcessor.swift +++ b/compiler/compiler/Compiler/Sources/Processors/DumpCompilationMetadataProcessor.swift @@ -1,7 +1,6 @@ import Foundation class DumpCompilationMetadataProcessor: CompilationProcessor { - let projectConfig: ValdiProjectConfig let compilerConfig: CompilerConfig let projectClassMappingManager: ProjectClassMappingManager @@ -21,9 +20,28 @@ class DumpCompilationMetadataProcessor: CompilationProcessor { self.typeScriptNativeTypeResolver = typeScriptNativeTypeResolver } + private func generatedTypes(items: CompilationItems) -> [GeneratedTypesSummary] { + return items.select { item -> (GeneratedTypeDescription, TypeScriptItemSrc)? in + if case let .generatedTypeDescription(description, src) = item.kind, + description.isNativeApi { + return (description, src) + } + return nil + }.groupBy { selectedItem in + selectedItem.data.1.compilationPath.removing(suffixes: FileExtensions.typescriptFileExtensionsDotted) + }.selectedItems.map { groupedItems in + let descriptions = groupedItems.items.map(\.data.0) + return GeneratedTypesSummary( + sourceFilePath: groupedItems.key, + generatedTypes: descriptions.sorted { $0.valueToSortBy < $1.valueToSortBy } + ) + }.sorted { $0.sourceFilePath < $1.sourceFilePath } + } + func process(items: CompilationItems) throws -> CompilationItems { let resolvedMappings = projectClassMappingManager.copyProjectClassMapping().copyMappings() - let nativeTypes = typeScriptNativeTypeResolver.serialize() + let nativeTypes = typeScriptNativeTypeResolver.serialize() + let generatedTypes = generatedTypes(items: items) let encoder = JSONEncoder() encoder.outputFormatting = .sortedKeys @@ -48,7 +66,11 @@ class DumpCompilationMetadataProcessor: CompilationProcessor { let transformed = try selectedItems.transformEach { selectedItem in let item = selectedItem.item let (resolvedMappingsFromModule, resolvedTypeResolverForModule) = selectedItem.data - let compilationMetadata = CompilationMetadata(classMappings: resolvedMappingsFromModule, nativeTypes: resolvedTypeResolverForModule) + let nativeApiMinVersion = projectConfig.nativeApiMinVersion.map(String.init) + let compilationMetadata = CompilationMetadata(classMappings: resolvedMappingsFromModule, + nativeTypes: resolvedTypeResolverForModule, + nativeApiMinVersion: nativeApiMinVersion, + generatedTypes: generatedTypes) let encoded = try encoder.encode(compilationMetadata) let file = File.data(encoded) diff --git a/compiler/compiler/Compiler/Sources/Processors/GenerateModelsProcessor.swift b/compiler/compiler/Compiler/Sources/Processors/GenerateModelsProcessor.swift index 41f9af80..4f86553e 100644 --- a/compiler/compiler/Compiler/Sources/Processors/GenerateModelsProcessor.swift +++ b/compiler/compiler/Compiler/Sources/Processors/GenerateModelsProcessor.swift @@ -18,10 +18,12 @@ final class GenerateModelsProcessor: CompilationProcessor { private let logger: ILogger private let compilerConfig: CompilerConfig + private let generateNativeSources: Bool - init(logger: ILogger, compilerConfig: CompilerConfig) { + init(logger: ILogger, compilerConfig: CompilerConfig, generateNativeSources: Bool) { self.logger = logger self.compilerConfig = compilerConfig + self.generateNativeSources = generateNativeSources } var description: String { @@ -61,65 +63,73 @@ final class GenerateModelsProcessor: CompilationProcessor { let classMapping: ResolvedClassMapping } - private func typeDescription(for exportedType: ExportedType) -> GeneratedTypeDescription? { + private func typeDescription(for exportedType: ExportedType, baseline: String?) -> GeneratedTypeDescription { switch exportedType { case let .valdiModel(model): if model.exportAsInterface { - return .interface(GeneratedNativeInterfaceDescription(model: model)) + return .interface(GeneratedNativeInterfaceDescription(model: model, baseline: baseline)) } else { - return .class(GeneratedNativeClassDescription(model: model)) + return .class(GeneratedNativeClassDescription(model: model, baseline: baseline)) } case let .enum(exportedEnum): - return .enum(GeneratedEnumDescription.from(exportedEnum: exportedEnum)) + return .enum(GeneratedEnumDescription.from(exportedEnum: exportedEnum, baseline: baseline)) case let .function(exportedFunction): - return .function(GeneratedFunctionDescription.from(exportedFunction: exportedFunction)) - case .module: - return nil + return .function(GeneratedFunctionDescription.from(exportedFunction: exportedFunction, baseline: baseline)) + case let .module(exportedModule): + return .module(GeneratedNativeInterfaceDescription(model: exportedModule.model, baseline: baseline)) } } private func generate(selectedItem: SelectedItem<[IntermediateItem]>) -> [CompilationItem] { var out = [CompilationItem]() for item in selectedItem.data { - switch item.exportedType { - case .valdiModel(let valdiModel): - out += doGenerate(item: selectedItem.item, - intermediateItem: item, - iosType: valdiModel.iosType, - androidClassName: valdiModel.androidClassName, - cppType: valdiModel.cppType, - generationType: "model", - generator: ValdiModelGenerator(model: valdiModel)) - case .enum(let exportedEnum): - out += doGenerate(item: selectedItem.item, - intermediateItem: item, - iosType: exportedEnum.iosType, - androidClassName: exportedEnum.androidTypeName, - cppType: exportedEnum.cppType, - generationType: "enum", - generator: ExportedEnumGenerator(exportedEnum: exportedEnum)) - case .function(let exportedFunc): - out += doGenerate(item: selectedItem.item, - intermediateItem: item, - iosType: exportedFunc.containingIosType, - androidClassName: exportedFunc.containingAndroidTypeName, - cppType: exportedFunc.containingCppType, - generationType: "function", - generator: ExportedFunctionGenerator(exportedFunction: exportedFunc, modulePath: selectedItem.item.relativeBundleURL.deletingPathExtension().absoluteString)) - case .module(let exportedModule): - out += doGenerate(item: selectedItem.item, - intermediateItem: item, - iosType: exportedModule.model.iosType, - androidClassName: exportedModule.model.androidClassName, - cppType: exportedModule.model.cppType, - generationType: "module", - generator: ExportedModuleGenerator(bundleInfo: selectedItem.item.bundleInfo, exportedModule: exportedModule)) + if generateNativeSources { + switch item.exportedType { + case .valdiModel(let valdiModel): + out += doGenerate(item: selectedItem.item, + intermediateItem: item, + iosType: valdiModel.iosType, + androidClassName: valdiModel.androidClassName, + cppType: valdiModel.cppType, + generationType: "model", + generator: ValdiModelGenerator(model: valdiModel)) + case .enum(let exportedEnum): + out += doGenerate(item: selectedItem.item, + intermediateItem: item, + iosType: exportedEnum.iosType, + androidClassName: exportedEnum.androidTypeName, + cppType: exportedEnum.cppType, + generationType: "enum", + generator: ExportedEnumGenerator(exportedEnum: exportedEnum)) + case .function(let exportedFunc): + out += doGenerate(item: selectedItem.item, + intermediateItem: item, + iosType: exportedFunc.containingIosType, + androidClassName: exportedFunc.containingAndroidTypeName, + cppType: exportedFunc.containingCppType, + generationType: "function", + generator: ExportedFunctionGenerator(exportedFunction: exportedFunc, modulePath: selectedItem.item.relativeBundleURL.deletingPathExtension().absoluteString)) + case .module(let exportedModule): + out += doGenerate(item: selectedItem.item, + intermediateItem: item, + iosType: exportedModule.model.iosType, + androidClassName: exportedModule.model.androidClassName, + cppType: exportedModule.model.cppType, + generationType: "module", + generator: ExportedModuleGenerator(bundleInfo: selectedItem.item.bundleInfo, exportedModule: exportedModule)) + } } - if let description = typeDescription(for: item.exportedType) { - let newItem = selectedItem.item.with(newKind: .generatedTypeDescription(description), newPlatform: .none) - out.append(newItem) - } + let baseline = selectedItem.item.bundleInfo.projectConfig.nativeApiMinVersion.map(String.init) + let description = typeDescription(for: item.exportedType, baseline: baseline) + let newItem = selectedItem.item.with( + newKind: .generatedTypeDescription( + description, + src: item.sourceFilename.src + ), + newPlatform: .none + ) + out.append(newItem) } if case .document = selectedItem.item.kind { @@ -148,7 +158,14 @@ final class GenerateModelsProcessor: CompilationProcessor { } if case .document(let result) = item.kind, let viewModel = result.originalDocument.viewModel { - let generatedSourceFilename = GeneratedSourceFilename(filename: result.componentPath.fileName, symbolName: result.componentPath.exportedMember) + let generatedSourceFilename = GeneratedSourceFilename( + filename: result.componentPath.fileName, + symbolName: result.componentPath.exportedMember, + src: TypeScriptItemSrc( + compilationPath: item.relativeProjectPath, + sourceURL: item.sourceURL + ) + ) var out = [IntermediateItem]() out.append(IntermediateItem(sourceFilename: generatedSourceFilename, exportedType: .valdiModel(viewModel), diff --git a/compiler/compiler/Compiler/Sources/Processors/GenerateViewClassesProcessor.swift b/compiler/compiler/Compiler/Sources/Processors/GenerateViewClassesProcessor.swift index 1cc3ba86..f7575c63 100644 --- a/compiler/compiler/Compiler/Sources/Processors/GenerateViewClassesProcessor.swift +++ b/compiler/compiler/Compiler/Sources/Processors/GenerateViewClassesProcessor.swift @@ -32,7 +32,14 @@ class GenerateViewClassesProcessor: CompilationProcessor { logger.debug("Not generating view class for \(item.sourceURL.path) because the root node doesn't have a custom class.") return [] } - let generatedSourceFilename = GeneratedSourceFilename(filename: item.relativeProjectPath, symbolName: compilationResult.componentPath.exportedMember) + let generatedSourceFilename = GeneratedSourceFilename( + filename: item.relativeProjectPath, + symbolName: compilationResult.componentPath.exportedMember, + src: TypeScriptItemSrc( + compilationPath: item.relativeProjectPath, + sourceURL: item.sourceURL + ) + ) let iosType = classMapping.iosType let androidClassName = classMapping.androidClassName @@ -55,7 +62,13 @@ class GenerateViewClassesProcessor: CompilationProcessor { } let nativeSourceItems = result.nativeSources.map { item.with(newKind: .nativeSource($0.source), newPlatform: $0.platform) } let typeDescription = GeneratedTypeDescription.viewClass(result.description) - let descriptionItem = item.with(newKind: .generatedTypeDescription(typeDescription), newPlatform: .none) + let descriptionItem = item.with( + newKind: .generatedTypeDescription( + typeDescription, + src: generatedSourceFilename.src + ), + newPlatform: .none + ) return nativeSourceItems + [descriptionItem] } catch let error { logger.error("Failed to generate View class for \(item.sourceURL.path): \(error.legibleLocalizedDescription)") diff --git a/compiler/compiler/Compiler/Sources/Processors/GeneratedTypesVerificationProcessor.swift b/compiler/compiler/Compiler/Sources/Processors/GeneratedTypesVerificationProcessor.swift index 0f4ef8c7..925e7a07 100644 --- a/compiler/compiler/Compiler/Sources/Processors/GeneratedTypesVerificationProcessor.swift +++ b/compiler/compiler/Compiler/Sources/Processors/GeneratedTypesVerificationProcessor.swift @@ -23,7 +23,7 @@ class GeneratedTypesVerificationProcessor: CompilationProcessor { func process(items: CompilationItems) throws -> CompilationItems { try items.select { item -> GeneratedTypeDescription? in - guard case let .generatedTypeDescription(generatedTypeDescription) = item.kind else { + guard case let .generatedTypeDescription(generatedTypeDescription, _) = item.kind else { return nil } return generatedTypeDescription diff --git a/compiler/compiler/Compiler/Sources/Processors/NativeCodeGenerationManager.swift b/compiler/compiler/Compiler/Sources/Processors/NativeCodeGenerationManager.swift index a444b372..ad8271f3 100644 --- a/compiler/compiler/Compiler/Sources/Processors/NativeCodeGenerationManager.swift +++ b/compiler/compiler/Compiler/Sources/Processors/NativeCodeGenerationManager.swift @@ -236,6 +236,20 @@ class NativeCodeGenerationManager { nativeTypeResolver.registerTypeConverter(src: commentedFile.src, emittingBundleName: compilationItem.bundleInfo.name, tsTypeName: symbol.text, fromTypePath: typeReferenceFromType.fileName, tsFromTypeName: typeReferenceFromType.name, toTypePath: typeReferenceToType.fileName, tsToTypeName: typeReferenceToType.name) } + func nativeTypeDescription(annotatedSymbol: TypeScriptAnnotatedSymbol, + nativeClass: TypeScriptNativeClass, + compilationItem: CompilationItem) -> GeneratedTypeDescription { + let baseline = compilationItem.bundleInfo.projectConfig.nativeApiMinVersion.map(String.init) + let description = GeneratedNativeClassDescription( + nativeClass: nativeClass, + annotations: annotatedSymbol.annotations, + baseline: baseline + ) + return nativeClass.kind == .interface + ? .interface(description) + : .class(description) + } + func addViewModelSymbol(sourceURL: URL, symbol: String) { viewModelSymbolNameBySourceURL.data { $0[sourceURL] = symbol } } @@ -516,7 +530,15 @@ class NativeCodeGenerationManager { let resolvedClassMapping = ResolvedClassMapping(localClassMapping: classMapping, projectClassMapping: ProjectClassMapping(allowMappingOverride: false), currentBundle: nativeModuleToGenerate.compilationItem.bundleInfo) let item = nativeModuleToGenerate.compilationItem.with( - newKind: .exportedType(.module(exportedModule), resolvedClassMapping, GeneratedSourceFilename(filename: nativeModuleToGenerate.compilationItem.relativeProjectPath, symbolName: nativeModuleToGenerate.tsTypeName)) + newKind: .exportedType( + .module(exportedModule), + resolvedClassMapping, + GeneratedSourceFilename( + filename: nativeModuleToGenerate.compilationItem.relativeProjectPath, + symbolName: nativeModuleToGenerate.tsTypeName, + src: nativeModuleToGenerate.commentedFile.src + ) + ) ) items.append(item: item) @@ -536,7 +558,11 @@ class NativeCodeGenerationManager { return nativeTypeExported.export().then { (nativeTypeToExport, classMapping) -> Void in let resolvedClassMapping = ResolvedClassMapping(localClassMapping: classMapping, projectClassMapping: ProjectClassMapping(allowMappingOverride: false), currentBundle: nativeClassToGenerate.compilationItem.bundleInfo) - let generatedSourceFilename = GeneratedSourceFilename(filename: nativeClassToGenerate.compilationItem.relativeProjectPath, symbolName: nativeClassToGenerate.nativeClass.tsTypeName) + let generatedSourceFilename = GeneratedSourceFilename( + filename: nativeClassToGenerate.compilationItem.relativeProjectPath, + symbolName: nativeClassToGenerate.nativeClass.tsTypeName, + src: nativeClassToGenerate.commentedFile.src + ) switch nativeTypeToExport { case .valdiModel(let model): @@ -618,7 +644,15 @@ class NativeCodeGenerationManager { let resolvedClassMapping = ResolvedClassMapping(localClassMapping: classMapping, projectClassMapping: ProjectClassMapping(allowMappingOverride: false), currentBundle: nativeFuncToGenerate.compilationItem.bundleInfo) let item = nativeFuncToGenerate.compilationItem.with( - newKind: .exportedType(.function(exportedFunction), resolvedClassMapping, GeneratedSourceFilename(filename: nativeFuncToGenerate.compilationItem.relativeProjectPath, symbolName: nativeFuncToGenerate.tsTypeName)) + newKind: .exportedType( + .function(exportedFunction), + resolvedClassMapping, + GeneratedSourceFilename( + filename: nativeFuncToGenerate.compilationItem.relativeProjectPath, + symbolName: nativeFuncToGenerate.tsTypeName, + src: nativeFuncToGenerate.commentedFile.src + ) + ) ) items.append(item: item) @@ -626,7 +660,7 @@ class NativeCodeGenerationManager { let exporterError = CompilerError(type: "NativeFuncExporter error", message: "Failed to export native function '\(nativeFuncToGenerate.dumpedSymbol.text)': \(error.legibleLocalizedDescription)", range: nativeFuncToGenerate.annotation.range, inDocument: nativeFuncToGenerate.commentedFile.fileContent) return Promise(error: exporterError) } - } + } private func makeNativeClassToGenerate(commentedFile: TypeScriptCommentedFile, annotation: ValdiTypeScriptAnnotation, @@ -736,4 +770,4 @@ class NativeCodeGenerationManager { return nativeFunc } -} \ No newline at end of file +} diff --git a/compiler/compiler/Compiler/Sources/Processors/TypeScriptAnnotationsManager.swift b/compiler/compiler/Compiler/Sources/Processors/TypeScriptAnnotationsManager.swift index f3f93928..abf8fb95 100644 --- a/compiler/compiler/Compiler/Sources/Processors/TypeScriptAnnotationsManager.swift +++ b/compiler/compiler/Compiler/Sources/Processors/TypeScriptAnnotationsManager.swift @@ -42,6 +42,7 @@ enum ValdiAnnotationType: String, CaseIterable { case allowSyncCall = "AllowSyncCall" case untypedMap = "UntypedMap" case untyped = "Untyped" + case version = "Version" /// Returns true if this annotation type can have ios/android parameters that indicate native exports. /// Used for detecting native exports in Vue files and validating annotation parameters. @@ -71,7 +72,8 @@ enum ValdiAnnotationType: String, CaseIterable { .workerThread, .allowSyncCall, .untypedMap, - .untyped: + .untyped, + .version: return false } } diff --git a/compiler/compiler/Compiler/Sources/Template/CompilationMetadata.swift b/compiler/compiler/Compiler/Sources/Template/CompilationMetadata.swift index 022e0eff..7cfea100 100644 --- a/compiler/compiler/Compiler/Sources/Template/CompilationMetadata.swift +++ b/compiler/compiler/Compiler/Sources/Template/CompilationMetadata.swift @@ -3,8 +3,45 @@ import Foundation struct CompilationMetadata: Codable { + let nativeApiMinVersion: String? let classMappings: [String: ValdiClass] let nativeTypes: SerializedTypeScriptNativeTypeResolver + let generatedTypes: [GeneratedTypesSummary] + init(classMappings: [String: ValdiClass], + nativeTypes: SerializedTypeScriptNativeTypeResolver, + nativeApiMinVersion: String?, + generatedTypes: [GeneratedTypesSummary]) { + self.nativeApiMinVersion = nativeApiMinVersion + self.classMappings = classMappings + self.nativeTypes = nativeTypes + self.generatedTypes = generatedTypes + } + + enum CodingKeys: CodingKey { + case nativeApiMinVersion + case classMappings + case nativeTypes + case generatedTypes + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + nativeApiMinVersion = try container.decodeIfPresent(String.self, forKey: .nativeApiMinVersion) + classMappings = try container.decode([String: ValdiClass].self, forKey: .classMappings) + nativeTypes = try container.decode(SerializedTypeScriptNativeTypeResolver.self, forKey: .nativeTypes) + // Compilation metadata is decoded only to restore class mappings and native type resolver + // entries from dependencies. Generated type descriptions are an output-only API snapshot, + // so decoding intentionally does not round-trip them. + generatedTypes = [] + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(nativeApiMinVersion, forKey: .nativeApiMinVersion) + try container.encode(classMappings, forKey: .classMappings) + try container.encode(nativeTypes, forKey: .nativeTypes) + try container.encode(generatedTypes, forKey: .generatedTypes) + } } diff --git a/compiler/compiler/Compiler/Sources/TypeScript/CompanionExecutable.swift b/compiler/compiler/Compiler/Sources/TypeScript/CompanionExecutable.swift index b8b6a346..3276e73a 100644 --- a/compiler/compiler/Compiler/Sources/TypeScript/CompanionExecutable.swift +++ b/compiler/compiler/Compiler/Sources/TypeScript/CompanionExecutable.swift @@ -52,6 +52,7 @@ private struct CreateWorkspace: Command { static let type: CommandType = .createWorkspace struct Request: RequestBody { + let nativeApiMinVersion: Int? } struct Response: ResponseBody { @@ -594,8 +595,8 @@ class CompanionExecutable { return processRequest(BatchMinifyJS.self, request).then { return $0.results } } - func createWorkspace() -> Promise { - let request = CreateWorkspace.Request() + func createWorkspace(nativeApiMinVersion: Int?) -> Promise { + let request = CreateWorkspace.Request(nativeApiMinVersion: nativeApiMinVersion) return processRequest(CreateWorkspace.self, request).then { response in response.workspaceId } } diff --git a/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptAnnotation.swift b/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptAnnotation.swift index 2626f170..69026af7 100644 --- a/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptAnnotation.swift +++ b/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptAnnotation.swift @@ -23,16 +23,21 @@ struct ValdiTypeScriptAnnotation { let name: String let range: NSRange let parameters: [String: String]? + let positionalPayload: String? let content: String - init(name: String, parameters: [String: String]?, range: NSRange, content: String) { + init(name: String, parameters: [String: String]?, positionalPayload: String?, range: NSRange, content: String) { self.name = name self.parameters = parameters + self.positionalPayload = positionalPayload self.range = range self.content = content } - private static let annotationRegex = try! NSRegularExpression(pattern: "@([A-z-]+) *(?:\\((?:\\{(.*?)\\})\\))?", options: [.dotMatchesLineSeparators]) + private static let annotationRegex = try! NSRegularExpression( + pattern: "@([A-z-]+) *(?:\\((?:\\{(.*?)\\}|\\s*(\\d+|__PLACEHOLDER__)\\s*)\\))?", + options: [.dotMatchesLineSeparators] + ) static func extractAnnotations(comments: TS.AST.Comments, fileContent: String) throws -> [ValdiTypeScriptAnnotation] { let joinedComments = comments.text @@ -76,7 +81,30 @@ struct ValdiTypeScriptAnnotation { parameters = foundParameters } - annotations.append(ValdiTypeScriptAnnotation(name: annotationName, parameters: parameters, range: totalRange, content: annotationContent)) + let positionalPayloadRange = match.range(at: 3) + let positionalPayload: String? + if positionalPayloadRange.location != NSNotFound && annotationName != ValdiAnnotationType.version.rawValue { + let rangeInFile = NSRange( + location: commentsRange.location + positionalPayloadRange.location, + length: positionalPayloadRange.length + ) + try throwAnnotationError( + message: "Only @Version supports a positional annotation payload", + range: rangeInFile, + inDocument: fileContent + ) + } + if positionalPayloadRange.location != NSNotFound { + positionalPayload = nsString.substring(with: positionalPayloadRange) + } else { + positionalPayload = nil + } + + annotations.append(ValdiTypeScriptAnnotation(name: annotationName, + parameters: parameters, + positionalPayload: positionalPayload, + range: totalRange, + content: annotationContent)) } return annotations diff --git a/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptCommentedFile.swift b/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptCommentedFile.swift index 6d9a030c..0218d6bf 100644 --- a/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptCommentedFile.swift +++ b/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptCommentedFile.swift @@ -60,6 +60,22 @@ class TypeScriptCommentedFile { annotatedSymbol.addMemberAnnotations(memberAnnotations, atIndex: idx) } } + + if let enumDeclaration = annotatedSymbol.symbol.enum { + for (idx, member) in enumDeclaration.members.enumerated() { + guard let memberComments = member.leadingComments else { + continue + } + let memberAnnotations = try ValdiTypeScriptAnnotation.extractAnnotations( + comments: memberComments, + fileContent: fileContent + ) + guard !memberAnnotations.isEmpty else { + continue + } + annotatedSymbol.addMemberAnnotations(memberAnnotations, atIndex: idx) + } + } } return annotatedSymbol diff --git a/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptCompiler.swift b/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptCompiler.swift index 4999524e..4319a8b5 100644 --- a/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptCompiler.swift +++ b/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptCompiler.swift @@ -63,7 +63,11 @@ final class TypeScriptCompiler { emitDebug: Bool) { self.logger = logger self.companion = companion - self.driver = TypeScriptCompilerCompanionDriver(logger: logger, companion: companion) + self.driver = TypeScriptCompilerCompanionDriver( + logger: logger, + companion: companion, + nativeApiMinVersion: projectConfig.nativeApiMinVersion + ) self.projectConfig = projectConfig self.fileManager = fileManager self.emitDebug = emitDebug diff --git a/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptCompilerCompanionDriver.swift b/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptCompilerCompanionDriver.swift index 0ef32dba..5f5c2614 100644 --- a/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptCompilerCompanionDriver.swift +++ b/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptCompilerCompanionDriver.swift @@ -11,13 +11,15 @@ class TypeScriptCompilerCompanionDriver: TypeScriptCompilerDriver { private let logger: ILogger private let companion: CompanionExecutable + private let nativeApiMinVersion: Int? private var workspaceId: Int? private var lock = DispatchSemaphore.newLock() private var currentCreateWorkspacePromise: Promise? - init(logger: ILogger, companion: CompanionExecutable) { + init(logger: ILogger, companion: CompanionExecutable, nativeApiMinVersion: Int?) { self.logger = logger self.companion = companion + self.nativeApiMinVersion = nativeApiMinVersion } func destroyWorkspace() -> Promise { @@ -51,7 +53,7 @@ class TypeScriptCompilerCompanionDriver: TypeScriptCompilerDriver { lock.lock { if self.workspaceId == nil && self.currentCreateWorkspacePromise == nil { logger.trace("Creating TypeScript Workspace project") - self.currentCreateWorkspacePromise = companion.createWorkspace() + self.currentCreateWorkspacePromise = companion.createWorkspace(nativeApiMinVersion: nativeApiMinVersion) needsOnWorkspaceCreatedCallback = true } diff --git a/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptNativeTypeExporter.swift b/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptNativeTypeExporter.swift index 2e8c0d13..f1067d64 100644 --- a/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptNativeTypeExporter.swift +++ b/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptNativeTypeExporter.swift @@ -27,6 +27,7 @@ private struct EnumMember { let value: Value let comments: String? + let declaredVersion: String? } private class DocumentIndexMatcher { @@ -151,7 +152,13 @@ final class TypeScriptNativeTypeExporter { } if let function = type.function { - let functionParameters = try function.parameters.map { try parsePropertyOrParameter(propertyLikeDeclaration: $0, references: references) } + let functionParameters = try function.parameters.map { + try parsePropertyOrParameter( + propertyLikeDeclaration: $0, + references: references, + declaredVersion: nil + ) + } let returnValue = try resolveType(type: function.returnValue, references: references) var isSingleCall = false @@ -171,6 +178,8 @@ final class TypeScriptNativeTypeExporter { shouldCallOnWorkerThread = true } else if annotation == .allowSyncCall { allowSyncCall = true + } else if annotation == .version { + // Version annotations are validation metadata and do not affect native code generation. } else { try self.throwAnnotationError(comments: type.leadingComments!, message: "Function only support the @SingleCall, @WorkerThread and @AllowSyncCall annotations") } @@ -347,7 +356,8 @@ final class TypeScriptNativeTypeExporter { type: TS.AST.TSType, isOptional: Bool, leadingComments: TS.AST.Comments?, - references: [TS.AST.TypeReference]) throws -> ValdiModelProperty { + references: [TS.AST.TypeReference], + declaredVersion: String?) throws -> ValdiModelProperty { var parsedType = try resolveType(type: type, references: references) if isOptional { parsedType = .nullable(parsedType) @@ -385,15 +395,19 @@ final class TypeScriptNativeTypeExporter { type: parsedType, comments: resolvedPropertyMetadata?.comments, omitConstructor: resolvedPropertyMetadata?.omitConstructor, - injectableParams: resolvedPropertyMetadata?.injectableParams ?? .empty) + injectableParams: resolvedPropertyMetadata?.injectableParams ?? .empty, + declaredVersion: declaredVersion) } - private func parsePropertyOrParameter(propertyLikeDeclaration: TS.AST.PropertyLikeDeclaration, references: [TS.AST.TypeReference]) throws -> ValdiModelProperty { + private func parsePropertyOrParameter(propertyLikeDeclaration: TS.AST.PropertyLikeDeclaration, + references: [TS.AST.TypeReference], + declaredVersion: String?) throws -> ValdiModelProperty { return try parsePropertyLike(name: propertyLikeDeclaration.name, type: propertyLikeDeclaration.type, isOptional: propertyLikeDeclaration.isOptional, leadingComments: propertyLikeDeclaration.leadingComments, - references: references) + references: references, + declaredVersion: declaredVersion) } func export() -> Promise<(ExportedType, ValdiClassMapping)> { @@ -404,20 +418,22 @@ final class TypeScriptNativeTypeExporter { } } - private func parseEnumMember(enumMember: TS.AST.EnumMember, sequence: EnumMemberSequence) throws -> EnumMember { + private func parseEnumMember(enumMember: TS.AST.EnumMember, + sequence: EnumMemberSequence, + declaredVersion: String?) throws -> EnumMember { if let numberValue = enumMember.numberValue { guard let enumValue = Int(numberValue) else { throw CompilerError("Could not parse number '\(numberValue)' in enum member \(enumMember.name) ") } sequence.setNext(enumValue + 1) - return EnumMember(name: enumMember.name, value: .number(enumValue), comments: enumMember.leadingComments?.text) + return EnumMember(name: enumMember.name, value: .number(enumValue), comments: enumMember.leadingComments?.text, declaredVersion: declaredVersion) } if let stringValue = enumMember.stringValue { - return EnumMember(name: enumMember.name, value: .string(stringValue), comments: enumMember.leadingComments?.text) + return EnumMember(name: enumMember.name, value: .string(stringValue), comments: enumMember.leadingComments?.text, declaredVersion: declaredVersion) } - return EnumMember(name: enumMember.name, value: .number(sequence.assign()), comments: enumMember.leadingComments?.text) + return EnumMember(name: enumMember.name, value: .number(sequence.assign()), comments: enumMember.leadingComments?.text, declaredVersion: declaredVersion) } func exportFunction() -> Promise<(ExportedFunction, ValdiClassMapping)> { @@ -429,7 +445,13 @@ final class TypeScriptNativeTypeExporter { let allowSyncCall = annotatedSymbol.annotations.contains(where: { $0.name == ValdiAnnotationType.allowSyncCall.rawValue }) do { - let parameters = try dumpedFunction.type.parameters.map { try self.parsePropertyOrParameter(propertyLikeDeclaration: $0, references: self.commentedFile.references) } + let parameters = try dumpedFunction.type.parameters.map { + try self.parsePropertyOrParameter( + propertyLikeDeclaration: $0, + references: self.commentedFile.references, + declaredVersion: nil + ) + } let returnType = try self.resolveType(type: dumpedFunction.type.returnValue, references: self.commentedFile.references) let exportedFunction = ExportedFunction(containingIosType: self.iosType, containingAndroidTypeName: self.androidClass, @@ -438,7 +460,8 @@ final class TypeScriptNativeTypeExporter { parameters: parameters, returnType: returnType, allowSyncCall: allowSyncCall, - comments: comments) + comments: comments, + declaredVersion: nativeApiDeclaredVersion(annotations: annotatedSymbol.annotations)) let classMapping = ValdiClassMapping() return Promise(data: (exportedFunction, classMapping)) } catch { @@ -449,13 +472,17 @@ final class TypeScriptNativeTypeExporter { func exportModule() -> Promise<(ExportedModule, ValdiClassMapping)> { do { var model = ValdiModel() + model.tsType = dumpedSymbol.text model.exportAsInterface = true model.cppType = cppType model.iosType = iosType model.androidClassName = androidClass + model.declaredVersion = nativeApiDeclaredVersion(annotations: annotatedSymbol.annotations) + model.isNativeApi = true - for annotatedSymbol in commentedFile.annotatedSymbols where annotatedSymbol.symbol.modifiers?.contains("export") == true { - if let function = annotatedSymbol.symbol.function { + for exportedSymbol in commentedFile.annotatedSymbols where exportedSymbol.symbol.modifiers?.contains("export") == true { + let declaredVersion = nativeApiDeclaredVersion(annotations: exportedSymbol.annotations) + if let function = exportedSymbol.symbol.function { let wrappedType = TS.AST.TSType(name: function.name, leadingComments: nil, function: function.type, @@ -468,11 +495,19 @@ final class TypeScriptNativeTypeExporter { type: wrappedType, isOptional: false, leadingComments: dumpedSymbol.leadingComments, - references: self.commentedFile.references)) + references: self.commentedFile.references, + declaredVersion: declaredVersion)) } - if let variable = annotatedSymbol.symbol.variable { - model.properties.append(try parsePropertyLike(name: variable.name, type: variable.type, isOptional: false, leadingComments: variable.leadingComments, references: self.commentedFile.references)) + if let variable = exportedSymbol.symbol.variable { + model.properties.append(try parsePropertyLike( + name: variable.name, + type: variable.type, + isOptional: false, + leadingComments: variable.leadingComments, + references: self.commentedFile.references, + declaredVersion: declaredVersion + )) } } @@ -494,7 +529,15 @@ final class TypeScriptNativeTypeExporter { do { let enumSequence = EnumMemberSequence() - let enumMembers = try dumpedEnum.members.map { try self.parseEnumMember(enumMember: $0, sequence: enumSequence) } + let enumMembers = try dumpedEnum.members.enumerated().map { index, enumMember in + try self.parseEnumMember( + enumMember: enumMember, + sequence: enumSequence, + declaredVersion: nativeApiDeclaredVersion( + annotations: annotatedSymbol.memberAnnotations[index] ?? [] + ) + ) + } let classMapping = ValdiClassMapping() @@ -504,7 +547,7 @@ final class TypeScriptNativeTypeExporter { TypeScriptAnnotatedSymbol.cleanCommentString($0) } - return EnumCase(name: member.name, value: value, comments: comments) + return EnumCase(name: member.name, value: value, comments: comments, declaredVersion: member.declaredVersion) } let numberCases: [EnumCase] = enumMembers.compactMap { member in guard case let .number(value) = member.value else { return nil } @@ -512,7 +555,7 @@ final class TypeScriptNativeTypeExporter { TypeScriptAnnotatedSymbol.cleanCommentString($0) } - return EnumCase(name: member.name, value: value, comments: comments) + return EnumCase(name: member.name, value: value, comments: comments, declaredVersion: member.declaredVersion) } let valid = stringCases.isEmpty != numberCases.isEmpty @@ -531,7 +574,8 @@ final class TypeScriptNativeTypeExporter { androidTypeName: self.androidClass, cppType: self.cppType, cases: enumCases, - comments: comments) + comments: comments, + declaredVersion: nativeApiDeclaredVersion(annotations: annotatedSymbol.annotations)) return Promise(data: (.enum(exportedEnum), classMapping)) } catch { @@ -548,9 +592,15 @@ final class TypeScriptNativeTypeExporter { do { var properties: [ValdiModelProperty] = [] - for member in dumpedInterface.members { + for (index, member) in dumpedInterface.members.enumerated() { do { - let property = try self.parsePropertyOrParameter(propertyLikeDeclaration: member, references: self.commentedFile.references) + let property = try self.parsePropertyOrParameter( + propertyLikeDeclaration: member, + references: self.commentedFile.references, + declaredVersion: nativeApiDeclaredVersion( + annotations: annotatedSymbol.memberAnnotations[index] ?? [] + ) + ) properties.append(property) } catch let error { throw CompilerError("Failed to parse property \(member.name): \(error.legibleLocalizedDescription)") @@ -565,6 +615,8 @@ final class TypeScriptNativeTypeExporter { model.properties = properties model.comments = comments model.typeParameters = dumpedInterface.typeParameters?.map { ValdiTypeParameter(name: $0.name) } + model.declaredVersion = nativeApiDeclaredVersion(annotations: annotatedSymbol.annotations) + model.isNativeApi = true // Check for usePublicFields parameter in ExportModel annotation if let exportModelAnnotation = self.annotatedSymbol.annotations.first(where: { $0.name == "ExportModel" }) { diff --git a/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptNativeTypeResolver.swift b/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptNativeTypeResolver.swift index b5e3884c..2c555072 100644 --- a/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptNativeTypeResolver.swift +++ b/compiler/compiler/Compiler/Sources/TypeScript/TypeScriptNativeTypeResolver.swift @@ -54,7 +54,6 @@ final class TypeScriptNativeTypeResolver { private let lock = DispatchSemaphore.newLock() private var typesByPath = [String: [String: TypeScriptNativeType]]() - init(rootURL: URL) { self.rootURL = rootURL } diff --git a/compiler/compiler/Compiler/Sources/Utils/GeneratedSourceFilename.swift b/compiler/compiler/Compiler/Sources/Utils/GeneratedSourceFilename.swift index 9c2f2185..3000e5e8 100644 --- a/compiler/compiler/Compiler/Sources/Utils/GeneratedSourceFilename.swift +++ b/compiler/compiler/Compiler/Sources/Utils/GeneratedSourceFilename.swift @@ -10,4 +10,5 @@ import Foundation struct GeneratedSourceFilename { let filename: String let symbolName: String + let src: TypeScriptItemSrc } diff --git a/compiler/compiler/Compiler/Sources/ValdiCompilerRunner.swift b/compiler/compiler/Compiler/Sources/ValdiCompilerRunner.swift index 11cd49f9..d1cf3f87 100644 --- a/compiler/compiler/Compiler/Sources/ValdiCompilerRunner.swift +++ b/compiler/compiler/Compiler/Sources/ValdiCompilerRunner.swift @@ -423,11 +423,6 @@ class ValdiCompilerRunner { typeScriptCompilerManager: typeScriptCompilerManager, typeScriptAnnotationsManager: typeScriptAnnotationsManager, nativeCodeGenerationManager: nativeCodeGenerationManager)) - builder.append(processor: DumpCompilationMetadataProcessor(projectConfig: configs.projectConfig, - compilerConfig: configs.compilerConfig, - projectClassMappingManager: projectClassMappingManager, - typeScriptCompilationManager: typeScriptCompilerManager, - typeScriptNativeTypeResolver: nativeCodeGenerationManager.nativeTypeResolver)) if !codeGenOnly { builder.append(processor: CompileTypeScriptProcessor(typeScriptCompilerManager: typeScriptCompilerManager, compilerConfig: configs.compilerConfig)) @@ -465,12 +460,28 @@ class ValdiCompilerRunner { if !configs.compilerConfig.generateTSResFiles { if !hotReloadingEnabled && !regenerateValdiModulesBuildFilesOnly { builder.append(postprocessor: GenerateViewClassesProcessor(logger: logger, compilerConfig: configs.compilerConfig)) - builder.append(postprocessor: GenerateModelsProcessor(logger: logger, compilerConfig: configs.compilerConfig)) + builder.append(postprocessor: GenerateModelsProcessor(logger: logger, + compilerConfig: configs.compilerConfig, + generateNativeSources: true)) + builder.append(postprocessor: DumpCompilationMetadataProcessor(projectConfig: configs.projectConfig, + compilerConfig: configs.compilerConfig, + projectClassMappingManager: projectClassMappingManager, + typeScriptCompilationManager: typeScriptCompilerManager, + typeScriptNativeTypeResolver: nativeCodeGenerationManager.nativeTypeResolver)) // GenerateDependencyInjectionDataProcessor must run BEFORE CombineNativeSourcesProcessor // so that Factory classes are included in the combined output for single_file_codegen modules builder.append(postprocessor: GenerateDependencyInjectionDataProcessor(logger: logger, onlyFocusProcessingForModules: configs.compilerConfig.onlyFocusProcessingForModules)) builder.append(postprocessor: CombineNativeSourcesProcessor(logger: logger, compilerConfig: configs.compilerConfig, projectConfig: configs.projectConfig, bundleManager: bundleManager)) builder.append(postprocessor: GeneratedTypesVerificationProcessor(logger: logger, projectConfig: configs.projectConfig)) + } else { + builder.append(postprocessor: GenerateModelsProcessor(logger: logger, + compilerConfig: configs.compilerConfig, + generateNativeSources: false)) + builder.append(postprocessor: DumpCompilationMetadataProcessor(projectConfig: configs.projectConfig, + compilerConfig: configs.compilerConfig, + projectClassMappingManager: projectClassMappingManager, + typeScriptCompilationManager: typeScriptCompilerManager, + typeScriptNativeTypeResolver: nativeCodeGenerationManager.nativeTypeResolver)) } if !codeGenOnly && !regenerateValdiModulesBuildFilesOnly { diff --git a/compiler/compiler/Compiler/Sources/ViewModels/ExportedEnumGenerator.swift b/compiler/compiler/Compiler/Sources/ViewModels/ExportedEnumGenerator.swift index 5b16ed9b..ad558d86 100644 --- a/compiler/compiler/Compiler/Sources/ViewModels/ExportedEnumGenerator.swift +++ b/compiler/compiler/Compiler/Sources/ViewModels/ExportedEnumGenerator.swift @@ -18,6 +18,7 @@ struct ExportedEnum { } let cases: Cases let comments: String? + let declaredVersion: String? } final class ExportedEnumGenerator: NativeSourceGenerator { diff --git a/compiler/compiler/Compiler/Tests/CompilerTests/NativeApiMetadataTests.swift b/compiler/compiler/Compiler/Tests/CompilerTests/NativeApiMetadataTests.swift new file mode 100644 index 00000000..6836def0 --- /dev/null +++ b/compiler/compiler/Compiler/Tests/CompilerTests/NativeApiMetadataTests.swift @@ -0,0 +1,148 @@ +import Foundation +import XCTest +@testable import Compiler + +final class NativeApiMetadataTests: XCTestCase { + private func apiDescription() -> GeneratedTypeDescription { + return .function( + GeneratedFunctionDescription( + containingIosTypeName: nil, + containingAndroidTypeName: nil, + containingCppTypeName: nil, + functionName: "exportedFunction", + parameters: [], + returnType: PropertyTypeDescription(propType: .void), + declaredVersion: nil, + effectiveVersion: "0" + ) + ) + } + + func testVersionInheritance() { + XCTAssertEqual(nativeApiEffectiveMemberVersion(declared: nil, container: "3"), "3") + XCTAssertEqual(nativeApiEffectiveMemberVersion(declared: "2", container: "3"), "3") + XCTAssertEqual(nativeApiEffectiveMemberVersion(declared: "7", container: "3"), "7") + XCTAssertEqual(nativeApiEffectiveMemberVersion(declared: "__PLACEHOLDER__", container: "3"), "__PLACEHOLDER__") + XCTAssertEqual(nativeApiEffectiveMemberVersion(declared: "future", container: "3"), "future") + XCTAssertEqual(nativeApiEffectiveMemberVersion(declared: "2", container: "future"), "future") + } + + func testVersionsAlwaysEncodeAsStringsOrNull() throws { + let data = try JSONEncoder().encode(apiDescription()) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let function = try XCTUnwrap(object["function"] as? [String: Any]) + + XCTAssertTrue(function["declaredVersion"] is NSNull) + XCTAssertEqual(function["effectiveVersion"] as? String, "0") + } + + func testGeneratedModelDescriptionUsesExportedVersionMetadata() throws { + var model = ValdiModel() + model.declaredVersion = "2" + model.isNativeApi = true + model.properties = [ + ValdiModelProperty( + name: "value", + type: .string, + comments: nil, + omitConstructor: nil, + injectableParams: .empty, + declaredVersion: "5" + ), + ] + + let description = GeneratedNativeClassDescription(model: model, baseline: "0") + XCTAssertTrue(description.isNativeApi) + XCTAssertEqual(description.declaredVersion, "2") + XCTAssertEqual(description.effectiveVersion, "2") + XCTAssertEqual(description.properties[0].declaredVersion, "5") + XCTAssertEqual(description.properties[0].effectiveVersion, "5") + + var ordinaryModel = ValdiModel() + ordinaryModel.properties = [ + ValdiModelProperty( + name: "value", + type: .string, + comments: nil, + omitConstructor: nil, + injectableParams: .empty, + declaredVersion: nil + ), + ] + + let ordinaryDescription = GeneratedNativeClassDescription(model: ordinaryModel, baseline: "9") + XCTAssertFalse(ordinaryDescription.isNativeApi) + XCTAssertNil(ordinaryDescription.effectiveVersion) + } + + func testCompilationMetadataEncodingAndLegacyDecoding() throws { + let metadata = CompilationMetadata( + classMappings: [:], + nativeTypes: SerializedTypeScriptNativeTypeResolver(entries: []), + nativeApiMinVersion: "0", + generatedTypes: [ + GeneratedTypesSummary( + sourceFilePath: "module/src/Api", + generatedTypes: [apiDescription()] + ), + ] + ) + let encoded = try JSONEncoder().encode(metadata) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + + XCTAssertEqual(object["nativeApiMinVersion"] as? String, "0") + let generatedTypes = try XCTUnwrap(object["generatedTypes"] as? [[String: Any]]) + XCTAssertEqual(generatedTypes.count, 1) + XCTAssertEqual(generatedTypes[0]["sourceFilePath"] as? String, "module/src/Api") + + let decoded = try JSONDecoder().decode(CompilationMetadata.self, from: encoded) + XCTAssertTrue(decoded.generatedTypes.isEmpty) + + let unversionedMetadata = CompilationMetadata( + classMappings: [:], + nativeTypes: SerializedTypeScriptNativeTypeResolver(entries: []), + nativeApiMinVersion: nil, + generatedTypes: [] + ) + let unversionedData = try JSONEncoder().encode(unversionedMetadata) + let unversionedObject = try XCTUnwrap(JSONSerialization.jsonObject(with: unversionedData) as? [String: Any]) + XCTAssertTrue(unversionedObject["nativeApiMinVersion"] is NSNull) + + let legacy = Data(#"{"classMappings":{},"nativeTypes":{"entries":[]}}"#.utf8) + let decodedLegacy = try JSONDecoder().decode(CompilationMetadata.self, from: legacy) + + XCTAssertNil(decodedLegacy.nativeApiMinVersion) + XCTAssertTrue(decodedLegacy.generatedTypes.isEmpty) + } + + func testWireTypeDescriptionIsRecursive() throws { + let callback = ValdiModelPropertyType.function( + parameters: [ + ValdiModelProperty( + name: "values", + type: .array(elementType: .map(keyType: .string, valueType: .nullable(.long))), + comments: "not metadata", + omitConstructor: nil, + injectableParams: .empty, + declaredVersion: nil + ), + ], + returnType: .promise(typeArgument: .genericTypeParameter(name: "T")), + isSingleCall: true, + shouldCallOnWorkerThread: true, + allowSyncCall: true + ) + let data = try JSONEncoder().encode(PropertyTypeDescription(propType: callback)) + let json = String(decoding: data, as: UTF8.self) + + XCTAssertTrue(json.contains(#""typeStr":"function""#)) + XCTAssertTrue(json.contains(#""typeStr":"array""#)) + XCTAssertTrue(json.contains(#""typeStr":"map""#)) + XCTAssertTrue(json.contains(#""typeStr":"nullable""#)) + XCTAssertTrue(json.contains(#""typeStr":"promise""#)) + XCTAssertTrue(json.contains(#""typeParameterName":"T""#)) + XCTAssertFalse(json.contains("declaredVersion")) + XCTAssertFalse(json.contains("not metadata")) + XCTAssertFalse(json.contains("sourcePosition")) + } +} diff --git a/compiler/compiler/Compiler/Tests/CompilerTests/ValdiAnnotationTests.swift b/compiler/compiler/Compiler/Tests/CompilerTests/ValdiAnnotationTests.swift index 3a73fe24..c8c4402f 100644 --- a/compiler/compiler/Compiler/Tests/CompilerTests/ValdiAnnotationTests.swift +++ b/compiler/compiler/Compiler/Tests/CompilerTests/ValdiAnnotationTests.swift @@ -94,6 +94,51 @@ final class ValdiAnnotationTests: XCTestCase { XCTAssertEqual(result.first?.parameters, ["ios": "blah"]) } + func testVersionAnnotationWithNumericPayload() throws { + let content = """ +/** + * Native model docs. + * @Version( 42 ) + * @ExportModel + */ +""" + let annotations = try extractAnnotations(content) + + XCTAssertEqual(annotations.map(\.name), ["Version", "ExportModel"]) + XCTAssertEqual(annotations.first?.content, "@Version( 42 )") + XCTAssertEqual(annotations.first?.positionalPayload, "42") + XCTAssertEqual(nativeApiDeclaredVersion(annotations: annotations), "42") + XCTAssertEqual( + TypeScriptAnnotatedSymbol.mergedCommentsWithoutAnnotations( + fullComments: content, + annotations: annotations + ), + "Native model docs." + ) + } + + func testVersionAnnotationWithPlaceholderPayload() throws { + let content = """ +/** + * @Version(__PLACEHOLDER__) + * @NativeClass + */ +""" + let annotations = try extractAnnotations(content) + + XCTAssertEqual(annotations.map(\.name), ["Version", "NativeClass"]) + XCTAssertEqual(annotations.first?.content, "@Version(__PLACEHOLDER__)") + XCTAssertEqual(annotations.first?.positionalPayload, "__PLACEHOLDER__") + XCTAssertEqual(nativeApiDeclaredVersion(annotations: annotations), "__PLACEHOLDER__") + XCTAssertEqual( + TypeScriptAnnotatedSymbol.mergedCommentsWithoutAnnotations( + fullComments: content, + annotations: annotations + ), + "" + ) + } + func testBadCases() throws { var content: String = "" diff --git a/src/valdi_modules/src/valdi/valdi_core/src/CompilerIntrinsics.ts b/src/valdi_modules/src/valdi/valdi_core/src/CompilerIntrinsics.ts index 44dfbb4e..b7f84fb1 100644 --- a/src/valdi_modules/src/valdi/valdi_core/src/CompilerIntrinsics.ts +++ b/src/valdi_modules/src/valdi/valdi_core/src/CompilerIntrinsics.ts @@ -1,4 +1,15 @@ import { AnyRenderFunction } from 'valdi_core/src/AnyRenderFunction'; +import { ValdiRuntime } from './ValdiRuntime'; + +declare global { + /** Placeholder for a native API version allocated automatically when a change merges. */ + const __PLACEHOLDER__: number; +} + +declare const runtime: ValdiRuntime; +const PLACEHOLDER_VERSION = Number.MAX_SAFE_INTEGER; + +(globalThis as typeof globalThis & { __PLACEHOLDER__: number }).__PLACEHOLDER__ = PLACEHOLDER_VERSION; type GetTypeOfChildren = TViewModel extends { children: any } ? TViewModel['children'] : never; @@ -17,3 +28,12 @@ export declare function $slot(value: T | undefined) * @param value the named slots object that should be passed as the view model "children" property. */ export declare function $namedSlots(value: GetTypeOfChildren): GetTypeOfChildren; + +/** + * Compiler intrinsic to guard access to APIs annotated with @Version. + * The compiler recognizes numeric literals and __PLACEHOLDER__, and treats the + * guarded block as safe for declarations introduced at that version or lower. + */ +export function isVersionAtLeast(version: number): boolean { + return version === PLACEHOLDER_VERSION || runtime.apiVersion >= version; +} diff --git a/src/valdi_modules/src/valdi/valdi_core/src/ValdiRuntime.d.ts b/src/valdi_modules/src/valdi/valdi_core/src/ValdiRuntime.d.ts index f9b26acf..9e5bc69f 100644 --- a/src/valdi_modules/src/valdi/valdi_core/src/ValdiRuntime.d.ts +++ b/src/valdi_modules/src/valdi/valdi_core/src/ValdiRuntime.d.ts @@ -87,6 +87,8 @@ export interface LoadedAssetSkCodecMetadata { export type LoadedAssetMetadata = LoadedAssetImageMetadata | LoadedAssetLottieMetadata | LoadedAssetSkCodecMetadata; export interface ValdiRuntime extends RuntimeBase { + apiVersion: number; + postMessage(contextId: string, command: string, params: any): void; getFrameForElementId( contextId: string, diff --git a/src/valdi_modules/src/valdi/valdi_core/test/CompilerIntrinsics.spec.ts b/src/valdi_modules/src/valdi/valdi_core/test/CompilerIntrinsics.spec.ts new file mode 100644 index 00000000..bff69bae --- /dev/null +++ b/src/valdi_modules/src/valdi/valdi_core/test/CompilerIntrinsics.spec.ts @@ -0,0 +1,18 @@ +import 'jasmine/src/jasmine'; +import { isVersionAtLeast } from '../src/CompilerIntrinsics'; +import { ValdiRuntime } from '../src/ValdiRuntime'; + +declare const runtime: ValdiRuntime; + +describe('CompilerIntrinsics', () => { + it('enables placeholder-versioned API guards during development', () => { + expect(isVersionAtLeast(__PLACEHOLDER__)).toBeTrue(); + }); + + it('continues comparing concrete versions with the runtime API version', () => { + const currentVersion = runtime.apiVersion; + + expect(isVersionAtLeast(0)).toBe(currentVersion >= 0); + expect(isVersionAtLeast(2147483647)).toBe(currentVersion >= 2147483647); + }); +}); diff --git a/valdi/src/valdi/runtime/JavaScript/JavaScriptRuntime.cpp b/valdi/src/valdi/runtime/JavaScript/JavaScriptRuntime.cpp index 0b1acfeb..2afa39dd 100644 --- a/valdi/src/valdi/runtime/JavaScript/JavaScriptRuntime.cpp +++ b/valdi/src/valdi/runtime/JavaScript/JavaScriptRuntime.cpp @@ -2341,6 +2341,13 @@ void JavaScriptRuntime::buildContext(Valdi::IJavaScriptContext& context, return; } + auto jsApiVersion = context.newNumber(_resourceManager.getApiVersion()); + + context.setObjectProperty(runtimeObject.get(), "apiVersion", jsApiVersion.get(), exceptionTracker); + if (!exceptionTracker) { + return; + } + auto jsEnableDebugger = context.newBool(_enableDebugger); context.setObjectProperty(runtimeObject.get(), "isDebugEnabled", jsEnableDebugger.get(), exceptionTracker); diff --git a/valdi/src/valdi/runtime/Resources/ResourceManager.cpp b/valdi/src/valdi/runtime/Resources/ResourceManager.cpp index 031ee2fe..ad31bf45 100644 --- a/valdi/src/valdi/runtime/Resources/ResourceManager.cpp +++ b/valdi/src/valdi/runtime/Resources/ResourceManager.cpp @@ -36,13 +36,17 @@ #include "valdi_core/cpp/Interfaces/ILogger.hpp" #include "valdi_core/cpp/Utils/FlatSet.hpp" #include "valdi_core/cpp/Utils/Parser.hpp" +#include "valdi_core/cpp/Utils/TextParser.hpp" #include "valdi_core/cpp/Utils/Trace.hpp" #include "valdi_core/cpp/Utils/ValueMap.hpp" #include "valdi_core/cpp/Utils/ValueUtils.hpp" #include +#include #include #include +#include +#include namespace Valdi { @@ -77,6 +81,37 @@ static StringBox resolveSourceMapFilePath(const StringBox& modulePath) { return modulePath.append(".map.json"); } +static int32_t parseApiVersion(const BytesView& bytes, ILogger& logger) { + TextParser parser(std::string_view(reinterpret_cast(bytes.data()), bytes.size())); + + parser.tryParseWhitespaces(); + auto version = parser.parseUInt(); + parser.tryParseWhitespaces(); + + if (!version.has_value() || !parser.isAtEnd() || version.value() > std::numeric_limits::max()) { + VALDI_WARN(logger, "Invalid valdi_api_version resource content. Expected a non-negative integer."); + return 0; + } + + return static_cast(version.value()); +} + +int32_t ResourceManager::getApiVersion() { + std::lock_guard guard(_mutex); + if (_apiVersion.has_value()) { + return _apiVersion.value(); + } + + auto content = _resourceLoader->loadModuleContent(STRING_LITERAL("valdi_api_version")); + if (!content) { + _apiVersion = 0; + return _apiVersion.value(); + } + + _apiVersion = parseApiVersion(content.value(), _logger); + return _apiVersion.value(); +} + Result> ResourceManager::getArchiveForModule(const StringBox& modulePath, bool useMmap, const Path& mmapCacheDir, diff --git a/valdi/src/valdi/runtime/Resources/ResourceManager.hpp b/valdi/src/valdi/runtime/Resources/ResourceManager.hpp index 937cfba9..82620456 100644 --- a/valdi/src/valdi/runtime/Resources/ResourceManager.hpp +++ b/valdi/src/valdi/runtime/Resources/ResourceManager.hpp @@ -8,7 +8,9 @@ #pragma once +#include #include +#include #include #include "valdi/runtime/Resources/Bundle.hpp" @@ -107,6 +109,8 @@ class ResourceManager : public SimpleRefCountable { void warmUpBundles(const std::vector& modulePaths); + int32_t getApiVersion(); + bool enableAccessibility() const; bool enableDeferredGC() const; bool isLazyModulePreloadingEnabled() const; @@ -132,6 +136,7 @@ class ResourceManager : public SimpleRefCountable { bool _enableTSN = true; bool _inlineAssetsEnabled = true; bool _hotReloaderEnabled; + std::optional _apiVersion; std::atomic_bool _lazyModulePreloadingEnabled = true; Path _mmapCacheDirectory; diff --git a/valdi/test/integration/Runtime_tests.cpp b/valdi/test/integration/Runtime_tests.cpp index 6fbb3373..1022f52d 100644 --- a/valdi/test/integration/Runtime_tests.cpp +++ b/valdi/test/integration/Runtime_tests.cpp @@ -260,6 +260,16 @@ static Result callFunctionSync(RuntimeWrapper& wrapper, return callFunctionSync(wrapper, nullptr, moduleName, functionName, std::move(params)); } +TEST_P(RuntimeFixture, exposesDefaultApiVersion) { + std::string evalBody = "return runtime.apiVersion;"; + + auto evalResult = wrapper.runtime->getJavaScriptRuntime()->evaluateScript( + makeShared(evalBody)->toBytesView(), STRING_LITERAL("eval.js")); + + ASSERT_TRUE(evalResult) << evalResult.description(); + ASSERT_EQ(0, evalResult.value().toInt()); +} + TEST_P(RuntimeFixture, canLoadSimpleViewTree) { auto tree = wrapper.createViewNodeTreeAndContext("test", "BasicViewTree");