diff --git a/src/sema/driver/compile/tests/mod.rs b/src/sema/driver/compile/tests/mod.rs index db2cfee6558a..adeac367b38a 100644 --- a/src/sema/driver/compile/tests/mod.rs +++ b/src/sema/driver/compile/tests/mod.rs @@ -13,6 +13,7 @@ mod left_out_bodies; mod listed_members; mod load; mod load_task; +mod noted_members; mod queue; mod relation_budget; mod roles; diff --git a/src/sema/driver/compile/tests/noted_members.rs b/src/sema/driver/compile/tests/noted_members.rs new file mode 100644 index 000000000000..94c2b4a4db23 --- /dev/null +++ b/src/sema/driver/compile/tests/noted_members.rs @@ -0,0 +1,171 @@ +use super::support::{check, with_program}; +use crate::typed_files::module_types; +use std::sync::Mutex; + +fn noted_members(files: &[(&str, &str)]) -> Vec<(String, String)> { + with_program(files, &["./main.ts"], 1, |program| { + let main = program.file("main.ts"); + let noted = Mutex::new(Vec::new()); + check(program, &|checker, file| { + if file != main { + return; + } + let types = module_types(checker, file).unwrap(); + let name_at = |key: u32| { + let rest = &types.text[key as usize..]; + let is_in_name = |byte: &&u8| byte.is_ascii_alphanumeric() || **byte == b'#'; + let length = rest.iter().take_while(is_in_name).count(); + String::from_utf8(rest[..length].to_vec()).unwrap() + }; + let members = types.members.iter(); + *noted.lock().unwrap() = members + .map(|(key, text)| (name_at(*key), String::from_utf8(text.to_vec()).unwrap())) + .collect(); + }); + noted.into_inner().unwrap() + }) +} + +const MEMBERS: &str = "type Mode = 'fast' | 'slow'; +enum Color { Red, Blue } +interface Named { name: string } +declare const on: boolean; +declare const flag: boolean; +declare const words: string | null; +declare const yes: true | null; +declare const minus: -1 | false; +declare const big: 10n | -2n; +declare const far: 1e999 | -1e999; +declare const mode: Mode; +declare const anything: any; +declare const color: Color; +declare const shade: Color.Red; +declare const named: Named; +declare const list: string[]; +export class Noted { + toggled; + maybe; + words; + yes; + minus; + big; + far; + mode; + loose; + color; + shade; + named; + list; + optional?; + static count; + #hidden; + declare ambient; + declared: number; + annotated: string = 'a'; + valued = 1; + get size() { + return 1; + } + static { + if (on) this.count = 1; + } + constructor() { + this.toggled = on; + if (on) this.maybe = flag; + this.words = words; + if (on) this.yes = yes; + this.minus = minus; + this.big = big; + if (on) this.far = far; + if (on) this.mode = mode; + this.loose = anything; + this.color = color; + this.shade = shade; + this.named = named; + this.list = list; + if (on) this.optional = 'a'; + this.#hidden = 1; + this.ambient = 1; + this.declared = 1; + } +} +"; + +fn assert_noted(files: &[(&str, &str)], expected: &[(&str, &str)]) { + let noted = noted_members(files); + let noted: Vec<(&str, &str)> = noted.iter().map(|it| (&it.0[..], &it.1[..])).collect(); + assert_eq!(noted, expected); +} + +#[test] +fn a_member_with_no_annotation_and_no_value_is_noted_with_a_type_that_names_nothing() { + let expected = [ + ("toggled", "boolean"), + ("maybe", "boolean | undefined"), + ("words", "string | null"), + ("yes", "true | null | undefined"), + ("minus", "-1 | false"), + ("big", "-2n | 10n"), + ("far", "number | undefined"), + ("mode", "\"fast\" | \"slow\" | undefined"), + ("loose", "any"), + ("optional", "string | undefined"), + ("count", "number | undefined"), + ("#hidden", "number"), + ]; + assert_noted(&[("main.ts", MEMBERS)], &expected); +} + +const OPTIONAL: &str = "export class Holder { + first; + label?; + maybe?; + constructor(items: string[], on: boolean) { + this.first = items[0]; + this.label = 'a'; + this.maybe = on ? 'a' : undefined; + } +} +"; + +fn options(more: &str) -> String { + format!(r#"{{ "compilerOptions": {{ "strict": true, "noLib": true, "types": []{more} }} }}"#) +} + +#[test] +fn an_index_that_may_have_no_entry_is_undefined_in_a_note() { + let unchecked = options(r#", "noUncheckedIndexedAccess": true"#); + let files = [("main.ts", OPTIONAL), ("tsconfig.json", &unchecked[..])]; + let expected = [ + ("first", "string | undefined"), + ("label", "string | undefined"), + ("maybe", "string | undefined"), + ]; + assert_noted(&files, &expected); +} + +#[test] +fn the_question_mark_says_that_a_member_can_be_absent_under_exact_optional_property_types() { + let exact = + options(r#", "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true"#); + let files = [("main.ts", OPTIONAL), ("tsconfig.json", &exact[..])]; + let expected = [ + ("first", "string | undefined"), + ("label", "string"), + ("maybe", "string | undefined"), + ]; + assert_noted(&files, &expected); +} + +const WITHOUT_NO_IMPLICIT_ANY: &str = r#"{ + "compilerOptions": { "strict": true, "noImplicitAny": false, "noLib": true, "types": [] } +}"#; + +#[test] +fn without_no_implicit_any_a_member_is_any_and_is_not_noted() { + let files = [ + ("main.ts", MEMBERS), + ("tsconfig.json", WITHOUT_NO_IMPLICIT_ANY), + ]; + assert_noted(&files, &[]); +} diff --git a/src/sema/driver/typed_files.rs b/src/sema/driver/typed_files.rs index c83a7fb963e7..3c7aba488a44 100644 --- a/src/sema/driver/typed_files.rs +++ b/src/sema/driver/typed_files.rs @@ -3,8 +3,8 @@ use bun_ast::op::Level; use bun_sema::atom::Interner; use bun_sema::check::Checker; use bun_sema::check::services::{ - Child, NodeRef, Services, SymbolFlags, SymbolOp, SymbolRef, SymbolTable, TypeFlags, - TypeFormatFlags, + Child, LiteralValue, NodeRef, Services, SymbolFlags, SymbolOp, SymbolRef, SymbolTable, + TypeFlags, TypeFormatFlags, }; use bun_sema::check::spans::{compute_ecma_line_starts, skip_trivia, skip_trivia_back, token_end}; use bun_sema::config::find_config_of_package; @@ -479,6 +479,18 @@ impl<'a, 's> Syntax<'a, 's> { false } + /// Where the decorators in front of a member or a parameter end. The bundle calls them: a typed file states none. + fn decorators_end(&self, modifiers: &[Modifier]) -> Option { + let is_decorator = + |modifier: &&Modifier| matches!(modifier.kind, ModifierKind::Decorator(_)); + let last = modifiers.iter().take_while(is_decorator).last()?; + Some(self.modifier_end(last)) + } + + fn after_decorators(&self, modifiers: &[Modifier], start: u32) -> u32 { + (self.decorators_end(modifiers)).map_or(start, |end| self.after_trivia(end)) + } + fn member_key(&self, member: &Member) -> u32 { if self.byte(member.name_pos) != b'[' { return member.name_pos; @@ -499,15 +511,7 @@ impl<'a, 's> Syntax<'a, 's> { } let key = self.member_key(member); let modifiers = self.file.modifier_list(member.modifiers); - let is_decorator = - |modifier: &&Modifier| matches!(modifier.kind, ModifierKind::Decorator(_)); - let first = modifiers - .iter() - .take_while(is_decorator) - .last() - .map_or(member.start, |last| { - self.after_trivia(self.modifier_end(last)) - }); + let first = self.after_decorators(modifiers, member.start); let span_of = |flags: Flags| { let mut listed = modifiers.iter().filter(|modifier| { flags.contains(modifier.kind.flag()) && !modifier.kind.flag().is_empty() @@ -1317,6 +1321,8 @@ enum Order { Open(u32), Close(u32), Slot, + /// A line before the `}` of a class. Where no `;` ends the last member, its type and its casts go there too. + ClassEnd, } struct Insertions { @@ -1441,6 +1447,30 @@ impl Written<'_, '_> { self.text(start, end).trim_ascii().to_vec() } + /// A member or a parameter up to `end` as it stands behind `declare`: with no decorator, and with no `override`. + fn declared(&self, modifiers: &[Modifier], start: u32, end: u32) -> Vec { + let syntax = &self.syntax; + let start = syntax.after_decorators(modifiers, start); + let is_override = |modifier: &&Modifier| modifier.kind.flag() == Flags::OVERRIDE; + let Some(written) = modifiers.iter().find(is_override) else { + return self.text(start, end); + }; + let behind = syntax.after_trivia(syntax.token_end(written.pos)); + [self.text(start, written.pos), self.text(behind, end)].concat() + } + + /// A member with no annotation up to its name or `?`, and then the type that the check of the sources noted. + fn noted_member(&self, member: &Member) -> Option> { + let syntax = &self.syntax; + let types = self.types.filter(|_| !self.is_unchecked())?; + let noted = types.member_type(syntax.member_key(member))?; + let mut slots = Vec::new(); + syntax.member_slots(member, &mut slots); + let modifiers = syntax.file.modifier_list(member.modifiers); + let name = self.declared(modifiers, member.start, slots.last()?.end); + Some([&name[..], b": ", noted].concat()) + } + fn types_of_namespace(&self, id: StmtId, dropped_modifiers: Flags) -> Option> { let (syntax, file) = (&self.syntax, self.syntax.file); let StmtKind::Module(declared) = file[id].kind else { @@ -3075,11 +3105,22 @@ impl<'a> Transfer<'_, 'a, '_> { { insertions.add(slot.start, Order::Slot, b": undefined".to_vec()); } + if slot.place == Place::MemberType + && let Some(noted) = module.types.and_then(|types| types.member_type(key)) + { + insertions.add(slot.start, Order::Slot, [&b": "[..], noted].concat()); + } continue; }; let text = module.trimmed_text(found.start, found.end); let is_computed = module.syntax.is_in_computed_key(found.key); let (order, text) = match slot.place { + Place::MemberType if text == b"?" => { + match module.types.and_then(|types| types.member_type(key)) { + Some(noted) => (Order::Slot, [&b"?: "[..], noted].concat()), + None => (Order::Slot, text), + } + } Place::MemberType if is_computed && text.starts_with(b":") @@ -3917,29 +3958,35 @@ impl<'a> Transfer<'_, 'a, '_> { match next { Some(next) => insertions.add_line_before(javascript, next, text), None if is_terminated => { - insertions.add_line_before(javascript, close, text) + insertions.add_line_in_order(javascript, close, Order::ClassEnd, text) } None => { - insertions.add_line_before(javascript, close, &[b";", text].concat()) + let line = [b";", text].concat(); + insertions.add_line_in_order(javascript, close, Order::ClassEnd, &line) } } }; for (place, member) in members.iter().enumerate() { if syntax.is_type_only_member(member) { - add(place, &module.text(member.start, member.loc.end)); + let modifiers = file.modifier_list(member.modifiers); + let text = match syntax.decorators_end(modifiers) { + Some(end) => module.trimmed_text(end, member.loc.end), + None => module.text(member.start, member.loc.end), + }; + add(place, &text); } else if member.kind == MemberKind::Property && !output_members.contains_key(&(index as u32, syntax.member_key(member))) - && member.ty.is_some() { - add( - place, - &[ - &b"declare "[..], - &module.text(member.start, syntax.type_span(member.ty).1), - b";", - ] - .concat(), - ); + let typed = if member.ty.is_some() { + let modifiers = file.modifier_list(member.modifiers); + let end = syntax.type_span(member.ty).1; + Some(module.declared(modifiers, member.start, end)) + } else { + module.noted_member(member) + }; + if let Some(typed) = typed { + add(place, &[&b"declare "[..], &typed, b";"].concat()); + } } let Some(constructor) = member .func @@ -3948,12 +3995,12 @@ impl<'a> Transfer<'_, 'a, '_> { else { continue; }; - for parameter in file[constructor] + for id in file[constructor] .params .iter() - .map(|parameter| &file[parameter]) - .filter(|parameter| parameter.flags.contains(Flags::PARAMETER_PROPERTY)) + .filter(|&id| file[id].flags.contains(Flags::PARAMETER_PROPERTY)) { + let parameter = &file[id]; let Some(binding) = syntax.binding_slot( parameter.pat, parameter.ty, @@ -3962,15 +4009,9 @@ impl<'a> Transfer<'_, 'a, '_> { continue; }; if !output_members.contains_key(&(index as u32, binding.key)) { - add( - 0, - &[ - &b"declare "[..], - &module.text(parameter.pos, binding.end), - b";", - ] - .concat(), - ); + let modifiers = file.modifier_list(file.param_modifiers(id)); + let typed = module.declared(modifiers, parameter.pos, binding.end); + add(0, &[&b"declare "[..], &typed, b";"].concat()); } } } @@ -4120,6 +4161,8 @@ pub struct ModuleTypes { pub exports: Vec<(Box<[u8]>, Target)>, pub constants: Vec<(u32, Box<[u8]>)>, pub functions_returning_undefined: Vec, + /// The type of each class member with no annotation and no value, if it names nothing. + pub members: Vec<(u32, Box<[u8]>)>, pub globals: Vec, pub augmented_modules: Vec<(u32, Box<[u8]>)>, pub locals: Vec<(u32, u32)>, @@ -4127,6 +4170,13 @@ pub struct ModuleTypes { } impl ModuleTypes { + fn member_type(&self, key: u32) -> Option<&[u8]> { + self.members + .iter() + .find(|member| member.0 == key) + .map(|member| &member.1[..]) + } + fn constant_type(&self, binding: u32) -> Option<&[u8]> { self.constants .iter() @@ -4234,6 +4284,60 @@ fn target_of_declaration( }) } +/// The text of `ty` where it names nothing: a keyword, or a literal that is no member of an enum. +fn text_that_names_nothing(services: &mut Services<'_, '_, '_>, ty: TypeId) -> Option> { + if services.type_flags(ty).contains(TypeFlags::ENUM_LITERAL) { + return None; + } + Some(match services.intrinsic_name(ty) { + // No type that a typed file can write: a name that does not resolve, and the `intrinsic` of a library. + Some("error" | "intrinsic") => return None, + // `boolean` also where the source has an alias of `true | false`, which the printer names. + Some(keyword) => keyword.as_bytes().into(), + // `Infinity` is a value and no type. + None => match services.literal_value(ty)? { + LiteralValue::Number(value) if !value.is_finite() => b"number"[..].into(), + _ => { + let format = TypeFormatFlags::DEFAULT | TypeFormatFlags::NO_TRUNCATION; + services.type_to_string(ty, None, format).into() + } + }, + }) +} + +/// [`text_that_names_nothing`], and for a union the text of each of its types: the printer names an alias of a union. +fn text_of_types_that_name_nothing( + services: &mut Services<'_, '_, '_>, + ty: TypeId, + drops_missing: bool, +) -> Option> { + if !services.type_flags(ty).contains(TypeFlags::UNION) { + return text_that_names_nothing(services, ty); + } + let mut texts: Vec> = Vec::new(); + let parts = services.constituents(ty).iter(); + for &part in parts.filter(|&&part| !(drops_missing && part == TypeId::MISSING)) { + let text = text_that_names_nothing(services, part)?; + if !texts.contains(&text) { + texts.push(text); + } + } + let place_of = + |texts: &[Box<[u8]>], wanted: &[u8]| texts.iter().position(|text| &text[..] == wanted); + // In the order of the printer: `boolean` for `false | true`, and `null` and `undefined` last. + if let (Some(no), Some(yes)) = (place_of(&texts, b"false"), place_of(&texts, b"true")) { + texts[no.min(yes)] = b"boolean"[..].into(); + texts.remove(no.max(yes)); + } + for last in [&b"null"[..], b"undefined"] { + if let Some(at) = place_of(&texts, last) { + let text = texts.remove(at); + texts.push(text); + } + } + Some(texts.join(&b" | "[..]).into()) +} + pub fn module_types(checker: &mut Checker<'_, '_>, file: FileId) -> Option { if !is_project_source(checker, file) { return None; @@ -4393,23 +4497,10 @@ pub fn module_types(checker: &mut Checker<'_, '_>, file: FileId) -> Option, file: FileId) -> Option, file: FileId) -> Option { + using dir = tempDir("aot-library-typed-file-member-without-store", { + ...library, + "library/src/index.ts": library["library/src/index.ts"] + `export { Ruler } from "./ruler";\n`, + "library/src/ruler.ts": `declare const __DEV__: boolean; +export type Unit = "cm" | "in"; +export class Ruler { + unit; + constructor() { + if (__DEV__) this.unit = "cm" as Unit; + } +} +`, + "app/app.ts": `import { Ruler } from "shapes"; +const wrong: number = new Ruler().unit; +console.log(wrong); +`, + }); + await install(String(dir), ["--define", "__DEV__=false"]); + expect(await compile(String(dir))).toMatchObject({ + said: ["error: TS2322: Type 'string | undefined' is not assignable to type 'number'."], + exitCode: 1, + }); + const typed = readFileSync(join(String(dir), "app/node_modules/shapes/index.aot.ts"), "utf8"); + expect(typed).toContain(' unit: "cm" | "in" | undefined;\n'); + }); + + // The bundle calls the decorator behind the class. A decorator that index.aot.ts states too runs a second time. + // The comment between a decorator and its member stays in index.aot.ts: here it is a directive. + test("a decorator of a member that has no JavaScript runs once in a program", async () => { + const options = { ...JSON.parse(tsconfig).compilerOptions, experimentalDecorators: true }; + using dir = tempDir("aot-library-typed-file-decorated-members", { + ...library, + "library/tsconfig.json": JSON.stringify({ compilerOptions: options }), + "library/src/index.ts": library["library/src/index.ts"] + `export { seen, Row } from "./row";\n`, + // The helper of the bundle reads Reflect.decorate. A package that imports `reflect-metadata` has it declared. + "library/src/row.ts": `declare global { + namespace Reflect { + function decorate(decorators: unknown[], target: object, key?: string | symbol, desc?: PropertyDescriptor | null): any; + } +} +export const seen: string[] = []; +function column(target: object, key: string): void { + seen.push(key); +} +export class Base { + id: string = ""; +} +export abstract class Row extends Base { + @column + // @ts-expect-error The table has numbers. + declare id: number; + @column abstract name: string; +} +`, + "app/app.ts": `import { seen } from "shapes"; +console.log(seen.join()); +`, + }); + await install(String(dir)); + // The program is compiled from app.ts and index.aot.ts. + expect(await compile(String(dir))).toEqual({ ...USED, typedFiles: 2, stdout: "id,name\n" }); + }); + test("does not read index.js, so a change to it reaches plain Bun alone", async () => { using dir = tempDir("aot-library-typed-file-patched", library); await install(String(dir)); diff --git a/test/bundler/bun-build-api-dts.test.ts b/test/bundler/bun-build-api-dts.test.ts index 2a666f16cf3e..552428e4f08d 100644 --- a/test/bundler/bun-build-api-dts.test.ts +++ b/test/bundler/bun-build-api-dts.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { readdirSync, readFileSync } from "fs"; +import { existsSync, readdirSync, readFileSync } from "fs"; import { tempDir } from "harness"; import { join } from "path"; @@ -152,6 +152,80 @@ export { join }; expect(filesIn(join(root, "out"))).toContain("index.aot.ts"); }); + test.each([ + ["to outdir", true], + ["to outputs", false], + ])("a class member whose stores the build removes keeps the type of the sources, written %s", async (_, toOutdir) => { + using dir = tempDir("build-api-dts-members", { + "tsconfig.json": project["tsconfig.json"], + "src/index.ts": `declare const __DEV__: boolean; +export class Settings { + mode; + static count; + static { + if (__DEV__) this.count = 1; + } + constructor() { + if (__DEV__) this.mode = "dev"; + } +} +`, + }); + const root = String(dir); + const built = await Bun.build({ + entrypoints: [join(root, "src/index.ts")], + outdir: toOutdir ? join(root, "out") : undefined, + tsconfig: join(root, "tsconfig.json"), + define: { __DEV__: "false" }, + dts: true, + }); + expect(built.logs).toEqual([]); + const typed = await built.outputs.find(output => output.path.endsWith("index.aot.ts"))?.text(); + expect(typed).toContain("class Settings {\n mode: string | undefined;\n static count: number | undefined;\n"); + expect(existsSync(join(root, "out/index.aot.ts"))).toBe(toOutdir); + }); + + // A constant that only a type names is not in the bundle. The typed file declares it with what the type checker says. + test("a constant that only a type names is declared with a type that names nothing", async () => { + using dir = tempDir("build-api-dts-constants", { + "tsconfig.json": project["tsconfig.json"], + "src/flag.ts": `type Flag = true | false; +declare const stored: Flag; +export const on = stored; +`, + "src/index.ts": `import { on } from "./flag"; +type Flag = "yes" | "no"; +const big = 1e999; +const small = -1e999; +const word = "a"; +export type On = typeof on; +export type Sizes = [typeof big, typeof small, typeof word]; +export function pick(flag: Flag): On { + return flag === "yes"; +} +export function sizes(): Sizes { + return [1e999, -1e999, "a"]; +} +`, + }); + const root = String(dir); + const built = await Bun.build({ + entrypoints: [join(root, "src/index.ts")], + outdir: join(root, "out"), + tsconfig: join(root, "tsconfig.json"), + dts: true, + }); + expect(built.logs).toEqual([]); + const typed = readFileSync(join(root, "out/index.aot.ts"), "utf8"); + // `Flag` of the other file is `boolean`, and no type is `Infinity`. + expect(typed.match(/^declare const .*$/gm)?.sort()).toEqual([ + "declare const big: number;", + "declare const on: boolean;", + "declare const small: number;", + `declare const word: "a";`, + ]); + }); + test("two builds at once keep their files apart", async () => { using dir = tempDir("build-api-dts-twice", { ...Object.fromEntries(Object.entries(project).map(([path, text]) => ["a/" + path, text])), diff --git a/test/bundler/bun-build-dts.test.ts b/test/bundler/bun-build-dts.test.ts index 13e8ff4ec4b6..00766302b0cf 100644 --- a/test/bundler/bun-build-dts.test.ts +++ b/test/bundler/bun-build-dts.test.ts @@ -115,6 +115,36 @@ describe.concurrent("bun build --dts", () => { ); }); + test("--splitting --minify-syntax --minify-whitespace: a class member of a chunk keeps the type of the sources", async () => { + using dir = tempDir("build-dts-minify-chunk", { + "tsconfig.json": project["tsconfig.json"], + "package.json": project["package.json"], + "src/settings.ts": `declare const __DEV__: boolean; +export class Settings { + mode; + constructor() { + if (__DEV__) this.mode = "dev"; + } +} +`, + "src/first.ts": `import { Settings } from "./settings"; +export const first = (): Settings => new Settings(); +`, + "src/second.ts": `import { Settings } from "./settings"; +export const second = (): string | undefined => new Settings().mode; +`, + }); + const flags = ["--dts", "--splitting", "--minify-syntax", "--minify-whitespace", "--define", "__DEV__=false"]; + const built = await bun(String(dir), "build", "./src/first.ts", "./src/second.ts", "--outdir", "out", ...flags); + expect({ stderr: built.stderr, exitCode: built.exitCode }).toEqual({ stderr: "", exitCode: 0 }); + const typed = readdirSync(join(String(dir), "out")).filter(name => name.endsWith(".aot.ts")); + const chunk = typed.find(name => name !== "first.aot.ts" && name !== "second.aot.ts"); + expect(typed).toHaveLength(3); + expect(readFileSync(join(String(dir), "out", chunk!), "utf8")).toBe( + "class Settings{mode: string | undefined;\nconstructor(){}}\nexport{Settings};\n", + ); + }); + test("a statement that never runs is left out", async () => { using dir = tempDir("build-dts-dead", { ...project, @@ -134,6 +164,353 @@ export function join(first: string, second: string): string { expect(readFileSync(join(String(dir), "out/index.aot.ts"), "utf8")).not.toContain("__DEV__"); }); + const only = (source: string) => ({ + "tsconfig.json": project["tsconfig.json"], + "package.json": project["package.json"], + "src/index.ts": source, + }); + const written = (dir: unknown, name: string) => readFileSync(join(String(dir), "out", name), "utf8"); + const membersOf = (text: string) => + text.match(/^ +(?:static )?#?\w+\??(?:: [^;{]+| = [^;]+)?;$/gm)?.map(it => it.trim()); + + // Without a `;` behind the last member, its type and a member that has no JavaScript go to the same place. + test("--minify-syntax --minify-whitespace: the type of the last member of a class stands behind its name", async () => { + using dir = tempDir( + "build-dts-minify-last-member", + only(`export abstract class Shape { + constructor() { + this.kind = "shape"; + } + kind; + abstract area(): number; +} +export class Sizes { + constructor() { + this.width = 1; + } + width: number; + [side: string]: number; +} +export abstract class Named { + name = "n" as const; + abstract area(): number; +} +`), + ); + const flags = ["--dts", "--minify-syntax", "--minify-whitespace"]; + const built = await bun(String(dir), "build", "./src/index.ts", "--outdir", "out", ...flags); + expect({ stderr: built.stderr, exitCode: built.exitCode }).toEqual({ stderr: "", exitCode: 0 }); + expect(written(dir, "index.aot.ts")).toBe( + `abstract class Shape{constructor(){this.kind="shape"} +kind: string;abstract area(): number; +} +class Sizes{constructor(){this.width=1} +width: number;[side: string]: number; +} +abstract class Named{name="n" as const;abstract area(): number; +} +export{Named,Shape,Sizes}; +`, + ); + }); + + test("noUncheckedIndexedAccess: a member that is stored from an index is undefined too", async () => { + const options = { strict: true, noUncheckedIndexedAccess: true }; + using dir = tempDir("build-dts-unchecked-index", { + "tsconfig.json": JSON.stringify({ + compilerOptions: { ...options, module: "esnext", moduleResolution: "bundler", target: "esnext", types: [] }, + }), + "package.json": JSON.stringify({ name: "greeter", version: "1.0.0", compilerOptions: options }), + "src/index.ts": `export class Holder { + first; + constructor(items: string[]) { + this.first = items[0]; + } +} +`, + }); + const built = await bun(String(dir), "build", "./src/index.ts", "--outdir", "out", "--dts"); + expect({ stderr: built.stderr, exitCode: built.exitCode }).toEqual({ stderr: "", exitCode: 0 }); + expect(membersOf(written(dir, "index.aot.ts"))).toEqual(["first: string | undefined;"]); + }); + + test("exactOptionalPropertyTypes: the note of an optional member leaves to its `?` that it can be absent", async () => { + const options = { strict: true, exactOptionalPropertyTypes: true }; + using dir = tempDir("build-dts-exact-optional", { + "tsconfig.json": JSON.stringify({ + compilerOptions: { ...options, module: "esnext", moduleResolution: "bundler", target: "esnext", types: [] }, + }), + "package.json": JSON.stringify({ name: "greeter", version: "1.0.0", compilerOptions: options }), + "src/index.ts": `declare const __DEV__: boolean; +export interface Options { + name?: string; +} +export class Settings implements Options { + name?; + label?; + constructor(name: string) { + this.name = name; + if (__DEV__) this.label = "dev"; + } +} +export const shown: Options = new Settings("a"); +`, + }); + const flags = ["--dts", "--define", "__DEV__=false"]; + const built = await bun(String(dir), "build", "./src/index.ts", "--outdir", "out", ...flags); + expect({ stderr: built.stderr, exitCode: built.exitCode }).toEqual({ stderr: "", exitCode: 0 }); + // `name?: string | undefined` is another type there, and `Settings` would not implement `Options`. + // A path of the constructor leaves `label` without a store, so its `undefined` is a value. + expect(membersOf(written(dir, "index.aot.ts"))).toEqual([ + "name?: string;", + "name?: string;", + "label?: string | undefined;", + ]); + }); + + test("useDefineForClassFields: false: a member without a value is declared with the type of the sources", async () => { + const options = { strict: true, useDefineForClassFields: false }; + const more = { noImplicitOverride: true, module: "esnext", moduleResolution: "bundler", target: "esnext" }; + using dir = tempDir("build-dts-no-define", { + "tsconfig.json": JSON.stringify({ compilerOptions: { ...options, ...more, types: [] } }), + "package.json": JSON.stringify({ name: "greeter", version: "1.0.0", compilerOptions: options }), + "src/index.ts": `declare const __DEV__: boolean; +export class Counter { + mode; + count; + label: string; + constructor() { + this.count = 0; + this.label = "c"; + if (__DEV__) this.mode = "dev"; + } + next(): number { + return ++this.count; + } +} +export class Named extends Counter { + override mode; + override label: string; + constructor(override count: number) { + super(); + this.label = "n"; + if (__DEV__) this.mode = "named"; + } +} +`, + }); + const flags = ["--dts", "--define", "__DEV__=false"]; + const built = await bun(String(dir), "build", "./src/index.ts", "--outdir", "out", ...flags); + expect({ stderr: built.stderr, exitCode: built.exitCode }).toEqual({ stderr: "", exitCode: 0 }); + // The bundle has none of these fields. `declare` allows no `override` (TS1243). + expect(written(dir, "index.aot.ts").match(/^ declare .*$/gm)).toEqual([ + " declare mode: string | undefined;", + " declare count: number;", + " declare label: string;", + " declare mode: string | undefined;", + " declare label: string;", + " declare count: number;", + ]); + // The tsconfig.json has no "include", so index.aot.ts is a source of this project too. + const checked = await bun(String(dir), "check"); + expect({ said: (checked.stdout + checked.stderr).match(/TS\d+[^\n]*/g), exitCode: checked.exitCode }).toEqual({ + said: null, + exitCode: 0, + }); + }); + + test("experimentalDecorators: a line that index.aot.ts adds for a member has none of its decorators", async () => { + const options = { strict: true, experimentalDecorators: true, useDefineForClassFields: false }; + using dir = tempDir("build-dts-decorated-members", { + "tsconfig.json": JSON.stringify({ + compilerOptions: { ...options, module: "esnext", moduleResolution: "bundler", target: "esnext", types: [] }, + }), + "package.json": JSON.stringify({ name: "greeter", version: "1.0.0", compilerOptions: options }), + // The helper of the bundle reads Reflect.decorate. A package that imports `reflect-metadata` has it declared. + "src/index.ts": `declare const __DEV__: boolean; +declare global { + namespace Reflect { + function decorate(decorators: unknown[], target: object, key?: string | symbol, desc?: PropertyDescriptor | null): any; + } +} +export const columns: string[] = []; +function column(target: object, key: string): void { + columns.push(key); +} +function indexed(name: string) { + return (target: object, key: string): void => { + columns.push(name + ":" + key); + }; +} +export class Row { + @column id: number; + @column + @indexed("by name") + name; + @column private readonly note?; + @column declare owner: string; + constructor(id: number) { + this.id = id; + if (__DEV__) this.name = "row"; + if (__DEV__) this.note = "n"; + } +} +export const load = () => import("./service"); +`, + // A decorator of a parameter assigns to the class. The bundle has the class of a module in a variable only + // where it loads the module this way. + "src/service.ts": `function inject(target: object, key: string | undefined, index: number): void {} +export class Service { + constructor(@inject private readonly name: string) {} + read(): string { + return this.name; + } +} +`, + }); + const flags = ["--dts", "--define", "__DEV__=false"]; + const built = await bun(String(dir), "build", "./src/index.ts", "--outdir", "out", ...flags); + expect({ stderr: built.stderr, exitCode: built.exitCode }).toEqual({ stderr: "", exitCode: 0 }); + // The bundle has none of these fields: it stores in the constructor, and it calls the decorators behind the class. + expect(written(dir, "index.aot.ts").match(/^ +declare .*$/gm)).toEqual([ + " declare private readonly name: string;", + " declare id: number;", + " declare name: string | undefined;", + " declare private readonly note?: string | undefined;", + " declare owner: string;", + ]); + // A decorator that index.aot.ts stated too would run twice. + const erased = (name: string, loader: "ts" | "js") => + new Bun.Transpiler({ loader, target: "node", tsconfig: { compilerOptions: options } }) + .transformSync(written(dir, name)) + .replace(/\s+/g, ""); + expect(erased("index.aot.ts", "ts")).toBe(erased("index.js", "js")); + }); + + test("a class member without an annotation keeps the type of the sources when the build removes what it stores", async () => { + using dir = tempDir( + "build-dts-dead-stores", + only(`declare const __DEV__: boolean; +declare const process: { env: Record }; +export class Settings { + mode; + level; + static count; + static { + if (__DEV__) this.count = 1; + } + constructor(level: number) { + if (__DEV__) this.mode = "dev"; + if (__DEV__) this.level = "debug"; + else this.level = level; + } + read(): string { + return String(this.mode) + this.level + Settings.count; + } +} +export class Trace { + to; + constructor() { + if (process.env.NODE_ENV === "production") this.to = "stderr"; + } + read(): string { + return String(this.to); + } +} +`), + ); + const flags = ["--dts", "--define", "__DEV__=false"]; + const built = await bun(String(dir), "build", "./src/index.ts", "--outdir", "out", ...flags); + expect({ stderr: built.stderr, exitCode: built.exitCode }).toEqual({ stderr: "", exitCode: 0 }); + const members = [ + "mode: string | undefined;", + // The store of a string is not in the bundle, and the store of a number is. + "level: string | number;", + "static count: number | undefined;", + "to: string | undefined;", + ]; + expect(membersOf(written(dir, "index.aot.ts"))).toEqual(members); + // The check of the sources wrote the same types into index.d.ts. + expect(membersOf(written(dir, "index.d.ts"))).toEqual(members); + expect(membersOf(written(dir, "index.js"))).toEqual(["mode;", "level;", "static count;", "to;"]); + const erased = (name: string, loader: "ts" | "js") => + new Bun.Transpiler({ loader, target: "node" }).transformSync(written(dir, name)).replace(/\s+/g, ""); + expect(erased("index.aot.ts", "ts")).toBe(erased("index.js", "js")); + // The tsconfig.json has no "include", so index.aot.ts is a source of this project too. The build judges the file + // as a dependency of another program, where TS7008 does not count. Here it does. + const checked = await bun(String(dir), "check"); + expect({ said: (checked.stdout + checked.stderr).match(/TS\d+[^\n]*/g), exitCode: checked.exitCode }).toEqual({ + said: null, + exitCode: 0, + }); + }); + + test("a member is written with the type of the sources only where that type names nothing", async () => { + using dir = tempDir( + "build-dts-member-types", + only(`declare const __DEV__: boolean; +export type Mode = "fast" | "slow"; +export enum Color { + Red, + Blue, +} +function mode(): Mode { + return "fast"; +} +export class Rows { + optional?; + #hidden; + either; + flag; + both; + picked; + far; + kept; + color; + entries; + valued = 1; + constructor(on: boolean) { + this.either = on ? "a" : null; + this.kept = "kept"; + if (__DEV__) this.optional = "dev"; + if (__DEV__) this.#hidden = 1n; + if (__DEV__) this.flag = false as const; + if (__DEV__) this.both = on; + if (__DEV__) this.picked = mode(); + if (__DEV__) this.far = 1e999 as const; + if (__DEV__) this.color = Color.Red; + if (__DEV__) this.entries = new Map(); + } + read(): string { + return [this.optional, this.#hidden, this.flag, this.both, this.picked, this.far, this.color].join(); + } + size(): number { + return this.valued + this.kept.length + (this.entries?.size ?? 0) + (this.either?.length ?? 0); + } +} +`), + ); + const flags = ["--dts", "--define", "__DEV__=false"]; + const built = await bun(String(dir), "build", "./src/index.ts", "--outdir", "out", ...flags); + expect({ stderr: built.stderr, exitCode: built.exitCode }).toEqual({ stderr: "", exitCode: 0 }); + expect(membersOf(written(dir, "index.aot.ts"))).toEqual([ + "optional?: string | undefined;", + "#hidden: bigint | undefined;", + "either: string | null;", + "flag: false | undefined;", + "both: boolean | undefined;", + // The type checker prints `Mode | undefined` for it, and `Mode` can be the name of another type in a bundle. + 'picked: "fast" | "slow" | undefined;', + // `Infinity` is the name of a value. + "far: number | undefined;", + "kept: string;", + // The member of an enum and a class are names. These two are as the bundle has them. + "color;", + "entries;", + "valued = 1;", + ]); + }); + test("a build without it writes what it always did", async () => { using dir = tempDir("build-without-dts", project); const built = await bun(String(dir), "build", "./src/index.ts", "--outdir", "out"); diff --git a/test/cli/compile/compile-library.test.ts b/test/cli/compile/compile-library.test.ts index f6172e5ae2e3..f958d35bccb7 100644 --- a/test/cli/compile/compile-library.test.ts +++ b/test/cli/compile/compile-library.test.ts @@ -908,6 +908,110 @@ export const left: number = __LEFT__; ]); }); + test("a class member without an annotation has the type of the sources, whatever the bundle keeps of its stores", async () => { + using dir = tempDir( + "library-typed-file-members", + library({ + "src/index.ts": `import { Unchecked } from "./unchecked"; +declare const __DEV__: boolean; +export class Shapes { + mode; + kept; + static count; + static { + if (__DEV__) this.count = 1; + } + constructor() { + this.kept = "kept"; + if (__DEV__) this.mode = "dev"; + } + sum(): number { + return this.kept.length + (Shapes.count ?? 0) + (this.mode?.length ?? 0) + new Unchecked().bare; + } +} +`, + "src/unchecked.ts": `// @ts-nocheck +export class Unchecked { + bare; + constructor() { + this.bare = 1; + } +} +`, + }), + ); + const built = await bun(String(dir), "compile", "--library", "src/index.ts", "--define", "__DEV__=false"); + expect(built).toMatchObject({ stderr: "", exitCode: 0 }); + expect(json(join(String(dir), "dist/package.json")).exports["."]).toEqual(conditions("index")); + // A module under `// @ts-nocheck` keeps what the bundle has. + expect(typedFile(dir).match(/^ (?:static )?\w+(?:: [^;{]+)?;$/gm)).toEqual([ + " bare;", + " mode: string | undefined;", + " kept: string;", + " static count: number | undefined;", + ]); + }); + + // The build runs what an `accessor` field starts with in the field after it. + test("a member without a value has its type before a value that the build gives it", async () => { + using dir = tempDir( + "library-typed-file-member-with-made-value", + library({ + "src/index.ts": `declare const __DEV__: boolean; +export class Hosts { + accessor first = 1; + after; + constructor() { + if (__DEV__) this.after = 2; + } + read(): string { + return this.first + (this.after === undefined ? "" : this.after.toFixed()); + } +} +`, + }), + ); + const built = await bun(String(dir), "compile", "--library", "src/index.ts", "--define", "__DEV__=false"); + expect(built).toMatchObject({ stderr: "", exitCode: 0 }); + expect(typedFile(dir)).toContain(" after: number | undefined = (__privateAdd(this, _first, 1), undefined);\n"); + }); + + // The .aot.ts file has `any` for a namespace object, so `pick` answers with its first signature there. + // On Windows the build says nothing about .aot.ts files that it leaves out, as for `--splitting` below. + test.todoIf(isWindows)( + "a store that the .aot.ts file types in another way than the sources is a warning, not another type for the member", + async () => { + using dir = tempDir( + "library-typed-file-member-refused", + library({ + "src/index.ts": `function pick(value: string): string; +function pick(value: number): number; +function pick(value: string | number): string | number { + return value; +} +export namespace Defaults { + export const size: number = 1; +} +export class Box { + size; + constructor() { + this.size = pick(Defaults.size); + } +} +`, + }), + ); + const built = await bun(String(dir), "compile", "--library", "src/index.ts"); + expect(built.stderr).toMatch( + /^warn: the \.aot\.ts files that --aot compiles a package from are left out: they have type errors, the first at index\.aot\.ts:\d+:\d+: TS2322: Type 'string' is not assignable to type 'number'\.\n$/, + ); + expect(built.exitCode).toBe(0); + expect(readFileSync(join(String(dir), "dist/index.d.ts"), "utf8")).toContain(" size: number;\n"); + expect(filesIn(join(String(dir), "dist")).filter(path => /\.aot\.|tsconfig/.test(path))).toEqual([]); + expect(JSON.stringify(json(join(String(dir), "dist/package.json")))).not.toContain('"aot"'); + }, + ); + const rewritten = { "src/index.ts": `let notes = 0; const note = (): void => void notes++;