/// Describes an Elide project and its dependencies, build, distribution, and runtime configuration. @ModuleInfo { minPklVersion = "0.28.1" } module elide.project import "pkl:semver" as _semver import "Android.pkl" as _android import "Artifacts.pkl" as _artifacts import "Base.pkl" as _common import "Coverage.pkl" as _coverage import "Dev.pkl" as _dev import "Engine.pkl" as _engine import "JavaScript.pkl" as _js import "Jvm.pkl" as _jvm import "Kotlin.pkl" as _kotlin import "NativeImage.pkl" as _nativeImage import "OpenTelemetry.pkl" as _otel import "Publishing.pkl" as _publishing import "Sources.pkl" as _srcs import "Testing.pkl" as _testing import "Toolchain.pkl" as _toolchain import "Web.pkl" as _web /// Name of a script within the project's configuration. typealias ScriptName = String /// Entrypoint definition. typealias Entrypoint = Listing<_common.FilePath> /// A feature set grouping build tasks and other capabilities. typealias FeatureSet = "kotlin" | "java" | "js" /// A name for the project. name: String? /// Version of the project. version: String? /// Optional description for the project, displayed when published as a package. description: String? /// Relative path to the source file evaluated by the runtime on startup. entrypoint: Entrypoint? /// A script entry: a shell command, a path to a file to run, or a plain string. typealias ScriptValue = _common.ShellCommand /// Script mappings; each entry is a name and a script to run. scripts: Mapping = new {} /// Where a build extension comes from: a path relative to the project root, or `scheme:rest` /// naming something to fetch. /// /// A path names a JavaScript or TypeScript module whose default export declares build tasks, or a /// `.elx` package carrying one. The three schemes fetch exactly one of those: /// /// - `url:https://…/tasks.elx` downloads the file the URL names. /// - `npm:@scope/tasks@1.2.3` resolves a package tarball through the npm registry. /// - `maven:group:artifact:1.2.3` resolves one artifact against the `maven.repositories` this /// manifest configures, with the usual `[:packaging[:classifier]]` in between. /// /// A remote version must be pinned to one exact release; ranges, dist-tags and `-SNAPSHOT` are /// rejected. Only those three schemes are reserved, so anything else is a path. typealias BuildExtensionSource = _common.FilePath /// The three schemes a build extension may be fetched over; anything else is a path. const local extensionSchemes: List = List("url", "npm", "maven") /// The scheme [value] opens with, folded to lowercase, or the whole value when it names none. /// /// A scheme is case-insensitive (RFC 3986 §3.1) and `ExtensionOrigins.parse` reads it that way, so /// `NPM:tasks@1.0.0` must not be a path here while it is a fetch there. Only the scheme is folded. const local function extensionScheme(value: String): String = value.split(":").first.toLowerCase() /// True when [value] names something Elide fetches rather than a file under the project. const local function isFetchedExtension(value: String): Boolean = extensionSchemes.contains(extensionScheme(value)) /// An absolute `http`/`https` URL naming a host, matched as `URI` reads a scheme. const local _httpUrl: Regex = Regex(#"(?i)https?://[^/?#]+(?:[/?#].*)?"#) /// True when [rest] carries both a package name and a version: the separator is the last `@`, /// since a scoped name opens with one. const local function _pinsNpmVersion(rest: String): Boolean = let (separator = rest.lastIndexOfOrNull("@")) separator != null && separator > 0 && separator < rest.length - 1 /// True when [rest] carries group, artifact and version, each spelled out. const local function _pinsMavenVersion(rest: String): Boolean = let (parts = rest.split(":")) parts.length >= 3 && parts.every((part) -> !part.isEmpty) /// True when [value] names a file or one of the fetchable schemes, spelled out far enough to /// resolve. /// /// Each scheme check tests the component, not just its separator: a source that parses here and /// fails at fetch time gives the author the wrong error in the wrong phase. const local function isExtensionSource(value: String): Boolean = let (scheme = extensionScheme(value)) if (!extensionSchemes.contains(scheme)) true else let (rest = value.drop(scheme.length + 1)) if (rest.isEmpty) throw("Build extension '\(value)' names a \(scheme) source but does not say which one.") else if (scheme == "url" && !rest.matches(_httpUrl)) throw("Build extension '\(value)' must name an absolute http or https URL.") else if (scheme == "npm" && !_pinsNpmVersion(rest)) throw("Build extension '\(value)' must pin a version: npm:package@1.2.3.") else if (scheme == "maven" && !_pinsMavenVersion(rest)) throw("Build extension '\(value)' must pin a version: maven:group:artifact:1.2.3.") else true /// Digest an author may pin fetched bytes to, in Subresource Integrity spelling. const local _integrityPin: Regex = Regex(#"(?:sha256|sha512)-[A-Za-z0-9+/]+={0,2}"#) /// True when [value] spells a digest Elide can check. const local function isIntegrityPin(value: String): Boolean = if (value.matches(_integrityPin)) true else throw(""" Build extension integrity '\(value)' is not a digest Elide can check. Write it as Subresource Integrity: `sha256-` or `sha512-`. """) /// True when [source] is fetched, the only kind of declaration whose bytes a digest can pin. const local function isPinnableSource(source: String): Boolean = if (isFetchedExtension(source)) true else throw(""" Build extension '\(source)' is a path under the project, whose bytes are already whatever the tree holds. Drop `integrity`, or name a `url:`, `npm:` or `maven:` source. """) /// Deepest a free-form configuration value may nest. /// /// Every walk over one — the Pkl decoder, `FreeFormValue` on the JSON side, and the serializers /// that encode it — recurses one frame per level, and a `StackOverflowError` is an `Error` no /// decoding path catches. Bounded here so an over-deep block is a manifest problem instead. const local freeFormDepthLimit: Int = 32 /// True when every entry of [members] keys on a string and carries a free-form value; [path] /// names the value [members] belong to. /// /// The rule holds at every depth: a nested `Mapping` decodes through the same map as the top-level /// one, and `Map` cannot represent a key that is not text. const local function _isFreeFormMembers(members: Map, depth: Int, path: String): Boolean = members.every((key, member) -> if (!(key is String)) throw(""" A free-form configuration value cannot key on `\(key)`: its keys are read as text, however deeply the mapping is nested. Write the key as a string. """) else isFreeFormValue(member, depth + 1, "\(path).\(key)") ) /// True when every element of [elements] is a free-form value; [path] names their container. const local function _isFreeFormElements(elements: Collection, depth: Int, path: String): Boolean = elements.foldIndexed(true, (index, result, element) -> result && isFreeFormValue(element, depth + 1, "\(path)[\(index)]") ) /// True when [value], found at [path], holds no inline object elements, no non-string key, no /// more than [freeFormDepthLimit] levels of nesting and no value without a data form, none of which /// a free-form value can carry. /// /// Every container Pkl can put inside a configuration is walked: a `List`, `Set`, `Map` or `Pair` /// hides an inline element from this check as readily as a `Listing` does. A leaf must be data an /// extension can read; `Bytes`, a class, a type alias or a function is not, and would otherwise /// only fail once something reads the configuration. const local function isFreeFormValue(value: Any, depth: Int, path: String): Boolean = if (depth > freeFormDepthLimit) throw(""" A free-form configuration value nests more than \(freeFormDepthLimit) levels deep. Flatten it: reading one walks the whole tree, a level at a time. """) else if (value is Dynamic) if (!value.toList().isEmpty) throw(""" A free-form configuration value cannot carry inline object elements. Write a list as a `Listing`: `plugins = new Listing { "grpc" }`, or `plugins = List("grpc")`. """) else _isFreeFormMembers(value.toMap(), depth, path) else if (value is Mapping) _isFreeFormMembers(value.toMap(), depth, path) else if (value is Typed) _isFreeFormMembers(value.toMap(), depth, path) else if (value is Listing) _isFreeFormElements(value.toList(), depth, path) else if (value is Map) _isFreeFormMembers(value, depth, path) else if (value is Collection) _isFreeFormElements(value, depth, path) else if (value is Pair) isFreeFormValue(value.first, depth + 1, "\(path).first") && isFreeFormValue(value.second, depth + 1, "\(path).second") else if ( value == null || value is String || value is Boolean || value is Number || value is Duration || value is DataSize || value is Regex ) true else throw(""" `\(path)` holds a `\(value.getClass().simpleName)`, which an extension cannot read as configuration. Use a string, boolean, number, `Duration`, `DataSize` or `Regex`, or a block, `Listing`, `List`, `Set`, `Map` or `Pair` of those. """) /// True when [value] is a block of named settings, the only shape a configuration is read as. const local function isFreeFormConfig(value: Object): Boolean = if (value is Listing) throw(""" An extension's `config` is a block of named settings, not a list. Name the list: `config { items = new Listing { "grpc" } }`. """) else isFreeFormValue(value, 0, "config") /// A build extension declared together with the settings it reads. class ExtensionSpec { /// Where the extension module or archive comes from: a path relative to the project root, or a /// `url:`, `npm:` or `maven:` source Elide fetches. A path may not resolve outside the project /// root, or outside the workspace root when this project is a workspace member. from: BuildExtensionSource(!isEmpty, isExtensionSource(this)) /// Digest the fetched bytes must match, spelled as Subresource Integrity: `sha256-` or /// `sha512-`. /// /// An extension is code the build runs, and a URL is trusted on TLS and its host alone; a pin /// is what makes the bytes reproducible. It is checked before a download is cached and again on /// every cache hit, so neither the server nor the workspace's shared cache can change what runs. /// A digest an npm or Maven registry publishes is checked as well, never instead of this one. /// /// Only a fetched source can be pinned. integrity: String(isIntegrityPin(this), isPinnableSource(from))? /// Settings for this extension, carried through the manifest unchanged; the extension reads /// this block as `ctx.config`. config: Object(isFreeFormConfig(this)) = new Dynamic {} } /// An extension declaration: a bare source, or a source carrying that extension's configuration. typealias BuildExtension = ExtensionSpec | BuildExtensionSource(!isEmpty, isExtensionSource(this)) /// The module an extension entry names, whichever way it was written. const local function extensionPath(entry: BuildExtension): String = if (entry is ExtensionSpec) entry.from else entry /// True when no two entries name the same module. const local function isDistinctExtensions(entries: Listing): Boolean = let (paths = entries.toList().map((entry) -> extensionPath(entry))) if (paths.isDistinct) true else let (repeated = paths.filter((path) -> paths.count((other) -> other == path) > 1).first) throw(""" Build extension declared more than once: \(repeated). Declare it once; its settings belong in that entry's `config` block. """) /// Build extensions loaded when this project's build is configured. /// /// Each entry names a JavaScript or TypeScript module whose default export is called with the /// build's extension API, or a `.elx` package carrying one. The tasks it declares join the /// project's task graph alongside Elide's own. /// /// An entry is a path relative to the project root — which it may not resolve outside of, nor /// outside the workspace root when this project is a member — or a [BuildExtensionSource] Elide /// fetches and caches: a `url:`, an `npm:` package or a `maven:` artifact. /// /// An entry that takes settings is amended in place; the settings reach that extension alone. A /// fetched entry may also pin the digest of the bytes it expects ([ExtensionSpec.integrity]). /// /// Settings an extension types as a Pkl class are written against a copy of its module kept in the /// project: a manifest imports local files, the Pkl standard library and Elide's own schema, never /// a `package://` or other network URI. /// /// ``` /// import "tools/Codegen.pkl" /// /// extensions { /// "tools/lint.ts" /// "tools/codegen.js" /// "tools/checks.elx" /// "npm:@acme/build-tasks@1.4.0" /// "maven:com.acme:build-tasks:1.4.0" /// "url:https://downloads.example.com/protoc-tasks-1.4.0.elx" /// new { /// from = "tools/protoc.ts" /// config { /// version = "25.1" /// plugins = new Listing { "grpc" } /// } /// } /// new { /// from = "npm:@acme/codegen@2.0.1" /// config = new Codegen.Settings { target = "es2022" } /// } /// new { /// from = "url:https://downloads.example.com/codegen-2.0.1.elx" /// integrity = "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=" /// } /// } /// ``` extensions: Listing(isDistinctExtensions(this)) = new { default = (_) -> new ExtensionSpec {} } /// Represents a release channel for the Elide version running the build. /// @schema ignore typealias ReleaseChannel = "release" | "debug" /// Represents a major version of the Elide project manifest format, used to indicate breaking changes. typealias ManifestVersion = Int(isNonZero && isPositive) /// Information about the Elide runtime version used during the build. /// @schema ignore class ElideInfo { /// Release channel of the Elide runtime executing the build. channel: ReleaseChannel /// Semantic version of the Elide runtime executing the build. version: _semver.Version } /// Build flags passed on the command line as `--flag=NAME[=VALUE]` or `-fNAME[=VALUE]`. /// /// Flags are free-form: nothing needs to be declared ahead of time, and an unrecognized name is /// simply absent rather than an error. Read them through the typed accessors, which validate the /// raw value, or from [raw] when the raw string is what you want. /// /// ``` /// local isRelease = build.flags.bool("RELEASE") /// local stampedVersion = build.flags.string("VERSION") /// ``` /// /// A flag passed more than once keeps every value: the scalar accessors read the last one, and the /// list accessors ([strings], [ints], [bools]) read all of them, in command-line order. /// /// ``` /// local extraArgs = build.flags.strings("ARG") // -fARG=one -fARG=two -> List("one", "two") /// ``` /// /// At module level, read flags as `build.flags`. Inside a nested block an unqualified `build` does /// not resolve, so either bind a module-level `local` (as above) and use it, or qualify the access /// with `module`: /// /// ``` /// dependencies { /// maven { /// packages { /// when (module.build.flags.bool("EXTRA")) { /// "com.example:extra:1.0.0" /// } /// } /// } /// } /// ``` /// @schema ignore class BuildFlags { /// Every value passed for each flag name, in command-line order; a flag passed with no value /// contributes the empty string. Each name maps to at least one value. all: Mapping> /// Raw flag values keyed by flag name; a flag passed with no value maps to the empty string, and /// a flag passed more than once maps to its last value. raw: Mapping = new { for (name, values in all) { [name] = values.last } } /// Whether the named flag was passed at all, with or without a value. function has(name: String): Boolean = raw.containsKey(name) /// Number of times the named flag was passed; `0` when it was not passed. function count(name: String): Int = all.getOrNull(name)?.length ?? 0 /// Value of the named flag; `null` when it was not passed. Throws when it was passed bare. function string(name: String): String? = let (value = raw.getOrNull(name)) if (value == null) null else _asString(name, value) /// Value of the named flag, falling back to [orElse] when it was not passed. function stringOr(name: String, orElse: String): String = string(name) ?? orElse /// Value of the named flag as a boolean: absent is `false`, passed bare is `true`. Accepts /// `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0`, case-insensitively; throws otherwise. function bool(name: String): Boolean = let (value = raw.getOrNull(name)) if (value == null) false else _asBool(name, value) /// Value of the named flag as an integer; `null` when it was not passed, throws when it is not /// a valid integer. function int(name: String): Int? = let (value = string(name)) if (value == null) null else _asInt(name, value) /// Every value passed for the named flag, in command-line order; empty when it was not passed. /// Throws when any occurrence was passed bare. function strings(name: String): List = _values(name).map((value) -> _asString(name, value)) /// [strings], falling back to [orElse] when the flag was not passed. function stringsOr(name: String, orElse: List): List = if (has(name)) strings(name) else orElse /// Every value passed for the named flag as integers, in command-line order; empty when it was /// not passed. Throws when any value is not a valid integer. function ints(name: String): List = strings(name).map((value) -> _asInt(name, value)) /// Every value passed for the named flag as booleans, in command-line order; empty when it was /// not passed, with a bare occurrence reading as `true`. Throws on a non-boolean value. function bools(name: String): List = _values(name).map((value) -> _asBool(name, value)) local function _values(name: String): List = all.getOrNull(name) ?? List() local function _asString(name: String, value: String): String = if (value == "") throw("build flag `\(name)` was passed without a value; expected `-f\(name)=`") else value local function _asBool(name: String, value: String): Boolean = if (value == "") true else if (_trueValues.contains(value.toLowerCase())) true else if (_falseValues.contains(value.toLowerCase())) false else throw("build flag `\(name)` is not a boolean: `\(value)`") local function _asInt(name: String, value: String): Int = if (value.matches(_integer)) value.toInt() else throw("build flag `\(name)` is not an integer: `\(value)`") local _trueValues: List = List("true", "yes", "on", "1") local _falseValues: List = List("false", "no", "off", "0") local _integer: Regex = Regex(#"[+-]?\d+"#) } /// State captured from the Elide build system at evaluation time. /// @schema ignore class BuildInfo { /// Major version of the project manifest format; incremented on breaking schema changes. manifestVersion: ManifestVersion /// Build flags passed for this invocation. flags: BuildFlags } /// Names of the build flags passed for this invocation. Each occurrence's value is carried in its /// own external property, so a value may contain any character (including a comma). local flagNames: List = let (joined = read?("prop:elide.build.flags")) if (joined == null || joined.isEmpty) List() else joined.split(",") /// Every value passed for the flag named [name], in command-line order. The number of occurrences /// arrives in its own property; a name present with no count was passed exactly once. local function flagValues(name: String): List = let (count = read?("prop:elide.build.flagcount.\(name)")?.toInt() ?? 1) new Listing { for (index in IntSeq(0, count - 1)) { read?("prop:elide.build.flagvalue.\(index).\(name)") ?? "" } }.toList() /// Information about the current build. /// @schema ignore fixed build: BuildInfo = new { manifestVersion = read?("prop:elide.build.state.manifestVersion")?.toInt() ?? 1 flags = new { all { for (name in flagNames) { [name] = flagValues(name) } } } } /// Information about the Elide runtime currently executing the build. /// @schema ignore fixed elide: ElideInfo = new { channel = if (read?("prop:elide.build.state.release") == "true") "release" else "debug" local plainVersion = read?("prop:elide.build.state.version") version = if (plainVersion != null) _semver.Version(plainVersion) else _semver.Version("0.0.0") } // // ------ Dependencies ---------------------------------------------------------------------------------------------- // /// NPM package dependency specifier, such as `react@19.0.0` or `@scope/package@1.0.0`. typealias NpmPackageDependency = String /// PyPI package dependency specifier, such as `six==1.17.0` or `requests>=2`. typealias PypiPackageDependency = String /// Configuration for NPM dependency resolution. class NpmDependencies { /// A list of NPM packages to install for this project. packages: Listing = new {} /// A list of NPM packages to make available at dev-time only. devPackages: Listing = new {} /// Registry `npm:` build extensions are fetched from; defaults to Elide's `npm.pkg.st` proxy, or /// to `ELIDE_PKGST_NPM_ENDPOINT` when that is set. NPM package dependencies do not read it. registry: String? } /// Configuration for PyPI dependency resolution. class PypiDependencies { /// A list of PyPI packages to install for this project. packages: Listing = new {} /// Optional PyPI dependency groups. optionalPackages: Mapping> = new {} } /// Dependency resolution configuration. class Dependencies { /// Configure JVM dependencies using Maven. maven: _jvm.MavenDependencies = new {} /// Configure JavaScript dependencies using NPM. npm: NpmDependencies = new {} /// Configure Python dependencies using PyPI. pypi: PypiDependencies = new {} /// Configure Spring Boot starters; expands into `maven` at manifest parse time. spring: _jvm.SpringDependencies? /// Micronaut core modules and the matching annotation processor. micronaut: _jvm.MicronautDependencies? } /// Dependency resolution configuration. dependencies: Dependencies = new {} /// Reference to what another project of the same workspace builds: `project("core")` is the /// artifact project `core` declares, and `project("core").artifact("fat")` picks one of several. /// /// The reference names no artifact kind; whether the block it is declared in can consume the /// artifact it resolves to is checked when the build is configured. /// /// Bind it to a `local` at module level and use that name in the dependency block — a module-level /// `local` resolves inside a nested block, an unqualified call does not: /// /// ``` /// local core = project("core") /// /// dependencies { /// maven { /// packages { /// core /// } /// } /// } /// ``` /// /// Declaring the reference in place takes the qualified call, `module.project("core")`, and the /// underlying `Artifacts.ProjectArtifact` can be constructed by hand after /// `import "elide:Artifacts.pkl" as Artifacts`. const function project(name: _artifacts.ProjectName): _artifacts.ProjectArtifact = new { project = name } // // ------ Configuration --------------------------------------------------------------------------------------------- // /// Configure JVM runtime settings. jvm: _jvm.JvmSettings? /// Configure Kotlin language settings. /// /// An Android project (one declaring [android]) defaults `kotlinx`, `defaultPlugins` and `reflection` /// off: whatever they put on the runtime classpath is dexed into the app, and none of it is something /// an Android app is assumed to use. Declaring a feature turns it back on. kotlin: _kotlin.KotlinSettings? = if (android != null) new _kotlin.KotlinSettings { features { kotlinx = false defaultPlugins = false reflection = false } } else Null(new _kotlin.KotlinSettings {}) /// Configure JavaScript language settings. javascript: _js.JavaScriptSettings? /// Configure the Android SDK this project builds against. android: _android.AndroidSettings? /// Project-wide settings for the Native Image compiler. nativeImage: _nativeImage.NativeImageSettings? /// Configuration for the execution engine. engine: _engine.EngineSettings? /// OpenTelemetry traces, metrics, logs, and profiles; off unless destinations are configured here or in the environment. opentelemetry: _otel.OpenTelemetrySettings? // // ------ Structure ------------------------------------------------------------------------------------------------- // /// Top-level project sources definition. /// /// Left empty, a project's source sets are the ones its layout implies: `src/main` (or `src`) as /// `main`, `src/test` (or `test`) as `test`, with `src//` roots and /// `src//resources` honored when present. Declaring a name here replaces the convention for /// that name; see `elide:Sources.pkl`. sources: _srcs.Sources = new { default = (_) -> new _srcs.SourceSetSpec {} } /// Development settings for this project. dev: _dev.DevSettings = new {} /// Toolchain settings for this project. toolchain: _toolchain.ToolchainSettings = new {} /// Artifacts to be produced by this project. artifacts: _artifacts.Artifacts = new {} /// How `elide publish` publishes this project's artifacts. publishing: _publishing.PublishingSettings = new {} /// Settings which apply to web-based projects and applications. web: _web.WebSettings? = new {} /// Settings for testing features testing: _testing.Testing = new {} /// Coverage settings for this project, applied to whichever tasks `coverage.scope` names. coverage: _coverage.CoverageSettings = new {} /// Workspace settings: the member projects this project is the root of. class WorkspaceSettings { /// Directories of member projects, relative to this manifest's directory; each holds its own `elide.pkl`. members: Listing<_common.FilePath>(isDistinct) = new {} } /// Workspace settings for this project, when it is the root of a multi-project workspace. workspace: WorkspaceSettings = new {}