Skip to main content

kohebi_core/
classify.rs

1//! The questions a string answers about itself.
2//!
3//! Twelve methods on `str` whose names all start with `is`, asking eleven
4//! different questions, and the names do not make it obvious which. Three of
5//! them are here in name only, because their data lives with the thing it
6//! belongs to: `isprintable` is [`crate::printable`], and the three case ones
7//! lean on [`crate::casing`].
8//!
9//! ## The three number ones
10//!
11//! `isdecimal`, `isdigit` and `isnumeric` sound like one question asked three
12//! ways and are three properties that nest. `'٣'` is all three, being an
13//! Arabic-Indic three you could write a number with. `'³'` is a digit and a
14//! number and not a decimal, because a superscript is not a position in a
15//! numeral. `'½'` is only a number. So `int()` takes the first group, and a
16//! runtime that answered `char::is_numeric` to all three would be wrong twice.
17//!
18//! ## Empty
19//!
20//! Most of these are false for the empty string, on the grounds that a claim
21//! about every character is worth nothing when there are none. `isascii` and
22//! `isprintable` are true for it instead, because those are claims about what
23//! the string does not contain. That split is CPython's and is not a rule you
24//! could guess, so it is written down here and pinned by a test.
25
26mod table;
27
28use table::{ALPHABETIC, DECIMAL, DIGIT, NUMERIC, SPACE, XID_CONTINUE, XID_START};
29
30use crate::casing;
31use crate::printable::is_printable;
32use crate::ranges::among;
33
34/// `str.isalpha`.
35#[must_use]
36pub fn is_alpha(points: &[u32]) -> bool {
37    every(points, |cp| among(&ALPHABETIC, cp))
38}
39
40/// `str.isalnum`, which is the union of the other four and not a property of
41/// its own.
42#[must_use]
43pub fn is_alnum(points: &[u32]) -> bool {
44    // Only two tests, because the number ones nest and `NUMERIC` is the widest.
45    every(points, |cp| among(&ALPHABETIC, cp) || among(&NUMERIC, cp))
46}
47
48/// `str.isdecimal`, the narrowest of the three number questions.
49#[must_use]
50pub fn is_decimal(points: &[u32]) -> bool {
51    every(points, |cp| among(&DECIMAL, cp))
52}
53
54/// `str.isdigit`.
55#[must_use]
56pub fn is_digit(points: &[u32]) -> bool {
57    every(points, |cp| among(&DIGIT, cp))
58}
59
60/// `str.isnumeric`, the widest.
61#[must_use]
62pub fn is_numeric(points: &[u32]) -> bool {
63    every(points, |cp| among(&NUMERIC, cp))
64}
65
66/// `str.isspace`.
67#[must_use]
68pub fn is_space(points: &[u32]) -> bool {
69    every(points, |cp| among(&SPACE, cp))
70}
71
72/// Whether a single code point is whitespace, which is also what a split with
73/// no separator splits on and what a strip with no argument takes off.
74#[must_use]
75pub fn is_space_point(cp: u32) -> bool {
76    among(&SPACE, cp)
77}
78
79/// `str.isascii`, which is true of the empty string because it is a claim
80/// about what is not there.
81#[must_use]
82pub fn is_ascii(points: &[u32]) -> bool {
83    points.iter().all(|&cp| cp < 0x80)
84}
85
86/// `str.isprintable`, true of the empty string for the same reason.
87#[must_use]
88pub fn is_printable_str(points: &[u32]) -> bool {
89    // A lone surrogate is not a `char` and is not printable either, so the two
90    // failures give the same answer and neither needs a case of its own.
91    points
92        .iter()
93        .all(|&cp| char::from_u32(cp).is_some_and(is_printable))
94}
95
96/// `str.islower`.
97///
98/// Not every character being lowercase, which would make `'abc!'` fail. It is
99/// that at least one is, and that none is uppercase or titlecase. The three
100/// properties do not cover everything, so a digit or a space is neither for
101/// nor against.
102#[must_use]
103pub fn is_lower(points: &[u32]) -> bool {
104    leaning(points, casing::is_lowercase, casing::is_uppercase)
105}
106
107/// `str.isupper`.
108///
109/// A titlecase character counts against this one and against `islower` both,
110/// so `'Dž'` is neither upper nor lower.
111#[must_use]
112pub fn is_upper(points: &[u32]) -> bool {
113    leaning(points, casing::is_uppercase, casing::is_lowercase)
114}
115
116/// The two above, which differ only in which way they lean.
117fn leaning(points: &[u32], wanted: fn(u32) -> bool, against: fn(u32) -> bool) -> bool {
118    let mut found = false;
119    for &cp in points {
120        // Titlecase counts against both of them, being neither.
121        if against(cp) || casing::is_titlecase(cp) {
122            return false;
123        }
124        found |= wanted(cp);
125    }
126    found
127}
128
129/// `str.istitle`.
130///
131/// Every word starts with an uppercase or titlecase character and carries on
132/// in lowercase, and there is at least one word. What ends a word is a
133/// character that is none of the three, so `"they're"` is not titled, `'A'` is,
134/// and `'123'` is not because it has no word in it at all.
135#[must_use]
136pub fn is_title(points: &[u32]) -> bool {
137    let mut found = false;
138    let mut inside = false;
139    for &cp in points {
140        let starts = casing::is_uppercase(cp) || casing::is_titlecase(cp);
141        let carries = casing::is_lowercase(cp);
142        // A word may not start twice running, and may not carry on before it
143        // has started.
144        if starts && inside || carries && !inside {
145            return false;
146        }
147        if starts || carries {
148            inside = true;
149            found = true;
150        } else {
151            inside = false;
152        }
153    }
154    found
155}
156
157/// `str.isidentifier`.
158///
159/// Nothing to do with keywords, so `'if'.isidentifier()` is true and the
160/// caller is the one who has to care. It is also not what the parser accepts,
161/// which normalises the name first, so `'fi'` is an identifier here and is the
162/// name `fi` in a program.
163#[must_use]
164pub fn is_identifier(points: &[u32]) -> bool {
165    let Some((&first, rest)) = points.split_first() else {
166        return false;
167    };
168    among(&XID_START, first) && rest.iter().all(|&cp| among(&XID_CONTINUE, cp))
169}
170
171/// A claim about every code point, which an empty string cannot make.
172fn every(points: &[u32], holds: impl Fn(u32) -> bool) -> bool {
173    !points.is_empty() && points.iter().all(|&cp| holds(cp))
174}