Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 56 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -356,18 +356,69 @@ jobs:
~/.android/adb*
key: avd-30-aosp_atd-x86_64-pixel6-v1

tools-changed:
name: Detect tools/ changes
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
changed: ${{ steps.filter.outputs.tools }}
steps:
- uses: actions/checkout@v7
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: filter
with:
filters: |
tools:
- 'tools/**'

tools:
name: Go build · vet · fmt · tests (tools/)
needs: tools-changed
if: needs.tools-changed.outputs.changed == 'true'
runs-on: ubuntu-latest
# Needs no JDK and no emulator, so it's the cheapest job in this workflow; a generous
# bound is still cheap insurance against a hung `go test`.
timeout-minutes: 10
defaults:
run:
working-directory: tools
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v5
with:
go-version-file: tools/go.mod
- name: Build, vet, gofmt, test
run: |
go build ./...
go vet ./...
if [ -n "$(gofmt -l .)" ]; then
echo "The following files are not gofmt-formatted:"
gofmt -l .
exit 1
fi
go test ./...

# Aggregate gate: a single check to require in branch protection. Green only when every
# job above succeeded; fails if any was skipped, cancelled or failed.
# job above succeeded; fails if any was skipped, cancelled or failed. `tools` is allowed
# to be legitimately skipped (it's path-gated via tools-changed) so it isn't in `needs`
# directly — only tools-changed is, which always runs and always succeeds or fails cleanly.
# A failing `tools` run still fails branch protection because GitHub reports the `tools`
# check itself as a required status when it runs; when it doesn't run there is nothing to
# gate on and the aggregate correctly ignores it.
ci:
name: CI
if: always()
needs: [ static-analysis, web, native, android-unit, android-instrumented ]
needs: [ static-analysis, web, native, android-unit, android-instrumented, tools-changed, tools ]
runs-on: ubuntu-latest
# Only evaluates the aggregate result; a couple of minutes is ample.
timeout-minutes: 5
steps:
- name: Fail if any job did not succeed
if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }}
- name: Fail if any required job did not succeed
# `tools` is expected to report `skipped` when tools/ didn't change (see tools-changed
# above) — that's not a failure, so it's excluded from the failure check while every
# other job (including tools-changed itself) must have succeeded.
if: ${{ contains(fromJSON('["failure","cancelled","skipped"]'), needs.static-analysis.result) || contains(fromJSON('["failure","cancelled","skipped"]'), needs.web.result) || contains(fromJSON('["failure","cancelled","skipped"]'), needs.native.result) || contains(fromJSON('["failure","cancelled","skipped"]'), needs.android-unit.result) || contains(fromJSON('["failure","cancelled","skipped"]'), needs.android-instrumented.result) || contains(fromJSON('["failure","cancelled"]'), needs.tools-changed.result) || contains(fromJSON('["failure","cancelled"]'), needs.tools.result) }}
run: |
echo "One or more CI jobs did not succeed: ${{ join(needs.*.result, ', ') }}"
echo "One or more required CI jobs did not succeed."
echo "static-analysis=${{ needs.static-analysis.result }} web=${{ needs.web.result }} native=${{ needs.native.result }} android-unit=${{ needs.android-unit.result }} android-instrumented=${{ needs.android-instrumented.result }} tools-changed=${{ needs.tools-changed.result }} tools=${{ needs.tools.result }}"
exit 1
7 changes: 7 additions & 0 deletions docs/idna-unicode-update.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,13 @@ codegen never touches. Search the repo for the outgoing version's `Unicode <OLD>
- the hand-written Kotlin KDocs — `IdnaMappingTable.kt` and `IdnaValidity.kt` (decoders)
and `IdnaConformanceTest.kt` and `NormalizerTest.kt` (tests).

Also update `TestBundledUnicodeVersionRenderings` in `tools/internal/ucd/ucd_test.go` by
hand: it asserts the bare `MajorMinor()`/`String()` literals (e.g. `"17.0"`, `"17.0.0"`),
which the grep above won't catch since neither pattern matches an unprefixed version
string. Nothing runs this test as part of a version bump (`go test` isn't part of the
`./gradlew build` gate in step 6, and CI's `tools` job only runs when files under `tools/`
change), so a missed update here fails silently rather than failing the bump.

Then update the "current version" note at the top of this file. (The mapping-table KDoc
had already drifted a full major release behind before this was written down, so treat
the grep as authoritative rather than trusting this list to stay complete.)
Expand Down
104 changes: 104 additions & 0 deletions tools/internal/ucd/bidi_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Copyright (c) 2026 dexpace and Omar Aljarrah
// SPDX-License-Identifier: MIT

package ucd

import "testing"

