From f5003299436d6881beb110f3c7825a1b79d8cfb6 Mon Sep 17 00:00:00 2001 From: TSUYUSATO Kitsune Date: Sun, 25 Oct 2026 09:04:49 +0900 Subject: [PATCH 1/3] A builtin's own method no route compiles is refused, not a NoMethodError emit_unresolved_call raises NoMethodError for a call no emitter took, as CRuby would for a method that does not exist. A call on a value typed as an Array, a Hash, a String, an Integer, a Float, a Symbol or a Range of a method that class has in CRuby (`h.transform_values` with no block) is no such call: the compiler only has no route for it, and the program raised NoMethodError where CRuby calls the method, with no refusal. Such a call is now refused at compile time, naming the class and the method; under --defer-refusals it raises NotImplementedError where it runs. Only a call the program wrote is refused: an arm a rewrite builds is guarded by a test that may never pass (a class's own arm of desugar_builtin_enum_calls, the arities and block forms of desugar_builtin_splat_forward, desugar_unpack_block's block test), and keeps the NoMethodError it raised before. --- docs/limitations.md | 1 + src/analyze.c | 5 ++++ src/codegen_call.c | 33 ++++++++++++++++++++++++ test/collect/refusals.expected | 6 +++++ test/reject/unresolved_builtin_method.rb | 8 ++++++ 5 files changed, 53 insertions(+) create mode 100644 test/reject/unresolved_builtin_method.rb diff --git a/docs/limitations.md b/docs/limitations.md index bcd28a3f4e..b954c5db6c 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -329,6 +329,7 @@ Limited today, but additively fixable; listed roughly easiest-first. | `StringIO#readpartial` / `#sysread` / `#read_nonblock` with a buffer argument | the data comes back as the result, but the caller's own buffer variable is not changed | the methods are plain Ruby in the stringio package, and a String parameter a package class's method changes is not yet passed by reference the way a user class's is. Through a value that may be a File or a StringIO (`def rd(io) = io.readpartial(n)` called with both) the three methods raise NoMethodError: that receiver takes the built-in IO arms, which don't see a package's plain-Ruby methods | | `IO::Buffer` | the full in-memory API, CRuby-faithful: `new` (INTERNAL/MAPPED flags), `get_value`/`set_value`/`get_values`/`set_values` over all 18 type symbols (little/big-endian, `u64` round-trips Bignums under `--int-overflow=promote`), `get_string`/`set_string` (NUL-safe binary), `resize`/`clear`/`copy`/`size`, `slice` (live views, safe across a source `resize`), `transfer`/`free`/`dup`, `<=>`/`==`, `hexdump`/`inspect`/`to_s`, the predicates, the tiling bitwise family (`&` `\|` `^` `~` and `and!`/`or!`/`xor!`/`not!`), `locked`, `IO::Buffer.for(string)`/.string/.size_of, the CRuby exception classes (`IO::Buffer::AccessError` etc.), and the IO integration: `#read`/`#write`/`#pread`/`#pwrite` against an IO (one syscall each, answering the count, 0 at EOF or -errno; a blocking read on a socket or pipe parks the green thread, and the buffer is locked for the duration) and `IO::Buffer.map(file, size, offset, flags)` as an mmap view (READONLY / SHARED / PRIVATE, default 0: writable and shared, as in CRuby; munmap'd by the finalizer; `resize` refused as for EXTERNAL). Passed to an `ffi_func` pointer argument, a buffer hands C its base address for the call ([FFI.md](FFI.md)). No `require` needed, as in CRuby. A literal type symbol compiles to a direct typed accessor (the wasm-runtime / binary-protocol hot path) | `#read` serves the bytes the IO's own stream already buffered before reading the descriptor (a `getc` followed by a `read` sees the next bytes); CRuby's reads the descriptor directly and can skip what its buffer holds. Three more deliberate divergences: `IO::Buffer.for(string)` copies (Spinel strings are immutable, so unobservable) and its write-through BLOCK form raises `NotImplementedError`; `each`/`each_byte`/`values` are block-form only (no Enumerator, as with StringIO); `get_string`'s third (encoding) argument is not accepted | | The value of `super` in `initialize` (`c = super`, `super.frozen?`, `c = if f then super else [] end`) when the parent's `initialize` is the program's own | refused at compile time, naming the line | an `initialize` is compiled to return nothing, since `new` drops its value; one whose value a subclass's `super` asks for would return its last value instead | +| A call the program writes of a method an Array, a Hash, a String, an Integer, a Float, a Symbol or a Range has, on a value of that type, that the compiler has no route for (`h.transform_values` with no block) | refused at compile time, naming the class and the method; with `--defer-refusals` the call raises `NotImplementedError` where it runs. It used to raise `NoMethodError`, which CRuby does not | each such call is its own missing route, additive | | `Array#hash` (and arrays as hash keys) | unsupported | a builtin is additive, but array *keys* need the fundamental key-dispatch above | | `IO.popen` | refused at compile time, naming it | the bundled `open3` package's `Open3.capture2` / `capture3` (with `stdin_data:`) and `Process.spawn` with pipes cover what it is used for; the method itself is a stream held open over a child, which the open3 package does not model yet | | `File.read` with an options hash other than an encoding (`mode:`, `open_args:`, a transcoding encoding) | refused at compile time, naming it | `encoding:` / `external_encoding:` of UTF-8 reads as the call without it, and of BINARY / ASCII-8BIT as `File.binread`; `File.open` takes a mode | diff --git a/src/analyze.c b/src/analyze.c index a13c1118a4..72adf615ca 100644 --- a/src/analyze.c +++ b/src/analyze.c @@ -53388,8 +53388,13 @@ static void an_phase_nullable_returns(Compiler *c, const NodeTypes *promoted) { free(rc.seen); g_ret_no_new_poly = 0; } +/* the node table's size when the analysis began: the calls the program + wrote are below it, the ones a rewrite made above (an_written_call) */ +static int g_an_src_nodes = -1; +int an_written_call(int id) { return g_an_src_nodes < 0 || id < g_an_src_nodes; } void analyze_program(Compiler *c) { double tm_an = sp_timing_now(); + g_an_src_nodes = c->nt->count; an_note_written_names(c->nt); AN_PHASE("desugar_register", an_phase_desugar_register(c)); AN_PHASE("class_structure", an_phase_class_structure(c)); diff --git a/src/codegen_call.c b/src/codegen_call.c index 43a977bdc9..6870930d3c 100644 --- a/src/codegen_call.c +++ b/src/codegen_call.c @@ -21754,6 +21754,38 @@ int call_misses_self_method(Compiler *c, int id) { g_emitting_class_id >= 0 && comp_method_in_chain(c, g_emitting_class_id, name, NULL) < 0 && an_user_defines_method(c, name); } +/* The builtin class a receiver of type t is an instance of, for the + unresolved-call refusal below, or NULL. */ +static const char *unresolved_builtin_class(TyKind t) { + if (ty_is_array(t)) return "Array"; + if (ty_is_hash(t)) return "Hash"; + if (t == TY_STRING || t == TY_STRBUF) return "String"; + if (t == TY_INT) return "Integer"; + if (t == TY_FLOAT) return "Float"; + if (t == TY_SYMBOL) return "Symbol"; + if (t == TY_RANGE) return "Range"; + return NULL; +} +/* A call on a receiver of a builtin type known here, whose class has the + method (CRuby's own table, builtin_arity.inc), that no emitter took: the + analysis missed a route for it, and the NoMethodError the gate below + would raise is no answer CRuby gives. Refused instead, naming the method; + under --defer-refusals it raises NotImplementedError where it runs. Only + a call the program wrote: an arm a rewrite builds is guarded by a test + that may never pass here (desugar_builtin_enum_calls' `is_a?(K) ? + recv.m : ...` for a class of the program's own, the arities and block + forms of desugar_builtin_splat_forward, desugar_unpack_block's block + test), and is left to the gate. */ +int an_written_call(int id); +static void unresolved_builtin_refusal(Compiler *c, int id, int recv, const char *nm, TyKind t) { + if (recv < 0 || !nm || !an_written_call(id)) return; + const char *cls = unresolved_builtin_class(t); + if (!cls || !builtin_method_known(cls, nm)) return; + char msg[256]; + snprintf(msg, sizeof msg, "%s#%s on a value typed as %s, a call no route of the compiler took " + "(CRuby calls the method; this would raise NoMethodError)", cls, nm, cls); + unsupported(c, id, msg); +} int emit_unresolved_call(Compiler *c, int id, Buf *b) { const NodeTable *nt = c->nt; const char *name = nt_str(nt, id, "name"); @@ -22439,6 +22471,7 @@ if (recv >= 0 && nm && (grt == TY_RATIONAL || grt == TY_COMPLEX)) { unsupported(c, id, "a method of a module included or prepended into Rational or Complex " "(define it in the class body instead)"); } + unresolved_builtin_refusal(c, id, recv, nm, grt); if (warn_unresolved_pos(c, id)) { fprintf(stderr, "unresolved call '%s' on %s receiver -> %s\n", nm ? nm : "?", ty_name(grt), diff --git a/test/collect/refusals.expected b/test/collect/refusals.expected index 02d63552a6..7d13d3ed25 100644 --- a/test/collect/refusals.expected +++ b/test/collect/refusals.expected @@ -1778,6 +1778,12 @@ spinel: 1 refusal, nothing written == test/reject/typed_array_kept_by_struct_into_mutated_param.rb [promote] rc=1 spinel: test/reject/typed_array_kept_by_struct_into_mutated_param.rb:10: an Array[Integer] is passed to `add`'s parameter `out`, which the method mutates: the parameter is a general Array and the argument would be copied into it, so the mutation would not reach the caller's array. Give the parameter the argument's kind (an rbs seed, or callers that all pass Array[Integer]), or build the argument as a general Array. spinel: 1 refusal, nothing written +== test/reject/unresolved_builtin_method.rb [default] rc=1 +spinel: test/reject/unresolved_builtin_method.rb:7: unsupported Hash#transform_values on a value typed as Hash, a call no route of the compiler took (CRuby calls the method; this would raise NoMethodError): node N (CallNode `transform_values`) recv=LocalVariableReadNode/ty28 argc=0 +spinel: 1 refusal, nothing written +== test/reject/unresolved_builtin_method.rb [promote] rc=1 +spinel: test/reject/unresolved_builtin_method.rb:7: unsupported Hash#transform_values on a value typed as Hash, a call no route of the compiler took (CRuby calls the method; this would raise NoMethodError): node N (CallNode `transform_values`) recv=LocalVariableReadNode/ty28 argc=0 +spinel: 1 refusal, nothing written == test/reject/unsupported_node_kind.rb [default] rc=1 spinel: test/reject/unsupported_node_kind.rb:5: unsupported condition (non-bool): FlipFlopNode at test/reject/unsupported_node_kind.rb:5:8 spinel: test/reject/unsupported_node_kind.rb:10: unsupported condition (non-bool): MatchLastLineNode at test/reject/unsupported_node_kind.rb:10:8 diff --git a/test/reject/unresolved_builtin_method.rb b/test/reject/unresolved_builtin_method.rb new file mode 100644 index 0000000000..05d6e1741c --- /dev/null +++ b/test/reject/unresolved_builtin_method.rb @@ -0,0 +1,8 @@ +# spinel: reject-subclass: Hash#transform_values on a value typed as Hash, a call no route of the compiler took +# A call of a method a Hash has, on a value typed as a Hash, that no route +# of the compiler takes (here the blockless form, which answers an +# Enumerator in CRuby): refused at compile time, naming the method, rather +# than raising NoMethodError for a method CRuby calls. +h = { a: 1, b: 2 } +e = h.transform_values +p e.class From 6bac5bf99e5c81a3cd5d143c90270c408453ee49 Mon Sep 17 00:00:00 2001 From: TSUYUSATO Kitsune Date: Sun, 25 Oct 2026 09:56:26 +0900 Subject: [PATCH 2/3] A reject test for `&:m` on an Array the analysis types late `none_yet.drop_while(&:label)`, on the answer of a method that returns a local assigned only `[]`: no route of the compiler takes the call, and it is refused, naming Array#drop_while, where it raised NoMethodError. --- test/collect/refusals.expected | 6 ++++++ .../unresolved_builtin_method_sym_block.rb | 17 +++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 test/reject/unresolved_builtin_method_sym_block.rb diff --git a/test/collect/refusals.expected b/test/collect/refusals.expected index 7d13d3ed25..903ce091ff 100644 --- a/test/collect/refusals.expected +++ b/test/collect/refusals.expected @@ -1784,6 +1784,12 @@ spinel: 1 refusal, nothing written == test/reject/unresolved_builtin_method.rb [promote] rc=1 spinel: test/reject/unresolved_builtin_method.rb:7: unsupported Hash#transform_values on a value typed as Hash, a call no route of the compiler took (CRuby calls the method; this would raise NoMethodError): node N (CallNode `transform_values`) recv=LocalVariableReadNode/ty28 argc=0 spinel: 1 refusal, nothing written +== test/reject/unresolved_builtin_method_sym_block.rb [default] rc=1 +spinel: test/reject/unresolved_builtin_method_sym_block.rb:17: unsupported Array#drop_while on a value typed as Array, a call no route of the compiler took (CRuby calls the method; this would raise NoMethodError): node N (CallNode `drop_while`) recv=CallNode/ty22 argc=0 +spinel: 1 refusal, nothing written +== test/reject/unresolved_builtin_method_sym_block.rb [promote] rc=1 +spinel: test/reject/unresolved_builtin_method_sym_block.rb:17: unsupported Array#drop_while on a value typed as Array, a call no route of the compiler took (CRuby calls the method; this would raise NoMethodError): node N (CallNode `drop_while`) recv=CallNode/ty22 argc=0 +spinel: 1 refusal, nothing written == test/reject/unsupported_node_kind.rb [default] rc=1 spinel: test/reject/unsupported_node_kind.rb:5: unsupported condition (non-bool): FlipFlopNode at test/reject/unsupported_node_kind.rb:5:8 spinel: test/reject/unsupported_node_kind.rb:10: unsupported condition (non-bool): MatchLastLineNode at test/reject/unsupported_node_kind.rb:10:8 diff --git a/test/reject/unresolved_builtin_method_sym_block.rb b/test/reject/unresolved_builtin_method_sym_block.rb new file mode 100644 index 0000000000..76d6133319 --- /dev/null +++ b/test/reject/unresolved_builtin_method_sym_block.rb @@ -0,0 +1,17 @@ +# spinel: reject-subclass: Array#drop_while on a value typed as Array, a call no route of the compiler took +# An Array's own method given `&:name`, on the answer of a method that +# returns a local assigned only `[]` (an Array the analysis types only +# after its main pass): no route of the compiler takes the call, so it is +# refused at compile time, naming the method, rather than raising +# NoMethodError for a method CRuby calls. +class Item + def initialize(s) = @s = s + def label = @s +end + +def none_yet + out = [] + out +end + +p none_yet.drop_while(&:label) From fcba9d2a41810dbd7bcba41261e7444a1ce51dd0 Mon Sep 17 00:00:00 2001 From: TSUYUSATO Kitsune Date: Sun, 25 Oct 2026 10:12:00 +0900 Subject: [PATCH 3/3] The builtin-method refusal comes after the refusals of particular shapes unresolved_builtin_refusal ran ahead of the lazy-chain refusal in emit_unresolved_call. `unsupported` does not return, so a call both would refuse would have been named only as a method no route takes, losing the lazy-chain refusal's words on what to write instead. It now runs after every refusal of a particular shape there, just before the NoMethodError gate. No refusal's words change: the program-written `lazy` calls of test/reject reach the lazy-chain refusal either way. --- src/codegen_call.c | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/codegen_call.c b/src/codegen_call.c index 6870930d3c..5090b4fc19 100644 --- a/src/codegen_call.c +++ b/src/codegen_call.c @@ -22471,7 +22471,6 @@ if (recv >= 0 && nm && (grt == TY_RATIONAL || grt == TY_COMPLEX)) { unsupported(c, id, "a method of a module included or prepended into Rational or Complex " "(define it in the class body instead)"); } - unresolved_builtin_refusal(c, id, recv, nm, grt); if (warn_unresolved_pos(c, id)) { fprintf(stderr, "unresolved call '%s' on %s receiver -> %s\n", nm ? nm : "?", ty_name(grt), @@ -22498,6 +22497,9 @@ if (recv >= 0 && nm && (grt == TY_RATIONAL || grt == TY_COMPLEX)) { unsupported(c, id, "a lazy chain held in an instance, class or global variable or a " "constant (an Enumerator::Lazy is fused into the call that forces it; " "keep the chain in a local or force it where it is built)"); + /* after the refusals of particular shapes above, whose words say what + to do instead: this one only names the method */ + unresolved_builtin_refusal(c, id, recv, nm, grt); if (g_gate_raise) { /* Scalar slot: the comma-expr yields a typed default the surrounding C accepts directly. Poly slot: emit the recognizable sp_raise_nomethod