Skip to content
1 change: 0 additions & 1 deletion packages/cli/src/cmd/lockfile/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ export async function runLockfileInfo({ flags }: CLILockfileInfoCmdOptions) {
const { storeDir, json } = flags;
const storePath = storeDir ? resolve(storeDir) : process.cwd();

console.error("Reading lockfile info from store at:", storePath);
try {
const fs = NodeFileSystemBridge({ basePath: storePath });
const lockfilePath = getLockfilePath();
Expand Down
83 changes: 82 additions & 1 deletion packages/shared/src/files.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,86 @@
import type { UnicodeFileTreeNodeWithoutLastModified } from "@ucdjs/schemas";
import { prependLeadingSlash } from "@luxass/utils";
import { prependLeadingSlash, trimLeadingSlash, trimTrailingSlash } from "@luxass/utils";
import { hasUCDFolderPath } from "@unicode-utils/core";

/**
* Normalizes an API file-tree path to a version-relative path suitable for filtering.
*
* This strips:
* - Leading/trailing slashes
* - Version prefix (e.g., "16.0.0/")
* - "ucd/" prefix for versions that have it
*
* @param {string} version - The Unicode version string
* @param {string} rawPath - The raw path from the API file tree (e.g., "/16.0.0/ucd/Blocks.txt")
* @returns {string} The normalized path (e.g., "Blocks.txt")
*
* @example
* ```typescript
* normalizePathForFiltering("16.0.0", "/16.0.0/ucd/Blocks.txt");
* // Returns: "Blocks.txt"
*
* normalizePathForFiltering("16.0.0", "/16.0.0/ucd/auxiliary/GraphemeBreakProperty.txt");
* // Returns: "auxiliary/GraphemeBreakProperty.txt"
* ```
*/
export function normalizePathForFiltering(version: string, rawPath: string): string {
// Strip leading and trailing slashes
let path = trimTrailingSlash(trimLeadingSlash(rawPath));

// Strip version prefix if present
const versionPrefix = `${version}/`;
if (path.startsWith(versionPrefix)) {
path = path.slice(versionPrefix.length);
}

// Strip "ucd/" prefix for versions that have it
if (hasUCDFolderPath(version) && path.startsWith("ucd/")) {
path = path.slice(4);
}

return path;
}

/**
* Creates a normalized view of a file tree for filtering purposes.
*
* This recursively maps all `path` properties to version-relative paths,
* so that filter patterns like "Blocks.txt" or "auxiliary/**" will match
* against paths like "/16.0.0/ucd/Blocks.txt".
*
* @template {UnicodeFileTreeNodeWithoutLastModified} T - A tree node type that extends the base TreeNode interface
* @param {string} version - The Unicode version string
* @param {T[]} entries - Array of file tree nodes from the API
* @returns {T[]} A new tree with normalized paths suitable for filtering
*
* @example
* ```typescript
* const apiTree = [{ type: "file", name: "Blocks.txt", path: "/16.0.0/ucd/Blocks.txt" }];
* const normalizedTree = normalizeTreeForFiltering("16.0.0", apiTree);
* // Returns: [{ type: "file", name: "Blocks.txt", path: "Blocks.txt" }]
* ```
*/
export function normalizeTreeForFiltering<T extends UnicodeFileTreeNodeWithoutLastModified>(
version: string,
entries: T[],
): T[] {
return entries.map((entry) => {
const normalizedPath = normalizePathForFiltering(version, entry.path);

if (entry.type === "directory" && entry.children) {
return {
...entry,
path: normalizedPath,
children: normalizeTreeForFiltering(version, entry.children),
};
}

return {
...entry,
path: normalizedPath,
};
});
}

/**
* Recursively find a node (file or directory) by its path in the tree.
Expand Down
35 changes: 32 additions & 3 deletions packages/shared/src/filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,32 @@ export function createPathFilter(options: PathFilterOptions = {}): PathFilter {
return filterFn;
}

function normalizeForMatching(value: string): string {
// 1) unify slashes
let normalized = value.replace(/\\/g, "/");

// 2) drop leading "./" segments
normalized = normalized.replace(/^\.\/+/, "");

// 3) drop leading slash to let relative globs match absolute-style inputs
normalized = normalized.replace(/^\//, "");

// 4) drop trailing slash so directory paths don't require it in patterns
normalized = normalized.replace(/\/$/, "");

return normalized;
}

function normalizePatterns(patterns: string[]): string[] {
const normalized: string[] = [];

for (let i = 0; i < patterns.length; i += 1) {
normalized.push(normalizeForMatching(patterns[i]!));
}

return normalized;
}

function internal__createFilterFunction(config: PathFilterOptions): PathFilterFn {
// If include is empty or not set, include everything using "**" pattern
const includePatterns = config.include && config.include.length > 0 ? config.include : ["**"];
Expand All @@ -153,14 +179,17 @@ function internal__createFilterFunction(config: PathFilterOptions): PathFilterFn
...(config.exclude || []),
];

const normalizedIncludePatterns = normalizePatterns(includePatterns);
const normalizedExcludePatterns = normalizePatterns(rawExcludePatterns);

// Transform directory-only patterns to include their contents
// e.g., "**/extracted" becomes both "**/extracted" and "**/extracted/**"
const excludePatterns = expandDirectoryPatterns(rawExcludePatterns);
const excludePatterns = expandDirectoryPatterns(normalizedExcludePatterns);

return (path: string): boolean => {
const normalizedPath = path.replace(/\\/g, "/").replace(/^\.\//, "");
const normalizedPath = normalizeForMatching(path);

return picomatch.isMatch(normalizedPath, includePatterns, {
return picomatch.isMatch(normalizedPath, normalizedIncludePatterns, {
...DEFAULT_PICOMATCH_OPTIONS,
ignore: excludePatterns,
} satisfies PicomatchOptions);
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export * from "./debugger";
export { customFetch } from "./fetch/fetch";
export type { FetchOptions, FetchResponse, SafeFetchResponse } from "./fetch/types";

export { findFileByPath, flattenFilePaths } from "./files";
export { findFileByPath, flattenFilePaths, normalizePathForFiltering, normalizeTreeForFiltering } from "./files";

export {
createPathFilter,
Expand Down
130 changes: 129 additions & 1 deletion packages/shared/test/files.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import type { UnicodeFileTreeNode } from "@ucdjs/schemas";
import { describe, expect, it } from "vitest";
import { findFileByPath, flattenFilePaths } from "../src/files";
import {
findFileByPath,
flattenFilePaths,
normalizePathForFiltering,
normalizeTreeForFiltering,
} from "../src/files";

describe("findFileByPath", () => {
it("should return undefined for empty input", () => {
Expand Down Expand Up @@ -435,3 +440,126 @@ describe("flattenFilePaths", () => {
expect(result).toEqual(["/file1.txt", "/folder/nested.txt"]);
});
});

describe("normalizePathForFiltering", () => {
it("strips leading and trailing slashes", () => {
expect(normalizePathForFiltering("16.0.0", "/16.0.0/ucd/auxiliary/")).toBe("auxiliary");
expect(normalizePathForFiltering("16.0.0", "16.0.0/ucd/Blocks.txt")).toBe("Blocks.txt");
});

it("strips version prefix regardless of leading slash", () => {
expect(normalizePathForFiltering("17.0.0", "/17.0.0/ucd/auxiliary/Grapheme.txt")).toBe("auxiliary/Grapheme.txt");
expect(normalizePathForFiltering("17.0.0", "17.0.0/ucd/auxiliary/Grapheme.txt")).toBe("auxiliary/Grapheme.txt");
});

it("strips ucd/ when version uses UCD folder", () => {
expect(normalizePathForFiltering("16.0.0", "/16.0.0/ucd/Blocks.txt")).toBe("Blocks.txt");
expect(normalizePathForFiltering("16.0.0", "/16.0.0/ucd/auxiliary/Grapheme.txt")).toBe("auxiliary/Grapheme.txt");
});

it("keeps ucd/ when version does not use UCD folder", () => {
expect(normalizePathForFiltering("1.1.0", "/1.1.0/ucd/Blocks.txt")).toBe("ucd/Blocks.txt");
});

it("handles nested paths with all prefixes", () => {
expect(normalizePathForFiltering("16.0.0", "/16.0.0/ucd/auxiliary/LineBreak.txt")).toBe("auxiliary/LineBreak.txt");
expect(normalizePathForFiltering("16.0.0", "/16.0.0/ucd/emoji/emoji-data.txt")).toBe("emoji/emoji-data.txt");
});

it("no-ops when already normalized", () => {
expect(normalizePathForFiltering("16.0.0", "Blocks.txt")).toBe("Blocks.txt");
expect(normalizePathForFiltering("16.0.0", "auxiliary/LineBreak.txt")).toBe("auxiliary/LineBreak.txt");
});
});

describe("normalizeTreeForFiltering", () => {
it("normalizes file paths and preserves structure", () => {
const tree: UnicodeFileTreeNode[] = [
{ type: "file", name: "Blocks.txt", path: "/16.0.0/ucd/Blocks.txt", lastModified: null },
{
type: "directory",
name: "auxiliary",
path: "/16.0.0/ucd/auxiliary/",
lastModified: null,
children: [
{ type: "file", name: "Grapheme.txt", path: "/16.0.0/ucd/auxiliary/Grapheme.txt", lastModified: null },
{
type: "directory",
name: "nested",
path: "/16.0.0/ucd/auxiliary/nested/",
lastModified: null,
children: [
{ type: "file", name: "Deep.txt", path: "/16.0.0/ucd/auxiliary/nested/Deep.txt", lastModified: null },
],
},
],
},
];

const normalized = normalizeTreeForFiltering("16.0.0", tree);

expect(normalized).toEqual([
{ type: "file", name: "Blocks.txt", path: "Blocks.txt", lastModified: null },
{
type: "directory",
name: "auxiliary",
path: "auxiliary",
lastModified: null,
children: [
{ type: "file", name: "Grapheme.txt", path: "auxiliary/Grapheme.txt", lastModified: null },
{
type: "directory",
name: "nested",
path: "auxiliary/nested",
lastModified: null,
children: [
{ type: "file", name: "Deep.txt", path: "auxiliary/nested/Deep.txt", lastModified: null },
],
},
],
},
]);
});

it("retains ucd/ prefix when version lacks UCD folder", () => {
const tree: UnicodeFileTreeNode[] = [
{ type: "file", name: "Blocks.txt", path: "/1.1.0/ucd/Blocks.txt", lastModified: null },
{ type: "file", name: "ReadMe.txt", path: "/1.1.0/ReadMe.txt", lastModified: null },
];

const normalized = normalizeTreeForFiltering("1.1.0", tree);

expect(normalized).toEqual([
{ type: "file", name: "Blocks.txt", path: "ucd/Blocks.txt", lastModified: null },
{ type: "file", name: "ReadMe.txt", path: "ReadMe.txt", lastModified: null },
]);
});

it("handles trailing slashes on directories", () => {
const tree: UnicodeFileTreeNode[] = [
{
type: "directory",
name: "emoji",
path: "/17.0.0/ucd/emoji/",
lastModified: null,
children: [
{ type: "file", name: "emoji-data.txt", path: "/17.0.0/ucd/emoji/emoji-data.txt", lastModified: null },
],
},
];

const normalized = normalizeTreeForFiltering("17.0.0", tree);

expect(normalized).toEqual([
{
type: "directory",
name: "emoji",
path: "emoji",
lastModified: null,
children: [
{ type: "file", name: "emoji-data.txt", path: "emoji/emoji-data.txt", lastModified: null },
],
},
]);
});
});
Loading
Loading