Skip to content
Merged
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
1 change: 1 addition & 0 deletions CRATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ fn main() -> Result<(), ParseError> {
- `Postcode::parse` checks the structure: 2 letters, 2 digits, 3 letters or digits, 2 letters, 2 digits, with numeric segments from 01 to 99.
- `Postcode::parse_lenient` first swaps look-alike characters that cannot occur where they stand (`O`/`0`, `I`/`1`, `S`/`5`, `B`/`8`) and reports how many it changed.
- `Postcode::from_segments` assembles a code from its parts and zero-fills the LGA and unit.
- `Prefix::parse` reads a code cut off after a segment, such as the district `EK-01-A03`, and `Postcode::truncate` cuts a code down to one. A prefix knows which codes it contains, so it groups them by state, LGA, district, or area.
- `Postcode` is `Copy`, 11 bytes, and sorts by state, LGA, district, area, unit.

A well-formed code is not necessarily assigned to a building, and the state is not checked against a list of state codes. Only the API can confirm that a postcode exists.
Expand Down
4 changes: 4 additions & 0 deletions docs/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ match parse("ek 01 a03 fk 01"):
- `parse` accepts hyphenated, spaced or compact input in either case.
- `parse_lenient` first swaps look-alikes that cannot occur where they stand (`O`/`0`, `I`/`1`, `S`/`5`, `B`/`8`) and reports how many it changed.
- `from_segments` assembles a code from its parts and zero-fills the LGA and unit.
- `parse_prefix` reads a code cut off after a segment, such as the district `EK-01-A03`, and `Postcode.truncate` cuts a code down to one. A prefix knows which codes it contains, so it groups them by state, LGA, district, or area.
- `Postcode` is immutable, hashable and sorts by state, LGA, district, area, unit.

A well-formed code is not necessarily assigned to a building. Only the API can confirm that a postcode exists.
Expand Down Expand Up @@ -428,6 +429,7 @@ fn main() -> Result<(), ParseError> {
- `Postcode::parse` checks the structure: 2 letters, 2 digits, 3 letters or digits, 2 letters, 2 digits, with numeric segments from 01 to 99.
- `Postcode::parse_lenient` first swaps look-alike characters that cannot occur where they stand (`O`/`0`, `I`/`1`, `S`/`5`, `B`/`8`) and reports how many it changed.
- `Postcode::from_segments` assembles a code from its parts and zero-fills the LGA and unit.
- `Prefix::parse` reads a code cut off after a segment, such as the district `EK-01-A03`, and `Postcode::truncate` cuts a code down to one. A prefix knows which codes it contains, so it groups them by state, LGA, district, or area.
- `Postcode` is `Copy`, 11 bytes, and sorts by state, LGA, district, area, unit.

A well-formed code is not necessarily assigned to a building, and the state is not checked against a list of state codes. Only the API can confirm that a postcode exists.
Expand Down Expand Up @@ -500,6 +502,7 @@ if (code instanceof Postcode) {
- `parse` accepts hyphenated, spaced or compact input in either case.
- `parseLenient` first swaps look-alikes that cannot occur where they stand (`O`/`0`, `I`/`1`, `S`/`5`, `B`/`8`) and reports how many it changed.
- `fromSegments` assembles a code from its parts and zero-fills the LGA and unit.
- `parsePrefix` reads a code cut off after a segment, such as the district `EK-01-A03`, and `truncate` cuts a code down to one. A prefix knows which codes it contains, so it groups them by state, LGA, district, or area.
- `isValid` answers yes or no.

A well-formed code is not necessarily assigned to a building. Only the API can confirm that a postcode exists.
Expand Down Expand Up @@ -578,6 +581,7 @@ if (Postcode.parse("ek 01 a03 fk 01") instanceof Postcode code) {
- `Postcode.parse` accepts hyphenated, spaced or compact input in either case, and returns a `Postcode` or a `ParseError` such as "invalid lga segment".
- `Postcode.parseLenient` first swaps look-alikes that cannot occur where they stand (`O`/`0`, `I`/`1`, `S`/`5`, `B`/`8`) and reports how many it changed.
- `Postcode.fromSegments` assembles a code from its parts and zero-fills the LGA and unit.
- `Prefix.parse` reads a code cut off after a segment, such as the district `EK-01-A03`, and `Postcode.truncate` cuts a code down to one. A prefix knows which codes it contains, so it groups them by state, LGA, district, or area.
- `Postcode.isValid` answers yes or no.

A well-formed code is not necessarily assigned to a building. Only the API can confirm that a postcode exists.
Expand Down
1 change: 1 addition & 0 deletions java/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ if (Postcode.parse("ek 01 a03 fk 01") instanceof Postcode code) {
- `Postcode.parse` accepts hyphenated, spaced or compact input in either case, and returns a `Postcode` or a `ParseError` such as "invalid lga segment".
- `Postcode.parseLenient` first swaps look-alikes that cannot occur where they stand (`O`/`0`, `I`/`1`, `S`/`5`, `B`/`8`) and reports how many it changed.
- `Postcode.fromSegments` assembles a code from its parts and zero-fills the LGA and unit.
- `Prefix.parse` reads a code cut off after a segment, such as the district `EK-01-A03`, and `Postcode.truncate` cuts a code down to one. A prefix knows which codes it contains, so it groups them by state, LGA, district, or area.
- `Postcode.isValid` answers yes or no.

A well-formed code is not necessarily assigned to a building. Only the API can confirm that a postcode exists.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package io.github.adeniyikayodee.ngpostcode;

/** Why a string is not a well-formed postcode. */
public sealed interface ParseError extends Parsed, Lenient {
public sealed interface ParseError extends Parsed, Lenient, ParsedPrefix {

/** The input did not hold exactly 11 letters and digits. */
record WrongLength(int found) implements ParseError {
Expand All @@ -11,6 +11,14 @@ public String toString() {
}
}

/** The input did not end where a segment does: after 2, 4, 7, 9 or 11 letters and digits. */
record WrongPrefixLength(int found) implements ParseError {
@Override
public String toString() {
return "expected 2, 4, 7, 9 or 11 letters and digits, found " + found;
}
}

/**
* The input held something other than letters, digits, spaces and hyphens.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package io.github.adeniyikayodee.ngpostcode;

/** What {@link Prefix#parse} returns: a {@link Prefix}, or why there is none. */
public sealed interface ParsedPrefix permits Prefix, ParseError {}
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,14 @@ public String prefix(Segment through) {
return joined(through, "-");
}

/**
* The code cut off after {@code through}, as a value: {@code truncate(AREA)} is the area this
* building is in.
*/
public Prefix truncate(Segment through) {
return new Prefix(compact.substring(0, through.end));
}

@Override
public int compareTo(Postcode other) {
return compact.compareTo(other.compact);
Expand All @@ -148,7 +156,20 @@ private String joined(Segment through, String separator) {

/** Fills {@code kept} with the upper-case letters and digits, or says what stopped it. */
private static ParseError collect(String text, StringBuilder kept) {
Scan scan = scan(text);
kept.append(scan.kept());
if (scan.error() != null) {
return scan.error();
}
return scan.found() == LENGTH ? null : new WrongLength(scan.found());
}

/** The upper-case letters and digits of a text and how many there were, or what stopped it. */
record Scan(String kept, int found, InvalidCharacter error) {}

static Scan scan(String text) {
// Every character is checked, but no more than a postcode's worth is held.
var kept = new StringBuilder();
int found = 0;
int index = 0;
for (int at = 0; at < text.length(); index++) {
Expand All @@ -158,18 +179,22 @@ private static ParseError collect(String text, StringBuilder kept) {
continue;
}
if (!isAlphanumeric(point)) {
return new InvalidCharacter(Character.toString(point), index);
return new Scan(kept.toString(), found, new InvalidCharacter(Character.toString(point), index));
}
if (found++ < LENGTH) {
// ASCII arithmetic, so no locale can change the result.
kept.append((char) (point >= 'a' ? point - 32 : point));
}
}
return found == LENGTH ? null : new WrongLength(found);
return new Scan(kept.toString(), found, null);
}

private static ParseError validate(CharSequence compact) {
for (Segment segment : Segment.values()) {
return validate(compact, Segment.UNIT);
}

static ParseError validate(CharSequence compact, Segment through) {
for (Segment segment : Arrays.copyOf(Segment.values(), through.ordinal() + 1)) {
CharSequence part = compact.subSequence(segment.start, segment.end);
boolean accepted = switch (segment) {
case STATE, AREA -> part.chars().allMatch(Postcode::isUpper);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package io.github.adeniyikayodee.ngpostcode;

import io.github.adeniyikayodee.ngpostcode.ParseError.WrongPrefixLength;
import java.util.Arrays;
import java.util.Optional;
import java.util.stream.Collectors;

/**
* A postcode cut off after one of its segments, such as the district {@code EK-01-A03}.
*
* <p>It names every code that starts with it, so it serves to group or select codes by state,
* LGA, district or area. A whole code is the narrowest prefix. Build one with {@link #parse} or
* {@link Postcode#truncate}; constructing it from an unchecked string throws.
*/
public record Prefix(String compact) implements ParsedPrefix, Comparable<Prefix> {

public Prefix {
Postcode.Scan scan = Postcode.scan(compact);
Segment through = ending(scan.found());
boolean clean = scan.error() == null && through != null && compact.equals(scan.kept());
if (!clean || Postcode.validate(compact, through) != null) {
throw new IllegalArgumentException("not a compact upper-case prefix: " + compact);
}
}

/** Parses a hyphenated, spaced or compact prefix in either case. */
public static ParsedPrefix parse(String text) {
Postcode.Scan scan = Postcode.scan(text);
if (scan.error() != null) {
return scan.error();
}
Segment through = ending(scan.found());
if (through == null) {
return new WrongPrefixLength(scan.found());
}
ParseError error = Postcode.validate(scan.kept(), through);
return error != null ? error : new Prefix(scan.kept());
}

/** The last segment the prefix holds. */
public Segment through() {
return ending(compact.length());
}

/** The prefix one segment shorter, or empty for a state. */
public Optional<Prefix> parent() {
int wider = through().ordinal() - 1;
return wider < 0
? Optional.empty()
: Optional.of(new Prefix(compact.substring(0, Segment.values()[wider].end)));
}

/** Whether {@code code} starts with this prefix. */
public boolean contains(Postcode code) {
return code.compact().startsWith(compact);
}

/** The hyphenated form, {@code EK-01-A03}. */
@Override
public String toString() {
return Arrays.stream(Segment.values())
.limit(through().ordinal() + 1)
.map(segment -> compact.substring(segment.start, segment.end))
.collect(Collectors.joining("-"));
}

/** By compact form, so a prefix sorts just before the codes it contains. */
@Override
public int compareTo(Prefix other) {
return compact.compareTo(other.compact);
}

private static Segment ending(int length) {
return Arrays.stream(Segment.values())
.filter(segment -> segment.end == length)
.findFirst()
.orElse(null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import io.github.adeniyikayodee.ngpostcode.ParseError.InvalidCharacter;
import io.github.adeniyikayodee.ngpostcode.ParseError.InvalidSegment;
import io.github.adeniyikayodee.ngpostcode.ParseError.WrongLength;
import io.github.adeniyikayodee.ngpostcode.ParseError.WrongPrefixLength;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Locale;
Expand Down Expand Up @@ -45,8 +46,11 @@ private static Stream<JsonNode> cases(JsonNode list) {

private static JsonNode outcome(Object result) {
Object value;
if (result instanceof Postcode code) {
value = Map.of("canonical", code.toString());
if (result instanceof Postcode || result instanceof Prefix) {
value = Map.of("canonical", result.toString());
} else if (result instanceof WrongPrefixLength error) {
value = Map.of(
"error", Map.of("kind", "prefix_length", "found", error.found(), "message", error.toString()));
} else if (result instanceof Corrected fixed) {
value = Map.of("canonical", fixed.postcode().toString(), "corrections", fixed.corrections());
} else if (result instanceof WrongLength error) {
Expand Down Expand Up @@ -111,6 +115,51 @@ Stream<DynamicTest> prefixes() {
}));
}

@TestFactory
Stream<DynamicTest> parsesValidPrefixes() {
return cases(CASES.at("/parse_prefix/valid")).map(c -> dynamicTest(name(c), () -> {
Prefix prefix = (Prefix) Prefix.parse(c.get("input").asText());
assertEquals(c.get("canonical").asText(), prefix.toString());
assertEquals(c.get("compact").asText(), prefix.compact());
assertEquals(c.get("through").asText(), prefix.through().toString());
}));
}

@TestFactory
Stream<DynamicTest> rejectsInvalidPrefixes() {
return cases(CASES.at("/parse_prefix/invalid"))
.map(c -> dynamicTest(name(c), () -> assertEquals(
expected(c), outcome(Prefix.parse(c.get("input").asText())))));
}

@TestFactory
Stream<DynamicTest> prefixesContainTheirCodes() {
return cases(CASES.get("prefix_contains")).map(c -> dynamicTest(c.get("prefix").asText(), () -> {
Prefix prefix = (Prefix) Prefix.parse(c.get("prefix").asText());
Postcode code = (Postcode) Postcode.parse(c.get("code").asText());
assertEquals(c.get("contains").asBoolean(), prefix.contains(code));
assertEquals(c.get("contains").asBoolean(), code.truncate(prefix.through()).equals(prefix));
}));
}

@TestFactory
Stream<DynamicTest> prefixesHaveParents() {
return cases(CASES.get("prefix_parent")).map(c -> dynamicTest(c.get("prefix").asText(), () -> {
Prefix prefix = (Prefix) Prefix.parse(c.get("prefix").asText());
assertEquals(
c.get("parent").asText(null),
prefix.parent().map(Prefix::toString).orElse(null));
}));
}

@Test
void aPrefixRefusesAnUncheckedString() {
assertEquals("EK-01", new Prefix("EK01").toString());
for (String bad : new String[] {"", "E", "ek", "EK-01", "EK00", "E1", "EK01A03FK011"}) {
assertThrows(IllegalArgumentException.class, () -> new Prefix(bad));
}
}

@Test
void aTurkishLocaleChangesNothing() {
Locale before = Locale.getDefault();
Expand Down
1 change: 1 addition & 0 deletions js/packages/ng-postcode-js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ if (code instanceof Postcode) {
- `parse` accepts hyphenated, spaced or compact input in either case.
- `parseLenient` first swaps look-alikes that cannot occur where they stand (`O`/`0`, `I`/`1`, `S`/`5`, `B`/`8`) and reports how many it changed.
- `fromSegments` assembles a code from its parts and zero-fills the LGA and unit.
- `parsePrefix` reads a code cut off after a segment, such as the district `EK-01-A03`, and `truncate` cuts a code down to one. A prefix knows which codes it contains, so it groups them by state, LGA, district, or area.
- `isValid` answers yes or no.

A well-formed code is not necessarily assigned to a building. Only the API can confirm that a postcode exists.
Expand Down
3 changes: 3 additions & 0 deletions js/packages/ng-postcode-js/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,12 @@ export {
LENGTH,
type ParseError,
Postcode,
Prefix,
parse,
parseLenient,
parsePrefix,
SEGMENTS,
type Segment,
WrongLength,
WrongPrefixLength,
} from "./postcode.js";
Loading
Loading