Skip to content

Commit fec1ea9

Browse files
Haofeiclaude
andcommitted
rsscript: #lower_name("...") backend-name escape hatch
Add a function attribute that pins a declaration's generated Rust symbol, for FFI/autogen boundaries and compiler transitions. Follows the existing `#deprecated("...")` attribute convention. - Parser: extend the function attribute loop to accept `#lower_name("...")`; store it on `FunctionDecl.lower_name`. - Lowering: a per-run thread-local override map (populated from the program's pins) is consulted by the canonical name-lowering helpers (`rust_function_ident`, `rust_qualified_function_ident`, and `lower_callee`'s free-call path), so the pinned symbol is used at the definition and every call site. - Inventory: `symbol_inventory` installs the same overrides so `lowered_name` reports the pinned symbol. - Checker (RS0035): a pin must be a valid Rust identifier and must not collide with another function's final backend name. - Formatter round-trips the attribute. Tests: symbol-inventory pin test, definition+call-site lowering test, pass fixture, two fail fixtures (conflict + invalid), formatter round-trip. Full `cargo test -p rsscript` green; clippy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9f9b929 commit fec1ea9

14 files changed

Lines changed: 278 additions & 9 deletions

File tree

crates/rsscript/src/analyzer.rs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1039,6 +1039,7 @@ impl Analyzer<'_> {
10391039
self.check_async_fn_lowerable();
10401040
self.check_match_exhaustiveness();
10411041
self.check_duplicate_declarations();
1042+
self.check_lowered_name_conflicts();
10421043
self.check_protocol_contracts();
10431044
self.check_signature_explicitness();
10441045
self.check_unknown_types();
@@ -3737,6 +3738,72 @@ impl Analyzer<'_> {
37373738
}
37383739
}
37393740

