From 21baa24ffee54583e7ff8ac5bf443978d88ab5db Mon Sep 17 00:00:00 2001 From: mkbadeniyi Date: Sat, 10 Oct 2026 22:13:32 +0100 Subject: [PATCH 1/6] feat(spec): add cases for a prefix, a code cut off after a segment --- spec/vectors.json | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/spec/vectors.json b/spec/vectors.json index c40ffc0..bfb9b75 100644 --- a/spec/vectors.json +++ b/spec/vectors.json @@ -1,5 +1,5 @@ { - "about": "Conformance cases every ng-postcode implementation must pass. Errors: length {found}, invalid_character {char, index}, segment {segment}, each with the message every implementation prints for it.", + "about": "Conformance cases every ng-postcode implementation must pass. Errors: length {found}, prefix_length {found}, invalid_character {char, index}, segment {segment}, each with the message every implementation prints for it. A prefix is a code cut off after one of its segments; it contains every code that starts with it, and its parent is the prefix one segment shorter.", "parse": { "valid": [ { "input": "EK-01-A03-FK-01", "canonical": "EK-01-A03-FK-01", "compact": "EK01A03FK01", "spaced": "EK 01 A03 FK 01" }, @@ -75,5 +75,36 @@ { "input": "LA-11-W06-TC-10", "through": "district", "prefix": "LA-11-W06" }, { "input": "LA-11-W06-TC-10", "through": "area", "prefix": "LA-11-W06-TC" }, { "input": "LA-11-W06-TC-10", "through": "unit", "prefix": "LA-11-W06-TC-10" } + ], + "parse_prefix": { + "valid": [ + { "input": "EK", "canonical": "EK", "compact": "EK", "through": "state" }, + { "input": "ek 01", "canonical": "EK-01", "compact": "EK01", "through": "lga" }, + { "input": "EK-01-A03", "canonical": "EK-01-A03", "compact": "EK01A03", "through": "district" }, + { "input": "ek01a03fk", "canonical": "EK-01-A03-FK", "compact": "EK01A03FK", "through": "area" }, + { "input": "EK-01-A03-FK-01", "canonical": "EK-01-A03-FK-01", "compact": "EK01A03FK01", "through": "unit" } + ], + "invalid": [ + { "input": "", "error": { "kind": "prefix_length", "found": 0, "message": "expected 2, 4, 7, 9 or 11 letters and digits, found 0" } }, + { "input": "E", "error": { "kind": "prefix_length", "found": 1, "message": "expected 2, 4, 7, 9 or 11 letters and digits, found 1" } }, + { "input": "EK-0", "error": { "kind": "prefix_length", "found": 3, "message": "expected 2, 4, 7, 9 or 11 letters and digits, found 3" } }, + { "input": "EK-01-A03-FK-011", "error": { "kind": "prefix_length", "found": 12, "message": "expected 2, 4, 7, 9 or 11 letters and digits, found 12" } }, + { "input": "E1", "error": { "kind": "segment", "segment": "state", "message": "invalid state segment" } }, + { "input": "EK-00", "error": { "kind": "segment", "segment": "lga", "message": "invalid lga segment" } }, + { "input": "EK-01-A03-F1", "error": { "kind": "segment", "segment": "area", "message": "invalid area segment" } }, + { "input": "EK_01", "error": { "kind": "invalid_character", "char": "_", "index": 2, "message": "invalid character '_' at index 2" } } + ] + }, + "prefix_contains": [ + { "prefix": "EK", "code": "EK-01-A03-FK-01", "contains": true }, + { "prefix": "EK-01-A03", "code": "EK-01-A03-FK-01", "contains": true }, + { "prefix": "EK-01-A03-FK-01", "code": "EK-01-A03-FK-01", "contains": true }, + { "prefix": "EK-01-A04", "code": "EK-01-A03-FK-01", "contains": false } + ], + "prefix_parent": [ + { "prefix": "EK-01-A03-FK-01", "parent": "EK-01-A03-FK" }, + { "prefix": "EK-01-A03", "parent": "EK-01" }, + { "prefix": "EK-01", "parent": "EK" }, + { "prefix": "EK", "parent": null } ] } From da2404cc54d2ca4e07c7e25de958944be7593a4f Mon Sep 17 00:00:00 2001 From: mkbadeniyi Date: Sat, 10 Oct 2026 22:13:32 +0100 Subject: [PATCH 2/6] feat(crate): add Prefix, with parse, contains, and parent --- src/lib.rs | 2 +- src/postcode.rs | 135 +++++++++++++++++++++++++++++++++++++++++-- tests/conformance.rs | 40 ++++++++++++- 3 files changed, 171 insertions(+), 6 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index f7be3ab..2c80a00 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,7 +26,7 @@ pub mod api; #[cfg(feature = "client")] pub mod client; -pub use postcode::{is_valid, Corrected, ParseError, Postcode, Segment}; +pub use postcode::{is_valid, Corrected, ParseError, Postcode, Prefix, Segment}; // Compiles and runs the examples in the crate README. #[cfg(all(doctest, feature = "client"))] diff --git a/src/postcode.rs b/src/postcode.rs index cb43e81..750cbe5 100644 --- a/src/postcode.rs +++ b/src/postcode.rs @@ -34,6 +34,8 @@ pub enum Segment { pub enum ParseError { /// The input did not hold exactly 11 letters and digits. Length { found: usize }, + /// The input did not end where a segment does: after 2, 4, 7, 9 or 11 letters and digits. + PrefixLength { found: usize }, /// The input held something other than letters, digits, spaces and hyphens. InvalidCharacter { ch: char, index: usize }, /// A segment has the wrong shape, such as digits in the state. @@ -107,6 +109,14 @@ impl Postcode { self.joined(through, "-") } + /// The code cut off after `through`, as a value: `truncate(Segment::Area)` is the + /// area this building is in. + pub fn truncate(&self, through: Segment) -> Prefix { + let mut bytes = self.0; + bytes[through.range().end..].fill(0); + Prefix { bytes, through } + } + pub fn segment(&self, segment: Segment) -> &str { &self.as_str()[segment.range()] } @@ -141,6 +151,57 @@ impl Postcode { } } +/// A postcode cut off after one of its segments, such as the district `EK-01-A03`. +/// +/// 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. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct Prefix { + // Zero after the last segment. + bytes: [u8; LEN], + through: Segment, +} + +impl Prefix { + /// Parses a hyphenated, spaced or compact prefix in either case. + pub fn parse(input: &str) -> Result { + let (bytes, found) = scan(input)?; + let through = Segment::ALL + .into_iter() + .find(|s| s.range().end == found) + .ok_or(ParseError::PrefixLength { found })?; + match (Segment::ALL.into_iter()) + .take_while(|&s| s <= through) + .find(|s| !s.accepts(&bytes[s.range()])) + { + Some(segment) => Err(ParseError::Segment(segment)), + None => Ok(Self { bytes, through }), + } + } + + /// The last segment the prefix holds. + pub fn through(&self) -> Segment { + self.through + } + + /// The compact form, `EK01A03`. Store and compare this one. + pub fn as_str(&self) -> &str { + std::str::from_utf8(&self.bytes[..self.through.range().end]) + .expect("prefix bytes are ASCII") + } + + /// Whether `code` starts with this prefix. + pub fn contains(&self, code: &Postcode) -> bool { + code.truncate(self.through) == *self + } + + /// The prefix one segment shorter, or `None` for a state. + pub fn parent(&self) -> Option { + let through = (Segment::ALL.into_iter()).rfind(|&s| s < self.through)?; + Some(Postcode(self.bytes).truncate(through)) + } +} + /// Whether `input` is a well-formed postcode. pub fn is_valid(input: &str) -> bool { Postcode::parse(input).is_ok() @@ -200,6 +261,14 @@ impl Segment { } fn collect(input: &str) -> Result<[u8; LEN], ParseError> { + match scan(input)? { + (bytes, LEN) => Ok(bytes), + (_, found) => Err(ParseError::Length { found }), + } +} + +/// The letters and digits of `input` in upper case, and how many there were. +fn scan(input: &str) -> Result<([u8; LEN], usize), ParseError> { // Every character is checked, but only a postcode's worth is held: no allocation. let mut bytes = [0; LEN]; let mut found = 0; @@ -215,10 +284,7 @@ fn collect(input: &str) -> Result<[u8; LEN], ParseError> { } found += 1; } - match found { - LEN => Ok(bytes), - _ => Err(ParseError::Length { found }), - } + Ok((bytes, found)) } fn validate(bytes: [u8; LEN]) -> Result<[u8; LEN], ParseError> { @@ -252,6 +318,46 @@ impl fmt::Debug for Postcode { } } +impl FromStr for Prefix { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + Self::parse(s) + } +} + +impl From for Prefix { + fn from(code: Postcode) -> Self { + code.truncate(Unit) + } +} + +/// The hyphenated form, `EK-01-A03`. +impl fmt::Display for Prefix { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.pad(&Postcode(self.bytes).prefix(self.through)) + } +} + +impl fmt::Debug for Prefix { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Prefix({self})") + } +} + +/// By compact form, so a prefix sorts just before the codes it contains. +impl Ord for Prefix { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.as_str().cmp(other.as_str()) + } +} + +impl PartialOrd for Prefix { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + impl fmt::Display for Segment { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.pad(match self { @@ -268,6 +374,12 @@ impl fmt::Display for ParseError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Length { found } => write!(f, "expected {LEN} letters and digits, found {found}"), + Self::PrefixLength { found } => { + write!( + f, + "expected 2, 4, 7, 9 or 11 letters and digits, found {found}" + ) + } Self::InvalidCharacter { ch, index } => { write!(f, "invalid character '{ch}' at index {index}") } @@ -293,6 +405,21 @@ impl<'de> serde::Deserialize<'de> for Postcode { } } +#[cfg(feature = "serde")] +impl serde::Serialize for Prefix { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_str(self) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for Prefix { + fn deserialize>(deserializer: D) -> Result { + let text = >::deserialize(deserializer)?; + text.parse().map_err(serde::de::Error::custom) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/tests/conformance.rs b/tests/conformance.rs index 0bb2365..eaeb0e7 100644 --- a/tests/conformance.rs +++ b/tests/conformance.rs @@ -1,6 +1,6 @@ //! Runs the shared cases in `spec/vectors.json`, which every implementation must pass. -use ng_postcode::{ParseError, Postcode, Segment}; +use ng_postcode::{ParseError, Postcode, Prefix, Segment}; use serde_json::{json, Value}; const VECTORS: &str = include_str!("../spec/vectors.json"); @@ -36,6 +36,7 @@ fn segment(name: &str) -> Segment { fn error_json(error: &ParseError) -> Value { let mut json = match error { ParseError::Length { found } => json!({ "kind": "length", "found": found }), + ParseError::PrefixLength { found } => json!({ "kind": "prefix_length", "found": found }), ParseError::InvalidCharacter { ch, index } => { json!({ "kind": "invalid_character", "char": ch.to_string(), "index": index }) } @@ -107,3 +108,40 @@ fn prefix() { assert_eq!(code.prefix(through), text(case, "prefix"), "{case}"); } } + +#[test] +fn parse_prefix() { + let all = vectors(); + for case in cases(&all, "/parse_prefix/valid") { + let prefix = Prefix::parse(text(case, "input")).unwrap(); + assert_eq!(prefix.to_string(), text(case, "canonical"), "{case}"); + assert_eq!(prefix.as_str(), text(case, "compact"), "{case}"); + assert_eq!(prefix.through(), segment(text(case, "through")), "{case}"); + } + for case in cases(&all, "/parse_prefix/invalid") { + let error = Prefix::parse(text(case, "input")).unwrap_err(); + assert_eq!(error_json(&error), case["error"], "{case}"); + } +} + +#[test] +fn prefix_contains_and_parent() { + let all = vectors(); + for case in cases(&all, "/prefix_contains") { + let prefix = Prefix::parse(text(case, "prefix")).unwrap(); + let code = Postcode::parse(text(case, "code")).unwrap(); + assert_eq!(json!(prefix.contains(&code)), case["contains"], "{case}"); + assert_eq!( + code.truncate(prefix.through()) == prefix, + prefix.contains(&code) + ); + } + for case in cases(&all, "/prefix_parent") { + let parent = Prefix::parse(text(case, "prefix")).unwrap().parent(); + assert_eq!( + json!(parent.map(|p| p.to_string())), + case["parent"], + "{case}" + ); + } +} From fd4c903e22ef957cadcc75a76a9d53a220e1f02d Mon Sep 17 00:00:00 2001 From: mkbadeniyi Date: Sat, 10 Oct 2026 22:14:35 +0100 Subject: [PATCH 3/6] feat(python): add Prefix and parse_prefix --- python/src/ng_postcode/__init__.py | 6 ++ python/src/ng_postcode/_postcode.py | 100 ++++++++++++++++++++++++++-- python/tests/test_conformance.py | 48 ++++++++++++- 3 files changed, 146 insertions(+), 8 deletions(-) diff --git a/python/src/ng_postcode/__init__.py b/python/src/ng_postcode/__init__.py index ef5be60..54c6c0d 100644 --- a/python/src/ng_postcode/__init__.py +++ b/python/src/ng_postcode/__init__.py @@ -21,12 +21,15 @@ InvalidSegment, ParseError, Postcode, + Prefix, Segment, WrongLength, + WrongPrefixLength, from_segments, is_valid, parse, parse_lenient, + parse_prefix, ) __all__ = [ @@ -35,10 +38,13 @@ "InvalidSegment", "ParseError", "Postcode", + "Prefix", "Segment", "WrongLength", + "WrongPrefixLength", "from_segments", "is_valid", "parse", "parse_lenient", + "parse_prefix", ] diff --git a/python/src/ng_postcode/_postcode.py b/python/src/ng_postcode/_postcode.py index 902136f..c1cbfda 100644 --- a/python/src/ng_postcode/_postcode.py +++ b/python/src/ng_postcode/_postcode.py @@ -39,6 +39,7 @@ class Segment(Enum): Segment.AREA: range(7, 9), Segment.UNIT: range(9, 11), } +_ENDS = {span.stop: segment for segment, span in _SPANS.items()} _ALPHA = frozenset({Segment.STATE, Segment.AREA}) _NUMERIC = frozenset({Segment.LGA, Segment.UNIT}) @@ -53,6 +54,16 @@ def __str__(self) -> str: return f"expected {LENGTH} letters and digits, found {self.found}" +@dataclass(frozen=True, slots=True) +class WrongPrefixLength: + """The input did not end where a segment does: after 2, 4, 7, 9 or 11 letters and digits.""" + + found: int + + def __str__(self) -> str: + return f"expected 2, 4, 7, 9 or 11 letters and digits, found {self.found}" + + @dataclass(frozen=True, slots=True) class InvalidCharacter: """The input held something other than letters, digits, spaces and hyphens.""" @@ -74,7 +85,7 @@ def __str__(self) -> str: return f"invalid {self.segment.value} segment" -ParseError: TypeAlias = WrongLength | InvalidCharacter | InvalidSegment +ParseError: TypeAlias = WrongLength | WrongPrefixLength | InvalidCharacter | InvalidSegment @dataclass(frozen=True, slots=True, order=True) @@ -128,8 +139,51 @@ def segment(self, segment: Segment) -> str: def prefix(self, through: Segment) -> str: """The hyphenated code down to `through`: `prefix(Segment.AREA)` is `EK-01-A03-FK`.""" - order = list(Segment) - return "-".join(self.segment(s) for s in order[: order.index(through) + 1]) + return str(self.truncate(through)) + + def truncate(self, through: Segment) -> Prefix: + """The code cut off after `through`, as a value: `truncate(Segment.AREA)` is the + area this building is in.""" + return Prefix(self.compact[: _SPANS[through].stop]) + + +@dataclass(frozen=True, slots=True, order=True) +class Prefix: + """A postcode cut off after one of its segments, such as the district `EK-01-A03`. + + 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 + `parse_prefix` or `Postcode.truncate`; an unchecked string raises `ValueError`. + """ + + compact: str + + def __post_init__(self) -> None: + through = _ENDS.get(len(self.compact)) + clean = _scan(self.compact) == (self.compact, len(self.compact)) + if through is None or not clean or _validate(self.compact, through) is not None: + raise ValueError(f"not a compact upper-case prefix: {self.compact!r}") + + def __str__(self) -> str: + return "-".join(_part(self.compact, s) for s in _through(self.through)) + + def __repr__(self) -> str: + return f"Prefix('{self.compact}')" + + @property + def through(self) -> Segment: + """The last segment the prefix holds.""" + return _ENDS[len(self.compact)] + + @property + def parent(self) -> Prefix | None: + """The prefix one segment shorter, or None for a state.""" + wider = _through(self.through)[:-1] + return Prefix(self.compact[: _SPANS[wider[-1]].stop]) if wider else None + + def contains(self, code: Postcode) -> bool: + """Whether `code` starts with this prefix.""" + return code.compact.startswith(self.compact) @dataclass(frozen=True, slots=True) @@ -177,6 +231,24 @@ def parse_lenient(text: str) -> Corrected | ParseError: return Corrected(Postcode(fixed), sum(a != b for a, b in zip(raw, fixed, strict=True))) +def parse_prefix(text: str) -> Prefix | ParseError: + """Parse a hyphenated, spaced or compact prefix in either case. + + >>> parse_prefix("ek 01 a03") + Prefix('EK01A03') + >>> str(parse_prefix("EK-0")) + 'expected 2, 4, 7, 9 or 11 letters and digits, found 3' + """ + scanned = _scan(text) + if isinstance(scanned, InvalidCharacter): + return scanned + compact, found = scanned + through = _ENDS.get(found) + if through is None: + return WrongPrefixLength(found=found) + return _validate(compact, through) or Prefix(compact) + + def from_segments( state: str, lga: str, district: str, area: str, unit: str ) -> Postcode | ParseError: @@ -199,6 +271,15 @@ def is_valid(text: str) -> bool: def _collect(text: str) -> str | ParseError: + scanned = _scan(text) + if isinstance(scanned, InvalidCharacter): + return scanned + compact, found = scanned + return compact if found == LENGTH else WrongLength(found=found) + + +def _scan(text: str) -> tuple[str, int] | InvalidCharacter: + """The letters and digits of `text` in upper case, and how many there were.""" # Every character is checked, but no more than a postcode's worth is held, so the # memory used does not grow with the input. kept: list[str] = [] @@ -211,7 +292,13 @@ def _collect(text: str) -> str | ParseError: if found < LENGTH: kept.append(char) found += 1 - return "".join(kept).upper() if found == LENGTH else WrongLength(found=found) + return "".join(kept).upper(), found + + +def _through(last: Segment) -> list[Segment]: + """The segments from the state down to `last`.""" + order = list(Segment) + return order[: order.index(last) + 1] def _part(compact: str, segment: Segment) -> str: @@ -219,8 +306,9 @@ def _part(compact: str, segment: Segment) -> str: return compact[span.start : span.stop] -def _validate(compact: str) -> InvalidSegment | None: - return next((InvalidSegment(s) for s in Segment if not _accepts(s, _part(compact, s))), None) +def _validate(compact: str, through: Segment = Segment.UNIT) -> InvalidSegment | None: + bad = (s for s in _through(through) if not _accepts(s, _part(compact, s))) + return next((InvalidSegment(s) for s in bad), None) def _accepts(segment: Segment, text: str) -> bool: diff --git a/python/tests/test_conformance.py b/python/tests/test_conformance.py index 4c6a461..35882f1 100644 --- a/python/tests/test_conformance.py +++ b/python/tests/test_conformance.py @@ -14,11 +14,14 @@ InvalidSegment, ParseError, Postcode, + Prefix, Segment, WrongLength, + WrongPrefixLength, from_segments, parse, parse_lenient, + parse_prefix, ) VECTORS = Path(__file__).resolve().parents[2] / "spec" / "vectors.json" @@ -29,14 +32,16 @@ ) -def outcome(result: Postcode | Corrected | ParseError) -> dict[str, Any]: +def outcome(result: Postcode | Prefix | Corrected | ParseError) -> dict[str, Any]: match result: - case Postcode(): + case Postcode() | Prefix(): return {"canonical": str(result)} case Corrected(postcode, corrections): return {"canonical": str(postcode), "corrections": corrections} case WrongLength(found): error: dict[str, Any] = {"kind": "length", "found": found} + case WrongPrefixLength(found): + error = {"kind": "prefix_length", "found": found} case InvalidCharacter(char, index): error = {"kind": "invalid_character", "char": char, "index": index} case InvalidSegment(segment): @@ -79,3 +84,42 @@ def test_prefix(case: dict[str, Any]) -> None: code = parse(case["input"]) assert isinstance(code, Postcode) assert code.prefix(Segment(case["through"])) == case["prefix"] + + +@pytest.mark.parametrize("case", CASES.get("parse_prefix", {}).get("valid", [])) +def test_parse_prefix_valid(case: dict[str, Any]) -> None: + prefix = parse_prefix(case["input"]) + assert isinstance(prefix, Prefix) + assert (str(prefix), prefix.compact, prefix.through.value) == ( + case["canonical"], + case["compact"], + case["through"], + ) + assert Prefix(prefix.compact) == prefix + + +@pytest.mark.parametrize("case", CASES.get("parse_prefix", {}).get("invalid", [])) +def test_parse_prefix_invalid(case: dict[str, Any]) -> None: + assert outcome(parse_prefix(case["input"])) == expected(case) + + +@pytest.mark.parametrize("case", CASES.get("prefix_contains", [])) +def test_prefix_contains(case: dict[str, Any]) -> None: + prefix, code = parse_prefix(case["prefix"]), parse(case["code"]) + assert isinstance(prefix, Prefix) + assert isinstance(code, Postcode) + assert prefix.contains(code) is case["contains"] + assert (code.truncate(prefix.through) == prefix) is case["contains"] + + +@pytest.mark.parametrize("case", CASES.get("prefix_parent", [])) +def test_prefix_parent(case: dict[str, Any]) -> None: + prefix = parse_prefix(case["prefix"]) + assert isinstance(prefix, Prefix) + assert (str(prefix.parent) if prefix.parent else None) == case["parent"] + + +@pytest.mark.parametrize("compact", ["", "E", "ek", "EK-01", "EK00", "E1", "EK01A03FK011"]) +def test_a_prefix_cannot_be_built_from_unchecked_text(compact: str) -> None: + with pytest.raises(ValueError, match="not a compact upper-case prefix"): + Prefix(compact) From 97d02dbb7d9ce953a68f1658e054d3cd337edd37 Mon Sep 17 00:00:00 2001 From: mkbadeniyi Date: Sat, 10 Oct 2026 22:15:21 +0100 Subject: [PATCH 4/6] feat(js): add Prefix and parsePrefix --- js/packages/ng-postcode-js/src/index.ts | 3 + js/packages/ng-postcode-js/src/postcode.ts | 99 ++++++++++++++++++-- js/packages/ng-postcode-js/test/spec.test.ts | 41 +++++++- 3 files changed, 136 insertions(+), 7 deletions(-) diff --git a/js/packages/ng-postcode-js/src/index.ts b/js/packages/ng-postcode-js/src/index.ts index 0844635..ddf9b42 100644 --- a/js/packages/ng-postcode-js/src/index.ts +++ b/js/packages/ng-postcode-js/src/index.ts @@ -25,9 +25,12 @@ export { LENGTH, type ParseError, Postcode, + Prefix, parse, parseLenient, + parsePrefix, SEGMENTS, type Segment, WrongLength, + WrongPrefixLength, } from "./postcode.js"; diff --git a/js/packages/ng-postcode-js/src/postcode.ts b/js/packages/ng-postcode-js/src/postcode.ts index 9bde172..f37e3a8 100644 --- a/js/packages/ng-postcode-js/src/postcode.ts +++ b/js/packages/ng-postcode-js/src/postcode.ts @@ -27,6 +27,15 @@ export class WrongLength { } } +/** The input did not end where a segment does: after 2, 4, 7, 9 or 11 letters and digits. */ +export class WrongPrefixLength { + constructor(readonly found: number) {} + + toString(): string { + return `expected 2, 4, 7, 9 or 11 letters and digits, found ${this.found}`; + } +} + /** The input held something other than letters, digits, spaces and hyphens. */ export class InvalidCharacter { constructor( @@ -48,7 +57,7 @@ export class InvalidSegment { } } -export type ParseError = WrongLength | InvalidCharacter | InvalidSegment; +export type ParseError = WrongLength | WrongPrefixLength | InvalidCharacter | InvalidSegment; /** Held only here, so nothing outside this module can build a `Postcode` unchecked. */ const CHECKED: unique symbol = Symbol("checked"); @@ -120,10 +129,68 @@ export class Postcode { /** The hyphenated code down to `through`: `prefix("area")` is `EK-01-A03-FK`. */ prefix(through: Segment): string { - return SEGMENTS.slice(0, SEGMENTS.indexOf(known(through)) + 1) - .map((segment) => this.segment(segment)) + return String(this.truncate(through)); + } + + /** The code cut off after `through`, as a value: `truncate("area")` is the area this building is in. */ + truncate(through: Segment): Prefix { + return new Prefix(CHECKED, this.compact.slice(0, SPANS[known(through)][1])); + } +} + +/** + * A postcode cut off after one of its segments, such as the district `EK-01-A03`. + * + * 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 + * `parsePrefix` or `Postcode.truncate`. + */ +export class Prefix { + readonly #compact: string; + + /** @internal Callers use `parsePrefix`: only this module holds the key. */ + constructor(key: typeof CHECKED, compact: string) { + if (key !== CHECKED) throw new TypeError("build a Prefix with parsePrefix()"); + this.#compact = compact; + Object.freeze(this); + } + + /** Whether `value` came from `parsePrefix` or `truncate`. */ + static is(value: unknown): value is Prefix { + return typeof value === "object" && value !== null && #compact in value; + } + + /** The form to store and compare, `EK01A03`. */ + get compact(): string { + return this.#compact; + } + + /** The last segment the prefix holds. */ + get through(): Segment { + return SEGMENTS.find((segment) => SPANS[segment][1] === this.#compact.length) as Segment; + } + + /** The prefix one segment shorter, or null for a state. */ + get parent(): Prefix | null { + const wider = SEGMENTS[SEGMENTS.indexOf(this.through) - 1]; + return wider ? new Prefix(CHECKED, this.#compact.slice(0, SPANS[wider][1])) : null; + } + + /** Whether `code` starts with this prefix. */ + contains(code: Postcode): boolean { + return code.compact.startsWith(this.#compact); + } + + /** The hyphenated form, `EK-01-A03`. */ + toString(): string { + return SEGMENTS.slice(0, SEGMENTS.indexOf(this.through) + 1) + .map((segment) => part(this.#compact, segment)) .join("-"); } + + toJSON(): string { + return this.toString(); + } } /** The result of `parseLenient`. */ @@ -158,6 +225,16 @@ export function parseLenient(text: string): Corrected | ParseError { return { postcode: new Postcode(CHECKED, fixed), corrections }; } +/** Parse a hyphenated, spaced or compact prefix in either case. */ +export function parsePrefix(text: string): Prefix | ParseError { + const scanned = scan(text); + if (scanned instanceof InvalidCharacter) return scanned; + const [compact, found] = scanned; + const through = SEGMENTS.find((segment) => SPANS[segment][1] === found); + if (!through) return new WrongPrefixLength(found); + return validate(compact, through) ?? new Prefix(CHECKED, compact); +} + /** Build a code from its segments, zero-filling the LGA and unit. */ export function fromSegments( state: string, @@ -194,6 +271,14 @@ function isAsciiAlphanumeric(char: string): boolean { } function collect(text: string): string | ParseError { + const scanned = scan(text); + if (scanned instanceof InvalidCharacter) return scanned; + const [compact, found] = scanned; + return found === LENGTH ? compact : new WrongLength(found); +} + +/** The letters and digits of `text` in upper case, and how many there were. */ +function scan(text: string): [string, number] | InvalidCharacter { // Every character is checked, but no more than a postcode's worth is held. let kept = ""; let found = 0; @@ -206,7 +291,7 @@ function collect(text: string): string | ParseError { } index += 1; } - return found === LENGTH ? kept.toUpperCase() : new WrongLength(found); + return [kept.toUpperCase(), found]; } /** Untyped callers can pass any name: "Area" would otherwise give an empty prefix. */ @@ -220,8 +305,10 @@ function part(compact: string, segment: Segment): string { return compact.slice(start, end); } -function validate(compact: string): InvalidSegment | undefined { - const bad = SEGMENTS.find((segment) => !accepts(segment, part(compact, segment))); +function validate(compact: string, through: Segment = "unit"): InvalidSegment | undefined { + const bad = SEGMENTS.slice(0, SEGMENTS.indexOf(through) + 1).find( + (segment) => !accepts(segment, part(compact, segment)), + ); return bad ? new InvalidSegment(bad) : undefined; } diff --git a/js/packages/ng-postcode-js/test/spec.test.ts b/js/packages/ng-postcode-js/test/spec.test.ts index 64786aa..1f0baf5 100644 --- a/js/packages/ng-postcode-js/test/spec.test.ts +++ b/js/packages/ng-postcode-js/test/spec.test.ts @@ -17,10 +17,13 @@ import { InvalidCharacter, InvalidSegment, Postcode, + Prefix, parse, parseLenient, + parsePrefix, type Segment, WrongLength, + WrongPrefixLength, } from "../src/postcode.js"; type Case = any; @@ -31,8 +34,10 @@ const CODE = parse("FC03B06AG12") as Postcode; const HERE: Coordinate = { lat: 7.6211, lng: 5.2214 }; function outcome(result: unknown): Case { - if (result instanceof Postcode) return { canonical: String(result) }; + if (result instanceof Postcode || result instanceof Prefix) return { canonical: String(result) }; const message = String(result); + if (result instanceof WrongPrefixLength) + return { error: { kind: "prefix_length", found: result.found, message } }; if (result instanceof WrongLength) return { error: { kind: "length", found: result.found, message } }; if (result instanceof InvalidCharacter) { @@ -74,6 +79,40 @@ test.each(vectors.prefix as Case[])("prefix $input through $through", (c) => { expect((parse(c.input) as Postcode).prefix(c.through as Segment)).toBe(c.prefix); }); +test.each(vectors.parse_prefix.valid as Case[])("parse prefix valid $input", (c) => { + const prefix = parsePrefix(c.input) as Prefix; + expect([String(prefix), prefix.compact, prefix.through]).toEqual([ + c.canonical, + c.compact, + c.through, + ]); + expect(JSON.stringify(prefix)).toBe(JSON.stringify(c.canonical)); +}); + +test.each(vectors.parse_prefix.invalid as Case[])("parse prefix invalid $input", (c) => { + expect(outcome(parsePrefix(c.input))).toEqual(expected(c)); +}); + +test.each(vectors.prefix_contains as Case[])("$prefix contains $code", (c) => { + const [prefix, code] = [parsePrefix(c.prefix) as Prefix, parse(c.code) as Postcode]; + expect(prefix.contains(code)).toBe(c.contains); + expect(code.truncate(prefix.through).compact === prefix.compact).toBe(c.contains); +}); + +test.each(vectors.prefix_parent as Case[])("parent of $prefix", (c) => { + const parent = (parsePrefix(c.prefix) as Prefix).parent; + expect(parent && String(parent)).toBe(c.parent); +}); + +test("a Prefix cannot be built or changed outside the module", () => { + const build = Prefix as unknown as new (...args: unknown[]) => Prefix; + expect(() => new build("EK01")).toThrow(TypeError); + const prefix = parsePrefix("EK-01") as Prefix; + expect(() => Object.assign(prefix, { compact: "x" })).toThrow(TypeError); + expect(Prefix.is(prefix)).toBe(true); + expect(Prefix.is(Object.create(Prefix.prototype))).toBe(false); +}); + function build(kind: string, args: Case): Request { if (kind === "lookup") return lookup(parse(args.code) as Postcode, args.level); if (kind === "autocomplete") return autocomplete(args.q); From f51687badf62c3c299cbe555c8fa927873ecbfc1 Mon Sep 17 00:00:00 2001 From: mkbadeniyi Date: Sat, 10 Oct 2026 22:16:20 +0100 Subject: [PATCH 5/6] feat(java): add Prefix and Prefix.parse --- .../adeniyikayodee/ngpostcode/ParseError.java | 10 ++- .../ngpostcode/ParsedPrefix.java | 4 + .../adeniyikayodee/ngpostcode/Postcode.java | 31 +++++++- .../adeniyikayodee/ngpostcode/Prefix.java | 79 +++++++++++++++++++ .../ngpostcode/ConformanceTest.java | 53 ++++++++++++- 5 files changed, 171 insertions(+), 6 deletions(-) create mode 100644 java/core/src/main/java/io/github/adeniyikayodee/ngpostcode/ParsedPrefix.java create mode 100644 java/core/src/main/java/io/github/adeniyikayodee/ngpostcode/Prefix.java diff --git a/java/core/src/main/java/io/github/adeniyikayodee/ngpostcode/ParseError.java b/java/core/src/main/java/io/github/adeniyikayodee/ngpostcode/ParseError.java index 557acde..1b376ce 100644 --- a/java/core/src/main/java/io/github/adeniyikayodee/ngpostcode/ParseError.java +++ b/java/core/src/main/java/io/github/adeniyikayodee/ngpostcode/ParseError.java @@ -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 { @@ -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. * diff --git a/java/core/src/main/java/io/github/adeniyikayodee/ngpostcode/ParsedPrefix.java b/java/core/src/main/java/io/github/adeniyikayodee/ngpostcode/ParsedPrefix.java new file mode 100644 index 0000000..b35fca5 --- /dev/null +++ b/java/core/src/main/java/io/github/adeniyikayodee/ngpostcode/ParsedPrefix.java @@ -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 {} diff --git a/java/core/src/main/java/io/github/adeniyikayodee/ngpostcode/Postcode.java b/java/core/src/main/java/io/github/adeniyikayodee/ngpostcode/Postcode.java index 16faedb..65684a6 100644 --- a/java/core/src/main/java/io/github/adeniyikayodee/ngpostcode/Postcode.java +++ b/java/core/src/main/java/io/github/adeniyikayodee/ngpostcode/Postcode.java @@ -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); @@ -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++) { @@ -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); diff --git a/java/core/src/main/java/io/github/adeniyikayodee/ngpostcode/Prefix.java b/java/core/src/main/java/io/github/adeniyikayodee/ngpostcode/Prefix.java new file mode 100644 index 0000000..e935a86 --- /dev/null +++ b/java/core/src/main/java/io/github/adeniyikayodee/ngpostcode/Prefix.java @@ -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}. + * + *

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 { + + 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 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); + } +} diff --git a/java/core/src/test/java/io/github/adeniyikayodee/ngpostcode/ConformanceTest.java b/java/core/src/test/java/io/github/adeniyikayodee/ngpostcode/ConformanceTest.java index c61a806..7686c85 100644 --- a/java/core/src/test/java/io/github/adeniyikayodee/ngpostcode/ConformanceTest.java +++ b/java/core/src/test/java/io/github/adeniyikayodee/ngpostcode/ConformanceTest.java @@ -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; @@ -45,8 +46,11 @@ private static Stream 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) { @@ -111,6 +115,51 @@ Stream prefixes() { })); } + @TestFactory + Stream 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 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 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 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(); From a82d08d95df545c67031f41f3ef01cdda45a4fd7 Mon Sep 17 00:00:00 2001 From: mkbadeniyi Date: Sat, 10 Oct 2026 22:16:39 +0100 Subject: [PATCH 6/6] docs: describe prefixes in each library's README --- CRATE.md | 1 + docs/llms-full.txt | 4 ++++ java/README.md | 1 + js/packages/ng-postcode-js/README.md | 1 + python/README.md | 1 + 5 files changed, 8 insertions(+) diff --git a/CRATE.md b/CRATE.md index 1c9a5d8..cfb58bc 100644 --- a/CRATE.md +++ b/CRATE.md @@ -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. diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 08b8c75..a67934d 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -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. @@ -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. @@ -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. @@ -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. diff --git a/java/README.md b/java/README.md index 28b8602..9b317cf 100644 --- a/java/README.md +++ b/java/README.md @@ -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. diff --git a/js/packages/ng-postcode-js/README.md b/js/packages/ng-postcode-js/README.md index e5de4fa..bd98e6e 100644 --- a/js/packages/ng-postcode-js/README.md +++ b/js/packages/ng-postcode-js/README.md @@ -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. diff --git a/python/README.md b/python/README.md index 741f804..a94f95c 100644 --- a/python/README.md +++ b/python/README.md @@ -41,6 +41,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.