Skip to content
Merged
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
4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ members = [

[workspace.package]
edition = "2024"
rust-version = "1.85"
# The crates use let-chains (`if let … && let …`), stabilized in Rust 1.88, so 1.85
# would not compile them — declare the real floor rather than an aspirational one.
rust-version = "1.88"
license = "MIT"
authors = ["João Lucas <jlucaso@hotmail.com>"]
repository = "https://github.com/oxidezap/whatspec"
Expand Down
2 changes: 1 addition & 1 deletion crates/wa-codegen/src/emit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -790,7 +790,7 @@ fn emit_node_content(
/// `const_bytes` degrades to no content rather than panicking codegen. The
/// `is_ascii` gate also keeps the byte-index slicing below on char boundaries.
fn decode_hex(s: &str) -> Option<Vec<u8>> {
if s.len() % 2 != 0 || !s.is_ascii() {
if !s.len().is_multiple_of(2) || !s.is_ascii() {
return None;
}
(0..s.len())
Expand Down
6 changes: 6 additions & 0 deletions crates/wa-ir/src/proto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ pub struct ProtoField {
pub flags: Vec<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub packed: bool,
/// The proto2 `[default = X]` value, when the message declares one for this field
/// (from WA's `internalDefaults`). The semantic token: an enum variant name
/// (`E2EE`), `true`/`false`, or a number. `None` when no default is declared.
/// A consumer emits `[default = <default>]` (quoting it only for a `string` field).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
Expand Down
169 changes: 165 additions & 4 deletions crates/wa-proto/src/extract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap};
use oxc_allocator::Allocator;
use oxc_ast::ast::{
ArrayExpression, AssignmentExpression, BinaryExpression, BinaryOperator, Expression,
ObjectExpression, ObjectPropertyKind, VariableDeclarator,
ObjectExpression, ObjectPropertyKind, UnaryOperator, VariableDeclarator,
};
use oxc_ast_visit::{Visit, walk};
use wa_ir::{
Expand Down Expand Up @@ -59,6 +59,7 @@ const TYPE_SUFFIX: &str = "Type";

const FLAG_PACKED: &str = "packed";
const FLAG_OPTIONAL: &str = "optional";
const FLAG_REPEATED: &str = "repeated";

// ─── Name helpers ─────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -118,6 +119,8 @@ struct Ident {
alias: Option<String>,
enum_values: Option<Vec<ProtoEnumValue>>,
members: Option<Vec<MemberDesc>>,
/// field name → proto2 default token, from `X.internalDefaults = {...}`.
defaults: HashMap<String, String>,
}

#[derive(Default)]
Expand All @@ -126,6 +129,8 @@ struct ModuleInfo {
identifiers: HashMap<String, Ident>,
/// `(target_alias, members)` captured from `X.internalSpec = {...}`.
specs: Vec<(String, Vec<MemberDesc>)>,
/// `(target_alias, field→default)` captured from `X.internalDefaults = {...}`.
defaults: Vec<(String, HashMap<String, String>)>,
enum_aliases: HashMap<String, Vec<ProtoEnumValue>>,
alias_matches: Vec<(String, String)>, // (renamed key, alias)
ident_order: Vec<String>,
Expand Down Expand Up @@ -200,6 +205,12 @@ pub fn extract_proto_from_modules(
contents.visit_program(&ret.program);
info.specs = contents.specs;

let mut defaults = DefaultsCollector {
defaults: Vec::new(),
};
defaults.visit_program(&ret.program);
info.defaults = defaults.defaults;

modules.insert(def.name.clone(), info);
}

Expand Down Expand Up @@ -251,6 +262,21 @@ pub fn extract_proto_from_modules(
}
}

// Attach proto2 field defaults to their target identifier (same alias match).
for info in modules.values_mut() {
let defaults = std::mem::take(&mut info.defaults);
for (target_alias, map) in defaults {
if let Some(key) = info
.identifiers
.iter()
.find(|(_, v)| v.alias.as_deref() == Some(target_alias.as_str()))
.map(|(k, _)| k.clone())
{
info.identifiers.get_mut(&key).unwrap().defaults = map;
}
}
}

// Resolve into the proto entity tree.
let mut entities: Vec<ProtoEntity> = Vec::new();
for info in modules.values() {
Expand Down Expand Up @@ -554,7 +580,7 @@ fn build_entity(
// A message. Resolve members, then attach nested children (sorted).
let proto_members = members
.iter()
.map(|m| resolve_member(m, &ident.name, info, modules, indent_map))
.map(|m| resolve_member(m, &ident.name, &ident.defaults, info, modules, indent_map))
.collect();

let mut nested = Vec::new();
Expand Down Expand Up @@ -589,17 +615,19 @@ fn build_entity(
fn resolve_member(
m: &MemberDesc,
parent_name: &str,
defaults: &HashMap<String, String>,
info: &ModuleInfo,
modules: &HashMap<String, ModuleInfo>,
indent_map: &HashMap<String, Indent>,
) -> ProtoMember {
match m {
MemberDesc::OneOf { name, fields } => {
// oneof fields carry no auto-`optional` and no parent context, so
// nested types are always fully qualified.
// nested types are always fully qualified. proto2 forbids defaults on
// oneof fields, so pass an empty map (also enforced by `message_member`).
let fields = fields
.iter()
.map(|f| build_field(f, None, false, info, modules, indent_map))
.map(|f| build_field(f, None, false, &HashMap::new(), info, modules, indent_map))
.collect();
ProtoMember::OneOf(ProtoOneOf {
name: name.clone(),
Expand All @@ -610,6 +638,7 @@ fn resolve_member(
f,
Some(parent_name),
true,
defaults,
info,
modules,
indent_map,
Expand All @@ -621,6 +650,7 @@ fn build_field(
f: &FieldDesc,
parent_name: Option<&str>,
message_member: bool,
defaults: &HashMap<String, String>,
info: &ModuleInfo,
modules: &HashMap<String, ModuleInfo>,
indent_map: &HashMap<String, Indent>,
Expand All @@ -638,12 +668,23 @@ fn build_field(
flags.push(FLAG_OPTIONAL.to_string());
}

// A proto2 default applies only to a singular scalar/enum/message field — never a
// oneof member (`message_member` is false there), a `repeated` field, or a `map`
// (protoc rejects a default on the latter two). All of WA's `internalDefaults`
// target singular optional fields; the guards keep a future stray one valid.
let default = (message_member
&& !flags.iter().any(|fl| fl == FLAG_REPEATED)
&& !type_name.contains(TYPE_MAP))
.then(|| defaults.get(&f.name).cloned())
.flatten();

ProtoField {
name: f.name.clone(),
id: f.id,
type_name,
flags,
packed,
default,
}
}

Expand Down Expand Up @@ -844,6 +885,77 @@ impl<'a> Visit<'a> for ContentsCollector {
}
}

/// Captures `X.internalDefaults = { field: <defaultExpr>, … }` — the proto2
/// per-field defaults, keyed (like `internalSpec`) by the message's alias `X`.
struct DefaultsCollector {
defaults: Vec<(String, HashMap<String, String>)>,
}
impl<'a> Visit<'a> for DefaultsCollector {
fn visit_assignment_expression(&mut self, n: &AssignmentExpression<'a>) {
if let Some(member) = n.left.as_member_expression()
&& member.static_property_name() == Some(PROP_INTERNAL_DEFAULTS)
&& let (Some(obj_name), Expression::ObjectExpression(obj)) =
(as_identifier(member.object()), &n.right)
{
self.defaults
.push((obj_name.to_string(), parse_defaults(obj)));
}
walk::walk_assignment_expression(self, n);
}
}

/// Parse an `internalDefaults` object into `field → proto2 default token`, dropping
/// any value whose expression shape isn't a recognized default (see [`resolve_default`]).
fn parse_defaults(obj: &ObjectExpression) -> HashMap<String, String> {
let mut out = HashMap::new();
for prop in &obj.properties {
if let ObjectPropertyKind::ObjectProperty(p) = prop
&& let Some(key) = property_key_name(&p.key)
&& let Some(value) = resolve_default(&p.value)
{
out.insert(key.to_string(), value);
}
}
out
}

/// Resolve a single `internalDefaults` value to its proto2 default token. WA declares
/// these as an enum-variant member (`s.E2EE` / `c.Foo.E2EE` → the variant name), a
/// numeric literal (`1`), a negated number (`-1`), or a boolean written `!1`/`!0`.
/// Returns `None` for anything else (which then emits no default, never a wrong one).
fn resolve_default(expr: &Expression) -> Option<String> {
// An enum default references a variant: the bare last property name is the token.
if let Some(member) = expr.as_member_expression() {
return member.static_property_name().map(str::to_string);
}
match expr {
Expression::NumericLiteral(n) => Some(format_number(n.value)),
Expression::BooleanLiteral(b) => Some(b.value.to_string()),
Expression::StringLiteral(s) => Some(s.value.to_string()),
Expression::UnaryExpression(u) => {
let Expression::NumericLiteral(n) = &u.argument else {
return None;
};
match u.operator {
// `!1` → false, `!0` → true (JS truthiness of the numeric operand).
UnaryOperator::LogicalNot => Some((n.value == 0.0).to_string()),
Comment thread
greptile-apps[bot] marked this conversation as resolved.
UnaryOperator::UnaryNegation => Some(format!("-{}", format_number(n.value))),
_ => None,
}
}
_ => None,
}
}

/// Render a numeric default without a spurious `.0` (WA's numeric defaults are integers).
fn format_number(v: f64) -> String {
if v.fract() == 0.0 && v.abs() < 9.007_199_254_740_992e15 {
(v as i64).to_string()
} else {
v.to_string()
}
}

// ─── internalSpec parsing ─────────────────────────────────────────────────────

fn parse_internal_spec(obj: &ObjectExpression) -> Vec<MemberDesc> {
Expand Down Expand Up @@ -1102,6 +1214,55 @@ mod tests {
assert!(!out.contains("message "));
}

// `internalDefaults` across every value shape WA uses: enum variant (`s.ON`), a
// `!1` boolean, an int, and a negative int. A oneof member's default is dropped
// (proto2 forbids oneof defaults).
const DEFAULTS: &str = r#"__d("ModD.pb",["$InternalEnum","WAProtoConst"],(function(t,n,r,o,a,i,l){
var e, s=n("$InternalEnum")({UNKNOWN:0,ON:1}), u={};
u.internalDefaults={mode:s.ON,flag:!1,on:!0,count:5,delta:-1,pick:s.ON,tags:5};
u.name="Thing";
u.internalSpec={mode:[1,(e=r("WAProtoConst")).TYPES.ENUM,s],flag:[2,e.TYPES.BOOL],on:[6,e.TYPES.BOOL],count:[3,e.TYPES.INT32],delta:[4,e.TYPES.INT32],pick:[5,e.TYPES.ENUM,s],tags:[7,e.TYPES.MAP,[e.TYPES.STRING,e.TYPES.STRING]],__oneofs__:{choice:["pick"]}};
l.Mode=s,l.ThingSpec=u;
}),1);"#;

#[test]
fn recovers_internal_defaults() {
let out = stringify(&extract_proto(DEFAULTS, "2.3000.1"));
assert!(
out.contains("optional Mode mode = 1 [default = ON];"),
"{out}"
);
assert!(
out.contains("optional bool flag = 2 [default = false];"),
"{out}"
);
// `!0` is JS-truthy → `true` (the complement of the `!1` case above).
assert!(
out.contains("optional bool on = 6 [default = true];"),
"{out}"
);
assert!(
out.contains("optional int32 count = 3 [default = 5];"),
"{out}"
);
assert!(
out.contains("optional int32 delta = 4 [default = -1];"),
"{out}"
);
// A `map` field never carries a default (protoc rejects it), even with an
// `internalDefaults` entry.
assert!(
out.contains("map<string, string> tags = 7;") && !out.contains("tags = 7 [default"),
"{out}"
);
// `pick` is a oneof member → no default (proto2 forbids it).
assert!(out.contains("Mode pick = 5;"), "{out}");
assert!(
!out.contains("pick = 5 [default"),
"oneof field must not carry a default: {out}"
);
}

// Same type exported by two differently-named modules (a legacy + a `…V3`
// variant in real bundles). protoc rejects the redefinition; we keep one.
const DUP: &str = r#"__d("ModA.pb",["WAProtoConst"],(function(t,n,r,o,a,i,l){
Expand Down
Loading
Loading