feat: lookup path (builtins.findFile)

This commit is contained in:
2026-01-24 18:24:48 +08:00
parent 3d315cd050
commit 13a7d761f4
13 changed files with 371 additions and 20 deletions

36
Cargo.lock generated
View File

@@ -1927,11 +1927,11 @@ dependencies = [
"rnix", "rnix",
"rowan", "rowan",
"rusqlite", "rusqlite",
"rust-embed",
"rustyline", "rustyline",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
"sourcemap",
"string-interner", "string-interner",
"tar", "tar",
"tempfile", "tempfile",
@@ -2587,6 +2587,40 @@ dependencies = [
"smallvec", "smallvec",
] ]
[[package]]
name = "rust-embed"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27"
dependencies = [
"rust-embed-impl",
"rust-embed-utils",
"walkdir",
]
[[package]]
name = "rust-embed-impl"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa"
dependencies = [
"proc-macro2",
"quote",
"rust-embed-utils",
"syn",
"walkdir",
]
[[package]]
name = "rust-embed-utils"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
dependencies = [
"sha2",
"walkdir",
]
[[package]] [[package]]
name = "rustc-demangle" name = "rustc-demangle"
version = "0.1.27" version = "0.1.27"

View File

@@ -30,6 +30,8 @@ hashbrown = "0.16"
petgraph = "0.8" petgraph = "0.8"
string-interner = "0.19" string-interner = "0.19"
rust-embed="8.11"
itertools = "0.14" itertools = "0.14"
regex = "1.11" regex = "1.11"
@@ -41,7 +43,6 @@ nix-nar = "0.3"
sha2 = "0.10" sha2 = "0.10"
hex = "0.4" hex = "0.4"
sourcemap = "9.0"
base64 = "0.22" base64 = "0.22"
# Fetcher dependencies # Fetcher dependencies

View File

