/
nv-lang
/
nova
Обзор
Документация
Войти
/
nv-lang
/
nova
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
main
std/src/data/semver.nv
417 строк
17 KB
Evgeniy Golovin
refactor(p-tryfrom): migrate str→numeric call sites off retracted try_from
01 авг 2026, 22:11
01 авг 2026, 22:11
003d6b5
Код
Авторство
О чём код?
// stdlib/semver.nv — Semantic Versioning 2.0.0 (semver.org). // // Independent implementation, не порт. Спецификация: // https://semver.org/spec/v2.0.0.html // // Поддержка: // - MAJOR.MINOR.PATCH (обязательно) // - PRE-release: после "-", идентификаторы через "." // - BUILD metadata: после "+", идентификаторы через "." // - Сравнение per § 11 (build игнорируется, pre сравнивается покомпонентно) // // Примеры валидных версий: // 1.0.0 // 2.10.3 // 1.0.0-alpha // 1.0.0-alpha.1 // 1.0.0-0.3.7 // 1.0.0-x.7.z.92 // 1.0.0+20130313144700 // 1.0.0-beta+exp.sha.5114f85 // // Decisions: // - D52 record-тип с readonly-полями: Version иммутабелен, операции // возвращают новый. // - D55 record-coercion: { major, minor, patch, pre, build } → Self. // - D72 generic bounds: []str — массив через D27. // - `s.to_version()` (парсинг) / `v.to_str()` (сериализация) — // именованные конструктор/представление (D410), не протокол: // "1.2.3".to_version() // парсинг // v.to_str() // сериализация // - D74 instance-методы числовых операций (не используется напрямую, // но `len`, comparisons на u64 — стандартные методы). // - D46/D363 operator overloading: @equal, @compare — для естественного сравнения. // // Pending language features (используются ниже): // - `str.split(sep str) -> []str` — стандартный split // - `str.starts_with(prefix str) -> bool` // - `str.contains(needle str) -> bool` // - `str.find(needle str) -> Option[int]` — позиция или None // - `str[from int..to int] -> str` // - `s.to_u64() -> Result[u64, ParseIntError]` (конверсия на источнике, // Plan 174.1). Option получаем через `.ok()`: `s.to_u64().ok()`. // Все они стандартные str/int операции, не нуждаются в char-литералах. module data.semver // ────────────────────────────────────────────────────────────────────────── // Тип // ────────────────────────────────────────────────────────────────────────── // Pre-release и build identifier'ы — массивы строк, разделённых "." // в исходной строке. Пустой массив означает «нет компонента». // // Семантически identifier бывает numeric (только цифры) или // alphanumeric. Различие важно для сравнения — храним как str, // при сравнении проверяем «numeric ли». /// SemVer version (semver.org 2.0.0). `MAJOR.MINOR.PATCH[-pre][+build]`. /// /// `pre` — pre-release identifiers (`alpha.1` → `["alpha", "1"]`). /// `build` — build metadata (not considered for precedence). /// /// # Examples /// ```nova /// let v = "1.2.3-alpha.1+exp.sha.5114f85".to_version()!! /// assert(v.major() == 1) /// assert(v.is_prerelease()) /// ``` #stable(since = "0.1") export type Version { ro major u64 ro minor u64 ro patch u64 ro pre []str ro build []str } /// Errors when parsing a semver string (D30 convention). #stable(since = "0.1") export type ParseVersionError enum | EmptyInput | MissingMinor | MissingPatch | LeadingZero { component str, value str } | InvalidComponent { component str, value str } | EmptyIdentifier { section str } | InvalidIdentifier { section str, value str } /// Simple constructor without pre/build. The most common case. #stable(since = "0.1") export fn Version.new(major u64, minor u64, patch u64) -> Self => { major, minor, patch, pre: [], build: [] } /// Full constructor with pre-release and build metadata. #stable(since = "0.1") export fn Version.full(major u64, minor u64, patch u64, pre []str, build []str) -> Self => { major, minor, patch, pre, build } /// Major version number. #stable(since = "0.1") export fn Version @major() => @major /// Minor version number. #stable(since = "0.1") export fn Version @minor() => @minor /// Patch version number. #stable(since = "0.1") export fn Version @patch() => @patch /// True if the version has a pre-release identifier (e.g. "1.0.0-alpha.1"). #stable(since = "0.1") export fn Version @is_prerelease() -> bool => @pre.len() > 0 /// True if the version has build metadata (e.g. "1.0.0+sha1234"). #stable(since = "0.1") export fn Version @has_build() -> bool => @build.len() > 0 /// True if a stable release: not pre-release, major > 0. #stable(since = "0.1") export fn Version @is_stable() -> bool => @pre.len() == 0 && @major > 0 // ────────────────────────────────────────────────────────────────────────── // Парсинг // ────────────────────────────────────────────────────────────────────────── // `s.to_version()` — парсер по semver.org § 9-10 (str-экстеншен, 174.1; // статик `Version.from(s)` ретрактирован — конверсия живёт на источнике). // `Err(ParseVersionError)` на любой ошибке (D325). // // Алгоритм: // 1. Разделить input на core ("1.2.3") + optional pre + optional build. // `+` имеет приоритет над `-` в обнаружении (build после pre). // Точнее: ищем первый `-` или `+`, потом разбираем дальше. // 2. Распарсить core: split по ".", парсить три u64, валидация // leading-zero. // 3. Распарсить pre identifiers (split по ".", валидация alphanumeric). // 4. Распарсить build identifiers (split по ".", валидация). /// Parse semver.org §9-10 grammar. `Err(ParseVersionError)` on malformed input. /// /// # Examples /// ```nova /// let v = "2.5.1".to_version()!! /// assert(v.major() == 2 && v.minor() == 5 && v.patch() == 1) /// ``` #stable(since = "0.1") export fn str @to_version() -> Result[Version, ParseVersionError] { if @byte_len() == 0 { return Err(EmptyInput) } // Найти границы pre и build. // Build: после "+", единственное вхождение "+" в строке. // Pre: после "-", но "-" может быть в build identifier'е, поэтому // обрабатываем сначала split по "+", потом по "-" в первой части. // // [M-semver-trailing-dash-plus] (2026-07-08, волна 2 промоушена): // различаем «дефис/плюс отсутствует» (нет pre/build секции) от // «дефис/плюс есть, но секция пустая» (`1.0.0-`/`1.0.0+` — невалидно // per semver.org § 9-10, должно быть `EmptyIdentifier`). Раньше оба // случая схлопывались в один `byte_len() == 0` чек — молча принимали // trailing `-`/`+` как «нет pre/build». Обнаружено через `nova test // --full` ("пустой identifier в pre"), `nova check` этого не ловил. ro (left, build_str, has_build_marker) = match @find("+") { Some(i) => (@[..i], @[i + 1..], true) None => (@, "", false) } ro (core, pre_str, has_pre_marker) = match left.find("-") { Some(i) => (left[..i], left[i + 1..], true) None => (left, "", false) } // Парсим core. ro (major, minor, patch) = parse_core(core)? // Парсим pre identifiers. ro pre = if !has_pre_marker { [] } else if pre_str.byte_len() == 0 { return Err(EmptyIdentifier { section: "pre-release" }) } else { parse_identifiers(pre_str, "pre-release", true)? } // Парсим build identifiers. ro build = if !has_build_marker { [] } else if build_str.byte_len() == 0 { return Err(EmptyIdentifier { section: "build" }) } else { parse_identifiers(build_str, "build", false)? } Ok({ major, minor, patch, pre, build }) } // Парсинг "MAJOR.MINOR.PATCH". Все три обязательны, цифры, без leading // zero (кроме самого "0"). fn parse_core(core str) -> Result[(u64, u64, u64), ParseVersionError] { ro parts = core.split(".").collect() if parts.len() < 2 { return Err(MissingMinor) } if parts.len() < 3 { return Err(MissingPatch) } if parts.len() > 3 { return Err(InvalidComponent { component: "core", value: core }) } ro major = parse_numeric_component(parts[0], "major")? ro minor = parse_numeric_component(parts[1], "minor")? ro patch = parse_numeric_component(parts[2], "patch")? Ok((major, minor, patch)) } // Числовой компонент: только цифры, без leading zero (кроме "0"). fn parse_numeric_component(s str, name str) -> Result[u64, ParseVersionError] { if s.byte_len() == 0 { return Err(InvalidComponent { component: name, value: s }) } if !is_all_digits(s) { return Err(InvalidComponent { component: name, value: s }) } if s.byte_len() > 1 && s.starts_with("0") { return Err(LeadingZero { component: name, value: s }) } match s.to_u64() { Ok(n) => Ok(n) Err(_) => Err(InvalidComponent { component: name, value: s }) } } // Pre-release / build identifiers через ".". // strict_no_leading_zero — для pre numeric не разрешено leading zero; // для build разрешено всё что alphanumeric+"-". fn parse_identifiers(s str, section str, strict_no_leading_zero bool) -> Result[[]str, ParseVersionError] { ro raw = s.split(".") mut out []str = [] for ident in raw { if ident.byte_len() == 0 { return Err(EmptyIdentifier { section }) } if !is_valid_identifier(ident) { return Err(InvalidIdentifier { section, value: ident }) } // Numeric identifier в pre — без leading zero. if strict_no_leading_zero && is_all_digits(ident) && ident.byte_len() > 1 && ident.starts_with("0") { return Err(LeadingZero { component: section, value: ident }) } out.push(ident) } Ok(out) } // Проверка: все символы — ASCII цифры. fn is_all_digits(s str) -> bool { if s.byte_len() == 0 { return false } for c in s.chars() { if !is_ascii_digit(c) { return false } } true } fn is_ascii_digit(c char) -> bool => (c as int) >= 48 && (c as int) <= 57 // '0' = 48, '9' = 57 // Валидный identifier: ASCII letters, digits, "-". fn is_valid_identifier(s str) -> bool { for c in s.chars() { if !is_ascii_ident_char(c) { return false } } true } // [M-closure-trailing-scalar-coercion-no-typecheck] follow-up (2026-07-10): // a LEADING `||` on a continuation line is NOT an OR-continuation in Nova — // it is ALSO the zero-arg closure-literal syntax, and the parser deliberately // does not extend newline-tolerance across a line-initial `||` (same class // of miscompile as `std/encoding/toml.nv`'s `is_bare_key_char`, closed the // same day the checker gained `E_CLOSURE_SCALAR_RETURN`). Fix: trailing `||` // at END of each line — a TRAILING binary operator before a newline IS a // legal continuation. fn is_ascii_ident_char(c char) -> bool { ro n = c as int (n >= 48 && n <= 57) || // 0-9 (n >= 65 && n <= 90) || // A-Z (n >= 97 && n <= 122) || // a-z n == 45 // - } // ────────────────────────────────────────────────────────────────────────── // Сериализация (D410) // ────────────────────────────────────────────────────────────────────────── /// `to_str()` — the `MAJOR.MINOR.PATCH[-PRE][+BUILD]` format. /// Round-trip: `v.to_str().to_version()== v` for a valid Version. #stable(since = "0.1") export fn Version @to_str() -> str { consume sb = StringBuilder.new() sb.append(@major.to_str()).append(".").append(@minor.to_str()).append(".").append(@patch.to_str()) if @pre.len() > 0 { sb.append("-").append(join_dots(@pre)) } if @build.len() > 0 { sb.append("+").append(join_dots(@build)) } sb } fn join_dots(parts []str) -> str { consume sb = StringBuilder.new() mut first = true for p in parts { if !first { sb.append(".") } sb.append(p) first = false } sb } // ────────────────────────────────────────────────────────────────────────── // Сравнение (semver.org § 11) // ────────────────────────────────────────────────────────────────────────── /// Strict equality (`==`) — structural over all fields (including build). /// For semver-precedence equality (without build) use [`eq_precedence`]. #stable(since = "0.1") export fn Version @equal(other Version) -> bool => @major == other.major && @minor == other.minor && @patch == other.patch && pre_eq(@pre, other.pre) && pre_eq(@build, other.build) /// Semver-precedence equality — build metadata is ignored (semver.org § 10). #stable(since = "0.1") export fn Version @eq_precedence(other Version) -> bool => @major == other.major && @minor == other.minor && @patch == other.patch && pre_eq(@pre, other.pre) /// Compare per semver.org § 11 precedence. Returns <0, 0, >0. /// 1. major/minor/patch numeric compare; /// 2. non-pre > pre (1.0.0 > 1.0.0-anything); /// 3. component-wise comparison of pre (numeric < alphanumeric). #stable(since = "0.1") export fn Version @compare(other Version) -> int { if @major != other.major { return if @major < other.major { -1 } else { 1 } } if @minor != other.minor { return if @minor < other.minor { -1 } else { 1 } } if @patch != other.patch { return if @patch < other.patch { -1 } else { 1 } } // major.minor.patch равны → смотрим pre. ro a_has_pre = @pre.len() > 0 ro b_has_pre = other.pre.len() > 0 if a_has_pre && !b_has_pre { return -1 } // 1.0.0-alpha < 1.0.0 if !a_has_pre && b_has_pre { return 1 } if !a_has_pre && !b_has_pre { return 0 } // равны // Оба pre — покомпонентное сравнение. pre_compare(@pre, other.pre) } // Покомпонентное pre-release сравнение (§ 11.4). Returns <0, 0, >0. fn pre_compare(a []str, b []str) -> int { ro n = if a.len() < b.len() { a.len() } else { b.len() } for i in 0..n { ro result = compare_ident(a[i], b[i]) if result != 0 { return result } } // Все общие компоненты равны — у кого меньше длина, тот меньше. if a.len() < b.len() { -1 } else if a.len() > b.len() { 1 } else { 0 } } // Сравнение двух identifier'ов: возвращает -1, 0, или 1. // - numeric < alphanumeric (numeric всегда «меньше») // - оба numeric: numeric-сравнение // - оба alphanumeric: лексикографическое fn compare_ident(a str, b str) -> int { ro a_num = is_all_digits(a) ro b_num = is_all_digits(b) if a_num && !b_num { return -1 } if !a_num && b_num { return 1 } if a_num && b_num { // Оба numeric — парсим в u64 (валидация уже была в parse). // to_u64 возвращает Result, .ok() → Option, .unwrap_or → u64. ro an = a.to_u64().ok() ?? 0 ro bn = b.to_u64().ok() ?? 0 if an < bn { return -1 } if an > bn { return 1 } return 0 } // Оба alphanumeric — lex compare. if a < b { -1 } else if a > b { 1 } else { 0 } } // Helper для @equal: массив строк структурно равен. fn pre_eq(a []str, b []str) -> bool { if a.len() != b.len() { return false } mut i = 0 while i < a.len() { if a[i] != b[i] { return false } i += 1 } true } // Note: @le/@gt/@ge removed — use @compare(other) <=/>/>= 0, or < > <= >= operators. // Тесты — см. peer-файл semver_test.nv (module data.semver_test).