Sign in

    #text

    hutool StrUtil / CharSequenceUtil 的 MoonBit 对位:空白判定、切分连接、子串截取、{} 占位、版本比较、脱敏掩码、命名法互转。

    契约与边界矩阵见 docs/spec/01-text.md。本页只放典型用法,每条都带期望值——这些 test 块会被 moon test 真编译真执行(抄 moonbitlang/core/encoding/base64/README.mbt.md 的机制),所以文档本身就是要通过的检查,不是装饰。

    状态:已实现(10-04 第一批;10-10 批①补转义族与替换引擎)。本页每个 test 块都由moon test 真跑;块数与条数现读 moon test --package text,三档(wasm/js/wasm-gc)读数一致。 期望值仍是冻结状态:要改任何一条,必须单独一笔并给外部读数来源(AGENTS.md 红线一,门禁 G5/G11 双重盯着)。

    #用法

    ///|
    test "is_blank 空白档" {
    assert_true(@text.is_blank(" "))
    assert_true(@text.is_blank("\t \n"))
    assert_false(@text.is_blank(" a"))
    }

    is_empty 与 is_blank 是两档,不合并:

    ///|
    test "is_empty 与 is_blank 分档" {
    assert_true(@text.is_empty(""))
    assert_false(@text.is_empty(" ")) // 非空但全空白:empty 假、blank 真
    assert_true(@text.is_blank(" "))
    }

    #截取:未命中时两个方向不对称

    sub_before 未命中给整串,sub_after 未命中给空串——两个方向不对称,别"顺手统一"。 两者都按第一个分隔符切(hutool isLastSeparator 默认 false,源码 CharSequenceUtil.java:2369/2446):

    ///|
    test "sub_before / sub_after 未命中" {
    assert_eq(@text.sub_before("path/to/file.txt", "/"), "path") // 首个分隔符,不是最后一个
    assert_eq(@text.sub_before("nope", "/"), "nope") // 未命中 → 整串
    assert_eq(@text.sub_after("path/to/file.txt", "/"), "to/file.txt")
    assert_eq(@text.sub_after("nope", "/"), "") // 未命中 → 空串
    }

    ///|
    test "sub_between 可选带界定符" {
    assert_eq(@text.sub_between("hutool{abc}end", "{", "}"), "abc")
    assert_eq(
    @text.sub_between("hutool{abc}end", "{", "}", include_sep=true),
    "{abc}",
    )
    assert_eq(@text.sub_between("hutool abc end", "{", "}"), "")
    }

    #切分与连接:空段保留

    ///|
    test "split 保留空段" {
    assert_eq(@text.split("a,b,,c", ','), ["a", "b", "", "c"])
    assert_eq(@text.split("", ','), []) // 空输入是空数组,不是 [""]
    assert_eq(@text.split(" a , b ", ',', trim_result=true), ["a", "b"])
    }

    ///|
    test "join" {
    assert_eq(@text.join(["a", "b"], "-"), "a-b")
    assert_eq(@text.join(["a", "", "b"], "-"), "a--b")
    }

    #format:{} 占位,三条硬语义

    参数不足时未填的原样留着(不报错、不填空);前一字符是 \ 的 {} 当字面量。位置索引语法 {0} 不做——那是 JDK MessageFormat 的语义,与 {} 不同。

    ///|
    test "format 三条硬语义" {
    assert_eq(@text.format("a{}b", ["1"]), "a1b")
    assert_eq(@text.format("id={} name={}", ["7"]), "id=7 name={}")
    assert_eq(@text.format("keep \\{}", []), "keep {}")
    }

    #版本比较按数值,不按字典

    ///|
    test "compare_version" {
    assert_eq(@text.compare_version("1.2.3", "1.2.10"), -1)
    assert_eq(@text.compare_version("1.0.0", "1.0"), 0) // 缺段补零
    assert_eq(@text.compare_version("2", "10"), -1) // 若按字典序这里会得 1
    }

    #脱敏:下标按码点,越界夹紧

    hide 的 [start, end) 与 String::length()(UTF-16 单元)不是一套下标,中文串要按码点算:

    ///|
    test "hide 码点下标" {
    assert_eq(@text.hide("13012345678", 3, 7, '*'), "130****5678")
    assert_eq(@text.hide("中文测试", 1, 3, '*'), "中**试") // 区间外字符保留
    assert_eq(@text.hide("abc", 1, 99, '*'), "a**") // 越界夹紧,不报错
    }

    #命名法互转

    转换规则三条坑:连续大写当专有名词不拆;to_camel_case 只在含分隔符时转换,否则原样返回;转换时其余字符转小写。

    ///|
    test "naming 已冻结的三条" {
    assert_eq(@text.to_underline_case("userName"), "user_name")
    assert_eq(@text.to_camel_case("user_name"), "userName")
    assert_eq(@text.to_camel_case("userName"), "userName") // 无分隔符 → 原样返回
    }

    #转义族(10-10 批①)

    每条期望值都来自参照腿(scripts/EscapeLeg.java / scripts/EntityTableLeg.java),不是"看起来该这样"。 两族唯一的形差在撇号:XML 转、HTML4 不转。

    ///|
    test "escape 实体族:XML 收撇号、HTML4 不收" {
    assert_eq(@text.escape_xml("a<b&c>d\"e'f"), "a&lt;b&amp;c&gt;d&quot;e&apos;f")
    assert_eq(@text.escape_html4("a<b&c>d\"e'f"), "a&lt;b&amp;c&gt;d&quot;e'f")
    // 一次只解一层:`&amp;amp;` → `&amp;`,要回到 `&` 得再解一次
    assert_eq(@text.unescape_xml("&amp;amp;"), "&amp;")
    // HTML4 认 `&copy;`;XML 的还原表只有那 5 条 + `&nbsp;`
    assert_eq(@text.unescape_xml("&copy;"), "&copy;")
    assert_eq(@text.unescape_html4("&copy;"), "\u{00a9}")
    }

    escape 是百分号族,与 core 的 encoding/percent 不是一张嘴:非 ASCII 出的是 IE 风格的 %uXXXX(逐 UTF-16 码元、小写十六进制),而且 % 自己也被转 ⇒ 不幂等。

    ///|
    test "escape 百分号族:`%uXXXX` 不是 UTF-8 百分号编码" {
    assert_eq(@text.escape("中文 a"), "%u4e2d%u6587%20a")
    assert_eq(@text.escape_all("ab"), "%61%62")
    assert_eq(@text.escape("%41"), "%2541") // 已编码的再编码一次
    assert_eq(@text.unescape("%41"), "A")
    // 不做 UTF-8 解码:`%e4%b8%ad` 出三个字符,不是"中"
    assert_eq(@text.unescape("%e4%b8%ad"), "\u{00e4}\u{00b8}\u{00ad}")
    }

    还原失败折成错误面;safe_unescape 是"吞掉、返回入参"(不是空串、不是部分解码)。

    ///|
    test "unescape 的失败两档与 safe 档" {
    let a = try {
    let _ = @text.unescape("%zz")
    "未抛错"
    } catch {
    @text.BadHex(x) => "BadHex " + x
    @text.ShortInput => "ShortInput"
    _ => "其它"
    }
    assert_eq(a, "BadHex zz")
    let b = try {
    let _ = @text.unescape("%2")
    "未抛错"
    } catch {
    @text.BadHex(x) => "BadHex " + x
    @text.ShortInput => "ShortInput"
    _ => "其它"
    }
    assert_eq(b, "ShortInput")
    assert_eq(@text.safe_unescape("%zz"), "%zz")
    }

    UnicodeUtil 那三件是一把尺子三种入参:串档只转"不在可打印 ASCII 那段"的字符, unicode_of 走参照 Integer.toHexString 的形状(不补到 6 位、负数出 32 位补码)。

    ///|
    test "unicode 三件:一把尺子,三种入参形状" {
    assert_eq(@text.to_unicode("中a文"), "\\u4e2da\\u6587")
    assert_eq(@text.to_unicode_all("ab"), "\\u0061\\u0062")
    assert_eq(@text.unicode_of(0x4e2d), "\\u4e2d")
    assert_eq(@text.unicode_of(-1), "\\uffffffff") // 参照就是给 8 位 f
    assert_eq(@text.unicode_to_string("\\\\u4e2d"), "\\中") // 双反斜杠只挡住第一个:参照吃掉第二个当转义起始
    }

    encode_blank 只动空白位(用的就是本包那张 35 位表——整仓一张,ini/typex 共用):

    ///|
    test "encode_blank 只动空白" {
    assert_eq(@text.encode_blank(" a "), "%20a%20")
    assert_eq(@text.encode_blank("\t\n"), "%20%20")
    assert_eq(@text.encode_blank("中文"), "中文")
    }

    #替换引擎(10-10 批①)

    四条择路都有读数:长键优先且与表序无关、同键后写的生效、链是"每位置问第一个命中的" 而不是"依次全文替换"(下面那条给 1 而不是 X)、空键参照构造即抛。

    ///|
    test "replacer:长键优先、链不回头扫" {
    let t = @text.lookup_replacer([("ab", "X"), ("abc", "Y")])
    assert_eq(t.replace("xaby abc"), "xXy Y")
    let chain = @text.replacer_chain([
    @text.lookup_replacer([("ab", "1")]),
    @text.lookup_replacer([("1", "X")]), // 第一件产出的 `1` 不会被第二件再换掉
    ])
    assert_eq(chain.replace("ab"), "1")
    let empty_key = try {
    let _ = @text.lookup_replacer([("", "E")])
    "未抛错"
    } catch {
    @text.EmptyKey => "EmptyKey"
    _ => "其它"
    }
    assert_eq(empty_key, "EmptyKey")
    }

    #与 core 的分工(本页不重复的)

    pad_start / pad_end / repeat / has_prefix / contains / replace / to_upper(ASCII 档)等 core 已有同义能力,本包不转发——直接在调用点用 String::*。完整对照见 docs/spec/00-hutool-map.md。

    TextError

    pub suberror TextError {
    BadHex(String)
    ShortInput
    LoneSurrogate(Int)
    EmptyKey
    }

    本批公开面的失败档(四件 raise 面共用;每条都注明它折的是参照哪个异常)

    Replacer

    pub struct Replacer {
    // private fields
    }

    Replacer::replace

    fn Replacer::replace(self : Replacer, s : String) -> String

    整串替换(StrReplacer.replace(CharSequence) 的公开面)。 参照返回 CharSequence,实为 StrBuilder;本库直接给 String,那条返回类型没有额外承诺。

    compare_version

    fn compare_version(a : String, b : String) -> Int

    版本串比较:逐段取前导数字按数值比,缺段补零(hutool compareVersion)

    返回归一到 -1 / 0 / 1;数字段相等时再按剩余后缀比(spec 1.9)

    encode_blank

    fn encode_blank(s : String) -> String

    空白字符转 %20,其余原样(hutool URLUtil.encodeBlank——挂在 URLUtil 上,但它只吃空白 判定,而那条谓词整仓只有一张表,所以收在 text,与 is_blank/trim 同源)。 表里 35 位逐位等价(scripts/BlankScanLeg.java:isBlank/trim/cleanBlank/ encodeBlank/isBlankChar 五个出口命中集完全一致);非 BMP 一律原样。

    escape

    fn escape(s : String) -> String

    百分号转义,默认过滤器版(EscapeUtil.escape(CharSequence))。 非 ASCII 出的是 %uXXXX(每码元、小写十六进制),不是 UTF-8 三字节 %E4%B8%AD—— 这是参照的形状(IE 风格 %u),别"顺手改成标准百分号编码",改了就不是 hutool。

    escape_all

    fn escape_all(s : String) -> String

    百分号转义,全转版(EscapeUtil.escapeAll):一位都不留,ASCII 出 %XX(两位小写), BMP 其余出 %uXXXX(四位小写)。

    escape_by

    fn escape_by(s : String, to_escape : (Char) -> Bool) -> String

    百分号转义,自定义过滤器版(EscapeUtil.escape(CharSequence, Filter<Character>))。 方向照参照:to_escape(c) 为真 ⇒ 该字符转义(腿 P 行的 keepAll/keepNone 两档就是这条判据)。 形状差一条(写进 spec §1.17):参照把过滤器喂给每个 UTF-16 码元,本库喂码位 ⇒ 星平面字符在本库是"一次判定 + 两个 %u",参照是"两次判定、各自一个 %u"; 表内四位样例(keepAll/keepNone/ASCII 字母/非空白)两侧同读数。

    escape_html4

    fn escape_html4(s : String) -> String

    HTML4 实体转义(EscapeUtil.escapeHtml4)——表比 XML 大:BASIC 4 条 + ISO8859_1 96 条 + HTML40_EXTENDED 151 条 = 251 条,逐条由反射读来(见 escape_tables.mbt 头注)。

    escape_xml

    fn escape_xml(s : String) -> String

    XML 实体转义(hutool EscapeUtil.escapeXml)。与 escape_html4 的唯一读数差是 ': XML 出 &apos;,HTML4 原样留(腿 E 行两侧同输入并排给出)。 非 ASCII 一律不动;—/– 这类只在 HTML4 侧出实体名。

    first_non_blank

    fn first_non_blank(candidates : Array[String?]) -> String?

    返回第一个非空白项,全空白给 None(hutool firstNonBlank)

    format

    fn format(template : String, args : Array[String]) -> String

    {} 占位格式化(hutool StrUtil.format → StrFormatter)

    三条硬语义:前一字符是 \ 的 {} 当字面量并吃掉那个反斜杠;参数少于占位符时未填的原样留着; 位置索引语法 {0} 不做(JDK MessageFormat 的语义,与 {} 不同)

    hide

    fn hide(s : String, start : Int, end : Int, mask : Char) -> String

    区间掩码:[start, end) 用 mask 替换(hutool CharSequenceUtil.hide)

    下标按码点计;越界夹紧不报错;start >= end 时原样返回(spec 1.11)

    is_blank

    fn is_blank(s : String) -> Bool

    是否为空白:空串、或全为空白字符判真(hutool StrUtil.isBlank)

    is_blank_char

    fn is_blank_char(c : Char) -> Bool

    空白字符判定:hutool CharUtil.isBlankChar 的实测码表(整 BMP 逐位扫,35 位)。 公开的原因不是"给用户一个谓词",是本仓只许有一张这样的表——ini(is_blank_cp)与 typex(ds_is_blank)原先各抄一份,三份互不相同:本包多算了 U+0085、少算了 U+001C–U+001F, typex 少算了 U+0000/U+180E/U+2800/U+3164,ini 少算了 U+200C。腿(scripts/BlankScanLeg.java) 对五个出口(isBlank/trim/cleanBlank/encodeBlank/isBlankChar)逐位对撞, 命中集完全一致 ⇒ 参照那边本来就是一条谓词,三份副本是移植时抄漏,不是刻意的分档。 与 core Char::is_whitespace 的四处差(U+0085 参照不算、U+001C–U+001F 参照算、 U+200C 是 5.8.37 才加进 isBlankChar 的)一律跟参照,见 spec 1.1 第 3 行的更正。

    is_empty

    fn is_empty(s : String) -> Bool

    是否为空:长度为 0(hutool isEmpty)——与 is_blank 分档,不合并

    join

    fn join(items : Array[String], sep : String) -> String

    用分隔符连接(hutool CollUtil.join)

    lookup_replacer

    fn lookup_replacer(pairs : Array[(String, String)]) -> Replacer raise TextError

    查表替换器(LookupReplacer(String[]...))。表里每条是 (键, 值); 空键在本库是 raise(EmptyKey)(参照是同位置直接 StringIndexOutOfBoundsException)。

    lower_first

    fn lower_first(s : String) -> String

    首字母小写(同上,ASCII 档)

    replacer_chain

    fn replacer_chain(rs : Array[Replacer]) -> Replacer

    链式替换器(ReplacerChain(StrReplacer...) + addChain)。 语义是"每个位置按链序问第一个命中的",不是"依次全文替换"(腿 C|overlap、B|chain_fs/sf)。

    safe_unescape

    fn safe_unescape(s : String) -> String

    还原失败就原样返回(EscapeUtil.safeUnescape)——参照吞的是任何异常, 本库把 unescape 的三档失败收成原串返回,判据是腿 UN 行的每一档坏样本。

    split

    fn split(s : String, sep : Char, trim_result? : Bool) -> Array[String]

    按分隔符切分,trim_result 为真时逐段去空白(hutool splitTrim)

    空输入返回空数组;连续分隔符保留空段(Java String.split 会吞尾空串,差异见 spec 1.5)

    sub_after

    fn sub_after(s : String, sep : String) -> String

    取分隔符之后的部分;未命中返回空串(hutool subAfter,与 sub_before 不对称)

    sub_before

    fn sub_before(s : String, sep : String) -> String

    取分隔符之前的部分;未命中返回整串(hutool subBefore)

    sub_between

    fn sub_between(s : String, left : String, right : String, include_sep? : Bool) -> String

    取两个界定符之间的内容;任一未命中返回空串(hutool subBetween)

    to_camel_case

    fn to_camel_case(name : String) -> String

    驼峰命名:只在含 _ 时转换,否则原样返回(hutool toCamelCase 的真实行为,别当幂等化简); 转换时分隔符后一位取大写、其余字符全部转小写

    to_kebab_case

    fn to_kebab_case(name : String) -> String

    连字符命名(kebab-case):同 to_underline_case,分隔符换成 -

    to_pascal_case

    fn to_pascal_case(name : String) -> String

    帕斯卡命名:upperFirst(toCamelCase(name))(hutool toPascalCase)

    to_underline_case

    fn to_underline_case(name : String) -> String

    下划线(蛇形)命名:userName → user_name、HTTPServer → http_server

    规则坑(spec 1.12):连续大写当专有名词不拆,只在"大写串尾、后面接小写"处断开一次;首字母转小写

    to_unicode

    fn to_unicode(s : String) -> String

    把"不在 0x20..0x7E 这一段"的字符转成 \uXXXX(UnicodeUtil.toUnicode(String),参照第二参默认 true)。 原样档就是可打印 ASCII 那一整段(逐位扫描见 scripts/UnicodeScan.java: KEEP|toUnicode(true)|0020-007e,只一段)——换行符虽是 ASCII 也照转, 所以"只转非 ASCII"那句常识不能写进契约。十六进制小写、最少四位。

    to_unicode_all

    fn to_unicode_all(s : String) -> String

    每一位都转(UnicodeUtil.toUnicode(String, false))——含 ASCII 与反斜杠本身。

    trim

    fn trim(s : String) -> String

    去两端空白(hutool trim)——按本库的空白档判定,不借 core trim 的白名单

    trim_opt

    fn trim_opt(s : String?) -> String?

    Option 形态的去空白:None 透传 None(hutool trimToNull——只管 null,全空白仍出空串)

    unescape

    fn unescape(s : String) -> String raise TextError

    百分号还原(EscapeUtil.unescape)。不做 UTF-8 解码:%e4%b8%ad 出三个字符 ä¸\xad,不是"中"(腿 UN 行实测);+ 不当空格。坏 % 折成 TextError。

    unescape_html4

    fn unescape_html4(s : String) -> String raise TextError

    HTML4 实体还原(EscapeUtil.unescapeHtml4):XML 那 5 条 + 246 条 HTML4 名 = 252 条。 未知名、缺分号、坏数字引用一律原样留(腿 E/N 行逐档实测),所以这里只可能因 代理区数字引用而 raise。

    unescape_xml

    fn unescape_xml(s : String) -> String raise TextError

    XML 实体还原(EscapeUtil.unescapeXml)。只认 XML 那 5 条命名实体 + 数字引用: &nbsp; 解得出(参照的 XML 解表带它),&copy;/&pound; 原样留(腿 E 行实测)。

    unicode_of

    fn unicode_of(cp : Int) -> String

    单个码点的 \uXXXX(参照的 toUnicode(char) 与 toUnicode(int) 两件合一: 腿 N 行成对打这两个出口,BMP 内每一条两侧同字符串 ⇒ 没有第二条语义可承诺)。 形状照参照的 Integer.toHexString:取低 32 位、小写十六进制、最少四位(-1 → \uffffffff、0x10000 → \u10000,腿 N 行两位都在)。

    unicode_to_string

    fn unicode_to_string(s : String) -> String raise TextError

    \uXXXX 串 → 真字符(UnicodeUtil.toString)。参照的认法逐条钉住: 固定吃四位(\u12345 → 一位 + 5)、位数不足四位或含非十六进制位 ⇒ 原样留、 双反斜杠不挡它(\\u4e2d → \ + 中,腿 R 行)、十六进制大小写都认。

    upper_first

    fn upper_first(s : String) -> String

    首字母大写(ASCII 档;非 ASCII 原样——core 只有 ASCII 大小写映射,本库不假装 Unicode)