Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"**/lib": true
},
"task.autoDetect": "off",
"typescript.tsdk": "node_modules/typescript/lib",
"js/ts.tsdk.path": "node_modules/typescript/lib",
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
Expand Down
91 changes: 91 additions & 0 deletions dev-packages/cli/src/commands/generate-index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/********************************************************************************
* Copyright (c) 2026 EclipseSource and others.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is available at
* https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/

import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { initGlobby } from '../util';
import { GenerateIndexCmdOptions, generateIndex } from './generate-index';
import { cleanupTempDir, createTempDir } from '../../tests/helpers/test-helper';

const DEFAULT_OPTIONS: GenerateIndexCmdOptions = {
singleIndex: true,
forceOverwrite: true,
match: ['**/*.ts', '**/*.tsx'],
ignore: ['**/*.spec.ts', '**/*.spec.tsx', '**/*.d.ts'],
ignoreFile: '.indexignore',
style: 'commonjs',
verbose: false
};

describe('generateIndex', () => {
let tempDir: string;
let srcDir: string;
const originalCwd = process.cwd();

beforeAll(async () => {
await initGlobby();
});

beforeEach(() => {
tempDir = createTempDir();
// the ancestor-traversal is bounded by the enclosing git repository, so make the temp dir a repo root
execFileSync('git', ['init', '-q'], { cwd: tempDir });
srcDir = path.join(tempDir, 'src');
fs.mkdirSync(path.join(srcDir, 'base'), { recursive: true });
fs.mkdirSync(path.join(srcDir, 'test'), { recursive: true });
fs.writeFileSync(path.join(srcDir, 'base', 'util.ts'), '');
fs.writeFileSync(path.join(srcDir, 'test', 'test-util.ts'), '');
});

afterEach(() => {
process.chdir(originalCwd);
cleanupTempDir(tempDir);
});

afterAll(() => {
process.chdir(originalCwd);
});

function readIndex(): string {
return fs.readFileSync(path.join(srcDir, 'index.ts'), 'utf-8');
}

it('should include all matching files when no ignore file is present', () => {
generateIndex(srcDir, DEFAULT_OPTIONS);
const index = readIndex();
expect(index).toContain("export * from './base/util';");
expect(index).toContain("export * from './test/test-util';");
});

it('should honor an ignore file located in the source directory itself', () => {
fs.writeFileSync(path.join(srcDir, '.indexignore'), 'test/\n');
generateIndex(srcDir, DEFAULT_OPTIONS);
const index = readIndex();
expect(index).toContain("export * from './base/util';");
expect(index).not.toContain('test/test-util');
});

it('should honor an ignore file located in a parent directory (up to the git root)', () => {
// ignore file one level above the indexed source directory
fs.writeFileSync(path.join(tempDir, '.indexignore'), 'test/\n');
generateIndex(srcDir, DEFAULT_OPTIONS);
const index = readIndex();
expect(index).toContain("export * from './base/util';");
expect(index).not.toContain('test/test-util');
});
});
50 changes: 46 additions & 4 deletions dev-packages/cli/src/commands/generate-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,18 @@ import { createOption } from 'commander';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { GlobOptions, LOGGER, baseCommand, cd, configureLogger, globby, validateDirectory } from '../util';
import {
GlobOptions,
LOGGER,
baseCommand,
cd,
configureLogger,
getGitRoot,
globby,
isGitRepository,
isIgnoredByIgnoreFiles,
validateDirectory
} from '../util';
export interface GenerateIndexCmdOptions {
singleIndex: boolean;
forceOverwrite: boolean;
Expand Down Expand Up @@ -58,11 +69,12 @@ export function generateIndex(rootDir: string, options: GenerateIndexCmdOptions)
ignore,
cwd,
onlyFiles: options.singleIndex,
markDirectories: true,
ignoreFiles: '**/' + options.ignoreFile
markDirectories: true
};
LOGGER.debug('Search for children using the following glob options', globOptions);
const files = globby(pattern, globOptions);

const isIgnored = createIgnoreFilter(cwd, options.ignoreFile);
const files = globby(pattern, globOptions).filter(file => !isIgnored(file));
LOGGER.debug('All children considered in the input directory', files);

