//! The version a project declares, read from its own files. //! //! The first source that answers wins, so a `VERSION` file overrides everything //! and a project that already declares its version in `v.mod`, `package.json`, //! `Cargo.toml`, `Info.plist` or Gradle needs no second copy to keep in step. //! Nothing is evaluated: a value built from a variable or a function call is //! skipped and the next source is tried, so Koh never runs a build system to //! ask what version something is. module build import os // The spec's search order for Gradle's `versionName`. const gradle_files = ['app/build.gradle.kts', 'app/build.gradle', 'build.gradle.kts', 'build.gradle'] // project_version is the version the project declares. Finding nothing is an // error: publishing refuses rather than inventing 0.0.0, and a build cannot fill // `{version}` without one. pub fn project_version(root string) !string { if value := file_version(os.join_path(root, 'VERSION')) { return checked(value, 'VERSION') } if value := line_version(os.join_path(root, 'build.zig.zon'), '.version') { return checked(value, 'build.zig.zon') } if value := line_version(os.join_path(root, 'v.mod'), 'version') { return checked(value, 'v.mod') } if value := line_version(os.join_path(root, 'package.json'), '"version"') { return checked(value, 'package.json') } if value := line_version(os.join_path(root, 'Cargo.toml'), 'version') { return checked(value, 'Cargo.toml') } if value := plist_version(os.join_path(root, 'Info.plist')) { return checked(value, 'Info.plist') } for file in gradle_files { if value := gradle_version(os.join_path(root, file)) { return checked(value, file) } } return error('no version in ${root} — add a VERSION file holding the version to publish') } // checked refuses a value that is not a version. It becomes a release file // name, and 1–32 letters, digits, dots and dashes is what Koh 2 allowed. fn checked(value string, source string) !string { if value.len == 0 || value.len > 32 || !value.bytes().all(is_version_byte(it)) { return error('${source} says the version is "${value}", which is not 1–32 letters, digits, dots and dashes') } return value } fn is_version_byte(character u8) bool { return (character >= `a` && character <= `z`) || (character >= `A` && character <= `Z`) || (character >= `0` && character <= `9`) || character == `.` || character == `-` } // file_version is the VERSION rule: the whole file, trimmed. fn file_version(path string) ?string { text := os.read_file(path) or { return none } value := text.trim_space() if value == '' { return none } return value } // line_version takes the value of the first line whose key starts it, in either // quote. A line that builds its value from a variable has no literal, so this // source yields nothing and the next one is tried. fn line_version(path string, key string) ?string { text := os.read_file(path) or { return none } for line in text.split_into_lines() { trimmed := line.trim_left(' \t') if trimmed == '' || trimmed.starts_with('//') || trimmed.starts_with('#') { continue } if !trimmed.starts_with(key) { continue } if value := literal_after(trimmed, key.len) { return value } } return none } // literal_after is the quoted literal that starts right after the key, an // optional `=` and any spaces. That is what "assigned a quoted string literal" // means: a call such as `gradleProperty("VERSION")` or a name such as // `project.version` starts with something else, so it yields nothing and the // next source is tried. fn literal_after(line string, from int) ?string { mut at := from for at < line.len && line[at] in [` `, `\t`, `=`, `:`] { at++ } if at >= line.len || (line[at] != `"` && line[at] != `'`) { return none } quote := line[at] for end in at + 1 .. line.len { if line[end] == quote { return line[at + 1..end] } } return none } // plist_version reads CFBundleShortVersionString from an Info.plist: the value // is in the element after the key, not on the key's line. fn plist_version(path string) ?string { text := os.read_file(path) or { return none } at := text.index('CFBundleShortVersionString') or { return none } rest := text[at..] open := rest.index('') or { return none } close := rest.index('') or { return none } if close <= open + ''.len { return none } value := rest[open + ''.len..close].trim_space() if value == '' { return none } return value } // gradle_version is `versionName` assigned a quoted literal, in either the // Kotlin (`= "2.17.43"`) or the Groovy (`"2.17.43"`) syntax. Commented-out lines // are skipped and `versionNameSuffix` is not a versionName, as the spec says. // Koh never runs Gradle: a value from a variable or `gradle.properties` is not // read, and the next source is tried instead. fn gradle_version(path string) ?string { text := os.read_file(path) or { return none } for line in text.split_into_lines() { trimmed := line.trim_left(' \t') if trimmed == '' || trimmed.starts_with('//') || trimmed.starts_with('/*') || trimmed.starts_with('*') { continue } if !trimmed.starts_with('versionName') || trimmed.len <= 'versionName'.len || (trimmed['versionName'.len] != ` ` && trimmed['versionName'.len] != `=`) { continue } if value := literal_after(trimmed, 'versionName'.len) { return value } } return none }