diff options
author | 2021-05-12 20:33:58 -0700 | |
---|---|---|
committer | 2021-05-12 20:33:58 -0700 | |
commit | f12ed9904b03e11f755dce7b614925ea087f40da (patch) | |
tree | cfbbcab5ee4931d67b8e15d8175291675019ab92 | |
parent | 1010bae1a350d12f7db49b8ca7f94aa748790b77 (diff) | |
download | bun-f12ed9904b03e11f755dce7b614925ea087f40da.tar.gz bun-f12ed9904b03e11f755dce7b614925ea087f40da.tar.zst bun-f12ed9904b03e11f755dce7b614925ea087f40da.zip |
okay I think that's most of resolving packages/imports algorithm!!!
Former-commit-id: 80037859ec5236e13314a336e28d5f46a96c3300
-rw-r--r-- | src/api/schema.d.ts | 1 | ||||
-rw-r--r-- | src/api/schema.js | 17 | ||||
-rw-r--r-- | src/api/schema.peechy | 2 | ||||
-rw-r--r-- | src/api/schema.zig | 31 | ||||
-rw-r--r-- | src/cli.zig | 40 | ||||
-rw-r--r-- | src/fs.zig | 21 | ||||
-rw-r--r-- | src/global.zig | 1 | ||||
-rw-r--r-- | src/options.zig | 10 | ||||
-rw-r--r-- | src/resolver/resolver.zig | 835 | ||||
-rw-r--r-- | src/resolver/tsconfig_json.zig | 5 |
10 files changed, 927 insertions, 36 deletions
diff --git a/src/api/schema.d.ts b/src/api/schema.d.ts index ce4521d6b..c78d7fc6c 100644 --- a/src/api/schema.d.ts +++ b/src/api/schema.d.ts @@ -127,6 +127,7 @@ type uint32 = number; main_fields?: string[]; platform?: Platform; watch?: boolean; + extension_order?: string[]; } export interface FileHandle { diff --git a/src/api/schema.js b/src/api/schema.js index acd53ac51..43670dff0 100644 --- a/src/api/schema.js +++ b/src/api/schema.js @@ -231,6 +231,12 @@ function decodeTransformOptions(bb) { result["watch"] = !!bb.readByte(); break; + case 19: + var length = bb.readVarUint(); + var values = result["extension_order"] = Array(length); + for (var i = 0; i < length; i++) values[i] = bb.readString(); + break; + default: throw new Error("Attempted to parse invalid message"); } @@ -392,6 +398,17 @@ bb.writeByte(encoded); bb.writeByte(18); bb.writeByte(value); } + + var value = message["extension_order"]; + if (value != null) { + bb.writeByte(19); + var values = value, n = values.length; + bb.writeVarUint(n); + for (var i = 0; i < n; i++) { + value = values[i]; + bb.writeString(value); + } + } bb.writeByte(0); } diff --git a/src/api/schema.peechy b/src/api/schema.peechy index f36a968b2..6c6889f00 100644 --- a/src/api/schema.peechy +++ b/src/api/schema.peechy @@ -69,6 +69,8 @@ message TransformOptions { Platform platform = 17; bool watch = 18; + + string[] extension_order = 19; } struct FileHandle { diff --git a/src/api/schema.zig b/src/api/schema.zig index d32b8fb23..bba5a3c94 100644 --- a/src/api/schema.zig +++ b/src/api/schema.zig @@ -203,6 +203,9 @@ pub const Api = struct { /// watch watch: ?bool = null, + /// extension_order + extension_order: []const []const u8, + pub fn decode(allocator: *std.mem.Allocator, reader: anytype) anyerror!TransformOptions { var obj = std.mem.zeroes(TransformOptions); try update(&obj, allocator, reader); @@ -380,6 +383,21 @@ pub const Api = struct { 18 => { result.watch = (try reader.readByte()) == @as(u8, 1); }, + 19 => { + { + var array_count = try reader.readIntNative(u32); + if (array_count != result.extension_order.len) { + result.extension_order = try allocator.alloc([]const u8, array_count); + } + length = try reader.readIntNative(u32); + for (result.extension_order) |content, j| { + if (result.extension_order[j].len != length and length > 0) { + result.extension_order[j] = try allocator.alloc(u8, length); + } + _ = try reader.readAll(result.extension_order[j].?); + } + } + }, else => { return error.InvalidMessage; }, @@ -545,6 +563,19 @@ pub const Api = struct { try writer.writeByte(18); try writer.writeByte(@boolToInt(watch)); } + + if (result.extension_order) |extension_order| { + try writer.writeByte(19); + n = result.extension_order.len; + _ = try writer.writeIntNative(u32, @intCast(u32, n)); + { + var j: usize = 0; + while (j < n) : (j += 1) { + _ = try writer.writeIntNative(u32, @intCast(u32, result.extension_order[j].len)); + try writer.writeAll(std.mem.sliceAsBytes(extension_order[j])); + } + } + } try writer.writeByte(0); return; } diff --git a/src/cli.zig b/src/cli.zig index ecebc2ee0..01240482d 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -104,25 +104,26 @@ pub const Cli = struct { pub fn parse(allocator: *std.mem.Allocator, stdout: anytype, stderr: anytype) !Api.TransformOptions { @setEvalBranchQuota(9999); const params = comptime [_]clap.Param(clap.Help){ - clap.parseParam("-h, --help Display this help and exit. ") catch unreachable, - clap.parseParam("-r, --resolve <STR> Determine import/require behavior. \"disable\" ignores. \"dev\" bundles node_modules and builds everything else as independent entry points") catch unreachable, - clap.parseParam("-d, --define <STR>... Substitute K:V while parsing, e.g. --define process.env.NODE_ENV:development") catch unreachable, - clap.parseParam("-l, --loader <STR>... Parse files with .ext:loader, e.g. --loader .js:jsx. Valid loaders: jsx, js, json, tsx (not implemented yet), ts (not implemented yet), css (not implemented yet)") catch unreachable, - clap.parseParam("-o, --outdir <STR> Save output to directory (default: \"out\" if none provided and multiple entry points passed)") catch unreachable, - clap.parseParam("-e, --external <STR>... Exclude module from transpilation (can use * wildcards). ex: -e react") catch unreachable, - clap.parseParam("-i, --inject <STR>... Inject module at the top of every file") catch unreachable, - clap.parseParam("--cwd <STR> Absolute path to resolve entry points from. Defaults to cwd") catch unreachable, - clap.parseParam("--public-url <STR> Rewrite import paths to start with --public-url. Useful for web browsers.") catch unreachable, - clap.parseParam("--jsx-factory <STR> Changes the function called when compiling JSX elements using the classic JSX runtime") catch unreachable, - clap.parseParam("--jsx-fragment <STR> Changes the function called when compiling JSX fragments using the classic JSX runtime") catch unreachable, - clap.parseParam("--jsx-import-source <STR> Declares the module specifier to be used for importing the jsx and jsxs factory functions. Default: \"react\"") catch unreachable, - clap.parseParam("--jsx-runtime <STR> \"automatic\" (default) or \"classic\"") catch unreachable, - clap.parseParam("--jsx-production Use jsx instead of jsxDEV (default) for the automatic runtime") catch unreachable, - clap.parseParam("--react-fast-refresh Enable React Fast Refresh (not implemented yet)") catch unreachable, - clap.parseParam("--tsconfig-override <STR> Load tsconfig from path instead of cwd/tsconfig.json") catch unreachable, - clap.parseParam("--platform <STR> \"browser\" or \"node\". Defaults to \"browser\"") catch unreachable, - clap.parseParam("--main-fields <STR>... Main fields to lookup in package.json. Defaults to --platform dependent") catch unreachable, - clap.parseParam("<POS>... Entry points to use") catch unreachable, + clap.parseParam("-h, --help Display this help and exit. ") catch unreachable, + clap.parseParam("-r, --resolve <STR> Determine import/require behavior. \"disable\" ignores. \"dev\" bundles node_modules and builds everything else as independent entry points") catch unreachable, + clap.parseParam("-d, --define <STR>... Substitute K:V while parsing, e.g. --define process.env.NODE_ENV:development") catch unreachable, + clap.parseParam("-l, --loader <STR>... Parse files with .ext:loader, e.g. --loader .js:jsx. Valid loaders: jsx, js, json, tsx (not implemented yet), ts (not implemented yet), css (not implemented yet)") catch unreachable, + clap.parseParam("-o, --outdir <STR> Save output to directory (default: \"out\" if none provided and multiple entry points passed)") catch unreachable, + clap.parseParam("-e, --external <STR>... Exclude module from transpilation (can use * wildcards). ex: -e react") catch unreachable, + clap.parseParam("-i, --inject <STR>... Inject module at the top of every file") catch unreachable, + clap.parseParam("--cwd <STR> Absolute path to resolve entry points from. Defaults to cwd") catch unreachable, + clap.parseParam("--public-url <STR> Rewrite import paths to start with --public-url. Useful for web browsers.") catch unreachable, + clap.parseParam("--jsx-factory <STR> Changes the function called when compiling JSX elements using the classic JSX runtime") catch unreachable, + clap.parseParam("--jsx-fragment <STR> Changes the function called when compiling JSX fragments using the classic JSX runtime") catch unreachable, + clap.parseParam("--jsx-import-source <STR> Declares the module specifier to be used for importing the jsx and jsxs factory functions. Default: \"react\"") catch unreachable, + clap.parseParam("--jsx-runtime <STR> \"automatic\" (default) or \"classic\"") catch unreachable, + clap.parseParam("--jsx-production Use jsx instead of jsxDEV (default) for the automatic runtime") catch unreachable, + clap.parseParam("--extension-order <STR>... defaults to: .tsx,.ts,.jsx,.js,.json ") catch unreachable, + clap.parseParam("--react-fast-refresh Enable React Fast Refresh (not implemented yet)") catch unreachable, + clap.parseParam("--tsconfig-override <STR> Load tsconfig from path instead of cwd/tsconfig.json") catch unreachable, + clap.parseParam("--platform <STR> \"browser\" or \"node\". Defaults to \"browser\"") catch unreachable, + clap.parseParam("--main-fields <STR>... Main fields to lookup in package.json. Defaults to --platform dependent") catch unreachable, + clap.parseParam("<POS>... Entry points to use") catch unreachable, }; var diag = clap.Diagnostic{}; @@ -260,6 +261,7 @@ pub const Cli = struct { .write = write, .inject = inject, .entry_points = entry_points, + .extension_order = args.options("--extension-order"), .main_fields = args.options("--main-fields"), .platform = platform, }; diff --git a/src/fs.zig b/src/fs.zig index 2337bdb19..e5ab8e832 100644 --- a/src/fs.zig +++ b/src/fs.zig @@ -629,6 +629,7 @@ pub const Path = struct { text: string, namespace: string = "unspecified", name: PathName, + is_disabled: bool = false, pub fn generateKey(p: *Path, allocator: *std.mem.Allocator) !string { return try std.fmt.allocPrint(allocator, "{s}://{s}", .{ p.namespace, p.text }); @@ -643,6 +644,26 @@ pub const Path = struct { return str; } + // for now, assume you won't try to normalize a path longer than 1024 chars + pub fn normalizeNoAlloc(str: string, comptime remap_windows_paths: bool) string { + if (str.len == 0 or (str.len == 1 and (str[0] == ' ' or str[0] == '\\'))) return "."; + + if (remap_windows_paths) { + std.mem.copy(u8, &normalize_buf, str); + var i: usize = 0; + while (i < str.len) : (i += 1) { + if (str[i] == '\\') { + normalize_buf[i] = '/'; + } + } + } + + if (resolvePath(&normalize_buf, str)) |out| { + return out; + } + return str; + } + pub fn init(text: string) Path { return Path{ .pretty = text, .text = text, .namespace = "file", .name = PathName.init(text) }; } diff --git a/src/global.zig b/src/global.zig index 8f15aa3a2..5a48d1b9c 100644 --- a/src/global.zig +++ b/src/global.zig @@ -88,6 +88,7 @@ pub const Global = struct { std.debug.panic(fmt, args); } } + pub fn notimpl() noreturn { Global.panic("Not implemented yet!!!!!", .{}); } diff --git a/src/options.zig b/src/options.zig index 265c11148..9d5f91699 100644 --- a/src/options.zig +++ b/src/options.zig @@ -294,6 +294,12 @@ pub const BundleOptions = struct { log: *logger.Log, external: ExternalModules = ExternalModules{}, entry_points: []const string, + extension_order: []const string = &Defaults.ExtensionOrder, + + pub const Defaults = struct { + pub var ExtensionOrder = [_]string{ ".tsx", ".ts", ".jsx", ".js", ".json" }; + }; + pub fn fromApi( allocator: *std.mem.Allocator, fs: *Fs.FileSystem, @@ -338,6 +344,10 @@ pub const BundleOptions = struct { opts.jsx = try JSX.Pragma.fromApi(jsx, allocator); } + if (transform.extension_order.len > 0) { + opts.extension_order = transform.extension_order; + } + if (transform.platform) |plat| { opts.platform = if (plat == .browser) .browser else .node; opts.main_fields = Platform.DefaultMainFields.get(opts.platform); diff --git a/src/resolver/resolver.zig b/src/resolver/resolver.zig index 88bc13cd5..118a6d45c 100644 --- a/src/resolver/resolver.zig +++ b/src/resolver/resolver.zig @@ -2,7 +2,7 @@ usingnamespace @import("../global.zig"); const ast = @import("../import_record.zig"); const logger = @import("../logger.zig"); const options = @import("../options.zig"); -const fs = @import("../fs.zig"); +const Fs = @import("../fs.zig"); const std = @import("std"); const cache = @import("../cache.zig"); @@ -12,7 +12,7 @@ usingnamespace @import("./data_url.zig"); const StringBoolMap = std.StringHashMap(bool); -const Path = fs.Path; +const Path = Fs.Path; pub const SideEffectsData = struct { source: *logger.Source, range: logger.Range, @@ -31,17 +31,23 @@ pub const DirInfo = struct { enclosing_browser_scope: ?*DirInfo = null, abs_path: string, - entries: fs.FileSystem.DirEntry, + entries: Fs.FileSystem.DirEntry, has_node_modules: bool = false, // Is there a "node_modules" subdirectory? package_json: ?*PackageJSON = null, // Is there a "package.json" file? tsconfig_json: ?*TSConfigJSON = null, // Is there a "tsconfig.json" file in this directory or a parent directory? abs_real_path: string = "", // If non-empty, this is the real absolute path resolving any symlinks }; +pub const TemporaryBuffer = struct { + pub var ExtensionPathBuf = std.mem.zeroes([512]u8); + pub var TSConfigMatchStarBuf = std.mem.zeroes([512]u8); + pub var TSConfigMatchPathBuf = std.mem.zeroes([512]u8); + pub var TSConfigMatchFullBuf = std.mem.zeroes([512]u8); +}; pub const Resolver = struct { opts: options.BundleOptions, - fs: *fs.FileSystem, + fs: *Fs.FileSystem, log: *logger.Log, allocator: *std.mem.Allocator, @@ -96,7 +102,7 @@ pub const Resolver = struct { pub fn init1( allocator: *std.mem.Allocator, log: *logger.Log, - _fs: *fs.FileSystem, + _fs: *Fs.FileSystem, opts: options.BundleOptions, ) Resolver { return Resolver{ @@ -160,6 +166,25 @@ pub const Resolver = struct { pub const PathPair = struct { primary: Path, secondary: ?Path = null, + + pub const Iter = struct { + index: u2, + ctx: *PathPair, + pub fn next(i: *Iter) ?Path { + const ind = i.index; + i.index += 1; + + switch (ind) { + 0 => return i.ctx.primary, + 1 => return i.ctx.secondary, + else => return null, + } + } + }; + + pub fn iter(p: *PathPair) Iter { + return Iter{ .ctx = p, .index = 0 }; + } }; pub const Result = struct { @@ -169,7 +194,7 @@ pub const Resolver = struct { is_external: bool = false, - diff_case: ?fs.FileSystem.Entry.Lookup.DifferentCase = null, + diff_case: ?Fs.FileSystem.Entry.Lookup.DifferentCase = null, // If present, any ES6 imports to this file can be considered to have no side // effects. This means they should be removed if unused. @@ -309,10 +334,10 @@ pub const Resolver = struct { return result; } - pub fn resolveWithoutSymlinks(r: *Resolver, source_dir: string, import_path: string, kind: ast.ImportKind) !Result { + pub fn resolveWithoutSymlinks(r: *Resolver, source_dir: string, import_path: string, kind: ast.ImportKind) !?Result { // This implements the module resolution algorithm from node.js, which is // described here: https://nodejs.org/api/modules.html#modules_all_together - var result: Result = undefined; + var result: Result = Result{ .path_pair = PathPair{ .primary = Path.init("") } }; // Return early if this is already an absolute path. In addition to asking // the file system whether this is an absolute path, we also explicitly check @@ -333,16 +358,264 @@ pub const Resolver = struct { const dir_info: *DirInfo = _dir_info; if (dir_info.tsconfig_json) |tsconfig| { if (tsconfig.paths.count() > 0) { - const res = r.matchTSConfigPaths(tsconfig, import_path, kind); - return Result{ .path_pair = res.path_pair, .diff_case = res.diff_case }; + if (r.matchTSConfigPaths(tsconfig, import_path, kind)) |res| { + return Result{ .path_pair = res.path_pair, .diff_case = res.diff_case }; + } + } + } + } + + if (r.opts.external.abs_paths.count() > 0 and r.opts.external.abs_paths.exists(import_path)) { + // If the string literal in the source text is an absolute path and has + // been marked as an external module, mark it as *not* an absolute path. + // That way we preserve the literal text in the output and don't generate + // a relative path from the output directory to that path. + if (r.debug_logs) |*debug| { + debug.addNoteFmt("The path \"{s}\" is marked as external by the user", .{import_path}) catch {}; + } + + return Result{ + .path_pair = .{ .primary = Path.init(import_path) }, + .is_external = true, + }; + } + + // Run node's resolution rules (e.g. adding ".js") + if (r.loadAsFileOrDirectory(import_path, kind)) |entry| { + return Result{ .path_pair = entry.path_pair, .diff_case = entry.diff_case }; + } + + return null; + } + + // Check both relative and package paths for CSS URL tokens, with relative + // paths taking precedence over package paths to match Webpack behavior. + const is_package_path = isPackagePath(import_path); + var check_relative = !is_package_path or kind == .url; + var check_package = is_package_path; + + if (check_relative) { + const parts = [_]string{ source_dir, import_path }; + const abs_path = std.fs.path.join(r.allocator, &parts) catch unreachable; + + if (r.opts.external.abs_paths.count() > 0 and r.opts.external.abs_paths.exists(abs_path)) { + // If the string literal in the source text is an absolute path and has + // been marked as an external module, mark it as *not* an absolute path. + // That way we preserve the literal text in the output and don't generate + // a relative path from the output directory to that path. + if (r.debug_logs) |*debug| { + debug.addNoteFmt("The path \"{s}\" is marked as external by the user", .{abs_path}) catch {}; + } + + return Result{ + .path_pair = .{ .primary = Path.init(abs_path) }, + .is_external = true, + }; + } + + // Check the "browser" map for the first time (1 out of 2) + if (r.dirInfoCached(std.fs.path.dirname(abs_path) orelse unreachable) catch null) |_import_dir_info| { + if (_import_dir_info.enclosing_browser_scope) |import_dir_info| { + if (import_dir_info.package_json) |pkg| { + const pkg_json_dir = std.fs.path.dirname(pkg.source.key_path.text) orelse unreachable; + + const rel_path = try std.fs.path.relative(r.allocator, pkg_json_dir, abs_path); + if (r.checkBrowserMap(pkg, rel_path)) |remap| { + // Is the path disabled? + if (remap.len == 0) { + var _path = Path.init(abs_path); + _path.is_disabled = true; + return Result{ + .path_pair = PathPair{ + .primary = _path, + }, + }; + } + + if (r.resolveWithoutRemapping(import_dir_info, remap, kind)) |_result| { + result = Result{ .path_pair = _result.path_pair, .diff_case = _result.diff_case }; + check_relative = false; + check_package = false; + } + } + } + } + } + + if (check_relative) { + if (r.loadAsFileOrDirectory(abs_path, kind)) |res| { + check_package = false; + result = Result{ .path_pair = res.path_pair, .diff_case = res.diff_case }; + } else if (!check_package) { + return null; + } + } + + if (check_package) { + // Check for external packages first + if (r.opts.external.node_modules.count() > 0) { + var query = import_path; + while (true) { + if (r.opts.external.node_modules.exists(query)) { + if (r.debug_logs) |*debug| { + debug.addNoteFmt("The path \"{s}\" was marked as external by the user", .{query}) catch {}; + } + return Result{ + .path_pair = .{ .primary = Path.init(query) }, + .is_external = true, + }; + } + + // If the module "foo" has been marked as external, we also want to treat + // paths into that module such as "foo/bar" as external too. + var slash = strings.lastIndexOfChar(query, '/') orelse break; + query = query[0..slash]; } } + + const source_dir_info = (r.dirInfoCached(source_dir) catch null) orelse return null; + + // Support remapping one package path to another via the "browser" field + if (source_dir_info.enclosing_browser_scope) |browser_scope| { + if (browser_scope.package_json) |package_json| { + if (r.checkBrowserMap(package_json, import_path)) |remapped| { + if (remapped.len == 0) { + // "browser": {"module": false} + if (r.loadNodeModules(import_path, kind, source_dir_info)) |node_module| { + var pair = node_module.path_pair; + pair.primary.is_disabled = true; + if (pair.secondary != null) { + pair.secondary.?.is_disabled = true; + } + return Result{ .path_pair = pair, .diff_case = node_module.diff_case }; + } + } else { + var primary = Path.init(import_path); + primary.is_disabled = true; + return Result{ + .path_pair = PathPair{ .primary = primary }, + // this might not be null? i think it is + .diff_case = null, + }; + } + } + } + } + + if (r.resolveWithoutRemapping(source_dir_info, import_path, kind)) |res| { + result = Result{ .path_pair = res.path_pair, .diff_case = res.diff_case }; + } else { + // Note: node's "self references" are not currently supported + return null; + } + } + } + + var iter = result.path_pair.iter(); + while (iter.next()) |*path| { + const dirname = std.fs.path.dirname(path.text) orelse continue; + const base_dir_info = ((r.dirInfoCached(dirname) catch null)) orelse continue; + const dir_info = base_dir_info.enclosing_browser_scope orelse continue; + const pkg_json = dir_info.package_json orelse continue; + const rel_path = std.fs.path.relative(r.allocator, pkg_json.source.key_path.text, path.text) catch continue; + if (r.checkBrowserMap(pkg_json, rel_path)) |remapped| { + if (remapped.len == 0) { + r.allocator.free(rel_path); + path.is_disabled = true; + } else if (r.resolveWithoutRemapping(dir_info, remapped, kind)) |remapped_result| { + switch (iter.index) { + 0 => { + result.path_pair.primary = remapped_result.path_pair.primary; + }, + else => { + result.path_pair.secondary = remapped_result.path_pair.primary; + }, + } + } else { + r.allocator.free(rel_path); + return null; + } + } else { + r.allocator.free(rel_path); } } return result; } + pub fn loadNodeModules(r: *Resolver, import_path: string, kind: ast.ImportKind, _dir_info: *DirInfo) ?MatchResult { + var dir_info = _dir_info; + if (r.debug_logs) |*debug| { + debug.addNoteFmt("Searching for {s} in \"node_modules\" directories starting from \"{s}\"", .{ import_path, dir_info.abs_path }) catch {}; + debug.increaseIndent() catch {}; + } + + defer { + if (r.debug_logs) |*debug| { + debug.decreaseIndent() catch {}; + } + } + + // First, check path overrides from the nearest enclosing TypeScript "tsconfig.json" file + + if (dir_info.tsconfig_json) |tsconfig| { + // Try path substitutions first + if (tsconfig.paths.count() > 0) { + if (r.matchTSConfigPaths(tsconfig, import_path, kind)) |res| { + return res; + } + } + + // Try looking up the path relative to the base URL + if (tsconfig.base_url) |base| { + const paths = [_]string{ base, import_path }; + const abs = std.fs.path.join(r.allocator, &paths) catch unreachable; + + if (r.loadAsFileOrDirectory(abs, kind)) |res| { + return res; + } + r.allocator.free(abs); + } + } + + // Then check for the package in any enclosing "node_modules" directories + while (true) { + // Skip directories that are themselves called "node_modules", since we + // don't ever want to search for "node_modules/node_modules" + if (dir_info.has_node_modules) { + var _paths = [_]string{ dir_info.abs_path, "node_modules", import_path }; + const abs_path = std.fs.path.join(r.allocator, &_paths) catch unreachable; + if (r.debug_logs) |*debug| { + debug.addNoteFmt("Checking for a package in the directory \"{s}\"", .{abs_path}) catch {}; + } + + // TODO: esm "exports" field goes here!!! Here!! + + if (r.loadAsFileOrDirectory(abs_path, kind)) |res| { + return res; + } + r.allocator.free(abs_path); + } + + dir_info = dir_info.parent orelse break; + } + + // Mostly to cut scope, we don't resolve `NODE_PATH` environment variable. + // But also: https://github.com/nodejs/node/issues/38128#issuecomment-814969356 + + return null; + } + + pub fn resolveWithoutRemapping(r: *Resolver, source_dir_info: *DirInfo, import_path: string, kind: ast.ImportKind) ?MatchResult { + if (isPackagePath(import_path)) { + return r.loadNodeModules(import_path, kind, source_dir_info); + } else { + const paths = [_]string{ source_dir_info.abs_path, import_path }; + var resolved = std.fs.path.join(r.allocator, &paths) catch unreachable; + return r.loadAsFileOrDirectory(resolved, kind); + } + } + pub const TSConfigExtender = struct { visited: *StringBoolMap, file_dir: string, @@ -433,16 +706,546 @@ pub const Resolver = struct { pub const MatchResult = struct { path_pair: PathPair, - ok: bool = false, - diff_case: ?fs.FileSystem.Entry.Lookup.DifferentCase = null, + diff_case: ?Fs.FileSystem.Entry.Lookup.DifferentCase = null, }; - pub fn matchTSConfigPaths(r: *Resolver, tsconfig: *TSConfigJSON, path: string, kind: ast.ImportKind) MatchResult { - Global.notimpl(); + // This closely follows the behavior of "tryLoadModuleUsingPaths()" in the + // official TypeScript compiler + pub fn matchTSConfigPaths(r: *Resolver, tsconfig: *TSConfigJSON, path: string, kind: ast.ImportKind) ?MatchResult { + if (r.debug_logs) |*debug| { + debug.addNoteFmt("Matching \"{s}\" against \"paths\" in \"{s}\"", .{ path, tsconfig.abs_path }) catch unreachable; + } + + var abs_base_url = tsconfig.base_url_for_paths; + + // The explicit base URL should take precedence over the implicit base URL + // if present. This matters when a tsconfig.json file overrides "baseUrl" + // from another extended tsconfig.json file but doesn't override "paths". + if (tsconfig.base_url) |base| { + abs_base_url = base; + } + + if (r.debug_logs) |*debug| { + debug.addNoteFmt("Using \"{s}\" as \"baseURL\"", .{abs_base_url}) catch unreachable; + } + + // Check for exact matches first + { + var iter = tsconfig.paths.iterator(); + while (iter.next()) |entry| { + const key = entry.key; + + if (strings.eql(key, path)) { + for (entry.value) |original_path| { + var absolute_original_path = original_path; + var was_alloc = false; + + if (!std.fs.path.isAbsolute(absolute_original_path)) { + const parts = [_]string{ abs_base_url, original_path }; + absolute_original_path = std.fs.path.join(r.allocator, &parts) catch unreachable; + was_alloc = true; + } + + if (r.loadAsFileOrDirectory(absolute_original_path, kind)) |res| { + return res; + } else if (was_alloc) { + r.allocator.free(absolute_original_path); + } + } + + return null; + } + } + } + + const TSConfigMatch = struct { + prefix: string, + suffix: string, + original_paths: []string, + }; + + var longest_match: TSConfigMatch = undefined; + var longest_match_prefix_length: i32 = -1; + var longest_match_suffix_length: i32 = -1; + + var iter = tsconfig.paths.iterator(); + while (iter.next()) |entry| { + const key = entry.key; + const original_paths = entry.value; + + if (strings.indexOfChar(key, '*')) |star_index| { + const prefix = key[0..star_index]; + const suffix = key[star_index..key.len]; + + // Find the match with the longest prefix. If two matches have the same + // prefix length, pick the one with the longest suffix. This second edge + // case isn't handled by the TypeScript compiler, but we handle it + // because we want the output to always be deterministic and Go map + // iteration order is deliberately non-deterministic. + if (strings.startsWith(path, prefix) and strings.endsWith(path, suffix) and (prefix.len > longest_match_prefix_length or (prefix.len == longest_match_prefix_length and suffix.len > longest_match_suffix_length))) { + longest_match_prefix_length = @intCast(i32, prefix.len); + longest_match_suffix_length = @intCast(i32, suffix.len); + longest_match = TSConfigMatch{ .prefix = prefix, .suffix = suffix, .original_paths = original_paths }; + } + } + } + + // If there is at least one match, only consider the one with the longest + // prefix. This matches the behavior of the TypeScript compiler. + if (longest_match_prefix_length > -1) { + if (r.debug_logs) |*debug| { + debug.addNoteFmt("Found a fuzzy match for \"{s}*{s}\" in \"paths\"", .{ longest_match.prefix, longest_match.suffix }) catch unreachable; + } + + for (longest_match.original_paths) |original_path| { + // Swap out the "*" in the original path for whatever the "*" matched + const matched_text = path[longest_match.prefix.len .. path.len - longest_match.suffix.len]; + + std.mem.copy( + u8, + &TemporaryBuffer.TSConfigMatchPathBuf, + original_path, + ); + var start: usize = 0; + var total_length: usize = 0; + const star = std.mem.indexOfScalar(u8, original_path, '*') orelse unreachable; + total_length = star; + std.mem.copy(u8, &TemporaryBuffer.TSConfigMatchPathBuf, original_path[0..total_length]); + start = total_length; + total_length += matched_text.len; + std.mem.copy(u8, TemporaryBuffer.TSConfigMatchPathBuf[start..total_length], matched_text); + start = total_length; + + total_length += original_path.len - star + 1; // this might be an off by one. + std.mem.copy(u8, TemporaryBuffer.TSConfigMatchPathBuf[start..TemporaryBuffer.TSConfigMatchPathBuf.len], original_path[star..original_path.len]); + const region = TemporaryBuffer.TSConfigMatchPathBuf[0..total_length]; + + // Load the original path relative to the "baseUrl" from tsconfig.json + var absolute_original_path = region; + + var did_allocate = false; + if (!std.fs.path.isAbsolute(region)) { + const paths = [_]string{ abs_base_url, original_path }; + absolute_original_path = std.fs.path.join(r.allocator, &paths) catch unreachable; + did_allocate = true; + } else { + absolute_original_path = std.mem.dupe(r.allocator, u8, region) catch unreachable; + } + + if (r.loadAsFileOrDirectory(absolute_original_path, kind)) |res| { + return res; + } + } + } + + return null; + } + + pub const LoadResult = struct { + path: string, + diff_case: ?Fs.FileSystem.Entry.Lookup.DifferentCase, + }; + + pub fn checkBrowserMap(r: *Resolver, pkg: *PackageJSON, input_path: string) ?string { + // Normalize the path so we can compare against it without getting confused by "./" + var cleaned = Path.normalizeNoAlloc(input_path, true); + const original_cleaned = cleaned; + + if (cleaned.len == 1 and cleaned[0] == '.') { + // No bundler supports remapping ".", so we don't either + return null; + } + + if (r.debug_logs) |*debug| { + debug.addNoteFmt("Checking for \"{s}\" in the \"browser\" map in \"{s}\"", .{ input_path, pkg.source.path.text }) catch {}; + } + + if (r.debug_logs) |*debug| { + debug.addNoteFmt("Checking for \"{s}\" ", .{cleaned}) catch {}; + } + var remapped = pkg.browser_map.get(cleaned); + if (remapped == null) { + for (r.opts.extension_order) |ext| { + std.mem.copy(u8, &TemporaryBuffer.ExtensionPathBuf, cleaned); + std.mem.copy(u8, TemporaryBuffer.ExtensionPathBuf[cleaned.len .. cleaned.len + ext.len], ext); + const new_path = TemporaryBuffer.ExtensionPathBuf[0 .. cleaned.len + ext.len]; + if (r.debug_logs) |*debug| { + debug.addNoteFmt("Checking for \"{s}\" ", .{new_path}) catch {}; + } + if (pkg.browser_map.get(new_path)) |_remapped| { + remapped = _remapped; + cleaned = new_path; + break; + } + } + } + + if (remapped) |remap| { + // "" == disabled, {"browser": { "file.js": false }} + if (remap.len == 0 or (remap.len == 1 and remap[0] == '.')) { + if (r.debug_logs) |*debug| { + debug.addNoteFmt("Found \"{s}\" marked as disabled", .{remap}) catch {}; + } + return remap; + } + + if (r.debug_logs) |*debug| { + debug.addNoteFmt("Found \"{s}\" remapped to \"{s}\"", .{ original_cleaned, remap }) catch {}; + } + + // Only allocate on successful remapping. + return r.allocator.dupe(u8, remap) catch unreachable; + } + + return null; + } + + pub fn loadFromMainField(r: *Resolver, path: string, dir_info: *DirInfo, _field_rel_path: string, field: string, extension_order: []const string) ?MatchResult { + var field_rel_path = _field_rel_path; + // Is this a directory? + if (r.debug_logs) |*debug| { + debug.addNoteFmt("Found main field \"{s}\" with path \"{s}\"", .{ field, field_rel_path }) catch {}; + debug.increaseIndent() catch {}; + } + + defer { + if (r.debug_logs) |*debug| { + debug.decreaseIndent() catch {}; + } + } + + // Potentially remap using the "browser" field + if (dir_info.enclosing_browser_scope) |browser_scope| { + if (browser_scope.package_json) |browser_json| { + if (r.checkBrowserMap(browser_json, field_rel_path)) |remap| { + // Is the path disabled? + if (remap.len == 0) { + const paths = [_]string{ path, field_rel_path }; + const new_path = std.fs.path.join(r.allocator, &paths) catch unreachable; + var _path = Path.init(new_path); + _path.is_disabled = true; + return MatchResult{ + .path_pair = PathPair{ + .primary = _path, + }, + }; + } + + field_rel_path = remap; + } + } + } + + return r.loadAsIndex(dir_info, path, extension_order); + } + + pub fn loadAsIndex(r: *Resolver, dir_info: *DirInfo, path: string, extension_order: []const string) ?MatchResult { + var rfs = &r.fs.fs; + // Try the "index" file with extensions + for (extension_order) |ext| { + var base = TemporaryBuffer.ExtensionPathBuf[0 .. "index".len + ext.len]; + base[0.."index".len].* = "index".*; + std.mem.copy(u8, base["index".len..base.len], ext); + + if (dir_info.entries.get(base)) |lookup| { + if (lookup.entry.kind(rfs) == .file) { + const parts = [_]string{ path, base }; + const out_buf = std.fs.path.join(r.allocator, &parts) catch unreachable; + if (r.debug_logs) |*debug| { + debug.addNoteFmt("Found file: \"{s}\"", .{out_buf}) catch unreachable; + } + + return MatchResult{ .path_pair = .{ .primary = Path.init(out_buf) }, .diff_case = lookup.diff_case }; + } + } + + if (r.debug_logs) |*debug| { + debug.addNoteFmt("Failed to find file: \"{s}/{s}\"", .{ path, base }) catch unreachable; + } + } + + return null; + } + + pub fn loadAsIndexWithBrowserRemapping(r: *Resolver, dir_info: *DirInfo, path: string, extension_order: []const string) ?MatchResult { + if (dir_info.enclosing_browser_scope) |browser_scope| { + comptime const field_rel_path = "index"; + if (browser_scope.package_json) |browser_json| { + if (r.checkBrowserMap(browser_json, field_rel_path)) |remap| { + // Is the path disabled? + // This doesn't really make sense to me. + if (remap.len == 0) { + const paths = [_]string{ path, field_rel_path }; + const new_path = std.fs.path.join(r.allocator, &paths) catch unreachable; + var _path = Path.init(new_path); + _path.is_disabled = true; + return MatchResult{ + .path_pair = PathPair{ + .primary = _path, + }, + }; + } + + const new_paths = [_]string{ path, remap }; + const remapped_abs = std.fs.path.join(r.allocator, &new_paths) catch unreachable; + + // Is this a file + if (r.loadAsFile(remapped_abs, extension_order)) |file_result| { + return MatchResult{ .path_pair = .{ .primary = Path.init(file_result.path) }, .diff_case = file_result.diff_case }; + } + + // Is it a directory with an index? + if (r.dirInfoCached(remapped_abs) catch null) |new_dir| { + if (r.loadAsIndex(new_dir, remapped_abs, extension_order)) |absolute| { + return absolute; + } + } + + return null; + } + } + } + + return r.loadAsIndex(dir_info, path, extension_order); + } + + pub fn loadAsFileOrDirectory(r: *Resolver, path: string, kind: ast.ImportKind) ?MatchResult { + const extension_order = r.opts.extension_order; + + // Is this a file? + if (r.loadAsFile(path, extension_order)) |file| { + return MatchResult{ .path_pair = .{ .primary = Path.init(file.path) }, .diff_case = file.diff_case }; + } + + // Is this a directory? + if (r.debug_logs) |*debug| { + debug.addNoteFmt("Attempting to load \"{s}\" as a directory", .{path}) catch {}; + debug.increaseIndent() catch {}; + } + defer { + if (r.debug_logs) |*debug| { + debug.decreaseIndent() catch {}; + } + } + + const dir_info = (r.dirInfoCached(path) catch null) orelse return null; + + // Try using the main field(s) from "package.json" + if (dir_info.package_json) |pkg_json| { + if (pkg_json.main_fields.count() > 0) { + const main_field_values = pkg_json.main_fields; + const main_field_keys = r.opts.main_fields; + // TODO: check this works right. Not sure this will really work. + const auto_main = r.opts.main_fields.ptr == options.Platform.DefaultMainFields.get(r.opts.platform).ptr; + + if (r.debug_logs) |*debug| { + debug.addNoteFmt("Searching for main fields in \"{s}\"", .{pkg_json.source.path.text}) catch {}; + } + + for (main_field_keys) |key| { + const field_rel_path = (main_field_values.get(key)) orelse { + if (r.debug_logs) |*debug| { + debug.addNoteFmt("Did not find main field \"{s}\"", .{key}) catch {}; + } + continue; + }; + + var _result = r.loadFromMainField(path, dir_info, field_rel_path, key, extension_order) orelse continue; + + // If the user did not manually configure a "main" field order, then + // use a special per-module automatic algorithm to decide whether to + // use "module" or "main" based on whether the package is imported + // using "import" or "require". + if (auto_main and strings.eqlComptime(key, "module")) { + var absolute_result: ?MatchResult = null; + + if (main_field_values.get("main")) |main_rel_path| { + if (main_rel_path.len > 0) { + absolute_result = r.loadFromMainField(path, dir_info, main_rel_path, "main", extension_order); + } + } else { + // Some packages have a "module" field without a "main" field but + // still have an implicit "index.js" file. In that case, treat that + // as the value for "main". + absolute_result = r.loadAsIndexWithBrowserRemapping(dir_info, path, extension_order); + } + + if (absolute_result) |auto_main_result| { + // If both the "main" and "module" fields exist, use "main" if the + // path is for "require" and "module" if the path is for "import". + // If we're using "module", return enough information to be able to + // fall back to "main" later if something ended up using "require()" + // with this same path. The goal of this code is to avoid having + // both the "module" file and the "main" file in the bundle at the + // same time. + if (kind != ast.ImportKind.require) { + if (r.debug_logs) |*debug| { + debug.addNoteFmt("Resolved to \"{s}\" using the \"module\" field in \"{s}\"", .{ auto_main_result.path_pair.primary.text, pkg_json.source.key_path.text }) catch {}; + + debug.addNoteFmt("The fallback path in case of \"require\" is {s}", .{auto_main_result.path_pair.primary.text}) catch {}; + } + + return MatchResult{ + .path_pair = .{ + .primary = auto_main_result.path_pair.primary, + .secondary = _result.path_pair.primary, + }, + .diff_case = auto_main_result.diff_case, + }; + } else { + if (r.debug_logs) |*debug| { + debug.addNoteFmt("Resolved to \"{s}\" using the \"{s}\" field in \"{s}\"", .{ + auto_main_result.path_pair.primary.text, + key, + pkg_json.source.key_path.text, + }) catch {}; + } + + return auto_main_result; + } + } + } + } + } + } + + // Look for an "index" file with known extensions + return r.loadAsIndexWithBrowserRemapping(dir_info, path, extension_order); + } + + pub fn loadAsFile(r: *Resolver, path: string, extension_order: []const string) ?LoadResult { + var rfs: *Fs.FileSystem.RealFS = &r.fs.fs; + + if (r.debug_logs) |*debug| { + debug.addNoteFmt("Attempting to load \"{s}\" as a file", .{path}) catch {}; + debug.increaseIndent() catch {}; + } + defer { + if (r.debug_logs) |*debug| { + debug.decreaseIndent() catch {}; + } + } + + // Read the directory entries once to minimize locking + const dir_path = std.fs.path.dirname(path) orelse unreachable; // Expected path to be a file. + const dir_entry: Fs.FileSystem.RealFS.EntriesOption = r.fs.fs.readDirectory(dir_path) catch { + return null; + }; + + if (@as(Fs.FileSystem.RealFS.EntriesOption.Tag, dir_entry) == .err) { + if (dir_entry.err.original_err != error.ENOENT) { + r.log.addErrorFmt( + null, + logger.Loc.Empty, + r.allocator, + "Cannot read directory \"{s}\": {s}", + .{ + r.prettyPath(Path.init(dir_path)), + @errorName(dir_entry.err.original_err), + }, + ) catch {}; + } + return null; + } + + var entries = dir_entry.entries; + + const base = std.fs.path.basename(path); + + // Try the plain path without any extensions + if (r.debug_logs) |*debug| { + debug.addNoteFmt("Checking for file \"{s}\" ", .{base}) catch {}; + } + + if (entries.get(base)) |query| { + if (query.entry.kind(rfs) == .file) { + if (r.debug_logs) |*debug| { + debug.addNoteFmt("Found file \"{s}\" ", .{base}) catch {}; + } + + return LoadResult{ .path = base, .diff_case = query.diff_case }; + } + } + + // Try the path with extensions + std.mem.copy(u8, &TemporaryBuffer.ExtensionPathBuf, path); + for (r.opts.extension_order) |ext| { + var buffer = TemporaryBuffer.ExtensionPathBuf[0 .. path.len + ext.len]; + std.mem.copy(u8, buffer[path.len..buffer.len], ext); + + if (r.debug_logs) |*debug| { + debug.addNoteFmt("Checking for file \"{s}{s}\" ", .{ base, ext }) catch {}; + } + + if (entries.get(buffer)) |query| { + if (query.entry.kind(rfs) == .file) { + if (r.debug_logs) |*debug| { + debug.addNoteFmt("Found file \"{s}\" ", .{buffer}) catch {}; + } + + // now that we've found it, we allocate it. + return LoadResult{ + .path = rfs.allocator.dupe(u8, buffer) catch unreachable, + .diff_case = query.diff_case, + }; + } + } + } + + // TypeScript-specific behavior: if the extension is ".js" or ".jsx", try + // replacing it with ".ts" or ".tsx". At the time of writing this specific + // behavior comes from the function "loadModuleFromFile()" in the file + // "moduleNameResolver.ts" in the TypeScript compiler source code. It + // contains this comment: + // + // If that didn't work, try stripping a ".js" or ".jsx" extension and + // replacing it with a TypeScript one; e.g. "./foo.js" can be matched + // by "./foo.ts" or "./foo.d.ts" + // + // We don't care about ".d.ts" files because we can't do anything with + // those, so we ignore that part of the behavior. + // + // See the discussion here for more historical context: + // https://github.com/microsoft/TypeScript/issues/4595 + if (strings.lastIndexOfChar(base, '.')) |last_dot| { + const ext = base[last_dot..base.len]; + if (strings.eql(ext, ".js") or strings.eql(ext, ".jsx")) { + const segment = base[0..last_dot]; + std.mem.copy(u8, &TemporaryBuffer.ExtensionPathBuf, segment); + + comptime const exts = [_]string{ ".ts", ".tsx" }; + + for (exts) |ext_to_replace| { + var buffer = TemporaryBuffer.ExtensionPathBuf[0 .. segment.len + ext_to_replace.len]; + std.mem.copy(u8, buffer[segment.len..buffer.len], ext_to_replace); + + if (entries.get(buffer)) |query| { + if (query.entry.kind(rfs) == .file) { + if (r.debug_logs) |*debug| { + debug.addNoteFmt("Rewrote to \"{s}\" ", .{buffer}) catch {}; + } + + return LoadResult{ + .path = rfs.allocator.dupe(u8, buffer) catch unreachable, + .diff_case = query.diff_case, + }; + } + } + if (r.debug_logs) |*debug| { + debug.addNoteFmt("Failed to rewrite \"{s}\" ", .{base}) catch {}; + } + } + } + } + + if (r.debug_logs) |*debug| { + debug.addNoteFmt("Failed to find \"{s}\" ", .{path}) catch {}; + } + return null; } fn dirInfoUncached(r: *Resolver, path: string) anyerror!?*DirInfo { - var rfs: *fs.FileSystem.RealFS = &r.fs.fs; + var rfs: *Fs.FileSystem.RealFS = &r.fs.fs; var parent: ?*DirInfo = null; const parent_dir = std.fs.path.dirname(path) orelse return null; if (!strings.eql(parent_dir, path)) { @@ -459,7 +1262,7 @@ pub const Resolver = struct { // continue to check the directories above it, which is now node behaves. switch (_entries.err.original_err) { error.EACCESS => { - entries = fs.FileSystem.DirEntry.empty(path, r.allocator); + entries = Fs.FileSystem.DirEntry.empty(path, r.allocator); }, // Ignore "ENOTDIR" here so that calling "ReadDirectory" on a file behaves diff --git a/src/resolver/tsconfig_json.zig b/src/resolver/tsconfig_json.zig index 6c7992833..52469d6bf 100644 --- a/src/resolver/tsconfig_json.zig +++ b/src/resolver/tsconfig_json.zig @@ -7,7 +7,10 @@ const js_ast = @import("../js_ast.zig"); const js_lexer = @import("../js_lexer.zig"); const alloc = @import("../alloc.zig"); -const PathsMap = std.StringHashMap([]string); +// Heuristic: you probably don't have 100 of these +// Probably like 5-10 +// Array iteration is faster and deterministically ordered in that case. +const PathsMap = std.StringArrayHashMap([]string); pub const TSConfigJSON = struct { abs_path: string, |