Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/wire_descriptors_are_back.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

`scarlet check` describes `scarlet/wire` types again. At each `wire.encode`/`wire.decode` the compiler builds a descriptor of the type the value crosses at and puts it in the program, so a type it could not reconstruct is refused when the program is compiled rather than at run time — and the refusal names the offending sub-type and the path down to it, `` `wire.encode` cannot encode `a` ... (through [1] -> [value] -> [element]) ``. Running a wire call still stops with "wire.encode is not built yet": the byte walks come next.
7 changes: 2 additions & 5 deletions crates/scarlet/tests/type_errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -615,7 +615,6 @@ ok_case! {
/// `Outer(a)` refuses at the argument with no path, and a field can hold
/// nothing a type was not applied to.
#[test]
#[ignore = "waits for the wire redesign: check stopped refusing wire types in #50"]
fn wire_names_the_whole_path_down_to_the_refusing_type() {
let all = wire_rejects(
"import scarlet/map\n\
Expand Down Expand Up @@ -674,7 +673,6 @@ mod unknown_payload {
const WANTED: &str = "the type `wire.decode` produces here is not known; annotate the binding";

#[test]
#[ignore = "waits for the wire redesign: check stopped refusing wire types in #50"]
fn an_unconstrained_decode_is_refused() {
wire_rejects(
"import scarlet/wire\n\
Expand All @@ -698,7 +696,6 @@ mod unknown_payload {
/// elaborated at the generalised signature, where `a` is rigid. That is
/// the distinction from the case above, and it is invisible in the text.
#[test]
#[ignore = "waits for the wire redesign: check stopped refusing wire types in #50"]
fn a_decode_in_a_generic_fn_is_refused() {
wire_rejects(
"import scarlet/wire\n\
Expand Down Expand Up @@ -734,7 +731,7 @@ mod opaque_from_another_module {
/// (which compares `units` and `scale`) and by the declaring module's own
/// reader.
#[test]
#[ignore = "needs the VM"]
#[ignore = "needs the VM: the wire walks are not built yet"]
fn round_trips_with_equality() {
run_outputs(
"import scarlet/decimal\n\
Expand All @@ -757,7 +754,7 @@ mod opaque_from_another_module {
/// The decode half alone, with the payload type fixed only by the
/// declaring module's reader.
#[test]
#[ignore = "needs the VM"]
#[ignore = "needs the VM: the wire walks are not built yet"]
fn decode_is_typed_by_the_declaring_modules_reader() {
run_outputs(
"import scarlet/decimal\n\
Expand Down
4 changes: 0 additions & 4 deletions crates/scarlet/tests/wire_descriptor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@ fn messages(r: &scarlet::bytecode::CompileResult) -> String {
/// The LSP path is `IncrementalSession::check`, which never emits. A refusal
/// has to arrive here or an editor shows a clean file that `al run` rejects.
#[test]
#[ignore = "waits for the wire redesign: check stopped refusing wire types in #50"]
fn a_refusal_is_reported_on_the_session_check_path() {
let mut s = IncrementalSession::new();
let r = s.check(&parse(GENERIC), None);
Expand Down Expand Up @@ -138,7 +137,6 @@ fn a_stdlib_type_reached_only_through_a_field_checks_clean_on_the_session_path()
/// a rewound compile and being minted against a program that no longer has
/// the call site.
#[test]
#[ignore = "waits for the wire redesign: check stopped refusing wire types in #50"]
fn a_session_re_checks_a_wire_call_across_an_edit() {
let mut s = IncrementalSession::new();
for _ in 0..2 {
Expand All @@ -153,7 +151,6 @@ fn a_session_re_checks_a_wire_call_across_an_edit() {
/// nothing to fix its payload is the refusal a REPL user hits first, and it
/// must be a diagnostic there rather than an internal error.
#[test]
#[ignore = "needs the VM"]
fn the_repl_reports_an_unconstrained_decode() {
use std::io::Write;
use std::process::{Command, Stdio};
Expand Down Expand Up @@ -220,7 +217,6 @@ fn a_handle_three_levels_down_checks_clean_on_the_session_path() {
/// because a `Data` node's arguments are walked before its fields, so
/// `Outer(a)` refuses at the argument with no path.
#[test]
#[ignore = "waits for the wire redesign: check stopped refusing wire types in #50"]
fn the_refusal_path_survives_to_the_session_check_path() {
let mut s = IncrementalSession::new();
let r = s.check(
Expand Down
185 changes: 184 additions & 1 deletion crates/scarlet_core/src/bytecode/compiler/bridges.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
//! elaborator never sees the compiler's fields.

use super::*;
use crate::typed_ir::{FieldAt, PreludeTys, VariantLayout};
use crate::core_ir::WireOp;
use crate::typed_ir::{FieldAt, PreludeTys, VariantLayout, wire};
use scarlet_types::types::TypeBody;

impl PreludeTys for Compiler {
/// The one bridge from a live inference `Ty` into the program's `RTy`
Expand Down Expand Up @@ -252,4 +254,185 @@ impl ElabCtx for Compiler {
fn ty_nil(&mut self) -> Ty {
Compiler::ty_nil(self)
}

/// **The type is read here, and here is after the whole module's check
/// walk.** `analyse_module` finishes before any body is elaborated, so a
/// `decode` whose payload is fixed only by a later `match` — which is the
/// ordinary way one is written — is already solved by the time this runs.
/// Asking any earlier describes an unresolved variable and refuses, and
/// the refusal reads as "type is not known" on a program that is perfectly
/// well typed.
fn wire_descriptor(
&mut self,
pool: &mut ResolvedPool,
ty: RTy,
op: WireOp,
at: Span,
) -> Option<WireDescIdx> {
let desc = match wire::build_desc(pool, &mut *self, ty) {
Ok(desc) => desc,
Err(refusal) => {
let msg = refusal.message(&*self, pool, op);
self.error(msg, at);
return None;
}
};
let rt = desc.to_runtime();
// Two call sites at one type describe it twice, and both must reach
// the same entry. Descriptor equality is the right key and the
// fingerprint is NOT: the fingerprint deliberately excludes the type's
// identity, so two different types of one shape share it while naming
// different constructors, and deduping on it would hand one type's
// call site the other's.
if let Some(i) = (&self.wire_descs).into_iter().position(|d| *d == rt) {
return Some(WireDescIdx::from_usize(i));
}
Some(self.wire_descs.push(rt))
}
}

/// The declaration half of the descriptor builder's seam. Everything it
/// answers comes off `env`/`engine`; nothing here decides encodability, which
/// is [`wire`]'s alone.
impl wire::WireCtx for Compiler {
fn nominal(&mut self, pool: &mut ResolvedPool, id: TypeId) -> wire::Nominal {
// The structural primitives answer first. `Int`, `Array`, `Map` and
// the rest are declared with no body, so reading the body would report
// every one of them as uninhabited and write nothing for an `Int`.
if let Some(b) = self.wire_builtin(id) {
return wire::Nominal::Builtin(b);
}
let Some(info) = self.env.lookup_type_info_by_id(id) else {
wire::wire_bug("a type the checker admitted but never registered")
};
match info.body() {
TypeBody::External => match self.wire_handle(id) {
Some(kind) => wire::Nominal::Handle(kind),
None => wire::Nominal::Uninhabited,
},
// A body is attached by the pass that declares it, and an imported
// declaration is exported only after its own body is. Elaboration,
// the only caller, starts once every pass has run, so a body still
// in this state is the checker's bug; describing it as anything
// would write nothing for a value that exists.
TypeBody::Unresolved => wire::wire_bug("a type whose body is not yet hydrated"),
TypeBody::Alias { target } => wire::Nominal::Alias(self.resolve_rty(pool, target)),
TypeBody::Custom { variants, .. } => {
// Copied out of the arena first: interning a field type
// reborrows the engine.
let vs = self.engine.variants_of(variants).to_vec();
let ctors = vs
.iter()
.enumerate()
.map(|(i, v)| {
let declared: Vec<(StrId, Ty)> = self
.engine
.variant_fields_of(v.fields)
.iter()
.map(|f| (f.label, f.ty))
.collect();
let fields = declared
.into_iter()
.map(|(label, t)| (label, self.resolve_rty(pool, t)))
.collect();
wire::CtorDecl::new(
VariantRef {
type_id: id,
// The variant slice's own length is a `u16`,
// so an index into it cannot overflow one.
variant_idx: i as u16,
},
v.name,
fields,
)
})
.collect();
wire::Nominal::Data { ctors }
}
}
}

fn name(&self, s: StrId) -> String {
self.engine.str(s).to_string()
}

fn type_name(&self, id: TypeId) -> String {
match self.env.lookup_type_info_by_id(id) {
Some(info) => self.engine.str(info.name).to_string(),
// Only reachable from a refusal's rendered type, where a name
// nobody registered reads better than an abort on the error path.
None => format!("<type {}>", id.0),
}
}
}

impl Compiler {
/// The six types the wire format writes structurally rather than as a
/// tagged constructor.
///
/// Identity is the prelude's own binding, never a name: a user's
/// `type Map(k, v)` is a different type from `scarlet/map`'s and must be
/// described by its constructors. `Map` is late-bound, so this answers
/// `None` for it until `scarlet/map` has loaded — which is also the first
/// moment a value of it can exist.
fn wire_builtin(&self, id: TypeId) -> Option<wire::Builtin> {
let p = &self.prelude;
if p.int().is(id) {
Some(wire::Builtin::Int)
} else if p.float().is(id) {
Some(wire::Builtin::Float)
} else if p.string().is(id) {
Some(wire::Builtin::String)
} else if p.binary().is(id) {
Some(wire::Builtin::Binary)
} else if p.array().is(id) {
Some(wire::Builtin::Array)
} else if p.map().is(id) {
Some(wire::Builtin::Map)
} else {
None
}
}

/// The five host-backed stdlib types the wire format writes as an
/// identity, or `None` for a bodiless type that is not one of them.
///
/// Identity is the declaring module's own binding, resolved through the
/// module table and never a name: a user's `pub type Pid` is a different
/// type and stays bodiless. `Port` is absent on purpose — it is a record
/// over a `Connection`, and the runtime kind its stream carries is the
/// value's to write (see [`scarlet_ir::wire::HandleKind`]).
fn wire_handle(&mut self, id: TypeId) -> Option<wire::HandleKind> {
const HANDLES: &[(&[&str], &str, wire::HandleKind)] = &[
(&["scarlet", "process"], "Pid", wire::HandleKind::Pid),
(
&["scarlet", "process"],
"Subject",
wire::HandleKind::Subject,
),
(
&["scarlet", "net", "socket"],
"Connection",
wire::HandleKind::Connection,
),
(&["scarlet", "net"], "Server", wire::HandleKind::Listener),
(
&["scarlet", "net", "tls"],
"TlsConnection",
wire::HandleKind::Tls,
),
];
for &(module, name, kind) in HANDLES {
let path: ModulePath = module.iter().map(|s| s.to_string()).collect();
// A module this compile never loaded declares no type a value of
// `id` could have, so a miss here is not a miss on `id`.
let Some(iface) = self.module_table.get(&ModuleKey::of(&path)) else {
continue;
};
if iface.types.get(name).is_some_and(|et| et.info.id == id) {
return Some(kind);
}
}
None
}
}
9 changes: 8 additions & 1 deletion crates/scarlet_core/src/bytecode/compiler/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ use super::{PreludeBindings, TypeRef};
use crate::ast;
use crate::core_ir::{
Abi, Const, ConstId, CoreFn, FuncIdx, HttpTypes, IoErrors, JsonTypes, LoweredFn, Program,
Radix, TypeNames, VariantNames, VariantRef,
Radix, TypeNames, VariantNames, VariantRef, WireDescIdx,
};
use crate::diagnostic::{Diagnostic, DiagnosticCode, has_errors};
use crate::tivec::{Idx, TiVec};
Expand Down Expand Up @@ -407,6 +407,11 @@ pub struct Compiler {
/// constant) so `reset_to`'s pool truncate needs no paired invalidation —
/// a stale entry just misses.
const_dedup: HashMap<Const, ConstId>,
/// Every distinct type descriptor a `wire` call in this compile crosses
/// at, handed to [`Program::wire`] once the program is lowered. Dropped by
/// `reset_to` rather than truncated: a descriptor names a call site, and a
/// rewound compile's call sites are gone.
pub(super) wire_descs: TiVec<WireDescIdx, scarlet_ir::wire::Desc>,
pub(super) locals: HashMap<StrId, LocalSlot>,
/// Scoped-symbol-table undo log. Every mutation of `locals` made inside an
/// open block scope appends `(name, previous entry)`; `pop_local_scope`
Expand Down Expand Up @@ -1061,6 +1066,7 @@ pub(crate) fn new_compiler(base_dir: Option<&Path>, check_only: bool) -> Compile
main: None,
consts: Vec::new(),
const_dedup: HashMap::new(),
wire_descs: TiVec::new(),
locals: HashMap::new(),
undo_log: vec![],
scope_marks: vec![],
Expand Down Expand Up @@ -1385,6 +1391,7 @@ impl Compiler {
globals: self.local_count as u32,
types,
abi,
wire: self.wire_descs.clone(),
})
}

Expand Down
57 changes: 45 additions & 12 deletions crates/scarlet_core/src/bytecode/compiler/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -759,13 +759,20 @@ mod ctor_visibility_survives_on_the_type {
}

/// `scarlet/wire`'s declaration surface: the two `@vm` keys reach the core IR
/// as their intrinsics.
/// as described wire atoms.
mod wire_surface {
use crate::core_ir::Atom;
use crate::core_ir::{Atom, WireDescIdx, WireOp};
use scarlet_types::intrinsic::Intrinsic;

/// A wire call reaches Core IR as [`Atom::Wire`] carrying a descriptor,
/// never as the bare intrinsic: the descriptor IS the call, and a wire
/// atom without one would compile clean and be refusable only at run time.
///
/// Both calls here cross at `Int`, so both must name the *same* entry —
/// the dedup is on the descriptor, and two call sites at one type
/// describing it twice is the ordinary case rather than the exotic one.
#[test]
fn a_wire_call_reaches_the_core_ir_as_its_intrinsic() {
fn a_wire_call_reaches_the_core_ir_with_its_descriptor() {
let r = super::compile_script(
"import scarlet/wire\n\
b = wire.encode(1)\n\
Expand All @@ -780,18 +787,44 @@ mod wire_surface {
r.diagnostics,
);
let program = r.into_runnable().expect("a clean compile is runnable");
let calls: Vec<Intrinsic> = super::atoms(&program.toplevel.core.body)
let intrinsics: Vec<Intrinsic> = super::atoms(&program.toplevel.core.body)
.into_iter()
.filter_map(|a| {
if let Atom::Intrinsic { intrinsic, .. } = a {
Some(*intrinsic)
} else {
None
}
.filter_map(|a| match a {
Atom::Intrinsic { intrinsic, .. } => Some(*intrinsic),
_ => None,
})
.collect();
assert!(
!intrinsics.contains(&Intrinsic::WireEncode)
&& !intrinsics.contains(&Intrinsic::WireDecode),
"a wire call must not lower to a bare intrinsic: {intrinsics:?}"
);

let wires: Vec<(WireOp, WireDescIdx)> = super::atoms(&program.toplevel.core.body)
.into_iter()
.filter_map(|a| match a {
Atom::Wire { op, desc, .. } => Some((*op, *desc)),
_ => None,
})
.collect();
assert!(calls.contains(&Intrinsic::WireEncode), "{calls:?}");
assert!(calls.contains(&Intrinsic::WireDecode), "{calls:?}");
let ops: Vec<WireOp> = wires.iter().map(|(op, _)| *op).collect();
assert!(ops.contains(&WireOp::Encode), "{ops:?}");
assert!(ops.contains(&WireOp::Decode), "{ops:?}");

let descs: Vec<WireDescIdx> = wires.iter().map(|(_, d)| *d).collect();
assert_eq!(
descs.first(),
descs.last(),
"both calls cross at Int, so both name one descriptor: {descs:?}"
);
for d in &descs {
let desc = program.wire.get(*d).expect("the index names a descriptor");
assert_eq!(
desc.node(desc.root()),
Some(&scarlet_ir::wire::Node::Int),
"the descriptor is the type the call crosses at"
);
}
}
}

Expand Down
Loading
Loading