func TestLoadBidiUnicodeDataBasic(t *testing.T) {
data := []byte(
"0041;LATIN CAPITAL LETTER A;Lu;0;L;;;;;N;;;;;\n" +
"0627;ARABIC LETTER ALEF;Lo;0;AL;;;;;N;;;;;\n",
)
got, err := loadBidiUnicodeData(data)
if err != nil {
t.Fatalf("loadBidiUnicodeData: unexpected error: %v", err)
}
if len(got) != 2 {
t.Fatalf("loadBidiUnicodeData: got %d ranges, want 2", len(got))
}
if got[0].start != 0x41 || got[0].jtype != "L" {
t.Errorf("got[0] = %+v, want start=0x41 class=L", got[0])
}
if got[1].start != 0x627 || got[1].jtype != "AL" {
t.Errorf("got[1] = %+v, want start=0x627 class=AL", got[1])
}
}

func TestLoadBidiUnicodeDataFiltersUntrackedClasses(t *testing.T) {
// B is Paragraph_Separator, which is not in bidiClasses; such code points
// must be dropped entirely from the result, not recorded with an empty class.
data := []byte("2029;PARAGRAPH SEPARATOR;Zp;0;B;;;;;N;;;;;\n")
got, err := loadBidiUnicodeData(data)
if err != nil {
t.Fatalf("loadBidiUnicodeData: unexpected error: %v", err)
}
if len(got) != 0 {
t.Fatalf("loadBidiUnicodeData = %+v, want empty (Paragraph_Separator is untracked)", got)
}
}

func TestLoadBidiUnicodeDataFirstLastBlock(t *testing.T) {
// The expanded range takes the Bidi_Class of the Last (closing) row, mirroring
// the Mark/Virama pass's First/Last handling.
data := []byte(
"3400;<CJK Ideograph Extension A, First>;Lo;0;L;;;;;N;;;;;\n" +
"4DBF;<CJK Ideograph Extension A, Last>;Lo;0;L;;;;;N;;;;;\n",
)
got, err := loadBidiUnicodeData(data)
if err != nil {
t.Fatalf("loadBidiUnicodeData: unexpected error: %v", err)
}
if len(got) != 1 || got[0].start != 0x3400 || got[0].end != 0x4DBF || got[0].jtype != "L" {
t.Fatalf("got = %+v, want a single range [0x3400, 0x4DBF] class L", got)
}
}

func TestLoadBidiUnicodeDataSkipsShortLines(t *testing.T) {
data := []byte("\n0041;A;Lu;0;L;;;;;N;;;;;\n")
got, err := loadBidiUnicodeData(data)
if err != nil {
t.Fatalf("loadBidiUnicodeData: unexpected error: %v", err)
}
if len(got) != 1 {
t.Fatalf("loadBidiUnicodeData: got %d ranges, want 1 (blank line skipped)", len(got))
}
}
Comment on lines +57 to +66

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is the version the other two loaders' tests should look like: it asserts len(got) == 1, so it pins both that the blank line was skipped and that the real record survived. The NFC equivalent only asserts emptiness, and the validity equivalent doesn't exist — see the comments on those files.


func TestLoadBidiUnicodeDataInvalidCodePoint(t *testing.T) {
data := []byte("ZZZZ;BOGUS;Lu;0;L;;;;;N;;;;;\n")
if _, err := loadBidiUnicodeData(data); err == nil {
t.Fatal("loadBidiUnicodeData with invalid code point: expected error, got nil")
}
}

func TestLoadBidiUnicodeDataSortsByStart(t *testing.T) {
// Out-of-order input must come back sorted by start, mirroring the sort step
// in loadBidiUnicodeData.
data := []byte(
"0627;ARABIC LETTER ALEF;Lo;0;AL;;;;;N;;;;;\n" +
"0041;LATIN CAPITAL LETTER A;Lu;0;L;;;;;N;;;;;\n",
)
got, err := loadBidiUnicodeData(data)
if err != nil {
t.Fatalf("loadBidiUnicodeData: unexpected error: %v", err)
}
if len(got) != 2 || got[0].start != 0x41 || got[1].start != 0x627 {
t.Fatalf("loadBidiUnicodeData did not sort by start: got %+v", got)
}
}

func TestBidiClassesTrackedSet(t *testing.T) {
// Pin the exact set of classes the RFC 5893 rule consults, so an accidental
// addition or removal in bidi.go is caught here rather than only surfacing as
// a silent behavior change in the generated table.
want := []string{"L", "R", "AL", "EN", "ES", "ET", "AN", "CS", "NSM", "BN", "ON"}
if len(bidiClasses) != len(want) {
t.Fatalf("bidiClasses has %d entries, want %d: %v", len(bidiClasses), len(want), bidiClasses)
}
for _, class := range want {
if !bidiClasses[class] {
t.Errorf("bidiClasses[%q] = false, want true", class)
}
}
}
3 changes: 3 additions & 0 deletions tools/internal/ucd/mapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ func parseLine(line string) (*idnaRange, error) {
return nil, nil
}
fields := strings.Split(body, ";")
if len(fields) < 2 {
return nil, fmt.Errorf("ucd: expected at least 2 ';'-separated fields in %q", body)
}
for index := range fields {
fields[index] = strings.TrimSpace(fields[index])
}
Expand Down
Loading