Skip to content

feat(encoding): add encoding/percent for RFC 3986 URI components - #4214

Open
bobzhang wants to merge 1 commit into
mainfrom
hongbo/encoding-percent
Open

feat(encoding): add encoding/percent for RFC 3986 URI components#4214
bobzhang wants to merge 1 commit into
mainfrom
hongbo/encoding-percent

Conversation

@bobzhang

@bobzhang bobzhang commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds encoding/percent, a string-to-string RFC 3986 percent-encoding codec for URI components, so applications stop hand-rolling it or shimming JavaScript's encodeURIComponent / decodeURIComponent (see moonbitlang/openseek#1408, which replaced those shims with a pure MoonBit desktop/internal/uri package).

pub fn encode(StringView) -> String
pub fn decode(StringView) -> String raise Malformed
pub fn decode_lossy(StringView) -> String
pub suberror Malformed { Malformed(StringView) } derive(@debug.Debug)
  • encode converts the text to UTF-8 and escapes every byte outside the RFC 3986 unreserved set A-Z a-z 0-9 - . _ ~ as uppercase %XX. A space is %20, never +. It is stricter than encodeURIComponent, which leaves !*'() unescaped. It walks UTF-16 code units itself and replaces an unpaired surrogate with U+FFFD, because @utf8.encode panics on one on the non-JS backends; so encode never raises.
  • decode decodes every %XX (either hex case) exactly once, interprets each maximal escape run as UTF-8, copies literal code units through unchanged (including +, BOMs and unpaired surrogates), and raises Malformed with the whole input on a bad escape or invalid escaped UTF-8. This is the input that makes decodeURIComponent throw.
  • decode_lossy keeps a malformed % literally and resumes at the next code unit (%2%41%2A), and replaces invalid escaped UTF-8 with U+FFFD via @utf8.decode_lossy. It returns the same string as decode whenever decode succeeds.

Deliberately out of scope for this first version, documented in the README: byte-level entry points, per-component presets (path / query), application/x-www-form-urlencoded, and URL parsing. All can be added later without breaking these signatures.

Design notes

  • Literal spans are sliced with StringView::view, which checks bounds but not surrogate boundaries, so a view that splits a pair is handled instead of aborting.
  • Malformed carries the whole input view, matching encoding/hex and encoding/base64.
  • The escape-run approach (rather than UTF-8 encoding the entire input then decoding once) is what preserves literal unpaired surrogates.

Testing

  • moon test -p encoding/percent --target all: 24/24 on wasm, wasm-gc, js, native.
  • moon check --deny-warn --target all, moon info, moon fmt: clean. moon coverage analyze reports no uncovered lines in the package.
  • Tests cover the full ASCII table, every UTF-8 length boundary, surrogate pairing and ordering, nonzero-offset views (including a view ending in %2 with a digit right after it), the malformed-escape and invalid-UTF-8 table from openseek, lossy replacement counts, and quickcheck properties against an independent UTF-16 repair oracle and a byte-at-a-time reference encoder.
  • Mutation check: ten single-point mutations (drop surrogate replacement, lowercase hex, wrong 2-byte boundary, read past view end, lossy resume drops % / skips 3, no buffer reset between runs, strict path using lossy UTF-8, ! unreserved, missing literal flush) each fail between 2 and 8 of the 24 tests.

Review

Codex CLI reviewed the design (two blockers, both addressed: backend-independent surrogate policy; exact lossy consumption rule) and then the implementation: "No blocking issues found ... An independent transcription check passed for every code point and surrogate pair."

Signed-off-by: Codex CLI codex@openai.com

🤖 Generated with Claude Code

https://claude.ai/code/session_01MVKQs2BgHmc3VBDXBU8AgD

