Skip to content
Draft
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
15 changes: 15 additions & 0 deletions include/rbs/lexer.h
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,21 @@ enum RBSTokenType {
tANNOTATION, /* Annotation */
};

/**
* Sentinel code points that `rbs_next_char` reports for a valid non-ASCII
* character, picked by the encoding's `isupper_char`. The lexer never sees the
* real code point of a non-ASCII character, only which of these two classes it
* belongs to, which is all the grammar needs in order to tell a constant from a
* local identifier.
*
* The values are arbitrary; they are two characters of "ルビー" (Ruby) that are
* themselves non-ASCII, so they can never collide with a real ASCII code point.
* `src/lexer.re` refers to them as the `ル` / `ビ` character classes and
* must be kept in sync with these definitions.
*/
#define RBS_MB_UPPER_CODE_POINT 0x30EB /* ル */
#define RBS_MB_OTHER_CODE_POINT 0x30D3 /* ビ */

/**
* The `byte_pos` (or `char_pos`) is the primary data.
* The rest are cache.
Expand Down
16 changes: 14 additions & 2 deletions lib/rbs/type_name.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,21 @@ def initialize(namespace:, name:)
:alias
when name.start_with?("_")
:interface
else
# Defaults to :class
# `[[:upper:]]` resolves against the name's own encoding, which is
# what Ruby's own constant detection does. `\p{Uppercase}` cannot
# be used here: it pins the regexp to UTF-8, so matching it
# against a signature read as ISO-8859-1 raises.
when name.match?(/\A[[:upper:]]/)
:class
# Ruby starts a constant on a titlecase letter (`Dž`) as well, and
# `[[:upper:]]` does not cover the titlecase category. `\p{Lt}`
# does, at the cost of pinning the regexp to UTF-8 — hence the
# encoding guard. Only Unicode encodings have titlecase letters,
# so skipping the test for the others loses nothing.
when name.encoding == Encoding::UTF_8 && name.match?(/\A\p{Lt}/)
:class
else
:alias
end
end

Expand Down
84 changes: 80 additions & 4 deletions rust/ruby-rbs/src/type_name.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,29 @@ pub enum Kind {
Interface,
}

/// True for the titlecase (`Lt`) general category.
///
/// Ruby starts a constant on a titlecase letter as well as an uppercase one,
/// but `char::is_uppercase` tests the `Uppercase` property, which excludes
/// `Lt`, and `std` exposes no way to query a general category. Unicode has
/// exactly these 31 titlecase code points and has not added one since 3.0, so
/// they are listed here rather than pulled in as a dependency.
const fn is_titlecase(c: char) -> bool {
matches!(
c,
'\u{01C5}'
| '\u{01C8}'
| '\u{01CB}'
| '\u{01F2}'
| '\u{1F88}'..='\u{1F8F}'
| '\u{1F98}'..='\u{1F9F}'
| '\u{1FA8}'..='\u{1FAF}'
| '\u{1FBC}'
| '\u{1FCC}'
| '\u{1FFC}'
)
}

/// An entry rooted in the absolute namespace (`::`).
#[derive(Copy, Clone, Debug)]
struct AbsoluteTypeNameEntry {
Expand Down Expand Up @@ -303,14 +326,34 @@ impl TypeNameInterner {
}

/// Kind of the trailing segment. `None` for roots.
///
/// Follows Ruby's own constant detection: a leading `_` is an interface,
/// a leading uppercase or titlecase code point is a class, everything
/// else is an alias. Ruby's test is `Uppercase` plus the titlecase
/// category, which is why [`is_titlecase`] appears alongside
/// `char::is_uppercase` here.
///
/// The ASCII path — hit by virtually every identifier — is a single
/// byte comparison. Non-ASCII names pay one UTF-8 code point decode
/// plus a table lookup.
#[must_use]
pub fn kind(&self, name: TypeName, strings: &StringInterner) -> Option<Kind> {
let seg = self.last_segment(name)?;
let bytes = strings.resolve(seg).as_bytes();
let first = *bytes.first()?;
Some(if first == b'_' {
let s = strings.resolve(seg);
let first_byte = *s.as_bytes().first()?;
Some(if first_byte == b'_' {
Kind::Interface
} else if first.is_ascii_uppercase() {
} else if first_byte < 0x80 {
if first_byte.is_ascii_uppercase() {
Kind::Class
} else {
Kind::Alias
}
} else if s
.chars()
.next()
.is_some_and(|c| c.is_uppercase() || is_titlecase(c))
{
Kind::Class
} else {
Kind::Alias
Expand Down Expand Up @@ -610,6 +653,39 @@ mod tests {
assert_eq!(t.kind(root, &s), None);
}

#[test]
fn kind_uses_unicode_uppercase_property() {
let (mut s, mut t) = setup();

// Non-ASCII uppercase code points (Lu / Other_Uppercase) count as
// constants, matching Ruby's `rb_sym_constant_char_p`.
let ultima = t.parse(&mut s, "Última");
let omega = t.parse(&mut s, "Ωmega");
let n_tilde = t.parse(&mut s, "Ñoño");
assert_eq!(t.kind(ultima, &s), Some(Kind::Class));
assert_eq!(t.kind(omega, &s), Some(Kind::Class));
assert_eq!(t.kind(n_tilde, &s), Some(Kind::Class));

// Titlecase (Lt) starts a constant in Ruby even though it is not in
// the `Uppercase` property, so `char::is_uppercase` alone misses it.
let dz = t.parse(&mut s, "Džfoo");
let greek_iota = t.parse(&mut s, "ᾼfoo");
assert_eq!(t.kind(dz, &s), Some(Kind::Class));
assert_eq!(t.kind(greek_iota, &s), Some(Kind::Class));

// Non-ASCII lowercase (Ll) and Other_Letter (kanji etc.) are local
// identifiers in Ruby, so they belong to `Alias` here.
let alpha = t.parse(&mut s, "αlpha");
let kanji = t.parse(&mut s, "日本語");
assert_eq!(t.kind(alpha, &s), Some(Kind::Alias));
assert_eq!(t.kind(kanji, &s), Some(Kind::Alias));

// A leading underscore keeps interface semantics regardless of the
// rest of the name.
let iface = t.parse(&mut s, "_Únicos");
assert_eq!(t.kind(iface, &s), Some(Kind::Interface));
}

#[test]
fn to_absolute_to_relative() {
let (mut s, mut t) = setup();
Expand Down
Loading
Loading