/
nv-lang
/
nova
Обзор
Документация
Войти
/
nv-lang
/
nova
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
main
std/src/encoding/url.nv
424 строки
16 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/url.nv — URL parser по RFC 3986 (минимальный, для backend). // // Independent implementation. Спецификация: // https://www.rfc-editor.org/rfc/rfc3986 // // Поддержка: // - Generic URI: scheme://[user[:pass]@]host[:port][/path][?query][#fragment] // - HTTP/HTTPS (наиболее частые), но синтаксис общий — работает // для file://, ftp://, ws://, etc. // - Percent-encoding для query parameters (decode/encode helpers) // // Не поддержка (намеренно): // - IRI (internationalized), unicode в host — отдельная сложность // - URI references относительные ("../foo" с base) — требует resolution // algorithm RFC 3986 § 5; не критично для backend // - Полный валидатор хоста (DNS labels, IPv6 brackets) — упрощённо // // Decisions: // - D52 record-тип: Url иммутабелен. // - D55 record-coercion для конструирования. // - D67 ?-оператор работает только на Result/Option. // - `Url.from(s)` / `u.to_str()`: парсинг и сериализация (round-trip, D410). // - D74 instance-методы char-операций. // // API: // Url.from(s str) -> Result[Url, ParseUrlError] // u.to_str() -> str // Url.encode_query(s str) -> str (percent-encoding) // Url.decode_query(s str) -> Result[str, ParseUrlError] module encoding.url // ────────────────────────────────────────────────────────────────────────── // Тип // ────────────────────────────────────────────────────────────────────────── /// URL structural representation (RFC 3986). All components except `scheme` and /// `path` are `Option`. /// /// # Examples /// ```nova /// let u = Url.from("https://user:pass@example.com:8443/api?k=v#top") /// assert(u.scheme == "https") /// assert(u.host == Some("example.com")) /// ``` #stable(since = "0.1") export type Url { ro scheme str ro user Option[str] ro password Option[str] ro host Option[str] ro port Option[int] ro path str ro query Option[str] ro fragment Option[str] } /// Errors while parsing a URL. #stable(since = "0.1") export type ParseUrlError enum | EmptyInput | MissingScheme | InvalidScheme { value str } | InvalidPort { value str } | InvalidPercentEncoding { position int } // ────────────────────────────────────────────────────────────────────────── // Парсинг // ────────────────────────────────────────────────────────────────────────── /// Parse a URL per the RFC 3986 generic syntax. /// `Err(ParseUrlError)` on malformed input. #stable(since = "0.1") export fn Url.from(s str) -> Result[Url, ParseUrlError] { // [M-lint-findings-static-conversion] if s.byte_len() == 0 { return Err(EmptyInput) } // 1. Извлечь scheme (до первого ":") и валидировать. ro (scheme, after_scheme) = parse_scheme(s)? // 2. Если "://" — есть authority (user/host/port), иначе path сразу. ro (user, password, host, port, after_authority) = if after_scheme.starts_with("//") { parse_authority(after_scheme[2..])? } else { (None, None, None, None, after_scheme) } // 3. Path — до "?" или "#". ro (path, after_path) = parse_path(after_authority) // 4. Query — после "?", до "#". ro (query, after_query) = if after_path.starts_with("?") { ro rest = after_path[1..] match rest.find("#") { Some(i) => (Some(rest[..i]), rest[i..]) None => (Some(rest), "") } } else { (None, after_path) } // 5. Fragment — после "#". ro fragment = if after_query.starts_with("#") { Some(after_query[1..]) } else { None } Ok({ scheme, user, password, host, port, path, query, fragment }) } // Scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) (RFC 3986 § 3.1) // Заканчивается на ":". Чувствительна к регистру технически, но // конвенция — lowercase. fn parse_scheme(s str) -> Result[(str, str), ParseUrlError] { match s.find(":") { None => Err(MissingScheme) Some(i) => { if i == 0 { return Err(MissingScheme) } ro scheme = s[..i] // Scheme (RFC 3986 § 3.1) — pure ASCII → прямой byte-доступ O(1) // вместо ретрактированного `chars().nth()` O(n) скана (D260-амендмент). ro sb = scheme.bytes() // Первый символ — letter match sb.get(0) { Some(b) if !is_alpha((b as int).to_char() ?? ' ') => return Err(InvalidScheme { value: scheme }) _ => () } // Остальные — letter/digit/+/-/. for j in 1..scheme.byte_len() { match sb.get(j) { Some(b) if !is_scheme_char((b as int).to_char() ?? ' ') => return Err(InvalidScheme { value: scheme }) _ => () } } Ok((scheme.to_ascii_lower(), s[i + 1..])) } } } // Authority = [ userinfo "@" ] host [ ":" port ] // Заканчивается на "/", "?", "#" или EOF. fn parse_authority(s str) -> Result[(Option[str], Option[str], Option[str], Option[int], str), ParseUrlError] { // Найти конец authority — первый из /, ?, #. ro auth_end = find_first_of(s, "/?#") ro (auth, rest) = match auth_end { Some(i) => (s[..i], s[i..]) None => (s, "") } if auth.byte_len() == 0 { // `parse_authority` is only ever called after an explicit "//" was // seen (Url.from's dispatch) — an empty `auth` here means an // explicit-but-empty authority (`file:///etc/hosts`), i.e. host is // the empty string, not absent. `Some("")`, not `None` (test // "parse: file:// без host" expects `u.host == Some("")`). return Ok((None, None, Some(""), None, rest)) } // Userinfo — до "@", если есть ro (userinfo_opt, host_port) = match auth.find("@") { Some(i) => (Some(auth[..i]), auth[i + 1..]) None => (None, auth) } // Userinfo разбираем на user[:password] ro (user, password) = match userinfo_opt { None => (None, None) Some(ui) => match ui.find(":") { Some(i) => (Some(ui[..i]), Some(ui[i + 1..])) None => (Some(ui), None) } } // host[:port] ro (host, port) = match host_port.find(":") { Some(i) => { ro port_str = host_port[i + 1..] ro port_num = match port_str.to_int() { Ok(n) if n >= 0 && n <= 65535 => n _ => return Err(InvalidPort { value: port_str }) } ro h = host_port[..i] (Some(h), Some(port_num)) } None => (Some(host_port), None) } Ok((user, password, host, port, rest)) } fn parse_path(s str) -> (str, str) { // Path до "?" или "#" или EOF ro path_end = find_first_of(s, "?#") match path_end { Some(i) => (s[..i], s[i..]) None => (s, "") } } // Delimiter set (`chars`) — всегда ASCII ("/?#", "?#"); UTF-8 continuation/lead // байты многобайтовых codepoint'ов никогда не совпадают с ASCII (self-sync), // поэтому byte-scan корректен даже над non-ASCII `s` — O(1) индекс вместо // ретрактированного `chars().nth(i)` O(n) скана (D260-амендмент). fn find_first_of(s str, chars str) -> Option[int] { ro sb = s.bytes() mut i = 0 while i < s.byte_len() { ro c = (sb[i] as int).to_char() ?? ' ' if chars.contains(c.to_str()) { return Some(i) } i += 1 } None } fn is_alpha(c char) -> bool { ro n = c as int (n >= 65 && n <= 90) || (n >= 97 && n <= 122) } fn is_alphanum(c char) -> bool { ro n = c as int is_alpha(c) || (n >= 48 && n <= 57) } fn is_scheme_char(c char) -> bool => is_alphanum(c) || c == '+' || c == '-' || c == '.' // ────────────────────────────────────────────────────────────────────────── // Сериализация (D410) // ────────────────────────────────────────────────────────────────────────── /// `to_str()` — serialize a Url back into a string. Round-trip with `Url.from`. #stable(since = "0.1") export fn Url @to_str() -> str { consume buf = StringBuilder.new(cap: 64) buf.append(@scheme) buf.append(":") if @host != None { buf.append("//") // userinfo match @user { Some(u) => { buf.append(u) match @password { Some(p) => { buf.append(':') buf.append(p) } None => () } buf.append('@') } None => () } match @host { Some(h) => buf.append(h) None => () } match @port { Some(p) => { buf.append(':') buf.append(p.to_str()) } None => () } } buf.append(@path) match @query { Some(q) => { buf.append('?') buf.append(q) } None => () } match @fragment { Some(f) => { buf.append('#') buf.append(f) } None => () } buf } // ────────────────────────────────────────────────────────────────────────── // Percent-encoding (RFC 3986 § 2.1) // ────────────────────────────────────────────────────────────────────────── // Кодирует строку для использования в query parameter. // Безопасные символы: ALPHA / DIGIT / "-" / "." / "_" / "~" // Всё остальное — `%XX` где XX — hex. /// Percent-encode query string value (RFC 3986 § 2.4). Alphanumeric + `-_.~` raw, /// everything else → `%XX`. /// /// # Examples /// ```nova /// assert(Url.encode_query("a b&c") == "a%20b%26c") /// ``` #stable(since = "0.1") export fn Url.encode_query(s str) -> str { consume buf = StringBuilder.new(cap: s.byte_len() * 2) // worst case for c in s.chars() { if is_unreserved(c) { buf.append(c) } else if c == ' ' { buf.append('+') // конвенция form-encoding } else { // UTF-8 encode и percent-encode каждый байт. // Простая версия для ASCII; для unicode требуется UTF-8 u8 iteration. ro n = c as int if n < 128 { buf.append('%') buf.append(hex_upper((n / 16) as u8)) buf.append(hex_upper((n % 16) as u8)) } else { // Multi-byte UTF-8: упрощённо через char. // Production: перекодировать через UTF-8 u8 sequence. buf.append('%') buf.append(hex_upper((n / 16) as u8)) buf.append(hex_upper((n % 16) as u8)) } } } buf } // Декодирует percent-encoded строку. // // Mixed text+binary use-case: percent-decoded u8 может быть частью // multi-byte UTF-8 sequence — нужен byte-buffer с финализацией через // UTF-8 validation. StringBuilder text-only, не подходит. WriteBuffer // + write_char/write_str/write_byte накапливает UTF-8 bytes; финал // через `[]u8.to_str()?` (D325) валидирует и конвертирует. // // На невалидный UTF-8 после decode'а — Err(InvalidPercentEncoding) // (consistent с тем что percent-encoding должен давать valid UTF-8; // иначе input был malformed). /// Percent-decode query string. `Err(InvalidPercentEncoding)` if `%XX` is malformed. #stable(since = "0.1") export fn Url.decode_query(s str) -> Result[str, ParseUrlError] { mut buf = WriteBuffer.new(cap: s.byte_len()) // Литеральные (не `%XX`) символы проходят через as-is (`write_char(c)`) — // могут быть произвольным Unicode, поэтому нужны реальные char-значения, // не только ASCII-детект. Материализуем `[]char` ОДИН раз (push-loop, не // `.collect()` — крашит кодоген на CharsIter) вместо ретрактированного // `chars().nth(i)` O(n) скана на каждой итерации (D260-амендмент). ro sc = to_chars(s) ro n = sc.len() mut i = 0 while i < n { ro c = sc[i] if c == '%' { if i + 2 >= n { return Err(InvalidPercentEncoding { position: i }) } ro hi = hex_value_at(sc, i + 1)? ro lo = hex_value_at(sc, i + 2)? ro byte_val = (hi * 16 + lo) as u8 buf.write_byte(byte_val as u8) i += 3 } else if c == '+' { buf.write_char(' ') i += 1 } else { buf.write_char(c) i += 1 } } // [M-174.1-vec-method-chain-elem-erasure]: explicit `[]u8` local — see // backlog-followups.md marker. ro bytes []u8 = buf.into_bytes() // [M-lint-findings-writebuffer-into] match bytes.to_str() { Ok(t) => Ok(t) Err(_) => Err(InvalidPercentEncoding { position: i }) } } fn is_unreserved(c char) -> bool => is_alphanum(c) || c == '-' || c == '.' || c == '_' || c == '~' fn hex_upper(n u8) -> char { ro code = if n < 10 { '0' as int + n as int } else { 'A' as int + n as int - 10 } code.to_char() ?? ' ' } fn hex_value_at(sc []char, pos int) -> Result[u8, ParseUrlError] { match sc.get(pos) { Some(c) => { ro n = c as int if n >= 48 && n <= 57 { Ok((n - 48) as u8) } else if n >= 97 && n <= 102 { Ok((n - 97 + 10) as u8) } else if n >= 65 && n <= 70 { Ok((n - 65 + 10) as u8) } else { Err(InvalidPercentEncoding { position: pos }) } } None => Err(InvalidPercentEncoding { position: pos }) } } // Push-loop materialisation of `s.chars()` — see `Url.decode_query` doc. fn to_chars(s str) -> []char { mut out []char = []char.new(cap: s.byte_len()) for c in s.chars() { out.push(c) } out }