Copilot AI lite review requested due to automatic review settings September 9, 2026 15:32
Add `encoding/percent`, a string-to-string percent-encoding codec for URI
components, so applications stop hand-rolling it or shimming JavaScript's
`encodeURIComponent`/`decodeURIComponent` (moonbitlang/openseek#1408).

- `encode` converts the text to UTF-8 and escapes every byte outside the
  RFC 3986 unreserved set as uppercase `%XX`. It walks UTF-16 code units
  itself and replaces unpaired surrogates with U+FFFD, because
  `@utf8.encode` panics on them on the non-JS backends.
- `decode` decodes every `%XX` (either hex case) once, interprets each
  maximal escape run as UTF-8, copies literal code units through unchanged
  (including `+`, BOMs and unpaired surrogates) and raises `Malformed` with
  the whole input on a bad escape or invalid escaped UTF-8.
- `decode_lossy` keeps a malformed `%` literally, resumes at the next code
  unit, and replaces invalid escaped UTF-8 with U+FFFD; it agrees with
  `decode` whenever `decode` succeeds.

Literal spans are sliced with `StringView::view`, which checks bounds but
not surrogate boundaries, so views that split a pair are handled instead of
aborting. Tests cover the ASCII table, UTF-8 boundaries, surrogate cases,
nonzero-offset views, the malformed table from openseek, lossy replacement
counts, and quickcheck properties against an independent UTF-16 repair
oracle. Ten single-point mutations each fail between 2 and 8 of the 24
tests.

Codex CLI review: "No blocking issues found ... An independent
transcription check passed for every code point and surrogate pair."

Signed-off-by: Codex CLI <codex@openai.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MVKQs2BgHmc3VBDXBU8AgD
@bobzhang
bobzhang force-pushed the hongbo/encoding-percent branch from 6e608db to 753c46d Compare September 9, 2026 15:34
@bobzhang

bobzhang commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Codex CLI review (gpt-6-astra, medium)

Three rounds, run against this branch in a read-only sandbox:

  1. Design review raised two blockers, both addressed before implementation: @utf8.encode panics on unpaired surrogates on the non-JS backends, so encode owns its surrogate policy (U+FFFD); and the lossy rule needed an exact consumption rule (keep the bad %, resume at the next code unit; literal unpaired surrogates pass through decode unchanged).
  2. Implementation review: "No blocking issues found ... An independent transcription check passed for every code point and surrogate pair." One typo note, fixed.
  3. Final PR review of origin/main..HEAD found one documentation overclaim: the README said the encoded result "cannot introduce URL structure", but encode("..") returns .., which keeps its path semantics. The paragraph now says escaping the reserved delimiters prevents the value from terminating or splitting its component, and that rejecting . / .. remains the caller's job. Re-review of that change: "I approve the revised wording ... The previous blocking issue is resolved."

Final verdict:

Signed-off-by: Codex CLI codex@openai.com

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The implementation is self-contained, matches established encoding/* patterns, and includes thorough deterministic and property-based test coverage for the specified semantics.

Pull request overview

Adds a new encoding/percent package to the core library implementing RFC 3986 percent-encoding/decoding for individual URI components, providing a backend-independent alternative to JavaScript encodeURIComponent/decodeURIComponent.

Changes:

  • Introduces encode, decode (raising Malformed), and decode_lossy APIs for RFC 3986 percent-encoding of URI components.
  • Adds comprehensive unit + quickcheck tests covering ASCII tables, UTF-8 boundaries, surrogate behavior, malformed escapes, and lossy semantics.
  • Documents the package behavior and records the addition in the root changelog.
File summaries
File Description
encoding/percent/README.mbt.md Package documentation and usage examples for encode/decode/decode_lossy semantics.
encoding/percent/encode.mbt Implements strict RFC 3986 percent-encoding with surrogate repair behavior.
encoding/percent/decode.mbt Implements strict decode (raising Malformed) and lossy decode with precise resumption rules.
encoding/percent/encode_test.mbt Deterministic unit tests for encoding rules and edge cases.
encoding/percent/decode_test.mbt Deterministic unit tests for decoding, malformed handling, lossy behavior, and views.
encoding/percent/quickcheck_test.mbt Property tests against independent reference models and UTF-16 repair oracle.
encoding/percent/moon.pkg Declares package dependencies (runtime + test-only).
encoding/percent/extends.mbt Hides deprecated Debug method promotion for Malformed from generated interface (consistent with other encoding packages).
encoding/percent/pkg.generated.mbti Generated public interface for the new package.
CHANGELOG.md Notes addition of encoding/percent in the release changelog.
Review details
  • Files reviewed: 10/10 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@coveralls

coveralls commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 6587

Coverage increased (+0.03%) to 89.304%

Details

  • Coverage increased (+0.03%) from the base build.
  • Patch coverage: 59 of 59 lines across 2 files are fully covered (100%).
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 18390
Covered Lines: 16423
Line Coverage: 89.3%
Coverage Strength: 273025.31 hits per line

💛 - Coveralls

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants