从零写出 URL-Parse 仓颉三方库:模块讲解、完整源码与验证

这是一份可以不下载项目源码包,仅按文章逐个创建文件的教程。除了安装仓颉 SDK 与测试用的 Node.js,项目需要的 36 个文本文件都在本文中完整给出,包含生产源码、单元测试、独立应用、离线差分参考、脚本和提交材料。没有“其余代码略”,也不需要另行克隆上游仓库。

读法分成三条线:先看每节的职责、流程和关键函数,理解为什么这样写;再把标有文件路径的完整代码放入对应文件;最后在该节的检查点运行命令。普通“命令”或“输出”代码块不是需要创建的文件。所有文件以 UTF-8 保存,路径相对于 url-parse-cangjie 根目录。

已有的 0.1.0 源码包 仍可作为便捷下载,但不是完成教程的前提。其 SHA-256 为:

7196d925920390042e568b9c757124edf64cbfe135e3936a16dab4960a2bf117

本文的生产实现与该发布包相同。文章中的换行统一为 LF;Windows 编辑器保存为 CRLF 不改变代码语义,也不应拿不同换行的文件字节直接比较哈希。本文扩充的模块讲解不反向嵌入自身;最后提供的 doc/tutorial.md 是发布包里的简版操作手册。

项目源码与随附参考实现采用下文各自保留的 MIT 许可。博客主题底部的文章协议标识不能替代源码的 LICENSE;复制或再分发项目时一并保留版权、许可与来源文件。

0. 先明确我们移植的是什么

需求来自 Cangjie-TPC 招募清单 与 OpenHarmony 的 url_parse 样例。样例是调用 JavaScript 包的 ArkTS 应用,不是 URL 解析算法本身。entry/oh-package.json5 声明 "url-parse": "^1.5.10",README.OpenSource 登记 1.5.10。范围依赖不代表每次安装都锁定到同一份源码。

本教程明确选用 URL-Parse 1.5.10 发布标签,提交 8cd4c6c6435c1ea32243ec20c9cfe535251ec524,并锁定 querystringify 2.2.0 和 requires-port 1.0.0。不能只看 master 的 package.json,因为主分支即使版本号没变,也可能已经改过代码。

要保留的能力是:13 个公开 URL 字段、绝对与相对地址、显式基地址、认证信息、IPv4/IPv6、file 和自定义协议、原始或对象查询、自定义 parser、字段联动 set、序列化 toString,以及 location、extractProtocol、trimLeft、qs。样例 UI、Hypium 生命周期和 JavaScript 的 typeof 不是仓颉库应照搬的部分。

完整移植不等于模拟整个 JavaScript 运行时。 仓颉用 Query 枚举表示字符串或查询对象,用 UrlLocation 表示基地址,用函数类型表示回调。任意 JS 对象、原型身份、隐式 coercion 和孤立 UTF-16 代理字符的边界会明确记录。本库兼容指定版本,不是 WHATWG URL 校验器,也不执行 DNS、IDNA、网络请求或安全白名单检查。

1. 准备环境与空目录

实测环境是 Windows x86_64、PowerShell 7.6.5、仓颉 1.0.5 (cjnative)、Node.js v22.20.0。从 仓颉官方下载中心 安装对应 SDK,按安装位置加载环境:

. 'C:\Program Files (x86)\Cangjie\envsetup.ps1'
cjc -v
cjpm --version
node --version

本次编译器输出:

Cangjie Compiler: 1.0.5 (cjnative)
Target: x86_64-w64-mingw32

cjc 是编译器;cjpm 负责工程、依赖、构建与测试;cjfmt 格式化仓颉源码;cjcov 统计覆盖率。Node 只执行测试参考与用例生成器,使用最终库的应用不需要 Node,也不需要 npm install。Linux/macOS 需加载各自 SDK 的环境脚本并重新验收,本文没有声称这些平台或 OpenHarmony 设备已经通过测试。

在自己选择的工作目录创建一个新的空工程。因为下面给出完整 cjpm.toml,这里不用再运行 cjpm init:

New-Item -ItemType Directory -Path url-parse-cangjie
Set-Location url-parse-cangjie
$directories = @(
    'src', 'examples/demo/src', 'test/oracle_driver/src',
    'test/reference/url-parse/node_modules/querystringify',
    'test/reference/url-parse/node_modules/requires-port',
    'test/reference/querystringify', 'scripts', 'doc'
)
foreach ($directory in $directories) {
    New-Item -ItemType Directory -Path $directory -Force | Out-Null
}

不要在已有业务目录直接覆盖文件。教程中的“文件 01”到“文件 36”就是最终文件清单;target、test/fixtures、dist 和覆盖率报告由工具生成,不需要手写。

2. 先看整体结构与仓颉表达方式

层次文件负责什么不负责什么
基础规则src/helpers.cj协议、空白、斜杠、端口、相对路径、blob 解码不保存整个 URL 对象
查询模型src/query.cjcomponent 编解码、Raw/Params、键顺序、查询扫描与序列化不拆 host 或 pathname
基地址src/location.cj把字符串/Url/对象统一成显式 location 快照不读取浏览器全局变量
对外对象src/url.cj构造、解析、字段联动、派生字段、字符串输出不验证网络地址是否安全
原生回归src/*_test.cj对固定输入断言正确行为不自动证明与上游所有输入一致
离线差分test/reference、生成器、driver用真实上游计算期望,再比较仓颉结果不进入生产库的运行时
工程交付scripts、根文档、doc一键验收、打包、来源和申请材料不代替 CLA 与社区评审

调用关系可以这样读:Url 构造器 → 基地址标准化与协议提取 → 按顺序拆分 URL → 可选查询 parser → 相对路径与端口处理 → 认证编解码 → refresh → toString。set 则从“修改单个字段”进入,最终也走 refresh。

仓颉阅读前需要的几个概念:

语法/类型在这里的含义
package url_parse四个生产文件属于同一个库包,内部函数能跨文件调用
public 与默认可见性public 是消费者 API;没有 public 的辅助函数是实现细节
let / var前者不重新赋值,后者会随扫描位置或 URL 修改而变化;let 引用的集合仍可修改内容
Option<T>、Some、None表示“有值/缺省”,避免把缺省与空字符串、false 混为一谈
base!: String声明命名参数,调用写 base: "...";对象基地址重载按位置传参
(String) -> Queryparser 的函数类型;lambda 用 { raw: String => ... }
match、case Query.Params(params) =>解包枚举取得查询对象;分支内多行语句直接写在 => 后
String.size 和 text[i]这里是 UTF-8 字节大小与字节索引,不是第几个中文字符

ASCII 分隔符 / : ? # @ & = 在 UTF-8 中都是单字节,因此可以按字节扫描;Unicode 空白和 %uXXXX 则使用 Rune 处理。不要把 String.size 当成字符数,也不要对整条 URL 先调用 decodeComponent,否则 %2F 可能被误变成路径分隔符。

3. 配置静态库

文件 01:cjpm.toml

这是工程入口。name = "url_parse" 对应消费者的 import url_parse.*;output-type = "static" 表示构建可依赖的库,而不是需要 main 的程序;version = "0.1.0" 是移植库自己的版本,并非 JS 上游版本。cjc-version 记录使用的工具链,空 [dependencies] 表明生产库没有外部包。

其余 compile/link 字段沿用 cjpm 的完整初始化结构,当前不需要额外编译选项、FFI 或 stdx。示例应用的版本稍后独立配置,不影响库版本。

[package]
  cjc-version = "1.0.5"
  name = "url_parse"
  description = "Pure Cangjie port of url-parse 1.5.10"
  version = "0.1.0"
  target-dir = ""
  output-type = "static"
  compile-option = ""
  override-compile-option = ""
  link-option = ""
  package-configuration = {}

[dependencies]

文件 02:.gitignore

这个文件不参与编译。它排除根工程与嵌套示例的构建产物、覆盖率数据、生成的 fixtures 和发布暂存目录,让提交只包含可维护的输入。test/reference/.../node_modules 没有被忽略,因为这里的少量文件是刻意锁定、需要随测试交付的公开参考实现。

target/
**/target/
cov_output/
*.cjo
*.o
*.exe
*.a
*.dll
*.so
*.dylib
*.gcda
*.gcno
test/fixtures/
dist/

4. 基础规则:helpers.cj

文件 03:src/helpers.cj

这层把可以独立理解的规则从 URL 对象中抽出来。先理解下表,再阅读完整代码。函数调用可以向前引用,所以 portNumber 使用后面定义的 hexDigit 是正常的。

代码板块输入与输出为什么需要
ProtocolExtractprotocol、slashes、slashesCount、rest 四个字段一次扫描返回多个结果;Bool 斜杠语义与实际数量不是一回事
trimURLLeft / trimLeft原串 → 去掉左侧控制字符/ECMAScript 空白的串发布版仅清理左侧,不能随意改成全串 trim
asciiLetter / asciiDigit / isSpecial字节或 scheme → Bool集中保存协议扫描、数字与特殊协议判断
extractURLProtocol / extractProtocol地址 + 基协议 → ProtocolExtract清理 CR/LF/HT、识别 scheme、统计斜杠,再按协议选择 rest
splitPorthost + 是否允许空后缀 → hostname、port、是否匹配只把最后一个冒号后的数字当端口,不粗暴拆散 IPv6
numberWhitespace / portNumber文本 → Option<Float64>模拟 JS 数值语法;不能直接依赖仓颉 float parser 的接受范围
requiresPort端口 + 协议 → 是否需保留端口删除 http/ws 的 80、https/wss 的 443 等默认端口
hexDigit十六进制字节 → 数字或 -1供百分号、进制端口和 blob 解码复用
resolvePath相对 pathname + 基 pathname → 路径删除基地址文件名,处理 .、..,保持必要的尾部斜杠
unescapeLocationblob 的 pathname → 解码字符串模拟旧 unescape,支持 %XX 和 %uXXXX,不是 URI component 解码

协议扫描怎么工作? 第一个字符必须是字母;后续允许字母、数字、+、-、.,遇到 : 才确认 scheme。普通 abc/path 不会被当成协议。随后统计正反斜杠:特殊协议可接受异常斜杠形式,file: 另有规则,非特殊协议只在确有 // 时跳过两条斜杠。slashesCount 保留真实数量,后面判断 file 路径时需要它。

为什么端口判断这么长? 上游通过 JS 数字转换判断默认端口,因此 0x50、0b1010000、8e1 都可能等于 80;inf、带符号的十六进制、1_000 则不是同样语义。这里先检查语法再转 Float64,避免语言差异悄悄改结果。它不是 1~65535 范围验证器,不能据此判断连接端口合法。

相对路径为什么倒着扫描? 先把基地址最后一段当成文件名去掉,再接相对段。倒着扫描时,遇到 .. 增加待删除目录数 up,遇到普通段且 up > 0 就抵消一个。对 /a/b/page 与 ../next,拼接为 /a/b/../next,最终得到 /a/next。以 . 或 .. 结束时,末尾空段保留目录斜杠。

blob 解码为什么分两遍? 第一遍把普通字符、%XX、%uXXXX 转成码点列表;第二遍把 UTF-16 高低代理对合并成真实 Unicode 码点。孤立代理不能表示为仓颉有效 UTF-8 String,明确抛异常,不能静默丢字符。

// SPDX-License-Identifier: MIT
// Portions derived from url-parse and requires-port, Copyright (c) 2015 Unshift.io.
package url_parse

import std.collection.*
import std.convert.*

public struct ProtocolExtract {
    public let protocol: String
    public let slashes: Bool
    public let slashesCount: Int64
    public let rest: String

    public init(protocol: String, slashes: Bool, slashesCount: Int64, rest: String) {
        this.protocol = protocol
        this.slashes = slashes
        this.slashesCount = slashesCount
        this.rest = rest
    }
}

func trimURLLeft(text: String): String {
    var offset: Int64 = 0
    for (r in text.toRuneArray()) {
        let code = UInt32(r)
        if (code <= 32 || code == 0xA0 || code == 0x1680 || (code >= 0x2000 && code <= 0x200A) || code == 0x2028 ||
            code == 0x2029 || code == 0x202F || code == 0x205F || code == 0x3000 || code == 0xFEFF) {
            offset += String([r]).size
        } else {
            break
        }
    }
    return text[offset..text.size]
}

public func trimLeft(text: String): String {
    return trimURLLeft(text)
}

func asciiLetter(b: UInt8): Bool {
    return (b >= 65 && b <= 90) || (b >= 97 && b <= 122)
}

func asciiDigit(b: UInt8): Bool {
    return b >= 48 && b <= 57
}

func isSpecial(scheme: String): Bool {
    return scheme == "file:" || scheme == "ftp:" || scheme == "http:" || scheme == "https:" || scheme == "ws:" ||
        scheme == "wss:"
}

func extractURLProtocol(text: String, baseProtocol: String): ProtocolExtract {
    let address = trimURLLeft(text).replace("\r", "").replace("\n", "").replace("\t", "")
    var start: Int64 = 0
    var scheme = ""
    if (!address.isEmpty() && asciiLetter(address[0])) {
        var i: Int64 = 1
        while (i < address.size && (asciiLetter(address[i]) || asciiDigit(address[i]) || address[i] == 43 ||
                address[i] == 45 || address[i] == 46)) {
            i++
        }
        if (i < address.size && address[i] == 58) {
            start = i + 1
            scheme = address[0..start].toAsciiLower()
        }
    }
    let forward = address[start..address.size].startsWith("//")
    var end = start
    while (end < address.size && (address[end] == 47 || address[end] == 92)) {
        end++
    }
    let count = end - start
    var rest = address[start..address.size]
    if (scheme == "file:") {
        if (count >= 2) {
            rest = rest[2..rest.size]
        }
    } else if (isSpecial(scheme)) {
        rest = address[end..address.size]
    } else if (!scheme.isEmpty()) {
        if (forward) {
            rest = rest[2..rest.size]
        }
    } else if (count >= 2 && isSpecial(baseProtocol)) {
        rest = address[end..address.size]
    }
    return ProtocolExtract(scheme, forward || isSpecial(scheme), count, rest)
}

public func extractProtocol(address: String): ProtocolExtract {
    return extractURLProtocol(address, "")
}

public func extractProtocol(address: String, base: UrlLocation): ProtocolExtract {
    return extractURLProtocol(address, base.protocol)
}

func splitPort(host: String, allowEmpty: Bool): (String, String, Bool) {
    if (let Some(index) <- host.lastIndexOf(":")) {
        let suffix = host[index + 1..host.size]
        var digits = allowEmpty || !suffix.isEmpty()
        for (b in suffix) {
            if (!asciiDigit(b)) {
                digits = false
                break
            }
        }
        if (digits) {
            return (host[0..index], suffix, true)
        }
    }
    return (host, "", false)
}

func numberWhitespace(r: Rune): Bool {
    let code = UInt32(r)
    return (code >= 9 && code <= 13) || code == 32 || code == 0xA0 || code == 0x1680 || (code >= 0x2000 && code <= 0x200A) ||
        code == 0x2028 || code == 0x2029 || code == 0x202F || code == 0x205F || code == 0x3000 || code == 0xFEFF
}

// Validate ECMAScript numeric syntax before using the native float converter.
func portNumber(port: String): Option<Float64> {
    let runes = port.toRuneArray()
    var start: Int64 = 0
    var end = runes.size
    while (start < end && numberWhitespace(runes[start])) {
        start++
    }
    while (end > start && numberWhitespace(runes[end - 1])) {
        end--
    }
    let text = String(runes[start..end])
    if (text.isEmpty()) {
        return Some(0.0)
    }
    if (text == "Infinity" || text == "+Infinity" || text == "-Infinity") {
        return Some(Float64.parse(text))
    }
    if (text.size >= 2 && text[0] == 48) {
        let radix: Int64 = match (text[1]) {
            case 120 | 88 => 16
            case 98 | 66 => 2
            case 111 | 79 => 8
            case _ => 0
        }
        if (radix > 0) {
            if (text.size == 2) {
                return None
            }
            var number: Float64 = 0.0
            for (b in text[2..text.size]) {
                let digit = hexDigit(b)
                if (digit < 0 || digit >= radix) {
                    return None
                }
                number = number * Float64(radix) + Float64(digit)
            }
            return Some(number)
        }
    }
    var i: Int64 = if (text[0] == 43 || text[0] == 45) {
        1
    } else {
        0
    }
    var digits: Int64 = 0
    while (i < text.size && asciiDigit(text[i])) {
        i++;
        digits++
    }
    if (i < text.size && text[i] == 46) {
        i++
        while (i < text.size && asciiDigit(text[i])) {
            i++;
            digits++
        }
    }
    if (digits == 0) {
        return None
    }
    if (i < text.size && (text[i] == 101 || text[i] == 69)) {
        i++
        if (i < text.size && (text[i] == 43 || text[i] == 45)) {
            i++
        }
        let exponentStart = i
        while (i < text.size && asciiDigit(text[i])) {
            i++
        }
        if (i == exponentStart) {
            return None
        }
    }
    if (i != text.size) {
        return None
    }
    try {
        return Some(Float64.parse(text))
    } catch (_: IllegalArgumentException) {
        return None
    }
}

public func requiresPort(port: String, protocol: String): Bool {
    let number = portNumber(port) ?? 0.0
    if (number == 0.0 || number != number) {
        return false
    }
    let scheme = if (let Some(index) <- protocol.indexOf(":")) {
        protocol[0..index]
    } else {
        protocol
    }
    match (scheme) {
        case "http" | "ws" => return number != 80.0
        case "https" | "wss" => return number != 443.0
        case "ftp" => return number != 21.0
        case "gopher" => return number != 70.0
        case "file" => return false
        case _ => return true
    }
}

func hexDigit(b: UInt8): Int64 {
    if (asciiDigit(b)) {
        return Int64(b) - 48
    }
    if (b >= 65 && b <= 70) {
        return Int64(b) - 55
    }
    if (b >= 97 && b <= 102) {
        return Int64(b) - 87
    }
    return -1
}

func resolvePath(relative: String, base: String): String {
    if (relative.isEmpty()) {
        return base
    }
    let initial = (if (base.isEmpty()) {
        "/"
    } else {
        base
    }).split("/")
    let path = ArrayList<String>()
    for (i in 0..initial.size - 1) {
        path.add(initial[i])
    }
    for (part in relative.split("/")) {
        path.add(part)
    }
    let last = path[path.size - 1]
    var i = path.size
    var up: Int64 = 0
    var unshift = false
    while (i > 0) {
        i--
        if (path[i] == ".") {
            path.remove(at: i)
        } else if (path[i] == "..") {
            path.remove(at: i)
            up++
        } else if (up > 0) {
            if (i == 0) {
                unshift = true
            }
            path.remove(at: i)
            up--
        }
    }
    if (unshift) {
        path.add("", at: 0)
    }
    if (last == "." || last == "..") {
        path.add("")
    }
    return String.join(path.toArray(), delimiter: "/")
}

// Blob locations use JavaScript's legacy unescape, not decodeURIComponent.
func unescapeLocation(text: String): String {
    let points = ArrayList<UInt32>()
    let runes = text.toRuneArray()
    var i: Int64 = 0
    while (i < runes.size) {
        if (runes[i] == r'%') {
            let unicode = i + 1 < runes.size && runes[i + 1] == r'u'
            let length: Int64 = if (unicode) {
                6
            } else {
                3
            }
            if (i + length <= runes.size) {
                var value: UInt32 = 0
                var valid = true
                for (j in i + (if (unicode) {
                        2
                    } else {
                        1
                    })..i + length) {
                    let code = UInt32(runes[j])
                    let digit = if (code <= 127) {
                        hexDigit(UInt8(code))
                    } else {
                        -1
                    }
                    if (digit < 0) {
                        valid = false
                        break
                    }
                    value = value * 16 + UInt32(digit)
                }
                if (valid) {
                    points.add(value);
                    i += length
                    continue
                }
            }
        }
        points.add(UInt32(runes[i]))
        i++
    }
    let result = ArrayList<Rune>()
    i = 0
    while (i < points.size) {
        var code = points[i]
        if (code >= 0xD800 && code <= 0xDBFF && i + 1 < points.size && points[i + 1] >= 0xDC00 && points[i + 1] <= 0xDFFF) {
            code = 0x10000 + (code - 0xD800) * 0x400 + points[i + 1] - 0xDC00
            i++
        } else if (code >= 0xD800 && code <= 0xDFFF) {
            throw IllegalArgumentException("Blob location contains an unpaired UTF-16 surrogate")
        }
        result.add(Rune(code))
        i++
    }
    return String(result.toArray())
}

这一节先完成文件,不单独编译。它会调用下一节的类型和函数;四个生产文件同包一起编译后才到第一个检查点。

5. 查询模型与百分号编码:query.cj

文件 04:src/query.cj

JavaScript 的 query 字段既可能是字符串,也可能是对象。仓颉不用一个模糊的动态类型,而用 Query.Raw(String) 与 Query.Params(QueryParams) 明确区分两种状态。默认 Raw 保留原文,例如 ?a=A+B;启用 parser 后得到对象,再序列化可能变成 a=A%20B,这两种字符串看起来不同但各自契约清楚。

代码板块作用注意点
encodeComponent安全 ASCII 原样输出,其他 UTF-8 字节写成 %HH空格用 %20,不是 +;中文通常产生多个 %HH
decodeComponent校验 % 后两个 hex 字符,收集字节并校验 UTF-8不把 + 变空格;坏 hex 或坏 UTF-8 抛异常
indexKey判断键是否为 JS 有效 uint32 索引0、2 是索引;01、4294967295 不是
QueryParams 的 values / orderHashMap 查值,ArrayList 保存首次插入顺序更新已有键不移动普通键顺序;删除同时改两个结构
entries数字索引先升序,其余按插入顺序输出不能直接迭代 HashMap,否则 href 顺序可能与上游不同
Query保留 Raw 或 Params 状态,统一 toStringParams 调用 stringify;Raw 不自动编码或去掉 ?
inheritedKey / keyDelimiter排除上游原型名称,识别查询键分隔符排除名称是 parser 兼容行为,不是 HashMap 的安全缺陷
parseQuery按上游扫描模式取得键和值,解码并保存首个键坏键或坏值跳过整个 pair,不中止全部 query
stringifyQuery将有序条目编码成 key=value 再以 & 连接只有非空结果才加 prefix
QueryString为 Url.qs.parse/stringify 提供门面字符串 prefix 是命名参数;Bool prefix 可按位置传入

扫描循环的几个位置变量。 start 记录键开始;扫描到 = ? # & 后得到 rawKey;可选地跳过 =;valueStart 开始一直读到 &。这保留上游正则的非标准行为,不能用“先删问号,再按等号无限 split”替代。

解码查询前才把 + 替换为空格,因此 a=A+B → A B,而 decodeComponent("A+B") → A+B。a=first&a=second 保留 first。bad=%FF 因不是有效 UTF-8 被略过;toString=x 因上游原型规则被略过。手动创建 QueryParams 不会自动过滤这些键,和 parser 输入的契约不同。

用 ?name=A+B&name=second&bad=%FF 逐步跟一次循环:

本轮位置扫描与处理集合状态
开头的 ?keyDelimiter 跳过问号,start 指向 n空集合
name=A+BrawKey 是 name;valueStart 指向 A;+ 换空格后解码name = A B
&name=second跳过 &;解码成功,但 contains(name) 已为 true仍为 A B
&bad=%FFdecodeComponent 得到非法 UTF-8,进入 catchbad 不写入,已有 name 保留
扫描结束stringify 按 entries 的次序编码键值name=A%20B

values 与 order 必须一起维护:只从 map 删除键却不从 order 删除,会让 entries 读取不存在的值;只向 order 追加已有键,会重复序列化。代码中的 set/remove 各自把这两件事放在同一个操作中。

// SPDX-License-Identifier: MIT
// Derived from querystringify 2.2.0, Copyright (c) 2015 Unshift.io, Arnout Kazemier.
package url_parse

import std.collection.*
import std.sort.*

public func encodeComponent(text: String): String {
    let bytes = ArrayList<UInt8>()
    let hex = "0123456789ABCDEF"
    for (b in text) {
        if (asciiLetter(b) || asciiDigit(b) || b == 45 || b == 95 || b == 46 || b == 33 || b == 126 || b == 42 || b == 39 ||
            b == 40 || b == 41) {
            bytes.add(b)
        } else {
            bytes.add(37)
            bytes.add(hex[Int64(b / 16)])
            bytes.add(hex[Int64(b % 16)])
        }
    }
    return String.fromUtf8(bytes.toArray())
}

public func decodeComponent(text: String): String {
    let bytes = ArrayList<UInt8>()
    var i: Int64 = 0
    while (i < text.size) {
        if (text[i] == 37) {
            if (i + 2 >= text.size) {
                throw IllegalArgumentException("Incomplete percent escape")
            }
            let high = hexDigit(text[i + 1])
            let low = hexDigit(text[i + 2])
            if (high < 0 || low < 0) {
                throw IllegalArgumentException("Invalid percent escape")
            }
            bytes.add(UInt8(high * 16 + low))
            i += 3
        } else {
            bytes.add(text[i])
            i++
        }
    }
    return String.fromUtf8(bytes.toArray())
}

func indexKey(key: String): Option<Int64> {
    if (key.isEmpty() || key.size > 10 || (key.size > 1 && key[0] == 48)) {
        return None
    }
    var n: Int64 = 0
    for (b in key) {
        if (!asciiDigit(b)) {
            return None
        }
        n = n * 10 + Int64(b) - 48
    }
    return if (n < 4294967295) {
        Some(n)
    } else {
        None
    }
}

public class QueryParams {
    let values = HashMap<String, String>()
    let order = ArrayList<String>()

    public init() {}
    public init(entries: Array<(String, String)>) {
        for ((key, value) in entries) {
            set(key, value)
        }
    }
    public prop size: Int64 {
        get() {
            return values.size
        }
    }
    public func contains(key: String): Bool {
        return values.contains(key)
    }
    public func get(key: String): Option<String> {
        return values.get(key)
    }
    public func set(key: String, value: String): QueryParams {
        if (!values.contains(key)) {
            order.add(key)
        }
        values[key] = value
        return this
    }
    public func set(key: String, value: Int64): QueryParams {
        return set(key, value.toString())
    }
    public func set(key: String, value: Bool): QueryParams {
        return set(key, value.toString())
    }
    public func set(key: String, value: Option<String>): QueryParams {
        return set(key, value ?? "")
    }
    public func remove(key: String): Bool {
        if (!values.contains(key)) {
            return false
        }
        values.remove(key)
        for (i in 0..order.size) {
            if (order[i] == key) {
                order.remove(at: i);
                break
            }
        }
        return true
    }
    public func entries(): Array<(String, String)> {
        let indexes = ArrayList<(Int64, String)>()
        let ordinary = ArrayList<String>()
        for (key in order) {
            if (let Some(index) <- indexKey(key)) {
                indexes.add((index, key))
            } else {
                ordinary.add(key)
            }
        }
        let sorted = indexes.toArray()
        sort(sorted, lessThan: {a: (Int64, String), b: (Int64, String) => a[0] < b[0]})
        let result = ArrayList<(String, String)>()
        for ((_, key) in sorted) {
            result.add((key, values[key]))
        }
        for (key in ordinary) {
            result.add((key, values[key]))
        }
        return result.toArray()
    }
}

public enum Query <: ToString {
    Raw(String) | Params(QueryParams)

    public func toString(): String {
        match (this) {
            case Raw(value) => return value
            case Params(params) => return stringifyQuery(params)
        }
    }
}

func inheritedKey(key: String): Bool {
    return key == "__proto__" || key == "constructor" || key == "toString" || key == "toLocaleString" || key == "valueOf" ||
        key == "hasOwnProperty" || key == "isPrototypeOf" || key == "propertyIsEnumerable" || key == "__defineGetter__" ||
        key == "__defineSetter__" || key == "__lookupGetter__" || key == "__lookupSetter__"
}

func keyDelimiter(b: UInt8): Bool {
    return b == 61 || b == 63 || b == 35 || b == 38
}

public func parseQuery(text: String): QueryParams {
    let result = QueryParams()
    var i: Int64 = 0
    // Match /([^=?#&]+)=?([^&]*)/g, including nonstandard separators.
    while (i < text.size) {
        while (i < text.size && keyDelimiter(text[i])) {
            i++
        }
        if (i == text.size) {
            break
        }
        let start = i
        while (i < text.size && !keyDelimiter(text[i])) {
            i++
        }
        let rawKey = text[start..i]
        if (i < text.size && text[i] == 61) {
            i++
        }
        let valueStart = i
        while (i < text.size && text[i] != 38) {
            i++
        }
        try {
            let key = decodeComponent(rawKey.replace("+", " "))
            let value = decodeComponent(text[valueStart..i].replace("+", " "))
            if (!inheritedKey(key) && !result.contains(key)) {
                result.set(key, value)
            }
        } catch (_: IllegalArgumentException) {
            // Upstream omits the entire pair if either component cannot decode.
        }
    }
    return result
}

public func stringifyQuery(params: QueryParams, prefix!: String = ""): String {
    let pairs = ArrayList<String>()
    for ((key, value) in params.entries()) {
        pairs.add(encodeComponent(key) + "=" + encodeComponent(value))
    }
    return if (pairs.isEmpty()) {
        ""
    } else {
        prefix + String.join(pairs.toArray(), delimiter: "&")
    }
}

public class QueryString {
    public init() {}
    public func parse(text: String): QueryParams {
        return parseQuery(text)
    }
    public func stringify(params: QueryParams, prefix!: String = ""): String {
        return stringifyQuery(params, prefix: prefix)
    }
    public func stringify(params: QueryParams, prefix: Bool): String {
        return stringifyQuery(params, prefix: if (prefix) {
            "?"
        } else {
            ""
        })
    }
}

学习检查:顺着 parseQuery("?a=1&a=2&bad=%FF") 的循环推演,结果只有 a=1。顺着 QueryParams([("10","ten"),("2","two"),("a","x")]) 的 entries 推演,输出顺序是 2、10、a。对应可执行断言在第 8 节,不需要把这些表达式额外粘到生产文件顶层。

6. 显式基地址:location.cj

文件 05:src/location.cj

浏览器版本可能默认读取 window.location,原生库没有这个环境。因此调用者明确提供字符串、Url 或 UrlLocation,本层把它们统一成快照,让主解析器不必分辨来源。

代码板块负责什么
UrlLocation 与命名参数构造器保存 protocol、host、hostname、port、pathname 等基地址字段,也保留认证和 origin 元数据
slashes: Option<Bool>None 表示没有明确给出,Some(false) 表示明确不要;二者不能合并
normalizeLocation返回新对象;如果 slashes 缺省,从 href 的 scheme:// 形式推断;blob 对象先解码 pathname
stringLocation先 Url(loc) 解析字符串,再取快照,而不是手工另写一套 URL 拆分
snapshotLocation复制 Url 当前字段,不把对象引用本身当 location
location 的四组入口空值、String、Url、UrlLocation 分别进入适合的标准化路径

注意:对象里的 href 不是“补齐所有其他字段”的魔法来源。 它只参与 slashes 推断;对象 base 应实际提供需要的 host、hostname、pathname 等字段。标准化保留 auth 元数据,但相对 URL 主解析器不继承 auth、query 或 hash。这既避免泄漏认证,也是在保留上游行为,而不是本移植新增策略。

字符串 blob:https://example.com/id 与 UrlLocation(protocol: "blob:", pathname: "https%3A//example.com/id") 走不同路径,1.5.10 发布版结果也不同。不要为了看起来统一就把它们强行合并。

// SPDX-License-Identifier: MIT
package url_parse

public class UrlLocation {
    public var protocol: String
    public var slashes: Option<Bool>
    public var host: String
    public var hostname: String
    public var port: String
    public var pathname: String
    public var href: String
    public var auth: String
    public var username: String
    public var password: String
    public var origin: String

    public init(protocol!: String = "", slashes!: Option<Bool> = None, host!: String = "", hostname!: String = "",
        port!: String = "", pathname!: String = "", href!: String = "", auth!: String = "", username!: String = "",
        password!: String = "", origin!: String = "null") {
        this.protocol = protocol
        this.slashes = slashes
        this.host = host
        this.hostname = hostname
        this.port = port
        this.pathname = pathname
        this.href = href
        this.auth = auth
        this.username = username
        this.password = password
        this.origin = origin
    }
}

func normalizeLocation(loc: UrlLocation): UrlLocation {
    if (loc.protocol == "blob:") {
        return location(unescapeLocation(loc.pathname))
    }
    var schemeEnd: Int64 = 0
    if (!loc.href.isEmpty() && asciiLetter(loc.href[0])) {
        schemeEnd = 1
        while (schemeEnd < loc.href.size && (asciiLetter(loc.href[schemeEnd]) || asciiDigit(loc.href[schemeEnd]) ||
                loc.href[schemeEnd] == 43 || loc.href[schemeEnd] == 45 || loc.href[schemeEnd] == 46)) {
            schemeEnd++
        }
    }
    let inferred = schemeEnd > 0 && loc.href[schemeEnd..loc.href.size].startsWith("://")
    return UrlLocation(protocol: loc.protocol, slashes: Some(loc.slashes ?? inferred), host: loc.host,
        hostname: loc.hostname, port: loc.port, pathname: loc.pathname, href: loc.href, auth: loc.auth,
        username: loc.username, password: loc.password, origin: loc.origin)
}

public func location(): UrlLocation {
    return normalizeLocation(UrlLocation())
}

public func location(loc: String): UrlLocation {
    return stringLocation(loc)
}

func stringLocation(loc: String): UrlLocation {
    return snapshotLocation(Url(loc))
}

func snapshotLocation(url: Url): UrlLocation {
    return UrlLocation(protocol: url.protocol, slashes: Some(url.slashes), host: url.host, hostname: url.hostname,
        port: url.port, pathname: url.pathname, href: url.href, auth: url.auth, username: url.username,
        password: url.password, origin: url.origin)
}

public func location(loc: Url): UrlLocation {
    return normalizeLocation(snapshotLocation(loc))
}

public func location(loc: UrlLocation): UrlLocation {
    return normalizeLocation(loc)
}

7. URL 主解析、字段联动与输出:url.cj

文件 06:src/url.cj

Url 是消费者主要使用的类。13 个公开字段先有空值或 "null" 默认值,各构造器只负责把不同参数形式变成统一的 parseAddress(input, loc, parser),避免多套实现产生差异。

字段组字段含义
协议protocol、slashes协议一般带 :;slashes 是当前对象的斜杠语义
认证auth、username、password认证信息及拆分后的规范编码字符串
authorityhost、hostname、porthost 含保留的端口;hostname 不含端口,IPv6 方括号保留
资源pathname、query、hash路径、Raw/Params 查询、带 # 的 fragment
派生href、originhref 为缓存的完整地址;origin 是协议和 host,某些协议为字面字符串 null

先学 parseAddress 的八个阶段。

  1. extractURLProtocol 提取 scheme 与 rest;判断 relative;决定 protocol 和 slashes 是否继承;结合 file、drive 与 slash 数量决定 pathOnly。
  2. 先切 #,再切 ?。这样 fragment 中的 ? 不会变成 query,query 中的 / 也不会变成 pathname。
  3. 只对剩余地址做特殊协议的反斜杠替换。hash/query 中的反斜杠保留,不能全串 replace。
  4. 按 pathOnly 或第一个 / 切 pathname,再在剩余 authority 中找最后一个 @;用户名可能含 @,不能取第一个。
  5. 提取 host、数字端口后缀、hostname。仅相对地址的允许字段从 loc 继承,主机名小写,pathname 不强制小写。
  6. 有 parser 就调用,即使初始 query 为空。解析相对路径,给特殊协议 pathname 补 /,移除默认端口。
  7. setAuth 在**第一个 :**处分用户名与密码,先 decode 再 encode,使合法编码规范化;坏认证直接抛异常。
  8. refresh 统一主机大小写、重建 auth 和 origin,再用 toString 写 href。

以 https://U:p%40ss@EXAMPLE.com:443/a?x=A+B#top 为例:protocol 为 https:;默认 443 删除;hostname 为 example.com;password 仍是 p%40ss;Raw 模式保留 ?x=A+B;Params 模式把值解为 A B 后写为 x=A%20B。fragment 为 #top,不会参与认证或 query 扫描。

这里的 address 是不断缩短的“剩余待处理文本”,不是每次都从完整原串重新找字符:

阶段address 的剩余内容已存的结果
协议提取后U:p%40ss@EXAMPLE.com:443/a?x=A+B#topprotocol = https:
切 fragment 后U:p%40ss@EXAMPLE.com:443/a?x=A+Bhash = #top
切 query 后U:p%40ss@EXAMPLE.com:443/aquery = Raw(?x=A+B)
切 pathname 后U:p%40ss@EXAMPLE.com:443pathname = /a
切 auth 后EXAMPLE.com:443auth 暂为 U:p%40ss
切端口后EXAMPLE.comhostname 小写;443 随后消除

这样每一步只在自己的语法区域里找分隔符。若先找 @ 再切路径,http://example.com/@user 就容易被误认成认证;若先全串替换反斜杠,fragment 的原文会被破坏。

构造器中 None 代表没有查询 parser;Some({s: String => Query.Params(qs.parse(s))}) 代表启用内置解析;Some(parser) 代表调用者提供回调。真正的解析函数只做一次 if (let Some(callback) <- parser),三种入口因此不会各写一遍 URL 扫描。

再学 set 为什么不能简单赋值。 修改 hostname 需要重建 host,修改 port 需要检查默认端口,修改用户名/密码需要编码,修改 pathname/hash 需要补前缀。分支处理后统一 refresh,才不会出现 hostname 新了、href 旧了的状态。

setter 板块联动行为
query String空串变 Raw 空值;非空默认解析成 Params;flag true 的非空输入明确拒绝
port以当前协议判断默认值,更新 host;默认端口同时清空 port
hostname / hosthostname 保留已有 port;host 重新拆数字后缀,未额外执行默认端口消除
protocol小写并由 flag 控制 slashes;发布版不自动给字段补冒号
pathname / hash非空时自动补 / 或 #;发布版不受该 flag 抑制
username / password传入未编码文本,用 encodeComponent 编码
auth接收可含已有百分号编码的文本,decode 后再 encode;没有冒号时保留旧 password
Int64、QueryParams、Query、parser 重载把静态类型适配到对应语义;不复制一整套字符串 setter
未识别字段不改该字段,但仍刷新派生字段,保留上游行为

最后学 toString 与 href 的差别。 toString 读取当前字段,拼 protocol、slashes、认证、host、path、query、hash;Raw 查询直接拼,Params 才调用 stringify,并只补一个 ?。自定义 stringify 不改 href。公开字段可以直接修改,但不会自动触发 refresh;应优先通过 set 修改,或自行理解缓存语义。

例如 http://example.com:8080/a 调用 set("hostname", "OTHER.EXAMPLE") 后,hostname 先写为输入,host 同时重建为 OTHER.EXAMPLE:8080;refresh 再统一小写、计算 origin 为 http://other.example:8080、写 href 为 http://other.example:8080/a。接着 set("port", 80) 会把 host 改成 other.example 并清空 port。这条联动链是 setter 的核心,不是简单的属性赋值。

兼容细节需要记住:set("protocol", "https") 虽然输出字符串会补冒号,但字段仍是 https,origin 可为 null;正常调用应传 https:。set("auth", "new") 没有冒号时不清旧 password,清密码应再调用 set("password", "")。这些是发布版契约,不要未经版本决策自行“修正”。

末尾静态辅助函数把 Url.trimLeft/location/extractProtocol 转发到同一套内部实现;全局 parse 返回 Url,提供函数风格入口。它们是 API 门面,不是重复算法。

// SPDX-License-Identifier: MIT
// Derived from url-parse 1.5.10, Copyright (c) 2015 Unshift.io, Arnout Kazemier.
package url_parse

import std.unicode.*

public class Url <: ToString {
    public var protocol = ""
    public var slashes = false
    public var auth = ""
    public var username = ""
    public var password = ""
    public var host = ""
    public var hostname = ""
    public var port = ""
    public var pathname = ""
    public var query: Query = Query.Raw("")
    public var hash = ""
    public var href = ""
    public var origin = "null"
    public static let qs = QueryString()

    public init() {
        parseAddress("", UrlLocation(), None)
    }

    public init(address: String, base!: String = "", parseQuery!: Bool = false) {
        let loc = if (base.isEmpty()) {
            UrlLocation()
        } else {
            Url.location(base)
        }
        parseAddress(address, loc, if (parseQuery) {
            Some({s: String => Query.Params(qs.parse(s))})
        } else {
            None
        })
    }

    public init(address: String, base: UrlLocation, parseQuery!: Bool = false) {
        parseAddress(address, normalizeLocation(base), if (parseQuery) {
            Some({s: String => Query.Params(qs.parse(s))})
        } else {
            None
        })
    }

    public init(address: String, base: Url, parseQuery!: Bool = false) {
        parseAddress(address, Url.location(base), if (parseQuery) {
            Some({s: String => Query.Params(qs.parse(s))})
        } else {
            None
        })
    }

    public init(address: String, parser: (String) -> Query, base!: String = "") {
        let loc = if (base.isEmpty()) {
            UrlLocation()
        } else {
            Url.location(base)
        }
        parseAddress(address, loc, Some(parser))
    }

    public init(address: String, base: UrlLocation, parser: (String) -> Query) {
        parseAddress(address, normalizeLocation(base), Some(parser))
    }

    public init(address: String, base: Url, parser: (String) -> Query) {
        parseAddress(address, Url.location(base), Some(parser))
    }

    func parseAddress(input: String, loc: UrlLocation, parser: Option<(String) -> Query>): Unit {
        let extracted = extractURLProtocol(input, loc.protocol)
        let relative = extracted.protocol.isEmpty() && !extracted.slashes
        slashes = extracted.slashes || (relative && (loc.slashes ?? false))
        protocol = if (extracted.protocol.isEmpty()) {
            loc.protocol
        } else {
            extracted.protocol
        }
        var address = extracted.rest
        let drive = address.size >= 2 && asciiLetter(address[0]) && address[1] == 58
        let pathOnly = (extracted.protocol == "file:" && (extracted.slashesCount != 2 || drive)) || (!extracted.slashes &&
            (!extracted.protocol.isEmpty() || extracted.slashesCount < 2 || !isSpecial(protocol)))

        if (let Some(index) <- address.indexOf("#")) {
            hash = address[index..address.size]
            address = address[0..index]
        }
        if (let Some(index) <- address.indexOf("?")) {
            query = Query.Raw(address[index..address.size])
            address = address[0..index]
        }
        if (isSpecial(protocol)) {
            address = address.replace("\\", "/")
        }
        if (pathOnly) {
            // The upstream /(.*)/ rule stops at JS line separators.
            var end = address.size
            for (separator in ["\u{2028}", "\u{2029}"]) {
                if (let Some(index) <- address.indexOf(separator)) {
                    if (index < end) {
                        end = index
                    }
                }
            }
            pathname = address[0..end]
            address = ""
        } else if (let Some(index) <- address.indexOf("/")) {
            pathname = address[index..address.size]
            address = address[0..index]
        }
        if (let Some(index) <- address.lastIndexOf("@")) {
            auth = address[0..index]
            address = address[index + 1..address.size]
        }
        host = (if (address.isEmpty() && relative) {
            loc.host
        } else {
            address
        }).toLower()
        let (name, parsedPort, matched) = splitPort(address, true)
        port = if (parsedPort.isEmpty() && relative) {
            loc.port
        } else {
            parsedPort
        }
        if (matched) {
            address = name
        }
        hostname = (if (address.isEmpty() && relative) {
            loc.hostname
        } else {
            address
        }).toLower()
        if (let Some(callback) <- parser) {
            query = callback(query.toString())
        }
        if (relative && (loc.slashes ?? false) && !pathname.startsWith("/") && (!pathname.isEmpty() ||
            !loc.pathname.isEmpty())) {
            pathname = resolvePath(pathname, loc.pathname)
        }
        if (!pathname.startsWith("/") && isSpecial(protocol)) {
            pathname = "/" + pathname
        }
        if (!requiresPort(port, protocol)) {
            host = hostname
            port = ""
        }
        if (!auth.isEmpty()) {
            setAuth(auth)
        }
        refresh()
    }

    func setAuth(value: String): Unit {
        if (let Some(index) <- value.indexOf(":")) {
            username = encodeComponent(decodeComponent(value[0..index]))
            password = encodeComponent(decodeComponent(value[index + 1..value.size]))
        } else {
            username = encodeComponent(decodeComponent(value))
        }
    }

    func refresh(): Unit {
        host = host.toLower()
        hostname = hostname.toLower()
        auth = if (password.isEmpty()) {
            username
        } else {
            username + ":" + password
        }
        origin = if (protocol != "file:" && isSpecial(protocol) && !host.isEmpty()) {
            protocol + "//" + host
        } else {
            "null"
        }
        href = toString()
    }

    public func set(part: String, value: String, flag!: Bool = false): Url {
        match (part) {
            case "query" =>
                if (flag && !value.isEmpty()) {
                    throw IllegalArgumentException("Query parser must be a function, not true")
                }
                query = if (value.isEmpty()) {
                    Query.Raw("")
                } else {
                    Query.Params(qs.parse(value))
                }
            case "port" =>
                port = value
                host = if (requiresPort(value, protocol)) {
                    hostname + ":" + value
                } else {
                    hostname
                }
                if (!requiresPort(value, protocol)) {
                    port = ""
                }
            case "hostname" =>
                hostname = value
                host = value + (if (port.isEmpty()) {
                    ""
                } else {
                    ":" + port
                })
            case "host" =>
                host = value
                let (name, number, _) = splitPort(value, false)
                hostname = name
                port = number
            case "protocol" =>
                protocol = value.toLower()
                slashes = !flag
            case "pathname" => pathname = if (!value.isEmpty() && !value.startsWith("/")) {
                "/" + value
            } else {
                value
            }
            case "hash" => hash = if (!value.isEmpty() && !value.startsWith("#")) {
                "#" + value
            } else {
                value
            }
            case "username" => username = encodeComponent(value)
            case "password" => password = encodeComponent(value)
            case "auth" => setAuth(value)
            case _ => ()
        }
        refresh()
        return this
    }

    public func set(part: String, value: Int64): Url {
        return set(part, value.toString())
    }

    public func set(part: String, value: QueryParams): Url {
        return set(part, Query.Params(value))
    }

    public func set(part: String, value: Query): Url {
        if (part == "query") {
            query = value
        }
        refresh()
        return this
    }

    public func set(part: String, value: String, parser: (String) -> Query): Url {
        if (part == "query") {
            query = if (value.isEmpty()) {
                Query.Raw("")
            } else {
                parser(value)
            }
            refresh()
            return this
        }
        return set(part, value)
    }

    public func toString(): String {
        return toString({params: QueryParams => qs.stringify(params)})
    }

    public func toString(stringify: (QueryParams) -> String): String {
        let scheme = protocol + (if (!protocol.isEmpty() && !protocol.endsWith(":")) {
            ":"
        } else {
            ""
        })
        var result = scheme + (if ((!protocol.isEmpty() && slashes) || isSpecial(protocol)) {
            "//"
        } else {
            ""
        })
        if (!username.isEmpty()) {
            result += username + (if (password.isEmpty()) {
                ""
            } else {
                ":" + password
            }) + "@"
        } else if (!password.isEmpty()) {
            result += ":" + password + "@"
        } else if (protocol != "file:" && isSpecial(protocol) && host.isEmpty() && pathname != "/") {
            result += "@"
        }
        var authority = host
        let (_, _, hasPortSuffix) = splitPort(hostname, false)
        if (host.endsWith(":") || (hasPortSuffix && port.isEmpty())) {
            authority += ":"
        }
        result += authority + pathname
        let search = match (query) {
            case Query.Raw(value) => value
            case Query.Params(params) => stringify(params)
        }
        if (!search.isEmpty()) {
            result += (if (search.startsWith("?")) {
                ""
            } else {
                "?"
            }) + search
        }
        return result + hash
    }

    public static func trimLeft(text: String): String {
        return trimURLLeft(text)
    }
    public static func extractProtocol(address: String): ProtocolExtract {
        return extractURLProtocol(address, "")
    }
    public static func extractProtocol(address: String, base: UrlLocation): ProtocolExtract {
        return extractURLProtocol(address, base.protocol)
    }
    public static func location(): UrlLocation {
        return normalizeLocation(UrlLocation())
    }
    public static func location(loc: String): UrlLocation {
        return stringLocation(loc)
    }
    public static func location(loc: Url): UrlLocation {
        return normalizeLocation(snapshotLocation(loc))
    }
    public static func location(loc: UrlLocation): UrlLocation {
        return normalizeLocation(loc)
    }
}

public func parse(): Url {
    return Url()
}

public func parse(address: String, base!: String = "", parseQuery!: Bool = false): Url {
    return Url(address, base: base, parseQuery: parseQuery)
}

public func parse(address: String, base: UrlLocation, parseQuery!: Bool = false): Url {
    return Url(address, base, parseQuery: parseQuery)
}

public func parse(address: String, base: Url, parseQuery!: Bool = false): Url {
    return Url(address, base, parseQuery: parseQuery)
}

public func parse(address: String, parser: (String) -> Query, base!: String = ""): Url {
    return Url(address, parser, base: base)
}

检查点 A:四个生产文件完成后,在根目录运行。

cjpm build

应看到 cjpm build success。静态库没有 main,不应在根目录执行 cjpm run。编译失败先核对 SDK、文件是否都保存、package 是否相同、是否漏复制函数,不要通过删掉边界代码来消除报错。

8. 原生测试:教代码,也固定契约

测试文件放在 src,以 _test.cj 结尾;cjpm 才会自动发现。@Test 标记一个测试函数,@Assert(actual, expected) 比较值。一个函数可能含多组断言和循环,因此“16 组测试”不是“只测了 16 个 URL”。

文件 07:src/query_test.cj

三个板块分别验证编码、查询解析和查询集合:componentEncoding 检查安全字符、中文、多字节及非法 UTF-8;queryDuplicatesAndPrototypeKeys 检查首键保留、+、坏值、原型名称和非标准分隔符;queryOrderingAndMutation 检查数字索引排序、更新/删除、便利 set 重载、空值和 prefix。

异常测试先把 failed 设 false,捕获预期异常后设 true,再断言。若错误输入没有抛异常,测试会失败;这比只运行坏输入、观察有没有打印日志更可靠。

// SPDX-License-Identifier: MIT
package url_parse

import std.unittest.*
import std.unittest.testmacro.*

@Test
func componentEncoding() {
    @Assert(encodeComponent("AZaz09-_.!~*'()"), "AZaz09-_.!~*'()")
    @Assert(encodeComponent(" a+b/@:"), "%20a%2Bb%2F%40%3A")
    @Assert(encodeComponent("\u{4E2D}\u{1F680}"), "%E4%B8%AD%F0%9F%9A%80")
    @Assert(decodeComponent("%e4%b8%ad+"), "\u{4E2D}+")
    for (text in ["%", "%A", "%GG", "%FF", "%C0%AF", "%ED%A0%80", "%F4%90%80%80"]) {
        var failed = false
        try { decodeComponent(text) } catch (_: IllegalArgumentException) { failed = true }
        @Assert(failed)
    }
}

