From bf375b5a580fa6b3b1a3edc621a87ecad25e4611 Mon Sep 17 00:00:00 2001 From: Soutaro Matsumoto Date: Tue, 28 Jul 2026 17:18:22 +0900 Subject: [PATCH 1/3] Give owned `KeywordParam` a source location `KeywordParam` carried only a name and a `FunctionParam`, so the owned AST could not answer where a keyword parameter or its key is in the source. A consumer wanting to highlight `name:` in `(name: String) -> void` had to keep a side table of keyword locations next to the AST. The C AST has no single node for a keyword parameter -- it is a hash entry pairing a key symbol with a function param node -- so `KeywordParamLocation` spans the two: `range` runs from the start of the key to the end of the param, and `name_range` covers the key. Co-Authored-By: Claude Opus 5 (1M context) --- rust/ruby-rbs/src/ast/convert.rs | 33 +++++++++++++++++--- rust/ruby-rbs/src/ast/location.rs | 18 +++++++++++ rust/ruby-rbs/src/ast/mod.rs | 50 +++++++++++++++++++++++++++---- rust/ruby-rbs/src/ast/types.rs | 3 +- 4 files changed, 93 insertions(+), 11 deletions(-) diff --git a/rust/ruby-rbs/src/ast/convert.rs b/rust/ruby-rbs/src/ast/convert.rs index 628f0720b..f21f37553 100644 --- a/rust/ruby-rbs/src/ast/convert.rs +++ b/rust/ruby-rbs/src/ast/convert.rs @@ -12,10 +12,10 @@ use crate::ast::location::{ AliasDeclarationLocation, AliasLocation, AliasMemberLocation, AttributeMemberLocation, ClassDeclarationLocation, ClassInstanceLocation, ClassSingletonLocation, ClassSuperLocation, ConstantDeclarationLocation, FunctionParamLocation, GlobalDeclarationLocation, - InterfaceDeclarationLocation, InterfaceLocation, LocationRange, MethodDefinitionLocation, - MethodTypeLocation, MixinMemberLocation, ModuleDeclarationLocation, ModuleSelfLocation, - TypeAliasDeclarationLocation, TypeParamLocation, UseDirectiveLocation, UseSingleClauseLocation, - UseWildcardClauseLocation, VariableMemberLocation, + InterfaceDeclarationLocation, InterfaceLocation, KeywordParamLocation, LocationRange, + MethodDefinitionLocation, MethodTypeLocation, MixinMemberLocation, ModuleDeclarationLocation, + ModuleSelfLocation, TypeAliasDeclarationLocation, TypeParamLocation, UseDirectiveLocation, + UseSingleClauseLocation, UseWildcardClauseLocation, VariableMemberLocation, }; use crate::ast::members::{ AliasKind, AliasMember, AttrAccessorMember, AttrReaderMember, AttrWriterMember, AttributeKind, @@ -929,6 +929,7 @@ impl<'a> AstConverter<'a> { KeywordParam { name: self.intern_symbol(&symbol), param: self.convert_function_param_node(&value), + location: Some(keyword_param_location(symbol.location(), value.location())), } }) .collect() @@ -1068,6 +1069,30 @@ fn convert_optional_range(range: Option) -> Option KeywordParamLocation { + let name_range = convert_range(name_range); + + KeywordParamLocation { + range: span_range(name_range, convert_range(param_range)), + name_range, + } +} + +/// Returns the range starting where `start` starts and ending where `end` ends. +fn span_range(start: LocationRange, end: LocationRange) -> LocationRange { + LocationRange { + start_char: start.start_char, + start_byte: start.start_byte, + end_char: end.end_char, + end_byte: end.end_byte, + } +} + fn variable_member_location( range: RBSLocationRange, name_range: RBSLocationRange, diff --git a/rust/ruby-rbs/src/ast/location.rs b/rust/ruby-rbs/src/ast/location.rs index 9b9cecc72..6818c806d 100644 --- a/rust/ruby-rbs/src/ast/location.rs +++ b/rust/ruby-rbs/src/ast/location.rs @@ -70,6 +70,24 @@ pub struct FunctionParamLocation { pub name_range: Option, } +/// ```rbs +/// (name: String) -> void +/// ^^^^^^^^^^^^ range +/// ^^^^ name +/// +/// (?size: Integer bytes) -> void +/// ^^^^^^^^^^^^^^^^^^^ range +/// ^^^^ name +/// ``` +/// +/// The `?` marker of an optional keyword is not part of `range`: it belongs to +/// the enclosing function type, not to the keyword parameter itself. +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct KeywordParamLocation { + pub range: LocationRange, + pub name_range: LocationRange, +} + /// ```rbs /// _Foo /// ^^^^ name diff --git a/rust/ruby-rbs/src/ast/mod.rs b/rust/ruby-rbs/src/ast/mod.rs index 99e341184..cdc165a35 100644 --- a/rust/ruby-rbs/src/ast/mod.rs +++ b/rust/ruby-rbs/src/ast/mod.rs @@ -34,10 +34,10 @@ pub use location::{ AliasDeclarationLocation, AliasLocation, AliasMemberLocation, AttributeMemberLocation, ClassDeclarationLocation, ClassInstanceLocation, ClassSingletonLocation, ClassSuperLocation, ConstantDeclarationLocation, FunctionParamLocation, GlobalDeclarationLocation, - InterfaceDeclarationLocation, InterfaceLocation, LocationRange, MethodDefinitionLocation, - MethodTypeLocation, MixinMemberLocation, ModuleDeclarationLocation, ModuleSelfLocation, - ResolveTypeNamesDirectiveLocation, TypeAliasDeclarationLocation, TypeParamLocation, - UseDirectiveLocation, UseSingleClauseLocation, UseWildcardClauseLocation, + InterfaceDeclarationLocation, InterfaceLocation, KeywordParamLocation, LocationRange, + MethodDefinitionLocation, MethodTypeLocation, MixinMemberLocation, ModuleDeclarationLocation, + ModuleSelfLocation, ResolveTypeNamesDirectiveLocation, TypeAliasDeclarationLocation, + TypeParamLocation, UseDirectiveLocation, UseSingleClauseLocation, UseWildcardClauseLocation, VariableMemberLocation, }; pub use members::{ @@ -58,8 +58,9 @@ pub use types::{ #[cfg(test)] mod tests { use crate::ast::{ - AstConverter, BaseType, BaseTypeKind, ClassMember, Declaration, Directive, IvarName, - Literal, Member, MethodKind, ModuleMember, RecordKey, Type, UseClause, + AstConverter, BaseType, BaseTypeKind, ClassMember, Declaration, Directive, Function, + IvarName, Literal, LocationRange, Member, MethodKind, ModuleMember, RecordKey, Type, + UseClause, }; use crate::interner::StringInterner; use crate::node::{Node, parse}; @@ -324,4 +325,41 @@ mod tests { assert_eq!(type_names.display(wildcard.namespace, &strings), "Foo::Baz"); assert!(wildcard.location.is_some()); } + + #[test] + fn converts_keyword_param_locations() { + let source = "class Foo\n def bar: (name: String, ?size: Integer bytes) -> void\nend\n"; + let signature = parse(source).unwrap(); + + let mut strings = StringInterner::new(); + let mut type_names = TypeNameInterner::new(); + let mut converter = AstConverter::new(&mut strings, &mut type_names); + let declaration = + converter.convert_declaration(&signature.declarations().iter().next().unwrap()); + + let Declaration::Class(class_decl) = &declaration else { + panic!("expected class declaration"); + }; + let ClassMember::Member(Member::MethodDefinition(method)) = &class_decl.members[0] else { + panic!("expected method definition member"); + }; + let Function::Typed(function) = &method.overloads[0].method_type.function else { + panic!("expected typed function"); + }; + + let text = + |range: &LocationRange| &source[range.start_byte as usize..range.end_byte as usize]; + + let required = &function.required_keywords[0]; + let required_location = required.location.as_ref().unwrap(); + assert_eq!(required.name, strings.intern("name")); + assert_eq!(text(&required_location.range), "name: String"); + assert_eq!(text(&required_location.name_range), "name"); + + let optional = &function.optional_keywords[0]; + let optional_location = optional.location.as_ref().unwrap(); + assert_eq!(optional.name, strings.intern("size")); + assert_eq!(text(&optional_location.range), "size: Integer bytes"); + assert_eq!(text(&optional_location.name_range), "size"); + } } diff --git a/rust/ruby-rbs/src/ast/types.rs b/rust/ruby-rbs/src/ast/types.rs index 3d1262dfd..4c75f37f0 100644 --- a/rust/ruby-rbs/src/ast/types.rs +++ b/rust/ruby-rbs/src/ast/types.rs @@ -1,6 +1,6 @@ use crate::ast::location::{ AliasLocation, ClassInstanceLocation, ClassSingletonLocation, FunctionParamLocation, - InterfaceLocation, LocationRange, + InterfaceLocation, KeywordParamLocation, LocationRange, }; use crate::ids::{SymbolId, TypeName}; @@ -136,6 +136,7 @@ pub struct FunctionType { pub struct KeywordParam { pub name: SymbolId, pub param: FunctionParam, + pub location: Option, } #[derive(Clone, Debug, Eq, PartialEq, Hash)] From f2c3d36a4aa193580b7438223a89782f498c3c51 Mon Sep 17 00:00:00 2001 From: Soutaro Matsumoto Date: Tue, 28 Jul 2026 17:19:47 +0900 Subject: [PATCH 2/3] Give owned `RecordField` a source location `RecordField` had no location at all, so `{ name: String }` in the owned AST could not say where the field or its key sits in the source. As with keyword parameters, the C AST spreads a record field over a key node and a field type node, so `RecordFieldLocation` spans the two: `range` runs from the start of the key to the end of the field, and `key_range` covers the key. Co-Authored-By: Claude Opus 5 (1M context) --- rust/ruby-rbs/src/ast/convert.rs | 21 +++++++++++++++-- rust/ruby-rbs/src/ast/location.rs | 16 +++++++++++++ rust/ruby-rbs/src/ast/mod.rs | 39 ++++++++++++++++++++++++++++--- rust/ruby-rbs/src/ast/types.rs | 3 ++- 4 files changed, 73 insertions(+), 6 deletions(-) diff --git a/rust/ruby-rbs/src/ast/convert.rs b/rust/ruby-rbs/src/ast/convert.rs index f21f37553..e3af01963 100644 --- a/rust/ruby-rbs/src/ast/convert.rs +++ b/rust/ruby-rbs/src/ast/convert.rs @@ -14,8 +14,9 @@ use crate::ast::location::{ ConstantDeclarationLocation, FunctionParamLocation, GlobalDeclarationLocation, InterfaceDeclarationLocation, InterfaceLocation, KeywordParamLocation, LocationRange, MethodDefinitionLocation, MethodTypeLocation, MixinMemberLocation, ModuleDeclarationLocation, - ModuleSelfLocation, TypeAliasDeclarationLocation, TypeParamLocation, UseDirectiveLocation, - UseSingleClauseLocation, UseWildcardClauseLocation, VariableMemberLocation, + ModuleSelfLocation, RecordFieldLocation, TypeAliasDeclarationLocation, TypeParamLocation, + UseDirectiveLocation, UseSingleClauseLocation, UseWildcardClauseLocation, + VariableMemberLocation, }; use crate::ast::members::{ AliasKind, AliasMember, AttrAccessorMember, AttrReaderMember, AttrWriterMember, AttributeKind, @@ -163,6 +164,7 @@ impl<'a> AstConverter<'a> { Node::RecordType(node) => { let mut fields = Vec::new(); for (key, value) in node.all_fields().iter() { + let key_range = key.location(); let key = self.convert_record_key(&key); let Node::RecordFieldType(field) = value else { panic_expected("record field value while converting record type", &value); @@ -171,6 +173,7 @@ impl<'a> AstConverter<'a> { key, ty: self.convert_type(&field.type_()), required: field.required(), + location: Some(record_field_location(key_range, field.location())), }); } Type::Record(RecordType { @@ -1083,6 +1086,20 @@ fn keyword_param_location( } } +/// Builds the location of a record field, which the C AST keeps as a pair of a +/// key node and a field type node instead of a single node. +fn record_field_location( + key_range: RBSLocationRange, + field_range: RBSLocationRange, +) -> RecordFieldLocation { + let key_range = convert_range(key_range); + + RecordFieldLocation { + range: span_range(key_range, convert_range(field_range)), + key_range, + } +} + /// Returns the range starting where `start` starts and ending where `end` ends. fn span_range(start: LocationRange, end: LocationRange) -> LocationRange { LocationRange { diff --git a/rust/ruby-rbs/src/ast/location.rs b/rust/ruby-rbs/src/ast/location.rs index 6818c806d..da8e082bd 100644 --- a/rust/ruby-rbs/src/ast/location.rs +++ b/rust/ruby-rbs/src/ast/location.rs @@ -88,6 +88,22 @@ pub struct KeywordParamLocation { pub name_range: LocationRange, } +/// ```rbs +/// { name: String, "id" => Integer } +/// ^^^^^^^^^^^^ range +/// ^^^^ key +/// ^^^^^^^^^^^^^^^ range +/// ^^^^ key +/// ``` +/// +/// The `?` marker of an optional field is not part of `range`: it belongs to +/// the enclosing record type, not to the field itself. +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct RecordFieldLocation { + pub range: LocationRange, + pub key_range: LocationRange, +} + /// ```rbs /// _Foo /// ^^^^ name diff --git a/rust/ruby-rbs/src/ast/mod.rs b/rust/ruby-rbs/src/ast/mod.rs index cdc165a35..442413eb7 100644 --- a/rust/ruby-rbs/src/ast/mod.rs +++ b/rust/ruby-rbs/src/ast/mod.rs @@ -36,9 +36,9 @@ pub use location::{ ConstantDeclarationLocation, FunctionParamLocation, GlobalDeclarationLocation, InterfaceDeclarationLocation, InterfaceLocation, KeywordParamLocation, LocationRange, MethodDefinitionLocation, MethodTypeLocation, MixinMemberLocation, ModuleDeclarationLocation, - ModuleSelfLocation, ResolveTypeNamesDirectiveLocation, TypeAliasDeclarationLocation, - TypeParamLocation, UseDirectiveLocation, UseSingleClauseLocation, UseWildcardClauseLocation, - VariableMemberLocation, + ModuleSelfLocation, RecordFieldLocation, ResolveTypeNamesDirectiveLocation, + TypeAliasDeclarationLocation, TypeParamLocation, UseDirectiveLocation, UseSingleClauseLocation, + UseWildcardClauseLocation, VariableMemberLocation, }; pub use members::{ AliasKind, AliasMember, AttrAccessorMember, AttrReaderMember, AttrWriterMember, AttributeKind, @@ -362,4 +362,37 @@ mod tests { assert_eq!(text(&optional_location.range), "size: Integer bytes"); assert_eq!(text(&optional_location.name_range), "size"); } + + #[test] + fn converts_record_field_locations() { + let source = "type t = { name: String, ?age: Integer, \"id\" => Integer }\n"; + let signature = parse(source).unwrap(); + + let mut strings = StringInterner::new(); + let mut type_names = TypeNameInterner::new(); + let mut converter = AstConverter::new(&mut strings, &mut type_names); + let declaration = + converter.convert_declaration(&signature.declarations().iter().next().unwrap()); + + let Declaration::TypeAlias(alias) = &declaration else { + panic!("expected type alias declaration"); + }; + let Type::Record(record) = &alias.ty else { + panic!("expected record type"); + }; + + let text = + |range: &LocationRange| &source[range.start_byte as usize..range.end_byte as usize]; + let location = |index: usize| record.fields[index].location.as_ref().unwrap(); + + assert_eq!(text(&location(0).range), "name: String"); + assert_eq!(text(&location(0).key_range), "name"); + + // The `?` marker belongs to the record type, not to the field. + assert_eq!(text(&location(1).range), "age: Integer"); + assert_eq!(text(&location(1).key_range), "age"); + + assert_eq!(text(&location(2).range), "\"id\" => Integer"); + assert_eq!(text(&location(2).key_range), "\"id\""); + } } diff --git a/rust/ruby-rbs/src/ast/types.rs b/rust/ruby-rbs/src/ast/types.rs index 4c75f37f0..d9d056f82 100644 --- a/rust/ruby-rbs/src/ast/types.rs +++ b/rust/ruby-rbs/src/ast/types.rs @@ -1,6 +1,6 @@ use crate::ast::location::{ AliasLocation, ClassInstanceLocation, ClassSingletonLocation, FunctionParamLocation, - InterfaceLocation, KeywordParamLocation, LocationRange, + InterfaceLocation, KeywordParamLocation, LocationRange, RecordFieldLocation, }; use crate::ids::{SymbolId, TypeName}; @@ -99,6 +99,7 @@ pub struct RecordField { pub key: RecordKey, pub ty: Type, pub required: bool, + pub location: Option, } #[derive(Clone, Debug, Eq, PartialEq, Hash)] From 66496fa5cba0c132196337e97d07842d1ae31394 Mon Sep 17 00:00:00 2001 From: Soutaro Matsumoto Date: Tue, 28 Jul 2026 17:21:01 +0900 Subject: [PATCH 3/3] Give owned `MethodDefinitionOverload` a source location `MethodDefinitionOverload` had no location, so a method definition with several overloads could not say which source range each overload occupies without reaching into its `MethodType`. The C AST does have an overload node, so this is its range verbatim. Note that the range starts at the `:` or `|` separator preceding the overload, which is worth knowing before slicing the source with it. Co-Authored-By: Claude Opus 5 (1M context) --- rust/ruby-rbs/src/ast/convert.rs | 1 + rust/ruby-rbs/src/ast/members.rs | 7 +++++-- rust/ruby-rbs/src/ast/mod.rs | 27 +++++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/rust/ruby-rbs/src/ast/convert.rs b/rust/ruby-rbs/src/ast/convert.rs index e3af01963..1941937a4 100644 --- a/rust/ruby-rbs/src/ast/convert.rs +++ b/rust/ruby-rbs/src/ast/convert.rs @@ -469,6 +469,7 @@ impl<'a> AstConverter<'a> { MethodDefinitionOverload { method_type: self.convert_method_type_node(&node.method_type()), annotations: self.convert_annotations(node.annotations()), + location: Some(convert_range(node.location())), } } diff --git a/rust/ruby-rbs/src/ast/members.rs b/rust/ruby-rbs/src/ast/members.rs index bbe4e3111..c18d2cc2c 100644 --- a/rust/ruby-rbs/src/ast/members.rs +++ b/rust/ruby-rbs/src/ast/members.rs @@ -1,8 +1,8 @@ use crate::ast::annotation::Annotation; use crate::ast::comment::Comment; use crate::ast::location::{ - AliasMemberLocation, AttributeMemberLocation, MethodDefinitionLocation, MixinMemberLocation, - VariableMemberLocation, + AliasMemberLocation, AttributeMemberLocation, LocationRange, MethodDefinitionLocation, + MixinMemberLocation, VariableMemberLocation, }; use crate::ast::method_type::MethodType; use crate::ast::types::Type; @@ -54,6 +54,9 @@ pub struct MethodDefinitionMember { pub struct MethodDefinitionOverload { pub method_type: MethodType, pub annotations: Vec, + /// Starts at the `:` or `|` separator preceding the overload, so that the + /// overloads of a method definition tile its whole type without gaps. + pub location: Option, } #[derive(Clone, Debug, Eq, PartialEq, Hash)] diff --git a/rust/ruby-rbs/src/ast/mod.rs b/rust/ruby-rbs/src/ast/mod.rs index 442413eb7..0b07cdf4f 100644 --- a/rust/ruby-rbs/src/ast/mod.rs +++ b/rust/ruby-rbs/src/ast/mod.rs @@ -395,4 +395,31 @@ mod tests { assert_eq!(text(&location(2).range), "\"id\" => Integer"); assert_eq!(text(&location(2).key_range), "\"id\""); } + + #[test] + fn converts_method_definition_overload_locations() { + let source = "class Foo\n def bar: () -> void\n | (Integer) -> String\nend\n"; + let signature = parse(source).unwrap(); + + let mut strings = StringInterner::new(); + let mut type_names = TypeNameInterner::new(); + let mut converter = AstConverter::new(&mut strings, &mut type_names); + let declaration = + converter.convert_declaration(&signature.declarations().iter().next().unwrap()); + + let Declaration::Class(class_decl) = &declaration else { + panic!("expected class declaration"); + }; + let ClassMember::Member(Member::MethodDefinition(method)) = &class_decl.members[0] else { + panic!("expected method definition member"); + }; + + let text = + |range: &LocationRange| &source[range.start_byte as usize..range.end_byte as usize]; + let location = |index: usize| method.overloads[index].location.as_ref().unwrap(); + + // The leading `:`/`|` separator is part of the overload range. + assert_eq!(text(location(0)), ": () -> void"); + assert_eq!(text(location(1)), "| (Integer) -> String"); + } }