@@ -8,7 +8,7 @@ import { force } from "../thunk";
import { type NixStringContext, mkStringWithContext, addBuiltContext } from "../string-context"; import { type NixStringContext, mkStringWithContext, addBuiltContext } from "../string-context";
import { forceFunction } from "../type-assert"; import { forceFunction } from "../type-assert";
import { nixValueToJson } from "../conversion"; import { nixValueToJson } from "../conversion";
import { typeOf } from "./type-check"; import { isAttrs, isPath, typeOf } from "./type-check";
const convertJsonToNix = (json: unknown): NixValue => { const convertJsonToNix = (json: unknown): NixValue => {
if (json === null) { if (json === null) {
@@ -283,6 +283,16 @@ export const coerceToStringWithContext = (
* - Preserves string context if present * - Preserves string context if present
*/ */
export const coerceToPath = (value: NixValue, outContext?: NixStringContext): string => { export const coerceToPath = (value: NixValue, outContext?: NixStringContext): string => {
const forced = force(value);
if (isPath(forced)) {
return forced.value;
}
if (isAttrs(forced) && Object.hasOwn(forced, "__toString")) {
const toStringFunc = forceFunction(forced.__toString);
return coerceToPath(toStringFunc(forced), outContext);
}
const pathStr = coerceToString(value, StringCoercionMode.Base, false, outContext); const pathStr = coerceToString(value, StringCoercionMode.Base, false, outContext);
if (pathStr === "") { if (pathStr === "") {

View File

@@ -3,15 +3,16 @@
* Implemented via Rust ops exposed through deno_core * Implemented via Rust ops exposed through deno_core
*/ */
import { forceAttrs, forceBool, forceStringValue } from "../type-assert"; import { forceAttrs, forceBool, forceList, forceStringNoCtx, forceStringValue } from "../type-assert";
import type { NixValue, NixAttrs } from "../types"; import type { NixValue, NixAttrs, NixPath } from "../types";
import { isNixPath } from "../types"; import { isNixPath, IS_PATH, CatchableError } from "../types";
import { force } from "../thunk"; import { force } from "../thunk";
import { coerceToPath, coerceToString, StringCoercionMode } from "./conversion"; import { coerceToPath, coerceToString, StringCoercionMode } from "./conversion";
import { getPathValue } from "../path"; import { getPathValue } from "../path";
import type { NixStringContext, StringWithContext } from "../string-context"; import type { NixStringContext, StringWithContext } from "../string-context";
import { mkStringWithContext } from "../string-context"; import { mkStringWithContext } from "../string-context";
import { isPath } from "./type-check"; import { isPath } from "./type-check";
import { getCorepkg } from "../corepkgs";
export const importFunc = (path: NixValue): NixValue => { export const importFunc = (path: NixValue): NixValue => {
const context: NixStringContext = new Set(); const context: NixStringContext = new Set();
@@ -390,10 +391,70 @@ export const filterSource = (args: NixValue): never => {
throw new Error("Not implemented: filterSource"); throw new Error("Not implemented: filterSource");
}; };
const suffixIfPotentialMatch = (prefix: string, path: string): string | null => {
const n = prefix.length;
const needSeparator = n > 0 && n < path.length;
if (needSeparator && path[n] !== "/") {
return null;
}
if (!path.startsWith(prefix)) {
return null;
}
return needSeparator ? path.substring(n + 1) : path.substring(n);
};
export const findFile = export const findFile =
(search: NixValue) => (searchPath: NixValue) =>
(lookup: NixValue): never => { (lookupPath: NixValue): NixPath => {
throw new Error("Not implemented: findFile"); const forcedSearchPath = forceList(searchPath);
const lookupPathStr = forceStringNoCtx(lookupPath);
for (const item of forcedSearchPath) {
const attrs = forceAttrs(item);
const prefix = "prefix" in attrs ? forceStringNoCtx(attrs.prefix) : "";
if (!("path" in attrs)) {
throw new Error("findFile: search path element is missing 'path' attribute");
}
const suffix = suffixIfPotentialMatch(prefix, lookupPathStr);
if (suffix === null) {
continue;
}
const context: NixStringContext = new Set();
const pathVal = coerceToString(attrs.path, StringCoercionMode.Interpolation, false, context);
if (context.size > 0) {
throw new Error("findFile: path with string context is not yet supported");
}
const resolvedPath = Deno.core.ops.op_resolve_path(pathVal, "");
const candidatePath = suffix.length > 0
? Deno.core.ops.op_resolve_path(suffix, resolvedPath)
: resolvedPath;
if (Deno.core.ops.op_path_exists(candidatePath)) {
return { [IS_PATH]: true, value: candidatePath };
}
}
if (lookupPathStr.startsWith("nix/")) {
const corepkgName = lookupPathStr.substring(4);
const corepkgContent = getCorepkg(corepkgName);
if (corepkgContent !== undefined) {
// FIXME: special path type
return { [IS_PATH]: true, value: `<nix/${corepkgName}>` };
}
}
throw new CatchableError(`file '${lookupPathStr}' was not found in the Nix search path`);
}; };
export const getEnv = (s: NixValue): string => { export const getEnv = (s: NixValue): string => {

View File

@@ -0,0 +1,73 @@
export const FETCHURL_NIX = `{
system ? "", # obsolete
url,
hash ? "", # an SRI hash
# Legacy hash specification
md5 ? "",
sha1 ? "",
sha256 ? "",
sha512 ? "",
outputHash ?
if hash != "" then
hash
else if sha512 != "" then
sha512
else if sha1 != "" then
sha1
else if md5 != "" then
md5
else
sha256,
outputHashAlgo ?
if hash != "" then
""
else if sha512 != "" then
"sha512"
else if sha1 != "" then
"sha1"
else if md5 != "" then
"md5"
else
"sha256",
executable ? false,
unpack ? false,
name ? baseNameOf (toString url),
impure ? false,
}:
derivation (
{
builder = "builtin:fetchurl";
# New-style output content requirements.
outputHashMode = if unpack || executable then "recursive" else "flat";
inherit
name
url
executable
unpack
;
system = "builtin";
# No need to double the amount of network traffic
preferLocalBuild = true;
# This attribute does nothing; it's here to avoid changing evaluation results.
impureEnvVars = [
"http_proxy"
"https_proxy"
"ftp_proxy"
"all_proxy"
"no_proxy"
];
# To make "nix-prefetch-url" work.
urls = [ url ];
}
// (if impure then { __impure = true; } else { inherit outputHashAlgo outputHash; })
)
`;

View File

@@ -0,0 +1,9 @@
import { FETCHURL_NIX } from "./fetchurl.nix";
export const COREPKGS: Record<string, string> = {
"fetchurl.nix": FETCHURL_NIX,
};
export const getCorepkg = (name: string): string | undefined => {
return COREPKGS[name];
};

View File

@@ -169,7 +169,15 @@ export const concatStringsWithContext = (parts: NixValue[]): NixString | NixPath
* @returns NixPath object with absolute path * @returns NixPath object with absolute path
*/ */
export const resolvePath = (currentDir: string, path: NixValue): NixPath => { export const resolvePath = (currentDir: string, path: NixValue): NixPath => {
const pathStr = forceStringValue(path); const forced = force(path);
let pathStr: string;
if (isNixPath(forced)) {
pathStr = forced.value;
} else {
pathStr = forceStringValue(path);
}
const resolved = Deno.core.ops.op_resolve_path(currentDir, pathStr); const resolved = Deno.core.ops.op_resolve_path(currentDir, pathStr);
return mkPath(resolved); return mkPath(resolved);
}; };

View File

@@ -85,6 +85,17 @@ export const forceString = (value: NixValue): NixString => {
throw new TypeError(`Expected string, got ${typeOf(forced)}`); throw new TypeError(`Expected string, got ${typeOf(forced)}`);
}; };
export const forceStringNoCtx = (value: NixValue): string => {
const forced = force(value);
if (typeof forced === "string") {
return forced;
}
if (isStringWithContext(forced)) {
throw new TypeError(`the string '${forced.value}' is not allowed to refer to a store path`)
}
throw new TypeError(`Expected string, got ${typeOf(forced)}`);
}
/** /**
* Force a value and assert it's a boolean * Force a value and assert it's a boolean
* @throws TypeError if value is not a boolean after forcing * @throws TypeError if value is not a boolean after forcing

View File

@@ -5,7 +5,7 @@
import { force, IS_THUNK } from "./thunk"; import { force, IS_THUNK } from "./thunk";
import { type StringWithContext, HAS_CONTEXT, isStringWithContext, getStringContext } from "./string-context"; import { type StringWithContext, HAS_CONTEXT, isStringWithContext, getStringContext } from "./string-context";
import { op } from "./operators"; import { op } from "./operators";
import { forceAttrs } from "./type-assert"; import { forceAttrs, forceStringNoCtx } from "./type-assert";
import { isString, typeOf } from "./builtins/type-check"; import { isString, typeOf } from "./builtins/type-check";
export { HAS_CONTEXT, isStringWithContext }; export { HAS_CONTEXT, isStringWithContext };
export type { StringWithContext }; export type { StringWithContext };
@@ -81,13 +81,8 @@ export const mkAttrs = (attrs: NixAttrs, keys: NixValue[], values: NixValue[]):
if (key === null) { if (key === null) {
continue; continue;
} }
if (!isString(key)) { const str = forceStringNoCtx(key);
throw `Expected string, got ${typeOf(key)}` attrs[str] = values[i];
}
if (isStringWithContext(key)) {
throw new TypeError(`the string '${key.value}' is not allowed to refer to a store path`)
}
attrs[key] = values[i];
} }
return attrs; return attrs;
}; };

View File

@@ -103,7 +103,19 @@ impl<Ctx: DowngradeContext> Downgrade<Ctx> for ast::Path {
.collect::<Result<Vec<_>>>()?; .collect::<Result<Vec<_>>>()?;
let expr = if parts.len() == 1 { let expr = if parts.len() == 1 {
parts.into_iter().next().unwrap() let part = parts.into_iter().next().unwrap();
if let &Ir::Str(Str { ref val, span }) = ctx.get_ir(part)
&& let Some(path) = val.strip_prefix("<").map(|path| &path[..path.len() - 1]) {
ctx.replace_ir(part, Str { val: path.to_string(), span }.to_ir());
let sym = ctx.new_sym("findFile".into());
let find_file = ctx.new_expr(Builtin { inner: sym, span }.to_ir());
let sym = ctx.new_sym("nixPath".into());
let nix_path = ctx.new_expr(Builtin { inner: sym, span }.to_ir());
let call = ctx.new_expr(Call { func: find_file, arg: nix_path, span }.to_ir());
return Ok(ctx.new_expr(Call { func: call, arg: part, span }.to_ir()));
} else {
part
}
} else { } else {
ctx.new_expr(ConcatStrings { parts, span }.to_ir()) ctx.new_expr(ConcatStrings { parts, span }.to_ir())
}; };

View File

@@ -1,11 +1,12 @@
use std::borrow::Cow; use std::borrow::Cow;
use std::marker::PhantomData; use std::marker::PhantomData;
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use std::sync::Once; use std::sync::{Arc, Once};
use deno_core::{Extension, ExtensionFileSource, JsRuntime, OpState, RuntimeOptions, v8}; use deno_core::{Extension, ExtensionFileSource, JsRuntime, OpState, RuntimeOptions, v8};
use deno_error::JsErrorClass; use deno_error::JsErrorClass;
use itertools::Itertools as _; use itertools::Itertools as _;
use rust_embed::Embed;
use crate::error::{Error, Result, Source}; use crate::error::{Error, Result, Source};
use crate::store::Store; use crate::store::Store;
@@ -96,6 +97,10 @@ mod private {
} }
pub(crate) use private::NixError; pub(crate) use private::NixError;
#[derive(Embed)]
#[folder = "src/runtime/corepkgs"]
pub(crate) struct CorePkgs;
#[deno_core::op2] #[deno_core::op2]
#[string] #[string]
fn op_import<Ctx: RuntimeContext>( fn op_import<Ctx: RuntimeContext>(
@@ -105,6 +110,24 @@ fn op_import<Ctx: RuntimeContext>(
let _span = tracing::info_span!("op_import", path = %path).entered(); let _span = tracing::info_span!("op_import", path = %path).entered();
let ctx: &mut Ctx = state.get_ctx_mut(); let ctx: &mut Ctx = state.get_ctx_mut();
// FIXME: special path type
if path.starts_with("<nix/") && path.ends_with(">") {
let corepkg_name = &path[5..path.len() - 1];
if let Some(file) = CorePkgs::get(corepkg_name) {
tracing::info!("Importing corepkg: {}", corepkg_name);
let source = Source {
ty: crate::error::SourceType::Eval(Arc::new(ctx.get_current_dir().to_path_buf())),
src: str::from_utf8(&file.data)
.expect("corrupted corepkgs file")
.into(),
};
ctx.add_source(source.clone());
return Ok(ctx.compile_code(source).map_err(|err| err.to_string())?);
} else {
return Err(format!("Corepkg not found: {}", corepkg_name).into());
}
}
let current_dir = ctx.get_current_dir(); let current_dir = ctx.get_current_dir();
let mut absolute_path = current_dir let mut absolute_path = current_dir
.join(&path) .join(&path)

View File

@@ -0,0 +1,76 @@
{
system ? "", # obsolete
url,
hash ? "", # an SRI hash
# Legacy hash specification
md5 ? "",
sha1 ? "",
sha256 ? "",
sha512 ? "",
outputHash ?
if hash != "" then
hash
else if sha512 != "" then
sha512
else if sha1 != "" then
sha1
else if md5 != "" then
md5
else
sha256,
outputHashAlgo ?
if hash != "" then
""
else if sha512 != "" then
"sha512"
else if sha1 != "" then
"sha1"
else if md5 != "" then
"md5"
else
"sha256",
executable ? false,
unpack ? false,
name ? baseNameOf (toString url),
# still translates to __impure to trigger derivationStrict error checks.
impure ? false,
}:
derivation (
{
builder = "builtin:fetchurl";
# New-style output content requirements.
outputHashMode = if unpack || executable then "recursive" else "flat";
inherit
name
url
executable
unpack
;
system = "builtin";
# No need to double the amount of network traffic
preferLocalBuild = true;
impureEnvVars = [
# We borrow these environment variables from the caller to allow
# easy proxy configuration. This is impure, but a fixed-output
# derivation like fetchurl is allowed to do so since its result is
# by definition pure.
"http_proxy"
"https_proxy"
"ftp_proxy"
"all_proxy"
"no_proxy"
];
# To make "nix-prefetch-url" work.
urls = [ url ];
}
// (if impure then { __impure = true; } else { inherit outputHashAlgo outputHash; })
)

38
nix-js/tests/findfile.rs Normal file
View File

@@ -0,0 +1,38 @@
mod utils;
use utils::eval;
#[test]
fn test_find_file_corepkg_fetchurl() {
let result = eval(
r#"
let
searchPath = [];
lookupPath = "nix/fetchurl.nix";
in
builtins.findFile searchPath lookupPath
"#,
);
assert!(result.to_string().contains("fetchurl.nix"));
}
#[test]
fn test_lookup_path_syntax() {
let result = eval(r#"<nix/fetchurl.nix>"#);
assert!(result.to_string().contains("fetchurl.nix"));
}
#[test]
fn test_import_corepkg() {
let result = eval(
r#"
let
fetchurl = import <nix/fetchurl.nix>;
in
builtins.typeOf fetchurl
"#,
);
assert_eq!(result.to_string(), "\"lambda\"");
}