@Test
func queryDuplicatesAndPrototypeKeys() {
    let params = parseQuery("?a=first&a=second&plus=a+b&encoded=%E4%B8%AD&bad=%FF&__proto__=x&constructor=y&toString=z")
    @Assert(params.size, 3)
    @Assert(params.get("a"), Some("first"))
    @Assert(params.get("plus"), Some("a b"))
    @Assert(params.get("encoded"), Some("\u{4E2D}"))
    @Assert(params.contains("bad"), false)
    @Assert(params.contains("__proto__"), false)
    @Assert(stringifyQuery(params), "a=first&plus=a%20b&encoded=%E4%B8%AD")
    @Assert(parseQuery("?flag&x?value#hash").get("x"), Some("?value#hash"))
    @Assert(parseQuery("=value").get("value"), Some(""))
}

@Test
func queryOrderingAndMutation() {
    let params = QueryParams([("10", "ten"), ("2", "two"), ("a", "first"), ("01", "leading"), ("0", "zero")])
    @Assert(stringifyQuery(params), "0=zero&2=two&10=ten&a=first&01=leading")
    params.set("a", "new").set("count", 0).set("enabled", false).set("empty", Option<String>.None)
    @Assert(params.get("count"), Some("0"))
    @Assert(params.get("enabled"), Some("false"))
    @Assert(params.get("empty"), Some(""))
    @Assert(params.remove("2"), true)
    @Assert(params.remove("missing"), false)
    params.set("2", "again")
    @Assert(params.entries()[1][0], "2")
    @Assert(Url.qs.stringify(QueryParams([("a", "b")]), true), "?a=b")
    @Assert(Url.qs.stringify(QueryParams(), prefix: "?"), "")
    @Assert(Query.Raw("?a=b").toString(), "?a=b")
}

文件 08:src/url_test.cj

下面 13 个测试函数补齐主对象与辅助接口。

测试函数验证的板块
absoluteFields全字段、origin、href、空构造
queryModesAndCallbacksRaw/Params、自定义 parser/stringify、href 不被 toString 改写
relativeResolution相对目录、..、根路径、空输入、基地址继承
protocolExtractionAndControlsscheme、slash 数量、CR/LF/HT、Unicode 空白、尾空白保留
casingSlashesAndOrigins主机大小写、反斜杠、特殊协议 origin、宽松 host
portsAndIPAddresses默认端口、IPv4/IPv6、JS 进制与指数数值边界
fileAndOpaqueURLsfile 各 slash 形式、Windows drive、mailto/sip/data
authentication最后 @、首冒号、认证规范编码、非法认证拒绝
setterPropagationhost/hostname/port、protocol、path、hash、query 联动
setterAuthenticationAndCallbacks认证 setter、查询重载、无效动态 flag、未知字段
releaseSpecificSetProtocol发布版 protocol 不补字段冒号、flag 控制 slashes
explicitAndBlobLocations对象基地址、缺省 slashes、快照元数据、legacy blob 解码
publicOverloadsAndCallbackErrors所有公开便利入口及 parser/setter/stringify 异常向外传递
// SPDX-License-Identifier: MIT
package url_parse

import std.unittest.*
import std.unittest.testmacro.*

@Test
func absoluteFields() {
    let url = Url("https://www.example.com:8080/path?param1=value1&param2=value2#section")
    @Assert(url.protocol, "https:")
    @Assert(url.slashes, true)
    @Assert(url.host, "www.example.com:8080")
    @Assert(url.hostname, "www.example.com")
    @Assert(url.port, "8080")
    @Assert(url.pathname, "/path")
    @Assert(url.query.toString(), "?param1=value1&param2=value2")
    @Assert(url.hash, "#section")
    @Assert(url.origin, "https://www.example.com:8080")
    @Assert(url.href, url.toString())
    @Assert(Url().href, "")
    @Assert(parse("/path/to/resource").hostname, "")
}

@Test
func queryModesAndCallbacks() {
    let raw = Url("https://x/a?colors=red,green,blue")
    @Assert(raw.query.toString(), "?colors=red,green,blue")
    let parsed = parse("http://google.com/?foo=bar", parseQuery: true)
    match (parsed.query) {
        case Query.Params(params) => @Assert(params.get("foo"), Some("bar"))
        case _ => @Assert(false)
    }
    @Assert(Url("http://google.com/", parseQuery: true).href, "http://google.com/")
    let custom = Url("http://google.com/?foo=bar", { _: String => Query.Raw("1337") })
    @Assert(custom.query.toString(), "1337")
    @Assert(custom.href, "http://google.com/?1337")
    @Assert(parsed.toString({ _: QueryParams => "lolcakes" }), "http://google.com/?lolcakes")
    @Assert(parsed.toString({ _: QueryParams => "?already=prefixed" }), "http://google.com/?already=prefixed")
    @Assert(parsed.toString({ _: QueryParams => "" }), "http://google.com/")
    @Assert(parsed.href, "http://google.com/?foo=bar")
}

@Test
func relativeResolution() {
    let base = Url("https://user:pass@Example.com:8443/a/b/c?old=1#old")
    @Assert(Url("../d?new=2#new", base).href, "https://example.com:8443/a/d?new=2#new")
    @Assert(Url("", base).href, "https://example.com:8443/a/b/c")
    @Assert(Url("?new=2", base).pathname, "/a/b/c")
    @Assert(Url("/root", base).href, "https://example.com:8443/root")
    @Assert(Url("../../../../d", base).pathname, "/d")
    @Assert(Url(".", base).pathname, "/a/b/")
    @Assert(Url("..", base).pathname, "/a/")
    @Assert(Url("//other/path", base).href, "https://other/path")
    @Assert(Url("//example.com").href, "//example.com")
    @Assert(Url("http://other", base).port, "")
    @Assert(parse("/foo?foo=bar", base: "http://google.com").href, "http://google.com/foo?foo=bar")
}

@Test
func protocolExtractionAndControls() {
    let e = Url.extractProtocol("jav\n\rasc\nript\r:/\t/fo\no")
    @Assert(e.protocol, "javascript:")
    @Assert(e.slashes, true)
    @Assert(e.slashesCount, 2)
    @Assert(e.rest, "foo")
    @Assert(extractProtocol("//foo/bar").rest, "//foo/bar")
    @Assert(extractProtocol("/foo").slashesCount, 1)
    @Assert(extractProtocol("").protocol, "")
    @Assert(extractProtocol("foo\u{000B}bar\u{000C}baz\u{2028}qux\u{2029}").rest,
        "foo\u{000B}bar\u{000C}baz\u{2028}qux\u{2029}")
    for (i in 0..33) {
        let prefix = String([Rune(UInt32(i))])
        @Assert(Url.trimLeft(prefix + prefix + "lol"), "lol")
    }
    @Assert(trimLeft("\u{FEFF}\u{3000}\u{00A0}text "), "text ")
    @Assert(Url("http://example.com/a ").pathname, "/a ")
}

@Test
func casingSlashesAndOrigins() {
    @Assert(Url("HTTP://fOo.eXaMPle.com/Y/Z").href, "http://foo.example.com/Y/Z")
    for (address in ["https:github.com/foo/bar", "https:/github.com/foo/bar",
        "https:\\github.com/foo/bar", "https:\\/github.com/foo/bar", "https:/\\/\\github.com/foo/bar"]) {
        @Assert(Url(address).href, "https://github.com/foo/bar")
    }
    @Assert(Url("http://google.com:80\\@yahoo.com/#what\\is going on").hostname, "google.com")
    @Assert(Url("http://google.com:80\\@yahoo.com/#what\\is going on").hash, "#what\\is going on")
    @Assert(Url("\\\\\\\\what-is-up.com", base: "http://google.com").host, "what-is-up.com")
    @Assert(Url("http://").origin, "null")
    @Assert(Url("foo://example.com/pathname").origin, "null")
    @Assert(Url("http://x.y.com+a/b/c").hostname, "x.y.com+a")
    @Assert(Url("http://x/path?that's#all, folks").hash, "#all, folks")
}

@Test
func portsAndIPAddresses() {
    for ((scheme, number) in [("http", "80"), ("https", "443"), ("ws", "80"),
        ("wss", "443"), ("ftp", "21"), ("gopher", "70")]) {
        let url = Url(scheme + "://example.com:" + number + "/path")
        @Assert(url.port, "")
        @Assert(url.host, "example.com")
    }
    @Assert(Url("http://example.com:00080").href, "http://example.com/")
    @Assert(Url("http://example.com:0").port, "")
    @Assert(Url("http://example.com:").href, "http://example.com/")
    let ipv6 = Url("http://user:pass@[1080:0:0:0:8:800:200C:417A]:61616/foo?q=z")
    @Assert(ipv6.hostname, "[1080:0:0:0:8:800:200c:417a]")
    @Assert(ipv6.port, "61616")
    @Assert(ipv6.username, "user")
    @Assert(Url("http://127.0.0.1:8080/").host, "127.0.0.1:8080")
    @Assert(requiresPort("80", "http:"), false)
    @Assert(requiresPort("0x50", "http:"), false)
    @Assert(Url("").set("port", "8080").port, "8080")
    for (number in ["0b1010000", "0o120", "+80", "8e1", "\u{00A0}80\u{00A0}"]) {
        @Assert(requiresPort(number, "http:"), false)
        @Assert(requiresPort(number, "https:"), true)
    }
    for (number in ["inf", "-0x50", "0x", "1_000", "1e", "NaN"]) {
        @Assert(requiresPort(number, "https:"), false)
    }
    @Assert(requiresPort("Infinity", "http:"), true)
    @Assert(requiresPort("1e309", "http:"), true)
    @Assert(requiresPort("-1e309", "http:"), true)
    @Assert(requiresPort("1e9999999999999999999999999", "http:"), true)
    @Assert(requiresPort("1e-9999999999999999999999999", "http:"), false)
}

@Test
func fileAndOpaqueURLs() {
    for (slashes in ["", "/", "//", "///"]) { @Assert(Url("file:" + slashes).href, "file:///") }
    @Assert(Url("file:////").href, "file:////")
    @Assert(Url("file://c:\\foo\\bar\\").href, "file:///c:/foo/bar/")
    @Assert(Url("file://host/file").href, "file://host/file")
    @Assert(Url("foo/bar", base: "file:///baz/").href, "file:///baz/foo/bar")
    @Assert(Url("file:///a").origin, "null")
    @Assert(Url("mailto:user@example.com").pathname, "user@example.com")
    @Assert(Url("sip:alice@atlanta.com").href, "sip:alice@atlanta.com")
    @Assert(Url("data:text/plain,hello").host, "")
}

@Test
func authentication() {
    let url = Url("http://USER:p%40ss%3Aword@example.com/")
    @Assert(url.username, "USER")
    @Assert(url.password, "p%40ss%3Aword")
    @Assert(url.auth, "USER:p%40ss%3Aword")
    @Assert(Url("http://a@b:p@ss@example.com/").auth, "a%40b:p%40ss")
    @Assert(Url("http://user@example.com/").password, "")
    @Assert(Url("http://@/path").href, "http://@/path")
    @Assert(Url("http://example.com/@user").username, "")
    var failed = false
    try { Url("http://%ZZ@example.com") } catch (_: IllegalArgumentException) { failed = true }
    @Assert(failed)
    failed = false
    try { Url("http://%C0%AF@example.com") } catch (_: IllegalArgumentException) { failed = true }
    @Assert(failed)
}

@Test
func setterPropagation() {
    let url = Url("http://google.com:808/?foo=bar")
    url.set("hostname", "YAHOO.COM")
    @Assert(url.host, "yahoo.com:808")
    @Assert(url.href, "http://yahoo.com:808/?foo=bar")
    url.set("port", 80)
    @Assert(url.host, "yahoo.com")
    @Assert(url.port, "")
    url.set("host", "[56h7::1]:8000")
    @Assert(url.hostname, "[56h7::1]")
    @Assert(url.port, "8000")
    url.set("host", "[56h7::1]")
    @Assert(url.port, "")
    url.set("pathname", "new/path").set("hash", "top")
    @Assert(url.pathname, "/new/path")
    @Assert(url.hash, "#top")
    url.set("protocol", "HTTPS:")
    @Assert(url.origin, "https://[56h7::1]")
    url.set("query", "newParam=value")
    @Assert(url.query.toString(), "newParam=value")
    url.set("query", "").set("hash", "")
    @Assert(url.href, "https://[56h7::1]/new/path")
}

@Test
func setterAuthenticationAndCallbacks() {
    let url = Url("http://user:old@example.com/")
    url.set("username", "a@b").set("password", "p:word")
    @Assert(url.auth, "a%40b:p%3Aword")
    url.set("auth", "new")
    @Assert(url.password, "p%3Aword")
    url.set("auth", "encoded:%E4%B8%AD")
    @Assert(url.password, "%E4%B8%AD")
    url.set("query", "foo=bar", { _: String => Query.Raw("1337") })
    @Assert(url.query.toString(), "1337")
    url.set("query", QueryParams([("foo", "bar"), ("count", "2")]))
    @Assert(url.href, "http://encoded:%E4%B8%AD@example.com/?foo=bar&count=2")
    url.set("query", Query.Raw("?custom=raw"))
    @Assert(url.query.toString(), "?custom=raw")
    let old = url.href
    url.set("unknown", "ignored")
    @Assert(url.href, old)
    var failed = false
    try { url.set("query", "a=1", flag: true) } catch (_: IllegalArgumentException) { failed = true }
    @Assert(failed)
    url.set("query", "", flag: true)
    @Assert(url.query.toString(), "")
}

@Test
func releaseSpecificSetProtocol() {
    let url = Url("sip:alice@atlanta.com")
    url.set("protocol", "https")
    @Assert(url.protocol, "https")
    @Assert(url.href, "https://alice@atlanta.com")
    @Assert(url.origin, "null")
    url.set("protocol", "mailto", flag: true)
    @Assert(url.slashes, false)
    @Assert(url.href, "mailto:alice@atlanta.com")
}

@Test
func explicitAndBlobLocations() {
    let base = UrlLocation(protocol: "https:", host: "example.com:8443", hostname: "example.com",
        port: "8443", pathname: "/a/b", href: "https://example.com:8443/a/b?ignored=1#ignored")
    @Assert(Url("c", base).href, "https://example.com:8443/a/c")
    @Assert(base.slashes, None)
    @Assert(Url.location(base).slashes, Some(true))
    @Assert(Url.location(UrlLocation(href: "HTTP://example.com/a")).slashes, Some(true))
    @Assert(Url.location(UrlLocation(href: " http://example.com/a")).slashes, Some(false))
    @Assert(Url.location(UrlLocation(href: "http://example.com/a", slashes: Some(false))).slashes, Some(false))
    let blob = UrlLocation(protocol: "blob:", pathname: "https%3A//gist.github.com/id")
    @Assert(Url("/unshiftio/url-parse", blob).href, "https://gist.github.com/unshiftio/url-parse")
    @Assert(location(Url("https://x/a?ignored#ignored")).pathname, "/a")
    @Assert(Url.location("http://x/a").host, "x")
    @Assert(Url.location("http://u:p@x/a").auth, "u:p")
    @Assert(Url.location("http://u:p@x/a").origin, "http://x")
    @Assert(Url("x", base: "blob:https://example.com/id").href, "blob:x")
    let unicodeBlob = UrlLocation(protocol: "blob:", pathname: "https%3A//example.com/%uD83D%uDE80")
    @Assert(Url("", unicodeBlob).pathname, "/\u{1F680}")
    var failed = false
    try { Url.location(UrlLocation(protocol: "blob:", pathname: "http%3A//x/%uD800")) }
    catch (_: IllegalArgumentException) { failed = true }
    @Assert(failed)
}

@Test
func publicOverloadsAndCallbackErrors() {
    @Assert(location().slashes, Some(false))
    @Assert(Url.location().host, "")
    let base = Url("http://user:pass@example.com/a/b?old#old")
    let loc = location(base)
    @Assert(loc.auth, "user:pass")
    @Assert(loc.username, "user")
    @Assert(loc.password, "pass")
    @Assert(loc.origin, "http://example.com")
    @Assert(location(loc).href, base.href)
    @Assert(parse().href, "")
    @Assert(parse("c", base).href, "http://example.com/a/c")
    @Assert(parse("c", loc).href, "http://example.com/a/c")
    @Assert(parse("c", { _: String => Query.Raw("1337") }, base: base.href).href,
        "http://example.com/a/c?1337")
    @Assert(Url("c", base, { _: String => Query.Raw("1337") }).href,
        "http://example.com/a/c?1337")
    @Assert(Url("c", loc, { _: String => Query.Params(QueryParams()) }).href,
        "http://example.com/a/c")
    @Assert(extractProtocol("////host", loc).rest, "host")
    let extracted = ProtocolExtract("x:", false, 0, "rest")
    @Assert(extracted.rest, "rest")
    let qs = QueryString()
    @Assert(qs.stringify(qs.parse("a=1")), "a=1")
    @Assert(qs.stringify(qs.parse("a=1"), false), "a=1")
    let url = Url("http://example.com/")
    url.set("hostname", Query.Raw("ignored"))
    @Assert(url.hostname, "example.com")
    url.set("pathname", "child", { _: String => Query.Raw("unused") })
    @Assert(url.pathname, "/child")
    var failed = false
    try {
        Url("http://example.com/", { _: String => throw IllegalArgumentException("callback") })
    } catch (e: IllegalArgumentException) { failed = e.message == "callback" }
    @Assert(failed)
    failed = false
    try {
        url.set("query", "a=1", { _: String => throw IllegalArgumentException("setter") })
    } catch (e: IllegalArgumentException) { failed = e.message == "setter" }
    @Assert(failed)
    failed = false
    try {
        Url("http://example.com/?a=1", parseQuery: true).toString(
            { _: QueryParams => throw IllegalArgumentException("stringify") })
    } catch (e: IllegalArgumentException) { failed = e.message == "stringify" }
    @Assert(failed)
}

检查点 B:运行原生回归。

cjpm test --no-color --no-progress

应看到 TOTAL: 16、PASSED: 16、FAILED: 0。零测试不是通过,要核对文件名、目录及宏导入。修改任何兼容规则时先加一个最小回归断言,再运行这些测试。

9. 用独立应用验证公开 API

同包单测可以调用内部函数,独立应用只能导入公开 API,因此必须补这一层验证。下面两个文件构成真正的 cjpm executable,不是散落的代码片段。

文件 09:examples/demo/cjpm.toml

输出类型改为 executable,包名改为 url_parse_demo。依赖的 path = "../.." 从这个 cjpm.toml 所在目录计算,回到库根目录;不是相对当前终端位置。应用的 1.0.0 是示例自己的版本。

[package]
  cjc-version = "1.0.5"
  name = "url_parse_demo"
  description = "nothing here"
  version = "1.0.0"
  target-dir = ""
  output-type = "executable"
  compile-option = ""
  override-compile-option = ""
  link-option = ""
  package-configuration = {}

[dependencies]
  url_parse = { path = "../.." }

文件 10:examples/demo/src/main.cj

import url_parse.* 导入公开库;main(): Int64 是程序入口,返回 0 表示成功。第一段构造 Params 模式并打印字段;第二段链式 set 验证 query 改写与 href 联动;第三段验证相对地址;最后传 lambda 返回 Raw 查询,验证自定义 parser。

${...} 是字符串插值。set 返回 this,所以可以继续 .set(...);Query 实现 ToString,所以打印 url.query 会走对应的序列化。

// SPDX-License-Identifier: MIT
package url_parse_demo

import url_parse.*

