Skip to content

extern_path only matches package prefixes — per-type FQN mappings are silently ignored #111

@iainmcgin

Description

@iainmcgin

Problem

extern_path only matches at the proto package prefix level. A mapping for a specific type FQN is silently ignored.

This came up while working with Buf on the BSR Cargo SDK plugins. BSR's SDK assembly injects extern_path mappings to point a downstream plugin's generated code at the upstream module's SDK crate. Module-level (package-prefix) injection works today, but per-type injection — which prost/tonic support and which BSR could need if a single proto package's files were split across more than one BSR module — does not.

Where the limitation lives

CodeGenContext::new (buffa-codegen/src/context.rs) resolves a single rust_module per file by matching the file's package against extern_paths, then registers every top-level type in that file under that one prefix:

// per file
let rust_module =
    if let Some(rust_root) = resolve_extern_prefix(package, effective_extern_paths) {
        rust_root
    } else {
        package.replace('.', "::")
    };

// every top-level type in the file
let rust_path = format!("{}::{}", rust_module, name);
type_map.insert(fqn, rust_path);

resolve_extern_prefix (context.rs:637) takes only the package, never a type FQN:

fn resolve_extern_prefix(package: &str, extern_paths: &[(String, String)]) -> Option<String> {
    let dotted = format!(".{}", package);
    // longest-prefix match against extern_paths, requiring a `.` boundary
    ...
}

So extern_path=.google.protobuf.Timestamp=::other_crate::Ts never matches anything: the file's package is google.protobuf, and .google.protobuf does not have .google.protobuf.Timestamp as a prefix. The mapping is dead with no diagnostic.

How prost differs

prost resolves extern_path at type-resolution time against the type's FQN, exact-match first, then longest dotted prefix (prost-build/src/extern_paths.rs::resolve_ident):

pub fn resolve_ident(&self, pb_ident: &str) -> Option<String> {
    if let Some(rust_path) = self.extern_paths.get(pb_ident) {
        return Some(rust_path.clone());          // exact: per-type override
    }
    for (idx, _) in pb_ident.rmatch_indices('.') {
        if let Some(rust_path) = self.extern_paths.get(&pb_ident[..idx]) { ... }  // prefix fallback
    }
    None
}

extern_path=.google.protobuf.Timestamp=::pbjson_types::Timestamp is a normal thing to do in a prost build. Users migrating from prost will reach for this and find it silently does nothing here.

Proposed direction

Move extern_path matching from file-registration to type-registration in CodeGenContext::new: when registering a type FQN, first check for an exact (or longest-FQN-prefix) match in extern_paths, falling back to the file's package-level resolution if none. Same trie-style longest-match prost uses.

The non-trivial part is the __buffa:: ancillary tree. View/oneof/extension paths are derived as <package_module>::__buffa::view::<TypeView> etc., so we need to know where the package boundary falls inside a per-type Rust path. With package-level extern_path the boundary is the mapping. With per-type (.foo.bar.Outer.Inner::ext::foo::bar::outer::Inner), the boundary has to be recovered: count nesting segments past the package using package_of[fqn] and split the supplied Rust path at that point. That should be where the bugs hide; needs targeted tests for nested types and enums under a per-type override.

Independent of the implementation, a good first step is a diagnostic: warn (or error) when an extern_path entry's proto path isn't a package-only prefix, since today it's a silent no-op.

Related

  • The package-only behavior was sufficient for the WKT auto-injection (.google.protobuf::buffa_types::google::protobuf) and for buffa_module= / extern_path=.=… catch-alls, which is why this hasn't surfaced before.
  • connectrpc-codegen's TypeResolver wraps CodeGenContext::for_generate() for zero-drift type resolution, so it inherits whatever buffa-codegen does here. Tracking issue filed there as well.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions