usingnamespace @import("../global.zig"); const Api = @import("../api/schema.zig").Api; const std = @import("std"); const options = @import("../options.zig"); const cache = @import("../cache.zig"); const logger = @import("../logger.zig"); const js_ast = @import("../js_ast.zig"); const alloc = @import("../alloc.zig"); const fs = @import("../fs.zig"); const resolver = @import("./resolver.zig"); const js_lexer = @import("../js_lexer.zig"); const resolve_path = @import("./resolve_path.zig"); // Assume they're not going to have hundreds of main fields or browser map // so use an array-backed hash table instead of bucketed const MainFieldMap = std.StringArrayHashMap(string); pub const BrowserMap = std.StringArrayHashMap(string); threadlocal var hashy: [2048]u8 = undefined; pub const MacroImportReplacementMap = std.StringArrayHashMap(string); pub const MacroMap = std.StringArrayHashMapUnmanaged(MacroImportReplacementMap); const ScriptsMap = std.StringArrayHashMap(string); pub const PackageJSON = struct { pub const LoadFramework = enum { none, development, production, }; const node_modules_path = std.fs.path.sep_str ++ "node_modules" ++ std.fs.path.sep_str; pub fn nameForImport(this: *const PackageJSON, allocator: *std.mem.Allocator) !string { if (strings.indexOf(this.source.path.text, node_modules_path)) |_| { return this.name; } else { const parent = this.source.path.name.dirWithTrailingSlash(); if (strings.indexOf(parent, fs.FileSystem.instance.top_level_dir)) |i| { const relative_dir = parent[i + fs.FileSystem.instance.top_level_dir.len ..]; var out_dir = try allocator.alloc(u8, relative_dir.len + 2); std.mem.copy(u8, out_dir[2..], relative_dir); out_dir[0] = '.'; out_dir[1] = '/'; return out_dir; } return this.name; } } pub const FrameworkRouterPair = struct { framework: *options.Framework, router: *options.RouteConfig, loaded_routes: bool = false, }; name: string = "", source: logger.Source, main_fields: MainFieldMap, module_type: options.ModuleType, version: string = "", hash: u32 = 0xDEADBEEF, scripts: ?*ScriptsMap = null, always_bundle: []string = &.{}, macros: MacroMap = MacroMap{}, // Present if the "browser" field is present. This field is intended to be // used by bundlers and lets you redirect the paths of certain 3rd-party // modules that don't work in the browser to other modules that shim that // functionality. That way you don't have to rewrite the code for those 3rd- // party modules. For example, you might remap the native "util" node module // to something like https://www.npmjs.com/package/util so it works in the // browser. // // This field contains a mapping of absolute paths to absolute paths. Mapping // to an empty path indicates that the module is disabled. As far as I can // tell, the official spec is an abandoned GitHub repo hosted by a user account: // https://github.com/defunctzombie/package-browser-field-spec. The npm docs // say almost nothing: https://docs.npmjs.com/files/package.json. // // Note that the non-package "browser" map has to be checked twice to match // Webpack's behavior: once before resolution and once after resolution. It // leads to some unintuitive failure cases that we must emulate around missing // file extensions: // // * Given the mapping "./no-ext": "./no-ext-browser.js" the query "./no-ext" // should match but the query "./no-ext.js" should NOT match. // // * Given the mapping "./ext.js": "./ext-browser.js" the query "./ext.js" // should match and the query "./ext" should ALSO match. // browser_map: BrowserMap, exports: ?ExportsMap = null, pub inline fn isAppPackage(this: *const PackageJSON) bool { return this.hash == 0xDEADBEEF; } fn loadDefineDefaults( env: *options.Env, json: *const js_ast.E.Object, allocator: *std.mem.Allocator, ) !void { var valid_count: usize = 0; for (json.properties) |prop| { if (prop.value.?.data != .e_string) continue; valid_count += 1; } env.defaults.shrinkRetainingCapacity(0); env.defaults.ensureTotalCapacity(allocator, valid_count) catch {}; for (json.properties) |prop| { if (prop.value.?.data != .e_string) continue; env.defaults.appendAssumeCapacity(.{ .key = prop.key.?.data.e_string.string(allocator) catch unreachable, .value = prop.value.?.data.e_string.string(allocator) catch unreachable, }); } } fn loadOverrides( framework: *options.Framework, json: *const js_ast.E.Object, allocator: *std.mem.Allocator, ) void { var valid_count: usize = 0; for (json.properties) |prop| { if (prop.value.?.data != .e_string) continue; valid_count += 1; } var buffer = allocator.alloc([]const u8, valid_count * 2) catch unreachable; var keys = buffer[0 .. buffer.len / 2]; var values = buffer[keys.len..]; var i: usize = 0; for (json.properties) |prop| { if (prop.value.?.data != .e_string) continue; keys[i] = prop.key.?.data.e_string.string(allocator) catch unreachable; values[i] = prop.value.?.data.e_string.string(allocator) catch unreachable; } framework.override_modules = Api.StringMap{ .keys = keys, .values = values }; } fn loadDefineExpression( env: *options.Env, json: *const js_ast.E.Object, allocator: *std.mem.Allocator, ) anyerror!void { for (json.properties) |prop| { switch (prop.key.?.data) { .e_string => |e_str| { const str = e_str.string(allocator) catch ""; if (strings.eqlComptime(str, "defaults")) { switch (prop.value.?.data) { .e_object => |obj| { try loadDefineDefaults(env, obj, allocator); }, else => { env.defaults.shrinkRetainingCapacity(0); }, } } else if (strings.eqlComptime(str, ".env")) { switch (prop.value.?.data) { .e_string => |value_str| { env.setBehaviorFromPrefix(value_str.string(allocator) catch ""); }, else => { env.behavior = .disable; env.prefix = ""; }, } } }, else => continue, } } } fn loadFrameworkExpression( framework: *options.Framework, json: js_ast.Expr, allocator: *std.mem.Allocator, comptime read_define: bool, ) bool { if (json.asProperty("client")) |client| { if (client.expr.asString(allocator)) |str| { if (str.len > 0) { framework.client.path = str; framework.client.kind = .client; } } } if (json.asProperty("fallback")) |client| { if (client.expr.asString(allocator)) |str| { if (str.len > 0) { framework.fallback.path = str; framework.fallback.kind = .fallback; } } } if (json.asProperty("css")) |css_prop| { if (css_prop.expr.asString(allocator)) |str| { if (strings.eqlComptime(str, "onimportcss")) { framework.client_css_in_js = .facade_onimportcss; } else { framework.client_css_in_js = .facade; } } } if (json.asProperty("override")) |override| { if (override.expr.data == .e_object) { loadOverrides(framework, override.expr.data.e_object, allocator); } } if (comptime read_define) { if (json.asProperty("define")) |defines| { var skip_fallback = false; if (defines.expr.asProperty("client")) |client| { if (client.expr.data == .e_object) { const object = client.expr.data.e_object; framework.client.env = options.Env.init( allocator, ); loadDefineExpression(&framework.client.env, object, allocator) catch {}; framework.fallback.env = framework.client.env; skip_fallback = true; } } if (!skip_fallback) { if (defines.expr.asProperty("fallback")) |client| { if (client.expr.data == .e_object) { const object = client.expr.data.e_object; framework.fallback.env = options.Env.init( allocator, ); loadDefineExpression(&framework.fallback.env, object, allocator) catch {}; } } } if (defines.expr.asProperty("server")) |server| { if (server.expr.data == .e_object) { const object = server.expr.data.e_object; framework.server.env = options.Env.init( allocator, ); loadDefineExpression(&framework.server.env, object, allocator) catch {}; } } } } if (json.asProperty("server")) |server| { if (server.expr.asString(allocator)) |str| { if (str.len > 0) { framework.server.path = str; framework.server.kind = .server; } } } return framework.client.isEnabled() or framework.server.isEnabled() or framework.fallback.isEnabled(); } pub fn loadFrameworkWithPreference( package_json: *const PackageJSON, pair: *FrameworkRouterPair, json: js_ast.Expr, allocator: *std.mem.Allocator, comptime read_defines: bool, comptime load_framework: LoadFramework, ) void { const framework_object = json.asProperty("framework") orelse return; if (framework_object.expr.asProperty("displayName")) |name| { if (name.expr.asString(allocator)) |str| { if (str.len > 0) { pair.framework.display_name = str; } } } if (framework_object.expr.asProperty("static")) |static_prop| { if (static_prop.expr.asString(allocator)) |str| { if (str.len > 0) { pair.router.static_dir = str; pair.router.static_dir_enabled = true; } } } if (framework_object.expr.asProperty("assetPrefix")) |asset_prefix| { if (asset_prefix.expr.asString(allocator)) |_str| { const str = std.mem.trimRight(u8, _str, " "); if (str.len > 0) { pair.router.asset_prefix_path = str; } } } if (!pair.router.routes_enabled) { if (framework_object.expr.asProperty("router")) |router| { if (router.expr.asProperty("dir")) |route_dir| { switch (route_dir.expr.data) { .e_string => |estr| { const str = estr.string(allocator) catch unreachable; if (str.len > 0) { pair.router.dir = str; pair.router.possible_dirs = &[_]string{}; pair.loaded_routes = true; } }, .e_array => |array| { var count: usize = 0; for (array.items) |item| { count += @boolToInt(item.data == .e_string and item.data.e_string.utf8.len > 0); } switch (count) { 0 => {}, 1 => { const str = array.items[0].data.e_string.string(allocator) catch unreachable; if (str.len > 0) { pair.router.dir = str; pair.router.possible_dirs = &[_]string{}; pair.loaded_routes = true; } }, else => { const list = allocator.alloc(string, count) catch unreachable; var list_i: usize = 0; for (array.items) |item| { if (item.data == .e_string and item.data.e_string.utf8.len > 0) { list[list_i] = item.data.e_string.string(allocator) catch unreachable; list_i += 1; } } pair.router.dir = list[0]; pair.router.possible_dirs = list; pair.loaded_routes = true; }, } }, else => {}, } } if (router.expr.asProperty("extensions")) |extensions_expr| { if (extensions_expr.expr.asArray()) |*array| { const count = array.array.items.len; var valid_count: usize = 0; while (array.next()) |expr| { if (expr.data != .e_string) continue; const e_str: *const js_ast.E.String = expr.data.e_string; if (e_str.utf8.len == 0 or e_str.utf8[0] != '.') continue; valid_count += 1; } if (valid_count > 0) { var extensions = allocator.alloc(string, valid_count) catch unreachable; array.index = 0; var i: usize = 0; // We don't need to allocate the strings because we keep the package.json source string in memory while (array.next()) |expr| { if (expr.data != .e_string) continue; const e_str: *const js_ast.E.String = expr.data.e_string; if (e_str.utf8.len == 0 or e_str.utf8[0] != '.') continue; extensions[i] = e_str.utf8; i += 1; } } } } } } switch (comptime load_framework) { .development => { if (framework_object.expr.asProperty("development")) |env| { if (loadFrameworkExpression(pair.framework, env.expr, allocator, read_defines)) { pair.framework.package = package_json.nameForImport(allocator) catch unreachable; pair.framework.development = true; if (env.expr.asProperty("static")) |static_prop| { if (static_prop.expr.asString(allocator)) |str| { if (str.len > 0) { pair.router.static_dir = str; pair.router.static_dir_enabled = true; } } } return; } } }, .production => { if (framework_object.expr.asProperty("production")) |env| { if (loadFrameworkExpression(pair.framework, env.expr, allocator, read_defines)) { pair.framework.package = package_json.nameForImport(allocator) catch unreachable; pair.framework.development = false; if (env.expr.asProperty("static")) |static_prop| { if (static_prop.expr.asString(allocator)) |str| { if (str.len > 0) { pair.router.static_dir = str; pair.router.static_dir_enabled = true; } } } return; } } }, else => unreachable, } if (loadFrameworkExpression(pair.framework, framework_object.expr, allocator, read_defines)) { pair.framework.package = package_json.nameForImport(allocator) catch unreachable; pair.framework.development = false; } } pub fn parse( comptime ResolverType: type, r: *ResolverType, input_path: string, dirname_fd: StoredFileDescriptorType, comptime generate_hash: bool, comptime include_scripts: bool, ) ?PackageJSON { // TODO: remove this extra copy const parts = [_]string{ input_path, "package.json" }; const package_json_path_ = r.fs.abs(&parts); const package_json_path = r.fs.dirname_store.append(@TypeOf(package_json_path_), package_json_path_) catch unreachable; const entry = r.caches.fs.readFile( r.fs, package_json_path, dirname_fd, false, null, ) catch |err| { if (err != error.IsDir) { r.log.addErrorFmt(null, logger.Loc.Empty, r.allocator, "Cannot read file \"{s}\": {s}", .{ r.prettyPath(fs.Path.init(input_path)), @errorName(err) }) catch unreachable; } return null; }; if (r.debug_logs) |*debug| { debug.addNoteFmt("The file \"{s}\" exists", .{package_json_path}) catch unreachable; } const key_path = fs.Path.init(package_json_path); var json_source = logger.Source.initPathString(key_path.text, entry.contents); json_source.path.pretty = r.prettyPath(json_source.path); const json: js_ast.Expr = (r.caches.json.parseJSON(r.log, json_source, r.allocator) catch |err| { if (isDebug) { Output.printError("{s}: JSON parse error: {s}", .{ package_json_path, @errorName(err) }); } return null; } orelse return null); var package_json = PackageJSON{ .name = "", .version = "", .hash = 0xDEADBEEF, .source = json_source, .module_type = .unknown, .macros = MacroMap{}, .browser_map = BrowserMap.init(r.allocator), .main_fields = MainFieldMap.init(r.allocator), }; // Note: we tried rewriting this to be fewer loops over all the properties (asProperty loops over each) // The end result was: it's not faster! Sometimes, it's slower. // It's hard to say why. // Feels like a codegen issue. // or that looping over every property doesn't really matter because most package.jsons are < 20 properties if (json.asProperty("version")) |version_json| { if (version_json.expr.asString(r.allocator)) |version_str| { if (version_str.len > 0) { package_json.version = r.allocator.dupe(u8, version_str) catch unreachable; } } } if (json.asProperty("name")) |version_json| { if (version_json.expr.asString(r.allocator)) |version_str| { if (version_str.len > 0) { package_json.name = r.allocator.dupe(u8, version_str) catch unreachable; } } } if (json.asProperty("type")) |type_json| { if (type_json.expr.asString(r.allocator)) |type_str| { switch (options.ModuleType.List.get(type_str) orelse options.ModuleType.unknown) { .cjs => { package_json.module_type = .cjs; }, .esm => { package_json.module_type = .esm; }, .unknown => { r.log.addRangeWarningFmt( &json_source, json_source.rangeOfString(type_json.loc), r.allocator, "\"{s}\" is not a valid value for \"type\" field (must be either \"commonjs\" or \"module\")", .{type_str}, ) catch unreachable; }, } } else { r.log.addWarning(&json_source, type_json.loc, "The value for \"type\" must be a string") catch unreachable; } } if (json.asProperty("bun")) |bun_json| { if (bun_json.expr.asProperty("alwaysBundle")) |bundle_| { if (bundle_.expr.data == .e_array) { var always_bundle_count: u16 = 0; const array = bundle_.expr.data.e_array.items; for (array) |item| { always_bundle_count += @intCast(u16, @boolToInt(item.data == .e_string and item.data.e_string.utf8.len > 0)); } package_json.always_bundle = r.allocator.alloc(string, always_bundle_count) catch unreachable; var i: u16 = 0; for (array) |item| { if (!(item.data == .e_string and item.data.e_string.utf8.len > 0)) continue; package_json.always_bundle[i] = item.asString(r.allocator).?; i += 1; } // for (var i = 0; i < bundle_.expr.data.e_array.len; i++) { } } if (bun_json.expr.asProperty("macros")) |macros| { if (macros.expr.data == .e_object) { var always_bundle_count: u16 = 0; const properties = macros.expr.data.e_object.properties; for (properties) |property| { const key = property.key.?.asString(r.allocator) orelse continue; if (!resolver.isPackagePath(key)) { r.log.addRangeWarningFmt( &json_source, json_source.rangeOfString(property.key.?.loc), r.allocator, "\"{s}\" is not a package path. \"macros\" remaps package paths to macros. Skipping.", .{key}, ) catch unreachable; continue; } const value = property.value.?; if (value.data != .e_object) { r.log.addWarningFmt( &json_source, value.loc, r.allocator, "Invalid macro remapping in \"{s}\": expected object where the keys are import names and the value is a string path to replace", .{key}, ) catch unreachable; continue; } const remap_properties = value.data.e_object.properties; if (remap_properties.len == 0) continue; var map = MacroImportReplacementMap.init(r.allocator); map.ensureUnusedCapacity(remap_properties.len) catch unreachable; for (remap_properties) |remap| { const import_name = remap.key.?.asString(r.allocator) orelse continue; const remap_value = remap.value.?; if (remap_value.data != .e_string or remap_value.data.e_string.utf8.len == 0) { r.log.addWarningFmt( &json_source, remap_value.loc, r.allocator, "Invalid macro remapping for import \"{s}\": expected string to remap to. e.g. \"graphql\": \"bun-macro-relay\" ", .{import_name}, ) catch unreachable; continue; } const remap_value_str = remap_value.data.e_string.utf8; map.putAssumeCapacityNoClobber(import_name, remap_value_str); } if (map.count() > 0) { package_json.macros.put(r.allocator, key, map) catch unreachable; } } // for (var i = 0; i < bundle_.expr.data.e_array.len; i++) { } } } // Read the "main" fields for (r.opts.main_fields) |main| { if (json.asProperty(main)) |main_json| { const expr: js_ast.Expr = main_json.expr; if ((expr.asString(r.allocator))) |str| { if (str.len > 0) { package_json.main_fields.put(main, r.allocator.dupe(u8, str) catch unreachable) catch unreachable; } } } } // Read the "browser" property, but only when targeting the browser if (r.opts.platform.supportsBrowserField()) { // We both want the ability to have the option of CJS vs. ESM and the // option of having node vs. browser. The way to do this is to use the // object literal form of the "browser" field like this: // // "main": "dist/index.node.cjs.js", // "module": "dist/index.node.esm.js", // "browser": { // "./dist/index.node.cjs.js": "./dist/index.browser.cjs.js", // "./dist/index.node.esm.js": "./dist/index.browser.esm.js" // }, // if (json.asProperty("browser")) |browser_prop| { switch (browser_prop.expr.data) { .e_object => |obj| { // The value is an object // Remap all files in the browser field for (obj.properties) |*prop| { var _key_str = (prop.key orelse continue).asString(r.allocator) orelse continue; const value: js_ast.Expr = prop.value orelse continue; // Normalize the path so we can compare against it without getting // confused by "./". There is no distinction between package paths and // relative paths for these values because some tools (i.e. Browserify) // don't make such a distinction. // // This leads to weird things like a mapping for "./foo" matching an // import of "foo", but that's actually not a bug. Or arguably it's a // bug in Browserify but we have to replicate this bug because packages // do this in the wild. const key = r.allocator.dupe(u8, r.fs.normalize(_key_str)) catch unreachable; switch (value.data) { .e_string => |str| { // If this is a string, it's a replacement package package_json.browser_map.put(key, str.string(r.allocator) catch unreachable) catch unreachable; }, .e_boolean => |boolean| { if (!boolean.value) { package_json.browser_map.put(key, "") catch unreachable; } }, else => { r.log.addWarning(&json_source, value.loc, "Each \"browser\" mapping must be a string or boolean") catch unreachable; }, } } }, else => {}, } } } if (json.asProperty("exports")) |exports_prop| { if (ExportsMap.parse(r.allocator, &json_source, r.log, exports_prop.expr)) |exports_map| { package_json.exports = exports_map; } } if (include_scripts) { read_scripts: { if (json.asProperty("scripts")) |scripts_prop| { if (scripts_prop.expr.data == .e_object) { const scripts_obj = scripts_prop.expr.data.e_object; var count: usize = 0; for (scripts_obj.properties) |prop| { const key = prop.key.?.asString(r.allocator) orelse continue; const value = prop.value.?.asString(r.allocator) orelse continue; count += @as(usize, @boolToInt(key.len > 0 and value.len > 0)); } if (count == 0) break :read_scripts; var scripts = ScriptsMap.init(r.allocator); scripts.ensureUnusedCapacity(count) catch break :read_scripts; for (scripts_obj.properties) |prop| { const key = prop.key.?.asString(r.allocator) orelse continue; const value = prop.value.?.asString(r.allocator) orelse continue; if (!(key.len > 0 and value.len > 0)) continue; scripts.putAssumeCapacity(key, value); } package_json.scripts = r.allocator.create(ScriptsMap) catch unreachable; package_json.scripts.?.* = scripts; } } } } // TODO: side effects // TODO: exports map if (generate_hash) { if (package_json.name.len > 0 and package_json.version.len > 0) { std.mem.set(u8, &hashy, 0); var used: usize = 0; std.mem.copy(u8, &hashy, package_json.name); used = package_json.name.len; hashy[used] = '@'; used += 1; std.mem.copy(u8, hashy[used..], package_json.version); used += package_json.version.len; package_json.hash = std.hash.Murmur3_32.hash(hashy[0..used]); } } return package_json; } pub fn hashModule(this: *const PackageJSON, module: string) u32 { var hasher = std.hash.Wyhash.init(0); hasher.update(std.mem.asBytes(&this.hash)); hasher.update(module); return @truncate(u32, hasher.final()); } }; pub const ExportsMap = struct { root: Entry, exports_range: logger.Range = logger.Range.None, pub fn parse(allocator: *std.mem.Allocator, source: *const logger.Source, log: *logger.Log, json: js_ast.Expr) ?ExportsMap { var visitor = Visitor{ .allocator = allocator, .source = source, .log = log }; const root = visitor.visit(json); if (root.data == .@"null") { return null; } return ExportsMap{ .root = root, .exports_range = source.rangeOfString(json.loc), }; } pub const Visitor = struct { allocator: *std.mem.Allocator, source: *const logger.Source, log: *logger.Log, pub fn visit(this: Visitor, expr: js_ast.Expr) Entry { var first_token: logger.Range = logger.Range.None; switch (expr.data) { .e_null => { return Entry{ .first_token = js_lexer.rangeOfIdentifier(this.source, expr.loc), .data = .{ .@"null" = void{} } }; }, .e_string => |str| { return Entry{ .data = .{ .string = str.string(this.allocator) catch unreachable, }, .first_token = this.source.rangeOfString(expr.loc), }; }, .e_array => |e_array| { var array = this.allocator.alloc(Entry, e_array.items.len) catch unreachable; for (e_array.items) |item, i| { array[i] = this.visit(item); } return Entry{ .data = .{ .array = array, }, .first_token = logger.Range{ .loc = expr.loc, .len = 1 }, }; }, .e_object => |e_obj| { var map_data = Entry.Data.Map.List{}; map_data.ensureTotalCapacity(this.allocator, e_obj.*.properties.len) catch unreachable; map_data.len = map_data.capacity; var expansion_keys = this.allocator.alloc(Entry.Data.Map.MapEntry, e_obj.*.properties.len) catch unreachable; var expansion_key_i: usize = 0; var map_data_slices = map_data.slice(); var map_data_keys = map_data_slices.items(.key); var map_data_ranges = map_data_slices.items(.key_range); var map_data_entries = map_data_slices.items(.value); var is_conditional_sugar = false; first_token.loc = expr.loc; first_token.len = 1; for (e_obj.properties) |prop, i| { const key: string = prop.key.?.data.e_string.string(this.allocator) catch unreachable; const key_range: logger.Range = this.source.rangeOfString(prop.key.?.loc); // If exports is an Object with both a key starting with "." and a key // not starting with ".", throw an Invalid Package Configuration error. var cur_is_conditional_sugar = !strings.startsWithChar(key, '.'); if (i == 0) { is_conditional_sugar = cur_is_conditional_sugar; } else if (is_conditional_sugar != cur_is_conditional_sugar) { const prev_key_range = map_data_ranges[i - 1]; const prev_key = map_data_keys[i - 1]; this.log.addRangeWarningFmtWithNote( this.source, key_range, this.allocator, "This object cannot contain keys that both start with \".\" and don't start with \".\"", .{}, "The previous key \"{s}\" is incompatible with the current key \"{s}\"", .{ prev_key, key }, prev_key_range, ) catch unreachable; map_data.deinit(this.allocator); this.allocator.free(expansion_keys); return Entry{ .data = .{ .invalid = void{} }, .first_token = first_token, }; } map_data_keys[i] = key; map_data_ranges[i] = key_range; map_data_entries[i] = this.visit(prop.value.?); if (strings.endsWithAnyComptime(key, "/*")) { expansion_keys[expansion_key_i] = Entry.Data.Map.MapEntry{ .value = map_data_entries[i], .key = key, .key_range = key_range, }; expansion_key_i += 1; } } // this leaks a lil, but it's fine. expansion_keys = expansion_keys[0..expansion_key_i]; // Let expansion_keys be the list of keys of matchObj ending in "/" or "*", // sorted by length descending. const LengthSorter: type = strings.NewLengthSorter(Entry.Data.Map.MapEntry, "key"); var sorter = LengthSorter{}; std.sort.sort(Entry.Data.Map.MapEntry, expansion_keys, sorter, LengthSorter.lessThan); return Entry{ .data = .{ .map = Entry.Data.Map{ .list = map_data, .expansion_keys = expansion_keys, }, }, .first_token = first_token, }; }, .e_boolean => { first_token = js_lexer.rangeOfIdentifier(this.source, expr.loc); }, .e_number => { // TODO: range of number first_token.loc = expr.loc; first_token.len = 1; }, else => { first_token.loc = expr.loc; }, } this.log.addRangeWarning(this.source, first_token, "This value must be a string, an object, an array, or null") catch unreachable; return Entry{ .data = .{ .invalid = void{} }, .first_token = first_token, }; } }; pub const Entry = struct { first_token: logger.Range, data: Data, pub const Data = union(Tag) { invalid: void, @"null": void, boolean: bool, @"string": string, array: []const Entry, map: Map, pub const Tag = enum { @"null", string, boolean, array, map, invalid, }; pub const Map = struct { // This is not a std.ArrayHashMap because we also store the key_range which is a little weird pub const List = std.MultiArrayList(MapEntry); expansion_keys: []MapEntry, list: List, pub const MapEntry = struct { key: string, key_range: logger.Range, value: Entry, }; }; }; pub fn keysStartWithDot(this: *const Entry) bool { return this.data == .map and this.data.map.list.len > 0 and strings.startsWithChar(this.data.map.list.items(.key)[0], '.'); } pub fn valueForKey(this: *const Entry, key_: string) ?Entry { switch (this.data) { .map => |map| { var slice = this.data.map.list.slice(); const keys = slice.items(.key); for (keys) |key, i| { if (strings.eql(key, key_)) { return slice.items(.value)[i]; } } return null; }, else => { return null; }, } } }; }; pub const ESModule = struct { pub const ConditionsMap = std.StringArrayHashMap(void); debug_logs: ?*resolver.DebugLogs = null, conditions: ConditionsMap, allocator: *std.mem.Allocator, pub const Resolution = struct { status: Status = Status.Undefined, path: string = "", debug: Debug = Debug{}, pub const Debug = struct { // This is the range of the token to use for error messages token: logger.Range = logger.Range.None, // If the status is "UndefinedNoConditionsMatch", this is the set of // conditions that didn't match. This information is used for error messages. unmatched_conditions: []string = &[_]string{}, }; }; pub const Status = enum { Undefined, UndefinedNoConditionsMatch, // A more friendly error message for when no conditions are matched Null, Exact, Inexact, // This means we may need to try CommonJS-style extension suffixes // Module specifier is an invalid URL, package name or package subpath specifier. InvalidModuleSpecifier, // package.json configuration is invalid or contains an invalid configuration. InvalidPackageConfiguration, // Package exports or imports define a target module for the package that is an invalid type or string target. InvalidPackageTarget, // Package exports do not define or permit a target subpath in the package for the given module. PackagePathNotExported, // The package or module requested does not exist. ModuleNotFound, // The resolved path corresponds to a directory, which is not a supported target for module imports. UnsupportedDirectoryImport, // When a package path is explicitly set to null, that means it's not exported. PackagePathDisabled, pub inline fn isUndefined(this: Status) bool { return switch (this) { .Undefined, .UndefinedNoConditionsMatch => true, else => false, }; } }; pub const Package = struct { name: string, subpath: string, pub fn parse(specifier: string, subpath_buf: []u8) ?Package { if (specifier.len == 0) return null; var package = Package{ .name = "", .subpath = "" }; var slash = strings.indexOfCharNeg(specifier, '/'); if (!strings.startsWithChar(specifier, '@')) { slash = if (slash == -1) @intCast(i32, specifier.len) else slash; package.name = specifier[0..@intCast(usize, slash)]; } else { if (slash == -1) return null; const slash2 = strings.indexOfChar(specifier[@intCast(usize, slash) + 1 ..], '/') orelse specifier[@intCast(u32, slash + 1)..].len; package.name = specifier[0 .. @intCast(usize, slash + 1) + slash2]; } if (strings.startsWith(package.name, ".") or strings.indexAnyComptime(package.name, "\\%") != null) return null; std.mem.copy(u8, subpath_buf[1..], specifier[package.name.len..]); subpath_buf[0] = '.'; package.subpath = subpath_buf[0 .. specifier[package.name.len..].len + 1]; return package; } }; const ReverseKind = enum { exact, pattern, prefix }; pub const ReverseResolution = struct { subpath: string = "", token: logger.Range = logger.Range.None, }; const invalid_percent_chars = [_]string{ "%2f", "%2F", "%5c", "%5C", }; threadlocal var resolved_path_buf_percent: [std.fs.MAX_PATH_BYTES]u8 = undefined; pub fn resolve(r: *const ESModule, package_url: string, subpath: string, exports: ExportsMap.Entry) Resolution { var result = r.resolveExports(package_url, subpath, exports); if (result.status != .Exact and result.status != .Inexact) { return result; } // If resolved contains any percent encodings of "/" or "\" ("%2f" and "%5C" // respectively), then throw an Invalid Module Specifier error. const PercentEncoding = @import("../query_string_map.zig").PercentEncoding; var fbs = std.io.fixedBufferStream(&resolved_path_buf_percent); var writer = fbs.writer(); const len = PercentEncoding.decode(@TypeOf(&writer), &writer, result.path) catch return Resolution{ .status = .InvalidModuleSpecifier, .path = result.path, .debug = result.debug, }; const resolved_path = resolved_path_buf_percent[0..len]; var found: string = ""; if (strings.contains(resolved_path, invalid_percent_chars[0])) { found = invalid_percent_chars[0]; } else if (strings.contains(resolved_path, invalid_percent_chars[1])) { found = invalid_percent_chars[1]; } else if (strings.contains(resolved_path, invalid_percent_chars[2])) { found = invalid_percent_chars[2]; } else if (strings.contains(resolved_path, invalid_percent_chars[3])) { found = invalid_percent_chars[3]; } if (found.len != 0) { return Resolution{ .status = .InvalidModuleSpecifier, .path = result.path, .debug = result.debug }; } // If resolved is a directory, throw an Unsupported Directory Import error. if (strings.endsWithAnyComptime(resolved_path, "/\\")) { return Resolution{ .status = .UnsupportedDirectoryImport, .path = result.path, .debug = result.debug }; } result.path = resolved_path; return result; } fn resolveExports( r: *const ESModule, package_url: string, subpath: string, exports: ExportsMap.Entry, ) Resolution { if (exports.data == .invalid) { if (r.debug_logs) |logs| { logs.addNote("Invalid package configuration") catch unreachable; } return Resolution{ .status = .InvalidPackageConfiguration, .debug = .{ .token = exports.first_token } }; } if (strings.eqlComptime(subpath, ".")) { var main_export = ExportsMap.Entry{ .data = .{ .@"null" = void{} }, .first_token = logger.Range.None }; if (switch (exports.data) { .string, .array, => true, .map => !exports.keysStartWithDot(), else => false, }) { main_export = exports; } else if (exports.data == .map) { if (exports.valueForKey(".")) |value| { main_export = value; } } if (main_export.data != .@"null") { const result = r.resolveTarget(package_url, main_export, "", false); if (result.status != .Null and result.status != .Undefined) { return result; } } } else if (exports.data == .map and exports.keysStartWithDot()) { const result = r.resolveImportsExports(subpath, exports, package_url); if (result.status != .Null and result.status != .Undefined) { return result; } if (result.status == .Null) { return Resolution{ .status = .PackagePathDisabled, .debug = .{ .token = exports.first_token } }; } } if (r.debug_logs) |logs| { logs.addNoteFmt("The path \"{s}\" was not exported", .{subpath}) catch unreachable; } return Resolution{ .status = .PackagePathNotExported, .debug = .{ .token = exports.first_token } }; } fn resolveImportsExports( r: *const ESModule, match_key: string, match_obj: ExportsMap.Entry, package_url: string, ) Resolution { if (r.debug_logs) |logs| { logs.addNoteFmt("Checking object path map for \"{s}\"", .{match_key}) catch unreachable; } if (!strings.endsWithChar(match_key, '.')) { if (match_obj.valueForKey(match_key)) |target| { if (r.debug_logs) |log| { log.addNoteFmt("Found \"{s}\"", .{match_key}) catch unreachable; } return r.resolveTarget(package_url, target, "", false); } } if (match_obj.data == .map) { const expansion_keys = match_obj.data.map.expansion_keys; for (expansion_keys) |expansion| { // If expansionKey ends in "*" and matchKey starts with but is not equal to // the substring of expansionKey excluding the last "*" character if (strings.endsWithChar(expansion.key, '*')) { const substr = expansion.key[0 .. expansion.key.len - 1]; if (strings.startsWith(match_key, substr) and !strings.eql(match_key, substr)) { const target = expansion.value; const subpath = match_key[expansion.key.len - 1 ..]; if (r.debug_logs) |log| { log.addNoteFmt("The key \"{s}\" matched with \"{s}\" left over", .{ expansion.key, subpath }) catch unreachable; } return r.resolveTarget(package_url, target, subpath, true); } } if (strings.startsWith(match_key, expansion.key)) { const target = expansion.value; const subpath = match_key[expansion.key.len..]; if (r.debug_logs) |log| { log.addNoteFmt("The key \"{s}\" matched with \"{s}\" left over", .{ expansion.key, subpath }) catch unreachable; } var result = r.resolveTarget(package_url, target, subpath, false); result.status = if (result.status == .Exact) // Return the object { resolved, exact: false }. .Inexact else result.status; return result; } if (r.debug_logs) |log| { log.addNoteFmt("The key \"{s}\" did not match", .{expansion.key}) catch unreachable; } } } if (r.debug_logs) |log| { log.addNoteFmt("No keys matched \"{s}\"", .{match_key}) catch unreachable; } return Resolution{ .status = .Null, .debug = .{ .token = match_obj.first_token }, }; } threadlocal var resolve_target_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; fn resolveTarget( r: *const ESModule, package_url: string, target: ExportsMap.Entry, subpath: string, comptime pattern: bool, ) Resolution { switch (target.data) { .string => |str| { if (r.debug_logs) |log| { log.addNoteFmt("Checking path \"{s}\" against target \"{s}\"", .{ subpath, str }) catch unreachable; log.increaseIndent() catch unreachable; } defer { if (r.debug_logs) |log| { log.decreaseIndent() catch unreachable; } } // If pattern is false, subpath has non-zero length and target // does not end with "/", throw an Invalid Module Specifier error. if (comptime !pattern) { if (subpath.len > 0 and !strings.endsWithChar(str, '/')) { if (r.debug_logs) |log| { log.addNoteFmt("The target \"{s}\" is invalid because it doesn't end with a \"/\"", .{str}) catch unreachable; } return Resolution{ .path = str, .status = .InvalidModuleSpecifier, .debug = .{ .token = target.first_token } }; } } if (!strings.startsWith(str, "./")) { if (r.debug_logs) |log| { log.addNoteFmt("The target \"{s}\" is invalid because it doesn't start with a \"./\"", .{str}) catch unreachable; } return Resolution{ .path = str, .status = .InvalidPackageTarget, .debug = .{ .token = target.first_token } }; } // If target split on "/" or "\" contains any ".", ".." or "node_modules" // segments after the first segment, throw an Invalid Package Target error. if (findInvalidSegment(str)) |invalid| { if (r.debug_logs) |log| { log.addNoteFmt("The target \"{s}\" is invalid because it contains an invalid segment \"{s}\"", .{ str, invalid }) catch unreachable; } return Resolution{ .path = str, .status = .InvalidPackageTarget, .debug = .{ .token = target.first_token } }; } // Let resolvedTarget be the URL resolution of the concatenation of packageURL and target. var parts = [_]string{ package_url, str }; const resolved_target = resolve_path.joinStringBuf(&resolve_target_buf, parts, .auto); // If target split on "/" or "\" contains any ".", ".." or "node_modules" // segments after the first segment, throw an Invalid Package Target error. if (findInvalidSegment(resolved_target)) |invalid| { if (r.debug_logs) |log| { log.addNoteFmt("The target \"{s}\" is invalid because it contains an invalid segment \"{s}\"", .{ str, invalid }) catch unreachable; } return Resolution{ .path = str, .status = .InvalidModuleSpecifier, .debug = .{ .token = target.first_token } }; } if (comptime pattern) { // Return the URL resolution of resolvedTarget with every instance of "*" replaced with subpath. const len = std.mem.replacementSize(u8, resolved_target, "*", subpath); _ = std.mem.replace(u8, resolved_target, "*", subpath, &resolve_target_buf); const result = resolve_target_buf[0..len]; if (r.debug_logs) |log| { log.addNoteFmt("Subsituted \"{s}\" for \"*\" in \".{s}\" to get \".{s}\" ", .{ subpath, resolved_target, result }) catch unreachable; } return Resolution{ .path = result, .status = .Exact, .debug = .{ .token = target.first_token } }; } else { var parts2 = [_]string{ package_url, str, subpath }; const result = resolve_path.joinStringBuf(&resolve_target_buf, parts2, .auto); if (r.debug_logs) |log| { log.addNoteFmt("Substituted \"{s}\" for \"*\" in \".{s}\" to get \".{s}\" ", .{ subpath, resolved_target, result }) catch unreachable; } return Resolution{ .path = result, .status = .Exact, .debug = .{ .token = target.first_token } }; } }, .map => |object| { var did_find_map_entry = false; var last_map_entry_i: usize = 0; const slice = object.list.slice(); const keys = slice.items(.key); for (keys) |key, i| { if (strings.eqlComptime(key, "default") or r.conditions.contains(key)) { if (r.debug_logs) |log| { log.addNoteFmt("The key \"{s}\" matched", .{key}) catch unreachable; } var result = r.resolveTarget(package_url, slice.items(.value)[i], subpath, pattern); if (result.status.isUndefined()) { did_find_map_entry = true; last_map_entry_i = i; continue; } return result; } if (r.debug_logs) |log| { log.addNoteFmt("The key \"{s}\" did not match", .{key}) catch unreachable; } } if (r.debug_logs) |log| { log.addNoteFmt("No keys matched", .{}) catch unreachable; } var return_target = target; // ALGORITHM DEVIATION: Provide a friendly error message if no conditions matched if (keys.len > 0 and !target.keysStartWithDot()) { var last_map_entry = ExportsMap.Entry.Data.Map.MapEntry{ .key = keys[last_map_entry_i], .value = slice.items(.value)[last_map_entry_i], // key_range is unused, so we don't need to pull up the array for it. .key_range = undefined, }; if (did_find_map_entry and last_map_entry.value.data == .map and last_map_entry.value.data.map.list.len > 0 and !last_map_entry.value.keysStartWithDot()) { // If a top-level condition did match but no sub-condition matched, // complain about the sub-condition instead of the top-level condition. // This leads to a less confusing error message. For example: // // "exports": { // "node": { // "require": "./dist/bwip-js-node.js" // } // }, // // We want the warning to say this: // // note: None of the conditions provided ("require") match any of the // currently active conditions ("default", "import", "node") // 14 | "node": { // | ^ // // We don't want the warning to say this: // // note: None of the conditions provided ("browser", "electron", "node") // match any of the currently active conditions ("default", "import", "node") // 7 | "exports": { // | ^ // // More information: https://github.com/evanw/esbuild/issues/1484 return_target = last_map_entry.value; } return Resolution{ .path = "", .status = .UndefinedNoConditionsMatch, .debug = .{ .token = target.first_token, .unmatched_conditions = return_target.data.map.list.items(.key), }, }; } return Resolution{ .path = "", .status = .UndefinedNoConditionsMatch, .debug = .{ .token = target.first_token }, }; }, .array => |array| { if (array.len == 0) { if (r.debug_logs) |log| { log.addNoteFmt("The path \"{s}\" is an empty array", .{subpath}) catch unreachable; } return Resolution{ .path = "", .status = .Null, .debug = .{ .token = target.first_token } }; } var last_exception = Status.Undefined; var last_debug = Resolution.Debug{ .token = target.first_token }; for (array) |targetValue, i| { // Let resolved be the result, continuing the loop on any Invalid Package Target error. const result = r.resolveTarget(package_url, targetValue, subpath, pattern); if (result.status == .InvalidPackageTarget or result.status == .Null) { last_debug = result.debug; last_exception = result.status; } if (result.status.isUndefined()) { continue; } return result; } return Resolution{ .path = "", .status = last_exception, .debug = last_debug }; }, .@"null" => { if (r.debug_logs) |log| { log.addNoteFmt("The path \"{s}\" is null", .{subpath}) catch unreachable; } return Resolution{ .path = "", .status = .Null, .debug = .{ .token = target.first_token } }; }, else => {}, } if (r.debug_logs) |logs| { logs.addNoteFmt("Invalid package target for path \"{s}\"", .{subpath}) catch unreachable; } return Resolution{ .status = .InvalidPackageTarget, .debug = .{ .token = target.first_token } }; } fn resolveExportsReverse( r: *const ESModule, query: string, root: ExportsMap.Entry, ) ?ReverseResolution { if (root.data == .map and root.keysStartWithDot()) { if (r.resolveImportsExportsReverse(query, root)) |res| { return res; } } return null; } fn resolveImportsExportsReverse( r: *const ESModule, query: string, match_obj: ExportsMap.Entry, ) ?ReverseResolution { if (match_obj.data != .map) return null; const map = match_obj.data.map; if (!strings.endsWithChar(query, "*")) { var slices = map.list.slice(); var keys = slices.items(.key); var values = slices.items(.value); for (keys) |key, i| { if (r.resolveTargetReverse(query, key, values[i], .exact)) |result| { return result; } } } for (map.expansion_keys) |expansion| { if (strings.endsWithChar(expansion.key, '*')) { if (r.resolveTargetReverse(query, expansion.key, expansion.value, .pattern)) |result| { return result; } } if (r.resolveTargetReverse(query, expansion.key, expansion.value, .reverse)) |result| { return result; } } } threadlocal var resolve_target_reverse_prefix_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined; threadlocal var resolve_target_reverse_prefix_buf2: [std.fs.MAX_PATH_BYTES]u8 = undefined; fn resolveTargetReverse( r: *const ESModule, query: string, key: string, target: ExportsMap.Entry, comptime kind: ReverseKind, ) ?ReverseResolution { switch (target.data) { .string => |str| { switch (comptime kind) { .exact => { if (strings.eql(query, str)) { return ReverseResolution{ .subpath = str, .token = target.first_token }; } }, .prefix => { if (strings.startsWith(query, str)) { return ReverseResolution{ .subpath = std.fmt.bufPrint(&resolve_target_reverse_prefix_buf, "{s}{s}", .{ key, query[str.len..] }) catch unreachable, .token = target.first_token, }; } }, .pattern => { const key_without_trailing_star = std.mem.trimRight(u8, key, "*"); const star = strings.indexOfChar(str, '*') orelse { // Handle the case of no "*" if (strings.eql(query, str)) { return ReverseResolution{ .subpath = key_without_trailing_star, .token = target.first_token }; } return null; }; // Only support tracing through a single "*" const prefix = str[0..star]; const suffix = str[star + 1 ..]; if (strings.startsWith(query, prefix) and !strings.containsChar(suffix, '*')) { const after_prefix = query[prefix.len..]; if (strings.endsWith(after_prefix, suffix)) { const star_data = after_prefix[0 .. after_prefix.len - suffix.len]; return ReverseResolution{ .subpath = std.fmt.bufPrint( &resolve_target_reverse_prefix_buf2, "{s}{s}", .{ key_without_trailing_star, star_data, }, ) catch unreachable, .token = target.first_token, }; } } }, } }, .map => |map| { const slice = map.list.slice(); const keys = slice.items(.key); for (keys) |map_key, i| { if (strings.eqlComptime(map_key, "default") or r.conditions.contains(map_key)) { if (r.resolveTargetReverse(query, key, slice.items(.value)[i], kind)) |result| { return result; } } } }, .array => |array| { for (array) |target_value| { if (r.resolveTargetReverse(query, key, target_value, kind)) |result| { return result; } } }, else => {}, } return null; } }; fn findInvalidSegment(path_: string) ?string { var slash = strings.indexAnyComptime(path_, "/\\") orelse return ""; var path = path_[slash + 1 ..]; while (path.len > 0) { var segment = path; if (strings.indexAnyComptime(path, "/\\")) |new_slash| { segment = path[0..new_slash]; path = path[new_slash + 1 ..]; } else { path = ""; } switch (segment.len) { 1 => { if (strings.eqlComptimeIgnoreLen(segment, ".")) return segment; }, 2 => { if (strings.eqlComptimeIgnoreLen(segment, "..")) return segment; }, "node_modules".len => { if (strings.eqlComptimeIgnoreLen(segment, "node_modules")) return segment; }, else => {}, } } return null; }