3741+
/// Validate `#lower_name("...")` pins: each must be a valid Rust identifier,
3742+
/// and no pin may collide with another function's final backend name (pinned
3743+
/// or default), so generated symbols stay unique. Only conflicts that involve
3744+
/// at least one pin are reported here (plain default collisions, if any, are a
3745+
/// separate concern from this escape hatch).
3746+
fn check_lowered_name_conflicts(&mut self) {
3747+
use crate::rust_lower::lowered_symbol_name;
3748+
let mut seen: HashMap<String, (String, bool)> = HashMap::new();
3749+
let mut conflicts: Vec<(crate::diagnostic::Span, String, String)> = Vec::new();
3750+
let mut invalid: Vec<(crate::diagnostic::Span, String)> = Vec::new();
3751+
for item in &self.syntax_program.items {
3752+
let crate::syntax::ast::Item::Function(function) = item else {
3753+
continue;
3754+
};
3755+
let pinned = function.lower_name.as_deref();
3756+
if let Some(pin) = pinned
3757+
&& !is_valid_rust_identifier(pin)
3758+
{
3759+
invalid.push((function.span.clone(), pin.to_string()));
3760+
continue;
3761+
}
3762+
let lowered = pinned
3763+
.map(str::to_string)
3764+
.unwrap_or_else(|| lowered_symbol_name(&function.name));
3765+
let is_pinned = pinned.is_some();
3766+
if let Some((first_name, first_pinned)) = seen.get(&lowered) {
3767+
if is_pinned || *first_pinned {
3768+
conflicts.push((function.span.clone(), lowered.clone(), first_name.clone()));
3769+
}
3770+
} else {
3771+
seen.insert(lowered, (function.name.clone(), is_pinned));
3772+
}
3773+
}
3774+
for (span, pin) in invalid {
3775+
self.diagnostics.push(
3776+
Diagnostic::error(
3777+
code::LOWER_NAME_CONFLICT,
3778+
format!("`#lower_name(\"{pin}\")` is not a valid Rust identifier."),
3779+
span,
3780+
"invalid lowered name",
3781+
)
3782+
.with_cause(
3783+
"A pinned backend name must be a plain Rust identifier (letters, digits, and underscores, not starting with a digit).",
3784+
),
3785+
);
3786+
}
3787+
for (span, lowered, first_name) in conflicts {
3788+
self.diagnostics.push(
3789+
Diagnostic::error(
3790+
code::LOWER_NAME_CONFLICT,
3791+
format!("lowered backend name `{lowered}` is already used by `{first_name}`."),
3792+
span,
3793+
"lowered name conflict",
3794+
)
3795+
.with_cause(
3796+
"Two declarations would lower to the same Rust symbol; a `#lower_name(\"...\")` pin must be unique.",
3797+
)
3798+
.with_fix(
3799+
"rename_lowered",
3800+
"Choose a different `#lower_name(\"...\")` value.",
3801+
"manual",
3802+
),
3803+
);
3804+
}
3805+
}
3806+
37403807
fn check_duplicate_declarations(&mut self) {
37413808
for duplicate in self.hir.duplicate_symbols() {
37423809
self.diagnostics.push(
@@ -6253,6 +6320,19 @@ fn type_ref_name(ty: &TypeRef) -> String {
62536320
}
62546321
}
62556322

6323+
/// Whether `name` is a plain Rust identifier (used verbatim as a pinned backend
6324+
/// symbol): non-empty, starts with a letter or `_`, and otherwise alphanumeric or
6325+
/// `_`. Raw identifiers and keywords are intentionally rejected.
6326+
fn is_valid_rust_identifier(name: &str) -> bool {
6327+
let mut chars = name.chars();
6328+
let Some(first) = chars.next() else {
6329+
return false;
6330+
};
6331+
(first.is_ascii_alphabetic() || first == '_')
6332+
&& chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
6333+
&& !crate::rust_lower::is_rust_keyword(name)
6334+
}
6335+
62566336
fn type_ref_is_noescape(ty: &TypeRef) -> bool {
62576337
ty.is_noescape || ty.args.iter().any(type_ref_is_noescape)
62586338
}

crates/rsscript/src/diagnostic.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ pub mod code {
3838
pub const PROTOCOL_NOT_SATISFIED: &str = "RS0032";
3939
pub const INTEGER_LITERAL_OUT_OF_RANGE: &str = "RS0033";
4040
pub const UNINFERABLE_BINDING_TYPE: &str = "RS0034";
41+
pub const LOWER_NAME_CONFLICT: &str = "RS0035";
4142
pub const FEATURE_VIOLATION: &str = "RS0101";
4243
pub const UNNAMED_ARGUMENT: &str = "RS0201";
4344
pub const MISSING_DATA_EFFECT: &str = "RS0202";
@@ -445,6 +446,11 @@ static DIAGNOSTIC_EXPLANATIONS: &[DiagnosticExplanation] = &[
445446
title: "invalid noalloc call",
446447
explanation: "`effects(noalloc)` is a guarantee that the function performs no heap allocation. Calls inside it must target enum variants or functions also declared `effects(noalloc)`, while direct constructors and `manage` are allocation diagnostics.",
447448
},
449+
DiagnosticExplanation {
450+
code: code::LOWER_NAME_CONFLICT,
451+
title: "lowered name conflict",
452+
explanation: "A `#lower_name(\"...\")` pin must be a valid Rust identifier and must not collide with any other declaration's lowered backend name, so generated symbols stay unique.",
453+
},
448454
DiagnosticExplanation {
449455
code: code::NON_EXHAUSTIVE_MATCH,
450456
title: "non-exhaustive match",

crates/rsscript/src/formatter.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,9 @@ impl Formatter {
254254
}
255255

256256
fn function_decl(&mut self, function: &FunctionDecl) {
257+
if let Some(lower_name) = &function.lower_name {
258+
self.out.push_str(&format!("#lower_name({})\n", quoted_string(lower_name)));
259+
}
257260
let mut prefix = String::new();
258261
if function.is_public {
259262
prefix.push_str("pub ");
@@ -1892,6 +1895,18 @@ impl Writer for BufferWriter {
18921895
);
18931896
}
18941897

1898+
#[test]
1899+
fn round_trips_lower_name_attribute() {
1900+
let source = "#lower_name(\"helpers__count\")\nfn count(value: read Int) -> Int {\n return value\n}\n";
1901+
// The pin must survive formatting (idempotent + lossless).
1902+
let formatted = format_source("pin.rss", source);
1903+
assert!(
1904+
formatted.contains("#lower_name(\"helpers__count\")"),
1905+
"formatter dropped the lower_name pin:\n{formatted}"
1906+
);
1907+
assert_eq!(format_source("pin.rss", &formatted), formatted);
1908+
}
1909+
18951910
#[test]
18961911
fn renders_list_slice_patterns() {
18971912
let source = r#"features: local

crates/rsscript/src/rust_lower/helpers.rs

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1260,7 +1260,10 @@ pub(super) fn lower_callee(callee: &Callee) -> String {
12601260
}
12611261

12621262
match callee {
1263-
Callee::Name(name) => rust_ident(type_root_name(name)),
1263+
Callee::Name(name) => {
1264+
let canonical = type_root_name(name);
1265+
lower_name_override(canonical).unwrap_or_else(|| rust_ident(canonical))
1266+
}
12641267
Callee::Qualified { namespace, name } if type_root_name(namespace) == "ResourcePool" => {
12651268
format!(
12661269
"rsscript_runtime::ResourcePool::{}",
@@ -1613,12 +1616,41 @@ pub(super) fn lower_source_span(span: &Span) -> String {
16131616
)
16141617
}
16151618

1619+
thread_local! {
1620+
/// Per-lowering-run map from a function's source qualified name (e.g.
1621+
/// `helpers.count`) to its pinned backend name from `#lower_name("...")`.
1622+
/// Populated before a lowering run (and before building the symbol inventory)
1623+
/// and consulted by the canonical name-lowering helpers so the emitted Rust
1624+
/// symbol and the reported `lowered_name` always agree.
1625+
static LOWER_NAME_OVERRIDES: std::cell::RefCell<std::collections::HashMap<String, String>> =
1626+
std::cell::RefCell::new(std::collections::HashMap::new());
1627+
}
1628+
1629+
/// Install the pinned-name overrides for the current thread; pass an empty map to
1630+
/// disable. Returns the previous map so callers can restore it.
1631+
pub(crate) fn set_lower_name_overrides(
1632+
overrides: std::collections::HashMap<String, String>,
1633+
) -> std::collections::HashMap<String, String> {
1634+
LOWER_NAME_OVERRIDES.with(|cell| cell.replace(overrides))
1635+
}
1636+
1637+
fn lower_name_override(source_name: &str) -> Option<String> {
1638+
LOWER_NAME_OVERRIDES.with(|cell| cell.borrow().get(source_name).cloned())
1639+
}
1640+
16161641
pub(super) fn rust_function_ident(name: &str) -> String {
1642+
if let Some(pinned) = lower_name_override(name) {
1643+
return pinned;
1644+
}
16171645
let joined = name.split('.').collect::<Vec<_>>().join("_");
16181646
rust_ident(&joined)
16191647
}
16201648

16211649
pub(super) fn rust_qualified_function_ident(namespace: &str, name: &str) -> String {
1650+
let source_name = format!("{namespace}.{}", type_root_name(name));
1651+
if let Some(pinned) = lower_name_override(&source_name) {
1652+
return pinned;
1653+
}
16221654
namespace
16231655
.split('.')
16241656
.chain(std::iter::once(type_root_name(name)))
@@ -1637,7 +1669,7 @@ pub(super) fn rust_path_segment(segment: &str) -> String {
16371669

16381670
/// Whether `name` is a Rust keyword (strict or reserved) that cannot be used as a
16391671
/// bare identifier.
1640-
pub(super) fn is_rust_keyword(name: &str) -> bool {
1672+
pub(crate) fn is_rust_keyword(name: &str) -> bool {
16411673
const KEYWORDS: &[&str] = &[
16421674
"abstract", "as", "async", "await", "become", "box", "break", "const", "continue", "crate",
16431675
"do", "dyn", "else", "enum", "extern", "false", "final", "fn", "for", "gen", "if", "impl",

crates/rsscript/src/rust_lower/lowerer.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,10 @@ impl<'a> RustLowerer<'a> {
128128
}
129129

130130
pub(super) fn lower(mut self) -> LoweredRust {
131+
// Install this program's `#lower_name(...)` pins for the duration of the
132+
// run so the canonical name-lowering helpers emit the pinned symbols.
133+
let previous_overrides =
134+
set_lower_name_overrides(super::collect_lower_name_overrides(self.program));
131135
let mut out = String::new();
132136
out.push_str("// Generated by RSScript. Edit the .rss source instead.\n");
133137
out.push_str(
@@ -194,6 +198,7 @@ impl<'a> RustLowerer<'a> {
194198
out.pop();
195199
}
196200

201+
set_lower_name_overrides(previous_overrides);
197202
LoweredRust {
198203
rust_source: out,
199204
source_map: self.source_map,

crates/rsscript/src/rust_lower/mod.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ mod runtime_diagnostics;
1818
mod source_map;
1919
mod types;
2020

21+
pub(crate) use helpers::{is_rust_keyword, set_lower_name_overrides};
2122
pub use backend_check::check_generated_rust_package;
2223
pub use runtime_diagnostics::parse_runtime_diagnostics;
2324
pub use source_map::{
@@ -41,6 +42,25 @@ pub fn lowered_symbol_name(qualified_name: &str) -> String {
4142
helpers::rust_function_ident(qualified_name)
4243
}
4344

45+
/// Collect the `#lower_name("...")` pins a program's functions declare, keyed by
46+
/// source qualified name. Both the lowerer and the symbol inventory install
47+
/// these so the emitted Rust symbol and the reported `lowered_name` agree.
48+
pub(crate) fn collect_lower_name_overrides(
49+
program: &Program,
50+
) -> std::collections::HashMap<String, String> {
51+
program
52+
.items
53+
.iter()
54+
.filter_map(|item| match item {
55+
crate::syntax::ast::Item::Function(function) => function
56+
.lower_name
57+
.as_ref()
58+
.map(|pinned| (function.name.clone(), pinned.clone())),
59+
_ => None,
60+
})
61+
.collect()
62+
}
63+
4464
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
4565
pub struct LowerCoverageReport {
4666
pub runtime_intrinsics: CoverageBucket,

crates/rsscript/src/symbols.rs

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,12 @@ pub fn symbol_inventory(file: &str, source: &str) -> Vec<SymbolInventoryEntry> {
120120
.and_then(|stem| stem.to_str())
121121
.unwrap_or(file)
122122
.to_string();
123+
// Install this file's `#lower_name(...)` pins so the reported `lowered_name`
124+
// matches the symbol the backend actually emits.
125+
let overrides = crate::rust_lower::collect_lower_name_overrides(&parse_source_raw(file, source));
126+
let previous = crate::rust_lower::set_lower_name_overrides(overrides);
123127
let index = symbol_index(file, source);
124-
index
128+
let entries = index
125129
.definitions()
126130
.iter()
127131
.filter(|definition| {
@@ -137,7 +141,9 @@ pub fn symbol_inventory(file: &str, source: &str) -> Vec<SymbolInventoryEntry> {
137141
span: definition.span.clone(),
138142
lowered_name: crate::rust_lower::lowered_symbol_name(&definition.name),
139143
})
140-
.collect()
144+
.collect();
145+
crate::rust_lower::set_lower_name_overrides(previous);
146+
entries
141147
}
142148

143149
/// Parse `source` and return a top-level document outline.
@@ -989,6 +995,23 @@ mod tests {
989995
assert_eq!(find("Device").kind, SymbolKind::Type);
990996
}
991997

998+
#[test]
999+
fn symbol_inventory_reflects_lower_name_pin() {
1000+
let source = concat!(
1001+
"#lower_name(\"helpers__count\")\n",
1002+
"fn count(value: read Int) -> Int {\n",
1003+
" return value\n",
1004+
"}\n",
1005+
);
1006+
let inventory = symbol_inventory("helpers.rss", source);
1007+
let count = inventory
1008+
.iter()
1009+
.find(|entry| entry.qualname == "count")
1010+
.expect("count in inventory");
1011+
// The pinned backend name overrides the default flattened symbol.
1012+
assert_eq!(count.lowered_name, "helpers__count");
1013+
}
1014+
9921015
const SOURCE: &str = concat!(
9931016
"fn helper(value: read Int) -> Int {\n",
9941017
" return value\n",

crates/rsscript/src/syntax/ast.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,11 @@ pub struct FunctionDecl {
272272
pub has_body: bool,
273273
pub default_impl_marker: bool,
274274
pub deprecated_reason: Option<String>,
275+
/// An explicit pinned backend name from `#lower_name("...")`. When set, the
276+
/// function lowers to exactly this Rust symbol (rather than the flattened
277+
/// default) and the symbol inventory reports it. Used for FFI/autogen
278+
/// boundaries and compiler transitions.
279+
pub lower_name: Option<String>,
275280
pub type_params: Vec<GenericParam>,
276281
pub malformed_generic_param_spans: Vec<Span>,
277282
pub params: Vec<Param>,

crates/rsscript/src/syntax/parser.rs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -461,23 +461,32 @@ impl Parser<'_> {
461461
let start = self.index;
462462
let span = self.current()?.span.clone();
463463
let mut deprecated_reason = None;
464+
let mut lower_name = None;
464465
while self.at_symbol("#") {
465466
self.index += 1;
466-
if !self.at_ident("deprecated") {
467+
let attribute = if self.at_ident("deprecated") {
468+
"deprecated"
469+
} else if self.at_ident("lower_name") {
470+
"lower_name"
471+
} else {
467472
self.index = start;
468473
return None;
469-
}
474+
};
470475
self.index += 1;
471476
if !self.at_symbol("(") {
472477
self.index = start;
473478
return None;
474479
}
475480
self.index += 1;
476-
let Some(TokenKind::String(reason)) = self.current().map(|token| &token.kind) else {
481+
let Some(TokenKind::String(value)) = self.current().map(|token| &token.kind) else {
477482
self.index = start;
478483
return None;
479484
};
480-
deprecated_reason = Some(reason.clone());
485+
match attribute {
486+
"deprecated" => deprecated_reason = Some(value.clone()),
487+
"lower_name" => lower_name = Some(value.clone()),
488+
_ => unreachable!(),
489+
}
481490
self.index += 1;
482491
if !self.at_symbol(")") {
483492
self.index = start;
@@ -585,6 +594,7 @@ impl Parser<'_> {
585594
has_body,
586595
default_impl_marker,
587596
deprecated_reason,
597+
lower_name,
588598
type_params,
589599
malformed_generic_param_spans,
590600
params,

0 commit comments

Comments
 (0)