const relativeRootDirectory = '';
Expand All @@ -80,6 +92,36 @@ export function generateIndex(rootDir: string, options: GenerateIndexCmdOptions)
}
}

/**
* Creates a predicate that determines whether a globbed entry should be excluded based on the
* configured ignore files.
*
* Ignore files are resolved from the enclosing Git repository root down to the indexed directory, so
* that ignore files in parent directories are honored — matching how Git applies `.gitignore` files up
* the tree. Outside of a Git repository there is no well-defined upper boundary, so only ignore files
* within {@link rootDir} are considered.
*
* @param rootDir The absolute source directory that is being indexed (globbed entries are relative to it).
* @param ignoreFileName The name of the ignore file to honor (e.g. `.indexignore`).
* @returns A predicate that returns `true` for entries that should be excluded from indexing.
*/
export function createIgnoreFilter(rootDir: string, ignoreFileName: string): (file: string) => boolean {
const ignoreRoot = isGitRepository(rootDir) ? getGitRoot(rootDir) : rootDir;
const isIgnored = isIgnoredByIgnoreFiles('**/' + ignoreFileName, { cwd: ignoreRoot });
return file => {
// the predicate expects POSIX paths relative to `ignoreRoot`
let relative = path.relative(ignoreRoot, path.join(rootDir, file)).split(path.sep).join('/');
if (relative === '') {
return false;
}
// globby marks directories with a trailing slash; preserve it so directory-only patterns (e.g. `test/`) match
if (isDirectory(file)) {
relative += '/';
}
return isIgnored(relative);
};
}

export function isDirectChild(parent: string, child: string, childHasChildren: () => boolean): boolean {
return isChildFile(parent, child) || (isChildDirectory(parent, child) && childHasChildren());
}
Expand Down
25 changes: 25 additions & 0 deletions dev-packages/cli/src/util/file-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,15 @@ export interface GlobOptions {
}

type GlobbySync = (patterns: string | string[], options?: GlobOptions) => string[];
/** Predicate that reports whether a given path (relative to the configured `cwd`) is ignored. */
export type IgnoreFilter = (path: string) => boolean;
type IsIgnoredByIgnoreFilesSync = (patterns: string | string[], options?: GlobOptions) => IgnoreFilter;

let _globbySync: GlobbySync | undefined;
let _isIgnoredByIgnoreFilesSync: IsIgnoredByIgnoreFilesSync | undefined;
const _globbyReady: Promise<void> = import('globby').then(m => {
_globbySync = m.globbySync as GlobbySync;
_isIgnoredByIgnoreFilesSync = m.isIgnoredByIgnoreFilesSync as IsIgnoredByIgnoreFilesSync;
});

export async function initGlobby(): Promise<void> {
Expand All @@ -151,6 +156,26 @@ export function globby(patterns: string | string[], options?: GlobOptions): stri
return _globbySync(patterns, options);
}

/**
* Builds a predicate that reports whether a path is excluded by the gitignore-style ignore files
* matched by the given {@link patterns}.
*
* In contrast to the {@link GlobOptions.ignoreFiles} glob option, which only searches the glob `cwd`
* downwards, this also honors ignore files located in parent directories when {@link GlobOptions.cwd}
* points at an ancestor (e.g. the repository root) — mirroring how Git applies `.gitignore` files up
* the directory tree.
*
* @param patterns Glob pattern(s) used to locate the ignore files (e.g. `'**\/.indexignore'`).
* @param options Glob options; `cwd` defines the directory the returned predicate's paths are resolved against.
* @returns A predicate that returns `true` for paths (relative to `cwd`) that are ignored.
*/
export function isIgnoredByIgnoreFiles(patterns: string | string[], options?: GlobOptions): IgnoreFilter {
if (!_isIgnoredByIgnoreFilesSync) {
throw new Error('globby not initialized. Call initGlobby() before using glob functions.');
}
return _isIgnoredByIgnoreFilesSync(patterns, options);
}

/**
* Finds all files and directories matching the given pattern.
* @param paths The file or directory paths to search. If a path is a directory, all
Expand Down