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
9 changes: 9 additions & 0 deletions changelog.d/11461-array-mapping-result-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Stop inferring the input element type for Array.map and Array.flatMap results.
Their callbacks can return entirely different values; inheriting string[] from
the receiver sent class methods such as node-cron's task.match() through the
String.match fast path and returned null instead of calling the task method.

Use an unknown element type in the early callee-only inference table and retain
the existing callback-aware HIR refinement. Add lowering regressions and native
parity coverage for mapped class instances, flatMap, and ordinary string and
numeric results.
10 changes: 8 additions & 2 deletions crates/perry-hir/src/lower_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1497,8 +1497,13 @@ pub(crate) fn infer_call_return_type(callee: &ast::Expr, ctx: &LoweringContext)
"join" => Type::String,
"includes" | "every" | "some" => Type::Boolean,
"pop" | "shift" | "find" | "at" => *elem_ty.clone(),
"map" | "filter" | "slice" | "concat" | "flat" | "flatMap" | "reverse"
| "sort" | "splice" => obj_ty.clone(),
// Mapping callbacks can replace every element (#11446).
// This early table sees only the callee, so defer the
// result element type to callback-aware HIR inference.
"map" | "flatMap" => Type::Array(Box::new(Type::Any)),
"filter" | "slice" | "concat" | "flat" | "reverse" | "sort" | "splice" => {
obj_ty.clone()
}
"reduce" => Type::Any, // depends on accumulator
"fill" => obj_ty.clone(),
"forEach" => Type::Void,
Expand Down Expand Up @@ -1638,6 +1643,7 @@ pub(crate) fn infer_call_return_type(callee: &ast::Expr, ctx: &LoweringContext)
}
}

mod array_mapping_tests;
mod branded_intersection_tests;
mod buffer_backed_generic_tests;
mod extract;
Expand Down
85 changes: 85 additions & 0 deletions crates/perry-hir/src/lower_types/array_mapping_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
//! #11446: mapping callbacks can change an array's element type.
#![cfg(test)]

use crate::types::Type;
use crate::{infer_expr_type, HirTypeEnv};
use crate::{lower_module, Expr, Module, Stmt};
use perry_diagnostics::SourceCache;
use perry_parser::parse_typescript_with_cache;

fn lower(src: String) -> Module {
std::thread::Builder::new()
.stack_size(32 * 1024 * 1024)
.spawn(move || {
let mut cache = SourceCache::new();
let parsed = parse_typescript_with_cache(&src, "test.ts", &mut cache).unwrap();
lower_module(&parsed.module, "test", "test.ts").unwrap()
})
.unwrap()
.join()
.unwrap()
}

fn binding<'a>(module: &'a Module, name: &str) -> (&'a Type, &'a Expr) {
module
.init
.iter()
.find_map(|stmt| match stmt {
Stmt::Let {
name: n,
ty,
init: Some(init),
..
} if n == name => Some((ty, init)),
_ => None,
})
.unwrap_or_else(|| panic!("missing {name}"))
}

#[test]
fn mapping_does_not_reuse_the_input_element_type() {
for input in ["['x']", "[1]", "new Array<string>(1)"] {
for method in ["map", "flatMap"] {
let module = lower(format!(
"function make(value: any): any {{ return {{ match() {{ return true; }} }}; }}\n\
const input = {input}; const output = input.{method}(value => make(value));"
));
assert_eq!(
binding(&module, "output").0,
&Type::Array(Box::new(Type::Any)),
"{input}.{method} must not inherit the receiver's element type"
);
}
}
}

#[test]
fn element_preserving_methods_keep_the_input_type() {
let module = lower(
"const input = ['x']; const output = input.filter(value => true).slice().reverse();".into(),
);
assert_eq!(
binding(&module, "output").0,
&Type::Array(Box::new(Type::String))
);
}

#[test]
fn lowered_callbacks_still_supply_precise_mapping_types() {
let module = lower(
"function lengthOf(value: string): number { return value.length; }\n\
function lengthsOf(value: string): number[] { return [value.length]; }\n\
const input = ['x'];\n\
const mapped = input.map(lengthOf);\n\
const flattened = input.flatMap(lengthsOf);"
.into(),
);
for name in ["mapped", "flattened"] {
assert_eq!(
infer_expr_type(binding(&module, name).1, &HirTypeEnv::from_module(&module)),
Type::Array(Box::new(Type::Number)),
"{name} retains the lowered callback's return type: {:?}",
binding(&module, name).1
);
}
}
24 changes: 24 additions & 0 deletions test-files/test_gap_11446_array_mapping_result_types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// #11446: map/flatMap results have their callbacks' element types.
class Matcher11446 {
value: string;
constructor(value: string) { this.value = value; }
match(value: string): boolean { return this.value === value; }
}
function makeMatcher11446(value: string): any {
return new Matcher11446(value);
}

const source11446 = ['yes', 'no'];
const mapped11446 = source11446.map(value => makeMatcher11446(value));
const flat11446 = source11446.flatMap(value => [makeMatcher11446(value)]);
console.log('direct', makeMatcher11446('yes').match('yes'));
console.log('map', mapped11446[0].match('yes'), mapped11446[1].match('yes'));
console.log('flatMap', flat11446[0].match('yes'), flat11446[1].match('yes'));
console.log('chain', mapped11446.filter(value => true).slice()[0].match('yes'));

const lengths11446 = source11446.map((value: string): number => value.length);
const flatLengths11446 = source11446.flatMap((value: string): number[] => [value.length]);
console.log('numbers', lengths11446[0] + lengths11446[1], flatLengths11446.join(','));
const strings11446 = [1, 2].map(value => 'word' + value);
console.log('strings', strings11446[0].toUpperCase(), strings11446[1].match(/word/)?.[0]);
console.log('preserved', source11446.filter(value => true).slice().reverse().join(','));
Loading