main(): Int64 {
    let url = Url("https://www.example.com:8080/path?param1=value1&param2=value2#section", parseQuery: true)
    println("protocol: ${url.protocol}")
    println("hostname: ${url.hostname}")
    println("port: ${url.port}")
    println("pathname: ${url.pathname}")
    println("query: ${url.query}")
    println("hash: ${url.hash}")
    url.set("protocol", "https:").set("query", "newParam=value")
    println("updated: ${url.href}")
    println("relative: ${Url("../next", base: "https://example.com/a/b/").href}")
    let custom = Url("https://example.com/?a=1", {_: String => Query.Raw("custom=42")})
    println("custom: ${custom}")
    return 0
}

检查点 C:进入应用执行,再返回根目录。

Push-Location examples/demo
cjpm run
Pop-Location

关键输出应为:

updated: https://www.example.com:8080/path?newParam=value
relative: https://example.com/a/next
custom: https://example.com/?custom=42

再理解三个容易踩坑的用法,不需要另建生产文件:

let parsed = Url("https://example.com/?name=A+B", parseQuery: true)
match (parsed.query) {
    case Query.Params(params) =>
        params.set("page", 2)
        parsed.set("query", params)
    case Query.Raw(_) => ()
}
println(parsed.href)
// https://example.com/?name=A%20B&page=2

let authenticated = Url("https://example.com/")
authenticated.set("username", "a@b").set("password", "p:word")
println(authenticated.auth)
// a%40b:p%3Aword

let base = UrlLocation(protocol: "https:", slashes: Some(true),
    host: "example.com", hostname: "example.com", pathname: "/a/b")
println(Url("c", base).href)
// https://example.com/a/c

第一段直接改 Params 后要再 set("query", params) 刷新 href;第二段 username/password 接收未编码文本,所以不要传 %40 期望得到 @;第三段对象基地址提供了实际字段,不只填 href。这些片段可放入 demo 的 main 内试验,正式验收时保持上面的文件 10 完整版本。

10. 离线参考实现:期望值不能由自己计算

如果测试期望值也是调用仓颉实现算出来的,两边可能犯同一个错。因此差分链路分开:未修改的 JS 上游产生期望 → 以十六进制 TSV 保存 → 独立仓颉程序计算实际结果 → 逐字段比较。

这里的 JavaScript 只用于测试,不会进生产依赖。本节完整保留公开 MIT 源码及许可证,不要求读者下载 npm 包。node_modules 是 Node 的本地模块查找目录,不意味着要联网安装。请严格按路径创建,不能把几份 index.js 都放到同一个目录。

文件 11:test/reference/url-parse/package.json

这是上游的完整包描述。生成器 require('../test/reference/url-parse') 会按 main 找到 index.js。scripts、开发依赖和浏览器构建信息是保留的上游元数据;本项目不执行它们,也不需要安装它们。dependencies 记录参考模块关系,我们直接提供相应的锁定文件。

{
  "name": "url-parse",
  "version": "1.5.10",
  "description": "Small footprint URL parser that works seamlessly across Node.js and browser environments",
  "main": "index.js",
  "scripts": {
    "browserify": "rm -rf dist && mkdir -p dist && browserify index.js -s URLParse -o dist/url-parse.js",
    "minify": "uglifyjs dist/url-parse.js --source-map -cm -o dist/url-parse.min.js",
    "test": "c8 --reporter=lcov --reporter=text mocha test/test.js",
    "test-browser": "node test/browser.js",
    "prepublishOnly": "npm run browserify && npm run minify",
    "watch": "mocha --watch test/test.js"
  },
  "files": [
    "index.js",
    "dist"
  ],
  "repository": {
    "type": "git",
    "url": "https://github.com/unshiftio/url-parse.git"
  },
  "keywords": [
    "URL",
    "parser",
    "uri",
    "url",
    "parse",
    "query",
    "string",
    "querystring",
    "stringify"
  ],
  "author": "Arnout Kazemier",
  "license": "MIT",
  "dependencies": {
    "querystringify": "^2.1.1",
    "requires-port": "^1.0.0"
  },
  "devDependencies": {
    "assume": "^2.2.0",
    "browserify": "^17.0.0",
    "c8": "^7.3.1",
    "mocha": "^9.0.3",
    "pre-commit": "^1.2.2",
    "sauce-browsers": "^2.0.0",
    "sauce-test": "^1.3.3",
    "uglify-js": "^3.5.7"
  }
}

文件 12:test/reference/url-parse/index.js

阅读这份参考时,关注它和仓颉各层的对应,而不是学习另一个 UI 工程:顶部 rules 数组规定 fragment/query/path/auth/host/port/hostname 的切分顺序;trimLeft、extractProtocol、resolve 对应 helpers;lolcation 对应 location;Url 构造器对应 parseAddress;set 对应联动 setter;toString 对应输出;文件末尾 exports 对应公开门面。

它的动态 parser 参数允许 Boolean 或 Function,location 可以是普通 JS 对象,查询对象按 JS 的键枚举顺序输出。前面仓颉的枚举、重载、Option 和有序键表就是为这些动态行为建立静态契约。参考代码不做“顺手优化”,否则测试基线会变。

'use strict';

var required = require('requires-port')
  , qs = require('querystringify')
  , controlOrWhitespace = /^[\x00-\x20\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]+/
  , CRHTLF = /[\n\r\t]/g
  , slashes = /^[A-Za-z][A-Za-z0-9+-.]*:\/\//
  , port = /:\d+$/
  , protocolre = /^([a-z][a-z0-9.+-]*:)?(\/\/)?([\\/]+)?([\S\s]*)/i
  , windowsDriveLetter = /^[a-zA-Z]:/;

/**
 * Remove control characters and whitespace from the beginning of a string.
 *
 * @param {Object|String} str String to trim.
 * @returns {String} A new string representing `str` stripped of control
 *     characters and whitespace from its beginning.
 * @public
 */
function trimLeft(str) {
  return (str ? str : '').toString().replace(controlOrWhitespace, '');
}

/**
 * These are the parse rules for the URL parser, it informs the parser
 * about:
 *
 * 0. The char it Needs to parse, if it's a string it should be done using
 *    indexOf, RegExp using exec and NaN means set as current value.
 * 1. The property we should set when parsing this value.
 * 2. Indication if it's backwards or forward parsing, when set as number it's
 *    the value of extra chars that should be split off.
 * 3. Inherit from location if non existing in the parser.
 * 4. `toLowerCase` the resulting value.
 */
var rules = [
  ['#', 'hash'],                        // Extract from the back.
  ['?', 'query'],                       // Extract from the back.
  function sanitize(address, url) {     // Sanitize what is left of the address
    return isSpecial(url.protocol) ? address.replace(/\\/g, '/') : address;
  },
  ['/', 'pathname'],                    // Extract from the back.
  ['@', 'auth', 1],                     // Extract from the front.
  [NaN, 'host', undefined, 1, 1],       // Set left over value.
  [/:(\d*)$/, 'port', undefined, 1],    // RegExp the back.
  [NaN, 'hostname', undefined, 1, 1]    // Set left over.
];

/**
 * These properties should not be copied or inherited from. This is only needed
 * for all non blob URL's as a blob URL does not include a hash, only the
 * origin.
 *
 * @type {Object}
 * @private
 */
var ignore = { hash: 1, query: 1 };

/**
 * The location object differs when your code is loaded through a normal page,
 * Worker or through a worker using a blob. And with the blobble begins the
 * trouble as the location object will contain the URL of the blob, not the
 * location of the page where our code is loaded in. The actual origin is
 * encoded in the `pathname` so we can thankfully generate a good "default"
 * location from it so we can generate proper relative URL's again.
 *
 * @param {Object|String} loc Optional default location object.
 * @returns {Object} lolcation object.
 * @public
 */
function lolcation(loc) {
  var globalVar;

  if (typeof window !== 'undefined') globalVar = window;
  else if (typeof global !== 'undefined') globalVar = global;
  else if (typeof self !== 'undefined') globalVar = self;
  else globalVar = {};

  var location = globalVar.location || {};
  loc = loc || location;

  var finaldestination = {}
    , type = typeof loc
    , key;

  if ('blob:' === loc.protocol) {
    finaldestination = new Url(unescape(loc.pathname), {});
  } else if ('string' === type) {
    finaldestination = new Url(loc, {});
    for (key in ignore) delete finaldestination[key];
  } else if ('object' === type) {
    for (key in loc) {
      if (key in ignore) continue;
      finaldestination[key] = loc[key];
    }

    if (finaldestination.slashes === undefined) {
      finaldestination.slashes = slashes.test(loc.href);
    }
  }

  return finaldestination;
}

/**
 * Check whether a protocol scheme is special.
 *
 * @param {String} The protocol scheme of the URL
 * @return {Boolean} `true` if the protocol scheme is special, else `false`
 * @private
 */
function isSpecial(scheme) {
  return (
    scheme === 'file:' ||
    scheme === 'ftp:' ||
    scheme === 'http:' ||
    scheme === 'https:' ||
    scheme === 'ws:' ||
    scheme === 'wss:'
  );
}

/**
 * @typedef ProtocolExtract
 * @type Object
 * @property {String} protocol Protocol matched in the URL, in lowercase.
 * @property {Boolean} slashes `true` if protocol is followed by "//", else `false`.
 * @property {String} rest Rest of the URL that is not part of the protocol.
 */

/**
 * Extract protocol information from a URL with/without double slash ("//").
 *
 * @param {String} address URL we want to extract from.
 * @param {Object} location
 * @return {ProtocolExtract} Extracted information.
 * @private
 */
function extractProtocol(address, location) {
  address = trimLeft(address);
  address = address.replace(CRHTLF, '');
  location = location || {};

  var match = protocolre.exec(address);
  var protocol = match[1] ? match[1].toLowerCase() : '';
  var forwardSlashes = !!match[2];
  var otherSlashes = !!match[3];
  var slashesCount = 0;
  var rest;

  if (forwardSlashes) {
    if (otherSlashes) {
      rest = match[2] + match[3] + match[4];
      slashesCount = match[2].length + match[3].length;
    } else {
      rest = match[2] + match[4];
      slashesCount = match[2].length;
    }
  } else {
    if (otherSlashes) {
      rest = match[3] + match[4];
      slashesCount = match[3].length;
    } else {
      rest = match[4]
    }
  }

  if (protocol === 'file:') {
    if (slashesCount >= 2) {
      rest = rest.slice(2);
    }
  } else if (isSpecial(protocol)) {
    rest = match[4];
  } else if (protocol) {
    if (forwardSlashes) {
      rest = rest.slice(2);
    }
  } else if (slashesCount >= 2 && isSpecial(location.protocol)) {
    rest = match[4];
  }

  return {
    protocol: protocol,
    slashes: forwardSlashes || isSpecial(protocol),
    slashesCount: slashesCount,
    rest: rest
  };
}

/**
 * Resolve a relative URL pathname against a base URL pathname.
 *
 * @param {String} relative Pathname of the relative URL.
 * @param {String} base Pathname of the base URL.
 * @return {String} Resolved pathname.
 * @private
 */
function resolve(relative, base) {
  if (relative === '') return base;

  var path = (base || '/').split('/').slice(0, -1).concat(relative.split('/'))
    , i = path.length
    , last = path[i - 1]
    , unshift = false
    , up = 0;

  while (i--) {
    if (path[i] === '.') {
      path.splice(i, 1);
    } else if (path[i] === '..') {
      path.splice(i, 1);
      up++;
    } else if (up) {
      if (i === 0) unshift = true;
      path.splice(i, 1);
      up--;
    }
  }

  if (unshift) path.unshift('');
  if (last === '.' || last === '..') path.push('');

  return path.join('/');
}

/**
 * The actual URL instance. Instead of returning an object we've opted-in to
 * create an actual constructor as it's much more memory efficient and
 * faster and it pleases my OCD.
 *
 * It is worth noting that we should not use `URL` as class name to prevent
 * clashes with the global URL instance that got introduced in browsers.
 *
 * @constructor
 * @param {String} address URL we want to parse.
 * @param {Object|String} [location] Location defaults for relative paths.
 * @param {Boolean|Function} [parser] Parser for the query string.
 * @private
 */
function Url(address, location, parser) {
  address = trimLeft(address);
  address = address.replace(CRHTLF, '');

  if (!(this instanceof Url)) {
    return new Url(address, location, parser);
  }

  var relative, extracted, parse, instruction, index, key
    , instructions = rules.slice()
    , type = typeof location
    , url = this
    , i = 0;

  //
  // The following if statements allows this module two have compatibility with
  // 2 different API:
  //
  // 1. Node.js's `url.parse` api which accepts a URL, boolean as arguments
  //    where the boolean indicates that the query string should also be parsed.
  //
  // 2. The `URL` interface of the browser which accepts a URL, object as
  //    arguments. The supplied object will be used as default values / fall-back
  //    for relative paths.
  //
  if ('object' !== type && 'string' !== type) {
    parser = location;
    location = null;
  }

  if (parser && 'function' !== typeof parser) parser = qs.parse;

  location = lolcation(location);

  //
  // Extract protocol information before running the instructions.
  //
  extracted = extractProtocol(address || '', location);
  relative = !extracted.protocol && !extracted.slashes;
  url.slashes = extracted.slashes || relative && location.slashes;
  url.protocol = extracted.protocol || location.protocol || '';
  address = extracted.rest;

  //
  // When the authority component is absent the URL starts with a path
  // component.
  //
  if (
    extracted.protocol === 'file:' && (
      extracted.slashesCount !== 2 || windowsDriveLetter.test(address)) ||
    (!extracted.slashes &&
      (extracted.protocol ||
        extracted.slashesCount < 2 ||
        !isSpecial(url.protocol)))
  ) {
    instructions[3] = [/(.*)/, 'pathname'];
  }

  for (; i < instructions.length; i++) {
    instruction = instructions[i];

    if (typeof instruction === 'function') {
      address = instruction(address, url);
      continue;
    }

    parse = instruction[0];
    key = instruction[1];

    if (parse !== parse) {
      url[key] = address;
    } else if ('string' === typeof parse) {
      index = parse === '@'
        ? address.lastIndexOf(parse)
        : address.indexOf(parse);

      if (~index) {
        if ('number' === typeof instruction[2]) {
          url[key] = address.slice(0, index);
          address = address.slice(index + instruction[2]);
        } else {
          url[key] = address.slice(index);
          address = address.slice(0, index);
        }
      }
    } else if ((index = parse.exec(address))) {
      url[key] = index[1];
      address = address.slice(0, index.index);
    }

    url[key] = url[key] || (
      relative && instruction[3] ? location[key] || '' : ''
    );

    //
    // Hostname, host and protocol should be lowercased so they can be used to
    // create a proper `origin`.
    //
    if (instruction[4]) url[key] = url[key].toLowerCase();
  }

  //
  // Also parse the supplied query string in to an object. If we're supplied
  // with a custom parser as function use that instead of the default build-in
  // parser.
  //
  if (parser) url.query = parser(url.query);

  //
  // If the URL is relative, resolve the pathname against the base URL.
  //
  if (
      relative
    && location.slashes
    && url.pathname.charAt(0) !== '/'
    && (url.pathname !== '' || location.pathname !== '')
  ) {
    url.pathname = resolve(url.pathname, location.pathname);
  }

  //
  // Default to a / for pathname if none exists. This normalizes the URL
  // to always have a /
  //
  if (url.pathname.charAt(0) !== '/' && isSpecial(url.protocol)) {
    url.pathname = '/' + url.pathname;
  }

  //
  // We should not add port numbers if they are already the default port number
  // for a given protocol. As the host also contains the port number we're going
  // override it with the hostname which contains no port number.
  //
  if (!required(url.port, url.protocol)) {
    url.host = url.hostname;
    url.port = '';
  }

  //
  // Parse down the `auth` for the username and password.
  //
  url.username = url.password = '';

  if (url.auth) {
    index = url.auth.indexOf(':');

    if (~index) {
      url.username = url.auth.slice(0, index);
      url.username = encodeURIComponent(decodeURIComponent(url.username));

      url.password = url.auth.slice(index + 1);
      url.password = encodeURIComponent(decodeURIComponent(url.password))
    } else {
      url.username = encodeURIComponent(decodeURIComponent(url.auth));
    }

    url.auth = url.password ? url.username +':'+ url.password : url.username;
  }

  url.origin = url.protocol !== 'file:' && isSpecial(url.protocol) && url.host
    ? url.protocol +'//'+ url.host
    : 'null';

  //
  // The href is just the compiled result.
  //
  url.href = url.toString();
}

/**
 * This is convenience method for changing properties in the URL instance to
 * insure that they all propagate correctly.
 *
 * @param {String} part          Property we need to adjust.
 * @param {Mixed} value          The newly assigned value.
 * @param {Boolean|Function} fn  When setting the query, it will be the function
 *                               used to parse the query.
 *                               When setting the protocol, double slash will be
 *                               removed from the final url if it is true.
 * @returns {URL} URL instance for chaining.
 * @public
 */
function set(part, value, fn) {
  var url = this;

  switch (part) {
    case 'query':
      if ('string' === typeof value && value.length) {
        value = (fn || qs.parse)(value);
      }

      url[part] = value;
      break;

    case 'port':
      url[part] = value;

      if (!required(value, url.protocol)) {
        url.host = url.hostname;
        url[part] = '';
      } else if (value) {
        url.host = url.hostname +':'+ value;
      }

      break;

    case 'hostname':
      url[part] = value;

      if (url.port) value += ':'+ url.port;
      url.host = value;
      break;

    case 'host':
      url[part] = value;

      if (port.test(value)) {
        value = value.split(':');
        url.port = value.pop();
        url.hostname = value.join(':');
      } else {
        url.hostname = value;
        url.port = '';
      }

      break;

    case 'protocol':
      url.protocol = value.toLowerCase();
      url.slashes = !fn;
      break;

    case 'pathname':
    case 'hash':
      if (value) {
        var char = part === 'pathname' ? '/' : '#';
        url[part] = value.charAt(0) !== char ? char + value : value;
      } else {
        url[part] = value;
      }
      break;

    case 'username':
    case 'password':
      url[part] = encodeURIComponent(value);
      break;

    case 'auth':
      var index = value.indexOf(':');

      if (~index) {
        url.username = value.slice(0, index);
        url.username = encodeURIComponent(decodeURIComponent(url.username));

        url.password = value.slice(index + 1);
        url.password = encodeURIComponent(decodeURIComponent(url.password));
      } else {
        url.username = encodeURIComponent(decodeURIComponent(value));
      }
  }

  for (var i = 0; i < rules.length; i++) {
    var ins = rules[i];

    if (ins[4]) url[ins[1]] = url[ins[1]].toLowerCase();
  }

  url.auth = url.password ? url.username +':'+ url.password : url.username;

  url.origin = url.protocol !== 'file:' && isSpecial(url.protocol) && url.host
    ? url.protocol +'//'+ url.host
    : 'null';

  url.href = url.toString();

  return url;
}

/**
 * Transform the properties back in to a valid and full URL string.
 *
 * @param {Function} stringify Optional query stringify function.
 * @returns {String} Compiled version of the URL.
 * @public
 */
function toString(stringify) {
  if (!stringify || 'function' !== typeof stringify) stringify = qs.stringify;

  var query
    , url = this
    , host = url.host
    , protocol = url.protocol;

  if (protocol && protocol.charAt(protocol.length - 1) !== ':') protocol += ':';

  var result =
    protocol +
    ((url.protocol && url.slashes) || isSpecial(url.protocol) ? '//' : '');

  if (url.username) {
    result += url.username;
    if (url.password) result += ':'+ url.password;
    result += '@';
  } else if (url.password) {
    result += ':'+ url.password;
    result += '@';
  } else if (
    url.protocol !== 'file:' &&
    isSpecial(url.protocol) &&
    !host &&
    url.pathname !== '/'
  ) {
    //
    // Add back the empty userinfo, otherwise the original invalid URL
    // might be transformed into a valid one with `url.pathname` as host.
    //
    result += '@';
  }

  //
  // Trailing colon is removed from `url.host` when it is parsed. If it still
  // ends with a colon, then add back the trailing colon that was removed. This
  // prevents an invalid URL from being transformed into a valid one.
  //
  if (host[host.length - 1] === ':' || (port.test(url.hostname) && !url.port)) {
    host += ':';
  }

  result += host + url.pathname;

  query = 'object' === typeof url.query ? stringify(url.query) : url.query;
  if (query) result += '?' !== query.charAt(0) ? '?'+ query : query;

  if (url.hash) result += url.hash;

  return result;
}

Url.prototype = { set: set, toString: toString };

//
// Expose the URL parser and some additional properties that might be useful for
// others or testing.
//
Url.extractProtocol = extractProtocol;
Url.location = lolcation;
Url.trimLeft = trimLeft;
Url.qs = qs;

module.exports = Url;

文件 13:test/reference/url-parse/LICENSE

保留版权和 MIT 许可文本,是再分发参考源码的必要材料。它不是程序逻辑,也不能因为根目录已有 LICENSE 就省去上游随附文件。

The MIT License (MIT)

Copyright (c) 2015 Unshift.io, Arnout Kazemier,  the Contributors.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

文件 14:test/reference/url-parse/node_modules/querystringify/index.js

这份 2.2.0 参考实现供 URL-Parse 内部 require('querystringify') 使用。decode/encode 捕获编解码错误,querystring 用扫描正则并保留第一个键,querystringify 枚举并编码对象键值。仓颉对应的是 component 函数、parseQuery 与 stringifyQuery;错误策略应按各个调用点迁移,而不是统一吞掉所有异常。

'use strict';

var has = Object.prototype.hasOwnProperty
  , undef;

/**
 * Decode a URI encoded string.
 *
 * @param {String} input The URI encoded string.
 * @returns {String|Null} The decoded string.
 * @api private
 */
function decode(input) {
  try {
    return decodeURIComponent(input.replace(/\+/g, ' '));
  } catch (e) {
    return null;
  }
}

/**
 * Attempts to encode a given input.
 *
 * @param {String} input The string that needs to be encoded.
 * @returns {String|Null} The encoded string.
 * @api private
 */
function encode(input) {
  try {
    return encodeURIComponent(input);
  } catch (e) {
    return null;
  }
}

/**
 * Simple query string parser.
 *
 * @param {String} query The query string that needs to be parsed.
 * @returns {Object}
 * @api public
 */
function querystring(query) {
  var parser = /([^=?#&]+)=?([^&]*)/g
    , result = {}
    , part;

  while (part = parser.exec(query)) {
    var key = decode(part[1])
      , value = decode(part[2]);

    //
    // Prevent overriding of existing properties. This ensures that build-in
    // methods like `toString` or __proto__ are not overriden by malicious
    // querystrings.
    //
    // In the case if failed decoding, we want to omit the key/value pairs
    // from the result.
    //
    if (key === null || value === null || key in result) continue;
    result[key] = value;
  }

  return result;
}

/**
 * Transform a query string to an object.
 *
 * @param {Object} obj Object that should be transformed.
 * @param {String} prefix Optional prefix.
 * @returns {String}
 * @api public
 */
function querystringify(obj, prefix) {
  prefix = prefix || '';

  var pairs = []
    , value
    , key;

  //
  // Optionally prefix with a '?' if needed
  //
  if ('string' !== typeof prefix) prefix = '?';

  for (key in obj) {
    if (has.call(obj, key)) {
      value = obj[key];

      //
      // Edge cases where we actually want to encode the value to an empty
      // string instead of the stringified value.
      //
      if (!value && (value === null || value === undef || isNaN(value))) {
        value = '';
      }

      key = encode(key);
      value = encode(value);

      //
      // If we failed to encode the strings, we should bail out as we don't
      // want to add invalid strings to the query.
      //
      if (key === null || value === null) continue;
      pairs.push(key +'='+ value);
    }
  }

  return pairs.length ? prefix + pairs.join('&') : '';
}

//
// Expose the module.
//
exports.stringify = querystringify;
exports.parse = querystring;

文件 15:test/reference/url-parse/node_modules/querystringify/LICENSE

这一份与其 index.js 配套,保留原始许可。

The MIT License (MIT)

Copyright (c) 2015 Unshift.io, Arnout Kazemier,  the Contributors.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

文件 16:test/reference/url-parse/node_modules/requires-port/index.js

1.0.0 的参考函数先把 port 转为 Number,再按协议判断默认端口。前面的 portNumber 和 requiresPort 正是在静态语言里显式复现这两步;不要把这个函数误读成网络连接前的端口合法性验证。

'use strict';

/**
 * Check if we're required to add a port number.
 *
 * @see https://url.spec.whatwg.org/#default-port
 * @param {Number|String} port Port number we need to check
 * @param {String} protocol Protocol we need to check against.
 * @returns {Boolean} Is it a default port for the given protocol
 * @api private
 */
module.exports = function required(port, protocol) {
  protocol = protocol.split(':')[0];
  port = +port;

  if (!port) return false;

  switch (protocol) {
    case 'http':
    case 'ws':
    return port !== 80;

    case 'https':
    case 'wss':
    return port !== 443;

    case 'ftp':
    return port !== 21;

    case 'gopher':
    return port !== 70;

    case 'file':
    return false;
  }

  return port !== 0;
};

文件 17:test/reference/url-parse/node_modules/requires-port/LICENSE

与 requires-port 参考文件配套的许可。

The MIT License (MIT)

Copyright (c) 2015 Unshift.io, Arnout Kazemier,  the Contributors.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

文件 18:test/reference/querystringify/index.js

这是与文件 14 相同版本的独立入口。URL-Parse 的内部 require 用文件 14,生成器的 Q 类型用例直接 require 本文件。保留两个路径是为了让各自的 Node 模块解析完全离线;不是写了两套查询算法。

'use strict';

var has = Object.prototype.hasOwnProperty
  , undef;

/**
 * Decode a URI encoded string.
 *
 * @param {String} input The URI encoded string.
 * @returns {String|Null} The decoded string.
 * @api private
 */
function decode(input) {
  try {
    return decodeURIComponent(input.replace(/\+/g, ' '));
  } catch (e) {
    return null;
  }
}

/**
 * Attempts to encode a given input.
 *
 * @param {String} input The string that needs to be encoded.
 * @returns {String|Null} The encoded string.
 * @api private
 */
function encode(input) {
  try {
    return encodeURIComponent(input);
  } catch (e) {
    return null;
  }
}

/**
 * Simple query string parser.
 *
 * @param {String} query The query string that needs to be parsed.
 * @returns {Object}
 * @api public
 */
function querystring(query) {
  var parser = /([^=?#&]+)=?([^&]*)/g
    , result = {}
    , part;

  while (part = parser.exec(query)) {
    var key = decode(part[1])
      , value = decode(part[2]);

    //
    // Prevent overriding of existing properties. This ensures that build-in
    // methods like `toString` or __proto__ are not overriden by malicious
    // querystrings.
    //
    // In the case if failed decoding, we want to omit the key/value pairs
    // from the result.
    //
    if (key === null || value === null || key in result) continue;
    result[key] = value;
  }

  return result;
}

/**
 * Transform a query string to an object.
 *
 * @param {Object} obj Object that should be transformed.
 * @param {String} prefix Optional prefix.
 * @returns {String}
 * @api public
 */
function querystringify(obj, prefix) {
  prefix = prefix || '';

  var pairs = []
    , value
    , key;

  //
  // Optionally prefix with a '?' if needed
  //
  if ('string' !== typeof prefix) prefix = '?';

  for (key in obj) {
    if (has.call(obj, key)) {
      value = obj[key];

      //
      // Edge cases where we actually want to encode the value to an empty
      // string instead of the stringified value.
      //
      if (!value && (value === null || value === undef || isNaN(value))) {
        value = '';
      }

      key = encode(key);
      value = encode(value);

      //
      // If we failed to encode the strings, we should bail out as we don't
      // want to add invalid strings to the query.
      //
      if (key === null || value === null) continue;
      pairs.push(key +'='+ value);
    }
  }

  return pairs.length ? prefix + pairs.join('&') : '';
}

//
// Expose the module.
//
exports.stringify = querystringify;
exports.parse = querystring;

文件 19:test/reference/querystringify/LICENSE

独立入口也随附其原始许可证。

The MIT License (MIT)

Copyright (c) 2015 Unshift.io, Arnout Kazemier,  the Contributors.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

11. 差分用例生成器与仓颉驱动

文件 20:scripts/generate-fixtures.cjs

这个脚本不是库算法,而是“测试数据的生产线”。输入是锁定的 JS 参考;输出是 test/fixtures/oracle.tsv 和 manifest.json。输出可重新生成,因此不把它们作为第 37、38 个手写文件。

代码板块作用与设计理由
requires 与常量加载本地参考,固定 seed 0x5eed、目标 2000 条与硬上限;不取在线最新包
mulberry32、randInt、pick固定种子的伪随机选择;同一版本输入重复执行可得到同一批数据
cell、addRow、counts把每个字符串按 UTF-8 编成 hex,安全写入 TSV;统计五类操作及期望异常
fieldsOf保存 13 个字段外加 query 类型,共 14 个比较项;Params 用上游 stringify 规范输出
addP/addS/addE/addQ/addT调用真实 JS API 得到期望;抛异常保存为 ERROR;不调用仓颉实现
P_CASES 与 emitPCases人工选出的构造边界:协议、斜杠、认证、端口、相对路径、非法编码
S_BASES/S_PARTS/S_VALUES 与 emitSCombo系统组合 setter、不同起始 URL、字段值和 flag
E/Q/T 语料与 emit 函数分别覆盖协议提取、查询规则和 Unicode/控制字符清理
R_*、random*从限定语料组合随机地址/查询,不是无限随机噪声;填足固定目标
main先生成精选与组合用例,再补随机行,创建 fixtures 目录并写文件与简报

TSV 每行先有 5 个输入单元格,再是期望字段。例如 P 存操作码、address、base、mode、空占位;S 存操作码、初始地址、part、value、flag。所有单元格都 hex 编码,连空字符串也占位置。这样原 URL 里的制表符、换行或中文不会破坏行结构。

ERROR 表示该输入预期被拒绝,不跨语言比较 JS 与仓颉的异常类名/文案。这个 TSV 驱动覆盖字符串输入;对象基地址、复杂回调和静态重载仍由前面的原生测试补充。固定随机种子只是可复现,不是穷举证明。

// SPDX-License-Identifier: MIT
'use strict';

//
// Deterministic offline fixture generator for url-parse 1.5.10 differential
// validation. Writes test/fixtures/oracle.tsv (every cell hex-encoded) and
// test/fixtures/manifest.json, then prints a concise count JSON report.
//
// Row layout: 5 input cells followed by the expected cells.
//   P: ['P', address, baseString, mode, '']  + 14 expected fields
//   S: ['S', initialUrl, part, value, flagString] + 14 expected fields (or ['ERROR'])
//   E: ['E', address, baseProtocol, '', ''] + [protocol, slashes, slashesCount, rest]
//   Q: ['Q', queryText, '', '', ''] + [stringify(parse(queryText))]
//   T: ['T', text, '', '', ''] + [trimLeft(text)]
//
// Requires the reference modules (and their node_modules dependencies) that
// exist in the root project. Never executed in the isolated contract workspace
// (there node_modules is absent); syntax-check with node --check there.
//

var fs = require('fs');
var path = require('path');

var Url = require('../test/reference/url-parse');
var qs = require('../test/reference/querystringify');

var SEED = 0x5eed;
var URL_PARSE_VERSION = '1.5.10';
var MAX_CASES = 3000;
var TARGET_CASES = 2000;

// ---------------------------------------------------------------- PRNG ----

function mulberry32(seed) {
  var a = seed >>> 0;
  return function () {
    a = (a + 0x6d2b79f5) >>> 0;
    var t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

var random = mulberry32(SEED);

function randInt(n) {
  return Math.floor(random() * n);
}

function pick(list) {
  return list[randInt(list.length)];
}

// --------------------------------------------------------------- TSV ------

function cell(value) {
  return Buffer.from(String(value), 'utf8').toString('hex');
}

var rows = [];
var counts = { P: 0, S: 0, E: 0, Q: 0, T: 0 };
var errorCases = 0;

function addRow(op, inputs, expected) {
  if (rows.length >= MAX_CASES) return;
  if (expected.length === 1 && expected[0] === 'ERROR') errorCases += 1;
  counts[op] += 1;
  rows.push(inputs.concat(expected).map(cell).join('\t'));
}

// ---------------------------------------------------- expected helpers ----

function fieldsOf(url) {
  var kind = typeof url.query === 'object' ? 'params' : 'raw';
  var serialized = kind === 'params' ? qs.stringify(url.query) : String(url.query);
  return [
    String(url.protocol),
    String(url.slashes),
    String(url.auth),
    String(url.username),
    String(url.password),
    String(url.host),
    String(url.hostname),
    String(url.port),
    String(url.pathname),
    String(serialized),
    String(url.hash),
    String(url.href),
    String(url.origin),
    kind
  ];
}

function addP(address, baseString, mode) {
  var expected;
  try {
    var url = new Url(
      address,
      baseString || {},
      mode === 'custom' ? function () { return '1337'; } : mode === 'params'
    );
    expected = fieldsOf(url);
  } catch (err) {
    expected = ['ERROR'];
  }
  addRow('P', ['P', address, baseString, mode, ''], expected);
}

function addS(initialUrl, part, value, flagString) {
  var expected;
  try {
    var url = new Url(initialUrl, {});
    url.set(part, value, flagString === 'true');
    expected = fieldsOf(url);
  } catch (err) {
    expected = ['ERROR'];
  }
  addRow('S', ['S', initialUrl, part, value, flagString], expected);
}

function addE(address, baseProtocol) {
  var expected;
  try {
    var info = Url.extractProtocol(address, { protocol: baseProtocol });
    expected = [
      String(info.protocol),
      String(info.slashes),
      String(info.slashesCount),
      String(info.rest)
    ];
  } catch (err) {
    expected = ['ERROR'];
  }
  addRow('E', ['E', address, baseProtocol, '', ''], expected);
}

function addQ(queryText) {
  var expected;
  try {
    expected = [qs.stringify(qs.parse(queryText))];
  } catch (err) {
    expected = ['ERROR'];
  }
  addRow('Q', ['Q', queryText, '', '', ''], expected);
}

function addT(text) {
  var expected;
  try {
    expected = [Url.trimLeft(text)];
  } catch (err) {
    expected = ['ERROR'];
  }
  addRow('T', ['T', text, '', '', ''], expected);
}

// ------------------------------------------------------- curated P rows ----

var P_CASES = [
  // basic special protocols
  ['http://example.com', '', 'raw'],
  ['http://example.com/', '', 'params'],
  ['http://example.com/', '', 'custom'],
  ['https://example.com:443/a/b?x=1#h', '', 'raw'],
  ['https://example.com:443/a/b?x=1#h', '', 'params'],
  ['https://example.com:443/a/b?x=1#h', '', 'custom'],
  ['ftp://ftp.example.com/pub/file.txt', '', 'params'],
  ['ftp://ftp.example.com/pub/file.txt', '', 'raw'],
  ['ws://example.com:80/socket?token=a', '', 'raw'],
  ['ws://example.com/socket?token=a', '', 'params'],
  ['wss://example.com:443/socket?token=a', '', 'raw'],
  ['wss://example.com/socket?token=a', '', 'params'],
  ['http://EXAMPLE.COM/Path', '', 'raw'],
  ['HTTP://example.com/x', '', 'raw'],
  ['HtTpS://Example.Com/X?Y=Z', '', 'params'],

  // port forms: default / zero / leading zero / non-default / invalid
  ['http://example.com:80/', '', 'raw'],
  ['http://example.com:80/', '', 'params'],
  ['https://example.com:443/', '', 'raw'],
  ['http://example.com:0/', '', 'raw'],
  ['http://example.com:080/', '', 'raw'],
  ['http://example.com:0080/', '', 'raw'],
  ['http://example.com:0000080/', '', 'raw'],
  ['http://example.com:8080/', '', 'params'],
  ['http://example.com:8080/', '', 'raw'],
  ['http://example.com:65535/', '', 'raw'],
  ['http://example.com:65536/', '', 'raw'],
  ['http://example.com:99999/', '', 'raw'],
  ['http://example.com:/', '', 'raw'],
  ['http://example.com:abc/', '', 'raw'],
  ['ws://example.com:80/x', '', 'raw'],
  ['wss://example.com:443/x', '', 'raw'],
  ['ftp://example.com:21/', '', 'raw'],
  ['ftp://example.com:2121/', '', 'raw'],
  ['file://localhost:8080/etc', '', 'raw'],

  // slashes and backslashes
  ['http:\\\\example.com\\\\path', '', 'raw'],
  ['http://example.com\\\\path\\\\x', '', 'raw'],
  ['HTTP:\\\\Example.COM\\\\P', '', 'raw'],
  ['http://///x', '', 'raw'],
  ['http://\\\\\\\\host/x', '', 'raw'],
  ['foo:\\\\bar\\\\baz', '', 'raw'],
  ['foo://bar/baz', '', 'raw'],
  ['\\\\\\\\host\\\\share', 'file:///', 'raw'],
  ['//example.com/path', '', 'raw'],
  ['//example.com/path', 'https://base.example:8443/dir/', 'params'],
  ['//other.example/x', 'https://secure.example/dir/', 'params'],

  // file drive forms
  ['file:///etc/passwd', '', 'raw'],
  ['file://localhost/etc/passwd', '', 'raw'],
  ['file:///C:/Windows/System32/calc.exe', '', 'raw'],
  ['file://C:/Windows', '', 'raw'],
  ['file:C:\\\\Users\\\\test\\\\file.txt', '', 'raw'],
  ['file:C:/Users/test', '', 'raw'],
  ['file:///C|/Windows', '', 'raw'],
  ['file://c|/share', '', 'raw'],
  ['file:///c:/lower/drive', '', 'raw'],
  ['file:////server/share', '', 'raw'],
  ['file:///', '', 'raw'],
  ['file://', '', 'raw'],
  ['file:', '', 'raw'],
  ['file:///D:/x/y/../z', '', 'params'],
  ['FILE:///C:/X', '', 'raw'],
  ['file:///ünï/路径', '', 'raw'],

  // IPv4 / IPv6 / brackets
  ['http://127.0.0.1/', '', 'raw'],
  ['http://0.0.0.0:8080/', '', 'params'],
  ['http://192.168.1.254:65535/a', '', 'raw'],
  ['http://255.255.255.255/', '', 'raw'],
  ['http://[::1]/', '', 'raw'],
  ['http://[::1]:8080/', '', 'params'],
  ['https://[::ffff:192.168.0.1]:443/x', '', 'raw'],
  ['http://[2001:db8:85a3::8a2e:370:7334]/', '', 'raw'],
  ['http://[fe80::1%25eth0]/', '', 'raw'],
  ['http://[::]/', '', 'raw'],
  ['http://user:pass@[::1]:8443/x?a=b', '', 'params'],

  // unicode hosts and paths (Greek sigma, U+0130, CJK, umlauts, emoji)
  ['http://σ.example.com/', '', 'raw'],
  ['http://ΣΟΛ.COM/', '', 'raw'],
  ['http://İstanbul.com/Path', '', 'raw'],
  ['http://İ.com/', '', 'params'],
  ['http://example.com/Σ/ς/σ', '', 'raw'],
  ['http://example.com/Σ/ς/σ', '', 'params'],
  ['http://münchen.de/über', '', 'params'],
  ['http://中文.cn/中文?ключ=знач', '', 'raw'],
  ['http://😀.example/🎉', '', 'raw'],
  ['http://xn--80ak6aa92e.com/x', '', 'raw'],

  // whitespace, control characters, U+2028/U+2029
  ['  http://example.com/', '', 'raw'],
  ['\thttp://example.com/', '', 'raw'],
  ['\n\nhttp://example.com/', '', 'raw'],
  ['\r\thttp://example.com/a\rb', '', 'raw'],
  ['  
 http://example.com/', '', 'raw'],
  ['http://example.com/', '', 'raw'],
  ['\x00\x01http://example.com/', '', 'raw'],
  ['http://example.com/
path', '', 'raw'],
  ['http://example.com/a
b', '', 'params'],
  ['http://example.com/\x00a', '', 'raw'],
  ['http://ex ample.com/', '', 'raw'],
  ['http://example.com/a b/c', '', 'params'],

  // relative base inheritance and dot paths
  ['/abs/path', 'http://base.example:8080/dir/page?ignored=1#ignored', 'raw'],
  ['/abs/path', 'http://base.example:8080/dir/page?ignored=1#ignored', 'params'],
  ['foo/bar', 'http://base.example/dir/page', 'raw'],
  ['./foo', 'http://base.example/dir/page', 'raw'],
  ['../foo', 'http://base.example/dir/page', 'raw'],
  ['../../foo', 'http://base.example/a/b/page', 'raw'],
  ['.', 'http://base.example/dir/page', 'raw'],
  ['..', 'http://base.example/dir/page', 'raw'],
  ['././x', 'http://base.example/d/', 'raw'],
  ['', 'http://base.example/a/b/c', 'raw'],
  ['', 'https://user:pass@base.example:9090/x', 'params'],
  ['?q=1', 'http://base.example/path', 'params'],
  ['#frag', 'http://base.example/path', 'raw'],
  ['?q=1#f', 'http://base.example/path', 'params'],
  ['http://other.example/x', 'https://secure.example/', 'raw'],
  ['foo:bar', 'http://base.example/', 'raw'],
  ['x', 'blob:https://example.com/uuid-1234', 'raw'],
  ['y/z', 'foo://custom.host/dir/', 'params'],
  ['../up', 'file:///C:/base/dir/file', 'raw'],
  ['sub', 'file:///C:/base/', 'raw'],
  ['sub', 'mailto:someone@example.com', 'raw'],
  ['rel', 'relbase.example', 'raw'],
  // dot paths kept literal in absolute URLs
  ['http://example.com/a/./b', '', 'raw'],
  ['http://example.com/a/../b', '', 'params'],
  ['http://example.com/a/b/..', '', 'raw'],
  ['http://example.com/./', '', 'raw'],
  ['http://example.com/a/../../b', '', 'raw'],

  // auth variants: escaped, invalid-looking, multiple @, empty parts
  ['http://user:pass@example.com/', '', 'params'],
  ['http://user@example.com/', '', 'raw'],
  ['http://:pass@example.com/', '', 'raw'],
  ['http://user:pass:extra@example.com/', '', 'params'],
  ['http://user%40name:p%40ss@example.com/', '', 'params'],
  ['http://user%3Aname:%2Fpass@example.com/', '', 'raw'],
  ['http://a@b@c.example.com/', '', 'raw'],
  ['http://a@b@c@d.example.com/', '', 'params'],
  ['http://@example.com/', '', 'raw'],
  ['http://:@example.com/', '', 'raw'],
  ['http://us er:p@ss@example.com/', '', 'raw'],
  ['http://ünï:päss@example.com/', '', 'params'],
  ['http://user:pass@', '', 'raw'],
  ['http://user:pass@:8080/', '', 'raw'],
  ['http://user@127.0.0.1:8080/x', '', 'params'],
  ['https://user:pass@host.com/', '', 'custom'],
  ['http://$&:weird!@example.com/', '', 'raw'],

  // custom protocols
  ['mailto:user@example.com', '', 'raw'],
  ['mailto:?subject=hi', '', 'params'],
  ['mailto:user@example.com?subject=hi&body=x', '', 'params'],
  ['data:text/plain;base64,SGVsbG8=', '', 'raw'],
  ['data:application/json,{"a":1}', '', 'raw'],
  ['javascript:alert(1)//', '', 'raw'],
  ['javascript:void(0)?x=1', '', 'raw'],
  ['blob:https://example.com/38d8-12ab', '', 'raw'],
  ['about:blank', '', 'raw'],
  ['foo://bar/baz?x=1#h', '', 'params'],
  ['foo://bar/baz?x=1#h', '', 'raw'],
  ['foo://bar/baz?x=1#h', '', 'custom'],
  ['foo:bar', '', 'raw'],
  ['FOO:BAR', '', 'raw'],
  ['git+ssh://git@github.com/user/repo.git', '', 'params'],
  ['svn+ssh://host/repo', '', 'raw'],
  ['magnet:?xt=urn:btih:abc123&dn=name', '', 'params'],
  ['tel:+1-555-0100', '', 'raw'],
  ['ircs://irc.example.com:6697/channel', '', 'raw'],
  ['news:comp.lang.javascript', '', 'raw'],
  ['nntp://news.example.com/topic', '', 'raw'],
  ['chrome://settings/privacy', '', 'raw'],
  ['filesystem:https://example.com/temporary/x.txt', '', 'raw'],
  ['ssh://user@host:22/path', '', 'params'],
  ['redis://localhost:6379/0', '', 'raw'],
  ['custom://host/path?raw', '', 'raw'],

  // query edges: duplicates, prototype names, malformed percent, UTF8, numeric keys, empty
  ['http://example.com/path?', '', 'params'],
  ['http://example.com/path?', '', 'raw'],
  ['http://example.com/path?', '', 'custom'],
  ['http://example.com/?', '', 'raw'],
  ['http://example.com/?', '', 'params'],
  ['http://example.com/?a=1&a=2', '', 'params'],
  ['http://example.com/?43=1&42=2', '', 'params'],
  ['http://example.com/?42=1&43=2&41=3', '', 'raw'],
  ['http://example.com/?0=1&00=2&01=3', '', 'params'],
  ['http://example.com/?__proto__=polluted', '', 'params'],
  ['http://example.com/?constructor=1&prototype=2&toString=3', '', 'params'],
  ['http://example.com/?%zz=%zz', '', 'params'],
  ['http://example.com/?%E4%B8%AD=%E6%96%87', '', 'params'],
  ['http://example.com/?a=%E0%A4%A', '', 'params'],
  ['http://example.com/?=', '', 'params'],
  ['http://example.com/?a', '', 'params'],
  ['http://example.com/?a&b&c', '', 'params'],
  ['http://example.com/?a=1&b', '', 'raw'],
  ['http://example.com/?%20=%2B', '', 'params'],
  ['http://example.com/?a=b=c', '', 'params'],
  ['http://example.com/?q=http://other/?x=1', '', 'params'],
  ['http://example.com/?#', '', 'params'],

  // hash edges
  ['http://example.com/path#', '', 'raw'],
  ['http://example.com/path#?', '', 'params'],
  ['http://example.com/path#a?b=c', '', 'raw'],
  ['http://example.com/path#ü', '', 'raw'],
  ['http://example.com/#/route/?q=1', '', 'params'],
  ['http://example.com/path##x', '', 'raw'],
  ['http://example.com:8080/a b/c?x=1&y=2#h', '', 'params'],
  ['https://example.com/?a=1#h', '', 'custom']
];

function emitPCases() {
  for (var i = 0; i < P_CASES.length; i++) {
    addP(P_CASES[i][0], P_CASES[i][1], P_CASES[i][2]);
  }
}

// ------------------------------------------------------- curated S rows ----

var S_BASES = [
  'http://example.com:8080/dir/page?a=1#h',
  'https://example.com/',
  'file:///C:/Windows/System32',
  'ftp://user:pass@ftp.example.com:21/pub',
  ''
];

var S_PARTS = [
  'port', 'host', 'hostname', 'protocol', 'pathname', 'hash',
  'auth', 'username', 'password', 'query', 'href', 'foo'
];

var S_VALUES = {
  port: ['', '80', '0', '080', '8080', '443', '65535', '65536', 'abc',
    '0x50', '0b1010000', '0o120', '+80', '8e1', '80.', 'Infinity', '-Infinity',
    'inf', 'NaN', '\u00a080\u00a0', '0x', '-0x50', '1e-999'],
  host: ['', 'example.com', 'example.com:8080', 'EXAMPLE.COM:9090', '[::1]:8443', 'host:'],
  hostname: ['', 'example.com', 'EXAMPLE.COM', '127.0.0.1', '[::1]', 'xn--80ak6aa92e.com', 'İ', 'σ.example'],
  protocol: ['', 'http', 'https', 'HTTPS:', 'ftp:', 'file', 'wss:', 'foo:', 'mailto:'],
  pathname: ['', '/new', 'new', 'a/b', '/ünï/路径', '../up'],
  hash: ['', '#frag', 'frag', '#', '#ä?b'],
  auth: ['', 'user', 'user:pass', 'user:pa:ss', 'ünï:pä', '%40enc:pw'],
  username: ['', 'user', 'ünï', 'a b', 'user%20name'],
  password: ['', 'pass', 'pä ss', '%'],
  query: ['', 'a=1', 'x=1&y=2', '__proto__=x', '43=1&42=2', '%zz=1', '%E4%B8%AD=%E6%96%87'],
  href: ['', 'http://other.example/'],
  foo: ['', 'bar']
};

function emitSCombo() {
  var b, p, v, part, values;

  for (b = 0; b < S_BASES.length; b++) {
    for (p = 0; p < S_PARTS.length; p++) {
      part = S_PARTS[p];
      values = S_VALUES[part];
      for (v = 0; v < values.length; v++) {
        addS(S_BASES[b], part, values[v], 'false');
        addS(S_BASES[b], part, values[v], 'true');
      }
    }
  }

  // targeted relative / protocol-relative initial URLs
  var extraBases = ['//host.example:9090/path', '/relative/path'];
  var extraParts = ['port', 'host', 'hostname', 'protocol', 'pathname', 'query'];
  for (b = 0; b < extraBases.length; b++) {
    for (p = 0; p < extraParts.length; p++) {
      part = extraParts[p];
      values = S_VALUES[part];
      for (v = 0; v < values.length; v++) {
        addS(extraBases[b], part, values[v], 'false');
        addS(extraBases[b], part, values[v], 'true');
      }
    }
  }

  // exception rows: malformed percent escapes (decodeURIComponent throws)
  var badAuth = ['%', '%zz', 'a%b', '%E0%A4%A'];
  for (v = 0; v < badAuth.length; v++) {
    addS(S_BASES[0], 'auth', badAuth[v], 'false');
  }
  addS(S_BASES[1], 'auth', '%zz', 'true');
  addS(S_BASES[3], 'auth', 'user:%', 'false');

  // exception rows: query setter with flagString 'true' (truthy non-function parser)
  addS(S_BASES[0], 'query', 'a=1', 'true');
  addS(S_BASES[1], 'query', 'a=1&b=2', 'true');
}

// -------------------------------------------------------- combinatorial E ---

var E_ADDRESSES = [
  '', ' ', '  ', '\t', '\n', '\r', '\x00',
  'http://x/', 'https://x', '//x/', '//', '///x', '////x',
  '\\\\/x', '\\\\\\\\x', '\\\\', '/\\', 'x\\y',
  '/', '/p', 'p', '?q', '#h', '?', '#', '?q#h', 'p?q#h',
  'http:/x', 'http:x', 'http:', 'http://', 'https:', 'ftp://x',
  'ws://x', 'wss://x', 'ftp:////x',
  'file:///x', 'file://x', 'file:/x', 'file:x', 'file:', 'file:////x',
  'foo://x', 'foo:/x', 'foo:x', 'foo:', 'foo://', 'FOO://X',
  'mailto:a@b.c', 'mailto:', 'data:text/plain,hi', 'javascript:void(0)',
  'blob:https://x/y', 'about:blank', 'x://', 'x:/y', 'x:y:z',
  'a+b://x', 'a.b.c://x',
  ':', ':80', ':/', '://', ':\\\\', '/:', '//\\',
  ' http://x/', '\thttp://x', '\nhttp://x',
  '
http://x', 'http://x', '\x00x',
  'HtTp://X/', '1http://x', '-x://y',
  'http://\\x/', 'http:\\\\x', 'http://x\\y',
  'C:\\\\path', 'C:/path', '\\\\host\\share',
  'ünï', 'İ'
];

var E_BASE_PROTOCOLS = ['', 'http:', 'https:', 'file:', 'foo:'];

function emitECombo() {
  for (var a = 0; a < E_ADDRESSES.length; a++) {
    for (var b = 0; b < E_BASE_PROTOCOLS.length; b++) {
      addE(E_ADDRESSES[a], E_BASE_PROTOCOLS[b]);
    }
  }
}

// ---------------------------------------------------------- Q corpus -------

var Q_CORPUS = [
  '', 'a=1', 'a', 'a=', '=b', '=', '&', '&&', 'a&b',
  'a=1&b=2', 'a=1&a=2', 'a=1&a=2&a=3', 'a=1&b=2&a=3',
  '42=1', '43=1&42=2', '10=1&9=2&8=3', '43=2&42=1&41=0',
  '0=1', '00=1', '01=1', '1e3=2', '-1=2', '+1=2', '1.5=2',
  '__proto__=x', 'constructor=x', 'toString=y', 'hasOwnProperty=z',
  '__proto__=x&__proto__=y', 'a=__proto__', '__proto__=x&b=2',
  'constructor=1&prototype=2&toString=3', 'polluted=1&__proto__.x=2',
  '%zz=1', 'a=%zz', '%=%', 'a=%', '%=b', '%%=%%', 'a=%E0%A4%A',
  'a=%E4%B8%AD', '%E4%B8%AD=%E6%96%87', '%20=%2B', 'a=%20b',
  'a+b=c+d', 'a=%25', '%25=%25', 'a=%2F', '%2F=%3F', 'a=%26%3D',
  'q=%00', 'q=%0A', 'q=%09', 'emoji=%F0%9F%98%80', 'broken=%ED%A0%80',
  'a=%C3%28', 'a=%E2%82%',
  'ü=ñ', '中文=值', '😀=🎉', 'key with space=v', 'a b=c d', 'äöü=ß',
  'a[]=1', 'a[0]=1', 'a.b=c', 'a b=c', 'a=b=c', 'a==b',
  'a=1;b=2', 'a=1&b', 'a=1&&b=2', 'a=1&', '&a=1', '&&a=1&&',
  'a=&&b=', '=1&=2', 'a=1#b', 'a?b=c', 'a=b&c#d',
  'null=null', 'undefined=undefined', 'true=true', 'false=false', 'NaN=NaN',
  'q=https%3A%2F%2Fex.com%2F%3Fa%3D1',
  'a=1&__proto__=x&b=2', 'a=0&a=00&a=000',
  'a0=0&a1=1&a2=2&a3=3&a4=4&a5=5&a6=6&a7=7&a8=8&a9=9',
  'a=1&b=2&c=3&d=4&e=5&f=6&g=7&h=8&i=9&j=10',
  'A=1&a=2', 'B=3&b=4', 'x=1&X=2',
  'space=+plus', 'plus=%2B', 'amp=%26', 'eq=%3D', 'hash=%23',
  'semi=%3B', 'slash=%2F', 'quest=%3F', 'pct=%25',
  'k=', '=v', 'k=&=v', 'k=v&k2=', 'k= ',
  '  =  ', 'a=1 ', ' a=1', ' a = 1 ',
  'arr=1&arr=2&arr=3', 'deep[a][b]=c',
  'utf8=%E4%B8%AD%E6%96%87%E6%B5%8B%E8%AF%95',
  'greek=%CE%B1%CE%B2%CE%B3', 'sigma=%CE%A3%CF%83%CF%82',
  'İ=%C4%B0', 'trail=%', '%trailing', 'a=%E0%A4',
  'x=1&y=2&z=3&x=4', 'one=1&two=2&three=3'
];

function emitQCorpus() {
  for (var i = 0; i < Q_CORPUS.length; i++) addQ(Q_CORPUS[i]);
}

// ---------------------------------------------------------- T corpus -------

var T_CORPUS = [
  '', ' ', '  a', '\ta', '\na', '\ra', '\r\n\ta',
  '\x00', '\x00a', '\x01a', '\x1fa', '\x20a', '\x0ca', '\x0ba',
  '  ', '\t\t\t', '\n\n\n',
  ' a', ' ', ' a',
  ' a', ' a', ' a', ' a', ' a',
  ' a', ' a', ' a', ' a', ' a', ' a',
  '
a', '
a', '

', ' a', 'a',
  ' a', '  x', 'a', 'x',
  ' \t\n\r\x00  z',
  'a b', 'a', '   ',
  'http://x', '  http://x', '\t/rel',
  ' 
/rel', '
 x', '\t\x00b',
  'ünï', 'ünï leading', 'İ', 'Σ σ',
  ' leading', '​\ta', ' 
 ',
  '  tail', '\x00\x00\x00',
  '


', '', '     ',
  '​', ' ', ' ',
  'text with trailing   ', ' mixed ',
  '\t \t \tx', '\n \n \ny',
  '  ünï', 'ünï', '\tünï',
  'http://example.com/', '
http://example.com/'
];

// Explicitly escaped additions guarantee the full trimLeft character class
// (plus near-miss characters that must NOT be trimmed) is exercised.
var T_EXPLICIT = [
  '', '', '', ' ',
  ' a', ' ', ' a',
  ' a', ' a', ' a', ' a', ' a',
  ' a', ' a', ' a', ' a', ' a', ' a',
  '
a', '
a', '

', ' a', ' a',
  ' a', '  x', 'a', 'x',
  ' \t\n\r   z', '


', '',
  '​', '…a', '',
  'http://example.com/', '
http://example.com/',
  '  ünï', 'ünï', '

 '
];

function emitTCorpus() {
  for (var i = 0; i < T_CORPUS.length; i++) addT(T_CORPUS[i]);
  for (var j = 0; j < T_EXPLICIT.length; j++) addT(T_EXPLICIT[j]);
}

// -------------------------------------------------- seeded random rows -----

var R_SCHEMES = [
  'http', 'https', 'ftp', 'ws', 'wss', 'file',
  'blob', 'mailto', 'data', 'javascript', 'foo', 'git+ssh',
  'magnet', 'tel', 'ircs', 'news', 'chrome', 'ssh', 'redis', 'filesystem'
];

var R_SPECIAL = { http: true, https: true, ftp: true, ws: true, wss: true, file: true };

var R_HOSTS = [
  'example.com', 'a.b.c', 'localhost', '127.0.0.1', '0.0.0.0',
  '192.168.0.1', '10.0.0.1', 'ex42.io', 'σ.gr', 'İ.com',
  'münchen.de', '中文.cn', '[::1]', '[2001:db8::7]', '[::ffff:10.0.0.1]',
  'xn--80ak6aa92e.com', 'host-1.example', 'user.info', 'EXAMPLE.COM'
];

var R_PORTS = ['', ':80', ':443', ':8080', ':0', ':080', ':8443', ':65535', ':65536', ':666'];

// percent escapes below are all valid UTF-8 so random auth never throws
var R_AUTHS = [
  '', 'user', 'user:pass', 'u%40x:p%20y', 'ünï:pä', 'a:b:c', 'admin:%E4%B8%AD'
];

var R_PATHS = [
  '', '/', '/a', '/a/b', '/dir/42', '/ünï/路径', '/Σ/σ/ς',
  '/%20x', '/a/./b', '/a/../b', '/..', '/.', '/dot.', '/x%2Fy'
];

var R_QUERIES = [
  '', '?a=1', '?a=1&b=2', '?k=v', '?%E4%B8%AD=x', '?__proto__=p',
  '?42=1&43=2', '?%zz=1', '?a=&b', '?', '?a=1&a=2', '?=v', '?q'
];

var R_HASHES = ['', '#', '#frag', '#ä', '#a?b', '#/r/q'];

var R_BASES = [
  '', '', '', '', 'http://base.example/dir/page',
  'https://u:p@base.example:8443/x?a=1#h', 'file:///C:/base/dir',
  'foo://b.host/p', '//proto.host/path', 'base.example/x'
];

var R_RELATIVE = [
  '', 'x', 'rel/path', './x', '../y', '/abs/p', '//other.host/p',
  '?q=1', '#h', 'a/b?q=1#f'
];

var R_FILE_FORMS = [
  'file:///C:/Users/test.txt', 'file:///etc/hosts', 'file://localhost/etc/hosts',
  'file://server/share/file.txt', 'file:C:/Users/x.txt', 'file:/etc/passwd',
  'file:///ünï/文件', 'file:///a/../b', 'file:///C|/x',
  'file://127.0.0.1:8080/x', 'FILE:///C:/X', 'file:///', 'file://', 'file:'
];

var R_MODES = ['raw', 'params', 'custom'];

var S_ALL_BASES = S_BASES.concat(['//host.example:9090/path', '/relative/path']);

var Q_KEYS = [
  'a', 'b', 'key', '42', '43', '0', '__proto__', 'constructor', 'toString',
  'hasOwnProperty', 'ü', '中文', 'key with space', 'a b', '%E4%B8%AD',
  '%zz', '', 'x.y', 'arr[]', 'A'
];

var Q_VALS = [
  '1', 'x', '%E6%96%87', '%zz', '', 'a=b', 'https://ex.com/?q=1', '+',
  '%20', '0', 'null', 'ünï', '%', '%E4%B8%AD'
];

var R_T_LEADS = ['', ' ', '  ', '\t', '\n', '\r', '\x00', '\x1f', '\x01'];

var R_T_BODIES = [
  'text', 'http://example.com/', '/relative/path', '', 'ünï',
  'Σ σ', 'a b', 'İ', 'xxxxxx'
];

function randomPAddress() {
  if (randInt(8) === 0) return pick(R_RELATIVE);

  var scheme = pick(R_SCHEMES);
  var suffix = pick(R_QUERIES) + pick(R_HASHES);
  if (scheme === 'file') return pick(R_FILE_FORMS) + suffix;

  var sep = R_SPECIAL[scheme]
    ? pick(['//', '//', '\\\\', '/', ''])
    : pick(['://', '\\\\', '/', '']);
  var auth = pick(R_AUTHS);
  var authority = (auth ? auth + '@' : '') + pick(R_HOSTS) + pick(R_PORTS);
  return scheme + ':' + sep + authority + pick(R_PATHS) + suffix;
}

function randomP() {
  addP(randomPAddress(), pick(R_BASES), pick(R_MODES));
}

function randomS() {
  var part = pick(S_PARTS);
  addS(pick(S_ALL_BASES), part, pick(S_VALUES[part]), randInt(2) ? 'true' : 'false');
}

function randomE() {
  addE(pick(E_ADDRESSES), pick(E_BASE_PROTOCOLS));
}

function randomQ() {
  var n = 1 + randInt(4);
  var parts = [];
  for (var i = 0; i < n; i++) {
    parts.push(pick(Q_KEYS) + (randInt(4) ? '=' : '') + pick(Q_VALS));
  }
  addQ(parts.join(pick(['&', '&', '&', ''])));
}

function randomT() {
  addT(pick(R_T_LEADS) + pick(R_T_BODIES));
}

// --------------------------------------------------------------- main ------

function main() {
  emitPCases();
  emitSCombo();
  emitECombo();
  emitQCorpus();
  emitTCorpus();

  var fillers = [randomP, randomS, randomE, randomQ, randomT];
  var i = 0;
  while (rows.length < TARGET_CASES && rows.length < MAX_CASES) {
    fillers[i % fillers.length]();
    i += 1;
  }

  var fixtureDir = path.join(__dirname, '..', 'test', 'fixtures');
  fs.mkdirSync(fixtureDir, { recursive: true });
  fs.writeFileSync(
    path.join(fixtureDir, 'oracle.tsv'),
    rows.join('\n') + '\n',
    'utf8'
  );

  var manifest = {
    seed: '0x5eed',
    url_parse: URL_PARSE_VERSION,
    total: rows.length,
    counts: counts,
    error_cases: errorCases
  };
  fs.writeFileSync(
    path.join(fixtureDir, 'manifest.json'),
    JSON.stringify(manifest, null, 2) + '\n',
    'utf8'
  );

  console.log(JSON.stringify({
    seed: manifest.seed,
    url_parse: manifest.url_parse,
    total: manifest.total,
    counts: manifest.counts,
    error_cases: manifest.error_cases
  }));
}

main();

文件 21:test/oracle_driver/cjpm.toml

这是另一个独立 executable,通过 ../.. 导入生产库。让它独立构建,可以同时验证外部依赖和公开 API,而不是靠库内部可见性完成差分。

[package]
  cjc-version = "1.0.5"
  name = "url_parse_oracle"
  description = "nothing here"
  version = "1.0.0"
  target-dir = ""
  output-type = "executable"
  compile-option = ""
  override-compile-option = ""
  link-option = ""
  package-configuration = {}

[dependencies]
  url_parse = { path = "../.." }

文件 22:test/oracle_driver/src/main.cj

unhex 还原各单元格的 UTF-8;fields 提取与 JS 相同顺序的 14 个比较项;evaluate 根据 P/S/E/Q/T 分派到真正的仓颉 API;main 读取文件、计数、捕获预期类型的非法输入异常,再比较数组。它只打印前 20 个差异以免刷屏,最终仍统计所有失败。返回 0 表示全过,1 表示差分失败,2 表示命令参数错误。

这里 row[5..row.size] 是期望值,因为前五列固定为输入。修改格式时生成器和 driver 必须同时变更,不能只挪一边的列号。文件内的十六进制合法性由本项目生成器保证,这个 driver 不是用来解析任意外部不可信 TSV 的通用工具。

// SPDX-License-Identifier: MIT
package url_parse_oracle

import url_parse.*
import std.fs.*

func unhex(text: String): String {
    let data = Array<UInt8>(text.size / 2, repeat: 0)
    let hex = "0123456789abcdef"
    for (i in 0..data.size) {
        let hi = hex.indexOf(text[i * 2]).getOrThrow()
        let lo = hex.indexOf(text[i * 2 + 1]).getOrThrow()
        data[i] = UInt8(hi * 16 + lo)
    }
    return String.fromUtf8(data)
}

func fields(url: Url): Array<String> {
    let kind = match (url.query) {
        case Query.Raw(_) => "raw";
        case Query.Params(_) => "params"
    }
    return [url.protocol, url.slashes.toString(), url.auth, url.username, url.password, url.host, url.hostname, url.port,
        url.pathname, url.query.toString(), url.hash, url.href, url.origin, kind]
}

func evaluate(row: Array<String>): Array<String> {
    match (row[0]) {
        case "P" =>
            let url = if (row[3] == "custom") {
                Url(row[1], {_: String => Query.Raw("1337")}, base: row[2])
            } else {
                Url(row[1], base: row[2], parseQuery: row[3] == "params")
            }
            return fields(url)
        case "S" => return fields(Url(row[1]).set(row[2], row[3], flag: row[4] == "true"))
        case "E" =>
            let e = Url.extractProtocol(row[1], UrlLocation(protocol: row[2]))
            return [e.protocol, e.slashes.toString(), e.slashesCount.toString(), e.rest]
        case "Q" => return [stringifyQuery(parseQuery(row[1]))]
        case "T" => return [Url.trimLeft(row[1])]
        case _ => throw IllegalArgumentException("Unknown fixture operation")
    }
}

main(args: Array<String>): Int64 {
    if (args.size != 1) {
        println("Usage: url_parse_oracle <oracle.tsv>");
        return 2
    }
    let input = String.fromUtf8(File.readFrom(args[0]))
    var total: Int64 = 0
    var failures: Int64 = 0
    for (line in input.split("\n", removeEmpty: true)) {
        total++
        let clean = if (line.endsWith("\r")) {
            line[0..line.size - 1]
        } else {
            line
        }
        let parts = clean.split("\t")
        let row = Array<String>(parts.size, {i => unhex(parts[i])})
        var actual: Array<String> = []
        try {
            actual = evaluate(row)
        } catch (_: IllegalArgumentException) {
            actual = ["ERROR"]
        }
        let expected = row[5..row.size]
        if (actual != expected) {
            failures++
            if (failures <= 20) {
                println("Fixture ${total}: ${row[0]} input=${row[1]}")
                println("Expected: ${String.join(expected, delimiter: " | ")}")
                println("Actual:   ${String.join(actual, delimiter: " | ")}")
            }
        }
    }
    println("DIFFERENTIAL: ${total} fixtures, ${failures} failures")
    return if (failures == 0) {
        0
    } else {
        1
    }
}

检查点 D:分步运行离线差分。

node scripts/generate-fixtures.cjs
Push-Location test/oracle_driver
cjpm build
Pop-Location
& ./test/oracle_driver/target/release/bin/main.exe ./test/fixtures/oracle.tsv

生成器应报告 2000 条,P=211、S=1091、E=432、Q=138、T=128,其中 52 条期望异常。driver 应输出:

DIFFERENTIAL: 2000 fixtures, 0 failures

有差异时先看操作码、输入、Expected 与 Actual,缩成原生回归测试后修复实现;不要直接修改 JS 参考或把期望改成 Actual。

12. 自动检查、覆盖率与打包

文件 23:scripts/check.ps1

$PSScriptRoot 找到脚本目录,再取父目录作为工程根;Push-Location/finally 保证运行后恢复原终端目录。每条外部命令后检查 $LASTEXITCODE,避免前一步失败仍显示后面成功。完整顺序是 build → 原生 test → 独立 demo → JS 用例生成 → driver build → driver 运行。

-SkipDifferential 只在暂时没有 Node 的开发环境里做原生检查,不能用作完整发布验收。Set-StrictMode 和 Stop 错误策略让路径、变量和外部失败尽早暴露。

param([switch]$SkipDifferential)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$projectRoot = Split-Path -Parent $PSScriptRoot
Push-Location -LiteralPath $projectRoot
try {
    & cjpm build
    if ($LASTEXITCODE -ne 0) { throw 'Library build failed' }
    & cjpm test --no-color --no-progress
    if ($LASTEXITCODE -ne 0) { throw 'Native tests failed' }
    Push-Location -LiteralPath (Join-Path $projectRoot 'examples/demo')
    try {
        & cjpm run
        if ($LASTEXITCODE -ne 0) { throw 'Public API demo failed' }
    } finally { Pop-Location }
    if (-not $SkipDifferential) {
        & node scripts/generate-fixtures.cjs
        if ($LASTEXITCODE -ne 0) { throw 'Fixture generator failed' }
        Push-Location -LiteralPath (Join-Path $projectRoot 'test/oracle_driver')
        try {
            & cjpm build
            if ($LASTEXITCODE -ne 0) { throw 'Oracle driver build failed' }
            $driverPath = Join-Path $projectRoot 'test/oracle_driver/target/release/bin/main.exe'
            $fixturePath = Join-Path $projectRoot 'test/fixtures/oracle.tsv'
            & $driverPath $fixturePath
            if ($LASTEXITCODE -ne 0) { throw 'Upstream differential tests failed' }
        } finally { Pop-Location }
    }
} finally { Pop-Location }

文件 24:scripts/check.sh

这是同一检查流程的 shell 版本:定位根目录、依次执行命令,用 set -eu 在失败时退出。平台可执行文件名不带 .exe。脚本完整提供,但 Windows 验收不能证明 Linux/macOS SDK、链接或运行已经通过;使用对应平台时要实跑并记录。

#!/usr/bin/env sh
set -eu
project_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$project_root"
cjpm build
cjpm test --no-color --no-progress
(cd examples/demo && cjpm run)
node scripts/generate-fixtures.cjs
(cd test/oracle_driver && cjpm build)
test/oracle_driver/target/release/bin/main "$project_root/test/fixtures/oracle.tsv"

文件 25:scripts/package.ps1

打包脚本从 cjpm 清单读取版本,在 dist 下创建独立 staging 目录。根文件和源码/文档/脚本/示例/参考目录构成白名单;逐文件复制并生成 SHA-256 清单;最后压缩为 url_parse-0.1.0.zip。它不会把根目录全部压缩进去,因此不会顺带发布 target、个人配置或本文的 WordPress 发布工作资料。

注意白名单中的目录会递归纳入文件,新增 doc/scripts 文件也会进入包;发布前仍需审查清单。MANIFEST 验证每个文件内容,不验证代码质量或社区准入状态。重新打包 ZIP 的容器元数据可能不同,ZIP 总哈希不必等于前面已上传的冻结发布包,应该核对自己包内的逐文件清单。

$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$projectRoot = Split-Path -Parent $PSScriptRoot
$manifest = Get-Content -LiteralPath (Join-Path $projectRoot 'cjpm.toml') -Raw -Encoding utf8
$versionMatch = [regex]::Match($manifest, '(?m)^\s*version\s*=\s*"([0-9]+\.[0-9]+\.[0-9]+)"')
if (-not $versionMatch.Success) { throw 'Package version not found' }
$version = $versionMatch.Groups[1].Value
$distPath = Join-Path $projectRoot 'dist'
$stagePath = Join-Path $distPath ('staging-' + [guid]::NewGuid().ToString('N'))
$packagePath = Join-Path $stagePath ('url_parse-' + $version)
$null = New-Item -ItemType Directory -Path $packagePath -Force
$rootFiles = @('README.md', 'README.OpenSource', 'LICENSE', 'NOTICE', 'CHANGELOG.md', 'CONTRIBUTING.md', 'cjpm.toml', '.gitignore')
$directories = @('src', 'doc', 'scripts', 'examples/demo/src', 'test/oracle_driver/src', 'test/reference')
$relativeFiles = [System.Collections.Generic.List[string]]::new()
foreach ($name in $rootFiles) { $relativeFiles.Add($name) }
$relativeFiles.Add('examples/demo/cjpm.toml')
$relativeFiles.Add('test/oracle_driver/cjpm.toml')
foreach ($directory in $directories) {
    foreach ($file in Get-ChildItem -LiteralPath (Join-Path $projectRoot $directory) -Recurse -File) {
        $relativeFiles.Add([System.IO.Path]::GetRelativePath($projectRoot, $file.FullName))
    }
}
$hashes = @()
foreach ($relative in $relativeFiles | Sort-Object -Unique) {
    $sourcePath = Join-Path $projectRoot $relative
    $destinationPath = Join-Path $packagePath $relative
    $null = New-Item -ItemType Directory -Path (Split-Path -Parent $destinationPath) -Force
    Copy-Item -LiteralPath $sourcePath -Destination $destinationPath
    $hashes += [pscustomobject]@{ path = $relative.Replace('\', '/'); sha256 = (Get-FileHash -LiteralPath $sourcePath -Algorithm SHA256).Hash.ToLowerInvariant() }
}
$hashes | ConvertTo-Json -Depth 3 | Set-Content -LiteralPath (Join-Path $packagePath 'MANIFEST.sha256.json') -Encoding utf8
$archivePath = Join-Path $distPath ('url_parse-' + $version + '.zip')
Compress-Archive -LiteralPath $packagePath -DestinationPath $archivePath -Force
Write-Output $archivePath

全部文件创建完成后,一键检查:

& ./scripts/check.ps1

格式化只针对自己改过的仓颉生产文件,别格式化上游 JS 参考。代码变更后重新测试:

cjfmt -f src/helpers.cj
cjfmt -f src/query.cj
cjfmt -f src/location.cj
cjfmt -f src/url.cj
& ./scripts/check.ps1

统计覆盖率:

cjpm test --coverage --no-color --no-progress
cjcov --root . --source src --output target/coverage --json --html-details

只统计四个生产文件,不把 _test.cj 算进去。本次结果是 570/591 可执行行,96.45%。这是原生测试的行覆盖率,不是分支覆盖率,不包含差分程序,也不意味着安全性或所有输入已经得到证明。报告对应源码版本,修改或换 SDK 后要重建;换 SDK 先 cjpm clean。

13. 许可证与根目录交付材料

文件 26:LICENSE

本项目采用 MIT,保留上游版权与许可。再分发或提交社区时不能删去版权声明。不要把“代码能编译”误当成“来源和许可已齐备”。

The MIT License (MIT)

Copyright (c) 2015 Unshift.io, Arnout Kazemier,  the Contributors.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

文件 27:NOTICE

说明移植参考了哪些组件、版本和用途,区分“仓颉生产实现”与“仅测试使用的 JS 源码”,便于审核者追踪依赖。它补充 LICENSE,不替代 LICENSE。

url_parse is a Cangjie port of url-parse 1.5.10.

The port derives algorithms from url-parse, querystringify, and requires-port.
Copyright (c) 2015 Unshift.io, Arnout Kazemier, the Contributors.
Each original license is preserved in LICENSE or test/reference.

The OpenHarmony url_parse sample was used to identify requirements. Its
UI/platform code was not copied into this Cangjie library.

JavaScript files under test/reference are offline testing artifacts only.
The production Cangjie library does not execute or depend on JavaScript.

文件 28:README.OpenSource

机器可读的三方来源清单:每项名称、版本、许可证、上游地址、提交号和许可路径。锁定提交让后续差分基线可追溯;不能只写“最新版本”。这些字段是来源记录,不是 cjpm 下载依赖的配置。

[
  {
    "Name": "url-parse",
    "Version Number": "1.5.10",
    "License": "MIT",
    "License File": "LICENSE",
    "Upstream URL": "https://github.com/unshiftio/url-parse/tree/1.5.10",
    "Revision": "8cd4c6c6435c1ea32243ec20c9cfe535251ec524",
    "Usage": "Core algorithm port and offline test oracle"
  },
  {
    "Name": "querystringify",
    "Version Number": "2.2.0",
    "License": "MIT",
    "License File": "test/reference/querystringify/LICENSE",
    "Upstream URL": "https://github.com/unshiftio/querystringify",
    "Revision": "60d8dd92aeb5550fb79b93e6dae193d02e029daf",
    "Usage": "Query algorithm port and offline test oracle"
  },
  {
    "Name": "requires-port",
    "Version Number": "1.0.0",
    "License": "MIT",
    "License File": "test/reference/url-parse/node_modules/requires-port/LICENSE",
    "Upstream URL": "https://github.com/unshiftio/requires-port",
    "Revision": "07e8e8298f892243734d695e884a78a56e3dccb9",
    "Usage": "Default-port algorithm port and offline test oracle"
  }
]

文件 29:README.md

工程首页给使用者最短接入路径、状态、限制、目录和文档索引。SDK、平台和“拟孵化”状态要如实填写;不要为了看起来完成,改成“已被 TPC 收录”。其中 doc/tutorial 指向随包简版手册,本文则是另外的完整源码课堂。

# url_parse

| release | cjc | cjcov | state | domain |
|---|---|---|---|---|
| 0.1.0 | 1.0.5 (cjnative) | [96.45% 行覆盖率](doc/validation.md) | 待社区评审,拟孵化 | 通用工具 / Cloud |

`url_parse` 是 URL-Parse 1.5.10 的纯仓颉移植,提供绝对/相对 URL 解析、基地址解析、认证信息编码、查询字符串解析、字段联动修改及序列化。运行时只使用仓颉标准库,无 JavaScript、Node.js、FFI 或 stdx 依赖。

用户指定的 OpenHarmony `url_parse` 目录是使用 npm 包的样例工程。其 `entry/oh-package.json5` 声明 `url-parse: ^1.5.10`,`README.OpenSource` 登记 1.5.10。本项目以该版本的**发布标签**为兼容基线,保留该版本行为;它不是 WHATWG URL 校验器。

## 快速开始

安装仓颉 1.0.5 SDK,使 `cjc`、`cjpm`、`cjfmt` 在 PATH 中可用。在本项目根目录执行:

```powershell
cjc -v
cjpm build
cjpm test --no-color --no-progress
Set-Location examples/demo
cjpm run
```

在另一个 `cjpm` 项目中配置路径依赖:

```toml
[dependencies]
url_parse = { path = "../url-parse-cangjie" }
```

```cangjie
import url_parse.*

main(): Int64 {
    let url = Url("https://example.com:8080/path?a=1#top", parseQuery: true)
    url.set("hostname", "other.example").set("query", "page=2")
    println(url.href)
    return 0
}
```

输出 `https://other.example:8080/path?page=2#top`。独立可运行工程见 `examples/demo`。

## 功能与结构

| 路径 | 内容 |
|---|---|
| `src/url.cj` | `Url`、`parse`、联动设置与序列化 |
| `src/helpers.cj` | 协议提取、空白清理、默认端口、相对路径 |
| `src/location.cj` | 显式基地址与 blob location |
| `src/query.cj` | `Query`、`QueryParams`、`Url.qs`、严格 URI component 编解码 |
| `src/*_test.cj` | 仓颉原生单元测试 |
| `test/oracle_driver` | 独立仓颉差分验证程序 |
| `test/reference` | 仅测试使用的 MIT 上游源码 |
| `scripts` | 离线上游用例生成、质量检查与发布打包 |
| `doc` | 完整教程、API、设计、兼容说明和验收记录 |

查询值使用 `Query.Raw(String)` 和 `Query.Params(QueryParams)` 表达 JavaScript 的字符串/对象两种状态。基地址可传字符串、`Url` 或 `UrlLocation`。自定义 parser 返回 `Query`,自定义 stringify 接收 `QueryParams`。所有公开字段及重载见 [API 文档](doc/feature_api.md)。

## 文档

- [从零到提交三方库的完整教程](doc/tutorial.md)
- [功能、接口及 JavaScript 对照](doc/feature_api.md)
- [移植设计与版本边界](doc/design.md)
- [测试与实际验收记录](doc/validation.md)
- [TPC 接入材料](doc/tpc_submission.md)

## 约束与演进

实际验证环境是 Windows x86_64、仓颉 1.0.5。代码只依赖 `std.*`,其他仓颉原生目标需在对应 SDK 上重新验证;尚未验证 OpenHarmony 设备交叉编译、Linux、macOS。浏览器的全局 `window.location` 用显式 `UrlLocation` 替代。仓颉不表示 JavaScript 的任意动态对象及孤立 UTF-16 代理字符,具体迁移规则见兼容说明。

计划:在保留 1.5.10 基线的前提下,根据社区评审补充目标平台 CI、维护者登记和覆盖率门槛;任何改变解析语义的升级单独发布版本。

## 贡献与许可

维护责任由本项目的提交者承担;正式维护者姓名、联系方式和 committer 名单应在 TPC 申请时登记,当前没有冒用社区身份。提交修改需附回归测试并运行完整质量检查。参见 [CONTRIBUTING.md](CONTRIBUTING.md)。

本项目采用 [MIT License](LICENSE),保留上游版权及许可。来源清单见 [README.OpenSource](README.OpenSource),依赖用途见 [NOTICE](NOTICE)。本地材料齐备不代表已经被 TPC 收录。

文件 30:CHANGELOG.md

描述本版本新增了什么和兼容哪些行为,给未来升级提供变更基线。移植库 0.1.0 与 JS 1.5.10 是两个不同版本轴,升级任一方都应说明。

# 变更记录

## 0.1.0 - 2026-10-01

### Feature

- 实现 URL-Parse 1.5.10 的 URL 字段、绝对/相对地址、基地址、file 与自定义协议解析。
- 实现字段联动 set、默认端口、IPv6、认证信息编码、origin 和 href 序列化。
- 实现 Query/QueryParams、查询解析和自定义 parser/stringify。
- 实现 location、extractProtocol、trimLeft 和 qs 辅助接口。
- 提供原生测试、离线上游差分、独立示例、完整中文教程及 TPC 提交材料。

### Bugfix

- 首个移植版本,无前一版本升级修复。

### Remove

- 无。

文件 31:CONTRIBUTING.md

告诉后续贡献者怎样提 issue、补测试、运行验收,以及哪些身份/CLA 需要真人完成。这是维护规则,不是实现配置。功能修复要有回归,不应悄悄改锁定上游或借重构改变发布版语义。

# 参与贡献

维护者与 committer 由项目提交者在 TPC 创建申请和后续评审中实名登记。当前材料不代表社区已接受该项目。

## 修改流程

1. 描述输入 URL、基地址、实际行为与期待行为;标明期待来自 1.5.10 还是其他规范。
2. 对比 `test/reference` 中锁定的上游实现,补充原生回归测试或差分用例。
3. 仅修改必要文件,运行 `scripts/check.ps1` 或 `scripts/check.sh`。
4. 提交 PR,说明问题、实现、行为变化及验证环境。社区仓库建立后按其模板勾选 CLA。

新版本行为升级应明确更改兼容基线。不要无意中将 WHATWG URL 语义混入 1.5.10。使用非标准库依赖、FFI 或平台专用功能前需要说明原因及影响。

## 代码规范

生产代码采用仓颉 1.0.5 语法,运行时只依赖标准库。修改后使用 `cjfmt` 格式化本次涉及的生产文件。保留所有 SPDX 和上游许可证。新增 Markdown 的行内数学使用 `$...$`,独立数学使用 `$$...$$`;代码和路径的字面语法保持原样。

## 申请与合入

TPC 的创建申请、联系邮箱、CLA、孵化流程以 [HowToContribute](https://gitcode.com/Cangjie-TPC/TPC-Resource/blob/main/HowToContribute.md) 为准。需要真实申请人信息及社区批准的操作由提交者完成。具体材料见 `doc/tpc_submission.md`。

14. 把设计与验证写成可交接的文档

本节的五个 Markdown 文件也给完整内容。外层代码块比文件内部的三反引号更长,是为了显示整个文件;创建本地文件时只复制外层代码块的内容,保留内部代码块。文档中的相对链接在重建后的工程目录中解析,不是要求从博客再下载别的源码。

文件 32:doc/feature_api.md

这是 API 契约与样例功能映射。调用者查字段、构造器重载、set、回调和静态辅助接口时用它;审查者用它核对 ArkTS 样例的 40 个测试组是否落实到仓颉语义。它强调接口输入输出,不解释每一行实现。

# 功能与 API

所有公开类型属于 `url_parse`,使用 `import url_parse.*`。下列 `!` 表示仓颉命名参数,调用时写 `参数名: 值`;普通参数按位置传入。

## Url 与 parse

```cangjie
Url()
Url(address: String, base!: String = "", parseQuery!: Bool = false)
Url(address: String, base: UrlLocation, parseQuery!: Bool = false)
Url(address: String, base: Url, parseQuery!: Bool = false)
Url(address: String, parser: (String) -> Query, base!: String = "")
Url(address: String, base: UrlLocation, parser: (String) -> Query)
Url(address: String, base: Url, parser: (String) -> Query)

parse(): Url
parse(address: String, base!: String = "", parseQuery!: Bool = false): Url
parse(address: String, base: UrlLocation, parseQuery!: Bool = false): Url
parse(address: String, base: Url, parseQuery!: Bool = false): Url
parse(address: String, parser: (String) -> Query, base!: String = ""): Url
```

`Url` 实现 `ToString`。默认基地址为空,默认 query 不解析。构造器会刷新派生字段。基地址是字符串时先解析成 Url;是对象时复制其有效字段,原对象不被修改。

| 公开字段 | 类型 | 含义 |
|---|---|---|
| `protocol` | String | 协议,解析后通常带 `:` |
| `slashes` | Bool | 是否采用双斜杠表示 |
| `auth` | String | 已编码的 `username[:password]` |
| `username` / `password` | String | 已 URI component 编码的凭据 |
| `host` | String | hostname 与可选端口 |
| `hostname` | String | 不含端口的主机文本,不验证合法性 |
| `port` | String | 非默认端口文本,保留非默认端口的前导零 |
| `pathname` | String | 路径,特殊协议通常以 `/` 开始 |
| `query` | Query | Raw 或 Params |
| `hash` | String | 含 `#` 的 fragment |
| `href` | String | 构造或 set 后缓存的完整 URL |
| `origin` | String | 特殊协议 origin;file、非特殊协议或无 host 为 `null` 字符串 |

所有字段可直接读写,`Url` 是可变对象。直接赋值不触发联动刷新;正常修改使用 set。跨线程共享时由调用方同步。query 的 Params 保存同一可变 `QueryParams` 引用;修改其中的值后,可通过 `set("query", params)` 刷新 href。

## set

```cangjie
set(part: String, value: String, flag!: Bool = false): Url
set(part: String, value: Int64): Url
set(part: String, value: QueryParams): Url
set(part: String, value: Query): Url
set(part: String, value: String, parser: (String) -> Query): Url
```

返回同一实例,支持链式调用。类型为 Query/QueryParams 的重载仅作用于 query;其他字段不修改。Int64 重载将值转为文本。

| part | 规则 |
|---|---|
| `protocol` | Unicode 小写;`flag: true` 关闭 slashes;发布版不会在字段末尾自动补冒号 |
| `host` | 更新 hostname 和末尾数字 port;未带数字端口则清空 port;不消除默认端口 |
| `hostname` | 更新 host,保留当前 port |
| `port` | 默认端口和零端口清空,同时更新 host |
| `pathname` | 非空时自动补 `/` |
| `hash` | 非空时自动补 `#` |
| `username` / `password` | 将输入作为未编码文本执行 component 编码 |
| `auth` | 以第一个 `:` 分割,先解码再编码;无 `:` 时保留原 password |
| `query` | 非空 String 用内置 parser 或给定 parser;空 String 清空;Query/QueryParams 直接设置;非空且 `flag: true` 抛异常 |
| 其他 | 和上游一样不修改字段,但刷新派生字段 |

每次调用都会重新计算 auth、origin、href,并将 host/hostname 转为小写。非法认证百分号或 UTF-8 解码会抛出 `IllegalArgumentException`。如果回调抛异常,异常传递给调用方;setter 不承诺事务性回滚,和上游一致。

## toString

```cangjie
toString(): String
toString(stringify: (QueryParams) -> String): String
```

重新读取字段生成 URL。query 为 Params 才调用 stringify;Raw 直接使用原文本。序列化器返回非空文本时补 `?`,已有 `?` 则不重复补;返回空文本时省略查询。此方法不修改 href、origin。无 hostname 和有异常冒号后缀时,保留上游的无效 URL 防误修正行为。

## Query 与 QueryParams

```cangjie
public enum Query <: ToString {
    Raw(String) | Params(QueryParams)
}

QueryParams()
QueryParams(entries: Array<(String, String)>)
size: Int64                         // 只读 prop
contains(key: String): Bool
get(key: String): Option<String>
set(key: String, value: String): QueryParams
set(key: String, value: Int64): QueryParams
set(key: String, value: Bool): QueryParams
set(key: String, value: Option<String>): QueryParams
remove(key: String): Bool
entries(): Array<(String, String)>
```

查询值不自动推断数字或布尔。解析器返回 String;set 的便利重载分别转为十进制整数、`true`/`false` 和空值文本。QueryParams.set 更新已有值,parseQuery 的重复输入键则保留第一个值。这两种情况不同。

`entries()` 返回新的数组,按 JavaScript 对象键顺序输出。有效索引键范围为 `0` 至 `4294967294`,无前导零;例如 `2` 在 `10` 之前,`01` 是普通键。

```cangjie
let url = Url("https://x/?a=1", parseQuery: true)
match (url.query) {
    case Query.Params(params) => println(params.get("a") ?? "")
    case Query.Raw(raw) => println(raw)
}
```

## qs 与 component

`Url.qs` 是 `QueryString` 实例。可直接调用顶层函数,或通过 qs 调用。

也可用无参构造器 `QueryString()` 创建实例,其 parse/stringify 与 Url.qs 的签名相同。

```cangjie
parseQuery(text: String): QueryParams
stringifyQuery(params: QueryParams, prefix!: String = ""): String
encodeComponent(text: String): String
decodeComponent(text: String): String

Url.qs.parse(text: String): QueryParams
Url.qs.stringify(params: QueryParams, prefix!: String = ""): String
Url.qs.stringify(params: QueryParams, prefix: Bool): String
```

布尔 prefix 为 true 时补 `?`。空 QueryParams 的序列化结果始终为空,不保留前缀。查询解码将 `+` 转为空格,失败时忽略整个键值对;`decodeComponent` 本身保留 `+` 并在失败时抛异常。parseQuery 拒绝原型内置名称,即便对应查询文本未带 `?`,其扫描规则仍与上游一致。

## location

```cangjie
UrlLocation(protocol!: String = "", slashes!: Option<Bool> = None,
    host!: String = "", hostname!: String = "", port!: String = "",
    pathname!: String = "", href!: String = "", auth!: String = "",
    username!: String = "", password!: String = "", origin!: String = "null")

location(): UrlLocation
location(loc: String): UrlLocation
location(loc: Url): UrlLocation
location(loc: UrlLocation): UrlLocation
Url.location(): UrlLocation
Url.location(loc: String): UrlLocation
Url.location(loc: Url): UrlLocation
Url.location(loc: UrlLocation): UrlLocation
```

UrlLocation 字段与构造器名称一致,均可变。slashes 未指定时从 href 推断;显式 Some(false) 不被覆盖。location 返回独立对象,保留 auth、username、password、origin 和 href 信息,不带独立的 query/hash 字段。解析相对地址时不会继承认证、query 或 hash,即便 location 对象仍保存认证信息或 href 中含有查询。对象形式的 blob location 从 pathname 恢复原来的 origin URL,采用 legacy unescape;字符串形式的 blob 基地址保留 blob 协议,这个上游区别也被保留。

无参 location 返回空基地址,slashes 为 Some(false)。仓颉进程没有浏览器全局 location,应用需要时显式传入。

## extractProtocol、trimLeft 与 requiresPort

```cangjie
extractProtocol(address: String): ProtocolExtract
extractProtocol(address: String, base: UrlLocation): ProtocolExtract
Url.extractProtocol(address: String): ProtocolExtract
Url.extractProtocol(address: String, base: UrlLocation): ProtocolExtract
trimLeft(text: String): String
Url.trimLeft(text: String): String
requiresPort(port: String, protocol: String): Bool
ProtocolExtract(protocol: String, slashes: Bool, slashesCount: Int64, rest: String)
```

`ProtocolExtract` 是只读 struct,包含 `protocol: String`、`slashes: Bool`、`slashesCount: Int64`、`rest: String`。base 的 protocol 决定无协议输入的多斜杠处理。trimLeft 删除左侧控制字符和上游指定的 Unicode 空白。requiresPort 是为测试和复用额外公开的默认端口辅助函数。

requiresPort 按上游 ECMAScript 数值语法判断端口,包括十进制、小数、指数和无符号 `0x`/`0b`/`0o` 形式,以及数值两端的 Unicode 空白。零或无效数值返回 false;未知协议的非零数值返回 true。它不检查端口范围,也不会将 protocol 转为小写。Url 解析器仍只从 authority 的末尾数字提取端口;宽数值语法主要用于 port setter 和直接调用该辅助函数。

## 样例功能覆盖

| 用户指定样例的功能/用例组 | 本项目对应验证 |
|---|---|
| Index 页面各字段显示、protocol/query 设置和 toString | `examples/demo`、absoluteFields、setterPropagation |
| URL、参数、数组格式原样保留 | absoluteFields、queryModesAndCallbacks |
| location、extractProtocol、trimLeft 暴露及控制字符清理 | explicitAndBlobLocations、protocolExtractionAndControls |
| 内置查询解析、空查询、自定义 parser/stringify | queryModesAndCallbacks、setterAuthenticationAndCallbacks |
| 自定义基地址、相对路径、不继承认证/query/hash | relativeResolution |
| 主机/协议小写、路径大小写、origin | casingSlashesAndOrigins、portsAndIPAddresses |
| 默认端口、反斜杠、多斜杠、无协议双斜杠 | portsAndIPAddresses、casingSlashesAndOrigins、relativeResolution |
| protocol、hostname、host、IPv6 setter | setterPropagation、releaseSpecificSetProtocol |
| 上游样例以外的认证/file/blob/qs 边界 | authentication、fileAndOpaqueURLs、显式 blob 测试、query 测试 |

该对照按功能分组,不声称将 ArkTS 平台的测试生命周期、UI 框架或 typeof 动态类型检查移植成仓颉运行时。

文件 33:doc/design.md

记录来源、解析顺序、编码和静态语言适配,以及 1.5.10 的特殊 setter 行为。这份“为什么”文档用来阻止未来维护者把兼容点误当 bug 随手改变。例如 protocol 不补字段冒号、auth 保留旧密码、字符串/对象 blob 路径差异,都需和上游基线一起判断。

# 移植设计与兼容边界

## 1. 来源锁定

核对日期:2026-10-01。

| 资源 | 版本 / 提交 | 用途 |
|---|---|---|
| [TPC-Resource](https://gitcode.com/Cangjie-TPC/TPC-Resource) | `8901a5aaa7bf8f9a6f0c73c0858570d704472d2b` | 清单、贡献要求、项目模板 |
| [OpenHarmony 样例仓库](https://gitcode.com/CPF-ApplicationTPC/openharmony_tpc_samples/tree/master/url_parse) | `3fa03b4580d198971d3336b3cc8ed8ac5e8d964a` | 样例功能及测试需求 |
| [url-parse](https://github.com/unshiftio/url-parse/tree/1.5.10) | 标签 `1.5.10`,提交 `8cd4c6c6435c1ea32243ec20c9cfe535251ec524` | 核心行为基线 |
| [querystringify](https://github.com/unshiftio/querystringify) | 2.2.0,`60d8dd92aeb5550fb79b93e6dae193d02e029daf` | 查询语义,满足上游 `^2.1.1` |
| [requires-port](https://github.com/unshiftio/requires-port) | 1.0.0,`07e8e8298f892243734d695e884a78a56e3dccb9` | 默认端口行为 |

样例仓库只声明依赖范围 `^1.5.10`,没有在检出的目录中提供一个锁定解析结果的锁文件,因此不能声称其所有历史安装都使用相同发布文件。移植选择其登记的 1.5.10 版本,并将用于测试的实现保存在 `test/reference`,测试不依赖在线 npm 解析。

## 2. 解析顺序

解析先清理左侧控制字符及 ECMAScript 空白,并删除 CR/HT/LF。随后提取 scheme、斜杠数和剩余文本,决定是否存在 authority。后续严格按上游顺序执行:fragment、query、特殊协议反斜杠替换、pathname、最后一个 `@` 前的 auth、host、端口后缀、hostname。该顺序防止把 path/query/hash 中的分隔符错当成 authority。

host 和 hostname 使用标准库 Unicode 小写转换;路径、认证字符串和查询值保持大小写。相对地址仅继承 protocol、slashes、host、hostname 和 port,不继承 query、hash 或 auth。带斜杠的基地址通过删除目录中的 `.` 与 `..` 解析相对 pathname。默认端口从 host 和 port 同时消除。

`set` 以字段操作实现联动,然后刷新 auth、origin、href。`toString` 自行读取当前公开字段,支持自定义查询序列化;和上游一样,仅修改公开字段不会自动刷新缓存的 href。

## 3. 查询与编码

`QueryParams` 用 HashMap 存值,用有序键表保留插入次序。序列化按 JavaScript 对象枚举规则先输出有效 uint32 索引键,再输出普通键。查询解析模拟上游正则 `([^=?#&]+)=?([^&]*)` 的扫描规则,保留首个重复键,跳过 Object.prototype 名称以及无效编解码的键值对。

URI component 编码操作 UTF-8 字节,安全字符集合为 `A-Z a-z 0-9 - _ . ! ~ * ' ( )`。解码校验百分号和 UTF-8,拒绝过长编码、代理区码点和超过 Unicode 最大码点的输入。查询解码另外将 `+` 转为空格;auth 解码保持 `+`。blob 基地址使用上游的 legacy `unescape` 行为,支持 `%XX`、`%uXXXX` 及配对代理字符。

## 4. 静态语言适配

| JavaScript 行为 | 仓颉表示 |
|---|---|
| `Url()` / `new Url()` | `parse()` / `Url()` |
| 第三个参数 boolean | 命名参数 `parseQuery: true` |
| parser 返回字符串或对象 | `(String) -> Query`,返回 `Raw` 或 `Params` |
| 普通 location 对象 | `UrlLocation`,存解析所需字段和 auth/username/password/origin 元数据,不复制任意额外 JS 属性 |
| 全局浏览器 / worker location | 调用方显式传入 location;默认空基地址,和 Node 行为一致 |
| query 普通对象 | `QueryParams`,值归一为字符串;整数、布尔和空值提供便利 set 重载 |
| 任意对象、数组、自定义 JS coercion | 调用方转换为所需字符串,或用自定义 parser/stringify 定义格式 |
| URIError | `IllegalArgumentException` |
| 无效 UTF-16 代理字符 | 仓颉 String 使用有效 UTF-8;blob 解出孤立代理字符时拒绝 |
| setter 的非函数动态参数错误 | 函数回调由编译器检查;非空 query 搭配 `flag: true` 抛 IllegalArgumentException |

任意 JavaScript 对象身份、原型继承及隐式类型转换不属于仓颉公开类型系统。本项目保留样例的所有 URL 功能,不能当成 JavaScript 运行时仿真器。数值查询参数若需要科学计数法、NaN、Infinity 的特殊文本,应先按业务规定转换成 String;`None` 对应空查询值。

端口判断另行模拟 ECMAScript 数值语法,避免标准库浮点解析器接受 `inf`、带符号十六进制等非 JS 输入,也支持两端 Unicode 空白和二/八进制输入。默认端口判断不等于端口合法性检查。location 返回的认证元数据保留,但相对 URL 不继承这些元数据;字符串形式的 blob 基地址与对象形式的 blob location 按发布版分别处理。

## 5. 发布版行为

当前主分支仍标记 package version 为 1.5.10,但已经有发布标签之后的代码改动。本移植没有混用这些改动:

- 发布版仅清理左侧空白,不清理末尾普通空格及控制字符。
- `set("protocol", "https")` 保留字段值 `https`,`toString()` 补冒号;origin 因不是特殊协议的完整字符串而为 `null`。建议实际使用传 `https:`。
- 发布版 `set` 的 flag 对 protocol 控制 slashes;pathname/hash 仍自动补前缀。
- `set("auth", "newuser")` 不清除已有 password,和上游一致。显式清除应调用 `set("password", "")`。
- `set("host", "example.com:80")` 直接设置端口;仅 port setter 执行默认端口消除。
- 未识别的 setter 字段不修改 URL,但刷新派生字段。

这些兼容点通过回归测试固定。库会保留上游允许的不合法 hostname,不执行 DNS、IDNA、网络请求或 URL 安全判定;调用方需要相应策略时应独立校验。

## 6. 工程与审查

生产文件仅四个 `.cj`,独立示例和差分程序通过 path dependency 验证公开 API。对照源码仅参与离线测试。生成用例不进入生产构建,发布脚本不打包编译缓存、临时目录或认证信息。

验收以实际编译、原生回归测试、独立应用接入和发布标签差分为准。完整复现步骤及实际结果见 `doc/validation.md`,不以代码审查或单一行覆盖率替代行为验证。

文件 34:doc/validation.md

保存实际执行环境、原生测试数量、差分分布、fixture 哈希、覆盖率与干净解压验收结果。它是已发布 0.1.0 的验收记录,不是读者复制之后自动拥有的测试证明;读者应在自己的平台实际执行,并在将来发布时更新记录。

# 实际验收记录

验收日期:2026-10-01。以下结果来自实际编译和执行,不是预期值或模拟输出。

## 环境与版本

| 项目 | 实测值 |
|---|---|
| 库版本 | 0.1.0,static,包名 url_parse |
| 操作系统 / 目标 | Windows x86_64 / x86_64-w64-mingw32 |
| cjc | 1.0.5 (cjnative) |
| cjpm | 1.0.5 |
| PowerShell | 7.6.5 |
| Node.js | v22.20.0,仅用于离线测试 |
| URL-Parse 基线 | 发布标签 1.5.10,提交 8cd4c6c6435c1ea32243ec20c9cfe535251ec524 |
| 生产依赖 | 仓颉 std 标准库,无外部运行时依赖 |

## 构建与功能验收

在根目录运行 `./scripts/check.ps1`,构建、原生测试、独立示例、用例生成和独立差分程序均通过:

```text
cjpm build success
Summary: TOTAL: 16
    PASSED: 16, SKIPPED: 0, ERROR: 0
    FAILED: 0
cjpm test success
DIFFERENTIAL: 2000 fixtures, 0 failures
```

16 个原生测试函数包含多组断言与循环,不应误称为仅验证了 16 个 URL。覆盖字段、相对路径、基地址、认证、IPv4/IPv6、默认端口、file/opaque 协议、Unicode/控制字符、全部 setter、回调、查询扫描/排序/编解码、辅助接口与公开重载。

指定 OpenHarmony 样例的 `Ability.test.ets` 有 40 个测试组。其 URL 功能在本项目按语义重组,映射见 [功能与 API](feature_api.md);未移植 ArkTS UI、Hypium 生命周期或 JavaScript typeof 检查。

`examples/demo` 是独立 cjpm executable,通过 `path = "../.."` 导入公开库。已验证 SDK 构建依赖、导入、运行和字段联动。教程第 6 至 11 步的所有操作示例另在独立应用中汇总编译运行,并对注释中的关键输出作断言,结果为 `TUTORIAL: steps 6-11 passed`。

## 上游差分

生成器直接执行 `test/reference` 内未修改的上游源码,不使用本移植计算期望值。公开参考文件已经与锁定检出版本逐文件 SHA-256 比较一致。测试无需网络或 npm 安装。

固定种子 `0x5eed`,当前用例分布:

| 操作 | 数量 | 对比内容 |
|---|---:|---|
| P | 211 | URL 构造:13 个公开字段及 query 的 Raw/Params 状态 |
| S | 1091 | setter 后同样逐字段对比,包括特殊端口数值和 flag |
| E | 432 | extractProtocol 的 protocol、slashes、slashesCount、rest |
| Q | 138 | querystringify 解析后按上游顺序序列化 |
| T | 128 | trimLeft 的精确字符串结果 |
| 合计 | 2000 | 0 失败;其中 52 条为期望抛异常的输入 |

用例包括发布版特有行为、空串、相对地址、认证、各协议、斜杠/反斜杠、空白、非法百分号和 UTF-8、重复键、原型名称、数字键顺序、自定义查询结果和端口指数/进制输入。异常用例比较“是否拒绝”,不比较跨语言的异常类名或错误文案。对象形式的 location、复杂回调及静态类型便利重载由原生测试补充,而非该 TSV 格式穷举。

重复生成的 fixture 文件 SHA-256 一致:

```text
e466c32dab80d093d6c780274a05d75391888a1569c68638666479218fd4ebfa
```

fixture 是 UTF-8 十六进制 TSV;空字段和控制字符均能无损保存。`test/fixtures` 不打包,运行脚本会从参考实现重新生成。

## 行覆盖率

执行以下命令,统计仅来自原生测试:

```powershell
cjpm test --coverage --no-color --no-progress
cjcov --root . --source src --output target/coverage --json --html-details
```

从 `target/coverage/coverage.json` 的 `fileLists` 读取 `filepath`、`totalLines` 与 `hitLines`,排除 `_test.cj`。四个生产文件结果如下:

| 生产文件 | 命中行 / 可执行行 | 行覆盖率 |
|---|---:|---:|
| helpers.cj | 206 / 216 | 95.37% |
| location.cj | 39 / 39 | 100.00% |
| query.cj | 130 / 132 | 98.48% |
| url.cj | 195 / 204 | 95.59% |
| 总计 | 570 / 591 | 96.45% |

这是 cjcov 的可执行行口径,不是文件物理行数;覆盖率不代表全部分支、全部可能输入或安全性已得到证明。差分执行没有并入此统计。报告对应已格式化的生产源码,编译产物和报告不放入发布包,使用上述命令重建。

## 发布完整性检查

打包命令为 `./scripts/package.ps1`。脚本按白名单纳入源码、测试、示例、工具、文档和完整许可,产生 `dist/url_parse-0.1.0.zip` 及包内 `MANIFEST.sha256.json`。

实际已完成空目录解压验收:包内 36 个文件的 SHA-256 全部与清单一致;未包含 target、dist、cov_output 或生成的 fixtures。解压后的 `./scripts/check.ps1` 完整通过,原生测试仍为 16 组通过,差分仍为 2000 条、0 失败。该目录从零生成构建产物,未复制原工程的缓存;重新生成覆盖率得到上表结果。后续任何改动都应重新打包并复验。

## 已知边界

- 已验收 Windows x86_64;Linux、macOS 和 OpenHarmony 交叉编译/设备运行尚未实测,不能声称已完成这些平台认证。
- 兼容目标为 1.5.10 发布版,不是 WHATWG URL、DNS/IDNA、端口范围或网络安全校验。
- 仓颉的有效 UTF-8 String 和显式 Query/UrlLocation 类型不能复制任意 JavaScript 对象或孤立 UTF-16 代理字符;具体适配见 [设计说明](design.md)。
- 社区维护身份、CLA、正式仓库和社区评审需申请人完成。本地验收不是社区收录证明。

文件 35:doc/tpc_submission.md

把已有材料映射到 TPC 要求,给申请 issue 与初始 PR 的草稿。占位的姓名、邮箱、身份、真实仓库地址必须由申请人补齐,不能照抄成正式信息。它不包含凭据,也没有自动发送邮件或提交 issue 的逻辑。

# TPC 接入材料

## 已准备的技术材料

| 要求 | 本项目材料 |
|---|---|
| README、版本、SDK、状态和领域 | 根 README.md;0.1.0,仓颉 1.0.5,拟孵化 |
| LICENSE | 根 MIT LICENSE,保留上游许可 |
| CHANGELOG | 根 CHANGELOG.md |
| cjpm.toml | cjpm init 生成并完善的 static 清单 |
| 公开 API、架构与使用示例 | doc/feature_api.md、doc/design.md、examples/demo |
| 测试与验证环境 | src/*_test.cj、差分程序、doc/validation.md |
| 来源与三方开源清单 | README.OpenSource、NOTICE、test/reference 中许可证 |
| 初始化包 | scripts/package.ps1 生成的 dist/url_parse-0.1.0.zip |
| 从零复现 | doc/tutorial.md |

参考 [项目规范](https://gitcode.com/Cangjie-TPC/TPC-Resource/blob/main/Cangjie-TPC-Rules/Cangjie-TPC%E9%A1%B9%E7%9B%AE%E7%9B%B8%E5%85%B3%E8%A7%84%E8%8C%83.md) 与 [贡献流程](https://gitcode.com/Cangjie-TPC/TPC-Resource/blob/main/HowToContribute.md)。社区可能更新要求,正式提交时复核。

## 创建申请草稿

```text
项目名称:url_parse
类别:通用工具 / URL 解析
当前版本:0.1.0(拟孵化)
仓颉版本:1.0.5 (cjnative)
开源协议:MIT,已保留上游版权和许可证

项目描述:
将 URL-Parse 1.5.10 的解析、相对地址、基地址、认证编码、查询处理、
字段联动设置和序列化能力移植为纯仓颉静态库。运行时只依赖标准库。

需求依据:
TPC-LIST.md 中 url-parse 条目;OpenHarmony url_parse 样例依赖 ^1.5.10,
README.OpenSource 登记 1.5.10。移植以发布标签1.5.10而非master为基线。

上游:
https://github.com/unshiftio/url-parse/tree/1.5.10
提交:8cd4c6c6435c1ea32243ec20c9cfe535251ec524

主要接口:
Url / parse / set / toString / location / extractProtocol / trimLeft / qs。
公开字段和静态类型适配见 doc/feature_api.md。

验证:
Windows x86_64 + 仓颉1.0.5;原生单测、独立应用、离线上游差分。
具体数值及复现命令见 doc/validation.md。
其他平台尚待对应SDK验证,不声称已完成鸿蒙设备验收。

申请人/维护者:[由真实申请人填写]
联系邮箱:[由真实申请人填写]
组织/个人身份:[由真实申请人填写]
建议committer:[由申请人登记,社区确认]
源码托管地址:[填真实地址,建仓前可提供初始化zip]
```

这些身份字段是社区申请所需信息,不是库实现的配置。完成真实登记后更新 README 与 CONTRIBUTING。不要将示例文字作为真实联系方式提交。

## 初始 PR 草稿

```text
标题:[url_parse][需求] 移植 URL-Parse 1.5.10 为仓颉静态库

[issue number]:填写实际申请/需求issue
[impact files]:src、examples、test、scripts、doc、cjpm.toml、许可与来源文件

提供绝对与相对URL解析、显式基地址、认证编码、查询解析、字段联动修改
及序列化,覆盖OpenHarmony url_parse样例使用的功能。生产代码只使用std。
使用Query/QueryParams和UrlLocation表达动态API,兼容边界已记录。

验证:
scripts/check.ps1通过;实际工具链及差分结果见doc/validation.md。
已保留MIT许可、上游来源与版本记录。
```

## 仍需社区/申请人完成的步骤

真实身份和维护承诺、正式仓库命名可用性、CLA 签署、目标平台及覆盖率门槛、社区评审与合入。此项目未发送邮件、创建远程 issue、签署协议、创建社区仓库或声称已经入库。

文件 36:doc/tutorial.md

这是随源码包交付的简版运行与提交手册,便于离开博客后查命令。文章正文已经补上了完整代码和模块教学;此文件保留冻结 0.1.0 包中的原内容,避免为了自包含而递归把整篇文章嵌入自身。按本文创建此文件即可,不需额外资料。

# 从零完成 URL-Parse 仓颉移植与三方库提交

本文使用仓颉 1.0.5 (cjnative),库名 `url_parse`,移植版本 `0.1.0`,兼容 URL-Parse 1.5.10 发布版。整个流程从环境准备到独立应用接入、测试、发布包和社区申请。仓库已经包含对应源码、示例和脚本,可以逐步运行核对。

## 0. 明确交付目标

目标来源是 [TPC 清单](https://gitcode.com/Cangjie-TPC/TPC-Resource/blob/main/TPC-LIST.md) 中的 url-parse 和 [OpenHarmony 样例](https://gitcode.com/CPF-ApplicationTPC/openharmony_tpc_samples/tree/master/url_parse)。该样例通过 npm/ohpm 调用 JavaScript 库;本项目提供仓颉原生库。

完成标准如下:能够读取所有 URL 字段;解析绝对地址和带基地址的相对地址;支持 auth、IPv6、file、自定义协议;内置或自定义 query parser;修改字段并刷新相关字段;内置或自定义 stringify;实现 location/extractProtocol/trimLeft/qs;可作为其他仓颉应用的依赖;提供可复现测试、许可和贡献资料。

社区收录需要申请人真实身份、CLA 和维护者评审。本文完成技术与材料准备,并说明提交步骤,不将本地完成等同于社区已批准。

## 1. 安装与检查工具

从 [仓颉官方下载中心](https://cangjie-lang.cn/download) 取得适合操作系统的 1.0.5 SDK,按其安装说明安装或解压。使用 Windows PowerShell 7 时,在当前终端加载 SDK 环境脚本;路径按安装位置调整:

```powershell
. 'C:\Program Files (x86)\Cangjie\envsetup.ps1'
cjc -v
cjpm --version
cjfmt --help
```

本次 `cjc -v` 实测输出:

```text
Cangjie Compiler: 1.0.5 (cjnative)
Target: x86_64-w64-mingw32
```

Linux/macOS 对应 SDK 通常提供 `envsetup.sh`,在 shell 中使用 `source` 或 `.` 加载。后续同样先核对 `cjc -v`。本项目的跨平台源码尚未在这些平台实际验收。

库编译和原生测试需要 SDK;离线差分测试还需要 Node.js。安装 Node.js 后用 `node --version` 验证。项目的差分脚本只使用 Node 内置模块和仓库内的参考源码,无需 `npm install`。运行库的应用不需要 Node.js。

## 2. 核对并锁定上游

下载样例后读取以下两个文件:

```text
url_parse/entry/oh-package.json5  -> "url-parse": "^1.5.10"
url_parse/README.OpenSource    -> "Version Number": "1.5.10"
```

`^1.5.10` 是版本范围。核对功能时使用样例的 README、Index.ets 和 Ability.test.ets;核对实现时使用原始库的发布标签。

可以在独立参考目录执行以下命令锁定原始源码:

```powershell
git clone https://github.com/unshiftio/url-parse.git url-parse-reference
Set-Location url-parse-reference
git switch --detach 1.5.10
git rev-parse HEAD
```

预期提交是 `8cd4c6c6435c1ea32243ec20c9cfe535251ec524`。标签是 annotated tag 时,标签对象的 SHA 与提交 SHA 不同,记录 HEAD 的提交 SHA。

当前 master 的 package.json 仍登记 1.5.10,但已有标签之后的代码更改。可用 `git diff 1.5.10 origin/master -- index.js` 检查,不能仅凭 package version 判定实现一致。本项目的完整来源、依赖版本和兼容差异在 `doc/design.md`。

## 3. 创建静态库工程

从空目录创建时,使用 cjpm 生成完整清单:

```powershell
cjpm init --name url_parse --type=static --path url-parse-cangjie
Set-Location url-parse-cangjie
```

由 cjpm 初始化后,将本项目的四个生产文件放入 `src`。最终主要目录如下:

```text
url-parse-cangjie/
  cjpm.toml
  src/
    helpers.cj
    location.cj
    query.cj
    url.cj
    query_test.cj
    url_test.cj
  examples/demo/
  test/oracle_driver/
  test/reference/
  scripts/
  doc/
  README.md
  README.OpenSource
  LICENSE
  NOTICE
  CHANGELOG.md
  CONTRIBUTING.md
```

所有库文件声明 `package url_parse`,测试同包以便验证内部边界。`cjpm.toml` 的输出类型是 `static`,版本是 `0.1.0`;TPC 规范要求毕业前版本小于 1.0.0。该库只依赖 std.core、std.collection、std.convert、std.sort 和 std.unicode。

## 4. 理解移植分层

先实现纯函数和查询模型,再实现 location 与 Url。每一层都可以编译验证:

1. `helpers.cj` 保存 ProtocolExtract、协议/斜杠扫描、trimLeft、端口规则和相对路径解析。
2. `query.cj` 提供严格 UTF-8 component 编解码、QueryParams、查询扫描及序列化。
3. `location.cj` 提供可复制的显式基地址,处理 blob location。
4. `url.cj` 按上游解析顺序组合各层,提供 set/toString/parse 和辅助接口。

字符串索引是 UTF-8 **字节**索引。URL 中的 ASCII 分隔符可以按字节查找;Unicode 空白和 legacy `%uXXXX` 处理使用 Rune。不要将 byte 索引当成字符索引。`String.fromUtf8` 用来检查解码后的合法性。

解析时先分离 `#` 和 `?`,随后只对余下部分做特殊协议反斜杠规范化,最后提取 path 和 authority。auth 用最后一个 `@` 定位,用户名/密码用第一个 `:` 分割。将这种顺序改成一个通用 split,会改变认证、路径或 fragment 的行为。

## 5. 首次编译和运行测试

在项目根目录执行:

```powershell
cjpm build
cjpm test --no-color --no-progress
```

`cjpm build success` 和 `cjpm test success` 才是完成此步的证据。测试文件必须位于 cjpm 发现的 `src/*_test.cj` 中,单独放入 test 目录的文件不会自动执行。本项目使用 `std.unittest.*`、`std.unittest.testmacro.*`、`@Test` 与 `@Assert`。

遇到编译问题先查 SDK 版本和命名参数:声明为 `base!: String` 时写 `base: "..."`;普通参数 `base: Url` 按位置传入。仓颉的普通位置参数不能直接写默认值,本项目用无参构造器表达空 URL。

## 6. 在另一个应用中接入

库旁边创建应用:

```powershell
Set-Location ..
cjpm init --name url_parse_app --type=executable --path url_parse_app
Set-Location url_parse_app
```

在应用 `cjpm.toml` 的 dependencies 节添加:

```toml
[dependencies]
url_parse = { path = "../url-parse-cangjie" }
```

替换该应用 `src/main.cj` 为完整可执行例子:

```cangjie
package url_parse_app
import url_parse.*

main(): Int64 {
    let url = Url("https://www.example.com:8080/path?param1=value1&param2=value2#section")
    println(url.protocol)
    println(url.hostname)
    println(url.port)
    println(url.pathname)
    println(url.query.toString())
    println(url.hash)
    println(url.origin)
    url.set("protocol", "https:").set("query", "newParam=value")
    println(url.href)
    return 0
}
```

运行 `cjpm run`,最后一行应为:

```text
https://www.example.com:8080/path?newParam=value#section
```

仓库自带的 `examples/demo` 用 `path = "../.."` 引用根库,已经验证独立依赖构建和执行。可以直接进入该目录运行,无需再创建应用。

## 7. 相对地址和基地址

下列代码放在应用的 main 内:

```cangjie
let relative = Url("../next?x=1", base: "https://example.com/a/b/page?old=1#old")
println(relative.href)
// https://example.com/a/next?x=1

let base = Url("http://user:pass@example.com:8080/dir/page")
let child = Url("child", base)
println(child.href)
// http://example.com:8080/dir/child
```

auth、query 和 hash 不从 base 继承。`//host/path` 有无特殊协议基地址会影响解析;默认没有浏览器全局 location,结果遵循空基地址语义。

对象基地址需提供上游相同的各字段,href 不会自动填充遗漏的 hostname/host:

```cangjie
let objectBase = UrlLocation(protocol: "https:", slashes: Some(true),
    host: "example.com", hostname: "example.com", pathname: "/a/b")
println(Url("c", objectBase).href)
// https://example.com/a/c

let blobBase = UrlLocation(protocol: "blob:", pathname: "https%3A//example.com/id")
println(Url("/next", blobBase).href)
// https://example.com/next
```

## 8. 读取和修改查询对象

默认 `Query.Raw` 保留查询的原始文本和 `?`。启用内置解析后使用模式匹配:

```cangjie
let url = Url("https://example.com/?name=A+B&count=2", parseQuery: true)
match (url.query) {
    case Query.Params(params) =>
        println(params.get("name") ?? "") // A B
        params.set("count", 3).set("enabled", true)
        url.set("query", params)
    case Query.Raw(raw) => println(raw)
}
println(url.href)
// https://example.com/?name=A%20B&count=3&enabled=true
```

原始查询值中的逗号不自动变成数组,数字也不自动转为数值。需要数组或复杂值时由调用方明确选择文本格式或自定义回调。

内置 parser 保留第一个重复键,跳过非法百分号或非法 UTF-8 的整个键值对,拒绝原型内置名称。创建参数集合时则可以更新任意已有键:

```cangjie
let params = parseQuery("?a=first&a=second&bad=%FF&toString=x")
println(stringifyQuery(params)) // a=first
params.set("a", "changed")
println(Url.qs.stringify(params, true)) // ?a=changed
params.set("empty", Option<String>.None)
println(params.get("empty") ?? "missing") // 空字符串
```

component 编码对中文等文本按 UTF-8 编码,对空格用 `%20`。原型名限制只作用于 parser 输入,显式构造 QueryParams 时保留调用方的键。有效数字索引键会先按数值升序输出。

## 9. 自定义 parser 和 stringify

自定义 parser 的类型为 `(String) -> Query`,在传入空查询时也会调用:

```cangjie
let custom = Url("https://example.com/?old=1", { raw: String =>
    Query.Params(QueryParams([("received", raw)]))
})
println(custom.href)
// https://example.com/?received=%3Fold%3D1

let stringResult = Url("https://example.com/?a=1", { _: String => Query.Raw("1337") })
println(stringResult.href) // https://example.com/?1337
```

toString 的自定义回调只在 query 为 Params 时执行:

```cangjie
let parsed = Url("https://example.com/?a=1", parseQuery: true)
println(parsed.toString({ _: QueryParams => "custom=42" }))
// https://example.com/?custom=42
println(parsed.href) // https://example.com/?a=1
```

toString 不修改 href。设置时也可给 parser:

```cangjie
parsed.set("query", "input", { _: String => Query.Raw("output") })
println(parsed.href) // https://example.com/?output
```

## 10. 修改其他字段

```cangjie
let url = Url("http://example.com:8080/a")
url.set("hostname", "OTHER.EXAMPLE")
println(url.host) // other.example:8080
url.set("port", 80)
println(url.host) // other.example
url.set("pathname", "new/path").set("hash", "top")
println(url.href) // http://other.example/new/path#top
url.set("host", "[::1]:9000")
println(url.hostname) // [::1]
url.set("username", "user@name").set("password", "p:word")
println(url.auth) // user%40name:p%3Aword
```

username/password setter 接收未编码文本,因此设置 `%40` 会编码为 `%2540`;auth setter 接收可编码文本,先解码再编码。不要将已编码用户名误传入 username setter。

为保持发布版行为,`set("protocol", "https")` 的 protocol 字段不会自动补 `:`,虽 href 补了冒号,origin 仍可能为 `null`。正常使用传 `https:`。`set("auth", "new")` 保留已有密码;需要清除时再调用 `set("password", "")`。完整兼容表见 `doc/design.md`。

## 11. 辅助接口和异常

```cangjie
let info = Url.extractProtocol("https:\\example.com/a")
println(info.protocol) // https:
println(info.slashesCount) // 1
println(info.rest) // example.com/a
println(Url.trimLeft("  http://x/")) // http://x/
println(Url.location("https://example.com/a?ignored=1#ignored").pathname) // /a
```

认证信息中的不合法百分号序列会使构造器或 auth setter 抛出 `IllegalArgumentException`。根据输入边界处理异常:

```cangjie
try {
    let bad = Url("http://%ZZ@example.com/")
    println(bad.href)
} catch (e: IllegalArgumentException) {
    println(e.message)
}
```

查询 parser 对非法键值对采用忽略策略。`decodeComponent` 直接调用则抛异常。自定义回调的异常会向外传递。

## 12. 执行上游差分验证

Windows 在项目根目录运行:

```powershell
& ./scripts/check.ps1
```

该脚本依次构建库、运行原生测试、运行独立示例、用锁定的 JS 实现生成固定种子用例,再构建并运行独立仓颉差分程序。每个失败都会返回错误。只需本地 Node,不需网络。

也可以分步执行:

```powershell
node scripts/generate-fixtures.cjs
Push-Location test/oracle_driver
cjpm build
Pop-Location
& ./test/oracle_driver/target/release/bin/main.exe ./test/fixtures/oracle.tsv
```

fixture 用 UTF-8 十六进制 TSV 保存,控制字符不破坏行边界。P/S 操作比较全部 13 个公开字段和 query 的 Raw/Params 状态,E 比较协议提取,Q 比较查询解析/序列化,T 比较 trimLeft。生成的 manifest 记录种子和各类型数量。实际执行数及结果见 `doc/validation.md`。

Linux 可运行 `sh scripts/check.sh`;需使用该平台 SDK,自行完成平台验收。`check.ps1 -SkipDifferential` 仅检查原生功能,不能代替完整发布验收。

## 13. 格式化和覆盖率

只格式化修改的生产文件,不盲目格式化上游参考源码:

```powershell
cjfmt -f src/helpers.cj
cjfmt -f src/location.cj
cjfmt -f src/query.cj
cjfmt -f src/url.cj
```

代码发生修改后重新执行完整检查。可选覆盖率流程:

```powershell
cjpm test --coverage --no-color --no-progress
cjcov --root . --source src --output target/coverage --json --html-details
```

报告是 `target/coverage/index.html` 和 `coverage.json`。统计生产文件时排除 `_test.cj`;行覆盖率不等于分支覆盖率,也不等于所有可能输入都验证过。报告必须对应当前源码,格式化或修改后应重新生成。SDK 版本变化后先执行 `cjpm clean`,避免复用不兼容制品。

## 14. 准备发布包

根目录运行:

```powershell
& ./scripts/package.ps1
```

产生 `dist/url_parse-0.1.0.zip`,其中包含源码、原生测试、示例、差分脚本、上游测试参考、文档和许可证,以及 `MANIFEST.sha256.json`。打包脚本按白名单选择目录,不包含 target、生成的 fixtures、工作区临时资料或个人环境信息。保留的少量 `test/reference/.../node_modules` 是明确锁定的公开测试源码,并非应用 npm 安装目录。

在另一个空目录解压发布包后重新运行完整检查,验证相对路径依赖和许可文件均完整。SHA-256 清单用于核对打包内容;不要仅以 zip 创建成功当作库验收。

仓库正式托管后,可以在消费者中使用其真实 git URL;在社区仓库尚未创建时,使用经过验证的 path 依赖。不要在项目中填写一个并不存在的远程仓库地址。

## 15. 提交 Cangjie-TPC

依据 [HowToContribute](https://gitcode.com/Cangjie-TPC/TPC-Resource/blob/main/HowToContribute.md) 和项目规范准备以下流程:

1. 在 TPC-Resource 使用“项目创建申请” issue 模板登记名称、用途、上游、许可、维护者、功能和验收环境。
2. 填写官方项目创建申请文档,将初始化包作为附件发送给社区指定邮箱。联系信息以最新官方流程为准。
3. 社区评审通过后,根据实际身份签署个人 ICLA 或组织 CCLA。
4. 社区创建仓库并邀请维护者后,提交源码、许可证和可复现测试;按孵化与质量评审要求继续处理反馈。
5. 若已有对应仓库,按它的 issue/PR 流程贡献,并使用真实仓库地址及版本更新接入说明。

本项目附 `doc/tpc_submission.md`,提供技术描述、初始化材料清单、issue 文本和 PR 文本。真实姓名、邮箱、组织、committer 与 CLA 由申请人填写或签署,不由代码生成器虚构。提交前再确认清单中是否已出现其他 URL-Parse 仓颉实现,避免重复建库。

## 16. 常见故障

| 现象 | 检查与处理 |
|---|---|
| 找不到 cjc/cjpm | 在当前终端加载 SDK envsetup;核对 PATH 与 SDK 平台 |
| 运行时报 DLL 缺失 | 重新加载同版本 SDK 环境,确认 runtime/lib 与 tools/lib 在 PATH |
| 找不到 url_parse | 检查消费工程的依赖 path 相对于它自己的 cjpm.toml;package 名为 url_parse |
| query 没有对象成员 | Query 是枚举,用 match 取得 QueryParams,再调用 get/set |
| 原生测试数量为零 | 检查测试在 src、文件名以 `_test.cj` 结尾,且导入测试宏 |
| 主机大小写正常但 auth 编码不同 | 对照 username/password 与 auth 两类 setter 的编码契约 |
| 相对 URL 未继承基地址 | 显式传 base;对象 base 须提供 host/hostname/pathname 等字段 |
| 修改 Params 后 href 不变 | 再调用 `url.set("query", params)`;直接改字段或引用不触发缓存刷新 |
| 差分测试有失败 | 阅读前 20 个不同字段,核对发布标签而不是 master;增加原生回归测试后修复 |
| 更换 SDK 后链接错误 | 运行 cjpm clean 后重新构建各工程 |
| 社区不接受申请 | 按反馈补齐维护信息、许可、目标平台证明或质量门槛,保留验证记录 |

完成后应能从干净解压目录通过完整检查、从独立应用导入库,并从许可和来源记录追溯每个参考实现。

15. 最终验收与 Cangjie-TPC 提交

创建完 36 个文件后,回到根目录,从头执行完整检查、覆盖率和打包:

& ./scripts/check.ps1
cjpm test --coverage --no-color --no-progress
cjcov --root . --source src --output target/coverage --json --html-details
& ./scripts/package.ps1

再选一个新的空路径解压自己刚生成的 ZIP,并在该目录运行检查。这里的路径是示例,可换成自己的;不要复用有旧缓存的工程。

$verifyDirectory = Join-Path $env:TEMP ('url-parse-check-' + [guid]::NewGuid().ToString('N'))
Expand-Archive -LiteralPath ./dist/url_parse-0.1.0.zip -DestinationPath $verifyDirectory
Push-Location (Join-Path $verifyDirectory 'url_parse-0.1.0')
$manifest = Get-Content -LiteralPath MANIFEST.sha256.json -Raw -Encoding utf8 | ConvertFrom-Json
foreach ($entry in $manifest) {
    $actual = (Get-FileHash -LiteralPath $entry.path -Algorithm SHA256).Hash.ToLowerInvariant()
    if ($actual -ne $entry.sha256) { throw "Hash mismatch: $($entry.path)" }
}
& ./scripts/check.ps1
Pop-Location

本文的自包含性也做同样的实测,而不是靠阅读判断。 从文章中的文件代码块重建到空目录,逐文件比较(仅规范 CRLF/LF 换行),再运行构建、16 组单测、独立 demo、2000 条差分、覆盖率和打包。结果:36 个文件一致;原生 16/16;差分 2000 条、0 失败;生产行覆盖率 96.45%。因此不下载 ZIP 也能写出本文交付的完整工程。编辑器复制错误或不同 SDK/平台仍需按自己的运行结果排查。

提交社区遵循 官方贡献流程:先按项目创建模板提交 issue,再按模板准备申请邮件与初始化 ZIP;社区评审后签署适合个人或组织的 CLA,由社区建仓邀请维护者;后续代码经 Committer 审核合入。初始化资料至少包含 README、LICENSE、CHANGELOG、cjpm.toml,本文同时提供 API、测试、来源和设计材料。提交当天再核对官方流程与清单,避免重复创建已有实现。

技术准备完成不等于社区准入完成。真实维护者、联系方式、CLA、正式仓库、目标平台验收和社区评审都需要实际完成。本教程没有伪造身份,也没有声称已被社区正式收录。

16. 故障排查与下一步练习

现象原因线索与处理
找不到 cjc/cjpm 或运行缺 DLL当前终端加载对应 SDK 环境;核对工具与 runtime PATH
根目录 run 提示无 main根项目是 static;进入 examples/demo 执行
编译找不到 UrlLocation/Query检查四个生产文件完整、同一 package,不只复制 url.cj
单测显示 0 组src 下的 _test.cj 命名、@Test 与宏导入是否完整
Query 不能直接 .get用 match 解包 Params,取得 QueryParams
Params 已改,href 没变通过 set("query", params) 触发 refresh,理解公开字段的缓存语义
自定义 stringify 改了输出,href 未变toString 不写 href,这是约定,不是回调没有执行
用户名出现 %2540username setter 接收原文本;不要传已经编码的 %40
相对地址没有继承 host显式给 base;对象 location 要实际填 host/hostname/pathname
Node 找不到模块查本节 reference 的完整路径,尤其两个本地 node_modules 子目录,无需联网安装
差分失败先读具体行,把最小失败加进原生测试;别改上游或期望绕过
更换 SDK 后链接异常在根、demo、driver 清理旧制品再构建
打包成功但另一目录失败依赖 path、参考文件、许可证与实际清单要完整,不能只看 ZIP 存在
博客复制代码编译错误不复制外层围栏/行号;中文和百分号原样保存;main 示例不要放生产文件顶层

建议按顺序做三个练习:在原生测试中新增 ../x 的基地址用例;给 QueryParams 加一个普通键和数字键,解释序列化顺序;把同一个 URL 分别用 Raw 与 Params 构造,说明 query 文本与 href 的不同。练习后恢复本文基线再验收,或在 CHANGELOG 记录自己的修改。

到这里,读者应能说清每个板块的职责、各层如何协作,亲手创建完整项目,运行可复现测试,生成带许可和来源的提交包,并辨别哪些步骤仍需社区与真人完成。

此作者没有提供个人介绍。
最后更新于 2026-10-01