From 5cabf43ac493cbcbba6f4472906592a68b113766 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 18:50:12 +0000 Subject: [PATCH 1/7] feat(proto): recover proto2 field defaults from internalDefaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extractor skipped `internalDefaults`, so proto2 `[default = X]` was dropped from every field that declares one — a proto2 consumer (e.g. whatsapp-rust) then sees the type-zero value instead of WA's declared default on an absent field. Parse `X.internalDefaults = {field: }` alongside `internalSpec` and attach the resolved default to its field. Handles every value shape WA uses: an enum-variant member (`s.E2EE` / `c.Foo.E2EE` -> the bare variant name), a numeric literal, a negated number (`-1`), and a boolean written `!1`/`!0`. Defaults apply only to singular message fields — never oneof members or repeated fields, which protoc rejects. Adds a `ProtoField.default` IR field (omitted when serialized, so it's additive) and emits `[default = X]` (a `string` default quoted, others bare), combined with `[packed = true]` via a joined options list. Recovers 27 defaults across ~18 messages (accountType=E2EE, trigger=UNKNOWN, taskId=-1, messageVersion=1, status=PENDING, …). The protox compile guard confirms every default resolves against its field's enum type; output is deterministic and the change is purely additive. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013111jePsSca7epxMugRn2A --- crates/wa-ir/src/proto.rs | 6 ++ crates/wa-proto/src/extract.rs | 155 ++++++++++++++++++++++++++++++- crates/wa-proto/src/stringify.rs | 73 ++++++++++++++- generated/manifest.json | 2 +- generated/proto/WAProto.proto | 54 +++++------ 5 files changed, 256 insertions(+), 34 deletions(-) diff --git a/crates/wa-ir/src/proto.rs b/crates/wa-ir/src/proto.rs index fd85da4..5827cfb 100644 --- a/crates/wa-ir/src/proto.rs +++ b/crates/wa-ir/src/proto.rs @@ -69,6 +69,12 @@ pub struct ProtoField { pub flags: Vec, #[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 = ]` (quoting it only for a `string` field). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/wa-proto/src/extract.rs b/crates/wa-proto/src/extract.rs index 000ff3c..ef6846c 100644 --- a/crates/wa-proto/src/extract.rs +++ b/crates/wa-proto/src/extract.rs @@ -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::{ @@ -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 ───────────────────────────────────────────────────────────── @@ -118,6 +119,8 @@ struct Ident { alias: Option, enum_values: Option>, members: Option>, + /// field name → proto2 default token, from `X.internalDefaults = {...}`. + defaults: HashMap, } #[derive(Default)] @@ -126,6 +129,8 @@ struct ModuleInfo { identifiers: HashMap, /// `(target_alias, members)` captured from `X.internalSpec = {...}`. specs: Vec<(String, Vec)>, + /// `(target_alias, field→default)` captured from `X.internalDefaults = {...}`. + defaults: Vec<(String, HashMap)>, enum_aliases: HashMap>, alias_matches: Vec<(String, String)>, // (renamed key, alias) ident_order: Vec, @@ -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); } @@ -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 = Vec::new(); for info in modules.values() { @@ -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(); @@ -589,6 +615,7 @@ fn build_entity( fn resolve_member( m: &MemberDesc, parent_name: &str, + defaults: &HashMap, info: &ModuleInfo, modules: &HashMap, indent_map: &HashMap, @@ -596,10 +623,11 @@ fn resolve_member( 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(), @@ -610,6 +638,7 @@ fn resolve_member( f, Some(parent_name), true, + defaults, info, modules, indent_map, @@ -621,6 +650,7 @@ fn build_field( f: &FieldDesc, parent_name: Option<&str>, message_member: bool, + defaults: &HashMap, info: &ModuleInfo, modules: &HashMap, indent_map: &HashMap, @@ -638,12 +668,20 @@ fn build_field( flags.push(FLAG_OPTIONAL.to_string()); } + // A proto2 default applies only to a singular message field — never a oneof + // member (`message_member` is false there) nor a `repeated` field (protoc rejects + // that). All of WA's `internalDefaults` target singular optional fields. + let default = (message_member && !flags.iter().any(|fl| fl == FLAG_REPEATED)) + .then(|| defaults.get(&f.name).cloned()) + .flatten(); + ProtoField { name: f.name.clone(), id: f.id, type_name, flags, packed, + default, } } @@ -844,6 +882,77 @@ impl<'a> Visit<'a> for ContentsCollector { } } +/// Captures `X.internalDefaults = { field: , … }` — the proto2 +/// per-field defaults, keyed (like `internalSpec`) by the message's alias `X`. +struct DefaultsCollector { + defaults: Vec<(String, HashMap)>, +} +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 { + 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 { + // 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()), + 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 { @@ -1102,6 +1211,44 @@ 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,count:5,delta:-1,pick:s.ON}; + u.name="Thing"; + u.internalSpec={mode:[1,(e=r("WAProtoConst")).TYPES.ENUM,s],flag:[2,e.TYPES.BOOL],count:[3,e.TYPES.INT32],delta:[4,e.TYPES.INT32],pick:[5,e.TYPES.ENUM,s],__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}" + ); + assert!( + out.contains("optional int32 count = 3 [default = 5];"), + "{out}" + ); + assert!( + out.contains("optional int32 delta = 4 [default = -1];"), + "{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){ diff --git a/crates/wa-proto/src/stringify.rs b/crates/wa-proto/src/stringify.rs index 9d8b7bb..82dfe64 100644 --- a/crates/wa-proto/src/stringify.rs +++ b/crates/wa-proto/src/stringify.rs @@ -86,8 +86,29 @@ fn member_lines(member: &ProtoMember) -> Vec { fn field_line(f: &ProtoField) -> String { let flags = f.flags.join(" "); let sep = if f.flags.is_empty() { "" } else { " " }; - let packed = if f.packed { " [packed = true]" } else { "" }; - format!("{flags}{sep}{} {} = {}{packed};", f.type_name, f.name, f.id) + let mut opts: Vec = Vec::new(); + if f.packed { + opts.push("packed = true".to_string()); + } + if let Some(d) = &f.default { + // A proto2 `string` default is quoted; enum/bool/numeric defaults are bare. + // (packed and default never co-occur — packed is for repeated scalars, defaults + // for singular optionals — but the joined-options form handles either alone.) + if f.type_name == "string" { + opts.push(format!("default = \"{d}\"")); + } else { + opts.push(format!("default = {d}")); + } + } + let options = if opts.is_empty() { + String::new() + } else { + format!(" [{}]", opts.join(", ")) + }; + format!( + "{flags}{sep}{} {} = {}{options};", + f.type_name, f.name, f.id + ) } /// Prefix every non-empty line with one indent level. Blank lines stay empty @@ -117,9 +138,55 @@ mod tests { type_name: ftype.to_string(), flags: flags.iter().map(|s| s.to_string()).collect(), packed, + default: None, }) } + #[test] + fn emits_proto2_defaults() { + fn defaulted(name: &str, ftype: &str, id: i64, default: &str) -> ProtoMember { + ProtoMember::Field(ProtoField { + name: name.to_string(), + id, + type_name: ftype.to_string(), + flags: vec!["optional".to_string()], + packed: false, + default: Some(default.to_string()), + }) + } + let file = ProtoFile { + wa_version: "1".to_string(), + entities: vec![ProtoEntity::Message(ProtoMessage { + name: "M".to_string(), + members: vec![ + defaulted("accountType", "ADVEncryptionType", 1, "E2EE"), + defaulted("count", "int32", 2, "1"), + defaulted("flag", "bool", 3, "false"), + defaulted("label", "string", 4, "hi"), + ], + nested: vec![], + })], + }; + let out = stringify(&file); + // enum / int / bool defaults are bare; a `string` default is quoted. + assert!( + out.contains("optional ADVEncryptionType accountType = 1 [default = E2EE];"), + "{out}" + ); + assert!( + out.contains("optional int32 count = 2 [default = 1];"), + "{out}" + ); + assert!( + out.contains("optional bool flag = 3 [default = false];"), + "{out}" + ); + assert!( + out.contains(r#"optional string label = 4 [default = "hi"];"#), + "{out}" + ); + } + #[test] fn matches_adv_snippet() { // Hand-built IR for the ADV* head of a real WAProto.proto. @@ -209,6 +276,7 @@ message ADVKeyIndexList {\n\ type_name: "string".to_string(), flags: vec![], packed: false, + default: None, }, ProtoField { name: "num".to_string(), @@ -216,6 +284,7 @@ message ADVKeyIndexList {\n\ type_name: "int32".to_string(), flags: vec![], packed: false, + default: None, }, ], }), diff --git a/generated/manifest.json b/generated/manifest.json index bef4b2f..332981e 100644 --- a/generated/manifest.json +++ b/generated/manifest.json @@ -63,7 +63,7 @@ }, "proto": { "file": "proto/WAProto.proto", - "sha256": "bc53415bf23d0bb9cec94a36a767b766872ee9c04887519aff8f98b5030336a7" + "sha256": "5e183105862dd250f2cc7bdb70d5a336e56b2e6b1cc158b351afb7a7a6c3f507" }, "srvreq": { "file": "srvreq/index.json", diff --git a/generated/proto/WAProto.proto b/generated/proto/WAProto.proto index 3e57069..4d5b3b8 100644 --- a/generated/proto/WAProto.proto +++ b/generated/proto/WAProto.proto @@ -7,8 +7,8 @@ message ADVDeviceIdentity { optional uint32 rawId = 1; optional uint64 timestamp = 2; optional uint32 keyIndex = 3; - optional ADVEncryptionType accountType = 4; - optional ADVEncryptionType deviceType = 5; + optional ADVEncryptionType accountType = 4 [default = E2EE]; + optional ADVEncryptionType deviceType = 5 [default = E2EE]; } enum ADVEncryptionType { @@ -21,7 +21,7 @@ message ADVKeyIndexList { optional uint64 timestamp = 2; optional uint32 currentIndex = 3; repeated uint32 validIndexes = 4 [packed = true]; - optional ADVEncryptionType accountType = 5; + optional ADVEncryptionType accountType = 5 [default = E2EE]; } message ADVSignedDeviceIdentity { @@ -34,7 +34,7 @@ message ADVSignedDeviceIdentity { message ADVSignedDeviceIdentityHMAC { optional bytes details = 1; optional bytes hmac = 2; - optional ADVEncryptionType accountType = 3; + optional ADVEncryptionType accountType = 3 [default = E2EE]; } message ADVSignedKeyIndexList { @@ -1808,11 +1808,11 @@ message ContextInfo { } message FeatureEligibilities { - optional bool cannotBeReactedTo = 1; - optional bool cannotBeRanked = 2; - optional bool canRequestFeedback = 3; - optional bool canBeReshared = 4; - optional bool canReceiveMultiReact = 5; + optional bool cannotBeReactedTo = 1 [default = false]; + optional bool cannotBeRanked = 2 [default = false]; + optional bool canRequestFeedback = 3 [default = false]; + optional bool canBeReshared = 4 [default = false]; + optional bool canReceiveMultiReact = 5 [default = false]; } enum ForwardOrigin { @@ -2048,8 +2048,8 @@ message DeviceListMetadata { optional bytes senderKeyHash = 1; optional uint64 senderTimestamp = 2; repeated uint32 senderKeyIndexes = 3 [packed = true]; - optional ADVEncryptionType senderAccountType = 4; - optional ADVEncryptionType receiverAccountType = 5; + optional ADVEncryptionType senderAccountType = 4 [default = E2EE]; + optional ADVEncryptionType receiverAccountType = 5 [default = E2EE]; optional bytes recipientKeyHash = 8; optional uint64 recipientTimestamp = 9; repeated uint32 recipientKeyIndexes = 10 [packed = true]; @@ -2273,7 +2273,7 @@ enum FUTURE_PROOF_BEHAVIOR { IGNORE = 2; } message Field { - optional uint32 minVersion = 1; + optional uint32 minVersion = 1 [default = 1]; optional uint32 maxVersion = 2; optional uint32 notReportableMinVersion = 3; optional bool isMessage = 4; @@ -2622,7 +2622,7 @@ message LegacyMessage { message LimitSharing { optional bool sharingLimited = 1; - optional TriggerType trigger = 2; + optional TriggerType trigger = 2 [default = UNKNOWN]; optional int64 limitSharingSettingTimestamp = 3; optional bool initiatedByMe = 4; enum TriggerType { @@ -3447,8 +3447,8 @@ message Message { message CarouselMessage { repeated Message.InteractiveMessage cards = 1; - optional int32 messageVersion = 2; - optional CarouselCardType carouselCardType = 3; + optional int32 messageVersion = 2 [default = 1]; + optional CarouselCardType carouselCardType = 3 [default = HSCROLL_CARDS]; enum CarouselCardType { UNKNOWN = 0; HSCROLL_CARDS = 1; @@ -3459,7 +3459,7 @@ message Message { message CollectionMessage { optional string bizJid = 1; optional string id = 2; - optional int32 messageVersion = 3; + optional int32 messageVersion = 3 [default = 1]; } message Footer { @@ -3488,7 +3488,7 @@ message Message { message NativeFlowMessage { repeated NativeFlowButton buttons = 1; optional string messageParamsJson = 2; - optional int32 messageVersion = 3; + optional int32 messageVersion = 3 [default = 1]; message NativeFlowButton { optional string name = 1; optional string buttonParamsJson = 2; @@ -3498,7 +3498,7 @@ message Message { message ShopMessage { optional string id = 1; optional Surface surface = 2; - optional int32 messageVersion = 3; + optional int32 messageVersion = 3 [default = 1]; enum Surface { UNKNOWN_SURFACE = 0; FB = 1; @@ -3516,7 +3516,7 @@ message Message { } message Body { optional string text = 1; - optional Format format = 2; + optional Format format = 2 [default = DEFAULT]; enum Format { DEFAULT = 0; EXTENSIONS_1 = 1; @@ -3526,7 +3526,7 @@ message Message { message NativeFlowResponseMessage { optional string name = 1; optional string paramsJson = 2; - optional int32 version = 3; + optional int32 version = 3 [default = 1]; } } @@ -3726,7 +3726,7 @@ message Message { optional int64 totalAmount1000 = 10; optional string totalCurrencyCode = 11; optional ContextInfo contextInfo = 17; - optional int32 messageVersion = 12; + optional int32 messageVersion = 12 [default = 1]; optional MessageKey orderRequestMessageId = 13; optional string catalogType = 15; enum OrderStatus { @@ -4532,7 +4532,7 @@ message MessageAddOn { optional Message messageAddOn = 2; optional int64 senderTimestampMs = 3; optional int64 serverTimestampMs = 4; - optional WebMessageInfo.Status status = 5; + optional WebMessageInfo.Status status = 5 [default = PENDING]; optional MessageAddOnContextInfo addOnContextInfo = 6; optional MessageKey messageAddOnKey = 7; optional LegacyMessage legacyMessage = 8; @@ -5162,7 +5162,7 @@ message RecordStructure { } message Reportable { - optional uint32 minVersion = 1; + optional uint32 minVersion = 1 [default = 1]; optional uint32 maxVersion = 2; optional uint32 notReportableMinVersion = 3; optional bool never = 4; @@ -5175,9 +5175,9 @@ message ReportingTokenInfo { message RoutingInfo { repeated int32 regionId = 1; repeated int32 clusterId = 2; - optional int32 taskId = 3; - optional bool debug = 4; - optional bool tcpBbr = 5; + optional int32 taskId = 3 [default = -1]; + optional bool debug = 4 [default = false]; + optional bool tcpBbr = 5 [default = false]; optional bool tcpKeepalive = 6; } @@ -6420,7 +6420,7 @@ message WebMessageInfo { required MessageKey key = 1; optional Message message = 2; optional uint64 messageTimestamp = 3; - optional Status status = 4; + optional Status status = 4 [default = PENDING]; optional string participant = 5; optional uint64 messageC2STimestamp = 6; optional bool ignore = 16; From f6da852426fa225faaec9f428ef985c28fc29639 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 19:49:30 +0000 Subject: [PATCH 2/7] chore: declare the real MSRV (1.85 -> 1.88, for let-chains) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rust-version = "1.85"` was aspirational: the crates use let-chains (`if let … && let …`), stabilized in Rust 1.88, so 1.85 can't build them. Someone on 1.85 got a confusing mid-compile error instead of a clear "requires rustc 1.88" message from cargo. Declare the honest floor. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013111jePsSca7epxMugRn2A --- Cargo.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index d0b1a2e..e9bd351 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 "] repository = "https://github.com/oxidezap/whatspec" From 6dbf4b07a52d1d6800166a6e7fd76c69c787171f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 19:57:39 +0000 Subject: [PATCH 3/7] fix: satisfy clippy 1.96; quote bytes defaults; test !0 default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's stable toolchain advanced to Rust 1.96, whose clippy adds two lints that fail `-D warnings` on pre-existing code (unrelated to this PR's proto work, but they block any PR until fixed): - wa-codegen `decode_hex`: `len() % 2 != 0` -> `!len().is_multiple_of(2)` (is_multiple_of is stable since 1.87, within the 1.88 MSRV). - wa-scan `resolve_child_node`: collapse `if a { if let b {} }` into a single `if a && let b {}` let-chain (1.88, matching the bumped MSRV). Both are semantically identical — no output change. Also address two Greptile nitpicks on the proto defaults: - stringify: quote a `bytes` default too (proto2 bytes defaults are string literals). No WA field has one today, so output is unchanged, but a future one stays valid instead of emitting an unquoted `[default = x]`. - extract test: assert the `!0` -> `true` branch (only `!1` -> `false` was covered). Verified clean under the stable 1.96 toolchain: clippy -D warnings, fmt, and the full workspace test suite. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013111jePsSca7epxMugRn2A --- crates/wa-codegen/src/emit.rs | 2 +- crates/wa-proto/src/extract.rs | 9 ++++++-- crates/wa-proto/src/stringify.rs | 6 +++-- crates/wa-scan/src/request.rs | 38 ++++++++++++++++---------------- 4 files changed, 31 insertions(+), 24 deletions(-) diff --git a/crates/wa-codegen/src/emit.rs b/crates/wa-codegen/src/emit.rs index f75e3be..f6444e4 100644 --- a/crates/wa-codegen/src/emit.rs +++ b/crates/wa-codegen/src/emit.rs @@ -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> { - if s.len() % 2 != 0 || !s.is_ascii() { + if !s.len().is_multiple_of(2) || !s.is_ascii() { return None; } (0..s.len()) diff --git a/crates/wa-proto/src/extract.rs b/crates/wa-proto/src/extract.rs index ef6846c..d1fadee 100644 --- a/crates/wa-proto/src/extract.rs +++ b/crates/wa-proto/src/extract.rs @@ -1216,9 +1216,9 @@ mod tests { // (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,count:5,delta:-1,pick:s.ON}; + u.internalDefaults={mode:s.ON,flag:!1,on:!0,count:5,delta:-1,pick:s.ON}; u.name="Thing"; - u.internalSpec={mode:[1,(e=r("WAProtoConst")).TYPES.ENUM,s],flag:[2,e.TYPES.BOOL],count:[3,e.TYPES.INT32],delta:[4,e.TYPES.INT32],pick:[5,e.TYPES.ENUM,s],__oneofs__:{choice:["pick"]}}; + 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],__oneofs__:{choice:["pick"]}}; l.Mode=s,l.ThingSpec=u; }),1);"#; @@ -1233,6 +1233,11 @@ mod tests { 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}" diff --git a/crates/wa-proto/src/stringify.rs b/crates/wa-proto/src/stringify.rs index 82dfe64..51f6139 100644 --- a/crates/wa-proto/src/stringify.rs +++ b/crates/wa-proto/src/stringify.rs @@ -91,10 +91,12 @@ fn field_line(f: &ProtoField) -> String { opts.push("packed = true".to_string()); } if let Some(d) = &f.default { - // A proto2 `string` default is quoted; enum/bool/numeric defaults are bare. + // A proto2 `string`/`bytes` default is a quoted string literal; enum/bool/numeric + // defaults are bare. (No WA field currently has a bytes default, but quoting it + // keeps a future one valid rather than emitting `[default = x]` that protoc rejects.) // (packed and default never co-occur — packed is for repeated scalars, defaults // for singular optionals — but the joined-options form handles either alone.) - if f.type_name == "string" { + if f.type_name == "string" || f.type_name == "bytes" { opts.push(format!("default = \"{d}\"")); } else { opts.push(format!("default = {d}")); diff --git a/crates/wa-scan/src/request.rs b/crates/wa-scan/src/request.rs index e989746..1651d54 100644 --- a/crates/wa-scan/src/request.rs +++ b/crates/wa-scan/src/request.rs @@ -376,27 +376,27 @@ pub(crate) fn resolve_child_node( let repeated = owner == Some("WASmaxChildren") && method == "REPEATED_CHILD"; let optional = owner == Some("WASmaxChildren") && matches!(method, "OPTIONAL_CHILD" | "HAS_OPTIONAL_CHILD"); - if repeated || optional { - if let Some(first) = call.arguments.first().and_then(arg_expr) { - let mut r = resolve_template_arg( - first, - node_source, - scope, - module_source, - aliases, - contributions, - helpers, - depth, - ); - if repeated { - for c in &mut r { - c.repeats = true; - } - } - if !r.is_empty() { - return r; + if (repeated || optional) + && let Some(first) = call.arguments.first().and_then(arg_expr) + { + let mut r = resolve_template_arg( + first, + node_source, + scope, + module_source, + aliases, + contributions, + helpers, + depth, + ); + if repeated { + for c in &mut r { + c.repeats = true; } } + if !r.is_empty() { + return r; + } } // `merge…Mixin` and the disjunction `merge…MixinGroup` both wrap a stanza. let is_merge = method.starts_with("merge") && method.contains("Mixin"); From 3105e4b7f39cd6ca93a9c1c77a3eb80f17ff25c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 19:59:08 +0000 Subject: [PATCH 4/7] fix(proto): exclude map fields from proto2 defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `build_field` guarded oneof members and `repeated` fields but not `map` — protoc rejects a default on a map, so a stray `internalDefaults` entry on a map field would emit an invalid `[default = …]`. Mirror the existing `TYPE_MAP` guard. No WA map field has a default today (output unchanged); the guard keeps a future one valid. Test asserts a map field stays default-free. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013111jePsSca7epxMugRn2A --- crates/wa-proto/src/extract.rs | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/crates/wa-proto/src/extract.rs b/crates/wa-proto/src/extract.rs index d1fadee..4cefcc6 100644 --- a/crates/wa-proto/src/extract.rs +++ b/crates/wa-proto/src/extract.rs @@ -668,12 +668,15 @@ fn build_field( flags.push(FLAG_OPTIONAL.to_string()); } - // A proto2 default applies only to a singular message field — never a oneof - // member (`message_member` is false there) nor a `repeated` field (protoc rejects - // that). All of WA's `internalDefaults` target singular optional fields. - let default = (message_member && !flags.iter().any(|fl| fl == FLAG_REPEATED)) - .then(|| defaults.get(&f.name).cloned()) - .flatten(); + // 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(), @@ -1216,9 +1219,9 @@ mod tests { // (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}; + 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],__oneofs__:{choice:["pick"]}}; + 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);"#; @@ -1246,6 +1249,12 @@ mod tests { 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 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!( From 5a721bd2e861b90861e82769703b80073f069024 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 20:04:28 +0000 Subject: [PATCH 5/7] fix(proto): escape string/bytes default literals A proto2 `string`/`bytes` default was inserted between quotes raw, so a value containing `"` or `\` would produce invalid `.proto` (terminating the string early). Escape backslashes then quotes before formatting. No WA field has a string/bytes default today (output unchanged), but the emitted literal stays valid if one appears. Test covers a backslash + quote default. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013111jePsSca7epxMugRn2A --- crates/wa-proto/src/stringify.rs | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/crates/wa-proto/src/stringify.rs b/crates/wa-proto/src/stringify.rs index 51f6139..ae72367 100644 --- a/crates/wa-proto/src/stringify.rs +++ b/crates/wa-proto/src/stringify.rs @@ -92,12 +92,16 @@ fn field_line(f: &ProtoField) -> String { } if let Some(d) = &f.default { // A proto2 `string`/`bytes` default is a quoted string literal; enum/bool/numeric - // defaults are bare. (No WA field currently has a bytes default, but quoting it - // keeps a future one valid rather than emitting `[default = x]` that protoc rejects.) - // (packed and default never co-occur — packed is for repeated scalars, defaults - // for singular optionals — but the joined-options form handles either alone.) + // defaults are bare. (No WA field currently has a string/bytes default, but the + // handling keeps a future one valid rather than emitting a bare `[default = x]` + // that protoc rejects.) (packed and default never co-occur — packed is for + // repeated scalars, defaults for singular optionals — but the joined-options form + // handles either alone.) if f.type_name == "string" || f.type_name == "bytes" { - opts.push(format!("default = \"{d}\"")); + // Escape the proto2 string literal (backslashes before quotes) so a default + // containing `"` or `\` stays valid `.proto` instead of terminating the string. + let escaped = d.replace('\\', "\\\\").replace('"', "\\\""); + opts.push(format!("default = \"{escaped}\"")); } else { opts.push(format!("default = {d}")); } @@ -165,6 +169,8 @@ mod tests { defaulted("count", "int32", 2, "1"), defaulted("flag", "bool", 3, "false"), defaulted("label", "string", 4, "hi"), + // A string default with a backslash and a quote must be proto-escaped. + defaulted("path", "string", 5, "a\\b\"c"), ], nested: vec![], })], @@ -183,6 +189,11 @@ mod tests { out.contains("optional bool flag = 3 [default = false];"), "{out}" ); + // A string default with a backslash and a quote is escaped into a valid literal. + assert!( + out.contains("optional string path = 5 [default = \"a\\\\b\\\"c\"];"), + "{out}" + ); assert!( out.contains(r#"optional string label = 4 [default = "hi"];"#), "{out}" From d0812daddd204cc1ba7084a3d426b14d3c57ac34 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 20:12:47 +0000 Subject: [PATCH 6/7] fix(proto): escape control chars in string/bytes default literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The string/bytes default escape path only handled `\` and `"`. A default containing a newline or carriage return would split the emitted field declaration across lines and produce invalid `.proto`. Escape the named control chars (`\n`/`\r`/`\t`) and fall back to a 3-digit octal escape for any other control char; printable ASCII/Unicode passes through unchanged. No WA field currently has a string/bytes default, so generated output is unchanged — this keeps a future one valid. Adds a control-char regression test. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013111jePsSca7epxMugRn2A --- crates/wa-proto/src/stringify.rs | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/wa-proto/src/stringify.rs b/crates/wa-proto/src/stringify.rs index ae72367..136f5a8 100644 --- a/crates/wa-proto/src/stringify.rs +++ b/crates/wa-proto/src/stringify.rs @@ -98,9 +98,23 @@ fn field_line(f: &ProtoField) -> String { // repeated scalars, defaults for singular optionals — but the joined-options form // handles either alone.) if f.type_name == "string" || f.type_name == "bytes" { - // Escape the proto2 string literal (backslashes before quotes) so a default - // containing `"` or `\` stays valid `.proto` instead of terminating the string. - let escaped = d.replace('\\', "\\\\").replace('"', "\\\""); + // Escape the proto2 string literal so a default stays valid `.proto` instead of + // terminating the string or splitting the line: `\` and `"` are escaped, newline / + // CR / tab get their named escapes, and any other control char falls back to a + // 3-digit octal escape (proto reads at most 3 octal digits, so zero-padding keeps + // the boundary unambiguous). Printable text — ASCII or Unicode — passes through. + let escaped = d.chars().fold(String::new(), |mut out, c| { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c.is_control() => out.push_str(&format!("\\{:03o}", c as u32)), + c => out.push(c), + } + out + }); opts.push(format!("default = \"{escaped}\"")); } else { opts.push(format!("default = {d}")); @@ -171,6 +185,9 @@ mod tests { defaulted("label", "string", 4, "hi"), // A string default with a backslash and a quote must be proto-escaped. defaulted("path", "string", 5, "a\\b\"c"), + // Control chars would split or invalidate the line: newline/CR/tab get + // named escapes, other controls (here a bell, 0x07) an octal escape. + defaulted("ctrl", "string", 6, "x\n\r\t\u{07}y"), ], nested: vec![], })], @@ -198,6 +215,12 @@ mod tests { out.contains(r#"optional string label = 4 [default = "hi"];"#), "{out}" ); + // Control chars are escaped (named where proto has one, else 3-digit octal) so the + // emitted line stays on one line and parses. + assert!( + out.contains(r#"optional string ctrl = 6 [default = "x\n\r\t\007y"];"#), + "{out}" + ); } #[test] From fd031c70db7d4c253b8f524db5cb5823506cfdc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 20:19:39 +0000 Subject: [PATCH 7/7] fix(proto): octal-escape only ASCII controls in default literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The control-char octal fallback used `is_control()`, which is also true for C1 controls (U+0080–U+009F). Proto's octal escape encodes a single raw byte, so escaping a C1 control's code point (e.g. `\200` for U+0080) emits one byte instead of that char's two UTF-8 bytes — invalid UTF-8 for a `string` default (protoc rejects it) and a silent value change for `bytes`. Restrict the octal path to ASCII controls (`is_ascii_control()`, i.e. 0x00– 0x1F and 0x7F); non-ASCII (printable or C1 control) passes through as its raw UTF-8, which protoc accepts. Extends the regression test with a C1 control. Still no WA field has a string/bytes default, so generated output is unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013111jePsSca7epxMugRn2A --- crates/wa-proto/src/stringify.rs | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/crates/wa-proto/src/stringify.rs b/crates/wa-proto/src/stringify.rs index 136f5a8..71e4691 100644 --- a/crates/wa-proto/src/stringify.rs +++ b/crates/wa-proto/src/stringify.rs @@ -100,9 +100,13 @@ fn field_line(f: &ProtoField) -> String { if f.type_name == "string" || f.type_name == "bytes" { // Escape the proto2 string literal so a default stays valid `.proto` instead of // terminating the string or splitting the line: `\` and `"` are escaped, newline / - // CR / tab get their named escapes, and any other control char falls back to a + // CR / tab get their named escapes, and any other *ASCII* control falls back to a // 3-digit octal escape (proto reads at most 3 octal digits, so zero-padding keeps - // the boundary unambiguous). Printable text — ASCII or Unicode — passes through. + // the boundary unambiguous). The octal path is deliberately ASCII-only: proto's + // octal escape encodes a single raw byte, so applying it to a C1 control's code + // point (U+0080–U+009F) would emit one byte instead of that char's two UTF-8 bytes + // — invalid UTF-8 for a `string`, a silent value change for `bytes`. Non-ASCII + // (printable or C1 control) passes through as its raw UTF-8, which protoc accepts. let escaped = d.chars().fold(String::new(), |mut out, c| { match c { '\\' => out.push_str("\\\\"), @@ -110,7 +114,7 @@ fn field_line(f: &ProtoField) -> String { '\n' => out.push_str("\\n"), '\r' => out.push_str("\\r"), '\t' => out.push_str("\\t"), - c if c.is_control() => out.push_str(&format!("\\{:03o}", c as u32)), + c if c.is_ascii_control() => out.push_str(&format!("\\{:03o}", c as u32)), c => out.push(c), } out @@ -186,8 +190,10 @@ mod tests { // A string default with a backslash and a quote must be proto-escaped. defaulted("path", "string", 5, "a\\b\"c"), // Control chars would split or invalidate the line: newline/CR/tab get - // named escapes, other controls (here a bell, 0x07) an octal escape. - defaulted("ctrl", "string", 6, "x\n\r\t\u{07}y"), + // named escapes, other ASCII controls (here a bell, 0x07) an octal escape. + // A C1 control (here NEL, U+0085) is non-ASCII, so it passes through as its + // raw UTF-8 — octal-escaping its code point would corrupt the byte sequence. + defaulted("ctrl", "string", 6, "x\n\r\t\u{07}\u{85}y"), ], nested: vec![], })], @@ -215,10 +221,11 @@ mod tests { out.contains(r#"optional string label = 4 [default = "hi"];"#), "{out}" ); - // Control chars are escaped (named where proto has one, else 3-digit octal) so the - // emitted line stays on one line and parses. + // ASCII control chars are escaped (named where proto has one, else 3-digit octal) so + // the emitted line stays on one line and parses; the C1 control (U+0085) passes + // through as raw UTF-8 rather than a code-point octal escape. assert!( - out.contains(r#"optional string ctrl = 6 [default = "x\n\r\t\007y"];"#), + out.contains("optional string ctrl = 6 [default = \"x\\n\\r\\t\\007\u{85}y\"];"), "{out}" ); }